diff --git a/.gitattributes b/.gitattributes index d54690bad..bf67ef190 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,8 @@ *.yaml text eol=lf *.md text eol=lf *.sql text eol=lf +*.sh text eol=lf +Jenkinsfile text eol=lf *.ps1 text eol=crlf *.cmd text eol=crlf *.bat text eol=crlf @@ -13,4 +15,3 @@ *.jpg binary *.jpeg binary *.pdf binary - diff --git a/.gitignore b/.gitignore index dd775881e..f0ffe02b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ target/ +node_modules/ +.docker-context/ *.log .idea/ *.iml @@ -13,6 +15,7 @@ data/ .env .env.* !.env.example +!.env.*.example application-local.yml bootstrap-local.yml *.hprof @@ -21,4 +24,10 @@ dependency-reduced-pom.xml reports/runtime*/ reports/uploads/ reports/db-backup*/ +reports/ uploads/ +.codex-temp* +.codex-tmp* +.runtime-*/ +.codex-*.out +.codex-*.err 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/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..aa72ee8ff --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,138 @@ +# 头程与海外仓后端项目指引 + +## 适用范围与定位 + +- 本文件适用于 `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`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。 +- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。 + +## 服务路由(按关键词定位) + +| 任务关键词 | 首选服务 | 先看入口 | +|---|---|---| +| 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 | +| 客户档案、成员、审核、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`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 | +| 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` | +| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 | +| 平台订单批量审核预览 | `order/` | `PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`BatchOrderReviewPreviewService.java`、`BatchOrderReviewPreviewResponse.java`、`order-v1.yaml`;一次请求固定用订单与货物两次批量查询返回同序快照,不允许前端逐票请求详情,逐项资格以 `canApprove/blockReason` 为准 | +| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 | +| 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` | Firstleg `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`;平台接口 `/api/platform/inbounds/{inboundId}/hold`、`/release`、`/evidence`。扣货不改变 Order 主状态,命令使用入库聚合版本和幂等键;凭证只按客户、入库单及 `INBOUND_HOLD_EVIDENCE` 精确归属,所有装柜、PDA 拣货和国内出仓写入口统一复用门禁并失败关闭 | +| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 | +| 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` | `PlatformPriceBookService.java`、`ActivePriceBookEstimator.java`、`FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`、`InboundFeeDetailService.java`、`fba-pricing-v1.yaml`。`customerId=0` 为公共报价,正数为客户专属版本;计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。同柜区域重量阶梯按实际成员计费量聚合命中并守恒分摊,装柜变化只重算未锁定客户费用;人工计费量调整限基准上下 10%,私人仓尾程派送费只绑定 `PRIVATE` 入库单;一件代发 `WAIT_PALLETIZE` 允许在预装确认前试算,写费用时必须保留托盘化状态 | +| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | +| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 | +| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` | +| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在。上传以幂等键绑定包含文件内容的请求哈希,完全重放不得再次调用 File | +| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 | +| 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | Firstleg `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,业务编排 `service/SmartLoadingService.java`;待审核事实由 `client/PendingReviewOrderClient.java` 经内部令牌查询 Order `/internal/order/v1/pending-review-orders` 与 `PendingReviewOrderQueryService.java` 的权威 `SUBMITTED/PENDING_REVIEW` 行。候选四态只用于筛选展示,资格以 `canAssign` 为准;待审核或待测量可先占位,确认预装和启动 PDA 前仍须进入 `WAIT_SPLIT`;计划明细总量/余量由 Order 原始申报或最新已审核测量补齐,调整时重验权威数量;人工加入允许跨渠道,智能推荐仍按柜次渠道;同柜组整组分页、分配和移除 | +| 客户入库结果与头程费用确认 | `firstleg/` | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`,接口 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` 与 `/confirm`;客户按登录态范围读取并以费用版本和幂等键确认当前 `SENT` 费用,确认后锁定 | +| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` | +| 目的港码头拖车与还空预警 | `firstleg/` | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`src/main/resources/openapi/terminal-drayage-v1.yaml`;按 `containerBookingId` 聚合并与订舱级拖车分离,引用 Warehouse `ACTIVE + TRUCKING` 供应商,提柜/还柜复用船节点,服务端按海外卸柜完成时间加 72 小时计算还空预警 | +| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | +| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | 国内 `FinanceWalletService.java` 持有唯一 CNY 主账和不可变流水;平台、客户、内部入口见 `PlatformFinanceWalletController.java`、`ClientFinanceWalletController.java`、`InternalFinanceWalletController.java` 与 `finance-wallet-v1.yaml`。海外采购和换标由 Lastleg `wallet/` 发布 `WALLET_COMMAND_REQUESTED`,Finance 冻结、结算或释放后发布 `WALLET_COMMAND_COMPLETED` 回传,不跨区域同步调用钱包 | +| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 | +| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 | +| 入库/柜次应付、直接已付、付款流水与凭证 | `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` | +| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` | +| 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | `file/AGENTS.md`;法人身份证选填,可在已有营业执照后单独补传或替换;`PENDING` 且已有失败尝试的退避任务可人工立即到期并复用当前投递,不创建并发任务 | +| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | +| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | +| 海外 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`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | +| 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合并分页返回全部稳定来源柜,已完成计划转换的柜仍用于接柜仓查询与修改,生成资格由 `allPlansReady/pendingPlanCount` 表达,详情返回全柜数据。`inbound-plans/container-preview` 由 `InboundPlanService.java` 返回服务端分组、托数、阻断项及快照版本/哈希;`ContainerInboundPlanConversionCoordinator.java` 校验同一快照并以唯一 Saga 先向 `WarehousePalletBatchService.java` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,失败只补偿未确认资源,同柜重放不得新建第二批。暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 | +| 海外入仓分页、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 出库或派送 | +| 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` | +| 海外异常退回、库存恢复与客户回访 | `lastleg-service/`、`file/` | `exceptionorder/`、`inventory/command/`、`customervisit/`;异常按版本和幂等键执行 `PENDING → PROCESSING → COMPLETED`,完成证据固定归 `OVERSEAS_EXCEPTION_EVIDENCE`。派送退回明细只能来自原出库拣货事实,退回海外仓时校验真实目标托盘/库位并以 `RETURN_STOCK_IN` 恢复库存后,原子完成异常单与派送 `RETURNED`;回访仍以 Lastleg 客户范围和派送状态为准 | +| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU 与每箱数量;Firstleg `InboundCartonLabelService` 冻结箱唛 SKU,`DropshipPalletizationService` 完成清点、组托和封托,智能排柜只按 `SEALED` 整托分配,并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 | +| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`relabel/pda/`、`transfer/`、`discard/` 的 Merchant/PDA Controller 和 Service;换标下单按历史入仓库存选择,每个我方箱唛只形成一条箱任务,支持箱唛、SKU 和组合换标,SKU 子项不重复冻结。涉及箱唛换标时冻结派送方式,PDA 逐箱作业并上传完工照片,仓库复核后必须由客户查看证据确认,之后卡车进入预排车,自提/快递进入换标出库待创建;不伪造已入库单。Client 消费端 `../html/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | +| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕;`pallet-operations/relabel-disassemble` 按换标单箱唛快照解析原库存仓位,在一个幂等事务内移动对应冻结箱数并绑定已扫码目标托盘,同时更新换标明细和未开始拣货的出库托盘/箱唛快照;出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `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` | + +散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。 + +## 公共模块快速索引 + +| 关键词 | 模块与入口 | +|---|---| +| 公共依赖版本 | `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` | + +## 全量验收路由 + +- 当前订单与客户、入库测量与计费、国内货柜履约、海外仓业务及财务统一进入全量验收;按本次链路读取对应业务域/专题文档,不以历史规划终点阻断跨服务联调或回归。 +- `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。 + +## 国内头程与海外仓协同 + +- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。 +- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验;海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集。海外 Gateway 本地验签后必须通过 Lastleg 安全投影取得当前角色/权限并失败关闭,再清除外部伪造头、写入受内部服务令牌保护的可信身份上下文;海外服务不得访问国内 Redis,海外业务请求不得经国内 Gateway 中转。 +- 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。 +- 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。 + +## 契约、数据库与验证入口 + +- 对外 Controller/API 新增、改名或删除时,同时核对调用端、区域 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。 + +- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。 +- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。 +- 所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`;每个持久化服务只允许在启动类使用一次服务根包 `@MapperScan(..., annotationClass = Mapper.class)`,不得新增子域包白名单或分散的 `*MapperConfiguration`。新增或移动 Mapper 后先运行 `node scripts/mapper-scan-contract.mjs`,全量后端回归也会在 Maven 前执行该门禁。 +- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。 +- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。 +- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。 +- 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `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、数据库或部署环境。 +- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。 +- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。 + +## 全量 HTTP 验收脚本路由 + +- 脚本管理的唯一总索引是 `scripts/README.md`,最近执行规则是 `scripts/AGENTS.md`。仓库级脚本、Admin 内部模块、服务局部合同和部署脚本的目录归属以这两个文件为准,不再通过无范围搜索猜测入口。 +- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;入库测量与计费专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。 +- Client 快速下单定向验收使用 `scripts/client-quick-order-test-env-acceptance.mjs`,只验证批量草稿接口、逐行隔离和幂等行为,不提交、不审核,也不替代 Client 全量入口。 +- 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。 +- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 +- 订舱与拖车跨服务完整状态链由 Admin 注册模块 `service-center-flow` 验证,必须复用本轮已放舱订舱并由总入口展开依赖,不得直接执行模块文件或借历史数据。 +- 尾程公网控制由 `phase4-tail-order-controls` 在海外 PDA 前验证整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、暂扣/解除、八个人工节点和逐票人工计划;目的港码头拖车由 `phase5-terminal-drayage` 在卸柜后验证选车队、预约提柜、实际提柜和实际还柜,`phase4-carrier-nodes` 只读核验最终船节点终态。 +- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 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 通过不代表视觉验收完成。 +- 修改脚本目录、入口或说明后,至少运行 `node --test scripts/script-documentation-contract.test.mjs`;修改 `AGENTS.md` 后继续运行 `./scripts/agents-index-contract.ps1`。两者均为离线只读合同,不能代替真实 HTTP 验收。 + +## 业务域文档入口 + +- 订单与客户:`../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/` +- 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/` +- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束 + +全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。 diff --git a/Jenkinsfile b/Jenkinsfile index 9f47b07e9..28574f7ef 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,29 +1,147 @@ def serviceModules = [ - 'gateway', 'auth', 'iam', 'firstleg', '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', - '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', - 'order': 'order/target/order-service-1.0.0-SNAPSHOT.jar', - 'warehouse': 'warehouse/target/warehouse-service-1.0.0-SNAPSHOT.jar', - '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', - 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', - 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' +def serviceCheckboxes = [ + 'gateway': 'SERVICE_GATEWAY', + 'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS', + 'auth': 'SERVICE_AUTH', + 'iam': 'SERVICE_IAM', + 'firstleg': 'SERVICE_FIRSTLEG', + 'finance': 'SERVICE_FINANCE', + 'order': 'SERVICE_ORDER', + 'warehouse': 'SERVICE_WAREHOUSE', + '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', '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) { + if (!description?.startsWith('AUTO ')) return null + int markerIndex = description.indexOf(' | ') + if (markerIndex < 0) return null + String commit = description.substring('AUTO '.length(), markerIndex).trim() + if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null + String state = description.substring(markerIndex + ' | '.length()).trim() + List failedServices = [] + if (state.startsWith('RESULTS')) { + String summary = state.substring('RESULTS'.length()).trim() + 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 + } + } + } else if (state.startsWith('PENDING')) { + // 上一轮若在统一测试、排队或并行部署期间失败/取消,description 仍是 PENDING。 + // 将该轮已选服务作为精确补跑范围,同时让 Git 基线前进到该轮提交, + // 避免回退到很早的成功构建后把历史公共文件再次解释成全量变更。 + String selected = state.substring('PENDING'.length()).trim() + int suffixIndex = selected.indexOf(' | ') + if (suffixIndex >= 0) selected = selected.substring(0, suffixIndex).trim() + failedServices.addAll(selected.split(',').collect { it.trim() } + .findAll { allowedServices.contains(it) }) + } else { + return null + } + 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) + // 完成的 RESULTS 和中断留下的 PENDING 都能推进 AUTO 基线;PENDING 中的服务会被精确补跑。 + if (state != null) return state + candidate = candidate.previousBuild + inspected++ + } + return null +} + +def environmentChoicesForJob(String jobBaseName) { + return [fixedEnvironmentForJob(jobBaseName) ?: 'test'] +} + +def fixedEnvironmentForJob(String jobBaseName) { + switch (jobBaseName) { + case 'backend-java-dev': + return 'development' + case 'backend-java': + return 'test' + case 'backend-java-prod': + return 'production' + default: + return null + } +} + +def fixedDownstreamJobPrefixForJob(String jobBaseName) { + switch (jobBaseName) { + case 'backend-java-dev': + return 'baiyue-firstleg-dev-' + case 'backend-java': + return 'baiyue-firstleg-' + case 'backend-java-prod': + return 'baiyue-firstleg-prod-' + default: + return null + } +} + +def downstreamJobPrefixChoicesForJob(String jobBaseName) { + return [fixedDownstreamJobPrefixForJob(jobBaseName) ?: 'baiyue-firstleg-'] +} + pipeline { - agent { label 'docker-java17' } + // 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。 + agent none options { disableConcurrentBuilds() @@ -32,38 +150,128 @@ pipeline { } parameters { - choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 手工指定;ALL 构建全部服务') - string(name: 'SERVICE', defaultValue: 'gateway', description: 'SELECT 模式填写服务名,多个服务用英文逗号分隔') - choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境') - booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署') + 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)') + booleanParam(name: 'SERVICE_FINANCE', defaultValue: false, description: '财务服务(finance)') + booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)') + booleanParam(name: 'SERVICE_WAREHOUSE', defaultValue: false, description: '仓库与往来单位服务(warehouse)') + 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_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: 'registry.example.com/your-group/baiyue-firstleg', description: 'GitLab Container Registry 项目地址') - } - - environment { - REGISTRY_CREDENTIALS = 'gitlab-registry-credentials' + string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库地址') + string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') + choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀') } stages { stage('确定构建范围') { + agent { label 'java-builder' } steps { script { + String resolvedEnvironment = fixedEnvironmentForJob(env.JOB_BASE_NAME) + String resolvedJobPrefix = fixedDownstreamJobPrefixForJob(env.JOB_BASE_NAME) + if (!resolvedEnvironment || !resolvedJobPrefix) { + error("未配置总控 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续") + } + if (params.ENVIRONMENT != resolvedEnvironment || + params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) { + echo "忽略外部传入的环境或 Job 前缀;${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment} / ${resolvedJobPrefix}" + } + env.RESOLVED_ENVIRONMENT = resolvedEnvironment + echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}" + + if (env.RESOLVED_ENVIRONMENT == 'production') { + sh ''' + set -eu + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || { + echo "生产构建只允许 origin/main 当前提交;请先通过 Pull Request 合并到 main 后再部署" >&2 + exit 1 + } + ''' + } + + String requestedRepository = params.IMAGE_REPOSITORY?.trim() + if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') { + error('必须填写真实镜像仓库地址') + } + if (requestedRepository.contains('://') || !requestedRepository.contains('/')) { + error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://') + } + echo "${env.RESOLVED_ENVIRONMENT} 环境使用共享镜像仓库: ${requestedRepository}" + env.RESOLVED_IMAGE_REPOSITORY = requestedRepository + + env.RESOLVED_JOB_PREFIX = resolvedJobPrefix + 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 } else if (params.BUILD_MODE == 'SELECT') { - selected = params.SERVICE.split(',').collect { it.trim() }.findAll { it } + selected = serviceModules.findAll { service -> + params[serviceCheckboxes[service]] == true + } + if (!selected) { + 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) { + // 首次启用或旧构建记录已淘汰时,只比较当前提交的直接父提交。 + // 需要初始化全部服务时应显式使用 ALL,不能因历史成功基线过旧而意外全量部署。 + base = sh(script: 'git rev-parse HEAD^ 2>/dev/null || true', returnStdout: true).trim() + if (base) echo "AUTO 未找到历史记录,本次使用直接父提交作为基线: ${base}" + } 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 } - boolean commonChanged = changed.any { path -> commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) } } - selected = commonChanged ? serviceModules : serviceModules.findAll { module -> - changed.any { it == module || it.startsWith("${module}/") } + boolean databaseChanged = changed.any { path -> + databaseMigrationPaths.any { prefix -> path.startsWith(prefix) } } + env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false' + List serviceRelevantChanges = changed.findAll { path -> + !databaseMigrationPaths.any { prefix -> path.startsWith(prefix) } + } + 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(',') ?: '无'}" } } @@ -71,28 +279,83 @@ pipeline { if (invalid) { error("未知服务: ${invalid.join(', ')};允许值: ${serviceModules.join(', ')}") } + 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.NO_SERVICE_CHANGE = 'true' - env.SELECTED_SERVICES = '' + currentBuild.description = params.BUILD_MODE == 'AUTO' + ? "AUTO ${env.IMAGE_TAG} | RESULTS" + : (env.DATABASE_CHANGE_DETECTED == 'true' + ? '仅数据库迁移变更 | 需单独审核执行' + : '没有需要构建的业务服务') } else { - env.NO_SERVICE_CHANGE = 'false' - env.SELECTED_SERVICES = selected.join(',') - env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() - currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}" - echo "本次构建服务: ${env.SELECTED_SERVICES}" + String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : '' + 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') { + echo '检测到 deploy/sql/ 变更:AUTO 不据此扩大服务范围;统一 SQL 须独立审核并执行。' } } } } - stage('Maven 测试与打包') { - when { expression { env.NO_SERVICE_CHANGE != 'true' } } + stage('生产环境部署确认(仅 production 执行)') { + when { + allOf { + expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' } + } + } + steps { + input message: "确认将 ${env.SELECTED_SERVICES} 的镜像 ${env.IMAGE_TAG} 分别部署到生产环境?", ok: '确认部署' + } + } + + stage('Gateway 路由静态门禁') { + when { + expression { env.NO_SERVICE_CHANGE != 'true' } + } + agent { label 'java-builder' } + steps { + sh 'node --test scripts/gateway-route-contract.test.mjs' + } + } + + 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 testOption = params.SKIP_TESTS ? '-DskipTests' : '' - sh "mvn --batch-mode --errors --fail-at-end -pl ${env.SELECTED_SERVICES} -am clean package ${testOption}" + String selectedModules = env.SELECTED_SERVICES.split(',') + .collect { it.trim() } + .findAll { it } + .join(',') + echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}" + sh """ + test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\" + find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete + mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test + """ + echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试" } } post { @@ -102,53 +365,68 @@ pipeline { } } - stage('构建并推送镜像') { + stage('并行调度独立服务 Job') { when { expression { env.NO_SERVICE_CHANGE != 'true' } } steps { script { - String registryHost = params.IMAGE_REPOSITORY.tokenize('/')[0] - withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { - sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"' - env.SELECTED_SERVICES.split(',').each { service -> - String image = "${params.IMAGE_REPOSITORY}/${service}:${env.IMAGE_TAG}" - sh "docker build -f deploy/docker/Dockerfile --build-arg JAR_FILE=${serviceJars[service]} -t ${image} ." - sh "docker push ${image}" + Map branches = [:] + env.SELECTED_SERVICES.split(',').each { selectedService -> + 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, + wait: true, + propagate: false, + parameters: [ + string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT), + string(name: 'IMAGE_TAG', value: downstreamImageTag), + string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT), + booleanParam(name: 'DEPLOY', value: params.DEPLOY), + // 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。 + booleanParam(name: 'SKIP_TESTS', value: true), + string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY), + string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE), + // Choice 参数始终传递所有新旧 Job 都接受的单区值; + // 双区意图通过现有 IMAGE_TAG 参数携带,由下游还原真实镜像标签。 + string(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion) + ] + ) + downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN' + echo "${service} 执行结果: ${downstreamResults[service]} (${downstreamBuild.absoluteUrl})" } - sh "docker logout ${registryHost} || true" } + parallel branches } } } - stage('生产部署确认') { - when { - allOf { - expression { params.DEPLOY && params.ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' } - } - } - steps { - input message: "确认将 ${env.SELECTED_SERVICES} 的镜像 ${env.IMAGE_TAG} 部署到生产环境?", ok: '确认部署' - } - } - - stage('部署') { - when { - allOf { - expression { params.DEPLOY && env.NO_SERVICE_CHANGE != 'true' } - } - } + stage('汇总服务结果') { + when { expression { env.NO_SERVICE_CHANGE != 'true' } } steps { script { - String credentialId = "firstleg-${params.ENVIRONMENT}-env" - withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) { - sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${params.IMAGE_REPOSITORY}' '${env.IMAGE_TAG}' '${env.SELECTED_SERVICES}' \"\$DEPLOY_ENV_FILE\"" + List failed = downstreamResults.findAll { service, result -> result != 'SUCCESS' } + .collect { service, result -> "${service}=${result}" } + String summary = downstreamResults.collect { service, result -> "${service}=${result}" }.join(', ') + echo "单服务执行汇总: ${summary}" + currentBuild.description = params.BUILD_MODE == 'AUTO' + ? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}" + : "${env.IMAGE_TAG} | ${summary}" + if (failed) { + unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}") } } } } } - post { - always { cleanWs(deleteDirs: true, notFailBuild: true) } - } } diff --git a/Jenkinsfile.service b/Jenkinsfile.service new file mode 100644 index 000000000..1d44cdf46 --- /dev/null +++ b/Jenkinsfile.service @@ -0,0 +1,320 @@ +def serviceModules = [ + 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' +] + +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', + 'finance': 'finance/target/finance-service-1.0.0-SNAPSHOT.jar', + 'order': 'order/target/order-service-1.0.0-SNAPSHOT.jar', + 'warehouse': 'warehouse/target/warehouse-service-1.0.0-SNAPSHOT.jar', + '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' +] + +def fixedEnvironmentForServiceJob(String jobBaseName) { + if (jobBaseName?.startsWith('baiyue-firstleg-dev-')) { + return 'development' + } + if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) { + return 'production' + } + if (jobBaseName?.startsWith('baiyue-firstleg-')) { + return 'test' + } + return null +} + +def environmentChoicesForServiceJob(String jobBaseName) { + return [fixedEnvironmentForServiceJob(jobBaseName) ?: 'test'] +} + +pipeline { + // 构建在任意 java-builder 节点执行,部署再回到目标环境节点。 + agent none + + options { + skipDefaultCheckout(true) + disableConcurrentBuilds() + timestamps() + buildDiscarder(logRotator(numToKeepStr: '30')) + } + + parameters { + string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入') + string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位') + choice(name: 'ENVIRONMENT', choices: environmentChoicesForServiceJob(env.JOB_BASE_NAME), description: '由当前单服务 Job 固定的部署环境') + booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') + booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') + choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值') + string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签') + string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') + } + + environment { + REGISTRY_CREDENTIALS = 'gitea-registry-token' + MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml' + MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3' + } + + stages { + stage('构建并发布单服务') { + agent { label 'java-builder' } + tools { maven 'maven-3.8.8' } + stages { + stage('校验参数并检出指定提交') { + steps { + checkout scm + + script { + String service = serviceModules.find { candidate -> + env.JOB_BASE_NAME?.endsWith("-${candidate}") + } + String sourceCommit = params.SOURCE_COMMIT?.trim() + String imageTag = params.IMAGE_TAG?.trim() + String imageRepository = params.IMAGE_REPOSITORY?.trim() + String resolvedEnvironment = fixedEnvironmentForServiceJob(env.JOB_BASE_NAME) + + if (!sourceCommit) { + sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() + } + if (!imageTag) { + imageTag = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() + } + + if (!serviceModules.contains(service)) { + error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}") + } + if (!resolvedEnvironment) { + error("未配置单服务 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续") + } + if (params.ENVIRONMENT != resolvedEnvironment) { + echo "忽略外部传入的环境 ${params.ENVIRONMENT};${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment}" + } + boolean deployBothBridges = service == 'event-bridge-service' && + imageTag?.endsWith('-both') + if (deployBothBridges) { + imageTag = imageTag.substring(0, imageTag.length() - '-both'.length()) + } + if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) { + error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit') + } + if (!imageTag || !(imageTag ==~ /[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}/)) { + error('IMAGE_TAG 不是合法的 Docker 镜像标签') + } + if (!imageRepository || imageRepository.contains('://')) { + error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀') + } + if (!imageRepository.contains('/')) { + error('IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式') + } + + env.RESOLVED_SERVICE = service + env.RESOLVED_ENVIRONMENT = resolvedEnvironment + 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(env.RESOLVED_ENVIRONMENT)) { + error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS') + } + } + } + + sh ''' + if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" 2>/dev/null; then + git fetch --no-tags origin + fi + git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" + git checkout --detach "${RESOLVED_SOURCE_COMMIT}" + test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")" + ''' + + sh ''' + if [ "${RESOLVED_ENVIRONMENT}" = "production" ]; then + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + git merge-base --is-ancestor "${RESOLVED_SOURCE_COMMIT}" origin/main || { + echo "生产构建只允许已经合并到 main 的提交" >&2 + exit 1 + } + fi + ''' + + script { + currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${env.RESOLVED_ENVIRONMENT} | ${env.NODE_NAME}" + echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT},节点: ${env.NODE_NAME}" + } + } + } + + stage('Maven 测试与打包') { + steps { + script { + boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean + if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) { + error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包') + } + boolean skipTestExecution = env.RESOLVED_ENVIRONMENT == 'test' || params.SKIP_TESTS + String testOption = skipTestExecution ? '-DskipTests' : '' + if (params.SKIP_TESTS && coordinatorPinnedCommit) { + echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包" + } else if (env.RESOLVED_ENVIRONMENT == 'test') { + echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行' + } + sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" + } + } + post { + always { + junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml' + } + } + } + + stage('构建并发布单服务镜像') { + steps { + script { + String image = "${env.RESOLVED_IMAGE_REPOSITORY}/${env.RESOLVED_SERVICE}:${env.RESOLVED_IMAGE_TAG}" + 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'" + + echo "推送共享镜像: ${image}" + 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) { + stash name: 'deployment-files', includes: 'deploy/**', useDefaultExcludes: true + } + } + } + } + } + } + + stage('部署开发或测试环境') { + when { + beforeAgent true + expression { params.DEPLOY && ['development', 'test'].contains(env.RESOLVED_ENVIRONMENT) } + } + agent none + steps { + script { + String deploySlotLabel = "firstleg-${env.RESOLVED_ENVIRONMENT}-deploy-slot" + String serviceDeployResource = "firstleg-${env.RESOLVED_ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy" + String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env" + String targetLabel = env.RESOLVED_ENVIRONMENT == 'development' ? 'java-dev' : 'java-test' + // 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。 + // resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。 + lock(resource: serviceDeployResource, inversePrecedence: true) { + lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) { + node(targetLabel) { + deleteDir() + try { + unstash 'deployment-files' + echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${env.RESOLVED_ENVIRONMENT}" + List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && + (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || + ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) + ? ['CN', 'OVERSEAS'] + : [params.EVENT_BRIDGE_REGION] + withCredentials([ + file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), + usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, + usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') + ]) { + bridgeRegions.each { bridgeRegion -> + if (env.RESOLVED_SERVICE == 'event-bridge-service') { + echo "部署事件桥实例: ${bridgeRegion}" + } + withEnv([ + 'TARGET_IS_LOCAL=true', + "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" + ]) { + sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + } + } + } + } finally { + cleanWs(deleteDirs: true, notFailBuild: true) + } + } + } + } + } + } + } + + stage('部署生产环境单个服务') { + when { + beforeAgent true + expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' } + } + agent { label 'java-builder' } + steps { + deleteDir() + unstash 'deployment-files' + script { + String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env" + String serviceDeployResource = "firstleg-production-${env.RESOLVED_SERVICE}-deploy" + lock(resource: serviceDeployResource, inversePrecedence: true) { + withCredentials([ + file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), + usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'), + sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'), + string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'), + file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS') + ]) { + withEnv([ + 'TARGET_DEPLOY_DIR=/data/firstleg/deploy/services', + 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST, + "EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}" + ]) { + sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + } + } + } + } + } + post { + always { + cleanWs(deleteDirs: true, notFailBuild: true) + } + } + } + } + + post { + success { + echo "${env.RESOLVED_SERVICE} 已独立完成构建${params.DEPLOY ? '和部署' : ''}" + } + } +} diff --git a/README.md b/README.md index 8f60c8784..aaebd9eea 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,54 @@ -# 百悦头程平台 +# 柏悦头程平台 -本仓库承载国内头程业务,负责统一登录、租户、用户、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。 +本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。 + +> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 ## 模块 - `auth`:统一登录、令牌签发与刷新。 -- `iam`:租户、用户、角色、菜单、按钮权限和数据范围。 +- `iam`:客户主档、用户、企业成员、角色、菜单、按钮权限和数据范围;部分路径、表和类保留 `tenant` 历史命名。 - `order`:订单主数据及订单状态编排。 - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 -- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力。 -- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。 +- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 +- `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 ``` -2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 +GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。 -本地一期完整联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。 +2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 + +国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。 国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 +## 容器日志 + +所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。国内、海外及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。 + +单容器默认保留 `50m × 3`,可在各环境部署变量中覆盖: + +| 环境 | `DOCKER_LOG_DRIVER` | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 | +|---|---|---:|---:|---| +| 开发 | `json-file` | `20m` | `2` | 不要求 | +| 测试 | `json-file` | `50m` | `3` | 不要求 | +| 生产前期 | `json-file` | `100m` | `5` | 宝塔与 `docker logs` 提供短期排障能力 | + +当前优先保证宝塔可观测性,因此不启用 `json-file` 压缩或非阻塞投递,避免面板无法读取轮转文件或在日志压力下静默丢弃。接入集中日志系统后,可通过 `DOCKER_LOG_DRIVER` 切换驱动,并由集中系统承担长期检索与留存。 + +调整日志策略后必须重新创建容器;只重启既有容器不会改变其日志驱动。历史 `/data/logs/baiyue-firstleg` 文件和旧容器日志不自动删除,确认无需保留后再单独清理。 + ## 网关跨域配置 本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如: @@ -39,4 +61,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com ## 仓库边界 -详细规则见 `docs/repository-boundary.md`。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 +仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 diff --git a/audit-service/AGENTS.md b/audit-service/AGENTS.md new file mode 100644 index 000000000..3f9ba8e89 --- /dev/null +++ b/audit-service/AGENTS.md @@ -0,0 +1,30 @@ +# Audit Service 模块指引 + +## 职责边界 + +- Mapper 只由 `BaiyueAuditApplication.java` 对 `com.baiyue.audit` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 +- 负责接收 `common-audit` 产生的审计事件并持久化到 `baiyue_audit`。 +- 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 应用入口 | `src/main/java/com/baiyue/audit/BaiyueAuditApplication.java` | +| 内部接收 API | `controller/AuditIngestController.java`(`/internal/audit/events`) | +| 平台日志查询 API | `controller/PlatformAuditLogController.java`(`/api/platform/v1/audit/logs`)、`service/AuditLogQueryService.java`;列表与详情统一使用 `platform:audit:list` | +| 接收 DTO | `dto/AuditIngestRequest.java` | +| 入库编排 | `service/AuditIngestService.java` | +| 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` | +| 表结构 | `src/main/resources/db/audit.sql` | +| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/audit-service.yml` | + +## 修改与验证 + +- 当前接收接口尚未实现内部令牌保护,仅适合受控本地/内网联调;部署到共享或生产环境前必须补齐可信服务身份校验,且不对前端开放。 +- `POST /internal/audit/events` 成功接收保留真实 HTTP 202;请求进入 `common-web` 异常处理后的业务/校验失败遵循 HTTP 200 + body code。不要把 202 改成普通 200,也不要把业务失败重新改成 HTTP 4xx/5xx。 +- `common-web/CommonWebAutoConfiguration` 负责在本服务仅扫描 `com.baiyue.audit` 时注册统一异常处理和 Correlation 过滤器;相关改动需连同本模块做启动/上下文验证。 +- 入库前确保密码、Token、证件等敏感信息已脱敏;新增字段时同步检查 `../common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java` 与发送实现。 +- 平台日志列表不得返回变更前后快照,详情才返回已脱敏 JSON;客户账号不得访问平台查询接口。 +- 审计链路故障不得阻塞核心交易;保留幂等、超时、容量和留存策略的演进空间。 +- 自动化测试位于 `src/test/java/com/baiyue/audit/`,覆盖平台日志查询、Controller 权限、MQ 接收/死信和持久化;从父目录运行 `mvn -pl audit-service -am test`,并按改动验证接收和持久化结果。 diff --git a/audit-service/pom.xml b/audit-service/pom.xml index 87867c917..aa0b1626c 100644 --- a/audit-service/pom.xml +++ b/audit-service/pom.xml @@ -27,6 +27,11 @@ baiyue-common-openapi ${project.version} + + com.baiyue.shared + baiyue-common-security + ${project.version} + com.baomidou mybatis-plus-spring-boot3-starter @@ -36,6 +41,10 @@ mysql-connector-j runtime + + org.apache.rocketmq + rocketmq-spring-boot-starter + com.alibaba.cloud spring-cloud-starter-alibaba-nacos-discovery diff --git a/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java b/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java index 3a9bd5c31..8a80f655c 100644 --- a/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java +++ b/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java @@ -1,15 +1,22 @@ package com.baiyue.audit; +import com.baiyue.common.core.mybatis.MybatisPlusConfiguration; +import org.apache.ibatis.annotations.Mapper; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; +import org.springframework.scheduling.annotation.EnableScheduling; /** * 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。 */ -@MapperScan("com.baiyue.audit.mapper") +@MapperScan(basePackages = "com.baiyue.audit", annotationClass = Mapper.class) +@EnableScheduling +@Import(MybatisPlusConfiguration.class) @SpringBootApplication public class BaiyueAuditApplication { + public static void main(String[] args) { SpringApplication.run(BaiyueAuditApplication.class, args); } diff --git a/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java b/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java index ff361f6ec..ec7166ce4 100644 --- a/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java +++ b/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java @@ -8,11 +8,15 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; 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.ResponseStatus; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; /** @@ -25,21 +29,40 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/internal/audit/events") public class AuditIngestController { + private static final String INTERNAL_SERVICE_TOKEN_HEADER = "X-Internal-Service-Token"; + @Resource private AuditIngestService service; + + @Value("${baiyue.security.internal-service-token:}") + private String internalServiceToken; @Operation( summary = "Ingest business audit event / 写入业务审计事件", description = "Validates and stores one sanitized event in baiyue_audit.sys_audit_log. / 校验并将一条脱敏事件写入 baiyue_audit.sys_audit_log。" ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "202", description = "Event accepted and stored / 事件已接收并存储"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid event payload / 事件载荷无效"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "Audit storage failed / 审计存储失败") }) @PostMapping - @ResponseStatus(HttpStatus.ACCEPTED) - public ApiResponse ingest(@Valid @RequestBody AuditIngestRequest event) { + public ResponseEntity> ingest( + @RequestHeader(value = INTERNAL_SERVICE_TOKEN_HEADER, required = false) String providedToken, + @Valid @RequestBody AuditIngestRequest event) { + if (!tokenMatches(providedToken)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.FORBIDDEN, + "Internal service token is invalid", null, null)); + } service.ingest(event); - return ApiResponse.success(null); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(ApiResponse.success(null)); + } + + private boolean tokenMatches(String providedToken) { + if (providedToken == null || providedToken.isBlank() + || internalServiceToken == null || internalServiceToken.isBlank()) { + return false; + } + return MessageDigest.isEqual( + internalServiceToken.getBytes(StandardCharsets.UTF_8), + providedToken.getBytes(StandardCharsets.UTF_8)); } } diff --git a/audit-service/src/main/java/com/baiyue/audit/controller/PlatformAuditLogController.java b/audit-service/src/main/java/com/baiyue/audit/controller/PlatformAuditLogController.java new file mode 100644 index 000000000..e25aaf610 --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/controller/PlatformAuditLogController.java @@ -0,0 +1,65 @@ +package com.baiyue.audit.controller; + +import com.baiyue.audit.dto.AuditLogQuery; +import com.baiyue.audit.dto.AuditLogResponses; +import com.baiyue.audit.service.AuditLogQueryService; +import com.baiyue.audit.service.AuditPlatformScopeGuard; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; + +/** 平台端统一操作日志查询入口。 */ +@RestController +@Validated +@RequestMapping("/api/platform/v1/audit/logs") +@Tag(name = "Platform audit logs / 平台操作日志") +public class PlatformAuditLogController { + @Resource + private AuditLogQueryService service; + + @GetMapping + @PreAuthorize("hasAuthority('platform:audit:list')") + @Operation(summary = "分页查询操作日志", description = "仅平台端可查询,列表不返回变更前后快照。默认按发生时间、日志 ID 倒序。") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue = "1") @Min(1) long page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) long size, + @RequestParam(required = false) Long accountId, + @RequestParam(required = false) Long operatorUserId, + @RequestParam(required = false) String eventType, + @RequestParam(required = false) String resourceType, + @RequestParam(required = false) String resourceId, + @RequestParam(required = false) Boolean success, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant occurredFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant occurredTo, + @RequestParam(required = false) String keyword) { + AuditPlatformScopeGuard.requirePlatform(user); + return ApiResponse.success(service.page(new AuditLogQuery(accountId, operatorUserId, eventType, + resourceType, resourceId, success, occurredFrom, occurredTo, keyword), page, size)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('platform:audit:list')") + @Operation(summary = "查看操作日志详情", description = "仅平台端可查询,详情返回已脱敏的变更前后快照。") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Min(1) Long id) { + AuditPlatformScopeGuard.requirePlatform(user); + return ApiResponse.success(service.detail(id)); + } +} diff --git a/audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java b/audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java index 7948394c3..6ecb297be 100644 --- a/audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java +++ b/audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java @@ -13,6 +13,8 @@ import java.time.Instant; */ @Schema(name = "AuditIngestRequest", description = "Internal business audit event / 内部业务审计事件") public record AuditIngestRequest( + @Schema(description = "Global idempotency event ID / 全局幂等事件ID", example = "b150c69d-8813-46b7-9a7c-81bfa5b91021") + @Size(max = 64) String eventId, @Schema(description = "Event code / 事件编码", example = "WAREHOUSE_UPDATE", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank @Size(max = 64) String eventType, @Schema(description = "Resource type / 资源类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogQuery.java b/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogQuery.java new file mode 100644 index 000000000..71f54462e --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogQuery.java @@ -0,0 +1,19 @@ +package com.baiyue.audit.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; + +/** 平台操作日志筛选条件。accountId 对应审计库中的 tenant_id。 */ +@Schema(name = "AuditLogQuery", description = "平台操作日志分页筛选条件") +public record AuditLogQuery( + Long accountId, + Long operatorUserId, + String eventType, + String resourceType, + String resourceId, + Boolean success, + Instant occurredFrom, + Instant occurredTo, + String keyword) { +} diff --git a/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java b/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java new file mode 100644 index 000000000..8f43954e7 --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java @@ -0,0 +1,67 @@ +package com.baiyue.audit.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; +import java.time.LocalDateTime; + +/** 平台审计日志查询响应。列表刻意不包含前后快照,详情才返回完整证据。 */ +public final class AuditLogResponses { + private AuditLogResponses() { + } + + @Schema(name = "AuditLogListItem", description = "操作日志列表项,不包含变更前后快照") + public record ListItem( + Long id, + String eventId, + String eventType, + String resourceType, + String resourceId, + String description, + Long accountId, + Long operatorTenantId, + Long userId, + String username, + String appCode, + String clientType, + String clientIp, + String requestId, + String traceId, + String serviceName, + Boolean success, + String errorCode, + String errorMessage, + Instant occurredAt, + LocalDateTime createdAt) { + } + + @Schema(name = "AuditLogDetail", description = "操作日志详情,包含已脱敏的变更前后快照") + public record Detail( + Long id, + String eventId, + String eventType, + String resourceType, + String resourceId, + String description, + String requestId, + String traceId, + Long accountId, + Long operatorTenantId, + Long userId, + String username, + String appCode, + String clientType, + String httpMethod, + String requestUri, + String clientIp, + Long durationMs, + Boolean success, + String errorCode, + String errorMessage, + String beforeData, + String afterData, + String serviceName, + Instant occurredAt, + LocalDateTime createdAt) { + } +} diff --git a/audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java b/audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java index ba85a9170..237689526 100644 --- a/audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java +++ b/audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java @@ -23,6 +23,7 @@ public class AuditLogEntity { @Schema(description = "Audit log ID / 审计日志 ID", example = "190000000000000201") @TableId(type = IdType.AUTO) private Long id; + private String eventId; private String eventType; private String resourceType; private String resourceId; diff --git a/audit-service/src/main/java/com/baiyue/audit/mapper/AuditLogMapper.java b/audit-service/src/main/java/com/baiyue/audit/mapper/AuditLogMapper.java index 612c0e5cc..6d403debf 100644 --- a/audit-service/src/main/java/com/baiyue/audit/mapper/AuditLogMapper.java +++ b/audit-service/src/main/java/com/baiyue/audit/mapper/AuditLogMapper.java @@ -1,10 +1,13 @@ package com.baiyue.audit.mapper; +import org.apache.ibatis.annotations.Mapper; + import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.audit.entity.AuditLogEntity; /** * 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。 */ +@Mapper public interface AuditLogMapper extends BaseMapper { } diff --git a/audit-service/src/main/java/com/baiyue/audit/mq/AuditDeadLetterConsumer.java b/audit-service/src/main/java/com/baiyue/audit/mq/AuditDeadLetterConsumer.java new file mode 100644 index 000000000..4972299a2 --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/mq/AuditDeadLetterConsumer.java @@ -0,0 +1,40 @@ +package com.baiyue.audit.mq; + +import com.baiyue.audit.repository.AuditDeadLetterRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; +import org.apache.rocketmq.spring.core.RocketMQListener; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** 将 RocketMQ 最终死信转存数据库,供补偿任务恢复。 */ +@Component +@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "dead-letter-consumer-enabled", havingValue = "true") +@RocketMQMessageListener( + topic = "${baiyue.audit.rocketmq.dead-letter-topic}", + consumerGroup = "${baiyue.audit.rocketmq.dead-letter-group}") +public class AuditDeadLetterConsumer implements RocketMQListener { + @Resource + private ObjectMapper json; + @Resource + private AuditDeadLetterRepository repository; + + @Override + public void onMessage(String payload) { + repository.save(eventId(payload), payload); + } + + private String eventId(String payload) { + try { + String value = json.readTree(payload).path("eventId").asText(); + if (!value.isBlank()) return value; + } catch (Exception ignored) { + // 无法解析的毒消息也必须使用确定性 ID 收入死信表。 + } + return "invalid-" + UUID.nameUUIDFromBytes(payload.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/audit-service/src/main/java/com/baiyue/audit/mq/AuditRocketMqConsumer.java b/audit-service/src/main/java/com/baiyue/audit/mq/AuditRocketMqConsumer.java new file mode 100644 index 000000000..190c8414b --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/mq/AuditRocketMqConsumer.java @@ -0,0 +1,34 @@ +package com.baiyue.audit.mq; + +import com.baiyue.audit.dto.AuditIngestRequest; +import com.baiyue.audit.service.AuditIngestService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; +import org.apache.rocketmq.spring.core.RocketMQListener; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** RocketMQ 审计事件消费者;数据库 event_id 唯一键保证重复消息安全。 */ +@Component +@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "consumer-enabled", havingValue = "true") +@RocketMQMessageListener( + topic = "${baiyue.audit.rocketmq.topic}", + selectorExpression = "AUDIT", + consumerGroup = "${baiyue.audit.rocketmq.consumer-group}") +public class AuditRocketMqConsumer implements RocketMQListener { + @Resource + private ObjectMapper json; + @Resource + private AuditIngestService service; + + @Override + public void onMessage(String payload) { + try { + service.ingest(json.readValue(payload, AuditIngestRequest.class)); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException("审计消息格式无效", exception); + } + } +} diff --git a/audit-service/src/main/java/com/baiyue/audit/repository/AuditDeadLetterRepository.java b/audit-service/src/main/java/com/baiyue/audit/repository/AuditDeadLetterRepository.java new file mode 100644 index 000000000..aa3dd9620 --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/repository/AuditDeadLetterRepository.java @@ -0,0 +1,73 @@ +package com.baiyue.audit.repository; + +import jakarta.annotation.Resource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** 审计 RocketMQ 死信及补偿状态仓储。 */ +@Repository +public class AuditDeadLetterRepository { + @Resource + private JdbcTemplate jdbc; + + public void save(String eventId, String payload) { + jdbc.update(""" + INSERT INTO sys_audit_dead_letter(event_id,payload_text,status,retry_count,next_retry_at,created_at) + VALUES (?,?,'PENDING',0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + ON DUPLICATE KEY UPDATE payload_text=VALUES(payload_text) + """, eventId, payload); + } + + public List pendingIds(int limit) { + return jdbc.queryForList(""" + SELECT id FROM sys_audit_dead_letter + WHERE status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3) + ORDER BY next_retry_at,id LIMIT ? + """, Long.class, limit); + } + + public boolean claim(long id, String owner, int leaseSeconds) { + return jdbc.update(""" + UPDATE sys_audit_dead_letter SET status='PROCESSING',lease_owner=?, + lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),retry_count=retry_count+1 + WHERE id=? AND status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3) + """, owner, leaseSeconds, id) == 1; + } + + public DeadRow find(long id) { + List rows = jdbc.query( + "SELECT id,event_id,payload_text,retry_count FROM sys_audit_dead_letter WHERE id=?", + (rs, rowNum) -> new DeadRow(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getInt(4)), id); + return rows.isEmpty() ? null : rows.get(0); + } + + public void processed(long id, String owner) { + jdbc.update(""" + UPDATE sys_audit_dead_letter SET status='PROCESSED',processed_at=CURRENT_TIMESTAMP(3), + lease_owner=NULL,lease_until=NULL,last_error=NULL + WHERE id=? AND status='PROCESSING' AND lease_owner=? + """, id, owner); + } + + public void failed(long id, String owner, int maxAttempts, String error) { + jdbc.update(""" + UPDATE sys_audit_dead_letter + SET status=IF(retry_count>=?,'DEAD','PENDING'), + next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL 60 SECOND), + lease_owner=NULL,lease_until=NULL,last_error=? + WHERE id=? AND status='PROCESSING' AND lease_owner=? + """, maxAttempts, error, id, owner); + } + + public int releaseExpired() { + return jdbc.update(""" + UPDATE sys_audit_dead_letter SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3), + lease_owner=NULL,lease_until=NULL + WHERE status='PROCESSING' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 100 + """); + } + + public record DeadRow(long id, String eventId, String payload, int retryCount) { } +} diff --git a/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java b/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java index acc5669a4..43a023bbe 100644 --- a/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java +++ b/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java @@ -1,23 +1,29 @@ package com.baiyue.audit.repository; +import jakarta.annotation.Resource; + import com.baiyue.audit.dto.AuditIngestRequest; import com.baiyue.audit.entity.AuditLogEntity; import com.baiyue.audit.mapper.AuditLogMapper; +import com.baiyue.audit.dto.AuditLogQuery; +import com.baiyue.common.core.api.PageResult; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.springframework.stereotype.Repository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.util.StringUtils; /** * 审计事件持久化仓储,负责将内部审计请求映射并追加写入审计日志表。 */ @Repository public class AuditEventRepository { - private final AuditLogMapper mapper; + @Resource + private AuditLogMapper mapper; - public AuditEventRepository(AuditLogMapper mapper) { - this.mapper = mapper; - } - - public void save(AuditIngestRequest event) { + public boolean save(AuditIngestRequest event, String eventId) { AuditLogEntity entity = new AuditLogEntity(); + entity.setEventId(eventId); entity.setEventType(event.eventType()); entity.setResourceType(event.resourceType()); entity.setResourceId(event.resourceId()); @@ -41,7 +47,40 @@ public class AuditEventRepository { entity.setAfterData(jsonOrNull(event.afterData())); entity.setOccurredAt(event.occurredAt()); entity.setServiceName(event.serviceName()); - mapper.insert(entity); + try { + mapper.insert(entity); + return true; + } catch (DuplicateKeyException ignored) { + return false; + } + } + + public PageResult page(AuditLogQuery query, long page, long size) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(query.accountId() != null, AuditLogEntity::getTenantId, query.accountId()) + .eq(query.operatorUserId() != null, AuditLogEntity::getUserId, query.operatorUserId()) + .eq(StringUtils.hasText(query.eventType()), AuditLogEntity::getEventType, query.eventType()) + .eq(StringUtils.hasText(query.resourceType()), AuditLogEntity::getResourceType, query.resourceType()) + .eq(StringUtils.hasText(query.resourceId()), AuditLogEntity::getResourceId, query.resourceId()) + .eq(query.success() != null, AuditLogEntity::getSuccess, query.success()) + .ge(query.occurredFrom() != null, AuditLogEntity::getOccurredAt, query.occurredFrom()) + .le(query.occurredTo() != null, AuditLogEntity::getOccurredAt, query.occurredTo()) + .orderByDesc(AuditLogEntity::getOccurredAt) + .orderByDesc(AuditLogEntity::getId); + if (StringUtils.hasText(query.keyword())) { + String keyword = query.keyword().trim(); + wrapper.and(condition -> condition + .like(AuditLogEntity::getDescription, keyword) + .or().like(AuditLogEntity::getUsername, keyword) + .or().like(AuditLogEntity::getResourceId, keyword) + .or().like(AuditLogEntity::getRequestId, keyword) + .or().like(AuditLogEntity::getTraceId, keyword)); + } + return PageResult.from(mapper.selectPage(new Page<>(page, size), wrapper)); + } + + public AuditLogEntity findById(Long id) { + return mapper.selectById(id); } private static String jsonOrNull(String value) { diff --git a/audit-service/src/main/java/com/baiyue/audit/service/AuditDeadLetterCompensator.java b/audit-service/src/main/java/com/baiyue/audit/service/AuditDeadLetterCompensator.java new file mode 100644 index 000000000..04ace66ea --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/service/AuditDeadLetterCompensator.java @@ -0,0 +1,47 @@ +package com.baiyue.audit.service; + +import com.baiyue.audit.dto.AuditIngestRequest; +import com.baiyue.audit.repository.AuditDeadLetterRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** 定时重放已持久化的审计死信,达到上限后保留为 DEAD 供人工处理。 */ +@Component +@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "dead-letter-compensation-enabled", havingValue = "true") +public class AuditDeadLetterCompensator { + @Resource + private AuditDeadLetterRepository repository; + @Resource + private AuditIngestService service; + @Resource + private ObjectMapper json; + @Value("${spring.application.name:audit-service}-${random.uuid}") + private String owner; + @Value("${baiyue.audit.rocketmq.dead-letter-max-attempts:5}") + private int maxAttempts; + + @Scheduled(fixedDelayString = "${baiyue.audit.rocketmq.dead-letter-poll-ms:10000}") + public void compensate() { + repository.releaseExpired(); + for (Long id : repository.pendingIds(50)) { + if (!repository.claim(id, owner, 60)) continue; + AuditDeadLetterRepository.DeadRow row = repository.find(id); + if (row == null) continue; + try { + service.ingest(json.readValue(row.payload(), AuditIngestRequest.class)); + repository.processed(id, owner); + } catch (Exception exception) { + repository.failed(id, owner, maxAttempts, safe(exception.getMessage())); + } + } + } + + private static String safe(String value) { + String message = value == null ? "死信补偿失败" : value; + return message.length() <= 1000 ? message : message.substring(0, 1000); + } +} diff --git a/audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java b/audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java index a75e4177b..bcbbcc62a 100644 --- a/audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java +++ b/audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java @@ -5,6 +5,9 @@ import com.baiyue.audit.dto.AuditIngestRequest; import com.baiyue.audit.repository.AuditEventRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.UUID; /** * 审计事件写入应用服务,在事务内接收并持久化不可变业务审计记录。 @@ -16,7 +19,8 @@ public class AuditIngestService { @Transactional - public void ingest(AuditIngestRequest event) { - repository.save(event); + public boolean ingest(AuditIngestRequest event) { + String eventId = StringUtils.hasText(event.eventId()) ? event.eventId() : UUID.randomUUID().toString(); + return repository.save(event, eventId); } } diff --git a/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java b/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java new file mode 100644 index 000000000..d89b6527d --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java @@ -0,0 +1,48 @@ +package com.baiyue.audit.service; + +import com.baiyue.audit.dto.AuditLogQuery; +import com.baiyue.audit.dto.AuditLogResponses; +import com.baiyue.audit.entity.AuditLogEntity; +import com.baiyue.audit.repository.AuditEventRepository; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +/** 平台统一操作日志查询服务。 */ +@Service +public class AuditLogQueryService { + @Resource + private AuditEventRepository repository; + + public PageResult page(AuditLogQuery query, long page, long size) { + PageResult source = repository.page(query, page, size); + return new PageResult<>(source.records().stream().map(this::toListItem).toList(), + source.page(), source.size(), source.total(), source.pages()); + } + + public AuditLogResponses.Detail detail(Long id) { + AuditLogEntity entity = repository.findById(id); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "操作日志不存在"); + } + return new AuditLogResponses.Detail( + entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(), + entity.getDescription(), entity.getRequestId(), entity.getTraceId(), entity.getTenantId(), + entity.getOperatorTenantId(), entity.getUserId(), entity.getUsername(), entity.getAppCode(), + entity.getClientType(), entity.getHttpMethod(), entity.getRequestUri(), entity.getClientIp(), + entity.getDurationMs(), entity.getSuccess(), entity.getErrorCode(), entity.getErrorMessage(), + entity.getBeforeData(), entity.getAfterData(), entity.getServiceName(), entity.getOccurredAt(), + entity.getCreatedAt()); + } + + private AuditLogResponses.ListItem toListItem(AuditLogEntity entity) { + return new AuditLogResponses.ListItem( + entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(), + entity.getDescription(), entity.getTenantId(), entity.getOperatorTenantId(), entity.getUserId(), + entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getClientIp(), entity.getRequestId(), + entity.getTraceId(), entity.getServiceName(), entity.getSuccess(), entity.getErrorCode(), + entity.getErrorMessage(), entity.getOccurredAt(), entity.getCreatedAt()); + } +} diff --git a/audit-service/src/main/java/com/baiyue/audit/service/AuditPlatformScopeGuard.java b/audit-service/src/main/java/com/baiyue/audit/service/AuditPlatformScopeGuard.java new file mode 100644 index 000000000..fce5e77b1 --- /dev/null +++ b/audit-service/src/main/java/com/baiyue/audit/service/AuditPlatformScopeGuard.java @@ -0,0 +1,20 @@ +package com.baiyue.audit.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; + +/** 审计日志只允许平台端账号读取,避免商户端通过通用日志接口获取跨账号记录。 */ +public final class AuditPlatformScopeGuard { + private AuditPlatformScopeGuard() { + } + + public static void requirePlatform(AuthenticatedUser user) { + if (user == null || user.userId() == null) { + throw new BizException(ResultCode.UNAUTHORIZED, "请先登录"); + } + if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0L).equals(user.tenantId())) { + throw new BizException(ResultCode.FORBIDDEN, "仅平台端可查看操作日志"); + } + } +} diff --git a/audit-service/src/main/resources/application-dev.yml b/audit-service/src/main/resources/application-dev.yml new file mode 100644 index 000000000..cd4d4a031 --- /dev/null +++ b/audit-service/src/main/resources/application-dev.yml @@ -0,0 +1,6 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${AUDIT_DB_USERNAME:root} + password: ${AUDIT_DB_PASSWORD:123456} diff --git a/audit-service/src/main/resources/application-test.yml b/audit-service/src/main/resources/application-test.yml new file mode 100644 index 000000000..5cde9b056 --- /dev/null +++ b/audit-service/src/main/resources/application-test.yml @@ -0,0 +1,6 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${AUDIT_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} + username: ${AUDIT_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} + password: ${AUDIT_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/audit-service/src/main/resources/application.yml b/audit-service/src/main/resources/application.yml index 0244bf667..7246521f9 100644 --- a/audit-service/src/main/resources/application.yml +++ b/audit-service/src/main/resources/application.yml @@ -2,6 +2,8 @@ server: port: ${AUDIT_PORT:9700} spring: + profiles: + active: ${SPRING_PROFILES_ACTIVE:dev} application: name: audit-service config: @@ -14,18 +16,27 @@ spring: 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.namespace: ${NACOS_NAMESPACE:public} - - # 本地 IDEA 启动默认连接本机 MySQL;生产环境请通过 AUDIT_DB_* 环境变量覆盖。 - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${AUDIT_DB_USERNAME:root} - password: ${AUDIT_DB_PASSWORD:123456} + config: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + namespace: ${NACOS_NAMESPACE:public} baiyue.audit.enabled: false +baiyue: + audit: + rocketmq: + consumer-enabled: ${AUDIT_MQ_CONSUMER_ENABLED:false} + topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1 + consumer-group: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-consumer-v1 + dead-letter-consumer-enabled: ${AUDIT_DLQ_CONSUMER_ENABLED:false} + dead-letter-topic: "%DLQ%${MQ_TOPIC_PREFIX:test-}baiyue-audit-consumer-v1" + dead-letter-group: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-dlq-handler-v1 + dead-letter-compensation-enabled: ${AUDIT_DLQ_COMPENSATION_ENABLED:false} + dead-letter-max-attempts: ${AUDIT_DLQ_MAX_ATTEMPTS:5} +rocketmq: + name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876} management.endpoints.web.exposure.include: health,info,prometheus diff --git a/audit-service/src/main/resources/db/audit.sql b/audit-service/src/main/resources/db/audit.sql index 985b89b32..533d1ed5c 100644 --- a/audit-service/src/main/resources/db/audit.sql +++ b/audit-service/src/main/resources/db/audit.sql @@ -4,14 +4,15 @@ USE baiyue_audit; CREATE TABLE IF NOT EXISTS sys_audit_log ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '审计日志自增ID', + event_id VARCHAR(64) DEFAULT NULL COMMENT '审计事件全局唯一ID', event_type VARCHAR(64) NOT NULL COMMENT '审计事件编码', resource_type VARCHAR(64) NOT NULL COMMENT '被操作资源类型', resource_id VARCHAR(128) DEFAULT NULL COMMENT '被操作资源ID', description VARCHAR(255) DEFAULT NULL COMMENT '操作说明', request_id VARCHAR(128) DEFAULT NULL COMMENT '请求ID,用于单次请求追踪', trace_id VARCHAR(128) DEFAULT NULL COMMENT '分布式链路追踪ID', - tenant_id BIGINT DEFAULT NULL COMMENT '业务数据所属租户ID', - operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属租户ID', + tenant_id BIGINT DEFAULT NULL COMMENT '客户ID和数据隔离键;平台级审计可为空或为0', + operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属客户ID;平台操作可为空或为0', user_id BIGINT DEFAULT NULL COMMENT '操作用户ID', username VARCHAR(128) DEFAULT NULL COMMENT '操作用户名快照', app_code VARCHAR(64) DEFAULT NULL COMMENT '操作来源应用端', @@ -29,8 +30,26 @@ CREATE TABLE IF NOT EXISTS sys_audit_log ( occurred_at DATETIME(3) NOT NULL COMMENT '业务操作发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审计服务落库时间', PRIMARY KEY (id), + UNIQUE KEY uk_audit_event_id (event_id), KEY idx_audit_tenant_time (tenant_id, occurred_at), KEY idx_audit_event_time (event_type, occurred_at), KEY idx_audit_user_time (user_id, occurred_at), KEY idx_audit_request (request_id) ) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表'; + +CREATE TABLE IF NOT EXISTS sys_audit_dead_letter ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '死信记录自增ID', + event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID', + payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待补偿、PROCESSING处理中、PROCESSED已处理、DEAD永久失败', + retry_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 '最近一次补偿错误', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '死信记录创建时间', + processed_at DATETIME(3) NULL COMMENT '补偿处理完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_audit_dead_event (event_id), + KEY idx_audit_dead_retry (status, next_retry_at, lease_until) +) ENGINE=InnoDB COMMENT='审计消息死信补偿表'; diff --git a/audit-service/src/main/resources/db/reliable-audit-migration.sql b/audit-service/src/main/resources/db/reliable-audit-migration.sql new file mode 100644 index 000000000..0889751a8 --- /dev/null +++ b/audit-service/src/main/resources/db/reliable-audit-migration.sql @@ -0,0 +1,39 @@ +USE baiyue_audit; + +DROP PROCEDURE IF EXISTS add_audit_event_id; +DELIMITER $$ +CREATE PROCEDURE add_audit_event_id() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA='baiyue_audit' AND TABLE_NAME='sys_audit_log' AND COLUMN_NAME='event_id' + ) THEN + ALTER TABLE sys_audit_log ADD COLUMN event_id VARCHAR(64) NULL COMMENT '审计事件全局唯一ID' AFTER id; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA='baiyue_audit' AND TABLE_NAME='sys_audit_log' AND INDEX_NAME='uk_audit_event_id' + ) THEN + ALTER TABLE sys_audit_log ADD UNIQUE KEY uk_audit_event_id (event_id); + END IF; +END$$ +DELIMITER ; +CALL add_audit_event_id(); +DROP PROCEDURE IF EXISTS add_audit_event_id; + +CREATE TABLE IF NOT EXISTS sys_audit_dead_letter ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '死信记录自增ID', + event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID', + payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待补偿、PROCESSING处理中、PROCESSED已处理、DEAD永久失败', + retry_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 '最近一次补偿错误', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '死信记录创建时间', + processed_at DATETIME(3) NULL COMMENT '补偿处理完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_audit_dead_event (event_id), + KEY idx_audit_dead_retry (status, next_retry_at, lease_until) +) ENGINE=InnoDB COMMENT='审计消息死信补偿表'; diff --git a/audit-service/src/test/java/com/baiyue/audit/BaiyueAuditApplicationContractTest.java b/audit-service/src/test/java/com/baiyue/audit/BaiyueAuditApplicationContractTest.java new file mode 100644 index 000000000..20debe129 --- /dev/null +++ b/audit-service/src/test/java/com/baiyue/audit/BaiyueAuditApplicationContractTest.java @@ -0,0 +1,17 @@ +package com.baiyue.audit; + +import com.baiyue.common.core.mybatis.MybatisPlusConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +class BaiyueAuditApplicationContractTest { + @Test + void importsSharedMybatisPaginationConfiguration() { + Import imported = BaiyueAuditApplication.class.getAnnotation(Import.class); + + assertThat(imported).isNotNull(); + assertThat(imported.value()).contains(MybatisPlusConfiguration.class); + } +} diff --git a/audit-service/src/test/java/com/baiyue/audit/controller/AuditIngestControllerTest.java b/audit-service/src/test/java/com/baiyue/audit/controller/AuditIngestControllerTest.java new file mode 100644 index 000000000..06f5563d5 --- /dev/null +++ b/audit-service/src/test/java/com/baiyue/audit/controller/AuditIngestControllerTest.java @@ -0,0 +1,74 @@ +package com.baiyue.audit.controller; + +import com.baiyue.audit.service.AuditIngestService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringJUnitConfig(AuditIngestController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef") +class AuditIngestControllerTest { + private static final String TOKEN = "0123456789abcdef0123456789abcdef"; + + @MockBean + private AuditIngestService service; + + @Resource + private AuditIngestController controller; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void rejectsInvalidInternalToken() throws Exception { + mockMvc.perform(post("/internal/audit/events") + .header("X-Internal-Service-Token", "wrong-token") + .contentType(MediaType.APPLICATION_JSON) + .content(validRequest())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("A0403")); + + verify(service, never()).ingest(any()); + } + + @Test + void acceptsEventWithInternalToken() throws Exception { + mockMvc.perform(post("/internal/audit/events") + .header("X-Internal-Service-Token", TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(validRequest())) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.code").value("200")); + + verify(service).ingest(any()); + } + + private String validRequest() { + return """ + { + "eventType":"ROLE_UPDATE", + "resourceType":"ROLE", + "durationMs":0, + "success":true, + "occurredAt":"2026-08-05T00:00:00Z" + } + """; + } +} diff --git a/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java b/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java new file mode 100644 index 000000000..b7f14cbab --- /dev/null +++ b/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java @@ -0,0 +1,92 @@ +package com.baiyue.audit.controller; + +import com.baiyue.audit.dto.AuditLogQuery; +import com.baiyue.audit.dto.AuditLogResponses; +import com.baiyue.audit.service.AuditLogQueryService; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.security.access.prepost.PreAuthorize; + +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.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(PlatformAuditLogController.class) +class PlatformAuditLogControllerContractTest { + @MockBean + private AuditLogQueryService service; + + @Resource + private PlatformAuditLogController controller; + + @Test + void platformCanQueryLogList() { + when(service.page(any(AuditLogQuery.class), org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(20L))) + .thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + var response = controller.page(user("PLATFORM"), 1, 20, 10001L, 7L, "ROLE_UPDATE", "ROLE", "9", + true, null, null, "管理员"); + + assertThat(response.successful()).isTrue(); + verify(service).page(new AuditLogQuery(10001L, 7L, "ROLE_UPDATE", "ROLE", "9", true, + null, null, "管理员"), 1, 20); + } + + @Test + void merchantCannotReadPlatformLogs() { + assertThatThrownBy(() -> controller.page(user("MERCHANT"), 1, 20, null, null, null, null, + null, null, null, null, null)).isInstanceOf(BizException.class) + .hasMessage("仅平台端可查看操作日志"); + verifyNoInteractions(service); + } + + @Test + void platformApplicationWithCustomerTenantCannotReadLogs() { + AuthenticatedUser malformed = new AuthenticatedUser(7L, 10001L, 10001L, 1L, + "异常平台账号", "PLATFORM", "WEB", Set.of("platform:audit:list")); + + assertThatThrownBy(() -> controller.page(malformed, 1, 20, null, null, null, null, + null, null, null, null, null)).isInstanceOf(BizException.class) + .hasMessage("仅平台端可查看操作日志"); + verifyNoInteractions(service); + } + + @Test + void platformCanReadLogDetail() { + AuditLogResponses.Detail detail = new AuditLogResponses.Detail(8L, "audit-1", "ROLE_UPDATE", "ROLE", "9", null, + null, null, null, null, 7L, "管理员", "PLATFORM", "WEB", null, null, null, 1L, + true, null, null, "{}", "{}", "iam-service", null, null); + when(service.detail(8L)).thenReturn(detail); + + assertThat(controller.detail(user("PLATFORM"), 8L).data()).isEqualTo(detail); + verify(service).detail(8L); + } + + @Test + void listAndDetailUseTheSamePlatformAuditPermission() throws NoSuchMethodException { + String expected = "hasAuthority('platform:audit:list')"; + + assertThat(PlatformAuditLogController.class.getMethod("page", AuthenticatedUser.class, + long.class, long.class, Long.class, Long.class, String.class, String.class, String.class, + Boolean.class, java.time.Instant.class, java.time.Instant.class, String.class) + .getAnnotation(PreAuthorize.class).value()).isEqualTo(expected); + assertThat(PlatformAuditLogController.class.getMethod("detail", AuthenticatedUser.class, Long.class) + .getAnnotation(PreAuthorize.class).value()).isEqualTo(expected); + } + + private static AuthenticatedUser user(String appCode) { + return new AuthenticatedUser(7L, 0L, 0L, 1L, "管理员", appCode, "WEB", Set.of("platform:audit:list")); + } +} diff --git a/audit-service/src/test/java/com/baiyue/audit/mq/AuditMqReliabilityTest.java b/audit-service/src/test/java/com/baiyue/audit/mq/AuditMqReliabilityTest.java new file mode 100644 index 000000000..66b714017 --- /dev/null +++ b/audit-service/src/test/java/com/baiyue/audit/mq/AuditMqReliabilityTest.java @@ -0,0 +1,107 @@ +package com.baiyue.audit.mq; + +import com.baiyue.audit.repository.AuditDeadLetterRepository; +import com.baiyue.audit.service.AuditDeadLetterCompensator; +import com.baiyue.audit.service.AuditIngestService; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +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.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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; + +@SpringJUnitConfig(classes = { + AuditMqReliabilityTest.Config.class, + AuditRocketMqConsumer.class, + AuditDeadLetterConsumer.class, + AuditDeadLetterCompensator.class +}) +@TestPropertySource(properties = { + "baiyue.audit.rocketmq.consumer-enabled=true", + "baiyue.audit.rocketmq.dead-letter-consumer-enabled=true", + "baiyue.audit.rocketmq.dead-letter-compensation-enabled=true", + "baiyue.audit.rocketmq.topic=test-audit", + "baiyue.audit.rocketmq.consumer-group=test-audit-consumer", + "baiyue.audit.rocketmq.dead-letter-topic=%DLQ%test-audit-consumer", + "baiyue.audit.rocketmq.dead-letter-group=test-audit-dlq", + "spring.application.name=audit-service-test" +}) +class AuditMqReliabilityTest { + private static final String PAYLOAD = """ + {"eventId":"event-1","eventType":"ROLE_UPDATE","resourceType":"ROLE", + "durationMs":10,"success":true,"occurredAt":"2026-08-05T00:00:00Z"} + """; + + @MockBean + private AuditIngestService service; + @MockBean + private AuditDeadLetterRepository repository; + @Resource + private AuditRocketMqConsumer consumer; + @Resource + private AuditDeadLetterConsumer deadLetterConsumer; + @Resource + private AuditDeadLetterCompensator compensator; + + @Test + void consumesAuditEvent() { + consumer.onMessage(PAYLOAD); + + verify(service).ingest(any()); + } + + @Test + void rejectsInvalidPayload() { + assertThatThrownBy(() -> consumer.onMessage("not-json")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void storesDeadLetter() { + deadLetterConsumer.onMessage(PAYLOAD); + + verify(repository).save("event-1", PAYLOAD); + } + + @Test + void compensatesDeadLetter() { + when(repository.pendingIds(50)).thenReturn(java.util.List.of(7L)); + when(repository.claim(eq(7L), anyString(), eq(60))).thenReturn(true); + when(repository.find(7L)).thenReturn( + new AuditDeadLetterRepository.DeadRow(7L, "event-1", PAYLOAD, 1)); + + compensator.compensate(); + + verify(service).ingest(any()); + verify(repository).processed(eq(7L), anyString()); + } + + @Test + void retriesFailedCompensation() { + when(repository.pendingIds(50)).thenReturn(java.util.List.of(8L)); + when(repository.claim(eq(8L), anyString(), eq(60))).thenReturn(true); + when(repository.find(8L)).thenReturn( + new AuditDeadLetterRepository.DeadRow(8L, "event-2", "not-json", 1)); + + compensator.compensate(); + + verify(repository).failed(eq(8L), anyString(), eq(5), anyString()); + } + + @Configuration + static class Config { + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper().findAndRegisterModules(); + } + } +} diff --git a/audit-service/src/test/java/com/baiyue/audit/service/AuditIngestIdempotencyTest.java b/audit-service/src/test/java/com/baiyue/audit/service/AuditIngestIdempotencyTest.java new file mode 100644 index 000000000..8de45c19c --- /dev/null +++ b/audit-service/src/test/java/com/baiyue/audit/service/AuditIngestIdempotencyTest.java @@ -0,0 +1,39 @@ +package com.baiyue.audit.service; + +import com.baiyue.audit.dto.AuditIngestRequest; +import com.baiyue.audit.repository.AuditEventRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(AuditIngestService.class) +class AuditIngestIdempotencyTest { + @MockBean + private AuditEventRepository repository; + @Resource + private AuditIngestService service; + + @Test + void keepsStableEventIdForMqRedelivery() { + AuditIngestRequest request = request("audit-1"); + when(repository.save(request, "audit-1")).thenReturn(false); + + assertThat(service.ingest(request)).isFalse(); + + verify(repository).save(request, "audit-1"); + } + + private static AuditIngestRequest request(String eventId) { + return new AuditIngestRequest(eventId, "ROLE_UPDATE", "ROLE", "1", "修改角色", + "request-1", "trace-1", 1L, 1L, 1L, "admin", "PLATFORM", "WEB", "PUT", + "/roles/1", "127.0.0.1", 10, true, null, null, null, null, + Instant.parse("2026-08-05T00:00:00Z"), "iam-service"); + } +} diff --git a/audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java b/audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java new file mode 100644 index 000000000..d6f445396 --- /dev/null +++ b/audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java @@ -0,0 +1,103 @@ +package com.baiyue.audit.service; + +import com.baiyue.audit.dto.AuditLogQuery; +import com.baiyue.audit.dto.AuditLogResponses; +import com.baiyue.audit.entity.AuditLogEntity; +import com.baiyue.audit.repository.AuditEventRepository; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.time.Instant; +import java.time.LocalDateTime; +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.when; + +@SpringJUnitConfig(AuditLogQueryService.class) +class AuditLogQueryServiceTest { + @MockBean + private AuditEventRepository repository; + + @Resource + private AuditLogQueryService service; + + @Test + void pageDoesNotExposeSnapshots() { + AuditLogQuery query = new AuditLogQuery(10001L, 7L, "ROLE_UPDATE", "ROLE", "9", true, + null, null, "管理员"); + when(repository.page(query, 1, 20)).thenReturn(new PageResult<>(List.of(log(8L)), 1, 20, 1, 1)); + + PageResult result = service.page(query, 1, 20); + + assertThat(result.records()).singleElement().satisfies(item -> { + assertThat(item.id()).isEqualTo(8L); + assertThat(item.accountId()).isEqualTo(10001L); + assertThat(item.eventType()).isEqualTo("ROLE_UPDATE"); + }); + assertThat(AuditLogResponses.ListItem.class.getRecordComponents()) + .extracting(component -> component.getName()) + .doesNotContain("beforeData", "afterData"); + } + + @Test + void pagePreservesTotalAndPagesForCustomerLogPagination() { + AuditLogQuery query = new AuditLogQuery(10001L, null, null, "TENANT", null, null, + null, null, null); + when(repository.page(query, 1, 20)).thenReturn(new PageResult<>(List.of(log(8L)), 1, 20, 13, 1)); + + PageResult result = service.page(query, 1, 20); + + assertThat(result.records()).hasSize(1); + assertThat(result.total()).isEqualTo(13); + assertThat(result.pages()).isEqualTo(1); + } + + @Test + void detailReturnsSanitizedSnapshots() { + when(repository.findById(8L)).thenReturn(log(8L)); + + AuditLogResponses.Detail detail = service.detail(8L); + + assertThat(detail.beforeData()).isEqualTo("{\"name\":\"旧角色\"}"); + assertThat(detail.afterData()).isEqualTo("{\"name\":\"新角色\"}"); + } + + @Test + void missingLogIsReportedAsNotFound() { + when(repository.findById(8L)).thenReturn(null); + + assertThatThrownBy(() -> service.detail(8L)).isInstanceOf(BizException.class) + .hasMessage("操作日志不存在"); + } + + private static AuditLogEntity log(Long id) { + AuditLogEntity entity = new AuditLogEntity(); + entity.setId(id); + entity.setEventId("audit-8"); + entity.setEventType("ROLE_UPDATE"); + entity.setResourceType("ROLE"); + entity.setResourceId("9"); + entity.setDescription("管理员修改角色权限"); + entity.setTenantId(10001L); + entity.setOperatorTenantId(0L); + entity.setUserId(7L); + entity.setUsername("管理员"); + entity.setAppCode("PLATFORM"); + entity.setClientType("WEB"); + entity.setRequestId("request-8"); + entity.setTraceId("trace-8"); + entity.setSuccess(true); + entity.setBeforeData("{\"name\":\"旧角色\"}"); + entity.setAfterData("{\"name\":\"新角色\"}"); + entity.setServiceName("iam-service"); + entity.setOccurredAt(Instant.parse("2026-08-05T10:00:00Z")); + entity.setCreatedAt(LocalDateTime.of(2026, 8, 5, 18, 0)); + return entity; + } +} diff --git a/auth/AGENTS.md b/auth/AGENTS.md new file mode 100644 index 000000000..0cb3466c8 --- /dev/null +++ b/auth/AGENTS.md @@ -0,0 +1,38 @@ +# Auth Service 模块指引 + +## 职责与边界 + +- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 +- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。 +- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。 +- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。 +- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。 + +## 模块入口 + +| 目标 | 入口 | +|---|---| +| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` | +| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` | +| 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 | +| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 | +| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 | +| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只携带稳定身份、客户范围和版本声明,不携带角色或权限集;前端统一认证层负责按区域 API 自动选择 | +| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 | +| 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` | +| DTO/持久化 | `dto/AccountManagementDtos.java`、`entity/`、`mapper/`、`repository/` | +| SQL/OpenAPI | `src/main/resources/db/`、`src/main/resources/openapi/` | +| 测试/验证 | `src/test/java/com/baiyue/auth/`;`mvn -pl auth -am test` | + +## 修改约束 + +- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;短时海外 Token 不携带角色或权限集,海外授权由 IAM 事件投影到 Lastleg,再由海外 Gateway 验证并以可信内网上下文传递,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 +- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 +- 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。 +- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 + +## 全量验收导航 + +订单与客户、入库测量与计费、国内货柜履约、海外仓业务及财务全量验收均复用本模块认证能力;按本次链路读取对应业务域/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史规划边界排除其他消费者。 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/pom.xml b/auth/pom.xml index d8d25d7f3..8d1a1d8cb 100644 --- a/auth/pom.xml +++ b/auth/pom.xml @@ -6,6 +6,7 @@ auth-service com.baiyue.sharedbaiyue-common-audit${project.version} + org.apache.rocketmqrocketmq-spring-boot-starter com.baiyue.sharedbaiyue-common-openapi${project.version} com.baiyue.sharedbaiyue-common-web${project.version} com.baiyue.sharedbaiyue-common-security${project.version} diff --git a/auth/src/main/java/com/baiyue/auth/BaiyueAuthApplication.java b/auth/src/main/java/com/baiyue/auth/BaiyueAuthApplication.java index ad3d69c33..681e2de9d 100644 --- a/auth/src/main/java/com/baiyue/auth/BaiyueAuthApplication.java +++ b/auth/src/main/java/com/baiyue/auth/BaiyueAuthApplication.java @@ -1,5 +1,6 @@ package com.baiyue.auth; +import org.apache.ibatis.annotations.Mapper; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -8,7 +9,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients; /** * 认证服务启动入口,负责装配统一登录、令牌签发与会话管理相关组件。 */ -@MapperScan("com.baiyue.auth.mapper") +@MapperScan(basePackages = "com.baiyue.auth", annotationClass = Mapper.class) @EnableFeignClients(basePackages = "com.baiyue.auth.client") @SpringBootApplication(scanBasePackages = "com.baiyue") public class BaiyueAuthApplication { diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java index a48bc5303..5d69f230d 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java @@ -1,5 +1,8 @@ package com.baiyue.auth.client; +import jakarta.annotation.Resource; +import jakarta.annotation.PostConstruct; + import com.baiyue.auth.entity.AuthAccount; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -7,21 +10,23 @@ import feign.FeignException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.util.Objects; + /** - * 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为认证令牌组装权限声明。 + * 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为 Redis 会话组装授权快照。 */ @Component public class AuthorizationLoader { - private final IamAuthorizationClient client; - private final String internalToken; + @Resource + private IamAuthorizationClient client; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; - public AuthorizationLoader(IamAuthorizationClient client, - @Value("${baiyue.security.internal-service-token}") String internalToken) { + @PostConstruct + void validateConfiguration() { if (internalToken == null || internalToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); } - this.client = client; - this.internalToken = internalToken; } public AuthorizationSnapshot load(AuthAccount account) { @@ -29,14 +34,32 @@ public class AuthorizationLoader { var response = client.load(internalToken, new AuthorizationSnapshotRequest( account.getId(), account.getDefaultTenantId(), account.getDefaultMembershipId(), account.getDefaultAppCode())); - if (response == null || !response.successful() || response.data() == null) { + if (response == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用"); + } + if (!response.successful()) { + ResultCode code = ResultCode.of(response.code()); + if (code == ResultCode.BAD_REQUEST || code == ResultCode.FORBIDDEN || code == ResultCode.NOT_FOUND) { + throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限"); + } + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用"); + } + AuthorizationSnapshot snapshot = response.data(); + if (snapshot == null) { throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限"); } - return response.data(); + if (!Objects.equals(account.getId(), snapshot.getUserId()) + || !Objects.equals(account.getDefaultTenantId(), snapshot.getTenantId()) + || !Objects.equals(account.getDefaultMembershipId(), snapshot.getMembershipId()) + || !Objects.equals(account.getDefaultAppCode(), snapshot.getAppCode())) { + throw new BizException(ResultCode.FORBIDDEN, "IAM 授权快照身份与账号归属不一致"); + } + return snapshot; } catch (FeignException exception) { - if (exception.status() == 400 || exception.status() == 403 || exception.status() == 404) { - throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限"); - } + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用"); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "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 704653bb8..aae981b02 100644 --- a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java +++ b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java @@ -1,8 +1,11 @@ package com.baiyue.auth.client; import com.baiyue.common.core.api.ApiResponse; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -14,9 +17,20 @@ public interface IamOnboardingClient { @RequestHeader("X-Internal-Service-Token") String internalToken, @RequestBody OnboardingRequest request); + @GetMapping("/merchant-registrations/{authUserId}") + ApiResponse registration( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @PathVariable("authUserId") Long authUserId); + + @DeleteMapping("/merchant-registrations/{authUserId}/compensation") + ApiResponse compensate( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @PathVariable("authUserId") Long authUserId); + record OnboardingRequest(Long authUserId, String displayName, String companyName, - String legalPerson, String legalPhone, String contactEmail, + String legalPerson, String administratorPhone, 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 31a2e7d6e..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 编码器以及认证接口的安全过滤链。 @@ -25,15 +21,21 @@ import java.nio.charset.StandardCharsets; @Configuration @EnableConfigurationProperties(AuthSecurityProperties.class) public class AuthSecurityConfig { - @Bean - PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); } + static final String[] PUBLIC_AUTH_ENDPOINTS = { + "/api/auth/register", + "/api/auth/login", + "/api/auth/refresh", + "/api/auth/logout", + "/api/auth/impersonations/exchange", + "/api/auth/.well-known/jwks.json", + "/actuator/health", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html" + }; @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)); - } + PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); } @Bean SecurityFilterChain authSecurityFilterChain(HttpSecurity http, @@ -47,8 +49,7 @@ public class AuthSecurityConfig { .exceptionHandling(errors -> errors .authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout", - "/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + .requestMatchers(PUBLIC_AUTH_ENDPOINTS).permitAll() .requestMatchers("/internal/auth/**").permitAll() .anyRequest().authenticated()) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java index ba0804300..5b9f00a1c 100644 --- a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java +++ b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java @@ -8,7 +8,7 @@ import java.time.Duration; * 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。 */ @ConfigurationProperties(prefix = "baiyue.security") -public record AuthSecurityProperties(String jwtSecret, String issuer, +public record AuthSecurityProperties(String jwtSecret, String issuer, String audience, Duration accessTokenTtl, Duration refreshTokenTtl, int maxLoginFailures, Duration lockDuration) { } diff --git a/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java b/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java 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 2fd921852..6130bf029 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java @@ -8,6 +8,8 @@ import com.baiyue.auth.dto.MerchantRegisterRequest; import com.baiyue.auth.dto.MerchantRegisterResponse; import com.baiyue.auth.service.MerchantRegistrationService; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirements; @@ -18,7 +20,11 @@ import org.springframework.web.bind.annotation.*; import org.springframework.security.core.annotation.AuthenticationPrincipal; import com.baiyue.common.security.AuthenticatedUser; 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; /** @@ -34,20 +40,44 @@ public class AuthController { private MerchantRegistrationService registrationService; @Resource private AccountManagementService accountManagementService; + @Resource + private ObjectProvider overseasTokens; + + @PostMapping("/overseas/token") + @PreAuthorize("isAuthenticated()") + @Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。") + public ApiResponse exchangeOverseasToken( + @AuthenticationPrincipal AuthenticatedUser user) { + OverseasTokenService service = overseasTokens.getIfAvailable(); + if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用"); + return ApiResponse.success(service.exchange(user.userId())); + } @PutMapping("/password") @PreAuthorize("isAuthenticated()") @Operation(summary = "修改当前用户密码") public ApiResponse changePassword(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ChangePasswordRequest request) { + if (user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "代登录会话不能修改密码"); accountManagementService.changePassword(user.userId(), request); return ApiResponse.success(null); } + @PutMapping("/password/forced") + @PreAuthorize("isAuthenticated()") + @Operation(summary = "完成强制改密") + public ApiResponse forcedChangePassword(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ForcedPasswordChangeRequest request) { + if (user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "代登录会话不能修改密码"); + if (!user.passwordChangeRequired()) throw new BizException(ResultCode.CONFLICT, "当前账号不需要强制改密"); + accountManagementService.forcedChangePassword(user.userId(), request); + return ApiResponse.success(null); + } + @SecurityRequirements @PostMapping("/register") @Operation(summary = "Merchant registration / 客户注册", - description = "Create a login account and an enterprise profile awaiting one business-license upload.") + description = "Create a login account and enterprise profile. The supplied phone initializes both the first administrator login phone and company contact phone; they are maintained independently after creation. Only the business license and unified social credit code are required for review; legal-person ID is optional.") public ApiResponse register(@Valid @RequestBody MerchantRegisterRequest request) { return ApiResponse.success(registrationService.register(request)); } @@ -55,10 +85,7 @@ public class AuthController { @SecurityRequirements @Operation(summary = "Login / 登录", description = "Validate credentials and issue access and refresh tokens. / 校验账号密码并签发访问令牌和刷新令牌。") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Login succeeded / 登录成功"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数错误"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Invalid username or password / 用户名或密码错误"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "429", description = "Account temporarily locked / 账号暂时锁定") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Success or authentication business failure; inspect body code / 成功或认证业务失败,按响应体 code 判断"), }) @PostMapping("/login") public ApiResponse login(@Valid @RequestBody LoginRequest request) { @@ -68,8 +95,7 @@ public class AuthController { @SecurityRequirements @Operation(summary = "Refresh token / 刷新令牌", description = "Rotate a valid refresh token and issue a new token pair. / 轮换有效刷新令牌并签发新的令牌对。") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Token refreshed / 刷新成功"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Refresh token invalid or expired / 刷新令牌无效或已过期") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Success or refresh-token business failure; inspect body code / 成功或刷新令牌业务失败,按响应体 code 判断") }) @PostMapping("/refresh") public ApiResponse refresh(@Valid @RequestBody RefreshRequest request) { @@ -86,4 +112,11 @@ public class AuthController { authService.logout(request.refreshToken()); return ApiResponse.success(null); } + + @SecurityRequirements + @PostMapping("/impersonations/exchange") + public ApiResponse exchangeImpersonation( + @Valid @RequestBody AccountManagementDtos.ImpersonationExchangeRequest request) { + return ApiResponse.success(authService.exchangeImpersonationTicket(request.code())); + } } 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 01c56a0ec..966201676 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -2,11 +2,17 @@ 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.*; @@ -16,24 +22,39 @@ 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 { - private final AccountManagementService service; - private final String configuredToken; + @Resource + private AccountManagementService service; + @Resource + private AuthService authService; + @Resource + private OverseasSessionValidationService overseasSessionValidationService; + + @Value("${baiyue.security.internal-service-token}") + private String configuredToken; private byte[] tokenBytes; - public InternalAccountController(AccountManagementService service, - @Value("${baiyue.security.internal-service-token}") String configuredToken) { - this.service = service; - this.configuredToken = configuredToken; + @PostConstruct + void init() { + byte[] configured = configuredToken == null + ? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8); + if (configured.length < 32) { + throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + tokenBytes = configured; } - @PostConstruct void init() { tokenBytes = configuredToken.getBytes(StandardCharsets.UTF_8); } - @PostMapping public ApiResponse create(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @Valid @RequestBody CreateAccountRequest request) { verify(token); return ApiResponse.success(service.create(request)); } + @GetMapping("/availability") + public ApiResponse availability(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @RequestParam String username) { + verify(token); return ApiResponse.success(service.usernameAvailable(username)); + } @GetMapping("/{id}") public ApiResponse summary(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @PathVariable Long id) { @@ -44,10 +65,62 @@ public class InternalAccountController { @PathVariable Long id, @Valid @RequestBody UpdateAccountStatusRequest request) { verify(token); service.updateStatus(id, request); return ApiResponse.success(null); } + @PostMapping("/sessions/revoke") + public ApiResponse revokeSessions( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @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) { + verify(token); service.updateProfile(id, request); return ApiResponse.success(null); + } + @PutMapping("/{id}/login-identifiers") + public ApiResponse loginIdentifiers( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @PathVariable Long id, @Valid @RequestBody ChangeLoginIdentifiersRequest request) { + verify(token); + return ApiResponse.success(service.changeLoginIdentifiers(id, request)); + } + @PutMapping("/{id}/phone") + public ApiResponse phone(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @PathVariable Long id, @Valid @RequestBody UpdateAccountPhoneRequest request) { + verify(token); + service.updatePhone(id, request); + return ApiResponse.success(null); + } @PutMapping("/{id}/password") public ApiResponse reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) { - verify(token); service.resetPassword(id, request); return ApiResponse.success(null); + verify(token); service.resetPassword(id, request, request.forceChange()); return ApiResponse.success(null); + } + + @PostMapping("/impersonation-tickets") + public ApiResponse impersonationTicket( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @Valid @RequestBody ImpersonationTicketRequest request) { + verify(token); return ApiResponse.success(authService.createImpersonationTicket(request)); + } + + @PutMapping("/tenant/{tenantId}/archive") + public ApiResponse archiveTenant(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @PathVariable Long tenantId) { + verify(token); service.archiveTenant(tenantId); return ApiResponse.success(null); + } + @PutMapping("/{id}/scope") + public ApiResponse scope(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @PathVariable Long id, @Valid @RequestBody UpdateAccountScopeRequest request) { + verify(token); service.updateScope(id, request); return ApiResponse.success(null); } @DeleteMapping("/{id}/compensation") public ApiResponse compensate(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, 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/AccountManagementDtos.java b/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java index bd6763fe5..e63be9874 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java +++ b/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java @@ -2,9 +2,13 @@ package com.baiyue.auth.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Pattern; +import java.time.LocalDateTime; +import java.util.Set; + /** 账号安全及内部账号管理契约。 */ public final class AccountManagementDtos { private AccountManagementDtos() { } @@ -12,14 +16,61 @@ public final class AccountManagementDtos { public record ChangePasswordRequest(@NotBlank String oldPassword, @NotBlank @Size(min = 8, max = 64) @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } + public record ForcedPasswordChangeRequest( + @NotBlank @Size(min = 8, max = 64) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") + String newPassword) { } public record CreateAccountRequest(@NotNull Long accountId, - @NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username, + @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username, + @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, @NotBlank @Size(min = 8, max = 64) @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, @NotBlank String displayName, @NotNull Long tenantId, - @NotNull Long membershipId) { } + @NotNull Long membershipId, + @Pattern(regexp = "MERCHANT|PLATFORM", message = "应用端只能是 MERCHANT 或 PLATFORM") + String appCode, + @Pattern(regexp = "ACTIVE|DISABLED", message = "账号状态只能是 ACTIVE 或 DISABLED") + String accountStatus) { + public CreateAccountRequest(Long accountId, String username, String password, String displayName, + Long tenantId, Long membershipId, String appCode) { + this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, "ACTIVE"); + } + public CreateAccountRequest(Long accountId, String username, String password, String displayName, + Long tenantId, Long membershipId, String appCode, String accountStatus) { + this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, accountStatus); + } + } + public record ChangeLoginIdentifiersRequest( + @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username, + @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, + @NotBlank String currentPassword) { } + public record UpdateAccountPhoneRequest( + @Pattern(regexp = "^1[3-9]\\d{9}$") String phone) { } + public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { } public record UpdateAccountStatusRequest(@NotBlank String status) { } + public record RevokeAccountSessionsRequest( + @NotEmpty @Size(max = 500) Set<@NotNull Long> accountIds) { + public RevokeAccountSessionsRequest { + accountIds = Set.copyOf(accountIds); + } + } public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64) - @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } - public record AccountSummary(Long accountId, String username, String displayName, String status) { } + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword, + Boolean forceChangePassword) { + public boolean forceChange() { return Boolean.TRUE.equals(forceChangePassword); } + } + public record ResetPasswordOptions(boolean forceChangePassword) { } + public record ImpersonationTicketRequest(@NotNull Long targetAuthUserId, @NotNull Long tenantId, + @NotNull Long membershipId, @NotNull Long actorUserId, + @NotNull Long actorMembershipId, @NotBlank String reason) { } + public record ImpersonationTicketResponse(String code, String ticketId, long expiresIn) { } + public record ImpersonationExchangeRequest(@NotBlank String code) { } + public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId, + @NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { } + public record AccountSummary(Long accountId, String username, String phone, String displayName, String status, + LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) { + public AccountSummary(Long accountId, String username, String displayName, String status) { + this(accountId, username, null, displayName, status, null, null, null, null); + } + } } diff --git a/auth/src/main/java/com/baiyue/auth/dto/LoginRequest.java b/auth/src/main/java/com/baiyue/auth/dto/LoginRequest.java index db5b95596..5e09a4298 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/LoginRequest.java +++ b/auth/src/main/java/com/baiyue/auth/dto/LoginRequest.java @@ -2,13 +2,15 @@ package com.baiyue.auth.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; /** * 登录请求契约,接收账号、密码和目标应用域等统一登录参数。 */ @Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据") public record LoginRequest( - @Schema(description = "Login username / 登录用户名", example = "merchant_admin") @NotBlank String username, + @Schema(description = "Username or mainland China mobile number / 用户名或中国大陆手机号", example = "merchant_admin") + @NotBlank @Size(max = 64) String loginId, @Schema(description = "Login password / 登录密码", example = "ChangeMe123!", format = "password") @NotBlank String password ) { -} \ No newline at end of file +} diff --git a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java index 695e36832..d0ca9eb4c 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java +++ b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java @@ -8,12 +8,13 @@ import jakarta.validation.constraints.Size; @Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration") public record MerchantRegisterRequest( - @NotBlank @Pattern(regexp = "[A-Za-z0-9_]{4,64}") String username, + @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username, @NotBlank @Size(min = 8, max = 72) String password, @NotBlank @Size(max = 64) String displayName, @NotBlank @Size(max = 128) String companyName, - @NotBlank @Size(max = 64) String legalPerson, - @NotBlank @Size(max = 32) String legalPhone, + @Size(max = 64) String legalPerson, + @Schema(description = "首个企业管理员的登录手机号;注册时同时初始化企业联系电话,创建后两者独立维护") + @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, @Email @Size(max = 128) String contactEmail, @Size(min = 2, max = 2) String countryCode, @Size(max = 255) String address 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..aa86d983c 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java +++ b/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java @@ -2,6 +2,8 @@ package com.baiyue.auth.dto; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Set; + /** * 认证令牌响应契约,返回访问令牌、刷新令牌及其有效期等会话信息。 */ @@ -11,6 +13,16 @@ 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, + @Schema(description = "Current role snapshot returned outside JWT / JWT 外返回的当前角色快照") Set roles, + @Schema(description = "Current authority snapshot returned outside JWT / JWT 外返回的当前权限快照") Set authorities ) { -} \ No newline at end of file + public TokenResponse { + roles = roles == null ? Set.of() : Set.copyOf(roles); + authorities = authorities == null ? Set.of() : Set.copyOf(authorities); + } +} diff --git a/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java b/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java index d2b6a4a67..22f96b839 100644 --- a/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java +++ b/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java @@ -18,12 +18,14 @@ import java.time.LocalDateTime; public class AuthAccount { @TableId private Long id; private String username; + private String phone; private String passwordHash; private String displayName; private String accountStatus; private Integer failedLoginCount; private LocalDateTime lockedUntil; private Integer tokenVersion; + private Boolean passwordChangeRequired; private Long defaultTenantId; private Long operatorTenantId; private Long defaultMembershipId; diff --git a/auth/src/main/java/com/baiyue/auth/mapper/AuthAccountMapper.java b/auth/src/main/java/com/baiyue/auth/mapper/AuthAccountMapper.java index 5b43f3d1d..2bae4dc75 100644 --- a/auth/src/main/java/com/baiyue/auth/mapper/AuthAccountMapper.java +++ b/auth/src/main/java/com/baiyue/auth/mapper/AuthAccountMapper.java @@ -1,9 +1,12 @@ package com.baiyue.auth.mapper; +import org.apache.ibatis.annotations.Mapper; + import com.baiyue.auth.entity.AuthAccount; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /** * 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。 */ +@Mapper public interface AuthAccountMapper extends BaseMapper { } diff --git a/auth/src/main/java/com/baiyue/auth/mapper/AuthRefreshTokenMapper.java b/auth/src/main/java/com/baiyue/auth/mapper/AuthRefreshTokenMapper.java index 7704add04..20b5a3647 100644 --- a/auth/src/main/java/com/baiyue/auth/mapper/AuthRefreshTokenMapper.java +++ b/auth/src/main/java/com/baiyue/auth/mapper/AuthRefreshTokenMapper.java @@ -1,9 +1,12 @@ package com.baiyue.auth.mapper; +import org.apache.ibatis.annotations.Mapper; + import com.baiyue.auth.entity.AuthRefreshToken; import com.baomidou.mybatisplus.core.mapper.BaseMapper; /** * 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。 */ +@Mapper public interface AuthRefreshTokenMapper extends BaseMapper { } diff --git a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java index 31551dda9..636a1efe0 100644 --- a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java +++ b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java @@ -1,20 +1,20 @@ package com.baiyue.auth.repository; +import jakarta.annotation.Resource; + import com.baiyue.auth.entity.AuthAccount; import com.baiyue.auth.mapper.AuthAccountMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import org.springframework.stereotype.Repository; +import java.util.List; /** * 认证账号仓储,封装按用户名或主键查询以及账号保存操作,隔离服务层与 MyBatis-Plus 查询细节。 */ @Repository public class AuthAccountRepository { - private final AuthAccountMapper mapper; - - public AuthAccountRepository(AuthAccountMapper mapper) { - this.mapper = mapper; - } + @Resource + private AuthAccountMapper mapper; public AuthAccount findByUsername(String username) { return mapper.selectOne(new LambdaQueryWrapper() @@ -22,17 +22,46 @@ public class AuthAccountRepository { .last("LIMIT 1")); } + public AuthAccount findByPhone(String phone) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(AuthAccount::getPhone, phone) + .last("LIMIT 1")); + } + public boolean existsByUsername(String username) { return mapper.selectCount(new LambdaQueryWrapper() .eq(AuthAccount::getUsername, username)) > 0; } + public boolean existsByPhone(String phone) { + return mapper.selectCount(new LambdaQueryWrapper() + .eq(AuthAccount::getPhone, phone)) > 0; + } + + public boolean existsByUsernameForOtherAccount(String username, Long accountId) { + return mapper.selectCount(new LambdaQueryWrapper() + .eq(AuthAccount::getUsername, username) + .ne(AuthAccount::getId, accountId)) > 0; + } + + public boolean existsByPhoneForOtherAccount(String phone, Long accountId) { + return mapper.selectCount(new LambdaQueryWrapper() + .eq(AuthAccount::getPhone, phone) + .ne(AuthAccount::getId, accountId)) > 0; + } + public void insert(AuthAccount account) { mapper.insert(account); } public AuthAccount findById(Long id) { return mapper.selectById(id); } + public List findMerchantByTenant(Long tenantId) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(AuthAccount::getDefaultTenantId, tenantId) + .eq(AuthAccount::getDefaultAppCode, "MERCHANT")); + } + public void save(AuthAccount account) { mapper.updateById(account); } diff --git a/auth/src/main/java/com/baiyue/auth/repository/AuthRefreshTokenRepository.java b/auth/src/main/java/com/baiyue/auth/repository/AuthRefreshTokenRepository.java index 8f93c0d71..1e0096883 100644 --- a/auth/src/main/java/com/baiyue/auth/repository/AuthRefreshTokenRepository.java +++ b/auth/src/main/java/com/baiyue/auth/repository/AuthRefreshTokenRepository.java @@ -1,5 +1,7 @@ package com.baiyue.auth.repository; +import jakarta.annotation.Resource; + import com.baiyue.auth.entity.AuthRefreshToken; import com.baiyue.auth.mapper.AuthRefreshTokenMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -10,11 +12,8 @@ import org.springframework.stereotype.Repository; */ @Repository public class AuthRefreshTokenRepository { - private final AuthRefreshTokenMapper mapper; - - public AuthRefreshTokenRepository(AuthRefreshTokenMapper mapper) { - this.mapper = mapper; - } + @Resource + private AuthRefreshTokenMapper mapper; public AuthRefreshToken findByHash(String tokenHash, boolean forUpdate) { String limit = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1"; diff --git a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java index 8b0426157..6c64184e1 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java @@ -11,6 +11,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.baiyue.common.audit.BusinessAudit; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + /** 账号生命周期管理,所有密码只以 BCrypt 摘要写入数据库。 */ @Service public class AccountManagementService { @@ -18,38 +22,71 @@ public class AccountManagementService { @Resource private PasswordEncoder passwordEncoder; @Resource private TokenSessionStore tokenSessionStore; + public boolean usernameAvailable(String username) { + return username != null && !username.isBlank() && !repository.existsByUsername(username.trim()); + } + @Transactional @BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false) public void changePassword(Long accountId, ChangePasswordRequest request) { AuthAccount account = required(accountId); + if (Boolean.TRUE.equals(account.getPasswordChangeRequired())) { + throw new BizException(ResultCode.CONFLICT, "请先使用强制改密接口设置新密码"); + } if (!passwordEncoder.matches(request.oldPassword(), account.getPasswordHash())) { throw new BizException(ResultCode.BAD_REQUEST, "原密码错误"); } if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) { throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与原密码相同"); } - setPasswordAndRevoke(account, request.newPassword()); + setPasswordAndRevoke(account, request.newPassword(), false); + } + + @Transactional + @BusinessAudit(event = "PASSWORD_FORCED_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", + description = "完成管理员重置后的强制改密", captureAfter = false) + public void forcedChangePassword(Long accountId, ForcedPasswordChangeRequest request) { + AuthAccount account = required(accountId); + if (!Boolean.TRUE.equals(account.getPasswordChangeRequired())) { + throw new BizException(ResultCode.CONFLICT, "当前账号不需要强制改密"); + } + if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) { + throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与当前密码相同"); + } + setPasswordAndRevoke(account, request.newPassword(), false); } @Transactional @BusinessAudit(event = "ACCOUNT_CREATE", resourceType = "ACCOUNT", resourceId = "#request.accountId", description = "创建认证账号") public AccountSummary create(CreateAccountRequest request) { - if (repository.existsByUsername(request.username()) || repository.findById(request.accountId()) != null) { + String appCode = request.appCode() == null ? "MERCHANT" + : request.appCode().trim().toUpperCase(Locale.ROOT); + validateScope(appCode, request.tenantId()); + AuthAccount existing = repository.findById(request.accountId()); + if (existing != null) { + if (sameIdentity(existing, request, appCode)) return summary(existing); + throw new BizException(ResultCode.CONFLICT, "认证账号标识已被其他成员使用"); + } + if (repository.existsByUsername(request.username())) { throw new BizException(ResultCode.CONFLICT, "登录账号已存在"); } + if (request.phone() != null && repository.existsByPhone(request.phone())) { + throw new BizException(ResultCode.CONFLICT, "手机号已存在"); + } AuthAccount account = new AuthAccount(); account.setId(request.accountId()); account.setUsername(request.username()); + account.setPhone(request.phone()); account.setPasswordHash(passwordEncoder.encode(request.password())); account.setDisplayName(request.displayName()); - account.setAccountStatus("ACTIVE"); + account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus()); account.setFailedLoginCount(0); account.setTokenVersion(1); account.setDefaultTenantId(request.tenantId()); account.setOperatorTenantId(request.tenantId()); account.setDefaultMembershipId(request.membershipId()); - account.setDefaultAppCode("MERCHANT"); + account.setDefaultAppCode(appCode); account.setDeleted(0); repository.insert(account); return summary(account); @@ -66,10 +103,94 @@ public class AccountManagementService { bumpVersion(account); } + @Transactional + @BusinessAudit(event = "ACCOUNT_SESSIONS_REVOKE", resourceType = "ACCOUNT_BATCH", + resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话", + captureBefore = false, captureAfter = false) + public void revokeSessions(Set accountIds) { + accountIds.forEach(accountId -> bumpVersion(required(accountId))); + } + + @Transactional + @BusinessAudit(event = "ACCOUNT_PROFILE_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", + description = "同步账号显示名称", captureAfter = false) + public void updateProfile(Long accountId, UpdateAccountProfileRequest request) { + AuthAccount account = required(accountId); + if (Objects.equals(account.getDisplayName(), request.displayName())) return; + account.setDisplayName(request.displayName()); + bumpVersion(account); + } + + @Transactional + @BusinessAudit(event = "ACCOUNT_LOGIN_IDENTIFIERS_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", + description = "修改本人用户名和手机号", captureAfter = false) + public AccountSummary changeLoginIdentifiers(Long accountId, ChangeLoginIdentifiersRequest request) { + AuthAccount account = required(accountId); + if (!passwordEncoder.matches(request.currentPassword(), account.getPasswordHash())) { + throw new BizException(ResultCode.UNAUTHORIZED, "当前密码错误"); + } + String username = request.username().trim(); + String phone = request.phone().trim(); + if (repository.existsByUsernameForOtherAccount(username, accountId)) { + throw new BizException(ResultCode.CONFLICT, "用户名已存在"); + } + if (repository.existsByPhoneForOtherAccount(phone, accountId)) { + throw new BizException(ResultCode.CONFLICT, "手机号已存在"); + } + if (Objects.equals(account.getUsername(), username) && Objects.equals(account.getPhone(), phone)) { + return summary(account); + } + account.setUsername(username); + account.setPhone(phone); + bumpVersion(account); + return summary(account); + } + + @Transactional + @BusinessAudit(event = "ACCOUNT_PHONE_SYNC", resourceType = "ACCOUNT", resourceId = "#accountId", + description = "同步受管账号手机号", captureAfter = false) + public void updatePhone(Long accountId, UpdateAccountPhoneRequest request) { + AuthAccount account = required(accountId); + String phone = request.phone() == null ? null : request.phone().trim(); + if (Objects.equals(account.getPhone(), phone)) return; + if (phone != null && repository.existsByPhoneForOtherAccount(phone, accountId)) { + throw new BizException(ResultCode.CONFLICT, "手机号已存在"); + } + account.setPhone(phone); + bumpVersion(account); + } + @Transactional @BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false) - public void resetPassword(Long accountId, ResetPasswordRequest request) { - setPasswordAndRevoke(required(accountId), request.newPassword()); + public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) { + setPasswordAndRevoke(required(accountId), request.newPassword(), forceChangePassword); + } + + @Transactional + @BusinessAudit(event = "CUSTOMER_ACCOUNT_ARCHIVE", resourceType = "TENANT", resourceId = "#tenantId", + description = "归档客户并撤销客户账号", captureBefore = false, captureAfter = false) + public void archiveTenant(Long tenantId) { + for (AuthAccount account : repository.findMerchantByTenant(tenantId)) { + account.setAccountStatus("DISABLED"); + account.setPasswordChangeRequired(false); + account.setTokenVersion((account.getTokenVersion() == null ? 0 : account.getTokenVersion()) + 1); + repository.save(account); + tokenSessionStore.revokeAll(account.getId()); + } + } + + @Transactional + @BusinessAudit(event = "ACCOUNT_SCOPE_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", + description = "修改账号所属租户", captureAfter = false) + public void updateScope(Long accountId, UpdateAccountScopeRequest request) { + String appCode = request.appCode().trim().toUpperCase(Locale.ROOT); + validateScope(appCode, request.tenantId()); + AuthAccount account = required(accountId); + account.setDefaultTenantId(request.tenantId()); + account.setOperatorTenantId(request.tenantId()); + account.setDefaultMembershipId(request.membershipId()); + account.setDefaultAppCode(appCode); + bumpVersion(account); } @Transactional @@ -86,10 +207,11 @@ public class AccountManagementService { public AccountSummary summary(Long accountId) { return summary(required(accountId)); } - private void setPasswordAndRevoke(AuthAccount account, String password) { + private void setPasswordAndRevoke(AuthAccount account, String password, boolean forceChangePassword) { account.setPasswordHash(passwordEncoder.encode(password)); account.setFailedLoginCount(0); account.setLockedUntil(null); + account.setPasswordChangeRequired(forceChangePassword); bumpVersion(account); } @@ -106,6 +228,27 @@ public class AccountManagementService { } private AccountSummary summary(AuthAccount a) { - return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus()); + return new AccountSummary(a.getId(), a.getUsername(), a.getPhone(), a.getDisplayName(), a.getAccountStatus(), + a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode()); + } + + private void validateScope(String appCode, Long tenantId) { + if (!"MERCHANT".equals(appCode) && !"PLATFORM".equals(appCode)) { + throw new BizException(ResultCode.BAD_REQUEST, "应用端只能是 MERCHANT 或 PLATFORM"); + } + if (("PLATFORM".equals(appCode) && tenantId != 0L) + || ("MERCHANT".equals(appCode) && tenantId <= 0L)) { + throw new BizException(ResultCode.BAD_REQUEST, "账号应用端与租户范围不匹配"); + } + } + + private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) { + return Objects.equals(account.getUsername(), request.username()) + && Objects.equals(account.getPhone(), request.phone()) + && Objects.equals(account.getDefaultTenantId(), request.tenantId()) + && Objects.equals(account.getOperatorTenantId(), request.tenantId()) + && Objects.equals(account.getDefaultMembershipId(), request.membershipId()) + && Objects.equals(account.getDefaultAppCode(), appCode) + && !Integer.valueOf(1).equals(account.getDeleted()); } } 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 9fb486383..6ff1a3fa6 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -7,6 +7,8 @@ import com.baiyue.auth.entity.AuthAccount; import com.baiyue.auth.repository.AuthAccountRepository; import com.baiyue.auth.dto.LoginRequest; import com.baiyue.auth.dto.TokenResponse; +import com.baiyue.auth.dto.AccountManagementDtos.ImpersonationTicketRequest; +import com.baiyue.auth.dto.AccountManagementDtos.ImpersonationTicketResponse; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -21,15 +23,18 @@ 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; import java.time.LocalDateTime; import java.util.Base64; import java.util.UUID; +import java.time.Duration; +import java.util.List; /** - * 统一认证应用服务,编排密码校验、IAM 权限快照加载、JWT 签发、刷新令牌轮换与注销事务。 + * 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。 */ @Service public class AuthService { @@ -45,15 +50,20 @@ public class AuthService { private AuthorizationLoader authorizationLoader; @Resource private TokenSessionStore tokenSessionStore; + @Resource + private ObjectProvider overseasTokens; private final SecureRandom secureRandom = new SecureRandom(); @Transactional - @BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.username", + @BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.loginId", description = "User login / 用户登录", captureAfter = false) public TokenResponse login(LoginRequest request) { - AuthAccount account = accountRepository.findByUsername(request.username()); + String loginId = request.loginId().trim(); + AuthAccount account = loginId.matches("^1[3-9]\\d{9}$") + ? accountRepository.findByPhone(loginId) + : accountRepository.findByUsername(loginId); if (account == null) throw invalidCredentials(); LocalDateTime now = LocalDateTime.now(); if (!"ACTIVE".equals(account.getAccountStatus())) @@ -104,34 +114,122 @@ public class AuthService { Instant issuedAt = Instant.now(); String jwtId = UUID.randomUUID().toString(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); - JwtClaimsSet claims = JwtClaimsSet.builder() - .issuer(properties.issuer()).id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) + JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() + .issuer(properties.issuer()).audience(List.of(properties.audience())) + .id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) .subject(account.getId().toString()) .claim("user_id", account.getId()) .claim("username", account.getUsername()) + .claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName()) .claim("tenant_id", account.getDefaultTenantId()) .claim("operator_tenant_id", account.getOperatorTenantId()) .claim("membership_id", account.getDefaultMembershipId()) .claim("app_code", account.getDefaultAppCode()) .claim("client_type", authorization.getClientType()) - .claim("roles", authorization.getRoles()) - .claim("permissions", authorization.getPermissions()) .claim("token_version", account.getTokenVersion()) - .build(); + .claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())) + .claim("userId", account.getId()) + .claim("tenantId", account.getDefaultTenantId()) + .claim("appCode", account.getDefaultAppCode()) + .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( - JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue(); + signingHeader(), claims)).getTokenValue(); byte[] random = new byte[48]; secureRandom.nextBytes(random); String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); - tokenSessionStore.store(jwtId, account.getId(), rawRefresh, + 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(), + authorization.getRoles(), authorization.getPermissions()); + } + + @Transactional + @BusinessAudit(event = "CUSTOMER_IMPERSONATION_TICKET_CREATE", resourceType = "IMPERSONATION", + resourceId = "#request.tenantId", description = "创建客户代登录授权码", captureBefore = false, captureAfter = false) + public ImpersonationTicketResponse createImpersonationTicket(ImpersonationTicketRequest request) { + AuthAccount account = accountRepository.findById(request.targetAuthUserId()); + if (account == null || !"ACTIVE".equals(account.getAccountStatus()) + || !"MERCHANT".equals(account.getDefaultAppCode()) + || !request.tenantId().equals(account.getDefaultTenantId()) + || !request.membershipId().equals(account.getDefaultMembershipId())) { + throw new BizException(ResultCode.CONFLICT, "目标客户管理员账号不可代登录"); + } + byte[] bytes = new byte[48]; + secureRandom.nextBytes(bytes); + String code = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + String ticketId = UUID.randomUUID().toString(); + tokenSessionStore.storeImpersonationTicket(code, + new TokenSessionStore.ImpersonationTicket(ticketId, account.getId(), request.tenantId(), + request.membershipId(), request.actorUserId(), request.actorMembershipId(), request.reason()), + Duration.ofSeconds(60)); + return new ImpersonationTicketResponse(code, ticketId, 60); + } + + @Transactional + @BusinessAudit(event = "CUSTOMER_IMPERSONATION_EXCHANGE", resourceType = "IMPERSONATION", + resourceId = "'IMPERSONATION_EXCHANGE'", description = "兑换客户代登录授权码", captureBefore = false, captureAfter = false) + public ImpersonationTokenResponse exchangeImpersonationTicket(String code) { + TokenSessionStore.ImpersonationTicket ticket = tokenSessionStore.consumeImpersonationTicket(code); + if (ticket == null) throw new BizException(ResultCode.UNAUTHORIZED, "代登录授权码无效或已过期"); + AuthAccount account = accountRepository.findById(ticket.targetAuthUserId()); + if (account == null || !"ACTIVE".equals(account.getAccountStatus())) + throw new BizException(ResultCode.UNAUTHORIZED, "目标客户账号不可用"); + AuthorizationSnapshot authorization = authorizationLoader.load(account); + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(Duration.ofMinutes(15)); + String jwtId = UUID.randomUUID().toString(); + JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer()) + .audience(List.of(properties.audience())).id(jwtId) + .issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString()) + .claim("user_id", account.getId()).claim("username", account.getUsername()).claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName()) + .claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId()) + .claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT") + .claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion()) + .claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId()) + .claim("appCode", "MERCHANT") + .claim("permissionVersion", account.getTokenVersion()) + .claim("impersonated", true).claim("impersonation_id", ticket.ticketId()) + .claim("actor_user_id", ticket.actorUserId()).claim("actor_membership_id", ticket.actorMembershipId()) + .claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build(); + String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue(); + tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(), + authorization.getPermissions(), Duration.ofMinutes(15)); + OverseasTokenService service = overseasTokens.getIfAvailable(); + OverseasTokenService.OverseasTokenResponse overseas = service == null + ? null : service.issue(account, authorization); + return new ImpersonationTokenResponse("Bearer", accessToken, 900, + overseas == null ? null : overseas.accessToken(), + overseas == null ? 0L : overseas.expiresIn(), + authorization.getRoles(), authorization.getPermissions()); + } + + public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn, + String overseasAccessToken, long overseasExpiresIn, + java.util.Set roles, + java.util.Set authorities) { + public ImpersonationTokenResponse { + roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles); + authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities); + } + } + + private JwsHeader signingHeader() { + return JwsHeader.with(MacAlgorithm.HS256).build(); } private static BizException invalidCredentials() { - return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误"); + return new BizException(ResultCode.UNAUTHORIZED, "登录标识或密码错误"); } } 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 1b9ff3769..e17c5410e 100644 --- a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java +++ b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java @@ -11,13 +11,21 @@ import com.baiyue.common.audit.BusinessAudit; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import feign.FeignException; import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.annotation.Transactional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.atomic.AtomicBoolean; @Service public class MerchantRegistrationService { + private static final Logger LOGGER = LoggerFactory.getLogger(MerchantRegistrationService.class); @Resource private AuthAccountRepository accountRepository; @Resource private PasswordEncoder passwordEncoder; @Resource private IamOnboardingClient onboardingClient; @@ -27,29 +35,66 @@ public class MerchantRegistrationService { @BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT", resourceId = "#request.username", description = "商户注册", captureAfter = false) public MerchantRegisterResponse register(MerchantRegisterRequest request) { - if (accountRepository.existsByUsername(request.username())) { - throw new BizException(ResultCode.CONFLICT, "Username already exists"); + AuthAccount usernameAccount = accountRepository.findByUsername(request.username()); + AuthAccount phoneAccount = accountRepository.findByPhone(request.phone()); + if (usernameAccount != null && phoneAccount != null + && usernameAccount.getId().equals(phoneAccount.getId()) + && "MERCHANT".equals(usernameAccount.getDefaultAppCode()) + && "ACTIVE".equals(usernameAccount.getAccountStatus()) + && passwordEncoder.matches(request.password(), usernameAccount.getPasswordHash())) { + return existingRegistration(usernameAccount); + } + if (usernameAccount != null) { + throw new BizException(ResultCode.CONFLICT, "用户名已存在"); + } + if (phoneAccount != null) { + throw new BizException(ResultCode.CONFLICT, "该手机号已注册,请直接登录后继续完善企业资质"); } long accountId = IdWorker.getId(); + String passwordHash = passwordEncoder.encode(request.password()); + AtomicBoolean compensationCompleted = new AtomicBoolean(false); + registerRollbackCompensation(accountId, compensationCompleted); IamOnboardingClient.OnboardingResponse onboarding; try { var response = onboardingClient.register(internalToken, new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(), - request.companyName(), request.legalPerson(), request.legalPhone(), + request.companyName(), request.legalPerson(), request.phone(), request.contactEmail(), request.countryCode(), request.address())); - if (response == null || !response.successful() || response.data() == null) { + if (response == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed"); + } + if (!response.successful()) { + ResultCode code = ResultCode.of(response.code()); + String message = response.message() == null || response.message().isBlank() + ? code.message() : response.message(); + throw new BizException(code, message); + } + if (response.data() == null) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed"); } onboarding = response.data(); } catch (FeignException exception) { - if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "Enterprise already exists"); - throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration service is unavailable"); + BizException failure = new BizException(ResultCode.SERVICE_UNAVAILABLE, + "IAM registration service is unavailable"); + failure.addSuppressed(exception); + compensateOnboarding(accountId, failure, compensationCompleted); + throw failure; + } catch (BizException exception) { + compensateOnboarding(accountId, exception, compensationCompleted); + throw exception; + } catch (RuntimeException exception) { + BizException failure = new BizException(ResultCode.SERVICE_UNAVAILABLE, + "IAM registration service is unavailable"); + failure.addSuppressed(exception); + compensateOnboarding(accountId, failure, compensationCompleted); + throw failure; } AuthAccount account = new AuthAccount(); account.setId(accountId); account.setUsername(request.username()); - account.setPasswordHash(passwordEncoder.encode(request.password())); + account.setPhone(request.phone()); + account.setPasswordHash(passwordHash); account.setDisplayName(request.displayName()); account.setAccountStatus("ACTIVE"); account.setFailedLoginCount(0); @@ -59,8 +104,80 @@ public class MerchantRegistrationService { account.setDefaultMembershipId(onboarding.membershipId()); account.setDefaultAppCode("MERCHANT"); account.setDeleted(0); - accountRepository.insert(account); + try { + accountRepository.insert(account); + } catch (DuplicateKeyException exception) { + BizException failure = new BizException(ResultCode.CONFLICT, "用户名或手机号已存在"); + failure.addSuppressed(exception); + compensateOnboarding(accountId, failure, compensationCompleted); + throw failure; + } catch (RuntimeException exception) { + compensateOnboarding(accountId, exception, compensationCompleted); + throw exception; + } return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(), - onboarding.tenantStatus(), "LOGIN_AND_UPLOAD_BUSINESS_LICENSE"); + onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(), + "LOGIN_AND_COMPLETE_QUALIFICATIONS"); + } + + private MerchantRegisterResponse existingRegistration(AuthAccount account) { + try { + var response = onboardingClient.registration(internalToken, account.getId()); + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质"); + } + var onboarding = response.data(); + if (!java.util.Set.of("PROFILE_REQUIRED", "REJECTED").contains(onboarding.tenantStatus())) { + throw new BizException(ResultCode.CONFLICT, "该账号已注册,请直接登录"); + } + return new MerchantRegisterResponse(account.getId(), onboarding.tenantId(), onboarding.membershipId(), + onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(), + "LOGIN_AND_COMPLETE_QUALIFICATIONS"); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质"); + } + } + + private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status == STATUS_COMMITTED || compensationCompleted.get()) { + return; + } + IllegalStateException rollbackFailure = new IllegalStateException( + "Auth transaction rolled back after IAM onboarding"); + if (!compensateOnboarding(accountId, rollbackFailure, compensationCompleted)) { + LOGGER.error("IAM registration compensation failed after Auth rollback, authUserId={}", + accountId, rollbackFailure); + } + } + }); + } + + private boolean compensateOnboarding(long accountId, RuntimeException registrationFailure, + AtomicBoolean compensationCompleted) { + if (compensationCompleted.get()) { + return true; + } + try { + var response = onboardingClient.compensate(internalToken, accountId); + if (response != null && response.successful()) { + compensationCompleted.set(true); + return true; + } + { + String code = response == null ? "empty-response" : response.code(); + String message = response == null ? "IAM compensation returned no response" : response.message(); + registrationFailure.addSuppressed(new IllegalStateException( + "IAM registration compensation failed: code=" + code + ", message=" + message)); + } + } catch (RuntimeException compensationFailure) { + registrationFailure.addSuppressed(compensationFailure); + } + return false; } } 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..5c90d612e --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -0,0 +1,71 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.client.AuthorizationSnapshot; +import com.baiyue.auth.config.OverseasJwtProperties; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */ +@Service +@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") +public class OverseasTokenService { + @Resource private AuthAccountRepository accounts; + @Resource private AuthorizationLoader authorizationLoader; + @Resource private OverseasJwtProperties properties; + @Resource @Qualifier("overseasJwtEncoder") private JwtEncoder encoder; + + public OverseasTokenResponse exchange(long authenticatedUserId) { + AuthAccount account = accounts.findById(authenticatedUserId); + if (account == null || !"ACTIVE".equals(account.getAccountStatus())) { + throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用"); + } + AuthorizationSnapshot authorization = authorizationLoader.load(account); + return issue(account, authorization); + } + + public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) { + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); + JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() + .issuer(properties.issuer()).audience(List.of(properties.audience())) + .subject(account.getId().toString()).id(UUID.randomUUID().toString()) + .issuedAt(issuedAt).expiresAt(expiresAt) + .claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId()) + .claim("membership_id", authorization.getMembershipId()) + .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) + .claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion()) + .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()); + if (authorization.getOverseasWarehouseId() != null) { + claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); + } + JwtClaimsSet claims = claimsBuilder.build(); + String token = encoder.encode(JwtEncoderParameters.from( + JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue(); + return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds(), + authorization.getRoles(), authorization.getPermissions()); + } + + public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn, + java.util.Set roles, java.util.Set authorities) { + public OverseasTokenResponse { + roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles); + authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities); + } + } +} diff --git a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java index 2a7a75953..fbfefb41b 100644 --- a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java +++ b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java @@ -1,10 +1,14 @@ package com.baiyue.auth.service; +import jakarta.annotation.Resource; +import jakarta.annotation.PostConstruct; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.RedisTokenKeys; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.SetOperations; @@ -25,24 +29,27 @@ import java.util.List; */ @Component public class TokenSessionStore { - private final StringRedisTemplate redisTemplate; - private final ObjectMapper objectMapper; - private final String keyPrefix; + @Autowired + private StringRedisTemplate redisTemplate; + @Resource + private ObjectMapper objectMapper; + @Value("${baiyue.security.token-key-prefix:baiyue:auth}") + private String keyPrefix; - public TokenSessionStore(StringRedisTemplate redisTemplate, ObjectMapper objectMapper, - @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) { - this.redisTemplate = redisTemplate; - this.objectMapper = objectMapper; - this.keyPrefix = RedisTokenKeys.normalize(keyPrefix); + @PostConstruct + void normalizeKeyPrefix() { + keyPrefix = RedisTokenKeys.normalize(keyPrefix); } - public void store(String jwtId, Long userId, String rawRefreshToken, - Duration accessTtl, Duration refreshTtl) { + public void store(String jwtId, Long userId, Set roles, Set permissions, + String rawRefreshToken, Duration accessTtl, Duration refreshTtl) { String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken)); String userKey = userSessionsKey(userId); try { - redisTemplate.opsForValue().set(accessKey, userId.toString(), accessTtl); + String accessValue = objectMapper.writeValueAsString( + new AccessSession(1, userId, normalized(roles), normalized(permissions))); + redisTemplate.opsForValue().set(accessKey, accessValue, accessTtl); String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId)); redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl); SetOperations sets = redisTemplate.opsForSet(); @@ -54,6 +61,41 @@ public class TokenSessionStore { } } + public void storeAccessOnly(String jwtId, Long userId, Set roles, + Set permissions, Duration ttl) { + try { + String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); + String accessValue = objectMapper.writeValueAsString( + new AccessSession(1, userId, normalized(roles), normalized(permissions))); + redisTemplate.opsForValue().set(accessKey, accessValue, ttl); + redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey); + redisTemplate.expire(userSessionsKey(userId), ttl); + } catch (Exception exception) { + throw unavailable(exception); + } + } + + public void storeImpersonationTicket(String rawCode, ImpersonationTicket ticket, Duration ttl) { + try { + redisTemplate.opsForValue().set(keyPrefix + ":impersonation:" + sha256(rawCode), + objectMapper.writeValueAsString(ticket), ttl); + } catch (Exception exception) { + throw unavailable(exception); + } + } + + public ImpersonationTicket consumeImpersonationTicket(String rawCode) { + try { + String value = redisTemplate.opsForValue().getAndDelete(keyPrefix + ":impersonation:" + sha256(rawCode)); + if (value == null || value.isBlank()) return null; + return objectMapper.readValue(value, ImpersonationTicket.class); + } catch (DataAccessException exception) { + throw unavailable(exception); + } catch (Exception exception) { + throw new BizException(ResultCode.UNAUTHORIZED, "代登录授权码无效或已过期"); + } + } + public RefreshSession consume(String rawRefreshToken) { try { String value = redisTemplate.opsForValue().getAndDelete( @@ -97,7 +139,7 @@ public class TokenSessionStore { while (cursor.hasNext()) { String key = cursor.next(); String value = redisTemplate.opsForValue().get(key); - if ((!refresh && userId.toString().equals(value)) || (refresh && belongsTo(value, userId))) { + if ((!refresh && accessBelongsTo(value, userId)) || (refresh && belongsTo(value, userId))) { result.add(key); } } @@ -112,6 +154,16 @@ public class TokenSessionStore { catch (Exception ignored) { return false; } } + private boolean accessBelongsTo(String value, Long userId) { + if (value == null || value.isBlank()) return false; + try { return userId.equals(objectMapper.readValue(value, AccessSession.class).userId()); } + catch (Exception ignored) { return false; } + } + + private static Set normalized(Set values) { + return values == null ? Set.of() : Set.copyOf(values); + } + private String userSessionsKey(Long userId) { return keyPrefix + ":user-sessions:" + userId; } @@ -131,4 +183,13 @@ public class TokenSessionStore { } public record RefreshSession(Long userId, String accessJwtId) { } + public record AccessSession(int version, Long userId, Set roles, Set permissions) { + public AccessSession { + roles = normalized(roles); + permissions = normalized(permissions); + } + } + public record ImpersonationTicket(String ticketId, Long targetAuthUserId, Long tenantId, + Long membershipId, Long actorUserId, Long actorMembershipId, + String reason) { } } diff --git a/auth/src/main/resources/application-dev.yml b/auth/src/main/resources/application-dev.yml new file mode 100644 index 000000000..8174995ba --- /dev/null +++ b/auth/src/main/resources/application-dev.yml @@ -0,0 +1,6 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${AUTH_DB_USERNAME:root} + password: ${AUTH_DB_PASSWORD:123456} diff --git a/auth/src/main/resources/application-test.yml b/auth/src/main/resources/application-test.yml new file mode 100644 index 000000000..07c7f96b5 --- /dev/null +++ b/auth/src/main/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + cloud: + nacos: + config: + namespace: ${NACOS_NAMESPACE:test} + group: ${NACOS_GROUP:BAIYUE} + encode: UTF-8 + discovery: + namespace: ${NACOS_NAMESPACE:test} + group: ${NACOS_GROUP:BAIYUE} + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${AUTH_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} + username: ${AUTH_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} + password: ${AUTH_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml index 162844ec6..fd346398e 100644 --- a/auth/src/main/resources/application.yml +++ b/auth/src/main/resources/application.yml @@ -1,6 +1,8 @@ server: port: ${AUTH_PORT:9100} spring: + profiles: + active: ${SPRING_PROFILES_ACTIVE:dev} application: name: auth-service config: @@ -24,22 +26,26 @@ spring: 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: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${AUTH_DB_USERNAME:root} - password: ${AUTH_DB_PASSWORD:123456} - + config: + namespace: ${NACOS_NAMESPACE:public} + encode: UTF-8 baiyue.security: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} issuer: ${JWT_ISSUER:auth-service} - access-token-ttl: ${ACCESS_TOKEN_TTL:30m} + audience: ${JWT_AUDIENCE:lastleg-api} + access-token-ttl: ${ACCESS_TOKEN_TTL:24m} 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/audit-outbox.sql b/auth/src/main/resources/db/audit-outbox.sql new file mode 100644 index 000000000..580e2ce7a --- /dev/null +++ b/auth/src/main/resources/db/audit-outbox.sql @@ -0,0 +1,25 @@ +USE baiyue_auth; + +-- 可靠审计本地 Outbox;与认证业务表位于同一数据库。 +CREATE TABLE IF NOT EXISTS sys_audit_outbox ( + event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID', + event_type VARCHAR(64) NOT NULL COMMENT '审计事件类型', + resource_type VARCHAR(64) NOT NULL COMMENT '资源类型', + resource_id VARCHAR(128) NULL COMMENT '资源ID', + service_name VARCHAR(64) 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 '当前周期发送次数', + compensation_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 '最近一次发送错误信息', + dead_at DATETIME(3) 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_audit_outbox_publish (status, next_retry_at, lease_until, created_at), + KEY idx_audit_outbox_retention (status, sent_at) +) ENGINE=InnoDB COMMENT='可靠审计事件本地发送箱'; diff --git a/auth/src/main/resources/db/auth-login-identifiers.sql b/auth/src/main/resources/db/auth-login-identifiers.sql new file mode 100644 index 000000000..f5161b038 --- /dev/null +++ b/auth/src/main/resources/db/auth-login-identifiers.sql @@ -0,0 +1,46 @@ +-- Auth 单一登录标识增量:用户名或中国大陆手机号。 +-- 可重复执行;手机号仅从 IAM 当前用户档案回填,无法确认的账号保持 NULL。 +USE baiyue_auth; + +DROP PROCEDURE IF EXISTS auth_add_login_phone; +DELIMITER $$ +CREATE PROCEDURE auth_add_login_phone() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account' AND column_name = 'phone') THEN + ALTER TABLE auth_account ADD COLUMN phone VARCHAR(32) NULL COMMENT '全局唯一中国大陆登录手机号' AFTER username; + END IF; +END$$ +DELIMITER ; +CALL auth_add_login_phone(); +DROP PROCEDURE auth_add_login_phone; + +DROP PROCEDURE IF EXISTS auth_backfill_login_phone; +DELIMITER $$ +CREATE PROCEDURE auth_backfill_login_phone() +BEGIN + UPDATE auth_account + SET phone = NULL + WHERE phone IS NOT NULL AND phone NOT REGEXP '^1[3-9][0-9]{9}$'; + UPDATE auth_account account + JOIN baiyue_iam.iam_user user ON user.auth_user_id = account.id AND user.deleted = 0 + JOIN ( + SELECT phone + FROM baiyue_iam.iam_user + WHERE deleted = 0 AND phone REGEXP '^1[3-9][0-9]{9}$' + GROUP BY phone + HAVING COUNT(DISTINCT auth_user_id) = 1 + ) unique_phone ON unique_phone.phone = user.phone + SET account.phone = user.phone + WHERE account.phone IS NULL AND user.phone REGEXP '^1[3-9][0-9]{9}$'; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account' + AND index_name = 'uk_auth_account_phone') THEN + ALTER TABLE auth_account ADD UNIQUE KEY uk_auth_account_phone (phone); + END IF; +END$$ +DELIMITER ; +CALL auth_backfill_login_phone(); +DROP PROCEDURE auth_backfill_login_phone; diff --git a/auth/src/main/resources/db/auth.sql b/auth/src/main/resources/db/auth.sql index 594484e66..13659a4a3 100644 --- a/auth/src/main/resources/db/auth.sql +++ b/auth/src/main/resources/db/auth.sql @@ -6,22 +6,25 @@ USE baiyue_auth; CREATE TABLE IF NOT EXISTS auth_account ( id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)', username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名', + phone VARCHAR(32) DEFAULT NULL COMMENT '全局唯一中国大陆登录手机号', password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码', display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称', - account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE、DISABLED、LOCKED', + account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定', failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数', locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间', token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效', - default_tenant_id BIGINT NOT NULL COMMENT '默认业务租户ID', - operator_tenant_id BIGINT NOT NULL COMMENT '操作主体租户ID,平台端与商户端身份隔离', - default_membership_id BIGINT DEFAULT NULL COMMENT '默认租户成员关系ID', - default_app_code VARCHAR(20) NOT NULL COMMENT '默认应用端:MERCHANT或PLATFORM', + password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT '管理员重置后是否强制下次登录改密', + default_tenant_id BIGINT NOT NULL COMMENT '默认业务作用域ID:0为平台,正数为客户ID', + operator_tenant_id BIGINT NOT NULL COMMENT '操作主体作用域ID:0为平台,正数为客户ID,用于平台端与客户端身份隔离', + default_membership_id BIGINT DEFAULT NULL COMMENT '默认客户成员关系ID;平台账号可为空', + default_app_code VARCHAR(20) NOT NULL COMMENT '默认应用端:MERCHANT企业客户端、PLATFORM平台后台', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', last_login_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_auth_account_username (username), + UNIQUE KEY uk_auth_account_phone (phone), KEY idx_auth_account_status (account_status, locked_until) ) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态'; @@ -29,9 +32,9 @@ CREATE TABLE IF NOT EXISTS auth_refresh_token ( id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)', token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文', user_id BIGINT NOT NULL COMMENT '认证账号ID', - tenant_id BIGINT NOT NULL COMMENT '令牌当前业务租户ID', - operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体租户ID', - app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端', + tenant_id BIGINT NOT NULL COMMENT '令牌当前业务作用域ID:0为平台,正数为客户ID', + operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体作用域ID:0为平台,正数为客户ID', + app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端:MERCHANT企业客户端、PLATFORM平台后台', expires_at DATETIME(3) NOT NULL COMMENT '到期时间', revoked_at DATETIME(3) DEFAULT NULL COMMENT '吊销时间,非空表示不可继续使用', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '签发时间', @@ -39,6 +42,6 @@ CREATE TABLE IF NOT EXISTS auth_refresh_token ( UNIQUE KEY uk_auth_refresh_hash (token_hash), KEY idx_auth_refresh_user (user_id, revoked_at, expires_at), KEY idx_auth_refresh_expire (expires_at, revoked_at) -) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、租户上下文、有效期和轮换/吊销状态'; +) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、平台或客户作用域、有效期和轮换/吊销状态'; -- 初始管理员密码必须由部署脚本生成 BCrypt 哈希后显式写入,仓库内禁止提供默认密码。 diff --git a/auth/src/main/resources/db/phase1-admin-accounts.sql b/auth/src/main/resources/db/phase1-admin-accounts.sql index b84c3e956..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; @@ -6,19 +6,18 @@ SET NAMES utf8mb4; INSERT INTO auth_account ( id, username, password_hash, display_name, account_status, - failed_login_count, token_version, default_tenant_id, + 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, 3000001, 'PLATFORM', 0), - (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - '一期演示企业管理员', 'ACTIVE', 0, 1, 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, locked_until = NULL, + password_change_required = VALUES(password_change_required), default_tenant_id = VALUES(default_tenant_id), operator_tenant_id = VALUES(operator_tenant_id), default_membership_id = VALUES(default_membership_id), diff --git a/auth/src/main/resources/db/phase1-customer-account-operations.sql b/auth/src/main/resources/db/phase1-customer-account-operations.sql new file mode 100644 index 000000000..2abf45552 --- /dev/null +++ b/auth/src/main/resources/db/phase1-customer-account-operations.sql @@ -0,0 +1,11 @@ +USE baiyue_auth; + +SET @add_password_change_required = IF( + EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'auth_account' + AND column_name = 'password_change_required'), + 'SELECT 1', + 'ALTER TABLE auth_account ADD COLUMN password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT ''管理员重置后是否强制下次登录改密'' AFTER token_version'); +PREPARE phase1_customer_account_sql FROM @add_password_change_required; +EXECUTE phase1_customer_account_sql; +DEALLOCATE PREPARE phase1_customer_account_sql; diff --git a/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java b/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java new file mode 100644 index 000000000..7712cde85 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java @@ -0,0 +1,77 @@ +package com.baiyue.auth.client; + +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.test.util.ReflectionTestUtils; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +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.when; + +@ExtendWith(MockitoExtension.class) +class AuthorizationLoaderTest { + private static final String INTERNAL_TOKEN = "test-internal-service-token-at-least-32-bytes"; + + @Mock private IamAuthorizationClient client; + @InjectMocks private AuthorizationLoader loader; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(loader, "internalToken", INTERNAL_TOKEN); + loader.validateConfiguration(); + } + + @Test + void returnsSnapshotWhenEchoedIdentityMatchesAccount() { + AuthAccount account = account(); + AuthorizationSnapshot snapshot = snapshot(42L, 10001L, 9L, "MERCHANT"); + when(client.load(eq(INTERNAL_TOKEN), any())).thenReturn(ApiResponse.success(snapshot)); + + assertSame(snapshot, loader.load(account)); + } + + @Test + void rejectsEveryMismatchedEchoedIdentityField() { + AuthAccount account = account(); + + assertIdentityMismatch(account, snapshot(43L, 10001L, 9L, "MERCHANT")); + assertIdentityMismatch(account, snapshot(42L, 10002L, 9L, "MERCHANT")); + assertIdentityMismatch(account, snapshot(42L, 10001L, 10L, "MERCHANT")); + assertIdentityMismatch(account, snapshot(42L, 10001L, 9L, "PLATFORM")); + } + + private void assertIdentityMismatch(AuthAccount account, AuthorizationSnapshot snapshot) { + when(client.load(eq(INTERNAL_TOKEN), any())).thenReturn(ApiResponse.success(snapshot)); + + BizException exception = assertThrows(BizException.class, () -> loader.load(account)); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + } + + private AuthAccount account() { + AuthAccount account = new AuthAccount(); + account.setId(42L); + account.setDefaultTenantId(10001L); + account.setDefaultMembershipId(9L); + account.setDefaultAppCode("MERCHANT"); + return account; + } + + 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"), 1L); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/config/AuthSecurityConfigTest.java b/auth/src/test/java/com/baiyue/auth/config/AuthSecurityConfigTest.java new file mode 100644 index 000000000..a9a601220 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/config/AuthSecurityConfigTest.java @@ -0,0 +1,15 @@ +package com.baiyue.auth.config; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AuthSecurityConfigTest { + @Test + void permitsAnonymousImpersonationCodeExchange() { + assertTrue(Arrays.asList(AuthSecurityConfig.PUBLIC_AUTH_ENDPOINTS) + .contains("/api/auth/impersonations/exchange")); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java b/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java new file mode 100644 index 000000000..0b7440f31 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java @@ -0,0 +1,23 @@ +package com.baiyue.auth.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 AuthTokenTtlConfigurationContractTest { + @Test + void cnNacosConfigurationKeepsShortAccessAndSevenDayRefreshTokens() throws Exception { + String configuration = Files.readString( + Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8); + + assertThat(configuration) + .contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}") + .contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}") + .contains("access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}") + .doesNotContain("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/InternalAccountControllerConfigurationTest.java b/auth/src/test/java/com/baiyue/auth/controller/InternalAccountControllerConfigurationTest.java new file mode 100644 index 000000000..e1865ef1d --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/controller/InternalAccountControllerConfigurationTest.java @@ -0,0 +1,21 @@ +package com.baiyue.auth.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class InternalAccountControllerConfigurationTest { + @Test + void rejectsEmptyAndShortInternalServiceTokens() { + assertTokenRejected(null); + assertTokenRejected(""); + assertTokenRejected("short-token"); + } + + private void assertTokenRejected(String token) { + InternalAccountController controller = new InternalAccountController(); + ReflectionTestUtils.setField(controller, "configuredToken", token); + assertThrows(IllegalStateException.class, controller::init); + } +} 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/AuthLoginIdentifiersSqlContractTest.java b/auth/src/test/java/com/baiyue/auth/repository/AuthLoginIdentifiersSqlContractTest.java new file mode 100644 index 000000000..7a9e163ab --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/repository/AuthLoginIdentifiersSqlContractTest.java @@ -0,0 +1,32 @@ +package com.baiyue.auth.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 AuthLoginIdentifiersSqlContractTest { + @Test + void backfillsOnlyUniquelyOwnedPhonesAndKeepsDuplicateLegacyPhonesUnbound() throws Exception { + assertSafeBackfill(new ClassPathResource("db/auth-login-identifiers.sql") + .getContentAsString(StandardCharsets.UTF_8)); + assertSafeBackfill(Files.readString(unifiedSql(), StandardCharsets.UTF_8)); + } + + private static void assertSafeBackfill(String sql) { + assertThat(sql) + .contains("phone NOT REGEXP '^1[3-9][0-9]{9}$'") + .contains("HAVING COUNT(DISTINCT auth_user_id) = 1") + .contains("ADD UNIQUE KEY uk_auth_account_phone (phone)") + .doesNotContain("IAM 存在重复手机号,禁止建立 Auth 唯一登录索引"); + } + + 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/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java b/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java new file mode 100644 index 000000000..82620e90c --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.auth.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 InitialAccountSeedSqlContractTest { + @Test + void initializesOnlyThePlatformAdministratorAccount() throws Exception { + assertAccountSeed(new ClassPathResource("db/phase1-admin-accounts.sql") + .getContentAsString(StandardCharsets.UTF_8)); + assertAccountSeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8)); + } + + private static void assertAccountSeed(String sql) { + assertThat(sql) + .contains("(1000001, 'platform_admin'") + .contains("'平台系统管理员', 'ACTIVE'") + .contains("password_change_required") + .doesNotContain("merchant_admin") + .doesNotContain("IT_TEST") + .doesNotContain("(1000002, 'merchant_admin'") + .doesNotContain("一期演示企业管理员"); + } + + 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/auth/src/test/java/com/baiyue/auth/repository/LoginIdentifierContractTest.java b/auth/src/test/java/com/baiyue/auth/repository/LoginIdentifierContractTest.java new file mode 100644 index 000000000..5ce5d40e9 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/repository/LoginIdentifierContractTest.java @@ -0,0 +1,48 @@ +package com.baiyue.auth.repository; + +import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest; +import com.baiyue.auth.dto.MerchantRegisterRequest; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoginIdentifierContractTest { + + @Test + void registrationExposesAnAccountPhoneInsteadOfALegalPersonPhoneAlias() { + var componentNames = Arrays.stream(MerchantRegisterRequest.class.getRecordComponents()) + .map(component -> component.getName()) + .toList(); + + assertThat(componentNames).contains("phone").doesNotContain("legalPhone"); + } + + @Test + void mainlandMobileRuleRejectsPlaceholderNumbersAcrossJavaAndSql() throws Exception { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + var invalidRegistration = new MerchantRegisterRequest( + "merchant.user", "Password1", "测试用户", "测试企业", "张三", + "11111111111", null, "CN", null); + var validChange = new ChangeLoginIdentifiersRequest( + "merchant.user", "13800138000", "Password1"); + var invalidChange = new ChangeLoginIdentifiersRequest( + "merchant.user", "11111111111", "Password1"); + + assertThat(validator.validate(invalidRegistration)).isNotEmpty(); + assertThat(validator.validate(validChange)).isEmpty(); + assertThat(validator.validate(invalidChange)).isNotEmpty(); + } + + String sql = new ClassPathResource("db/auth-login-identifiers.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(sql) + .contains("REGEXP '^1[3-9][0-9]{9}$'") + .doesNotContain("REGEXP '^1[0-9]{10}$'"); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java index bce35b8b3..ee951962c 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java @@ -1,35 +1,50 @@ package com.baiyue.auth.service; import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; +import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest; +import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest; +import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest; +import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountPhoneRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest; +import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest; +import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest; import com.baiyue.auth.entity.AuthAccount; import com.baiyue.auth.repository.AuthAccountRepository; import com.baiyue.common.core.exception.BizException; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; 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.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import java.time.LocalDateTime; +import java.util.Set; +import java.util.List; +@DisplayName("账号管理服务") +@SpringJUnitConfig(classes = {AccountManagementService.class, AccountManagementServiceTest.TestConfig.class}) class AccountManagementServiceTest { - private final AuthAccountRepository repository = mock(AuthAccountRepository.class); - private final TokenSessionStore sessions = mock(TokenSessionStore.class); - private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(4); - private final AccountManagementService service = new AccountManagementService(); + @MockBean private AuthAccountRepository repository; + @MockBean private TokenSessionStore sessions; + @Resource private PasswordEncoder encoder; + @Resource private AccountManagementService service; private AuthAccount account; @BeforeEach void setUp() { - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "passwordEncoder", encoder); - ReflectionTestUtils.setField(service, "tokenSessionStore", sessions); account = new AuthAccount(); account.setId(10L); account.setPasswordHash(encoder.encode("OldPass1")); account.setTokenVersion(2); account.setAccountStatus("ACTIVE"); when(repository.findById(10L)).thenReturn(account); } - @Test void 修改密码校验旧密码并撤销全部会话() { + @Test void changePasswordValidatesCurrentPasswordAndRevokesAllSessions() { service.changePassword(10L, new ChangePasswordRequest("OldPass1", "NewPass2")); assertTrue(encoder.matches("NewPass2", account.getPasswordHash())); assertEquals(3, account.getTokenVersion()); @@ -37,16 +52,206 @@ class AccountManagementServiceTest { verify(sessions).revokeAll(10L); } - @Test void 原密码错误不得写入() { + @Test void changePasswordDoesNotPersistWhenCurrentPasswordIsInvalid() { assertThrows(BizException.class, () -> service.changePassword(10L, new ChangePasswordRequest("WrongPass1", "NewPass2"))); verify(repository, never()).save(any()); verify(sessions, never()).revokeAll(any()); } - @Test void 停用账号撤销全部会话() { + @Test void forcedPasswordChangeRequiresResetFlagAndClearsIt() { + account.setPasswordChangeRequired(true); + + service.forcedChangePassword(10L, new ForcedPasswordChangeRequest("NewPass2")); + + assertTrue(encoder.matches("NewPass2", account.getPasswordHash())); + assertFalse(account.getPasswordChangeRequired()); + verify(repository).save(account); + verify(sessions).revokeAll(10L); + } + + @Test void normalPasswordChangeIsRejectedUntilForcedChangeIsCompleted() { + account.setPasswordChangeRequired(true); + + assertThrows(BizException.class, + () -> service.changePassword(10L, new ChangePasswordRequest("OldPass1", "NewPass2"))); + verify(repository, never()).save(any()); + } + + @Test void disableAccountRevokesAllSessions() { service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED")); assertEquals("DISABLED", account.getAccountStatus()); verify(sessions).revokeAll(10L); } + + @Test void createPlatformAccountPersistsPlatformAppCodeAndPasswordHashOnly() { + when(repository.findById(20L)).thenReturn(null); + when(repository.existsByUsername("platform.user")).thenReturn(false); + + var result = service.create(new CreateAccountRequest(20L, "platform.user", "Password1", + "平台用户", 0L, 30L, "PLATFORM")); + + assertEquals("platform.user", result.username()); + var captor = org.mockito.ArgumentCaptor.forClass(AuthAccount.class); + verify(repository).insert(captor.capture()); + assertEquals("PLATFORM", captor.getValue().getDefaultAppCode()); + assertTrue(encoder.matches("Password1", captor.getValue().getPasswordHash())); + assertNotEquals("Password1", captor.getValue().getPasswordHash()); + } + + @Test void usernameAvailabilityUsesTheAuthAccountRepositoryAsTheFactSource() { + when(repository.existsByUsername("13800138000")).thenReturn(true); + + assertFalse(service.usernameAvailable("13800138000")); + assertTrue(service.usernameAvailable("13900139000")); + } + + @Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() { + when(repository.findById(20L)).thenReturn(null); + when(repository.existsByUsername("merchant.user")).thenReturn(false); + + var created = service.create(new CreateAccountRequest(20L, "merchant.user", "Password1", + "企业用户", 10001L, 30L, "MERCHANT", "DISABLED")); + var captor = org.mockito.ArgumentCaptor.forClass(AuthAccount.class); + verify(repository).insert(captor.capture()); + AuthAccount stored = captor.getValue(); + stored.setLastLoginAt(LocalDateTime.of(2026, 6, 1, 10, 30)); + when(repository.findById(20L)).thenReturn(stored); + + var summary = service.summary(20L); + assertEquals("DISABLED", created.status()); + assertEquals(10001L, summary.tenantId()); + assertEquals(30L, summary.membershipId()); + assertEquals(LocalDateTime.of(2026, 6, 1, 10, 30), summary.lastLoginAt()); + } + + @Test void updateScopeChangesTenantAndRevokesExistingSessions() { + account.setDefaultTenantId(10001L); account.setOperatorTenantId(10001L); + account.setDefaultMembershipId(30L); account.setDefaultAppCode("MERCHANT"); + + service.updateScope(10L, new UpdateAccountScopeRequest(10002L, 30L, "MERCHANT")); + + assertEquals(10002L, account.getDefaultTenantId()); + assertEquals(10002L, account.getOperatorTenantId()); + verify(repository).save(account); + verify(sessions).revokeAll(10L); + } + + @Test void createAccountRetriesIdempotentlyForSameAccountAndOwner() { + account.setUsername("platform.user"); account.setDisplayName("平台用户"); + account.setDefaultTenantId(0L); account.setOperatorTenantId(0L); + account.setDefaultMembershipId(30L); account.setDefaultAppCode("PLATFORM"); account.setDeleted(0); + + var result = service.create(new CreateAccountRequest(10L, "platform.user", "Password1", + "平台用户", 0L, 30L, "PLATFORM")); + + assertEquals(10L, result.accountId()); + verify(repository, never()).insert(any()); + } + + @Test void synchronizeDisplayNameRevokesExistingSessions() { + account.setDisplayName("旧名称"); + + service.updateProfile(10L, new UpdateAccountProfileRequest("新名称")); + + assertEquals("新名称", account.getDisplayName()); + verify(repository).save(account); + verify(sessions).revokeAll(10L); + } + + @Test void changeLoginIdentifiersRequiresCurrentPasswordAndRevokesSessions() { + account.setUsername("old.user"); + account.setPhone("13800138000"); + when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false); + when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(false); + + var result = service.changeLoginIdentifiers(10L, + new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1")); + + assertEquals("new.user", result.username()); + assertEquals("13900139000", result.phone()); + assertEquals(3, account.getTokenVersion()); + verify(sessions).revokeAll(10L); + } + + @Test void managedPhoneChangeOrRemovalRevokesOnlyThatAccountsSessions() { + account.setPhone("13800138000"); + + service.updatePhone(10L, new UpdateAccountPhoneRequest(null)); + + assertEquals(null, account.getPhone()); + assertEquals(3, account.getTokenVersion()); + verify(repository).save(account); + verify(sessions).revokeAll(10L); + } + + @Test void duplicatePhoneDoesNotChangeLoginIdentifiers() { + account.setUsername("old.user"); + account.setPhone("13800138000"); + when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false); + when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(true); + + assertThrows(BizException.class, () -> service.changeLoginIdentifiers(10L, + new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1"))); + verify(repository, never()).save(any()); + verify(sessions, never()).revokeAll(any()); + } + + @Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() { + AuthAccount second = new AuthAccount(); + second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE"); + when(repository.findById(11L)).thenReturn(second); + + service.revokeSessions(Set.of(10L, 11L)); + + assertEquals(3, account.getTokenVersion()); + assertEquals(8, second.getTokenVersion()); + verify(repository).save(account); + verify(repository).save(second); + verify(sessions).revokeAll(10L); + verify(sessions).revokeAll(11L); + } + + @Test void archiveTenantDisablesEveryMerchantAccountAndRevokesSessions() { + AuthAccount owner = merchantAccount(20L, 10001L, "ACTIVE", 3); + AuthAccount member = merchantAccount(21L, 10001L, "DISABLED", null); + when(repository.findMerchantByTenant(10001L)).thenReturn(List.of(owner, member)); + + service.archiveTenant(10001L); + + assertEquals("DISABLED", owner.getAccountStatus()); + assertEquals(4, owner.getTokenVersion()); + assertEquals("DISABLED", member.getAccountStatus()); + assertEquals(1, member.getTokenVersion()); + verify(repository).save(owner); + verify(repository).save(member); + verify(sessions).revokeAll(20L); + verify(sessions).revokeAll(21L); + } + + @Test void archiveTenantIsSuccessfulWhenCustomerHasNoAccounts() { + when(repository.findMerchantByTenant(10001L)).thenReturn(List.of()); + + service.archiveTenant(10001L); + + verify(repository, never()).save(any()); + verify(sessions, never()).revokeAll(any()); + } + + private AuthAccount merchantAccount(long id, long tenantId, String status, Integer tokenVersion) { + AuthAccount value = new AuthAccount(); + value.setId(id); + value.setDefaultTenantId(tenantId); + value.setDefaultAppCode("MERCHANT"); + value.setAccountStatus(status); + value.setTokenVersion(tokenVersion); + return value; + } + @Configuration + static class TestConfig { + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(4); + } + } } diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java new file mode 100644 index 000000000..1ea5b0459 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -0,0 +1,252 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.client.AuthorizationSnapshot; +import com.baiyue.auth.config.AuthSecurityProperties; +import com.baiyue.auth.dto.LoginRequest; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +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.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +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 jakarta.annotation.Resource; +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; + +@SpringJUnitConfig(classes = {AuthService.class, AuthServiceSmallJwtTest.TestConfig.class}) +class AuthServiceSmallJwtTest { + @MockBean private AuthAccountRepository repository; + @MockBean private PasswordEncoder passwordEncoder; + @MockBean private JwtEncoder jwtEncoder; + @MockBean private AuthorizationLoader authorizationLoader; + @MockBean private TokenSessionStore tokenSessionStore; + @MockBean private OverseasTokenService overseasTokenService; + @Resource private AuthService service; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean + AuthSecurityProperties properties() { + return new AuthSecurityProperties("unused", "auth-service", "lastleg-api", + Duration.ofMinutes(30), + Duration.ofDays(7), 5, Duration.ofMinutes(10)); + } + } + + @Test + void signsCrossRegionClaimsAndStoresSession() { + AuthAccount account = new AuthAccount(); + account.setId(42L); + account.setUsername("operator"); + account.setPasswordHash("hash"); + account.setAccountStatus("ACTIVE"); + account.setDefaultTenantId(0L); + account.setOperatorTenantId(0L); + account.setDefaultMembershipId(84L); + account.setDefaultAppCode("PLATFORM"); + account.setTokenVersion(1); + AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", + "WEB", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("platform:order:list"), 1L, 900L); + Jwt encoded = org.mockito.Mockito.mock(Jwt.class); + + when(repository.findByUsername("operator")).thenReturn(account); + when(passwordEncoder.matches("secret", "hash")).thenReturn(true); + when(authorizationLoader.load(account)).thenReturn(snapshot); + when(jwtEncoder.encode(any())).thenReturn(encoded); + when(encoded.getTokenValue()).thenReturn("small-token"); + when(overseasTokenService.issue(account, snapshot)).thenReturn( + new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900, + snapshot.getRoles(), snapshot.getPermissions())); + + var response = service.login(new LoginRequest("operator", "secret")); + + ArgumentCaptor jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class); + verify(jwtEncoder).encode(jwt.capture()); + assertThat(jwt.getValue().getClaims().getClaims()) + .containsEntry("user_id", 42L) + .containsEntry("userId", 42L) + .containsEntry("tenantId", 0L) + .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("OVERSEAS_WAREHOUSE_PDA_OPERATOR")), + 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); + assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); + assertThat(response.authorities()).containsExactly("platform:order:list"); + } + + @Test + void mainlandPhoneIsResolvedAsTheSinglePhoneLoginIdentifier() { + AuthAccount account = new AuthAccount(); + account.setId(42L); + account.setUsername("operator"); + account.setPhone("13800138000"); + account.setPasswordHash("hash"); + account.setAccountStatus("ACTIVE"); + account.setDefaultTenantId(0L); + account.setOperatorTenantId(0L); + account.setDefaultMembershipId(84L); + account.setDefaultAppCode("PLATFORM"); + account.setTokenVersion(1); + AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", + "WEB", Set.of(), Set.of(), 1L); + Jwt encoded = org.mockito.Mockito.mock(Jwt.class); + when(repository.findByPhone("13800138000")).thenReturn(account); + when(passwordEncoder.matches("secret", "hash")).thenReturn(true); + when(authorizationLoader.load(account)).thenReturn(snapshot); + when(jwtEncoder.encode(any())).thenReturn(encoded); + when(encoded.getTokenValue()).thenReturn("small-token"); + + service.login(new LoginRequest("13800138000", "secret")); + + verify(repository).findByPhone("13800138000"); + verify(repository, never()).findByUsername("13800138000"); + } + + @Test + void placeholderNumberIsNeverResolvedAsAMobileLoginIdentifier() { + when(repository.findByUsername("11111111111")).thenReturn(null); + + assertThatThrownBy(() -> service.login(new LoginRequest("11111111111", "secret"))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessageContaining("登录标识或密码错误"); + + verify(repository).findByUsername("11111111111"); + verify(repository, never()).findByPhone("11111111111"); + } + + @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, + snapshot.getRoles(), snapshot.getPermissions())); + + 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); + assertThat(response.roles()).containsExactly("MERCHANT_ADMIN"); + assertThat(response.authorities()).containsExactly("merchant:order:list"); + verify(tokenSessionStore).consume("refresh-old"); + verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L), + org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")), + org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), + org.mockito.ArgumentMatchers.eq(response.refreshToken()), + 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 + void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() { + AuthAccount account = new AuthAccount(); + account.setId(43L); + account.setUsername("merchant-owner"); + account.setAccountStatus("ACTIVE"); + account.setDefaultTenantId(10001L); + account.setOperatorTenantId(0L); + account.setDefaultMembershipId(85L); + account.setDefaultAppCode("MERCHANT"); + account.setTokenVersion(2); + AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT", + "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"); + + when(tokenSessionStore.consumeImpersonationTicket("one-time-code")).thenReturn(ticket); + when(repository.findById(43L)).thenReturn(account); + when(authorizationLoader.load(account)).thenReturn(snapshot); + when(jwtEncoder.encode(any())).thenReturn(encoded); + when(encoded.getTokenValue()).thenReturn("small-impersonation-token"); + when(overseasTokenService.issue(account, snapshot)).thenReturn( + new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-impersonation-token", 900, + Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"))); + + AuthService.ImpersonationTokenResponse response = service.exchangeImpersonationTicket("one-time-code"); + + assertThat(response.overseasAccessToken()).isEqualTo("overseas-impersonation-token"); + assertThat(response.overseasExpiresIn()).isEqualTo(900); + assertThat(response.roles()).containsExactly("MERCHANT_OWNER"); + assertThat(response.authorities()).containsExactly("merchant:order:list"); + + ArgumentCaptor jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class); + verify(jwtEncoder).encode(jwt.capture()); + assertThat(jwt.getValue().getClaims().getClaims()) + .containsEntry("appCode", "MERCHANT") + .containsEntry("impersonated", true) + .containsEntry("membership_id", 85L) + .doesNotContainKeys("roles", "authorities", "permissions", "scope"); + verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L), + org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")), + org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), + org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15))); + verify(overseasTokenService).issue(account, snapshot); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java new file mode 100644 index 000000000..7eb495634 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java @@ -0,0 +1,148 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.IamOnboardingClient; +import com.baiyue.auth.dto.MerchantRegisterRequest; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import com.baiyue.common.core.api.ApiResponse; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.crypto.password.PasswordEncoder; +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 jakarta.annotation.Resource; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; + +@SpringJUnitConfig(MerchantRegistrationService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class MerchantRegistrationServiceTest { + @MockBean private AuthAccountRepository accounts; + @MockBean private PasswordEncoder encoder; + @MockBean private IamOnboardingClient onboarding; + @Resource private MerchantRegistrationService service; + @Test + 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", "PENDING"))); + + var result = service.register(new MerchantRegisterRequest( + "registered_owner", "Password123", "注册人", "测试企业", "张三", + "13800138000", "owner@example.com", "CN", "深圳市宝安区")); + + ArgumentCaptor onboardingRequest = + ArgumentCaptor.forClass(IamOnboardingClient.OnboardingRequest.class); + ArgumentCaptor storedAccount = ArgumentCaptor.forClass(AuthAccount.class); + verify(onboarding).register(eq("internal-token"), onboardingRequest.capture()); + verify(accounts).insert(storedAccount.capture()); + + assertEquals(onboardingRequest.getValue().authUserId(), storedAccount.getValue().getId()); + assertEquals(result.accountId(), storedAccount.getValue().getId()); + assertEquals(10001L, storedAccount.getValue().getDefaultTenantId()); + assertEquals(10001L, storedAccount.getValue().getOperatorTenantId()); + assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId()); + assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode()); + assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus()); + assertEquals("13800138000", storedAccount.getValue().getPhone()); + assertEquals("13800138000", onboardingRequest.getValue().administratorPhone()); + assertEquals("encoded-password", storedAccount.getValue().getPasswordHash()); + assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep()); + assertEquals("PENDING", result.dimensionInitializationStatus()); + } + + @Test + void exactIncompleteRegistrationRetryReturnsTheExistingRegistration() { + AuthAccount account = new AuthAccount(); + account.setId(9001L); + account.setUsername("registered_owner"); + account.setPhone("13800138000"); + account.setPasswordHash("encoded-password"); + account.setAccountStatus("ACTIVE"); + account.setDefaultAppCode("MERCHANT"); + when(accounts.findByUsername("registered_owner")).thenReturn(account); + when(accounts.findByPhone("13800138000")).thenReturn(account); + when(encoder.matches("Password123", "encoded-password")).thenReturn(true); + when(onboarding.registration("internal-token", 9001L)).thenReturn(ApiResponse.success( + new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "READY"))); + + var result = service.register(new MerchantRegisterRequest( + "registered_owner", "Password123", "注册人", "测试企业", null, + "13800138000", "owner@example.com", "CN", "深圳市宝安区")); + + assertEquals(9001L, result.accountId()); + assertEquals(10001L, result.tenantId()); + assertEquals("READY", result.dimensionInitializationStatus()); + verify(onboarding, never()).register(eq("internal-token"), any()); + verify(accounts, never()).insert(any()); + } + + @Test + void phoneOwnedByAnotherUsernameDirectsTheUserBackToLogin() { + AuthAccount account = new AuthAccount(); + account.setId(9001L); + account.setUsername("registered_owner"); + account.setPhone("13800138000"); + when(accounts.findByPhone("13800138000")).thenReturn(account); + + var failure = assertThrows(com.baiyue.common.core.exception.BizException.class, + () -> service.register(new MerchantRegisterRequest( + "another_owner", "Password123", "注册人", "测试企业", null, + "13800138000", "owner@example.com", "CN", "深圳市宝安区"))); + + assertEquals("该手机号已注册,请直接登录后继续完善企业资质", failure.getMessage()); + verify(onboarding, never()).register(eq("internal-token"), any()); + } + + @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", "PENDING"))); + when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + doThrow(new IllegalStateException("auth database unavailable")).when(accounts).insert(any()); + + assertThrows(IllegalStateException.class, () -> service.register(new MerchantRegisterRequest( + "failed_owner", "Password123", "注册人", "测试企业", "张三", + "13800138000", "owner@example.com", "CN", "深圳市宝安区"))); + + ArgumentCaptor onboardingRequest = + ArgumentCaptor.forClass(IamOnboardingClient.OnboardingRequest.class); + ArgumentCaptor compensationUserId = ArgumentCaptor.forClass(Long.class); + verify(onboarding).register(eq("internal-token"), onboardingRequest.capture()); + verify(onboarding).compensate(eq("internal-token"), compensationUserId.capture()); + assertEquals(onboardingRequest.getValue().authUserId(), compensationUserId.getValue()); + } + + @Test + 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", "PENDING"))); + when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + + TransactionSynchronizationManager.initSynchronization(); + try { + service.register(new MerchantRegisterRequest( + "rollback_owner", "Password123", "注册人", "测试企业", "张三", + "13800138000", "owner@example.com", "CN", "深圳市宝安区")); + var synchronizations = TransactionSynchronizationManager.getSynchronizations(); + assertEquals(1, synchronizations.size()); + synchronizations.get(0).afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK); + verify(onboarding).compensate(eq("internal-token"), any()); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } +} 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..c076584bf --- /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("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), version, warehouseId); + } + + private void inject(String fieldName, Object value) throws Exception { + var field = OverseasSessionValidationService.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(service, value); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java new file mode 100644 index 000000000..65c0769af --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -0,0 +1,139 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.client.AuthorizationSnapshot; +import com.baiyue.auth.config.OverseasJwtProperties; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.TestPropertySource; + +import java.time.Duration; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class}) +@TestPropertySource(properties = "baiyue.security.overseas-jwt.enabled=true") +class OverseasTokenServiceTest { + @MockBean private AuthAccountRepository accounts; + @MockBean private AuthorizationLoader authorizationLoader; + @MockBean(name = "overseasJwtEncoder") private JwtEncoder encoder; + @Resource private OverseasTokenService service; + + @Configuration(proxyBeanMethods = false) + static class Config { + @Bean OverseasJwtProperties properties() { + return new OverseasJwtProperties(true, "auth-service", "lastleg-api", "{}", "current-kid", + Duration.ofMinutes(15)); + } + } + + @Test + void signsShortRs256TokenWithCrossRegionClaims() { + AuthAccount account = new AuthAccount(); + account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L); + account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT"); + String authority = "platform:overseas-master-data:quote:list"; + String merchantAuthority = "merchant:warehouse:delivery:list"; + String merchantCrossBorderAuthority = "merchant:cross-border:procurement"; + String lastlegAuthority = "lastleg:pda:operate"; + String financeAuthority = "platform:finance:fee-source:edit"; + String domesticAuthority = "platform:firstleg:order:list"; + AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", + Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), + Set.of(authority, merchantAuthority, merchantCrossBorderAuthority, lastlegAuthority, + financeAuthority, domesticAuthority), 7L, 900L); + Jwt encoded = org.mockito.Mockito.mock(Jwt.class); + when(accounts.findById(42L)).thenReturn(account); + when(authorizationLoader.load(account)).thenReturn(snapshot); + when(encoder.encode(any())).thenReturn(encoded); + when(encoded.getTokenValue()).thenReturn("overseas-token"); + + var response = service.exchange(42L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); + verify(encoder).encode(captor.capture()); + var parameters = captor.getValue(); + assertThat(parameters.getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256); + assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid"); + assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api"); + assertThat(parameters.getClaims().getClaims()) + .containsEntry("user_id", 42L).containsEntry("tenant_id", 0L) + .containsEntry("membership_id", 84L) + .containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB") + .containsEntry("permission_version", 7L) + .containsEntry("token_version", 0) + .containsEntry("overseas_warehouse_id", 900L) + .doesNotContainKeys("roles", "authorities", "permissions", "userId", "tenantId", "appCode", + "permissionVersion"); + assertThat(response.expiresIn()).isEqualTo(900); + assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); + assertThat(response.authorities()).containsExactlyInAnyOrder(authority, merchantAuthority, + merchantCrossBorderAuthority, lastlegAuthority, financeAuthority, domesticAuthority); + } + + @Test + void refreshedAuthorizationSnapshotReplacesWarehouseBindingAndTokenIdentity() { + AuthAccount account = new AuthAccount(); + account.setId(43L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(0L); + account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM"); + account.setTokenVersion(8); + AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", + Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 8L, 900L); + AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", + Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 9L, 901L); + Jwt first = org.mockito.Mockito.mock(Jwt.class); + Jwt second = org.mockito.Mockito.mock(Jwt.class); + when(encoder.encode(any())).thenReturn(first, second); + when(first.getTokenValue()).thenReturn("overseas-old-binding"); + when(second.getTokenValue()).thenReturn("overseas-new-binding"); + + var oldToken = service.issue(account, oldBinding); + var newToken = service.issue(account, newBinding); + + ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); + verify(encoder, times(2)).encode(captor.capture()); + var oldClaims = captor.getAllValues().get(0).getClaims(); + var newClaims = captor.getAllValues().get(1).getClaims(); + assertThat(oldClaims.getId()).isNotEqualTo(newClaims.getId()); + assertThat(oldClaims.getClaims()).containsEntry("overseas_warehouse_id", 900L) + .containsEntry("permission_version", 8L); + assertThat(newClaims.getClaims()).containsEntry("overseas_warehouse_id", 901L) + .containsEntry("permission_version", 9L); + assertThat(newClaims.getClaims()).containsEntry("token_version", 8); + assertThat(oldToken.accessToken()).isNotEqualTo(newToken.accessToken()); + } + + @Test + void unboundWarehouseIsOmittedSoPdaIdentityMustFailClosed() { + AuthAccount account = new AuthAccount(); + account.setId(44L); account.setAccountStatus("ACTIVE"); + AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA", + Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 10L, null); + Jwt encoded = org.mockito.Mockito.mock(Jwt.class); + when(encoder.encode(any())).thenReturn(encoded); + when(encoded.getTokenValue()).thenReturn("overseas-unbound"); + + service.issue(account, unbound); + + ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); + verify(encoder).encode(captor.capture()); + assertThat(captor.getValue().getClaims().getClaims()) + .doesNotContainKey("overseas_warehouse_id"); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java index 8eb6ed0ac..a18fc513d 100644 --- a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java @@ -1,10 +1,17 @@ package com.baiyue.auth.service; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.data.redis.core.SetOperations; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.time.Duration; import java.util.Set; @@ -16,32 +23,49 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.eq; +@DisplayName("令牌会话存储") +@SpringJUnitConfig(classes = {TokenSessionStore.class, TokenSessionStoreTest.TestConfig.class}) +@TestPropertySource(properties = "baiyue.security.token-key-prefix=baiyue:auth") class TokenSessionStoreTest { + @MockBean + private StringRedisTemplate redis; + + @Resource + private TokenSessionStore store; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + } @Test void storesAccessAndHashedRefreshWithIndependentTtls() { - StringRedisTemplate redis = mock(StringRedisTemplate.class); @SuppressWarnings("unchecked") ValueOperations values = mock(ValueOperations.class); when(redis.opsForValue()).thenReturn(values); - TokenSessionStore store = new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth:"); - store.store("jti-1", 42L, "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); + store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"), + "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); - verify(values).set("baiyue:auth:access:jti-1", "42", Duration.ofMinutes(30)); + verify(values).set(eq("baiyue:auth:access:jti-1"), + org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1") + && value.contains("\"userId\":42") + && value.contains("ADMIN") && value.contains("order:read")), + eq(Duration.ofMinutes(30))); verify(values).set(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:"), org.mockito.ArgumentMatchers.contains("\"userId\":42"), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); } @Test void atomicallyConsumesRefreshAndRevokesItsAccessSession() throws Exception { - StringRedisTemplate redis = mock(StringRedisTemplate.class); @SuppressWarnings("unchecked") ValueOperations values = mock(ValueOperations.class); when(redis.opsForValue()).thenReturn(values); String json = new ObjectMapper().writeValueAsString( new TokenSessionStore.RefreshSession(42L, "jti-1")); when(values.getAndDelete(anyString())).thenReturn(json); - TokenSessionStore store = new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth"); TokenSessionStore.RefreshSession session = store.consume("raw-refresh"); @@ -53,26 +77,23 @@ class TokenSessionStoreTest { @Test void treatsAlreadyConsumedRefreshAsInvalid() { - StringRedisTemplate redis = mock(StringRedisTemplate.class); @SuppressWarnings("unchecked") ValueOperations values = mock(ValueOperations.class); when(redis.opsForValue()).thenReturn(values); when(values.getAndDelete(anyString())).thenReturn(null); - assertThat(new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth") - .consume("raw-refresh")).isNull(); + assertThat(store.consume("raw-refresh")).isNull(); } @Test void revokesAllIndexedSessionsForUser() { - StringRedisTemplate redis = mock(StringRedisTemplate.class); @SuppressWarnings("unchecked") SetOperations sets = mock(SetOperations.class); when(redis.opsForSet()).thenReturn(sets); when(sets.members("baiyue:auth:user-sessions:42")) .thenReturn(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b")); - new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth").revokeAll(42L); + store.revokeAll(42L); verify(redis).delete(eq(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b"))); verify(redis).delete("baiyue:auth:user-sessions:42"); diff --git a/common-audit/AGENTS.md b/common-audit/AGENTS.md new file mode 100644 index 000000000..e34c18bea --- /dev/null +++ b/common-audit/AGENTS.md @@ -0,0 +1,23 @@ +# Common Audit 模块指引 + +## 职责边界 + +- 负责业务审计事件采集、注解切面、敏感字段脱敏以及日志/HTTP 输出。 +- 审计记录持久化属于 `audit-service/`;本模块不依赖具体业务 Entity 或数据库。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 审计注解与切面 | `src/main/java/com/baiyue/common/audit/BusinessAudit.java`、`BusinessAuditAspect.java` | +| 事件模型 | `AuditEvent.java` | +| 输出抽象与实现 | `AuditSink.java`、`LoggingAuditSink.java`、`HttpAuditSink.java` | +| 敏感数据脱敏 | `SensitiveDataSanitizer.java` | +| `baiyue.audit` 配置 | `AuditProperties.java` | +| 自动配置 | `BaiyueAuditAutoConfiguration.java` 与 `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | + +## 修改与验证 + +- 新增审计字段时先确认不会泄露密码、令牌、证件或客户敏感数据,并检查 `audit-service` 接收 DTO。 +- 审计失败不应破坏核心业务;调整输出策略时保留超时、关闭开关和安全降级。 +- 从父目录运行 `mvn -pl common-audit -am test`,并按影响追加验证 `audit-service`。 diff --git a/common-audit/pom.xml b/common-audit/pom.xml index 7b8224ae1..16946b96f 100644 --- a/common-audit/pom.xml +++ b/common-audit/pom.xml @@ -11,5 +11,16 @@ com.baiyue.sharedbaiyue-common-security${project.version} org.springframework.bootspring-boot-starter-aop org.springframework.bootspring-boot-starter-web + org.springframeworkspring-jdbc + + org.apache.rocketmq + rocketmq-spring-boot-starter + true + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java b/common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java index 75a59edb2..9f0e7da97 100644 --- a/common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java +++ b/common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java @@ -6,7 +6,7 @@ import java.time.Instant; * 跨服务业务审计事件契约,记录操作对象、操作者、租户、请求上下文、执行结果和变更快照。 */ public record AuditEvent( - String eventType, String resourceType, String resourceId, String description, + String eventId, String eventType, String resourceType, String resourceId, String description, String requestId, String traceId, Long tenantId, Long operatorTenantId, Long userId, String username, String appCode, String clientType, String httpMethod, String requestUri, String clientIp, long durationMs, diff --git a/common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxPublisher.java b/common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxPublisher.java new file mode 100644 index 000000000..2548a2262 --- /dev/null +++ b/common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxPublisher.java @@ -0,0 +1,62 @@ +package com.baiyue.common.audit; + +import org.apache.rocketmq.spring.core.RocketMQTemplate; +import org.apache.rocketmq.spring.support.RocketMQHeaders; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.scheduling.annotation.Scheduled; + +/** 带租约、退避、死信和自动补偿的审计 Outbox 发布器。 */ +public class AuditOutboxPublisher { + private static final Logger log = LoggerFactory.getLogger(AuditOutboxPublisher.class); + private static final int[] BACKOFF_SECONDS = {5, 15, 60, 300, 900}; + private final AuditOutboxRepository repository; + private final RocketMQTemplate rocketMQ; + private final AuditProperties properties; + private final String owner; + + public AuditOutboxPublisher(AuditOutboxRepository repository, RocketMQTemplate rocketMQ, + AuditProperties properties, String owner) { + this.repository = repository; + this.rocketMQ = rocketMQ; + this.properties = properties; + this.owner = owner; + } + + @Scheduled(fixedDelayString = "${baiyue.audit.outbox.poll-interval-ms:5000}") + public void publish() { + AuditProperties.Outbox config = properties.getOutbox(); + repository.releaseExpiredClaims(); + repository.compensateDead(config.getCompensationDelayMinutes(), config.getMaxCompensations()); + repository.deleteSent(config.getRetentionDays()); + for (String eventId : repository.publishableIds(config.getBatchSize())) { + if (!repository.claim(eventId, owner, config.getLeaseSeconds())) { + continue; + } + AuditOutboxRepository.OutboxRow row = repository.find(eventId); + if (row == null) { + continue; + } + try { + var message = MessageBuilder.withPayload(row.payloadJson()) + .setHeader(RocketMQHeaders.KEYS, eventId) + .setHeader("eventId", eventId) + .build(); + rocketMQ.syncSend(properties.getRocketmq().getTopic() + ":AUDIT", message, + properties.getRocketmq().getSendTimeoutMs()); + repository.sent(eventId, owner); + } catch (RuntimeException exception) { + int index = Math.min(Math.max(row.attemptCount(), 1) - 1, BACKOFF_SECONDS.length - 1); + repository.failed(eventId, owner, config.getMaxAttempts(), BACKOFF_SECONDS[index], + safe(exception.getMessage())); + log.warn("审计事件投递失败 eventId={} attempt={}", eventId, row.attemptCount(), exception); + } + } + } + + private static String safe(String value) { + String message = value == null ? "RocketMQ 发送失败" : value; + return message.length() <= 1000 ? message : message.substring(0, 1000); + } +} diff --git a/common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxRepository.java b/common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxRepository.java new file mode 100644 index 000000000..9083d5368 --- /dev/null +++ b/common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxRepository.java @@ -0,0 +1,94 @@ +package com.baiyue.common.audit; + +import jakarta.annotation.Resource; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; + +/** 业务库本地审计 Outbox 仓储。 */ +public class AuditOutboxRepository { + @Resource + private JdbcTemplate jdbc; + + public void append(AuditEvent event, String payload) { + jdbc.update(""" + INSERT INTO sys_audit_outbox + (event_id,event_type,resource_type,resource_id,service_name,payload_json,status, + attempt_count,compensation_count,next_retry_at,created_at,updated_at) + VALUES (?,?,?,?,?,CAST(? AS JSON),'PENDING',0,0,CURRENT_TIMESTAMP(3), + CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """, event.eventId(), event.eventType(), event.resourceType(), event.resourceId(), + event.serviceName(), payload); + } + + public List publishableIds(int limit) { + return jdbc.queryForList(""" + SELECT event_id FROM sys_audit_outbox + WHERE status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3) + ORDER BY next_retry_at,created_at LIMIT ? + """, String.class, limit); + } + + public boolean claim(String eventId, String owner, int leaseSeconds) { + return jdbc.update(""" + UPDATE sys_audit_outbox SET status='SENDING',lease_owner=?, + lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND), + attempt_count=attempt_count+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE event_id=? AND status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3) + """, owner, leaseSeconds, eventId) == 1; + } + + public OutboxRow find(String eventId) { + List rows = jdbc.query( + "SELECT event_id,payload_json,attempt_count FROM sys_audit_outbox WHERE event_id=?", + (rs, rowNum) -> new OutboxRow(rs.getString(1), rs.getString(2), rs.getInt(3)), eventId); + return rows.isEmpty() ? null : rows.get(0); + } + + public boolean sent(String eventId, String owner) { + return jdbc.update(""" + UPDATE sys_audit_outbox SET status='SENT',sent_at=CURRENT_TIMESTAMP(3), + lease_owner=NULL,lease_until=NULL,last_error=NULL,updated_at=CURRENT_TIMESTAMP(3) + WHERE event_id=? AND status='SENDING' AND lease_owner=? + """, eventId, owner) == 1; + } + + public boolean failed(String eventId, String owner, int maxAttempts, int backoffSeconds, String error) { + return jdbc.update(""" + UPDATE sys_audit_outbox + SET status=IF(attempt_count>=?,'DEAD','PENDING'), + next_retry_at=IF(attempt_count>=?,next_retry_at, + DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND)), + dead_at=IF(attempt_count>=?,CURRENT_TIMESTAMP(3),dead_at), + lease_owner=NULL,lease_until=NULL,last_error=?,updated_at=CURRENT_TIMESTAMP(3) + WHERE event_id=? AND status='SENDING' AND lease_owner=? + """, maxAttempts, maxAttempts, backoffSeconds, maxAttempts, error, eventId, owner) == 1; + } + + public int releaseExpiredClaims() { + return jdbc.update(""" + UPDATE sys_audit_outbox SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3), + lease_owner=NULL,lease_until=NULL,updated_at=CURRENT_TIMESTAMP(3) + WHERE status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 100 + """); + } + + public int compensateDead(int delayMinutes, int maxCompensations) { + return jdbc.update(""" + UPDATE sys_audit_outbox SET status='PENDING',attempt_count=0, + compensation_count=compensation_count+1,next_retry_at=CURRENT_TIMESTAMP(3), + dead_at=NULL,last_error=NULL,updated_at=CURRENT_TIMESTAMP(3) + WHERE status='DEAD' AND compensation_count outbox, + ObjectProvider transactionManagers, + @Value("${baiyue.security.internal-service-token:}") String internalServiceToken) { + AuditOutboxRepository repository = outbox.getIfAvailable(); + PlatformTransactionManager transactionManager = transactionManagers.getIfAvailable(); + if (p.getOutbox().isEnabled() && p.getOutbox().isPublisherEnabled() + && repository != null && transactionManager != null) { + TransactionTemplate transactions = new TransactionTemplate(transactionManager); + transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return new OutboxAuditSink(repository, mapper, transactions); + } + return p.getEndpoint() == null || p.getEndpoint().isBlank() + ? new LoggingAuditSink() + : new HttpAuditSink(builder, p.getEndpoint(), internalServiceToken); } + @Bean + @ConditionalOnBean(JdbcTemplate.class) + @ConditionalOnProperty(prefix = "baiyue.audit.outbox", name = "enabled", havingValue = "true") + AuditOutboxRepository auditOutboxRepository() { return new AuditOutboxRepository(); } + @Bean SensitiveDataSanitizer sensitiveDataSanitizer(ObjectMapper mapper, AuditProperties p) { return new SensitiveDataSanitizer(mapper, p.getMaxPayloadLength()); } - @Bean BusinessAuditAspect businessAuditAspect(AuditSink sink, SensitiveDataSanitizer sanitizer, AuditProperties p) { return new BusinessAuditAspect(sink, sanitizer, p.getServiceName()); } + @Bean BusinessAuditAspect businessAuditAspect() { return new BusinessAuditAspect(); } } diff --git a/common-audit/src/main/java/com/baiyue/common/audit/BusinessAudit.java b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAudit.java index 9a67d641e..d309acfbd 100644 --- a/common-audit/src/main/java/com/baiyue/common/audit/BusinessAudit.java +++ b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAudit.java @@ -14,6 +14,8 @@ public @interface BusinessAudit { String event(); String resourceType(); String resourceId() default ""; // SpEL, e.g. #request.orderId + /** 业务数据所属客户 ID 的可选 SpEL;平台代客操作未配置时自动识别名为 tenantId 的参数。 */ + String tenantId() default ""; String description() default ""; boolean captureBefore() default true; boolean captureAfter() default true; diff --git a/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java index cb1136966..ac123afb5 100644 --- a/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java +++ b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java @@ -1,8 +1,11 @@ package com.baiyue.common.audit; +import jakarta.annotation.Resource; + import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.web.CorrelationContext; import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; @@ -19,7 +22,7 @@ import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import java.time.Instant; -import java.util.Arrays; +import java.util.UUID; /** * 业务审计切面,拦截审计注解方法并采集操作者、租户、请求、耗时、结果及脱敏前后快照。 @@ -27,19 +30,18 @@ import java.util.Arrays; @Aspect public class BusinessAuditAspect { private static final Logger log = LoggerFactory.getLogger(BusinessAuditAspect.class); - private final AuditSink sink; - private final SensitiveDataSanitizer sanitizer; - private final String serviceName; + @Resource + private AuditSink sink; + @Resource + private SensitiveDataSanitizer sanitizer; + @Value("${baiyue.audit.service-name:${spring.application.name:unknown-service}}") + private String serviceName; private final ExpressionParser parser = new SpelExpressionParser(); - public BusinessAuditAspect(AuditSink sink, SensitiveDataSanitizer sanitizer, String serviceName) { - this.sink = sink; this.sanitizer = sanitizer; this.serviceName = serviceName; - } - @Around("@annotation(audit)") public Object around(ProceedingJoinPoint pjp, BusinessAudit audit) throws Throwable { long start = System.nanoTime(); - String before = audit.captureBefore() ? sanitizer.sanitize(pjp.getArgs()) : null; + String before = audit.captureBefore() ? sanitizer.sanitize(businessArguments(pjp.getArgs())) : null; Object result = null; Throwable failure = null; try { result = pjp.proceed(); return result; } catch (Throwable ex) { failure = ex; throw ex; } @@ -54,9 +56,10 @@ public class BusinessAuditAspect { HttpServletRequest req = request(); AuthenticatedUser user = currentUser(); String resourceId = evaluateResourceId(pjp, a.resourceId()); - sink.publish(new AuditEvent(a.event(), a.resourceType(), resourceId, a.description(), + Long tenantId = resolveTenantId(pjp, a, user, req); + sink.publish(new AuditEvent(UUID.randomUUID().toString(), a.event(), a.resourceType(), resourceId, a.description(), req == null ? null : CorrelationContext.requestId(req), req == null ? null : CorrelationContext.traceId(req), - user == null ? headerLong(req, "X-Tenant-Id") : user.tenantId(), + tenantId, user == null ? headerLong(req, "X-Operator-Tenant-Id") : user.operatorTenantId(), user == null ? null : user.userId(), user == null ? null : user.username(), user == null ? header(req, "X-App-Code") : user.appCode(), user == null ? null : user.clientType(), @@ -76,11 +79,58 @@ public class BusinessAuditAspect { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); return auth != null && auth.getPrincipal() instanceof AuthenticatedUser u ? u : null; } + private Object[] businessArguments(Object[] arguments) { + if (arguments == null || arguments.length == 0) return new Object[0]; + return java.util.Arrays.stream(arguments) + .filter(argument -> !(argument instanceof AuthenticatedUser)) + .toArray(); + } private String evaluateResourceId(ProceedingJoinPoint p, String expression) { if (expression == null || expression.isBlank()) return null; + Object value = evaluate(p, expression); + return value == null ? null : value.toString(); + } + private Long resolveTenantId(ProceedingJoinPoint p, BusinessAudit audit, AuthenticatedUser user, + HttpServletRequest request) { + if (user == null) return headerLong(request, "X-Tenant-Id"); + if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0L).equals(user.tenantId())) { + return user.tenantId(); + } + Long explicit = evaluatePositiveLong(p, audit.tenantId()); + if (explicit != null) return explicit; + MethodSignature signature = (MethodSignature) p.getSignature(); + String[] parameterNames = signature.getParameterNames(); + if (parameterNames == null) { + parameterNames = new DefaultParameterNameDiscoverer().getParameterNames(signature.getMethod()); + } + Object[] arguments = p.getArgs(); + if (parameterNames != null) { + for (int index = 0; index < parameterNames.length && index < arguments.length; index++) { + if ("tenantId".equals(parameterNames[index])) { + Long candidate = positiveLong(arguments[index]); + if (candidate != null) return candidate; + } + } + } + return user.tenantId(); + } + private Long evaluatePositiveLong(ProceedingJoinPoint p, String expression) { + if (expression == null || expression.isBlank()) return null; + return positiveLong(evaluate(p, expression)); + } + private Object evaluate(ProceedingJoinPoint p, String expression) { MethodSignature s = (MethodSignature) p.getSignature(); var ctx = new MethodBasedEvaluationContext(p.getTarget(), s.getMethod(), p.getArgs(), new DefaultParameterNameDiscoverer()); - Object v = parser.parseExpression(expression).getValue(ctx); return v == null ? null : v.toString(); + return parser.parseExpression(expression).getValue(ctx); + } + private static Long positiveLong(Object value) { + if (value == null) return null; + try { + long candidate = value instanceof Number number ? number.longValue() : Long.parseLong(value.toString()); + return candidate > 0 ? candidate : null; + } catch (NumberFormatException ignored) { + return null; + } } private static String header(HttpServletRequest r, String n) { return r == null ? null : r.getHeader(n); } private static Long headerLong(HttpServletRequest r, String n) { try { return header(r, n) == null ? null : Long.valueOf(header(r, n)); } catch (NumberFormatException e) { return null; } } diff --git a/common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java b/common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java index 0a8aa2a19..a623e1875 100644 --- a/common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java +++ b/common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java @@ -8,17 +8,28 @@ import org.springframework.web.client.RestClient; * HTTP 审计输出实现,将结构化业务审计事件发送到独立审计服务。 */ public class HttpAuditSink implements AuditSink { + public static final String INTERNAL_SERVICE_TOKEN_HEADER = "X-Internal-Service-Token"; private static final Logger auditLog = LoggerFactory.getLogger("BAIYUE_AUDIT"); private final RestClient client; private final String endpoint; + private final String internalServiceToken; - public HttpAuditSink(RestClient.Builder builder, String endpoint) { + public HttpAuditSink(RestClient.Builder builder, String endpoint, String internalServiceToken) { this.client = builder.build(); this.endpoint = endpoint; + this.internalServiceToken = internalServiceToken; } - @Override public void publish(AuditEvent event) { - try { client.post().uri(endpoint).body(event).retrieve().toBodilessEntity(); } + @Override + public void publish(AuditEvent event) { + try { + client.post() + .uri(endpoint) + .header(INTERNAL_SERVICE_TOKEN_HEADER, internalServiceToken) + .body(event) + .retrieve() + .toBodilessEntity(); + } catch (RuntimeException ex) { auditLog.warn("deliveryFailed errorType={} errorMessage={} audit={}", ex.getClass().getSimpleName(), ex.getMessage(), event); diff --git a/common-audit/src/main/java/com/baiyue/common/audit/OutboxAuditSink.java b/common-audit/src/main/java/com/baiyue/common/audit/OutboxAuditSink.java new file mode 100644 index 000000000..3a887797a --- /dev/null +++ b/common-audit/src/main/java/com/baiyue/common/audit/OutboxAuditSink.java @@ -0,0 +1,33 @@ +package com.baiyue.common.audit; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.transaction.support.TransactionOperations; + +/** 将审计事件先持久化到业务库 Outbox,核心业务不等待 MQ。 */ +public class OutboxAuditSink implements AuditSink { + private static final Logger log = LoggerFactory.getLogger(OutboxAuditSink.class); + private final AuditOutboxRepository repository; + private final ObjectMapper json; + private final TransactionOperations transactions; + + public OutboxAuditSink(AuditOutboxRepository repository, ObjectMapper json, + TransactionOperations transactions) { + this.repository = repository; + this.json = json; + this.transactions = transactions; + } + + @Override + public void publish(AuditEvent event) { + try { + String payload = json.writeValueAsString(event); + transactions.executeWithoutResult(status -> repository.append(event, payload)); + } catch (JsonProcessingException | RuntimeException exception) { + log.error("审计事件写入本地 Outbox 失败 eventId={} eventType={}", + event.eventId(), event.eventType(), exception); + } + } +} diff --git a/common-audit/src/main/java/com/baiyue/common/audit/SensitiveDataSanitizer.java b/common-audit/src/main/java/com/baiyue/common/audit/SensitiveDataSanitizer.java index 9bbe9d633..3248bb1de 100644 --- a/common-audit/src/main/java/com/baiyue/common/audit/SensitiveDataSanitizer.java +++ b/common-audit/src/main/java/com/baiyue/common/audit/SensitiveDataSanitizer.java @@ -30,7 +30,11 @@ public class SensitiveDataSanitizer { JsonNode root = mapper.valueToTree(value); redact(root); String json = mapper.writeValueAsString(root); - return json.length() <= maxLength ? json : json.substring(0, maxLength) + "...[truncated]"; + if (json.length() <= maxLength) return json; + ObjectNode truncated = mapper.createObjectNode(); + truncated.put("_truncated", true); + truncated.put("_originalLength", json.length()); + return mapper.writeValueAsString(truncated); } catch (IllegalArgumentException | JsonProcessingException e) { return "\"[unserializable]\""; } diff --git a/common-audit/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-audit/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 279053f24..7ad45d4f2 100644 --- a/common-audit/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-audit/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1,2 @@ com.baiyue.common.audit.BaiyueAuditAutoConfiguration +com.baiyue.common.audit.AuditOutboxRocketMqAutoConfiguration diff --git a/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java b/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java new file mode 100644 index 000000000..792347408 --- /dev/null +++ b/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java @@ -0,0 +1,110 @@ +package com.baiyue.common.audit; + +import com.baiyue.common.security.AuthenticatedUser; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Set; + +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.when; + +class BusinessAuditAspectTest { + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void platformOperationUsesTargetCustomerAndKeepsOperatorScope() throws Throwable { + AuditEvent event = invoke(new AuthenticatedUser(7L, 0L, 0L, 1L, + "platform.admin", "PLATFORM", "WEB", Set.of("platform:customer:edit")), 10001L); + + assertThat(event.tenantId()).isEqualTo(10001L); + assertThat(event.operatorTenantId()).isZero(); + assertThat(event.userId()).isEqualTo(7L); + assertThat(event.appCode()).isEqualTo("PLATFORM"); + assertThat(event.beforeData()).isEqualTo("[10001]"); + assertThat(event.beforeData()).doesNotContain("platform.admin", "platform:customer:edit", "permissions"); + } + + @Test + void merchantOperationIgnoresTargetCustomerArgument() throws Throwable { + AuditEvent event = invoke(new AuthenticatedUser(8L, 10001L, 10001L, 2L, + "merchant.user", "MERCHANT", "WEB", Set.of()), 20002L); + + assertThat(event.tenantId()).isEqualTo(10001L); + assertThat(event.operatorTenantId()).isEqualTo(10001L); + } + + @Test + void failedOperationStillPublishesSanitizedBeforeSnapshot() throws Throwable { + AuditEvent event = invokeFailure(new AuthenticatedUser(7L, 0L, 0L, 1L, + "platform.admin", "PLATFORM", "WEB", Set.of()), 10001L); + + assertThat(event.success()).isFalse(); + assertThat(event.errorCode()).isEqualTo("IllegalStateException"); + assertThat(event.beforeData()).isEqualTo("[10001]"); + assertThat(event.afterData()).isNull(); + } + + private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId) throws Throwable { + return invoke(user, requestedTenantId, null); + } + + private static AuditEvent invokeFailure(AuthenticatedUser user, long requestedTenantId) throws Throwable { + return invoke(user, requestedTenantId, new IllegalStateException("validation failed")); + } + + private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId, Throwable failure) throws Throwable { + AuditSink sink = mock(AuditSink.class); + BusinessAuditAspect aspect = new BusinessAuditAspect(); + ReflectionTestUtils.setField(aspect, "sink", sink); + ReflectionTestUtils.setField(aspect, "sanitizer", new SensitiveDataSanitizer(new ObjectMapper(), 1024)); + ReflectionTestUtils.setField(aspect, "serviceName", "test-service"); + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, List.of())); + + TestOperations target = new TestOperations(); + Method method = TestOperations.class.getDeclaredMethod("update", AuthenticatedUser.class, long.class); + MethodSignature signature = mock(MethodSignature.class); + when(signature.getMethod()).thenReturn(method); + when(signature.getParameterNames()).thenReturn(null); + + ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); + when(joinPoint.getSignature()).thenReturn(signature); + when(joinPoint.getTarget()).thenReturn(target); + when(joinPoint.getArgs()).thenReturn(new Object[]{user, requestedTenantId}); + if (failure == null) when(joinPoint.proceed()).thenReturn("ok"); + else when(joinPoint.proceed()).thenThrow(failure); + + try { + aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class)); + } catch (Throwable error) { + if (failure == null) throw error; + } + + ArgumentCaptor event = ArgumentCaptor.forClass(AuditEvent.class); + verify(sink).publish(event.capture()); + return event.getValue(); + } + + private static final class TestOperations { + @BusinessAudit(event = "TEST_UPDATE", resourceType = "TEST", captureAfter = false) + public String update(AuthenticatedUser user, long tenantId) { + return "ok"; + } + } +} diff --git a/common-audit/src/test/java/com/baiyue/common/audit/HttpAuditSinkTest.java b/common-audit/src/test/java/com/baiyue/common/audit/HttpAuditSinkTest.java new file mode 100644 index 000000000..6dddeb2cd --- /dev/null +++ b/common-audit/src/test/java/com/baiyue/common/audit/HttpAuditSinkTest.java @@ -0,0 +1,40 @@ +package com.baiyue.common.audit; + +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withAccepted; + +class HttpAuditSinkTest { + private static final String ENDPOINT = "http://audit-service/internal/audit/events"; + private static final String TOKEN = "0123456789abcdef0123456789abcdef"; + + @Test + void sendsInternalServiceToken() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(once(), requestTo(ENDPOINT)) + .andExpect(method(HttpMethod.POST)) + .andExpect(header(HttpAuditSink.INTERNAL_SERVICE_TOKEN_HEADER, TOKEN)) + .andRespond(withAccepted().contentType(MediaType.APPLICATION_JSON)); + + new HttpAuditSink(builder, ENDPOINT, TOKEN).publish(sampleEvent()); + + server.verify(); + } + + private AuditEvent sampleEvent() { + return new AuditEvent("audit-1", "ROLE_UPDATE", "ROLE", "1", "update role", + "request-1", "trace-1", 1L, 1L, 1L, "admin", "PLATFORM", "WEB", + "PUT", "/api/platform/v1/roles/1", "127.0.0.1", 10L, true, + null, null, null, null, Instant.parse("2026-08-05T00:00:00Z"), "iam"); + } +} diff --git a/common-audit/src/test/java/com/baiyue/common/audit/ReliableAuditDeliveryTest.java b/common-audit/src/test/java/com/baiyue/common/audit/ReliableAuditDeliveryTest.java new file mode 100644 index 000000000..9c7f8780d --- /dev/null +++ b/common-audit/src/test/java/com/baiyue/common/audit/ReliableAuditDeliveryTest.java @@ -0,0 +1,113 @@ +package com.baiyue.common.audit; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.apache.rocketmq.spring.core.RocketMQTemplate; +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.messaging.Message; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.support.TransactionOperations; + +import java.time.Instant; +import java.util.List; + +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.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(ReliableAuditDeliveryTest.Config.class) +class ReliableAuditDeliveryTest { + @MockBean + private AuditOutboxRepository repository; + @MockBean + private RocketMQTemplate rocketMQ; + @MockBean + private TransactionOperations transactions; + @Resource + private OutboxAuditSink sink; + @Resource + private AuditOutboxPublisher publisher; + + @Test + void storesEventBeforeDelivery() { + AuditEvent event = event("audit-1"); + org.mockito.Mockito.doAnswer(invocation -> { + java.util.function.Consumer callback = invocation.getArgument(0); + ((java.util.function.Consumer) callback).accept(null); + return null; + }).when(transactions).executeWithoutResult(any()); + + sink.publish(event); + + verify(repository).append(eq(event), anyString()); + } + + @Test + void marksDeliveredMessageSent() { + when(repository.publishableIds(50)).thenReturn(List.of("audit-1")); + when(repository.claim("audit-1", "test-owner", 60)).thenReturn(true); + when(repository.find("audit-1")) + .thenReturn(new AuditOutboxRepository.OutboxRow("audit-1", "{}", 1)); + + publisher.publish(); + + verify(rocketMQ).syncSend(anyString(), any(Message.class), anyLong()); + verify(repository).sent("audit-1", "test-owner"); + } + + @Test + void schedulesRetryAfterMqFailure() { + when(repository.publishableIds(50)).thenReturn(List.of("audit-2")); + when(repository.claim("audit-2", "test-owner", 60)).thenReturn(true); + when(repository.find("audit-2")) + .thenReturn(new AuditOutboxRepository.OutboxRow("audit-2", "{}", 1)); + doThrow(new IllegalStateException("broker unavailable")) + .when(rocketMQ).syncSend(anyString(), any(Message.class), anyLong()); + + publisher.publish(); + + verify(repository).failed("audit-2", "test-owner", 5, 5, "broker unavailable"); + } + + private static AuditEvent event(String id) { + return new AuditEvent(id, "ROLE_UPDATE", "ROLE", "1", "修改角色", + "request-1", "trace-1", 1L, 1L, 1L, "admin", "PLATFORM", "WEB", + "PUT", "/roles/1", "127.0.0.1", 10, true, null, null, + null, null, Instant.parse("2026-08-05T00:00:00Z"), "iam-service"); + } + + @Configuration + static class Config { + @Bean + ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); } + + @Bean + AuditProperties properties() { + AuditProperties value = new AuditProperties(); + value.getOutbox().setBatchSize(50); + value.getOutbox().setLeaseSeconds(60); + value.getOutbox().setMaxAttempts(5); + value.getRocketmq().setTopic("test-baiyue-audit-event-v1"); + return value; + } + + @Bean + OutboxAuditSink sink(AuditOutboxRepository repository, ObjectMapper json, + TransactionOperations transactions) { + return new OutboxAuditSink(repository, json, transactions); + } + + @Bean + AuditOutboxPublisher publisher(AuditOutboxRepository repository, RocketMQTemplate rocketMQ, + AuditProperties properties) { + return new AuditOutboxPublisher(repository, rocketMQ, properties, "test-owner"); + } + } +} diff --git a/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java b/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java new file mode 100644 index 000000000..ab9ee8e3a --- /dev/null +++ b/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java @@ -0,0 +1,38 @@ +package com.baiyue.common.audit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SensitiveDataSanitizerTest { + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void truncatedSnapshotRemainsValidJson() throws Exception { + SensitiveDataSanitizer sanitizer = new SensitiveDataSanitizer(mapper, 40); + + String sanitized = sanitizer.sanitize(Map.of("description", "x".repeat(200))); + JsonNode parsed = mapper.readTree(sanitized); + + assertThat(parsed.path("_truncated").asBoolean()).isTrue(); + assertThat(parsed.path("_originalLength").asInt()).isGreaterThan(40); + } + + @Test + void redactsIdentityAndContactAliasesInsideNestedObjects() throws Exception { + SensitiveDataSanitizer sanitizer = new SensitiveDataSanitizer(mapper, 1024); + + String sanitized = sanitizer.sanitize(Map.of( + "profile", Map.of("idCard", "4401...", "contactPhone", "13800138000"), + "contacts", java.util.List.of(Map.of("emailAddress", "user@example.com")))); + JsonNode parsed = mapper.readTree(sanitized); + + assertThat(parsed.at("/profile/idCard").asText()).isEqualTo("******"); + assertThat(parsed.at("/profile/contactPhone").asText()).isEqualTo("******"); + assertThat(parsed.at("/contacts/0/emailAddress").asText()).isEqualTo("******"); + } +} diff --git a/common-core/AGENTS.md b/common-core/AGENTS.md new file mode 100644 index 000000000..277a9fa7d --- /dev/null +++ b/common-core/AGENTS.md @@ -0,0 +1,25 @@ +# Common Core 模块指引 + +## 职责边界 + +- 提供不依赖具体业务域的返回模型、异常、基础实体和 MyBatis-Plus 通用配置。 +- 不引入订单、仓库、租户等领域实体,不调用业务服务,也不承载 Controller。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 统一响应与分页 | `src/main/java/com/baiyue/common/core/api/ApiResponse.java`、`PageResult.java` | +| 返回码与下游 code 还原 | `src/main/java/com/baiyue/common/core/api/ResultCode.java`;成功码是字符串 `"200"`,`ResultCode.of` 的未知 code 降级为 `B0001` | +| 业务异常 | `src/main/java/com/baiyue/common/core/exception/BizException.java` | +| 通用审计字段 | `src/main/java/com/baiyue/common/core/model/BaseEntity.java` | +| MyBatis-Plus 与审计人 | `src/main/java/com/baiyue/common/core/mybatis/` | +| 响应契约测试 | `src/test/java/com/baiyue/common/core/ApiResponseTest.java` | + +## 修改与验证 + +- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。 +- `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。 +- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。 +- 新能力只有被多个模块稳定复用时才放入本模块。 +- 从父目录运行 `mvn -pl common-core -am test`。 diff --git a/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java b/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java index edda90feb..848b89ce1 100644 --- a/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java +++ b/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java @@ -2,20 +2,33 @@ package com.baiyue.common.core.api; import org.springframework.http.HttpStatus; +import java.util.Arrays; + /** * 统一接口结果码枚举,定义业务错误编码、默认消息和对应 HTTP 状态。 */ public enum ResultCode { - SUCCESS("0", "success", HttpStatus.OK), + /** 请求处理成功。 */ + SUCCESS("200", "success", HttpStatus.OK), + /** 请求参数不符合接口要求。 */ BAD_REQUEST("A0400", "Bad request", HttpStatus.BAD_REQUEST), + /** 未登录、令牌无效或登录态已过期。 */ UNAUTHORIZED("A0401", "Authentication required", HttpStatus.UNAUTHORIZED), + /** 已登录但没有操作权限。 */ FORBIDDEN("A0403", "Access denied", HttpStatus.FORBIDDEN), + /** 请求的资源不存在或不在可访问范围内。 */ NOT_FOUND("A0404", "Resource not found", HttpStatus.NOT_FOUND), + /** 资源版本、幂等键或当前状态发生冲突。 */ CONFLICT("A0409", "Resource conflict", HttpStatus.CONFLICT), + /** 字段校验、格式校验或业务前置校验未通过。 */ VALIDATION_FAILED("A0422", "Validation failed", HttpStatus.UNPROCESSABLE_ENTITY), + /** 请求频率或并发数量超过限制。 */ TOO_MANY_REQUESTS("A0429", "Too many requests", HttpStatus.TOO_MANY_REQUESTS), + /** 未被更细错误码覆盖的通用业务失败。 */ BUSINESS_ERROR("B0001", "Business operation failed", HttpStatus.BAD_REQUEST), + /** 服务内部发生未预期异常。 */ INTERNAL_ERROR("S0500", "Internal server error", HttpStatus.INTERNAL_SERVER_ERROR), + /** 下游服务、数据库或基础设施暂不可用。 */ SERVICE_UNAVAILABLE("S0503", "Service unavailable", HttpStatus.SERVICE_UNAVAILABLE); private final String code; @@ -31,4 +44,15 @@ public enum ResultCode { public String code() { return code; } public String message() { return message; } public HttpStatus httpStatus() { return httpStatus; } + + /** + * 将下游统一响应中的业务码还原为本地结果码。 + * 未知或空业务码按通用业务错误处理,避免把契约漂移误判为成功。 + */ + public static ResultCode of(String code) { + return Arrays.stream(values()) + .filter(value -> value.code.equals(code)) + .findFirst() + .orElse(BUSINESS_ERROR); + } } diff --git a/common-core/src/main/java/com/baiyue/common/core/container/ContainerSpecificationCatalog.java b/common-core/src/main/java/com/baiyue/common/core/container/ContainerSpecificationCatalog.java new file mode 100644 index 000000000..7a955bdc8 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/container/ContainerSpecificationCatalog.java @@ -0,0 +1,56 @@ +package com.baiyue.common.core.container; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** Platform-wide container types and practical loading limits. */ +public final class ContainerSpecificationCatalog { + public static final String DEFAULT_CONTAINER_TYPE = "40HQ"; + + private static final List ORDERED_VALUES = List.of( + new Specification("40GP", new BigDecimal("56"), new BigDecimal("58"), new BigDecimal("19500"), + "美线电商较少使用,通常优先选择40HQ"), + new Specification("40HQ", new BigDecimal("65"), new BigDecimal("68"), new BigDecimal("19500"), + "轻泡货首选,适合家居、玩具、日用品"), + new Specification("45HQ", new BigDecimal("82"), new BigDecimal("86"), new BigDecimal("19500"), + "抛货量大时考虑,底盘和海外仓预约资源较少") + ); + private static final Map VALUES = ORDERED_VALUES.stream() + .collect(Collectors.toUnmodifiableMap(Specification::containerType, Function.identity())); + + private ContainerSpecificationCatalog() {} + + public static List values() { + return ORDERED_VALUES; + } + + public static Specification require(String containerType) { + String normalized = containerType == null ? "" : containerType.trim().toUpperCase(Locale.ROOT); + Specification specification = VALUES.get(normalized); + if (specification == null) { + throw new BizException(ResultCode.VALIDATION_FAILED, "柜型仅支持40GP、40HQ、45HQ"); + } + return specification; + } + + public static Specification requireMatch(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) { + Specification specification = require(containerType); + if (capacityCbm == null || maxWeightKg == null + || specification.capacityCbm().compareTo(capacityCbm) != 0 + || specification.maxWeightKg().compareTo(maxWeightKg) != 0) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "柜型容量和货重上限必须使用平台统一定义"); + } + return specification; + } + + public record Specification(String containerType, BigDecimal recommendedMinCbm, BigDecimal capacityCbm, + BigDecimal maxWeightKg, String description) {} +} diff --git a/common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java b/common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java new file mode 100644 index 000000000..d62b1c13d --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java @@ -0,0 +1,41 @@ +package com.baiyue.common.core.export; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; + +/** Versioned contracts shared by File Service and business export providers. */ +public final class ExportContracts { + private ExportContracts() { } + + public record ExportCommand( + int schemaVersion, String eventId, String taskId, String exportType, + int dispatchVersion, OffsetDateTime occurredAt, String traceId) { } + + public record ExportBatchRequest( + String taskId, String tenantId, String appCode, String requestedBy, + int dispatchVersion, Map parameters, String cursor, + String snapshotToken, int limit) { } + + public record ExportBatchResponse( + int schemaVersion, String schemaHash, String snapshotToken, + List columns, List> rows, + String nextCursor, boolean hasMore, Long totalRows) { } + + public record ExportColumn(String key, String title, ExportColumnType type, Integer width) { } + + public enum ExportColumnType { + /** 文本列。 */ + STRING, + /** 整数列。 */ + INTEGER, + /** 高精度小数列。 */ + DECIMAL, + /** 布尔值列。 */ + BOOLEAN, + /** 日期列,不包含时间。 */ + DATE, + /** 日期时间列。 */ + DATETIME + } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java b/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java index 1ba26fbea..ca94dff69 100644 --- a/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java +++ b/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java @@ -3,23 +3,36 @@ package com.baiyue.common.core; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.List; +import java.time.Instant; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@DisplayName("统一响应对象功能") class ApiResponseTest { @Test void createsSuccessfulResponseWithCorrelationIds() { ApiResponse response = ApiResponse.success("ok", "request-1", "trace-1"); + assertThat(response.code()).isEqualTo("200"); assertThat(response.code()).isEqualTo(ResultCode.SUCCESS.code()); assertThat(response.successful()).isTrue(); assertThat(response.requestId()).isEqualTo("request-1"); assertThat(response.timestamp()).isNotNull(); } + @Test + void resolvesKnownResultCodeAndFallsBackForUnknownCode() { + ApiResponse legacy = new ApiResponse<>("0", "legacy", null, null, null, Instant.now()); + assertThat(legacy.successful()).isFalse(); + assertThat(ResultCode.of("A0409")).isEqualTo(ResultCode.CONFLICT); + assertThat(ResultCode.of("UNKNOWN")).isEqualTo(ResultCode.BUSINESS_ERROR); + assertThat(ResultCode.of(null)).isEqualTo(ResultCode.BUSINESS_ERROR); + } + @Test void pageResultIsImmutableAndRejectsInvalidMetadata() { PageResult result = new PageResult<>(List.of("a"), 1, 20, 1, 1); diff --git a/common-core/src/test/java/com/baiyue/common/core/container/ContainerSpecificationCatalogTest.java b/common-core/src/test/java/com/baiyue/common/core/container/ContainerSpecificationCatalogTest.java new file mode 100644 index 000000000..058c744c9 --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/container/ContainerSpecificationCatalogTest.java @@ -0,0 +1,33 @@ +package com.baiyue.common.core.container; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ContainerSpecificationCatalogTest { + @Test + void exposesTheThreeCanonicalContainerTypesInBusinessOrder() { + assertThat(ContainerSpecificationCatalog.values()) + .extracting(ContainerSpecificationCatalog.Specification::containerType) + .containsExactly("40GP", "40HQ", "45HQ"); + assertThat(ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE).isEqualTo("40HQ"); + } + + @Test + void exposesPracticalLoadingRangesAndRejectsRemovedTypes() { + var specification = ContainerSpecificationCatalog.requireMatch( + "40hq", new BigDecimal("68.000"), new BigDecimal("19500.000")); + + assertThat(specification.containerType()).isEqualTo("40HQ"); + assertThat(specification.recommendedMinCbm()).isEqualByComparingTo("65"); + assertThatThrownBy(() -> ContainerSpecificationCatalog.require("40HC")) + .isInstanceOf(BizException.class).hasMessageContaining("40GP、40HQ、45HQ"); + assertThatThrownBy(() -> ContainerSpecificationCatalog.requireMatch( + "40HQ", new BigDecimal("76"), new BigDecimal("19500"))) + .isInstanceOf(BizException.class).hasMessageContaining("统一定义"); + } +} diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md new file mode 100644 index 000000000..5f107b109 --- /dev/null +++ b/common-event-contract/AGENTS.md @@ -0,0 +1,36 @@ +# Common Event Contract 模块指引 + +## 职责边界 + +- 维护国内头程与国外尾程之间稳定、可版本化的事件契约。 +- 只放事件信封、类型、区域、标识、规范化、哈希和校验;不放任一侧领域 Entity、数据库模型或发送实现。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 事件信封 | `src/main/java/com/baiyue/common/event/EventEnvelope.java` | +| 契约校验 | `EventEnvelopeValidator.java` | +| 事件 ID 与业务键 | `EventIdentifiers.java` | +| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | +| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | +| 头程装柜完成 V3(当前生产) | `FirstLegLoadingPlanCompletedV3.java` 在 V2 订单快照上增加物理柜空仓、半仓、满仓、锁仓、带锁码五类不可变照片引用;订单级仍冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java`;V1/V2 仅用于历史事件读取 | +| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | +| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次,信封固定使用 `tenantId=0` 平台技术范围;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | +| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 | +| 海外派送完成 V1 | `OverseasDeliveryCompletedV1.java`、`EventTypes.OVERSEAS_DELIVERY_COMPLETED`;每个按 FBA 仓拆分的派送单完成后,按关联来源入仓单分别发布,携带来源订单、入仓码、FBA 仓和签收时间,国内幂等推进对应 Firstleg 入仓单与 Order 完成 | +| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | +| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 | +| 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 | +| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | +| 契约说明 | `package-info.java` | +| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`FirstLegLoadingPlanCompletedV3Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` | +| 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` | + +## 修改与验证 + +- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。 +- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。 +- 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/BusinessFeeSourceChangedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java new file mode 100644 index 000000000..012d99fc3 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java @@ -0,0 +1,61 @@ +package com.baiyue.common.event; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +/** + * 跨区域业务费用来源的完整不可变快照。 + * + *

海外业务与国内 Finance 不做同步回查,因此事件必须携带可独立校验、入池的完整快照。 + * 标识统一使用字符串,避免跨语言大整数精度损失。

+ */ +public record BusinessFeeSourceChangedV1( + String action, + String sourceSystem, + String sourceType, + String sourceFeeId, + int sourceVersion, + String customerId, + String customerName, + String sourceNo, + String businessType, + String businessId, + String businessNo, + String orderId, + String inboundId, + String inboundCode, + String containerId, + String containerBookingId, + String containerNo, + String warehouseId, + String feeItemCode, + String feeItemName, + String direction, + String supplierId, + String supplierName, + String supplierCategory, + String settlementMode, + Instant paidAt, + String paymentMethod, + String paymentReferenceNo, + List paymentVoucherFileIds, + LocalDate businessDate, + String currency, + BigDecimal totalAmount, + String originalCurrency, + BigDecimal originalAmount, + BigDecimal exchangeRate, + String exchangeRateMonth, + BigDecimal chargeableQuantity, + String quantityUnit, + BigDecimal unitPrice, + LocalDate periodStart, + LocalDate periodEnd, + String reviewStatus, + String publicationStatus, + String sourceStatus, + String sourceHash +) { +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/ContainerNumbers.java b/common-event-contract/src/main/java/com/baiyue/common/event/ContainerNumbers.java new file mode 100644 index 000000000..2358f022f --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/ContainerNumbers.java @@ -0,0 +1,44 @@ +package com.baiyue.common.event; + +import java.util.Locale; + +/** Canonical physical container number used by cross-region business facts. */ +public final class ContainerNumbers { + private static final int[] LETTER_VALUES = { + 10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38 + }; + + private ContainerNumbers() { } + + public static String normalizeRequired(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("containerNo is required"); + } + StringBuilder compact = new StringBuilder(value.length()); + value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint) + && !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint); + String normalized = compact.toString().toUpperCase(Locale.ROOT); + if (!normalized.matches("[A-Z]{3}[UJZ][0-9]{7}")) { + throw new IllegalArgumentException("containerNo must comply with ISO 6346"); + } + int sum = 0; + for (int index = 0; index < 10; index++) { + char current = normalized.charAt(index); + int number = Character.isDigit(current) ? current - '0' : LETTER_VALUES[current - 'A']; + sum += number * (1 << index); + } + if (normalized.charAt(10) - '0' != sum % 11 % 10) { + throw new IllegalArgumentException("containerNo must comply with ISO 6346 check digit"); + } + return normalized; + } + + public static String requireCanonical(String value) { + String normalized = normalizeRequired(value); + if (!normalized.equals(value)) { + throw new IllegalArgumentException("containerNo must be normalized"); + } + return normalized; + } +} 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 eb12cc898..17172f544 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 @@ -9,6 +9,18 @@ public final class EventTypes { public static final String ORDER_APPROVED = "OrderApproved"; 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 OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged"; + public static final String OVERSEAS_DELIVERY_COMPLETED = "OverseasDeliveryCompleted"; + public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"; + public static final String WALLET_COMMAND_REQUESTED = "WalletCommandRequested"; + public static final String WALLET_COMMAND_COMPLETED = "WalletCommandCompleted"; + public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged"; + public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; + public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged"; public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted"; public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected"; public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged"; diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java new file mode 100644 index 000000000..d7389536f --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java @@ -0,0 +1,52 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.Instant; +import java.util.Map; + +/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */ +public record FirstLegContainerShippingNodeRecordedV1( + String queryId, + String oceanBookingId, + @JsonProperty("sourceContainerBookingId") 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()) { + throw new IllegalArgumentException("船节点跟踪任务标识不能为空字符串"); + } + if (oceanBookingId != null && oceanBookingId.isBlank()) { + throw new IllegalArgumentException("船节点订舱标识不能为空字符串"); + } + if (containerBookingId == null || containerBookingId.isBlank() + || containerNo == null || containerNo.isBlank()) { + throw new IllegalArgumentException("船节点交接业务标识不能为空"); + } + containerNo = ContainerNumbers.requireCanonical(containerNo); + 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 new file mode 100644 index 000000000..926c4dafb --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java @@ -0,0 +1,119 @@ +package com.baiyue.common.event; + +import java.math.BigDecimal; +import java.util.List; + +/** 头程装柜完成后交给海外尾程的不可变 V1 快照。 */ +public record FirstLegLoadingPlanCompletedV1( + String loadingPlanId, String planNo, String containerBookingId, + String containerNo, String cabinetNo, String warehouseId, String warehouseName, + String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt, + List orders) { + public FirstLegLoadingPlanCompletedV1(String loadingPlanId, String planNo, String containerBookingId, + String containerNo, String cabinetNo, String warehouseId, + String warehouseName, List orders) { + this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId, + warehouseName, null, null, null, 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, + BigDecimal volumeWeightKg, + List items, String customerId, String customerName, String orderType, List pallets, + String destinationType, String lxCode) { + public Order { + items = items == null ? List.of() : items; + if (customerId == null || customerId.isBlank()) { + throw new IllegalArgumentException("customerId is required"); + } + if (customerName == null || customerName.isBlank()) { + throw new IllegalArgumentException("customerName is required"); + } + 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 customerName, + String orderType, List pallets) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName, + orderType, pallets, null, 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, String customerId, String customerName, + String orderType, List pallets, String destinationType) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName, + orderType, pallets, destinationType, 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, + BigDecimal volumeWeightKg, List items, String customerId, String customerName, + String orderType, List pallets, String destinationType) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, + packageCount, productQuantity, palletCount, grossWeightKg, netWeightKg, volumeCbm, + volumeWeightKg, items, customerId, customerName, orderType, pallets, destinationType, 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 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 new file mode 100644 index 000000000..ad7df7775 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -0,0 +1,176 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.math.BigDecimal; +import java.util.List; + +/** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */ +public record FirstLegLoadingPlanCompletedV2( + String loadingPlanId, String planNo, + @JsonProperty("sourceContainerBookingId") String containerBookingId, + String containerNo, String cabinetNo, String warehouseId, String warehouseName, + String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt, + List orders) { + public FirstLegLoadingPlanCompletedV2 { + if (loadingPlanId == null || loadingPlanId.isBlank() + || containerBookingId == null || containerBookingId.isBlank()) { + throw new IllegalArgumentException("loadingPlanId and sourceContainerBookingId are required"); + } + containerNo = ContainerNumbers.requireCanonical(containerNo); + orders = orders == null ? List.of() : List.copyOf(orders); + if (orders.isEmpty()) throw new IllegalArgumentException("orders are required"); + Order owner = orders.get(0); + if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId()) + || !owner.customerName().equals(order.customerName()))) { + throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot"); + } + } + + public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId, + String containerNo, String cabinetNo, String warehouseId, + String warehouseName, List orders) { + this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId, + warehouseName, null, null, null, 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, + BigDecimal volumeWeightKg, + List items, String customerId, String customerName, String orderType, List pallets, + /** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */ + String destinationType, String lxCode) { + public Order { + items = items == null ? List.of() : items; + if (customerId == null || customerId.isBlank()) { + throw new IllegalArgumentException("customerId is required"); + } + if (customerName == null || customerName.isBlank()) { + throw new IllegalArgumentException("customerName is required"); + } + customerId = customerId.trim(); + customerName = customerName.trim(); + if (customerId.length() > 128 || customerName.length() > 128) { + throw new IllegalArgumentException("customerId/customerName snapshot is too long"); + } + 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 customerName, + String orderType, List pallets) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName, + orderType, pallets, null, 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, String customerId, String customerName, + String orderType, List pallets, String destinationType) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName, + orderType, pallets, destinationType, 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, + BigDecimal volumeWeightKg, List items, String customerId, String customerName, + String orderType, List pallets, String destinationType) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, + packageCount, productQuantity, palletCount, grossWeightKg, netWeightKg, volumeCbm, + volumeWeightKg, items, customerId, customerName, orderType, pallets, destinationType, 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()); + } + } + + /** 普通 FBA 只复用 palletNo;一件代发还携带同一物理 DP 的完整清单摘要。 */ + public record PalletSnapshot(String palletNo, List skuPackages, + Integer manifestVersion, Integer manifestPackageCount, + Integer manifestFragmentCount, String manifestHash) { + public PalletSnapshot { + skuPackages = skuPackages == null ? List.of() : List.copyOf(skuPackages); + } + + public PalletSnapshot(String palletNo, List skuPackages) { + this(palletNo, skuPackages, null, null, null, null); + } + } + + /** 托盘下来源入库明细、SKU、箱数、数量及箱号冻结快照;同 SKU 多明细按 sourceInboundDetailId 精确区分。 */ + public record SkuPackageSnapshot(String sourceInboundDetailId, String skuCode, String productName, + int packageCount, int productQuantity, + List cartonMarks, java.util.Map cartonQuantities) { + public SkuPackageSnapshot { + if (sourceInboundDetailId == null || sourceInboundDetailId.isBlank()) { + throw new IllegalArgumentException("sourceInboundDetailId is required for each pallet SKU snapshot"); + } + if (cartonMarks == null || cartonQuantities == null || cartonMarks.size() != packageCount + || new java.util.HashSet<>(cartonMarks).size() != packageCount + || !cartonQuantities.keySet().equals(new java.util.HashSet<>(cartonMarks)) + || cartonQuantities.values().stream().anyMatch(quantity -> quantity == null || quantity <= 0) + || cartonQuantities.values().stream().mapToLong(Integer::longValue).sum() != productQuantity) { + throw new IllegalArgumentException("cartonQuantities must exactly cover cartonMarks and productQuantity"); + } + cartonMarks = List.copyOf(cartonMarks); + cartonQuantities = java.util.Map.copyOf(cartonQuantities); + } + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3.java new file mode 100644 index 000000000..188d15c68 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3.java @@ -0,0 +1,45 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** 装柜完成 V3:在 V2 货物快照上增加整柜五类国内装柜照片引用。 */ +public record FirstLegLoadingPlanCompletedV3( + String loadingPlanId, String planNo, + @JsonProperty("sourceContainerBookingId") String containerBookingId, + String containerNo, String cabinetNo, String warehouseId, String warehouseName, + String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt, + List orders, List loadingEvidence) { + public FirstLegLoadingPlanCompletedV3 { + if (loadingPlanId == null || loadingPlanId.isBlank() + || containerBookingId == null || containerBookingId.isBlank()) { + throw new IllegalArgumentException("loadingPlanId and sourceContainerBookingId are required"); + } + containerNo = ContainerNumbers.requireCanonical(containerNo); + orders = orders == null ? List.of() : List.copyOf(orders); + loadingEvidence = loadingEvidence == null ? List.of() : List.copyOf(loadingEvidence); + if (orders.isEmpty()) throw new IllegalArgumentException("orders are required"); + var owner = orders.get(0); + if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId()) + || !owner.customerName().equals(order.customerName()))) { + throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot"); + } + if (loadingEvidence.size() != 5 + || loadingEvidence.stream().map(LoadingEvidence::evidenceType).distinct().count() != 5) { + throw new IllegalArgumentException("five unique loading evidence types are required"); + } + } + + public record LoadingEvidence(String evidenceType, long fileId, String fileName) { + public LoadingEvidence { + if (evidenceType == null || !evidenceType.matches("PHOTO_STEP_[1-4]|SEAL")) { + throw new IllegalArgumentException("unsupported loading evidence type"); + } + if (fileId <= 0) throw new IllegalArgumentException("loading evidence fileId must be positive"); + if (fileName == null || fileName.isBlank() || fileName.length() > 255) { + throw new IllegalArgumentException("loading evidence fileName is required"); + } + fileName = fileName.trim(); + } + } +} 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..8d1251801 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java @@ -0,0 +1,25 @@ +package com.baiyue.common.event; + +import java.time.Instant; +import java.util.Set; + +/** IAM 向海外服务发布的 V1 安全投影事件负载。 */ +public final class IamSecurityEventsV1 { + public static final int VERSION = 1; + + private IamSecurityEventsV1() { } + + public record UserStatusChanged(long authUserId, long tenantId, String appCode, + String status, long permissionVersion, Instant changedAt) { } + + public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { } + + public record PermissionVersionChanged(long authUserId, long tenantId, String appCode, + long permissionVersion, String reason, Instant changedAt, + Set roles, Set authorities) { + public PermissionVersionChanged { + roles = roles == null ? Set.of() : Set.copyOf(roles); + authorities = authorities == null ? Set.of() : Set.copyOf(authorities); + } + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java new file mode 100644 index 000000000..3f1994896 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java @@ -0,0 +1,22 @@ +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 +) { + public OverseasContainerUnloadedV1 { + if (sourceContainerBookingId == null || sourceContainerBookingId.isBlank()) { + throw new IllegalArgumentException("sourceContainerBookingId is required"); + } + containerNo = ContainerNumbers.requireCanonical(containerNo); + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasDeliveryCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasDeliveryCompletedV1.java new file mode 100644 index 000000000..90d88f414 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasDeliveryCompletedV1.java @@ -0,0 +1,19 @@ +package com.baiyue.common.event; + +import java.time.Instant; + +/** 海外派送单按目的 FBA 仓签收后,回传对应国内入仓单和订单的完成事实。 */ +public record OverseasDeliveryCompletedV1( + String deliveryOrderId, + String deliveryNo, + String lastlegOrderId, + String lastlegOrderNo, + String sourceInboundId, + String inboundCode, + String sourceOrderId, + String sourceOrderNo, + String rootOrderId, + String fbaWarehouseCode, + Instant completedAt +) { +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasInboundOrderDetainedChangedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasInboundOrderDetainedChangedV1.java new file mode 100644 index 000000000..88cf9681b --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasInboundOrderDetainedChangedV1.java @@ -0,0 +1,17 @@ +package com.baiyue.common.event; + +import java.time.Instant; + +/** 海外尾程按来源入仓单回传的暂扣状态事实。 */ +public record OverseasInboundOrderDetainedChangedV1( + String sourceInboundId, + String inboundCode, + String lastlegOrderId, + String lastlegOrderNo, + boolean detained, + String reason, + String operatorId, + String operatorName, + Instant changedAt +) { +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandCompletedV1.java new file mode 100644 index 000000000..f786e58c1 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandCompletedV1.java @@ -0,0 +1,21 @@ +package com.baiyue.common.event; + +import java.math.BigDecimal; +import java.time.Instant; + +/** 国内 Finance 对海外资金指令的最终、可幂等投影结果。 */ +public record WalletCommandCompletedV1( + String commandEventId, + String operation, + long customerId, + String businessType, + String businessNo, + String result, + String holdStatus, + BigDecimal amount, + String errorCode, + String errorMessage, + Instant completedAt +) { + public static final int VERSION = 1; +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandRequestedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandRequestedV1.java new file mode 100644 index 000000000..8c3514ec4 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandRequestedV1.java @@ -0,0 +1,18 @@ +package com.baiyue.common.event; + +import java.math.BigDecimal; +import java.time.Instant; + +/** 海外业务向国内 Finance 发起的客户资金冻结、结算或释放指令。 */ +public record WalletCommandRequestedV1( + String operation, + long customerId, + String businessType, + String businessNo, + BigDecimal amount, + String remark, + String idempotencyKey, + Instant requestedAt +) { + public static final int VERSION = 1; +} diff --git a/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml new file mode 100644 index 000000000..2b64f0259 --- /dev/null +++ b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml @@ -0,0 +1,108 @@ +openapi: 3.1.0 +info: + title: Baiyue Cross-region Event Contracts + version: 1.0.0 +paths: {} +components: + schemas: + FirstLegLoadingPlanCompletedV3: + allOf: + - $ref: '#/components/schemas/FirstLegLoadingPlanCompletedV2' + - type: object + required: [loadingEvidence] + properties: + loadingEvidence: + type: array + minItems: 5 + maxItems: 5 + description: Exactly one active domestic loading photo reference for each of PHOTO_STEP_1 through PHOTO_STEP_4 and SEAL. + items: {$ref: '#/components/schemas/FirstLegLoadingEvidenceV3'} + FirstLegLoadingEvidenceV3: + type: object + required: [evidenceType, fileId, fileName] + properties: + evidenceType: {type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]} + fileId: {type: integer, format: int64, minimum: 1, description: Domestic File service identity; never exposed to PDA clients} + fileName: {type: string, minLength: 1, maxLength: 255} + FirstLegLoadingPlanCompletedV2: + type: object + required: [loadingPlanId, sourceContainerBookingId, containerNo, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders] + properties: + loadingPlanId: {type: string} + sourceContainerBookingId: {type: string, minLength: 1, description: Immutable Firstleg container booking identity} + containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number frozen before loading completion publication} + cabinetNo: {type: string} + warehouseId: {type: string} + warehouseName: {type: string} + estimatedPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated port arrival; null when Firstleg has no fact} + actualPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local actual port arrival; never copied from an estimate} + estimatedWarehouseArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated warehouse arrival; null when Firstleg has no fact} + orders: + type: array + items: {$ref: '#/components/schemas/FirstLegLoadingPlanCompletedOrderV2'} + FirstLegLoadingPlanCompletedOrderV2: + type: object + required: [sourceInboundId, customerId, customerName, palletCount, packageCount, productQuantity, volumeCbm, volumeWeightKg, items, orderType, pallets, destinationType] + properties: + sourceInboundId: {type: string} + sourceOrderId: {type: string} + sourceOrderNo: {type: string} + inboundCode: {type: string} + customerId: {type: string, minLength: 1, description: Trusted customer identity frozen by Firstleg} + customerName: {type: string, minLength: 1, maxLength: 128, description: Immutable trusted container/customer name snapshot; consumers must not query domestic IAM} + packageCount: {type: integer, minimum: 1} + productQuantity: {type: [integer, 'null'], minimum: 0, description: Final loaded product quantity; null when unavailable} + palletCount: {type: [integer, 'null'], minimum: 1, description: Distinct physical pallet count from Firstleg allocation pallet identity; never a planned zero placeholder} + volumeCbm: {type: [number, 'null'], minimum: 0, description: Final loaded volume in cubic metres} + volumeWeightKg: {type: [number, 'null'], minimum: 0, description: Sum of final loaded item dimensional weight facts; null when unavailable} + items: {type: array, items: {type: object}} + orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]} + pallets: {type: array, items: {$ref: '#/components/schemas/FirstLegPalletSnapshotV2'}} + destinationType: {type: string, enum: [FBA, OVERSEAS]} + FirstLegContainerShippingNodeRecordedV1: + type: object + required: [sourceContainerBookingId, containerNo, standardNode, nodeSequence, observedAt, actualAt, providerCode, providerEventId, providerNodeCode, rawPayloadHash] + properties: + queryId: {type: [string, 'null'], description: Automatic tracking task ID; null for a direct platform manual node.} + oceanBookingId: {type: [string, 'null'], description: Ocean booking ID when the physical container has already been linked; null is valid for a direct manual node.} + sourceContainerBookingId: {type: string, minLength: 1} + containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number; never cabinetNo or a technical identity} + standardNode: {type: string} + nodeSequence: {type: integer} + observedAt: {type: string, format: date-time} + actualAt: {type: string, format: date-time} + providerCode: {type: string} + providerEventId: {type: string} + providerNodeCode: {type: string} + rawPayloadHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'} + FirstLegPalletSnapshotV2: + type: object + required: [palletNo, skuPackages] + properties: + palletNo: {type: string, minLength: 1} + skuPackages: + type: array + items: {$ref: '#/components/schemas/FirstLegSkuPackageSnapshotV2'} + FirstLegSkuPackageSnapshotV2: + type: object + required: [sourceInboundDetailId, skuCode, productName, packageCount, productQuantity, cartonMarks, cartonQuantities] + properties: + sourceInboundDetailId: {type: string, minLength: 1, description: Exact source inbound detail identity matching the order item; never infer from SKU because multiple details may share a SKU} + skuCode: {type: string} + productName: {type: [string, 'null']} + packageCount: {type: integer, minimum: 1} + productQuantity: {type: integer, minimum: 1} + cartonMarks: {type: array, items: {type: string}, minItems: 1} + cartonQuantities: + type: object + description: 箱唛到逐箱实收件数的完整映射;键与 cartonMarks 相同,件数合计等于 productQuantity + additionalProperties: {type: integer, minimum: 1} + OverseasContainerUnloadedV1: + type: object + required: [sourceContainerBookingId, containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt] + properties: + sourceContainerBookingId: {type: string, minLength: 1} + containerNo: {type: string, minLength: 1, description: Must equal the immutable normalized Firstleg container number; the CN consumer never overwrites it} + unloadingCompletedAt: {type: string, format: date-time} + unloadingPhotoUploadedAt: {type: string, format: date-time} + actualEmptyReportedAt: {type: [string, 'null'], format: date-time} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/ContainerNumbersTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/ContainerNumbersTest.java new file mode 100644 index 000000000..70b709ba5 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/ContainerNumbersTest.java @@ -0,0 +1,20 @@ +package com.baiyue.common.event; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ContainerNumbersTest { + @Test + void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactIso6346Value() { + assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970"); + assertThat(ContainerNumbers.requireCanonical("ZCSU7881970")).isEqualTo("ZCSU7881970"); + assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970")) + .hasMessageContaining("normalized"); + assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("ZCSU7881971")) + .hasMessageContaining("check digit"); + assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("CAB-260831-0001")) + .hasMessageContaining("ISO 6346"); + } +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java index 8cf5ac3a3..569608b99 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java @@ -3,6 +3,7 @@ package com.baiyue.common.event; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; @@ -12,11 +13,12 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@DisplayName("跨区域事件信封契约") class EventEnvelopeTest { private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); @Test - void 标准Uuid事件可以通过校验并完成序列化往返() throws Exception { + void standardUuidEventPassesValidationAndSerializationRoundTrip() throws Exception { EventEnvelope source = validEnvelope(EventIdentifiers.newUuid()); source.requireValid(); @@ -29,14 +31,14 @@ class EventEnvelopeTest { } @Test - void 合法Ulid可以作为事件标识() { + void validUlidCanBeUsedAsEventIdentifier() { EventEnvelope envelope = validEnvelope("01J8Z4QX9K7M2N3P4R5S6T7V8W"); assertThat(EventEnvelopeValidator.validate(envelope)).isEmpty(); } @Test - void 校验器一次返回全部基础错误() { + void validatorReturnsAllBasicErrorsAtOnce() { EventEnvelope envelope = new EventEnvelope( "bad-id", "bad_type", 0, null, null, null, " ", "bad-type", " ", -1, 0L, " ", " ", "bad-cause", null, "bad-hash"); @@ -51,7 +53,7 @@ class EventEnvelopeTest { } @Test - void 负载摘要可计算并校验() { + void payloadDigestCanBeCalculatedAndValidated() { byte[] payload = "{\"orderId\":\"90001\"}".getBytes(StandardCharsets.UTF_8); String hash = EventPayloadHash.sha256(payload); @@ -61,7 +63,7 @@ class EventEnvelopeTest { } @Test - void 对象字段顺序不同仍产生相同规范摘要() throws Exception { + void differentObjectFieldOrderProducesSameCanonicalDigest() throws Exception { JsonNode first = objectMapper.readTree("{\"orderId\":\"90001\",\"route\":{\"country\":\"US\",\"city\":\"LA\"}}"); JsonNode second = objectMapper.readTree("{\"route\":{\"city\":\"LA\",\"country\":\"US\"},\"orderId\":\"90001\"}"); @@ -70,7 +72,7 @@ class EventEnvelopeTest { } @Test - void 数组顺序仍作为业务语义保留() throws Exception { + void arrayOrderIsPreservedAsBusinessSemantic() throws Exception { JsonNode first = objectMapper.readTree("{\"steps\":[\"A\",\"B\"]}"); JsonNode second = objectMapper.readTree("{\"steps\":[\"B\",\"A\"]}"); 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..c69cea8be --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java @@ -0,0 +1,74 @@ +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); + var json = mapper.valueToTree(payload); + assertThat(json.path("sourceContainerBookingId").asText()).isEqualTo("2088000000000000003"); + assertThat(json.has("containerBookingId")).isFalse(); + } + + @Test + void rejectsNonCanonicalPhysicalContainerNumber() { + assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( + "1", "3", "4", " zcsu7881970 ", "PICKUP_READY", 80, + Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"), + null, null, "ZIM", "evt", "AVAILABLE", "ZIM:evt", "a".repeat(64))) + .hasMessageContaining("normalized"); + } + + @Test + void acceptsDirectManualNodeWithoutTrackingQuery() { + var payload = payload(); + var manual = new FirstLegContainerShippingNodeRecordedV1( + null, null, payload.containerBookingId(), payload.containerNo(), + payload.standardNode(), payload.nodeSequence(), payload.observedAt(), payload.actualAt(), + payload.locationCode(), payload.locationName(), "MANUAL", "manual-event", payload.providerNodeCode(), + "MANUAL:7", payload.rawPayloadHash()); + + assertThat(manual.queryId()).isNull(); + assertThat(manual.oceanBookingId()).isNull(); + } + + @Test + void rejectsEstimatedOnlyOrIncompleteProviderFacts() { + assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( + "1", "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", "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", "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 new file mode 100644 index 000000000..c364654b2 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java @@ -0,0 +1,71 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class FirstLegLoadingPlanCompletedV1Test { + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void v1PayloadRoundTripsWithoutLosingLargeIds() throws Exception { + var item = new FirstLegLoadingPlanCompletedV1.Item("2088000000000000001", "SKU-1", "产品", "Product", + 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"), new BigDecimal("12.000"), + new BigDecimal("0.200"), "2088000000000000002", "ONT8", "ONT8", "FBA-1", "SHIP-1", "MARK"); + var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004", + "2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002", + "ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"), + new BigDecimal("0.200"), List.of(item), "9001", "客户甲", "FIRST_LEG", List.of()); + var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008", + "CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order)); + String json = mapper.writeValueAsString(payload); + 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":"海外仓","estimatedPortArrivalAt":"2026-09-30T12:00", + "actualPortArrivalAt":null,"estimatedWarehouseArrivalAt":"2026-10-03T09:00", + "orders":[{"sourceInboundId":"100","destinationWarehouseId":"3","packageCount":1, + "palletCount":2,"volumeWeightKg":12.500,"items":[],"customerId":"9001", + "customerName":"客户甲","destinationType":"OVERSEAS"}]} + """; + + FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class); + + assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS"); + assertThat(restored.estimatedPortArrivalAt()).isEqualTo("2026-09-30T12:00"); + assertThat(restored.actualPortArrivalAt()).isNull(); + assertThat(restored.estimatedWarehouseArrivalAt()).isEqualTo("2026-10-03T09:00"); + assertThat(restored.orders().get(0).palletCount()).isEqualTo(2); + assertThat(restored.orders().get(0).volumeWeightKg()).isEqualByComparingTo("12.500"); + } +} 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 new file mode 100644 index 000000000..8e6375eed --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -0,0 +1,184 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FirstLegLoadingPlanCompletedV2Test { + @Test + void exactCartonQuantitiesRejectMissingMarksAndIncorrectTotals() { + var marks = List.of("BOX-A", "BOX-B"); + var snapshot = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20, + marks, java.util.Map.of("BOX-A", 7, "BOX-B", 13)); + assertThat(snapshot.cartonQuantities().get("BOX-B")).isEqualTo(13); + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20, + marks, java.util.Map.of("BOX-A", 20))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20, + marks, java.util.Map.of("BOX-A", 7, "BOX-B", 12))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20, + marks, java.util.Map.of("BOX-A", 0, "BOX-B", 20))).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void palletSkuRequiresExplicitSourceDetailIncludingJsonDeserialization() throws Exception { + var mapper = new ObjectMapper(); + var first = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SAME", "产品", 1, 10, List.of("BOX-101"), java.util.Map.of("BOX-101", 10)); + var second = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("102", "SAME", "产品", 1, 20, List.of("BOX-102"), java.util.Map.of("BOX-102", 20)); + assertThat(mapper.readValue(mapper.writeValueAsString(first), FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot.class)) + .isEqualTo(first).isNotEqualTo(second); + for (String invalid : new String[]{null, "", " "}) { + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(invalid, "SAME", "产品", 1, 10, List.of("BOX-101"), java.util.Map.of("BOX-101", 10))) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("sourceInboundDetailId"); + } + assertThatThrownBy(() -> mapper.readValue("{\"skuCode\":\"SAME\",\"packageCount\":1,\"productQuantity\":10,\"cartonMarks\":[\"BOX-101\"]}", + FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot.class)).hasMessageContaining("sourceInboundDetailId"); + } + + @Test + void carriesOnlyFrozenContainerTimesAndActualOrderAggregates() { + var order = new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8", + 12, 120, 3, null, null, new BigDecimal("2.500"), new BigDecimal("18.750"), + List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "OVERSEAS"); + var payload = new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", + "2026-09-30T12:00", null, "2026-10-03T09:00", List.of(order)); + + var json = new ObjectMapper().valueToTree(payload); + + assertThat(json.at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00"); + assertThat(json.at("/actualPortArrivalAt").isNull()).isTrue(); + assertThat(json.at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00"); + assertThat(json.at("/sourceContainerBookingId").asText()).isEqualTo("5"); + assertThat(json.has("containerBookingId")).isFalse(); + assertThat(json.at("/orders/0/palletCount").asInt()).isEqualTo(3); + assertThat(json.at("/orders/0/packageCount").asInt()).isEqualTo(12); + assertThat(json.at("/orders/0/productQuantity").asInt()).isEqualTo(120); + assertThat(json.at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2.500"); + assertThat(json.at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("18.750"); + } + + @Test + void rejectsNonCanonicalOrMissingPhysicalContainerIdentity() { + var order = new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8", + 1, 1, null, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of()); + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", " zcsu7881970 ", "CAB", "6", "WH", List.of(order))) + .hasMessageContaining("normalized"); + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", " ", "CAB", "6", "WH", List.of(order))) + .hasMessageContaining("containerNo"); + } + + @Test + void carriesTheThreeItemRemarks() throws Exception { + var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1, + 2, null, null, null, null, null, null, null, null, null, null, null, + "客户备注", "内部备注", "仓库备注"); + var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK", + null, null, null, 1, 2, null, null, null, null, List.of(item), + "11", "客户甲", "FIRST_LEG", List.of()); + var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order)); + String json = new ObjectMapper().writeValueAsString(payload); + assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark"); + assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText()) + .isEqualTo("仓库备注"); + } + + @Test + void carriesDropshipPalletAndCartonSnapshots() throws Exception { + var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot( + "101", "SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002"), java.util.Map.of("BOX-001", 7, "BOX-002", 13)); + 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", "ZCSU7881970", "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/customerName").asText()).isEqualTo("客户甲"); + assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP"); + assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); + assertThat(json.at("/orders/0/pallets/0/skuPackages/0/sourceInboundDetailId").asText()).isEqualTo("101"); + } + + @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", "ZCSU7881970", "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), + "11", "客户甲", "FIRST_LEG", List.of()); + + var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", "ZCSU7881970", "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); + } + + @Test + void rejectsMissingImmutableCustomerName() { + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, null, null, + 1, 1, 0, null, null, null, List.of(), "11", " ", "FIRST_LEG", List.of())) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("customerName"); + } + + @Test + void freezesTrimmedOwnerSnapshotAndRejectsMixedOwners() { + var first = new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, null, null, + 1, 1, 0, null, null, null, List.of(), " 11 ", " 客户甲 ", "FIRST_LEG", List.of()); + assertThat(first.customerId()).isEqualTo("11"); + assertThat(first.customerName()).isEqualTo("客户甲"); + + var anotherOwner = new FirstLegLoadingPlanCompletedV2.Order( + "3", "3", "4", "ORD-2", "4", "RK-2", null, null, null, + 1, 1, 0, null, null, null, List.of(), "12", "客户乙", "FIRST_LEG", List.of()); + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(first, anotherOwner))) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("immutable customerId/customerName"); + } + + @Test + void openApiRequiresImmutableCustomerNameNextToCustomerId() throws Exception { + String yaml; + try (var input = FirstLegLoadingPlanCompletedV2Test.class.getResourceAsStream( + "/openapi/cross-region-events-v1.yaml")) { + assertThat(input).isNotNull(); + yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + assertThat(yaml) + .contains("required: [sourceInboundId, customerId, customerName") + .contains("customerId: {type: string, minLength: 1") + .contains("customerName: {type: string, minLength: 1, maxLength: 128") + .contains("consumers must not query domestic IAM") + .contains("estimatedPortArrivalAt", "actualPortArrivalAt", "estimatedWarehouseArrivalAt") + .contains("palletCount", "volumeWeightKg", "never a planned zero placeholder"); + assertThat(yaml).contains("sourceContainerBookingId", "Uppercase normalized physical container number"); + } +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3Test.java new file mode 100644 index 000000000..dd83858d8 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3Test.java @@ -0,0 +1,46 @@ +package com.baiyue.common.event; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.Test; + +class FirstLegLoadingPlanCompletedV3Test { + @Test + void carriesExactlyFiveDomesticLoadingPhotoReferences() { + var json = new ObjectMapper().valueToTree(payload(evidence())); + + assertThat(json.path("loadingEvidence")).hasSize(5); + assertThat(json.at("/loadingEvidence/4/evidenceType").asText()).isEqualTo("SEAL"); + } + + @Test + void rejectsMissingOrDuplicatePhotoTypes() { + assertThatThrownBy(() -> payload(evidence().subList(0, 4))) + .hasMessageContaining("five unique"); + assertThatThrownBy(() -> payload(List.of( + photo("PHOTO_STEP_1", 1), photo("PHOTO_STEP_1", 2), photo("PHOTO_STEP_2", 3), + photo("PHOTO_STEP_3", 4), photo("SEAL", 5)))) + .hasMessageContaining("five unique"); + } + + private FirstLegLoadingPlanCompletedV3 payload(List photos) { + 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"); + return new FirstLegLoadingPlanCompletedV3( + "4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", + null, null, null, List.of(order), photos); + } + + private List evidence() { + return List.of(photo("PHOTO_STEP_1", 1), photo("PHOTO_STEP_2", 2), + photo("PHOTO_STEP_3", 3), photo("PHOTO_STEP_4", 4), photo("SEAL", 5)); + } + + private FirstLegLoadingPlanCompletedV3.LoadingEvidence photo(String type, long id) { + return new FirstLegLoadingPlanCompletedV3.LoadingEvidence(type, id, type + ".jpg"); + } +} 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..bfbbc938f --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java @@ -0,0 +1,34 @@ +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; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OverseasContainerUnloadedV1Test { + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Test + void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception { + var payload = new OverseasContainerUnloadedV1("1001", "ZCSU7881970", + 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", "ZCSU7881970", "unloadingPhotoUploadedAt"); + assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt()); + assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt()); + } + + @Test + void rejectsContainerNumberDriftBeforeDomesticConsumption() { + assertThatThrownBy(() -> new OverseasContainerUnloadedV1("1001", "zcsu7881970", + Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null)) + .hasMessageContaining("normalized"); + } +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/OverseasDeliveryCompletedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasDeliveryCompletedV1Test.java new file mode 100644 index 000000000..7d96a07b2 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasDeliveryCompletedV1Test.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 OverseasDeliveryCompletedV1Test { + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Test + void roundTripsStableSourceIdentifiers() throws Exception { + var value = new OverseasDeliveryCompletedV1("31", "DL-31", "41", "LM-41", "51", + "NYEXX2609080022", "61", "FL-61", "61", "ACY2", + Instant.parse("2026-09-15T08:00:00Z")); + + var restored = mapper.readValue(mapper.writeValueAsBytes(value), OverseasDeliveryCompletedV1.class); + + assertThat(restored).isEqualTo(value); + assertThat(restored.sourceInboundId()).isEqualTo("51"); + assertThat(restored.sourceOrderId()).isEqualTo("61"); + } +} diff --git a/common-openapi/AGENTS.md b/common-openapi/AGENTS.md new file mode 100644 index 000000000..f91a255d8 --- /dev/null +++ b/common-openapi/AGENTS.md @@ -0,0 +1,25 @@ +# Common OpenAPI 模块指引 + +## 职责边界 + +- 为 Spring MVC 服务提供 SpringDoc/OpenAPI 自动配置和统一文档元数据。 +- 不在这里维护具体业务接口契约;服务专属 OpenAPI 文件留在对应服务资源目录。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 自动配置 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` | +| `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`。 +- 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。 diff --git a/common-openapi/pom.xml b/common-openapi/pom.xml index 8ddfe901a..361af6538 100644 --- a/common-openapi/pom.xml +++ b/common-openapi/pom.xml @@ -10,5 +10,6 @@ org.springdocspringdoc-openapi-starter-webmvc-ui org.springframework.bootspring-boot-starter-webtrue + org.springframework.bootspring-boot-starter-testtest 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 04055e3b5..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 @@ -1,5 +1,7 @@ package com.baiyue.common.openapi; +import com.fasterxml.jackson.databind.type.TypeFactory; +import io.swagger.v3.core.converter.ModelConverter; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; @@ -34,30 +36,51 @@ public class BaiyueOpenApiAutoConfiguration { } @Bean - OperationCustomizer tenantHeaderCustomizer() { + OperationCustomizer correlationHeaderCustomizer() { return (operation, method) -> operation - .addParametersItem(header("X-Tenant-Id", "Current tenant ID / 当前租户 ID", false)) - .addParametersItem(header("X-Operator-Tenant-Id", "Operator tenant ID / 运营方租户 ID", false)) - .addParametersItem(header("X-App-Code", "Application code / 应用编码", false)) .addParametersItem(header("X-Request-Id", "Request correlation ID / 请求关联 ID", false)) .addParametersItem(header("X-Trace-Id", "Distributed trace ID / 分布式追踪 ID", false)); } + /** 保证 OpenAPI 与运行时 JSON 一致:所有 Java 长整型均声明为字符串。 */ @Bean - GroupedOpenApi platformApi(OperationCustomizer tenantHeaderCustomizer) { - return GroupedOpenApi.builder().group("platform") - .displayName("Platform APIs / 平台端接口") - .pathsToMatch("/api/platform/**", "/api/auth/**", "/api/iam/platform/**") - .addOperationCustomizer(tenantHeaderCustomizer).build(); + ModelConverter longAsStringModelConverter() { + return (type, context, chain) -> { + if (type.getType() != null) { + Class rawType = TypeFactory.defaultInstance().constructType(type.getType()).getRawClass(); + if (rawType == Long.class || rawType == Long.TYPE) { + return new io.swagger.v3.oas.models.media.StringSchema() + .description("长整型以字符串传输,避免浏览器精度丢失") + .example("190000000000000001"); + } + } + return chain.hasNext() ? chain.next().resolve(type, context, chain) : null; + }; } @Bean - GroupedOpenApi merchantApi(OperationCustomizer tenantHeaderCustomizer) { + GroupedOpenApi platformApi(OperationCustomizer correlationHeaderCustomizer) { + return GroupedOpenApi.builder().group("platform") + .displayName("Platform APIs / 平台端接口") + .pathsToMatch("/api/platform/**", "/api/auth/**", "/api/iam/platform/**") + .addOperationCustomizer(correlationHeaderCustomizer).build(); + } + + @Bean + GroupedOpenApi merchantApi(OperationCustomizer correlationHeaderCustomizer) { return GroupedOpenApi.builder().group("merchant") .displayName("Merchant APIs / 商户端接口") .pathsToMatch("/api/merchant/**", "/api/orders/**", "/api/warehouse/**", "/api/firstleg/**", "/api/lastleg/**") - .addOperationCustomizer(tenantHeaderCustomizer).build(); + .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) { diff --git a/common-openapi/src/test/java/com/baiyue/common/openapi/CorrelationHeaderCustomizerTest.java b/common-openapi/src/test/java/com/baiyue/common/openapi/CorrelationHeaderCustomizerTest.java new file mode 100644 index 000000000..d37ee4589 --- /dev/null +++ b/common-openapi/src/test/java/com/baiyue/common/openapi/CorrelationHeaderCustomizerTest.java @@ -0,0 +1,25 @@ +package com.baiyue.common.openapi; + +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.parameters.Parameter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springdoc.core.customizers.OperationCustomizer; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("OpenAPI 请求头定制") +class CorrelationHeaderCustomizerTest { + + @Test + void shouldOnlyExposeCallerControlledCorrelationHeaders() { + OperationCustomizer customizer = new BaiyueOpenApiAutoConfiguration().correlationHeaderCustomizer(); + + Operation operation = customizer.customize(new Operation(), null); + + assertThat(operation.getParameters()) + .extracting(Parameter::getName) + .containsExactly("X-Request-Id", "X-Trace-Id") + .doesNotContain("X-Tenant-Id", "X-Operator-Tenant-Id", "X-App-Code"); + } +} diff --git a/common-openapi/src/test/java/com/baiyue/common/openapi/LongAsStringModelConverterTest.java b/common-openapi/src/test/java/com/baiyue/common/openapi/LongAsStringModelConverterTest.java new file mode 100644 index 000000000..44a744168 --- /dev/null +++ b/common-openapi/src/test/java/com/baiyue/common/openapi/LongAsStringModelConverterTest.java @@ -0,0 +1,29 @@ +package com.baiyue.common.openapi; + +import io.swagger.v3.core.converter.AnnotatedType; +import io.swagger.v3.core.converter.ModelConverter; +import io.swagger.v3.core.converter.ModelConverterContext; +import io.swagger.v3.oas.models.media.Schema; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("长整型标识 OpenAPI 序列化功能") +class LongAsStringModelConverterTest { + + @Test + void longShouldBeDocumentedAsString() { + ModelConverter converter = new BaiyueOpenApiAutoConfiguration().longAsStringModelConverter(); + + Schema schema = converter.resolve( + new AnnotatedType(Long.class), + org.mockito.Mockito.mock(ModelConverterContext.class), + Collections.emptyIterator()); + + assertThat(schema.getType()).isEqualTo("string"); + assertThat(schema.getExample()).isEqualTo("190000000000000001"); + } +} 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 new file mode 100644 index 000000000..e9e566096 --- /dev/null +++ b/common-security/AGENTS.md @@ -0,0 +1,28 @@ +# Common Security 模块指引 + +## 职责边界 + +- 提供业务服务通用的 JWT 解码、认证过滤、Redis Access Token 会话校验、认证用户上下文及 401/403 JSON 响应。 +- 角色、权限、租户成员关系由 `iam/` 维护;本模块不实现 RBAC 管理业务。网关的 Reactive 安全链位于 `gateway/`。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 安全链自动配置 | `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`;海外 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` | +| 安全回归测试 | `src/test/java/com/baiyue/common/security/` | + +## 修改与验证 + +- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。 +- 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/AccessTokenSession.java b/common-security/src/main/java/com/baiyue/common/security/AccessTokenSession.java new file mode 100644 index 000000000..b2615691f --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/AccessTokenSession.java @@ -0,0 +1,15 @@ +package com.baiyue.common.security; + +import java.util.Set; + +/** Server-side access session and its current authorization snapshot. */ +public record AccessTokenSession(int version, Long userId, Set roles, + Set permissions) { + public AccessTokenSession { + if (version != 1 || userId == null || userId <= 0) { + throw new IllegalArgumentException("Invalid access token session"); + } + roles = roles == null ? Set.of() : Set.copyOf(roles); + permissions = permissions == null ? Set.of() : Set.copyOf(permissions); + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java index 6b431251e..314d9ac53 100644 --- a/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java +++ b/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java @@ -1,11 +1,16 @@ package com.baiyue.common.security; import org.springframework.security.oauth2.jwt.Jwt; +import jakarta.servlet.http.HttpServletRequest; /** * 访问令牌会话有效性校验扩展点,用于在 JWT 校验之外确认服务端登录会话状态。 */ @FunctionalInterface public interface AccessTokenSessionValidator { - void validate(Jwt jwt); + AccessTokenSession validate(Jwt jwt); + + default AccessTokenSession validate(Jwt jwt, HttpServletRequest request) { + return validate(jwt); + } } 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 27d1c0e0e..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 @@ -7,8 +7,37 @@ import java.util.Set; * 统一认证用户主体,携带用户、租户、成员身份、应用端、客户端类型及权限信息。 */ public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, - String username, String appCode, String clientType, - Set permissions) implements Principal { + String username, String displayName, String appCode, String clientType, + Set permissions, boolean impersonated, Long actorUserId, + 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, 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); } @@ -18,4 +47,3 @@ public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantI return username; } } - diff --git a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java index f7b81b803..3713986d1 100644 --- a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java @@ -23,7 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic * Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。 */ @AutoConfiguration( - after = HmacJwtDecoderAutoConfiguration.class, + after = {HmacJwtDecoderAutoConfiguration.class, JwksJwtDecoderAutoConfiguration.class}, before = SecurityAutoConfiguration.class) @EnableMethodSecurity public class BaiyueSecurityAutoConfiguration { @@ -47,10 +47,20 @@ public class BaiyueSecurityAutoConfiguration { @ConditionalOnMissingBean JwtAuthenticationFilter jwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, AuthenticationEntryPoint entryPoint, - StringRedisTemplate redisTemplate, - @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) { - return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, - new RedisAccessTokenSessionValidator(redisTemplate, keyPrefix)); + org.springframework.beans.factory.ObjectProvider redis, + ObjectMapper objectMapper, + @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix, + @Value("${baiyue.security.gateway-internal-service-token:${baiyue.security.internal-service-token:${INTERNAL_SERVICE_TOKEN:}}}") String gatewayToken, + @Value("${baiyue.security.session-mode:redis}") String sessionMode) { + AccessTokenSessionValidator validator; + if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator(); + else if ("gateway".equalsIgnoreCase(sessionMode)) validator = new GatewayAccessTokenSessionValidator(gatewayToken); + else { + StringRedisTemplate template=redis.getIfAvailable(); + if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate"); + validator=new RedisAccessTokenSessionValidator(template,keyPrefix,objectMapper); + } + return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator); } @Bean @@ -76,6 +86,7 @@ public class BaiyueSecurityAutoConfiguration { .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll() .requestMatchers("/internal/**").permitAll() + .requestMatchers("/openapi/**").permitAll() .requestMatchers("/api/merchant/**").hasAuthority("APP_MERCHANT") .requestMatchers("/api/platform/**").hasAuthority("APP_PLATFORM") .anyRequest().authenticated()) diff --git a/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java new file mode 100644 index 000000000..c32b30317 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java @@ -0,0 +1,16 @@ +package com.baiyue.common.security; + +import org.springframework.security.oauth2.jwt.Jwt; +import java.util.LinkedHashSet; +import java.util.Set; + +/** 海外模式直接使用已由 RS256/JWKS 验证的短时令牌授权快照。 */ +public class ClaimsAccessTokenSessionValidator implements AccessTokenSessionValidator { + @Override public AccessTokenSession validate(Jwt jwt) { + Long userId=number(jwt.getClaims().getOrDefault("userId",jwt.getClaims().get("user_id"))); + return new AccessTokenSession(1,userId,values(jwt.getClaims().get("roles")), + values(jwt.getClaims().getOrDefault("authorities",jwt.getClaims().get("permissions")))); + } + private static Long number(Object value){ if(value instanceof Number n)return n.longValue(); try{return Long.valueOf(String.valueOf(value));}catch(Exception e){return null;} } + private static Set values(Object value){ Set result=new LinkedHashSet<>(); if(value instanceof Iterable it)it.forEach(v->result.add(String.valueOf(v))); else if(value!=null)for(String v:String.valueOf(value).split("[ ,]+"))if(!v.isBlank())result.add(v); return result; } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java new file mode 100644 index 000000000..062ff2fa1 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java @@ -0,0 +1,65 @@ +package com.baiyue.common.security; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.LinkedHashSet; +import java.util.Set; + +/** Uses the overseas gateway's authenticated authorization snapshot instead of large JWT claims. */ +public final class GatewayAccessTokenSessionValidator implements AccessTokenSessionValidator { + public static final String TOKEN_HEADER = "X-Internal-Service-Token"; + public static final String ROLES_HEADER = "X-Overseas-Roles"; + public static final String AUTHORITIES_HEADER = "X-Overseas-Authorities"; + + private final byte[] expectedToken; + + public GatewayAccessTokenSessionValidator(String expectedToken) { + if (expectedToken == null || expectedToken.isBlank()) { + throw new IllegalStateException("session-mode=gateway requires an internal service token"); + } + this.expectedToken = expectedToken.getBytes(StandardCharsets.UTF_8); + } + + @Override + public AccessTokenSession validate(Jwt jwt) { + throw new IllegalArgumentException("Gateway authorization headers are required"); + } + + @Override + public AccessTokenSession validate(Jwt jwt, HttpServletRequest request) { + String supplied = request.getHeader(TOKEN_HEADER); + if (supplied == null || supplied.isBlank()) { + throw new IllegalArgumentException("Missing overseas gateway authorization context"); + } + byte[] suppliedToken = supplied.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expectedToken, suppliedToken)) { + throw new IllegalArgumentException("Invalid overseas gateway authorization context (expectedBytes=" + + expectedToken.length + ", suppliedBytes=" + suppliedToken.length + ")"); + } + Long userId = number(jwt.getClaims().getOrDefault("userId", jwt.getClaims().get("user_id"))); + if (userId == null) { + userId = number(jwt.getSubject()); + } + return new AccessTokenSession(1, userId, values(request.getHeader(ROLES_HEADER)), + values(request.getHeader(AUTHORITIES_HEADER))); + } + + private static Long number(Object value) { + if (value instanceof Number number) return number.longValue(); + try { return value == null ? null : Long.valueOf(value.toString()); } + catch (NumberFormatException exception) { return null; } + } + + private static Set values(String header) { + if (header == null || header.isBlank()) return Set.of(); + LinkedHashSet values = new LinkedHashSet<>(); + for (String value : header.split(",")) { + String normalized = value.trim(); + if (!normalized.isEmpty()) values.add(normalized); + } + return Set.copyOf(values); + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java index 4cc24909d..b4b8261ed 100644 --- a/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java @@ -17,7 +17,7 @@ import java.nio.charset.StandardCharsets; * HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。 */ @AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class) -@ConditionalOnProperty(name = "baiyue.security.jwt-secret") +@ConditionalOnProperty(name = "baiyue.security.jwt-mode", havingValue = "hmac", matchIfMissing = true) public class HmacJwtDecoderAutoConfiguration { @Bean @ConditionalOnMissingBean(JwtDecoder.class) diff --git a/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java new file mode 100644 index 000000000..b73770483 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java @@ -0,0 +1,32 @@ +package com.baiyue.common.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; + +import java.util.List; + +/** 海外服务仅通过可轮换 JWKS 验证 RS256,不访问国内 Redis。 */ +@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class) +@ConditionalOnProperty(name="baiyue.security.jwt-mode",havingValue="jwks") +public class JwksJwtDecoderAutoConfiguration { + @Bean @ConditionalOnMissingBean(JwtDecoder.class) + JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri}") String uri, + @Value("${baiyue.security.issuer:auth-service}") String issuer, + @Value("${baiyue.security.audience:lastleg-api}") String audience) { + NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri) + .jwsAlgorithm(SignatureAlgorithm.RS256).build(); + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(issuer), + new JwtClaimValidator>("aud", values -> values != null && values.contains(audience)))); + return decoder; + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java b/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java index ececf08fa..3ea44844c 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java @@ -12,6 +12,8 @@ import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.web.filter.OncePerRequestFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; @@ -19,6 +21,7 @@ import java.io.IOException; * Servlet JWT 认证过滤器,解析 Bearer 令牌、校验会话并建立当前请求的安全上下文。 */ public class JwtAuthenticationFilter extends OncePerRequestFilter { + private static final Logger LOG = LoggerFactory.getLogger(JwtAuthenticationFilter.class); private static final String BEARER = "Bearer "; private final JwtDecoder jwtDecoder; private final JwtClaimsMapper claimsMapper; @@ -27,7 +30,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, AuthenticationEntryPoint authenticationEntryPoint) { - this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> { }); + this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> null); } public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, @@ -55,11 +58,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { String tokenValue = header.substring(BEARER.length()).trim(); try { Jwt jwt = jwtDecoder.decode(tokenValue); - sessionValidator.validate(jwt); - Authentication authentication = claimsMapper.map(jwt); + AccessTokenSession session = sessionValidator.validate(jwt, request); + Authentication authentication = claimsMapper.map(jwt, session); SecurityContextHolder.getContext().setAuthentication(authentication); filterChain.doFilter(request, response); } catch (JwtException | IllegalArgumentException exception) { + LOG.warn("JWT authentication rejected for {}: {}", request.getRequestURI(), exception.getMessage()); SecurityContextHolder.clearContext(); authenticationEntryPoint.commence(request, response, new InvalidJwtAuthenticationException("Invalid or expired access token", exception)); 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 19d798d17..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 @@ -13,28 +13,39 @@ import java.util.Set; */ public class JwtClaimsMapper { public BaiyueJwtAuthenticationToken map(Jwt jwt) { - Set permissions = new LinkedHashSet<>(); - addClaimValues(permissions, jwt.getClaims().get("authorities")); - addClaimValues(permissions, jwt.getClaims().get("permissions")); - addClaimValues(permissions, jwt.getClaims().get("scope")); - addRoles(permissions, jwt.getClaims().get("roles")); + return map(jwt, null); + } - String appCode = jwt.getClaimAsString("app_code"); + public BaiyueJwtAuthenticationToken map(Jwt jwt, AccessTokenSession session) { + Set permissions = new LinkedHashSet<>(); + if (session != null) { + permissions.addAll(session.permissions()); + addRoles(permissions, session.roles()); + } + + String appCode = firstNonBlank(jwt.getClaimAsString("appCode"), jwt.getClaimAsString("app_code")); if (appCode != null && !appCode.isBlank()) { permissions.add("APP_" + appCode.toUpperCase(java.util.Locale.ROOT)); } String username = firstNonBlank(jwt.getClaimAsString("username"), jwt.getClaimAsString("preferred_username"), jwt.getSubject()); - Long userId = longClaim(jwt, "user_id"); + Long userId = longClaim(jwt, "userId"); + if (userId == null) userId = longClaim(jwt, "user_id"); AuthenticatedUser user = new AuthenticatedUser( userId == null ? longValue(jwt.getSubject()) : userId, - longClaim(jwt, "tenant_id"), + longClaimEither(jwt, "tenantId", "tenant_id"), longClaim(jwt, "operator_tenant_id"), longClaim(jwt, "membership_id"), username, - jwt.getClaimAsString("app_code"), + firstNonBlankNullable(jwt.getClaimAsString("displayName"), jwt.getClaimAsString("display_name")), + appCode, jwt.getClaimAsString("client_type"), - permissions); + permissions, + Boolean.TRUE.equals(jwt.getClaim("impersonated")), + longClaim(jwt, "actor_user_id"), + longClaim(jwt, "actor_membership_id"), + 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); @@ -68,10 +79,18 @@ public class JwtClaimsMapper { if (value instanceof String text) return longValue(text); return null; } + private Long longClaimEither(Jwt jwt, String first, String second) { + Long value = longClaim(jwt, first); + return value == null ? longClaim(jwt, second) : value; + } private String firstNonBlank(String... values) { for (String value : values) if (value != null && !value.isBlank()) return value; return "unknown"; } -} + private String firstNonBlankNullable(String... values) { + for (String value : values) if (value != null && !value.isBlank()) return value.trim(); + return null; + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java index 66098791f..394cecb5d 100644 --- a/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java +++ b/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java @@ -2,6 +2,7 @@ package com.baiyue.common.security; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.security.oauth2.jwt.Jwt; +import com.fasterxml.jackson.databind.ObjectMapper; /** * Redis 访问令牌会话校验器,根据 JWT ID 判断登录会话是否仍然有效。 @@ -9,24 +10,49 @@ import org.springframework.security.oauth2.jwt.Jwt; public final class RedisAccessTokenSessionValidator implements AccessTokenSessionValidator { private final StringRedisTemplate redisTemplate; private final String keyPrefix; + private final ObjectMapper objectMapper; public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix) { + this(redisTemplate, keyPrefix, new ObjectMapper()); + } + + public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix, + ObjectMapper objectMapper) { this.redisTemplate = redisTemplate; this.keyPrefix = RedisTokenKeys.normalize(keyPrefix); + this.objectMapper = objectMapper; } @Override - public void validate(Jwt jwt) { + public AccessTokenSession validate(Jwt jwt) { String jwtId = jwt.getId(); if (jwtId == null || jwtId.isBlank()) { throw new IllegalArgumentException("Access token has no jti"); } try { - if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) { + String value = redisTemplate.opsForValue().get(RedisTokenKeys.access(keyPrefix, jwtId)); + if (value == null || value.isBlank()) { throw new IllegalArgumentException("Access token session is revoked or expired"); } + AccessTokenSession session = objectMapper.readValue(value, AccessTokenSession.class); + Long userId = longClaim(jwt, "user_id"); + if (userId == null || !userId.equals(session.userId())) { + throw new IllegalArgumentException("Access token session identity mismatch"); + } + return session; } catch (org.springframework.dao.DataAccessException exception) { throw new IllegalArgumentException("Token session store is unavailable", exception); + } catch (IllegalArgumentException exception) { + throw exception; + } catch (Exception exception) { + throw new IllegalArgumentException("Invalid access token session", exception); } } + + private Long longClaim(Jwt jwt, String name) { + Object value = jwt.getClaims().get(name); + if (value instanceof Number number) return number.longValue(); + if (value instanceof String text) try { return Long.valueOf(text); } catch (NumberFormatException ignored) { } + return null; + } } diff --git a/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 09441e4a6..d4d261650 100644 --- a/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1,3 @@ com.baiyue.common.security.HmacJwtDecoderAutoConfiguration +com.baiyue.common.security.JwksJwtDecoderAutoConfiguration com.baiyue.common.security.BaiyueSecurityAutoConfiguration diff --git a/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java b/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java new file mode 100644 index 000000000..d9c86a28f --- /dev/null +++ b/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java @@ -0,0 +1,49 @@ +package com.baiyue.common.security; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GatewayAccessTokenSessionValidatorTest { + private final GatewayAccessTokenSessionValidator validator = + new GatewayAccessTokenSessionValidator("gateway-secret"); + + @Test void acceptsOnlyGatewayAuthenticatedAuthorizationSnapshot() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret"); + request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN"); + request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, + "platform:overseas-warehouse:last-mile-order:list,platform:finance:fee-source:view"); + + AccessTokenSession session = validator.validate(jwt(), request); + + assertThat(session.userId()).isEqualTo(42L); + assertThat(session.roles()).containsExactly("PLATFORM_ADMIN"); + assertThat(session.permissions()).containsExactlyInAnyOrder( + "platform:overseas-warehouse:last-mile-order:list", "platform:finance:fee-source:view"); + } + + @Test void rejectsForgedOrMissingGatewayToken() { + MockHttpServletRequest forged = new MockHttpServletRequest(); + forged.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "forged"); + forged.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, + "platform:overseas-warehouse:last-mile-order:list"); + assertThatThrownBy(() -> validator.validate(jwt(), forged)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid overseas gateway"); + + assertThatThrownBy(() -> validator.validate(jwt(), new MockHttpServletRequest())) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Missing overseas gateway"); + } + + private Jwt jwt() { + return Jwt.withTokenValue("small-overseas-token").header("alg", "RS256").subject("42") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", 0L).claim("app_code", "PLATFORM") + .claim("permission_version", 7L).claim("token_version", 1).build(); + } +} diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java index 65967aeac..9ea0f4fa1 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java @@ -2,6 +2,7 @@ package com.baiyue.common.security; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.mock.web.MockFilterChain; @@ -16,6 +17,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("JWT 认证过滤功能") class JwtAuthenticationFilterTest { @AfterEach void clearContext() { SecurityContextHolder.clearContext(); } @@ -39,4 +41,34 @@ class JwtAuthenticationFilterTest { assertThat(invoked).isTrue(); assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); } + + @Test + void authenticatesMinimalOverseasTokenWithGatewaySnapshot() throws Exception { + Jwt jwt = Jwt.withTokenValue("overseas").header("alg", "RS256").subject("42") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60)) + .claim("tenant_id", 0L).claim("app_code", "PLATFORM") + .claim("permission_version", 7L).claim("token_version", 1).build(); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(token -> jwt, new JwtClaimsMapper(), + new JsonAuthenticationEntryPoint(new ObjectMapper()), + new GatewayAccessTokenSessionValidator("gateway-secret")); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/platform/v1/last-leg/orders"); + request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer overseas"); + request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret"); + request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN"); + request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, "platform:lastleg:view"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.doFilter(request, response, (request1, response1) -> { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + assertThat(authentication).isNotNull(); + assertThat(authentication.getAuthorities()).extracting("authority") + .contains("APP_PLATFORM", "ROLE_PLATFORM_ADMIN", "platform:lastleg:view"); + invoked.set(true); + }); + + assertThat(invoked).isTrue(); + assertThat(response.getStatus()).isEqualTo(200); + } } 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 dd5000696..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 @@ -1,5 +1,6 @@ package com.baiyue.common.security; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.oauth2.jwt.Jwt; @@ -8,6 +9,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("JWT 声明映射功能") class JwtClaimsMapperTest { @Test void mapsTenantUserRolesAndPermissions() { @@ -25,13 +27,31 @@ class JwtClaimsMapperTest { .claim("roles", List.of("ADMIN")) .build(); - BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt); + AccessTokenSession session = new AccessTokenSession(1, 42L, + java.util.Set.of("ADMIN"), java.util.Set.of("order:read", "order:create")); + BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt, session); assertThat(authentication.getPrincipal().userId()).isEqualTo(42L); assertThat(authentication.getPrincipal().tenantId()).isEqualTo(7L); assertThat(authentication.getAuthorities()).extracting("authority") .contains("order:read", "order:create", "ROLE_ADMIN", "APP_MERCHANT"); } + @Test + void ignoresAuthorizationClaimsInJwt() { + Jwt jwt = Jwt.withTokenValue("token").header("alg", "none").subject("42") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60)) + .claim("user_id", 42L).claim("tenant_id", 0L).claim("app_code", "PLATFORM") + .claim("permissions", List.of("platform:forged")) + .claim("roles", List.of("FORGED")).build(); + + BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt, + new AccessTokenSession(1, 42L, java.util.Set.of(), java.util.Set.of("platform:real"))); + + assertThat(authentication.getAuthorities()).extracting("authority") + .contains("platform:real", "APP_PLATFORM") + .doesNotContain("platform:forged", "ROLE_FORGED"); + } + @Test void fallsBackToNumericSubjectForUserId() { Jwt jwt = Jwt.withTokenValue("token") @@ -47,4 +67,28 @@ class JwtClaimsMapperTest { assertThat(authentication.getPrincipal().userId()).isEqualTo(99L); assertThat(authentication.getAuthorities()).extracting("authority").contains("APP_PLATFORM"); } + + @Test + void mapsImpersonationAndForcedPasswordClaims() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .subject("99") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .claim("tenant_id", 7L) + .claim("app_code", "MERCHANT") + .claim("impersonated", true) + .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(); + assertThat(user.impersonated()).isTrue(); + assertThat(user.actorUserId()).isEqualTo(42L); + assertThat(user.actorMembershipId()).isEqualTo(43L); + assertThat(user.passwordChangeRequired()).isTrue(); + assertThat(user.overseasWarehouseId()).isEqualTo(900L); + } } diff --git a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java index 556bf7426..1f75a9232 100644 --- a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java @@ -1,8 +1,10 @@ package com.baiyue.common.security; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.data.redis.RedisConnectionFailureException; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; import org.springframework.security.oauth2.jwt.Jwt; import java.time.Instant; @@ -12,11 +14,15 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +@DisplayName("访问令牌 Redis 会话校验功能") class RedisAccessTokenSessionValidatorTest { @Test void acceptsActiveSession() { StringRedisTemplate redis = mock(StringRedisTemplate.class); - when(redis.hasKey("baiyue:auth:access:jti-1")).thenReturn(true); + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.get("baiyue:auth:access:jti-1")) + .thenReturn("{\"version\":1,\"userId\":1,\"roles\":[\"ADMIN\"],\"permissions\":[\"order:read\"]}"); assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") .validate(jwt("jti-1"))).doesNotThrowAnyException(); @@ -25,7 +31,9 @@ class RedisAccessTokenSessionValidatorTest { @Test void rejectsRevokedSession() { StringRedisTemplate redis = mock(StringRedisTemplate.class); - when(redis.hasKey("baiyue:auth:access:jti-1")).thenReturn(false); + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.get("baiyue:auth:access:jti-1")).thenReturn(null); assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") .validate(jwt("jti-1"))) @@ -36,7 +44,9 @@ class RedisAccessTokenSessionValidatorTest { @Test void failsClosedWhenRedisIsUnavailable() { StringRedisTemplate redis = mock(StringRedisTemplate.class); - when(redis.hasKey("baiyue:auth:access:jti-1")) + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.get("baiyue:auth:access:jti-1")) .thenThrow(new RedisConnectionFailureException("offline")); assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") @@ -45,9 +55,37 @@ class RedisAccessTokenSessionValidatorTest { .hasMessageContaining("unavailable"); } + @Test + void rejectsSessionForAnotherUser() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.get("baiyue:auth:access:jti-1")) + .thenReturn("{\"version\":1,\"userId\":2,\"roles\":[],\"permissions\":[]}"); + + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + .validate(jwt("jti-1"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("identity mismatch"); + } + + @Test + void rejectsMalformedSessionJson() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.get("baiyue:auth:access:jti-1")).thenReturn("not-json"); + + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + .validate(jwt("jti-1"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid access token session"); + } + private Jwt jwt(String jwtId) { return Jwt.withTokenValue("token").header("alg", "none") .subject("1").claim("jti", jwtId).issuedAt(Instant.now()) + .claim("user_id", 1L) .expiresAt(Instant.now().plusSeconds(60)).build(); } } diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md new file mode 100644 index 000000000..019c951df --- /dev/null +++ b/common-web/AGENTS.md @@ -0,0 +1,33 @@ +# Common Web 模块指引 + +## 职责边界 + +- 提供 Servlet Web 通用能力:请求链路标识、上下文和统一异常响应。 +- 不放具体业务 Controller、路由或领域异常编排;业务错误通过 `common-core` 的契约表达。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 链路上下文 | `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` | +| 异常响应测试 | `src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java` | +| 长整型 JSON 字符串序列化 | `src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java` | + +## 修改与验证 + +- 普通 `BizException`、参数校验和兜底系统异常统一返回 HTTP 200 + 非 `"200"` body code;查询参数/消息解析失败使用 `A0400`,Bean Validation 使用 `A0422`,不要再按 HTTP 400/422 判断业务失败。 +- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。 +- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。 +- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。 +- 入站 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 a9e635d84..ee8c7876b 100644 --- a/common-web/pom.xml +++ b/common-web/pom.xml @@ -23,6 +23,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework + spring-tx + org.springframework.security spring-security-core @@ -31,6 +35,19 @@ org.springframework.boot spring-boot-starter-validation + + io.micrometer + micrometer-core + + + 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/CommonWebAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java new file mode 100644 index 000000000..afa1a811e --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java @@ -0,0 +1,25 @@ +package com.baiyue.common.web; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** + * Registers the common servlet response contract even when an application only scans its own + * package, such as audit-service and event-bridge-service. + */ +@AutoConfiguration +public class CommonWebAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(GlobalExceptionHandler.class) + GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + + @Bean + @ConditionalOnMissingBean(CorrelationIdFilter.class) + CorrelationIdFilter correlationIdFilter() { + return new CorrelationIdFilter(); + } +} 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 aaa5095fe..01958f455 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 @@ -5,18 +5,27 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; +import io.micrometer.core.instrument.MeterRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; import org.springframework.validation.BindException; import org.springframework.validation.FieldError; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.dao.CannotAcquireLockException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; 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 org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; import java.util.LinkedHashMap; import java.util.Map; @@ -28,6 +37,9 @@ import java.util.Map; public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @Autowired(required = false) + private MeterRegistry meterRegistry; + @ExceptionHandler(BizException.class) public ResponseEntity> handleBusiness(BizException exception, HttpServletRequest request) { return failure(exception.getResultCode(), exception.getMessage(), request); @@ -54,7 +66,9 @@ public class GlobalExceptionHandler { return validationResponse(errors, request); } - @ExceptionHandler({HttpMessageNotReadableException.class, MissingServletRequestParameterException.class}) + @ExceptionHandler({HttpMessageNotReadableException.class, MissingRequestHeaderException.class, + MissingServletRequestParameterException.class, MissingServletRequestPartException.class, + MethodArgumentTypeMismatchException.class}) public ResponseEntity> handleBadRequest(Exception exception, HttpServletRequest request) { return failure(ResultCode.BAD_REQUEST, ResultCode.BAD_REQUEST.message(), request); } @@ -71,6 +85,39 @@ 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(CannotAcquireLockException.class) + public ResponseEntity> handleCannotAcquireLock( + CannotAcquireLockException exception, HttpServletRequest request) { + log.warn("Database lock acquisition conflict requestId={} traceId={}", + CorrelationContext.requestId(request), CorrelationContext.traceId(request)); + return failure(ResultCode.CONFLICT, "资源正在处理中,请稍后重试", request); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity> handleMaxUploadSize( + MaxUploadSizeExceededException exception, HttpServletRequest request) { + long maxBytes = exception.getMaxUploadSize(); + String message = maxBytes > 0 + ? "上传文件不能超过" + Math.max(1, (maxBytes + 1024 * 1024 - 1) / (1024 * 1024)) + "MB" + : "上传文件超过服务允许大小"; + return failure(ResultCode.BAD_REQUEST, message, request); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleUnexpected(Exception exception, HttpServletRequest request) { log.error("Unhandled request failure requestId={} traceId={}", @@ -87,16 +134,46 @@ public class GlobalExceptionHandler { private ResponseEntity>> validationResponse( Map errors, HttpServletRequest request) { ResultCode code = ResultCode.VALIDATION_FAILED; - ApiResponse> body = ApiResponse.of(code, code.message(), errors, + ApiResponse> body = ApiResponse.of(code, validationMessage(errors), errors, CorrelationContext.requestId(request), CorrelationContext.traceId(request)); - return ResponseEntity.status(code.httpStatus()).body(body); + recordFailure(code); + return ResponseEntity.ok(body); + } + + private String validationMessage(Map errors) { + if (errors.isEmpty()) { + return ResultCode.VALIDATION_FAILED.message(); + } + return errors.entrySet().stream() + .map(entry -> entry.getKey() + ": " + entry.getValue()) + .collect(java.util.stream.Collectors.joining(";")); } private ResponseEntity> failure(ResultCode code, String message, HttpServletRequest request) { ApiResponse body = ApiResponse.failure(code, message, CorrelationContext.requestId(request), CorrelationContext.traceId(request)); - return ResponseEntity.status(code.httpStatus()).body(body); + recordFailure(code); + return ResponseEntity.ok(body); + } + + private void recordFailure(ResultCode code) { + if (meterRegistry != null) { + 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/LongIdJacksonConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java new file mode 100644 index 000000000..7cfe445b2 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java @@ -0,0 +1,23 @@ +package com.baiyue.common.web; + +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; + +/** + * 统一将 Java 长整型序列化为 JSON 字符串,避免浏览器对雪花 ID 产生精度截断。 + * + *

请求反序列化仍兼容 JSON 字符串和安全范围内的 JSON 数字;数据库与领域模型继续使用 + * {@code BIGINT}/{@code Long},本配置只改变 HTTP JSON 表达。

+ */ +@AutoConfiguration +public class LongIdJacksonConfiguration { + + @Bean + Jackson2ObjectMapperBuilderCustomizer longAsStringJacksonCustomizer() { + return builder -> builder + .serializerByType(Long.class, ToStringSerializer.instance) + .serializerByType(Long.TYPE, ToStringSerializer.instance); + } +} 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 new file mode 100644 index 000000000..2d417843c --- /dev/null +++ b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +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 new file mode 100644 index 000000000..08739af98 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java @@ -0,0 +1,35 @@ +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; + +import static org.assertj.core.api.Assertions.assertThat; + +class CommonWebAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(CommonWebAutoConfiguration.class)); + + @Test + void registersExceptionHandlerAndCorrelationFilterOutsideComponentScan() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(GlobalExceptionHandler.class); + assertThat(context).hasSingleBean(CorrelationIdFilter.class); + assertThat(CorrelationIdFilter.class.getAnnotation(Order.class).value()) + .isEqualTo(Ordered.HIGHEST_PRECEDENCE); + }); + } + + @Test + void backsOffWhenApplicationProvidesItsOwnBeans() { + contextRunner.withBean(GlobalExceptionHandler.class, GlobalExceptionHandler::new) + .withBean(CorrelationIdFilter.class, CorrelationIdFilter::new) + .run(context -> { + assertThat(context).hasSingleBean(GlobalExceptionHandler.class); + assertThat(context).hasSingleBean(CorrelationIdFilter.class); + }); + } +} 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 ee9683390..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 @@ -1,37 +1,101 @@ package com.baiyue.common.web; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; 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("请求链路标识过滤功能") 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 ae2d02ae9..c1283467e 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 @@ -2,11 +2,29 @@ package com.baiyue.common.web; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.validation.BeanPropertyBindingResult; +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 org.springframework.dao.CannotAcquireLockException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; + +import java.lang.reflect.Method; import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("全局异常响应") class GlobalExceptionHandlerTest { private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); @@ -16,8 +34,128 @@ class GlobalExceptionHandlerTest { request.setAttribute(CorrelationContext.REQUEST_ID, "r1"); request.setAttribute(CorrelationContext.TRACE_ID, "t1"); var response = handler.handleBusiness(new BizException("inventory is locked"), request); - assertThat(response.getStatusCode().value()).isEqualTo(400); + assertThat(response.getStatusCode().value()).isEqualTo(200); assertThat(response.getBody().code()).isEqualTo(ResultCode.BUSINESS_ERROR.code()); assertThat(response.getBody().requestId()).isEqualTo("r1"); } + + @Test + void invalidQueryParameterTypeMapsToBadRequest() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + Method method = GlobalExceptionHandlerTest.class.getDeclaredMethod("paging", long.class); + MethodParameter parameter = new MethodParameter(method, 0); + MethodArgumentTypeMismatchException exception = new MethodArgumentTypeMismatchException( + "abc", Long.class, "page", parameter, new NumberFormatException("abc")); + + var response = handler.handleBadRequest(exception, request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code()); + } + + @Test + void missingRequiredRequestHeaderMapsToBadRequest() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + Method method = GlobalExceptionHandlerTest.class.getDeclaredMethod("paging", long.class); + MethodParameter parameter = new MethodParameter(method, 0); + + var response = handler.handleBadRequest( + new MissingRequestHeaderException("Idempotency-Key", parameter), request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code()); + } + + @Test + void missingRequiredMultipartPartMapsToBadRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + + var response = handler.handleBadRequest( + new MissingServletRequestPartException("file"), request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code()); + } + + @Test + void validationDetailsAreIncludedInMessageAndData() { + MockHttpServletRequest request = new MockHttpServletRequest(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "request"); + bindingResult.addError(new FieldError("request", "carrierId", "不能为null")); + bindingResult.addError(new FieldError("request", "carrierName", "不能为空")); + + var response = handler.handleBinding(new BindException(bindingResult), request); + + assertThat(response.getBody().code()).isEqualTo(ResultCode.VALIDATION_FAILED.code()); + assertThat(response.getBody().message()).isEqualTo("carrierId: 不能为null;carrierName: 不能为空"); + assertThat(response.getBody().data()).containsEntry("carrierId", "不能为null") + .containsEntry("carrierName", "不能为空"); + } + + @Test + void recordsBusinessFailureMetricByFiniteResultCode() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + ReflectionTestUtils.setField(handler, "meterRegistry", registry); + + handler.handleBusiness(new BizException(ResultCode.CONFLICT, "duplicate"), + new MockHttpServletRequest()); + + assertThat(registry.get("baiyue.api.business.failures") + .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()); + } + + @Test + void lockWaitTimeoutMapsToCanonicalConflict() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(CorrelationContext.REQUEST_ID, "lock-timeout"); + + var response = handler.handleCannotAcquireLock( + new CannotAcquireLockException("Lock wait timeout exceeded"), request); + + assertThat(response.getBody().code()).isEqualTo(ResultCode.CONFLICT.code()); + assertThat(response.getBody().requestId()).isEqualTo("lock-timeout"); + } + + @Test + void oversizedMultipartReturnsClearBadRequestInsteadOfInternalError() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(CorrelationContext.REQUEST_ID, "oversized-photo"); + + var response = handler.handleMaxUploadSize( + new MaxUploadSizeExceededException(10L * 1024 * 1024), request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code()); + assertThat(response.getBody().message()).isEqualTo("上传文件不能超过10MB"); + assertThat(response.getBody().requestId()).isEqualTo("oversized-photo"); + } + + private void paging(long page) { + } } diff --git a/common-web/src/test/java/com/baiyue/common/web/LongIdJacksonConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/LongIdJacksonConfigurationTest.java new file mode 100644 index 000000000..d75be7561 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/LongIdJacksonConfigurationTest.java @@ -0,0 +1,57 @@ +package com.baiyue.common.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("长整型标识序列化") +class LongIdJacksonConfigurationTest { + + private final ObjectMapper objectMapper = configuredObjectMapper(); + + @Test + void longAndPrimitiveLongShouldBeSerializedAsStrings() throws Exception { + String json = objectMapper.writeValueAsString( + new IdPayload(190000000000000001L, 190000000000000002L)); + + assertThat(json).isEqualTo( + "{\"boxedId\":\"190000000000000001\",\"primitiveId\":\"190000000000000002\"}"); + } + + @Test + void stringLongShouldStillBeDeserialized() throws Exception { + IdPayload payload = objectMapper.readValue( + "{\"boxedId\":\"190000000000000001\",\"primitiveId\":\"190000000000000002\"}", + IdPayload.class); + + assertThat(payload.boxedId()).isEqualTo(190000000000000001L); + assertThat(payload.primitiveId()).isEqualTo(190000000000000002L); + } + + @Test + void autoConfigurationShouldApplyToSpringObjectMapper() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + JacksonAutoConfiguration.class, LongIdJacksonConfiguration.class)) + .run(context -> { + ObjectMapper springObjectMapper = context.getBean(ObjectMapper.class); + assertThat(springObjectMapper.writeValueAsString(new IdPayload(1L, 2L))) + .isEqualTo("{\"boxedId\":\"1\",\"primitiveId\":\"2\"}"); + }); + } + + private ObjectMapper configuredObjectMapper() { + Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder(); + new LongIdJacksonConfiguration().longAsStringJacksonCustomizer().customize(builder); + return builder.build(); + } + + private record IdPayload(Long boxedId, long primitiveId) { + } +} 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 38e4be250..891768268 100644 --- a/config/nacos/cn/auth-service.yml +++ b/config/nacos/cn/auth-service.yml @@ -4,3 +4,14 @@ spring: username: ${AUTH_DB_USERNAME} password: ${AUTH_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + security: + access-token-ttl: ${ACCESS_TOKEN_TTL:24m} + refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} + overseas-jwt: + enabled: ${OVERSEAS_JWT_ENABLED:true} + issuer: ${OVERSEAS_JWT_ISSUER:auth-service} + audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} + rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} + current-kid: ${OVERSEAS_JWT_CURRENT_KID:} + access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m} diff --git a/config/nacos/cn/baiyue-common.yml b/config/nacos/cn/baiyue-common.yml index 3b6187a66..f7318ae43 100644 --- a/config/nacos/cn/baiyue-common.yml +++ b/config/nacos/cn/baiyue-common.yml @@ -8,8 +8,24 @@ baiyue: issuer: ${JWT_ISSUER:auth-service} internal-service-token: ${INTERNAL_SERVICE_TOKEN} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} + warehouse: + public-tenant-id: 0 openapi.enabled: ${SWAGGER_ENABLED:false} - audit.enabled: true + audit: + enabled: true + endpoint: ${AUDIT_SERVICE_URL:http://audit-service:9700}/internal/audit/events + outbox: + enabled: ${AUDIT_OUTBOX_ENABLED:false} + publisher-enabled: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:false} + batch-size: ${AUDIT_OUTBOX_BATCH_SIZE:50} + lease-seconds: ${AUDIT_OUTBOX_LEASE_SECONDS:60} + max-attempts: ${AUDIT_OUTBOX_MAX_ATTEMPTS:5} + max-compensations: ${AUDIT_OUTBOX_MAX_COMPENSATIONS:2} + compensation-delay-minutes: ${AUDIT_OUTBOX_COMPENSATION_DELAY_MINUTES:30} + retention-days: ${AUDIT_OUTBOX_RETENTION_DAYS:30} + rocketmq: + topic: ${MQ_TOPIC_PREFIX:}baiyue-audit-event-v1 + send-timeout-ms: ${AUDIT_MQ_SEND_TIMEOUT_MS:3000} spring: jackson: default-property-inclusion: non_null @@ -36,6 +52,7 @@ spring: read-timeout: 5000 server: shutdown: graceful + max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB} management: endpoints: web: diff --git a/config/nacos/cn/event-bridge-service.yml b/config/nacos/cn/event-bridge-service.yml index a5fbba087..27a06967c 100644 --- a/config/nacos/cn/event-bridge-service.yml +++ b/config/nacos/cn/event-bridge-service.yml @@ -8,3 +8,8 @@ baiyue: region: ${REGION_CODE:CN} deployment-cell: ${DEPLOYMENT_CELL:prod-cn} data-residency: ${DATA_RESIDENCY:CN} + event-dispatcher: + consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} + firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} + finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/business-fee-source-events} + finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/wallet-command-events} diff --git a/config/nacos/cn/file-service.yml b/config/nacos/cn/file-service.yml index e7d9a989e..2d1ff9358 100644 --- a/config/nacos/cn/file-service.yml +++ b/config/nacos/cn/file-service.yml @@ -4,10 +4,123 @@ spring: username: ${FILE_DB_USERNAME} password: ${FILE_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver -file: - storage: - provider: ${FILE_STORAGE_PROVIDER:oss} - endpoint: ${FILE_STORAGE_ENDPOINT:} - bucket: ${FILE_STORAGE_BUCKET:} - access-key: ${FILE_STORAGE_ACCESS_KEY:} - secret-key: ${FILE_STORAGE_SECRET_KEY:} + +# 当前代码只实现本地/共享文件目录,非本地环境必须显式注入并挂载可写目录。 +baiyue.file.storage-root: ${FILE_STORAGE_ROOT} +baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:} +baiyue: + file: + export: + definitions: + CUSTOMER_LIST: + provider-service: iam-service + provider-path: /internal/exports/v1/CUSTOMER_LIST/batches + required-permission: platform:customer:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + + DOMESTIC_WAREHOUSE_ORDER_LIST: + provider-service: order-service + provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches + required-permission: platform:order:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + + LOADING_LIST_BUY_DECLARATION_ITEMS: + provider-service: firstleg-service + provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches + required-permission: platform:firstleg:customs:manage + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + + FINANCE_CUSTOMER_ACCOUNT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches + required-permission: platform:finance:customer-account:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_CUSTOMER_ACCOUNT_LEDGER: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches + required-permission: platform:finance:customer-account:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_FEE_SOURCE_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches + required-permission: platform:finance:customer-arap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECONCILIATION_STATEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches + required-permission: platform:finance:customer-statement:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_SETTLEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches + required-permission: platform:finance:settlement-ar:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches + required-permission: platform:finance:settlement-ar:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_PAYABLE_SETTLEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches + required-permission: platform:finance:settlement-ap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_PAYABLE_SETTLEMENT_DETAIL: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches + required-permission: platform:finance:settlement-ap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_STATISTICS: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches + required-permission: platform:finance:receivable-statistics:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 +baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:} diff --git a/config/nacos/cn/finance-service.yml b/config/nacos/cn/finance-service.yml new file mode 100644 index 000000000..75979d525 --- /dev/null +++ b/config/nacos/cn/finance-service.yml @@ -0,0 +1,10 @@ +spring: + datasource: + url: ${FINANCE_DB_URL} + username: ${FINANCE_DB_USERNAME} + password: ${FINANCE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} +baiyue.finance.recharge.wechat-qr-code-url: ${FINANCE_RECHARGE_WECHAT_QR_CODE_URL:} +baiyue.finance.recharge.alipay-qr-code-url: ${FINANCE_RECHARGE_ALIPAY_QR_CODE_URL:} +baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index 4baf82f98..3ca256ca4 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -4,3 +4,62 @@ spring: username: ${FIRSTLEG_DB_USERNAME} password: ${FIRSTLEG_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + servlet: + multipart: + max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB} + max-request-size: ${FIRSTLEG_MULTIPART_MAX_REQUEST_SIZE:52MB} +baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} +baiyue.clients.order.url: ${ORDER_SERVICE_URL:} +baiyue.clients.file.url: ${FILE_SERVICE_URL:} +baiyue: + carrier-schedule: + providers: + matson-automated: ${MATSON_TRACKING_ENABLED:false} + 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: + enabled: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:false} + 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 + default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000} + reservation-ttl: ${PALLET_RESERVATION_TTL:30m} + warehouse-resource-tenant-id: 0 + measurement-openapi: + enabled: ${MEASUREMENT_OPENAPI_ENABLED:false} + # 墨家首轮联调暂不验 HMAC;通讯确认后将环境变量改为 true 即可恢复。 + signature-required: ${MEASUREMENT_OPENAPI_SIGNATURE_REQUIRED:false} + max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m} + nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m} + apps: + # 墨家设备群共用固定 AppKey;签名恢复后再启用 Secret。每台机器用 data.deviceCode 区分。 + mojia: + enabled: ${MOJIA_MEASUREMENT_ENABLED:false} + secret: ${MOJIA_MEASUREMENT_SECRET:} + tenant-id: ${MOJIA_MEASUREMENT_TENANT_ID:0} + subject-id: ${MOJIA_MEASUREMENT_SUBJECT_ID:0} + warehouse-bindings: + CN-YW-01: ${MOJIA_MEASUREMENT_WAREHOUSE_ID:0} + # 历史通用应用配置,保留兼容;新接入请使用 mojia。 + device-provider: + enabled: ${MEASUREMENT_DEVICE_PROVIDER_ENABLED:false} + secret: ${MEASUREMENT_DEVICE_PROVIDER_SECRET:} + tenant-id: ${MEASUREMENT_DEVICE_PROVIDER_TENANT_ID:0} + subject-id: ${MEASUREMENT_DEVICE_PROVIDER_SUBJECT_ID:0} + warehouse-bindings: + CN-YW-01: ${MEASUREMENT_DEVICE_PROVIDER_WAREHOUSE_ID:0} diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/cn/iam-service.yml index 43082d56b..f735164e7 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/cn/iam-service.yml @@ -4,3 +4,13 @@ spring: username: ${IAM_DB_USERNAME} 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} + mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} diff --git a/config/nacos/cn/integration-service.yml b/config/nacos/cn/integration-service.yml index a0de026fb..d4dd01f9c 100644 --- a/config/nacos/cn/integration-service.yml +++ b/config/nacos/cn/integration-service.yml @@ -1,8 +1,59 @@ +spring: + servlet: + multipart: + max-file-size: 5MB + max-request-size: 6MB + baiyue: integration: baidu-ocr: enabled: ${BAIDU_OCR_ENABLED:false} api-key: ${BAIDU_OCR_API_KEY:} secret-key: ${BAIDU_OCR_SECRET_KEY:} + input-root: ${WEIGHT_OCR_INPUT_ROOT:/data/baiyue/weight-ocr/input} + output-root: ${WEIGHT_OCR_OUTPUT_ROOT:/data/baiyue/weight-ocr/output} + max-image-size: ${WEIGHT_OCR_MAX_IMAGE_SIZE:5MB} + max-image-pixels: ${WEIGHT_OCR_MAX_IMAGE_PIXELS:40000000} + max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50} + connect-timeout: ${BAIDU_OCR_CONNECT_TIMEOUT:10s} + request-timeout: ${BAIDU_OCR_REQUEST_TIMEOUT:60s} + min-request-interval: ${BAIDU_OCR_MIN_REQUEST_INTERVAL:600ms} + 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/cn/order-service.yml b/config/nacos/cn/order-service.yml index 390e43a77..5e4a505b3 100644 --- a/config/nacos/cn/order-service.yml +++ b/config/nacos/cn/order-service.yml @@ -4,3 +4,11 @@ spring: username: ${ORDER_DB_USERNAME} password: ${ORDER_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + +baiyue.clients: + # 容器环境默认通过 Nacos 的 warehouse-service 实例调用;仅端口回归时显式设置直连 URL。 + warehouse.url: ${WAREHOUSE_SERVICE_URL:} + +baiyue.order: + # 默认由新系统自动生成;仅在两套系统并跑期间显式改为 LEGACY_MANUAL。 + inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO} diff --git a/config/nacos/cn/print-service.yml b/config/nacos/cn/print-service.yml index 613433e3f..c3fc80ed8 100644 --- a/config/nacos/cn/print-service.yml +++ b/config/nacos/cn/print-service.yml @@ -1,6 +1,11 @@ -spring: - datasource: - url: ${PRINT_DB_URL} - username: ${PRINT_DB_USERNAME} - password: ${PRINT_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver +print: + pdf: + max-pages: ${PRINT_PDF_MAX_PAGES:2000} + max-request-bytes: ${PRINT_PDF_MAX_REQUEST_BYTES:2097152} + max-output-bytes: ${PRINT_PDF_MAX_OUTPUT_BYTES:33554432} + max-concurrent-generations: ${PRINT_PDF_MAX_CONCURRENT_GENERATIONS:4} + acquire-timeout: ${PRINT_PDF_ACQUIRE_TIMEOUT:1s} + template-code: INBOUND_MARK_V1 + supported-width-mm: 100 + supported-height-mm: 100 + font-resource: fonts/NotoSansSC-Bold.ttf diff --git a/config/nacos/local/README.md b/config/nacos/local/README.md index acfe702a9..ce6d3e445 100644 --- a/config/nacos/local/README.md +++ b/config/nacos/local/README.md @@ -10,4 +10,28 @@ powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-local-nacos-config.ps1 ``` +也可以使用统一入口;`local` 会被强制限制在 `public` namespace: + +```powershell +powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 -Environment local +``` + 配置发布成功后,各服务仍由自身的 Nacos Discovery 配置在启动时自动注册,不需要手工创建服务实例。 + +本地海外链路也发布在同一 `public / BAIYUE` 作用域,包括 +`gateway-overseas.yml`、`lastleg-service.yml` 和 +`warehouse-overseas-service.yml`。这些配置默认通过 `127.0.0.1` 连接本机 +Auth、国内仓库、文件和海外服务;需要跨主机运行时使用对应环境变量覆盖。 + +## MyBatis SQL 日志 + +本地公共配置使用 MyBatis `Slf4jImpl`,数据库服务的 Mapper 日志级别默认为 `DEBUG`。执行数据库操作时,控制台会打印 SQL 模板、参数和影响行数。 + +可通过环境变量统一调整: + +```powershell +$env:MYBATIS_SQL_LOG_LEVEL = "INFO" # 关闭 SQL 明细 +$env:MYBATIS_SQL_LOG_LEVEL = "DEBUG" # 打开 SQL 明细 +``` + +修改后重新发布本地 Nacos 配置并重启相关服务。SQL 参数可能包含业务数据,该设置只用于本地开发,不得复制到测试或生产环境。 diff --git a/config/nacos/local/auth-service.yml b/config/nacos/local/auth-service.yml index 8843875b3..64d7b2be0 100644 --- a/config/nacos/local/auth-service.yml +++ b/config/nacos/local/auth-service.yml @@ -2,4 +2,14 @@ spring.datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} username: ${AUTH_DB_USERNAME:root} - password: ${AUTH_DB_PASSWORD:123456} +password: ${AUTH_DB_PASSWORD:123456} + +baiyue: + security: + overseas-jwt: + enabled: ${OVERSEAS_JWT_ENABLED:true} + issuer: ${OVERSEAS_JWT_ISSUER:auth-service} + audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} + rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} + current-kid: ${OVERSEAS_JWT_CURRENT_KID:} + access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m} diff --git a/config/nacos/local/baiyue-common.yml b/config/nacos/local/baiyue-common.yml index 0869c1028..9af5a8d4a 100644 --- a/config/nacos/local/baiyue-common.yml +++ b/config/nacos/local/baiyue-common.yml @@ -1,4 +1,3 @@ -# 头程本地公共配置。生产环境不得直接使用本文件中的本地默认密钥。 baiyue: deployment: region-code: ${REGION_CODE:CN} @@ -7,10 +6,26 @@ baiyue: security: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} issuer: ${JWT_ISSUER:auth-service} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} + warehouse: + public-tenant-id: 0 openapi.enabled: ${SWAGGER_ENABLED:true} - audit.enabled: true + audit: + enabled: true + endpoint: ${AUDIT_SERVICE_URL:http://127.0.0.1:9700}/internal/audit/events + outbox: + enabled: ${AUDIT_OUTBOX_ENABLED:false} + publisher-enabled: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:false} + batch-size: ${AUDIT_OUTBOX_BATCH_SIZE:50} + lease-seconds: ${AUDIT_OUTBOX_LEASE_SECONDS:60} + max-attempts: ${AUDIT_OUTBOX_MAX_ATTEMPTS:5} + max-compensations: ${AUDIT_OUTBOX_MAX_COMPENSATIONS:2} + compensation-delay-minutes: ${AUDIT_OUTBOX_COMPENSATION_DELAY_MINUTES:30} + retention-days: ${AUDIT_OUTBOX_RETENTION_DAYS:30} + rocketmq: + topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1 + send-timeout-ms: ${AUDIT_MQ_SEND_TIMEOUT_MS:3000} spring: jackson: default-property-inclusion: non_null @@ -32,7 +47,9 @@ spring: client.config.default: connect-timeout: 3000 read-timeout: 5000 -server.shutdown: graceful +server: + shutdown: graceful + max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB} management: endpoints.web.exposure.include: health,info,prometheus endpoint.health: diff --git a/config/nacos/local/event-bridge-service.yml b/config/nacos/local/event-bridge-service.yml index 2f3d5dd51..e70f98f7f 100644 --- a/config/nacos/local/event-bridge-service.yml +++ b/config/nacos/local/event-bridge-service.yml @@ -5,5 +5,7 @@ spring.datasource: password: ${EVENT_BRIDGE_DB_PASSWORD:123456} baiyue: region: ${REGION_CODE:CN} + event-bridge: + accept-any-region: ${EVENT_BRIDGE_ACCEPT_ANY_REGION:true} deployment-cell: ${DEPLOYMENT_CELL:local-cn} data-residency: ${DATA_RESIDENCY:CN} diff --git a/config/nacos/local/file-service.yml b/config/nacos/local/file-service.yml index bfc359e34..31fcdb590 100644 --- a/config/nacos/local/file-service.yml +++ b/config/nacos/local/file-service.yml @@ -4,3 +4,120 @@ spring.datasource: username: ${FILE_DB_USERNAME:root} password: ${FILE_DB_PASSWORD:123456} baiyue.file.storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-firstleg-runtime/uploads} +baiyue: + file: + export: + definitions: + CUSTOMER_LIST: + provider-service: iam-service + provider-path: /internal/exports/v1/CUSTOMER_LIST/batches + required-permission: platform:customer:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + + DOMESTIC_WAREHOUSE_ORDER_LIST: + provider-service: order-service + provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches + required-permission: platform:order:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + + LOADING_LIST_BUY_DECLARATION_ITEMS: + provider-service: firstleg-service + provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches + required-permission: platform:firstleg:customs:manage + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + + FINANCE_CUSTOMER_ACCOUNT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches + required-permission: platform:finance:customer-account:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_CUSTOMER_ACCOUNT_LEDGER: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches + required-permission: platform:finance:customer-account:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_FEE_SOURCE_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches + required-permission: platform:finance:customer-arap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECONCILIATION_STATEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches + required-permission: platform:finance:customer-statement:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_SETTLEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches + required-permission: platform:finance:settlement-ar:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches + required-permission: platform:finance:settlement-ar:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_PAYABLE_SETTLEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches + required-permission: platform:finance:settlement-ap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_PAYABLE_SETTLEMENT_DETAIL: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches + required-permission: platform:finance:settlement-ap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_STATISTICS: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches + required-permission: platform:finance:receivable-statistics:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 +baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:} +baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:} diff --git a/config/nacos/local/finance-service.yml b/config/nacos/local/finance-service.yml new file mode 100644 index 000000000..28e288d48 --- /dev/null +++ b/config/nacos/local/finance-service.yml @@ -0,0 +1,10 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${FINANCE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${FINANCE_DB_USERNAME:root} + password: ${FINANCE_DB_PASSWORD:123456} +baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} +baiyue.finance.recharge.wechat-qr-code-url: ${FINANCE_RECHARGE_WECHAT_QR_CODE_URL:/customer-recharge-qr.svg} +baiyue.finance.recharge.alipay-qr-code-url: ${FINANCE_RECHARGE_ALIPAY_QR_CODE_URL:/customer-recharge-qr.svg} +baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index 2597cf547..1da4fd0e1 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -3,3 +3,50 @@ spring.datasource: url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} username: ${FIRSTLEG_DB_USERNAME:root} password: ${FIRSTLEG_DB_PASSWORD:123456} +spring.servlet.multipart.max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB} +spring.servlet.multipart.max-request-size: ${FIRSTLEG_MULTIPART_MAX_REQUEST_SIZE:52MB} +baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} +baiyue.clients.order.url: ${ORDER_SERVICE_URL:} +baiyue.clients.file.url: ${FILE_SERVICE_URL:} +baiyue.carrier-schedule.providers.matson-automated: ${MATSON_TRACKING_ENABLED:false} +baiyue.carrier-schedule.providers.sml-automated: ${SML_TRACKING_ENABLED:false} +baiyue.carrier-schedule.providers.zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false} +baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} +baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false} +baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false} +baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} +baiyue.carrier-schedule.scheduler.enabled: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:false} +baiyue.carrier-schedule.scheduler.scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} +baiyue.carrier-schedule.scheduler.scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} +baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} +baiyue.carrier-schedule.scheduler.max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} +baiyue.carrier-schedule.scheduler.lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} +firstleg: + inbound: + allow-rejected-measurement: false + default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000} + reservation-ttl: ${PALLET_RESERVATION_TTL:30m} + warehouse-resource-tenant-id: 0 + measurement-openapi: + enabled: ${MEASUREMENT_OPENAPI_ENABLED:false} + # 墨家首轮联调暂不验 HMAC;通讯确认后将环境变量改为 true 即可恢复。 + signature-required: ${MEASUREMENT_OPENAPI_SIGNATURE_REQUIRED:false} + max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m} + nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m} + apps: + # 墨家设备群共用固定 AppKey;签名恢复后再启用 Secret。每台机器用 data.deviceCode 区分。 + mojia: + enabled: ${MOJIA_MEASUREMENT_ENABLED:false} + secret: ${MOJIA_MEASUREMENT_SECRET:} + tenant-id: ${MOJIA_MEASUREMENT_TENANT_ID:0} + subject-id: ${MOJIA_MEASUREMENT_SUBJECT_ID:0} + warehouse-bindings: + CN-YW-01: ${MOJIA_MEASUREMENT_WAREHOUSE_ID:0} + # 历史通用应用配置,保留兼容;新接入请使用 mojia。 + device-provider: + enabled: ${MEASUREMENT_DEVICE_PROVIDER_ENABLED:false} + secret: ${MEASUREMENT_DEVICE_PROVIDER_SECRET:} + tenant-id: ${MEASUREMENT_DEVICE_PROVIDER_TENANT_ID:0} + subject-id: ${MEASUREMENT_DEVICE_PROVIDER_SUBJECT_ID:0} + warehouse-bindings: + CN-YW-01: ${MEASUREMENT_DEVICE_PROVIDER_WAREHOUSE_ID:0} diff --git a/config/nacos/local/gateway-overseas.yml b/config/nacos/local/gateway-overseas.yml new file mode 100644 index 000000000..ea2ef8cb5 --- /dev/null +++ b/config/nacos/local/gateway-overseas.yml @@ -0,0 +1,16 @@ +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:true} + swagger-ui.enabled: ${SWAGGER_ENABLED:true} +baiyue: + security: + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + pda-session-validation: + auth-url: ${AUTH_SESSION_VALIDATION_URL:http://127.0.0.1:9100} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} + overseas-projection: + service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://127.0.0.1:9303} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/local/gateway.yml b/config/nacos/local/gateway.yml index 38bedb7c3..c31d6422d 100644 --- a/config/nacos/local/gateway.yml +++ b/config/nacos/local/gateway.yml @@ -1,3 +1,11 @@ springdoc: api-docs.enabled: ${SWAGGER_ENABLED:true} swagger-ui.enabled: ${SWAGGER_ENABLED:true} +baiyue: + cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174} + allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,PATCH,DELETE,OPTIONS} + allowed-headers: ${CORS_ALLOWED_HEADERS:*} + exposed-headers: ${CORS_EXPOSED_HEADERS:X-Request-Id,X-Trace-Id} + allow-credentials: ${CORS_ALLOW_CREDENTIALS:true} + max-age: ${CORS_MAX_AGE:3600} diff --git a/config/nacos/local/iam-service.yml b/config/nacos/local/iam-service.yml index fba851416..a1a4c5f90 100644 --- a/config/nacos/local/iam-service.yml +++ b/config/nacos/local/iam-service.yml @@ -3,3 +3,14 @@ spring.datasource: url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} username: ${IAM_DB_USERNAME:root} password: ${IAM_DB_PASSWORD:123456} +baiyue: + security-event: + # 本机只运行一个 Event Bridge;部署环境通过 SECURITY_EVENT_BRIDGE_URL 指向海外实例。 + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://127.0.0.1:9312} + customer-dimension-initialization: + fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} + max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} + notification: + scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} + dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} + mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} diff --git a/config/nacos/local/integration-service.yml b/config/nacos/local/integration-service.yml index f41753f73..b02d9d323 100644 --- a/config/nacos/local/integration-service.yml +++ b/config/nacos/local/integration-service.yml @@ -1,3 +1,53 @@ -baiyue.integration: - baidu-ocr.enabled: ${BAIDU_OCR_ENABLED:false} - amazon-sp-api.enabled: ${AMAZON_SP_API_ENABLED:false} +spring: + servlet: + multipart: + max-file-size: 5MB + max-request-size: 6MB + +baiyue: + integration: + baidu-ocr: + enabled: ${BAIDU_OCR_ENABLED:false} + input-root: ${WEIGHT_OCR_INPUT_ROOT:${java.io.tmpdir}/baiyue-weight-ocr/input} + output-root: ${WEIGHT_OCR_OUTPUT_ROOT:${java.io.tmpdir}/baiyue-weight-ocr/output} + max-image-size: ${WEIGHT_OCR_MAX_IMAGE_SIZE:5MB} + max-image-pixels: ${WEIGHT_OCR_MAX_IMAGE_PIXELS:40000000} + max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50} + amazon-sp-api: + enabled: ${AMAZON_SP_API_ENABLED:false} + carrier-schedule: + matson: + enabled: ${MATSON_TRACKING_ENABLED:false} + page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html} + query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php} + connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s} + hede: + enabled: ${HEDE_TRACKING_ENABLED:false} + query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic} + connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s} + sml: + enabled: ${SML_TRACKING_ENABLED:false} + query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do} + connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s} + zim: + enabled: ${ZIM_TRACKING_ENABLED:false} + authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1} + tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2} + client-id: ${ZIM_CLIENT_ID:} + client-secret: ${ZIM_CLIENT_SECRET:} + subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:} + connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s} + max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10} + evergreen: + enabled: ${EMC_TRACKING_ENABLED:false} + query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do} + connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s} diff --git a/config/nacos/local/lastleg-service.yml b/config/nacos/local/lastleg-service.yml new file mode 100644 index 000000000..77ec1e8b2 --- /dev/null +++ b/config/nacos/local/lastleg-service.yml @@ -0,0 +1,29 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${LASTLEG_DB_USERNAME:root} + password: ${LASTLEG_DB_PASSWORD:123456} +lastleg: + dispatch-query-api.enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true} + smart-dispatch-api.enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true} +baiyue: + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} + file: + url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} + event-bridge: + url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}} + warehouse: + base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://127.0.0.1:9305} + firstleg: + event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}} + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + jwt-mode: jwks + session-mode: gateway + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} diff --git a/config/nacos/local/order-service.yml b/config/nacos/local/order-service.yml index ca0bf449e..16d034278 100644 --- a/config/nacos/local/order-service.yml +++ b/config/nacos/local/order-service.yml @@ -4,4 +4,8 @@ spring.datasource: username: ${ORDER_DB_USERNAME:root} password: ${ORDER_DB_PASSWORD:123456} baiyue.clients: - warehouse.url: ${WAREHOUSE_SERVICE_URL:http://127.0.0.1:9205} + warehouse.url: ${WAREHOUSE_SERVICE_URL:} + +baiyue.order: + # 默认由新系统自动生成;仅在两套系统并跑期间显式改为 LEGACY_MANUAL。 + inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO} diff --git a/config/nacos/local/print-service.yml b/config/nacos/local/print-service.yml index 0a3a09dc5..c3fc80ed8 100644 --- a/config/nacos/local/print-service.yml +++ b/config/nacos/local/print-service.yml @@ -1,5 +1,11 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${PRINT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_print?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${PRINT_DB_USERNAME:root} - password: ${PRINT_DB_PASSWORD:123456} +print: + pdf: + max-pages: ${PRINT_PDF_MAX_PAGES:2000} + max-request-bytes: ${PRINT_PDF_MAX_REQUEST_BYTES:2097152} + max-output-bytes: ${PRINT_PDF_MAX_OUTPUT_BYTES:33554432} + max-concurrent-generations: ${PRINT_PDF_MAX_CONCURRENT_GENERATIONS:4} + acquire-timeout: ${PRINT_PDF_ACQUIRE_TIMEOUT:1s} + template-code: INBOUND_MARK_V1 + supported-width-mm: 100 + supported-height-mm: 100 + font-resource: fonts/NotoSansSC-Bold.ttf diff --git a/config/nacos/local/warehouse-overseas-service.yml b/config/nacos/local/warehouse-overseas-service.yml new file mode 100644 index 000000000..7edeaec76 --- /dev/null +++ b/config/nacos/local/warehouse-overseas-service.yml @@ -0,0 +1,19 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${WAREHOUSE_DB_USERNAME:root} + password: ${WAREHOUSE_DB_PASSWORD:123456} +baiyue: + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} + internal-service: + token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + security: + jwt-mode: jwks + session-mode: gateway + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} diff --git a/config/nacos/overseas/README.md b/config/nacos/overseas/README.md new file mode 100644 index 000000000..71866504e --- /dev/null +++ b/config/nacos/overseas/README.md @@ -0,0 +1,19 @@ +# 海外 Nacos 配置 + +本目录是海外远程部署模板,由 dev/development、test 和 production 共用。配置只引用环境变量,不保存密码或访问密钥;三个环境必须发布到各自独立的 Nacos namespace,Gateway 必须同时启用 `overseas` Profile。 + +海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。 + +发布示例: + +```powershell +# 开发环境 +powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 ` + -Environment development -Region overseas -ServerAddr -Namespace + +# 测试环境 +powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 ` + -Environment test -Region overseas -ServerAddr -Namespace +``` + +远程环境禁止使用 `public` namespace。数据库、Redis、JWKS、内部服务令牌和跨区服务地址继续由对应 Jenkins Secret file 提供;不要把真实值写入本目录。 diff --git a/config/nacos/overseas/audit-service.yml b/config/nacos/overseas/audit-service.yml 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..a5e5cb364 --- /dev/null +++ b/config/nacos/overseas/baiyue-common.yml @@ -0,0 +1,67 @@ +baiyue: + deployment: + region-code: ${REGION_CODE:OVERSEAS} + cell: ${DEPLOYMENT_CELL:prod-overseas} + data-residency: ${DATA_RESIDENCY:OVERSEAS} + security: + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + pda-session-validation: + auth-url: ${AUTH_SESSION_VALIDATION_URL} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} + permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} + openapi.enabled: ${SWAGGER_ENABLED:false} + audit.enabled: true +spring: + jackson: + default-property-inclusion: non_null + deserialization: + fail-on-unknown-properties: false + serialization: + write-dates-as-timestamps: false + data: + redis: + host: ${REDIS_HOST:redis-overseas.internal} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: ${REDIS_DATABASE:0} + timeout: ${REDIS_TIMEOUT:3s} + cloud: + nacos: + discovery: + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:prod-overseas} + openfeign: + circuitbreaker.enabled: true + client.config.default: + connect-timeout: 3000 + read-timeout: 5000 +server: + max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB} + shutdown: graceful +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + probes.enabled: true + show-details: never +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + call-setters-on-nulls: true + global-config: + banner: false + db-config: + id-type: assign_id + logic-delete-field: deleted + logic-delete-value: 1 + logic-not-delete-value: 0 +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:false} + swagger-ui.enabled: ${SWAGGER_ENABLED:false} diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml 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..c468507d4 --- /dev/null +++ b/config/nacos/overseas/lastleg-service.yml @@ -0,0 +1,57 @@ +spring: + servlet: + multipart: + max-file-size: ${LASTLEG_MULTIPART_MAX_FILE_SIZE:10MB} + max-request-size: ${LASTLEG_MULTIPART_MAX_REQUEST_SIZE:11MB} + 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} + customer-visit-file: + connect-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_READ_TIMEOUT:30s} + procurement-file: + connect-timeout: ${LASTLEG_PROCUREMENT_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_PROCUREMENT_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} + domestic-loading-evidence: + connect-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_READ_TIMEOUT:30s} + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} + file: + url: ${FILE_SERVICE_URL} + domestic-file: + url: ${FILE_SERVICE_URL} + event-bridge: + url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}} + warehouse: + base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305} + firstleg: + event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}} + outbox-relay: + fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} + lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} + retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} 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.audit.yml b/deploy/docker-compose.audit.yml new file mode 100644 index 000000000..b0d29c2e9 --- /dev/null +++ b/deploy/docker-compose.audit.yml @@ -0,0 +1,43 @@ +services: + auth: + environment: + AUDIT_OUTBOX_ENABLED: "true" + AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" + ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}auth-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + iam: + environment: + AUDIT_OUTBOX_ENABLED: "true" + AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" + ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}iam-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + order: + environment: + AUDIT_OUTBOX_ENABLED: "true" + AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" + ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}order-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + firstleg: + environment: + AUDIT_OUTBOX_ENABLED: "true" + AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" + ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}firstleg-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + warehouse: + environment: + AUDIT_OUTBOX_ENABLED: "true" + AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" + ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}warehouse-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + audit-service: + environment: + ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + AUDIT_MQ_CONSUMER_ENABLED: "true" + AUDIT_DLQ_CONSUMER_ENABLED: "true" + AUDIT_DLQ_COMPENSATION_ENABLED: "true" diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml new file mode 100644 index 000000000..5d969d59c --- /dev/null +++ b/deploy/docker-compose.overseas.yml @@ -0,0 +1,110 @@ +name: firstleg-${DEPLOY_ENV:-test}-overseas + +x-overseas-common: &overseas-common + restart: "on-failure:3" + 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: ${OVERSEAS_NACOS_USERNAME} + NACOS_PASSWORD: ${OVERSEAS_NACOS_PASSWORD} + NACOS_GROUP: ${NACOS_GROUP:-BAIYUE} + NACOS_NAMESPACE: ${OVERSEAS_NACOS_NAMESPACE:-prod-overseas} + REDIS_HOST: ${OVERSEAS_REDIS_HOST:-host.docker.internal} + REDIS_PORT: ${OVERSEAS_REDIS_PORT:-6379} + REDIS_PASSWORD: ${OVERSEAS_REDIS_PASSWORD:-} + REDIS_DATABASE: ${OVERSEAS_REDIS_DATABASE:-0} + INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} + JWT_ISSUER: ${JWT_ISSUER} + JWT_AUDIENCE: ${OVERSEAS_JWT_AUDIENCE:-lastleg-api} + JWT_JWK_SET_URI: ${JWT_JWK_SET_URI} + REGION_CODE: OVERSEAS + DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas} + DATA_RESIDENCY: OVERSEAS + SPRING_SQL_INIT_MODE: never + SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB} + TZ: Asia/Shanghai + logging: + # 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。 + driver: ${DOCKER_LOG_DRIVER:-json-file} + options: + max-size: ${DOCKER_LOG_MAX_SIZE:-50m} + max-file: "${DOCKER_LOG_MAX_FILE:-3}" + +services: + gateway-overseas: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"] + environment: + <<: *overseas-environment + SERVER_PORT: 9300 + SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false} + CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-} + CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} + AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL} + PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s} + LASTLEG_SECURITY_SERVICE_URL: ${LASTLEG_SECURITY_SERVICE_URL:-http://lastleg-service:9303} + LASTLEG_SECURITY_VALIDATION_TIMEOUT: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:-2s} + + lastleg-service: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-lastleg + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"] + environment: + <<: *overseas-environment + SERVER_PORT: 9303 + LASTLEG_DB_URL: ${LASTLEG_DB_URL} + LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} + LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} + CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} + FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} + DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} + FILE_SERVICE_URL: ${FILE_SERVICE_URL} + # Print 注册在国内 Nacos namespace;海外服务通过共享 infra-network 的 Docker DNS 直连。 + PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} + WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} + + warehouse-overseas-service: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"] + environment: + <<: *overseas-environment + SERVER_PORT: 9305 + WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL} + WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} + WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} + DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} + # 与海外 BOL 共用国内 Print;不能在海外 Nacos namespace 中发现该服务。 + PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} + + event-bridge-service: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"] + environment: + <<: *overseas-environment + SERVER_PORT: 9312 + EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_OVERSEAS_DB_URL} + EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} + EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} + EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} + LASTLEG_SECURITY_EVENT_CONSUMER_URL: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/security/v1/events} + networks: + firstleg-overseas-network: + infra-network: + aliases: [event-bridge-overseas] + +networks: + firstleg-overseas-network: + name: firstleg-${DEPLOY_ENV:-test}-overseas-network + infra-network: + external: true + name: ${INFRA_NETWORK:-firstleg-test-infra-network} diff --git a/deploy/docker-compose.rocketmq.yml b/deploy/docker-compose.rocketmq.yml new file mode 100644 index 000000000..7775ff3b9 --- /dev/null +++ b/deploy/docker-compose.rocketmq.yml @@ -0,0 +1,63 @@ +name: firstleg-${DEPLOY_ENV:-test}-rocketmq + +x-bounded-logging: &bounded-logging + driver: ${DOCKER_LOG_DRIVER:-json-file} + options: + max-size: ${DOCKER_LOG_MAX_SIZE:-50m} + max-file: "${DOCKER_LOG_MAX_FILE:-3}" + +services: + rocketmq-namesrv: + image: apache/rocketmq:5.3.1 + restart: "on-failure:3" + command: sh mqnamesrv + logging: *bounded-logging + networks: [infra-network] + + rocketmq-broker: + image: apache/rocketmq:5.3.1 + restart: "on-failure:3" + depends_on: [rocketmq-namesrv] + environment: + ROCKETMQ_BROKER_IP: ${ROCKETMQ_BROKER_IP:?ROCKETMQ_BROKER_IP is required} + volumes: + - ./rocketmq:/config:ro + - ${ROCKETMQ_DATA_DIR:-/data/firstleg/rocketmq/store}:/home/rocketmq/store + command: sh /config/render-broker-config.sh + logging: *bounded-logging + networks: [infra-network] + + rocketmq-topic-init: + image: apache/rocketmq:5.3.1 + depends_on: [rocketmq-broker] + environment: + # 必须与 File Service 完全一致;允许生产环境显式传入空字符串。 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX?MQ_TOPIC_PREFIX must be set; use test- for test and an empty value for production} + command: >- + sh -eu -c 'until sh mqadmin clusterList -n rocketmq-namesrv:9876 2>/dev/null + | grep -q "BaiyueCluster"; do sleep 2; done; + sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster + -t "$${MQ_TOPIC_PREFIX}baiyue-file-export-command-v1" -r 4 -w 4; + sh mqadmin updateSubGroup -n rocketmq-namesrv:9876 -c BaiyueCluster + -g "$${MQ_TOPIC_PREFIX}baiyue-file-export-worker-v1"; + sh mqadmin updateSubGroup -n rocketmq-namesrv:9876 -c BaiyueCluster + -g "$${MQ_TOPIC_PREFIX}baiyue-file-export-dlq-handler-v1"; + sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster + -t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-file-export-worker-v1" -r 1 -w 1 -p 6; + sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster + -t "$${MQ_TOPIC_PREFIX}baiyue-audit-event-v1" -r 4 -w 4; + sh mqadmin updateSubGroup -n rocketmq-namesrv:9876 -c BaiyueCluster + -g "$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1"; + sh mqadmin updateSubGroup -n rocketmq-namesrv:9876 -c BaiyueCluster + -g "$${MQ_TOPIC_PREFIX}baiyue-audit-dlq-handler-v1"; + sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster + -t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1" -r 1 -w 1 -p 6' + # Broker 注册较慢或任一初始化命令失败时整组重试;所有命令均幂等。 + restart: "on-failure:3" + logging: *bounded-logging + networks: [infra-network] + +networks: + infra-network: + external: true + name: ${INFRA_NETWORK:-firstleg-test-infra-network} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e11104038..4b703a3c0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,9 +1,13 @@ name: firstleg-${DEPLOY_ENV:-test} x-common: &common - restart: unless-stopped - networks: [firstleg-network] + restart: "on-failure:3" + networks: [firstleg-network, infra-network] + # 测试机复用宿主机的 MySQL、Redis、Nacos 时,容器通过此别名访问宿主机。 + extra_hosts: + - "host.docker.internal:host-gateway" environment: &common-environment + DEPLOY_ENV: ${DEPLOY_ENV:-test} SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-cn} NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR} NACOS_USERNAME: ${NACOS_USERNAME} @@ -16,22 +20,27 @@ x-common: &common REDIS_DATABASE: ${REDIS_DATABASE:-0} JWT_SECRET: ${JWT_SECRET} INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} + BAIYUE_AUDIT_ENDPOINT: ${BAIYUE_AUDIT_ENDPOINT:-http://audit-service:9700/internal/audit/events} + SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB} + AUDIT_OUTBOX_ENABLED: ${AUDIT_OUTBOX_ENABLED:-false} + AUDIT_OUTBOX_PUBLISHER_ENABLED: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:-false} REGION_CODE: ${REGION_CODE:-CN} DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn} DATA_RESIDENCY: ${DATA_RESIDENCY:-CN} TZ: Asia/Shanghai logging: - driver: json-file + # 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。 + driver: ${DOCKER_LOG_DRIVER:-json-file} options: - max-size: 100m - max-file: "5" + max-size: ${DOCKER_LOG_MAX_SIZE:-50m} + max-file: "${DOCKER_LOG_MAX_FILE:-3}" services: gateway: <<: *common image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG} - container_name: firstleg-gateway - ports: ["${GATEWAY_PORT:-9000}:9000"] + container_name: firstleg-${DEPLOY_ENV:-test}-gateway + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"] environment: <<: *common-environment SERVER_PORT: 9000 @@ -42,44 +51,85 @@ services: auth: <<: *common image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG} - container_name: firstleg-auth - ports: ["${AUTH_PORT:-9100}:9100"] + container_name: firstleg-${DEPLOY_ENV:-test}-auth + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"] environment: <<: *common-environment SERVER_PORT: 9100 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-iam - ports: ["${IAM_PORT:-9201}:9201"] + container_name: firstleg-${DEPLOY_ENV:-test}-iam + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"] environment: <<: *common-environment SERVER_PORT: 9201 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-business - ports: ["${FIRSTLEG_PORT:-9202}:9202"] + container_name: firstleg-${DEPLOY_ENV:-test}-business + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"] environment: <<: *common-environment SERVER_PORT: 9202 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_SCHEDULER_ENABLED: ${CARRIER_SCHEDULE_SCHEDULER_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} + TERMINAL_DRAYAGE_MAIL_ENABLED: ${TERMINAL_DRAYAGE_MAIL_ENABLED:-false} + TERMINAL_DRAYAGE_MAIL_HOST: ${TERMINAL_DRAYAGE_MAIL_HOST:-} + TERMINAL_DRAYAGE_MAIL_PORT: ${TERMINAL_DRAYAGE_MAIL_PORT:-587} + TERMINAL_DRAYAGE_MAIL_USERNAME: ${TERMINAL_DRAYAGE_MAIL_USERNAME:-} + TERMINAL_DRAYAGE_MAIL_PASSWORD: ${TERMINAL_DRAYAGE_MAIL_PASSWORD:-} + TERMINAL_DRAYAGE_MAIL_SSL: ${TERMINAL_DRAYAGE_MAIL_SSL:-false} + TERMINAL_DRAYAGE_MAIL_SCAN_DELAY: ${TERMINAL_DRAYAGE_MAIL_SCAN_DELAY:-10000} + + finance: + <<: *common + image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-finance + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"] + environment: + <<: *common-environment + SERVER_PORT: 9203 + FINANCE_DB_URL: ${FINANCE_DB_URL} + FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME} + FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD} + OVERSEAS_EVENT_BRIDGE_URL: ${OVERSEAS_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312} order: <<: *common image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} - container_name: firstleg-order - ports: ["${ORDER_PORT:-9204}:9204"] + container_name: firstleg-${DEPLOY_ENV:-test}-order + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"] environment: <<: *common-environment SERVER_PORT: 9204 @@ -90,8 +140,8 @@ services: warehouse: <<: *common image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG} - container_name: firstleg-warehouse - ports: ["${WAREHOUSE_PORT:-9205}:9205"] + container_name: firstleg-${DEPLOY_ENV:-test}-warehouse + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"] environment: <<: *common-environment SERVER_PORT: 9205 @@ -102,9 +152,10 @@ services: file: <<: *common image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG} - container_name: firstleg-file - ports: ["${FILE_PORT:-9206}:9206"] - volumes: ["firstleg-file-data:/data/files"] + container_name: firstleg-${DEPLOY_ENV:-test}-file + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"] + volumes: + - "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files" environment: <<: *common-environment SERVER_PORT: 9206 @@ -112,24 +163,30 @@ services: FILE_DB_USERNAME: ${FILE_DB_USERNAME} FILE_DB_PASSWORD: ${FILE_DB_PASSWORD} FILE_STORAGE_ROOT: /data/files + JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" + # Compose 会插值整份文件,即使只部署 auth 等其他服务;File 的必填校验由 deploy-services.sh 按服务执行。 + ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER-} + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + FILE_EXPORT_CONSUMER_ENABLED: ${FILE_EXPORT_CONSUMER_ENABLED:-false} + FILE_EXPORT_CONSUMER_CONCURRENCY: ${FILE_EXPORT_CONSUMER_CONCURRENCY:-4} + FILE_EXPORT_PUBLISHER_ENABLED: ${FILE_EXPORT_PUBLISHER_ENABLED:-false} + FILE_EXPORT_RECONCILER_ENABLED: ${FILE_EXPORT_RECONCILER_ENABLED:-false} print: <<: *common image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG} - container_name: firstleg-print - ports: ["${PRINT_PORT:-9207}:9207"] + container_name: firstleg-${DEPLOY_ENV:-test}-print + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"] environment: <<: *common-environment SERVER_PORT: 9207 - PRINT_DB_URL: ${PRINT_DB_URL} - PRINT_DB_USERNAME: ${PRINT_DB_USERNAME} - PRINT_DB_PASSWORD: ${PRINT_DB_PASSWORD} + JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" integration-service: <<: *common image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG} - container_name: firstleg-integration - ports: ["${INTEGRATION_PORT:-9210}:9210"] + container_name: firstleg-${DEPLOY_ENV:-test}-integration + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"] environment: <<: *common-environment SERVER_PORT: 9210 @@ -143,35 +200,76 @@ 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-event-bridge - ports: ["${EVENT_BRIDGE_PORT:-9300}:9300"] + container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"] 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} + FINANCE_FEE_EVENT_CONSUMER_URL: ${FINANCE_FEE_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/business-fee-source-events} + FINANCE_WALLET_EVENT_CONSUMER_URL: ${FINANCE_WALLET_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/wallet-command-events} + SPRING_SQL_INIT_MODE: never + networks: + firstleg-network: + infra-network: + aliases: [event-bridge-cn] audit-service: <<: *common image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG} - container_name: firstleg-audit - ports: ["${AUDIT_PORT:-9700}:9700"] + container_name: firstleg-${DEPLOY_ENV:-test}-audit + expose: ["9700"] environment: <<: *common-environment SERVER_PORT: 9700 AUDIT_DB_URL: ${AUDIT_DB_URL} AUDIT_DB_USERNAME: ${AUDIT_DB_USERNAME} AUDIT_DB_PASSWORD: ${AUDIT_DB_PASSWORD} + AUDIT_MQ_CONSUMER_ENABLED: ${AUDIT_MQ_CONSUMER_ENABLED:-false} + AUDIT_DLQ_CONSUMER_ENABLED: ${AUDIT_DLQ_CONSUMER_ENABLED:-false} + AUDIT_DLQ_COMPENSATION_ENABLED: ${AUDIT_DLQ_COMPENSATION_ENABLED:-false} networks: firstleg-network: name: firstleg-${DEPLOY_ENV:-test}-network - -volumes: - firstleg-file-data: - name: firstleg-${DEPLOY_ENV:-test}-file-data + infra-network: + external: true + name: ${INFRA_NETWORK:-firstleg-test-infra-network} diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 1687968a2..ad239b1bf 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,13 +1,27 @@ -FROM eclipse-temurin:17-jre-alpine +ARG BASE_IMAGE=172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine +FROM ${BASE_IMAGE} AS extractor -RUN apk add --no-cache curl \ - && addgroup -S app \ +WORKDIR /workspace + +ARG JAR_FILE +COPY ${JAR_FILE} app.jar +RUN java -Djarmode=layertools -jar app.jar extract + +FROM ${BASE_IMAGE} + +RUN addgroup -S app \ && adduser -S -G app -u 10001 app WORKDIR /app -ARG JAR_FILE -COPY --chown=app:app ${JAR_FILE} /app/app.jar +# 预建镜像内运行时数据和日志目录并归属 app 用户;宿主机 bind mount 的属主需在部署前单独设置为 UID 10001。 +RUN mkdir -p /data/files && chown -R app:app /data + +# 依赖层稳定、业务代码层高频变化;Registry 只需传输实际变化的 Layer。 +COPY --from=extractor --chown=app:app /workspace/dependencies/ ./ +COPY --from=extractor --chown=app:app /workspace/spring-boot-loader/ ./ +COPY --from=extractor --chown=app:app /workspace/snapshot-dependencies/ ./ +COPY --from=extractor --chown=app:app /workspace/application/ ./ USER app @@ -17,6 +31,6 @@ ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError -Dfile.enco EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ - CMD curl -fsS "http://127.0.0.1:${SERVER_PORT}/actuator/health" || exit 1 + CMD busybox wget -q -O /dev/null "http://127.0.0.1:${SERVER_PORT}/actuator/health" || exit 1 -ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"] +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"] diff --git a/deploy/env/.env.infra-test.example b/deploy/env/.env.infra-test.example new file mode 100644 index 000000000..583f4d3ec --- /dev/null +++ b/deploy/env/.env.infra-test.example @@ -0,0 +1,17 @@ +# 测试基础设施配置模板。复制为仓库根目录 .env 后填写真实值,禁止提交真实密码。 +INFRA_NETWORK=firstleg-test-infra-network +MYSQL_ROOT_PASSWORD=CHANGE_ME_TEST_MYSQL_ROOT_PASSWORD +MYSQL_DATABASE=baiyue +MYSQL_USER=baiyue +MYSQL_PASSWORD=CHANGE_ME_TEST_MYSQL_PASSWORD +REDIS_PASSWORD=CHANGE_ME_TEST_REDIS_PASSWORD +# RocketMQ 与 File Service 位于同一 Docker 网络,Broker 向客户端公布服务名。 +ROCKETMQ_BROKER_IP=rocketmq-broker +# 测试环境必须显式使用 test-;生产环境在对应凭据文件中显式设置为空字符串。 +MQ_TOPIC_PREFIX=test- +AUDIT_OUTBOX_ENABLED=false +AUDIT_OUTBOX_PUBLISHER_ENABLED=false +AUDIT_MQ_CONSUMER_ENABLED=false +AUDIT_DLQ_CONSUMER_ENABLED=false +AUDIT_DLQ_COMPENSATION_ENABLED=false +ROCKETMQ_DATA_DIR=/data/firstleg/rocketmq/store diff --git a/deploy/maven/settings-ci.xml b/deploy/maven/settings-ci.xml new file mode 100644 index 000000000..44e005ada --- /dev/null +++ b/deploy/maven/settings-ci.xml @@ -0,0 +1,39 @@ + + + + + aliyun-public + 阿里云 Maven 公共仓库 + https://maven.aliyun.com/repository/public + central + + + + + + 国内-CI-仓库 + + + central + https://repo.maven.apache.org/maven2 + true + false + + + + + central + https://repo.maven.apache.org/maven2 + true + false + + + + + + + 国内-CI-仓库 + + diff --git a/deploy/maven/settings-cn.xml b/deploy/maven/settings-cn.xml new file mode 100644 index 000000000..fb58f7035 --- /dev/null +++ b/deploy/maven/settings-cn.xml @@ -0,0 +1,5 @@ + + + diff --git a/deploy/rocketmq/broker.conf.template b/deploy/rocketmq/broker.conf.template new file mode 100644 index 000000000..abd7ae08b --- /dev/null +++ b/deploy/rocketmq/broker.conf.template @@ -0,0 +1,11 @@ +brokerClusterName=BaiyueCluster +brokerName=baiyue-file-export-broker +brokerId=0 +brokerRole=ASYNC_MASTER +flushDiskType=ASYNC_FLUSH +deleteWhen=04 +fileReservedTime=72 +autoCreateTopicEnable=false +autoCreateSubscriptionGroup=false +listenPort=10911 +brokerIP1=__BROKER_IP__ diff --git a/deploy/rocketmq/render-broker-config.sh b/deploy/rocketmq/render-broker-config.sh new file mode 100644 index 000000000..ee53cb3b7 --- /dev/null +++ b/deploy/rocketmq/render-broker-config.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu +case "${ROCKETMQ_BROKER_IP:-}" in + ""|localhost|127.0.0.1|__BROKER_IP__|*'${'*) + echo "ROCKETMQ_BROKER_IP must be an explicit client-reachable address" >&2 + exit 1 + ;; +esac +sed "s/__BROKER_IP__/${ROCKETMQ_BROKER_IP}/g" /config/broker.conf.template > /tmp/broker.conf +if grep -Eq '__BROKER_IP__|\$\{' /tmp/broker.conf; then + echo "broker.conf still contains an unresolved placeholder" >&2 + grep -nE '__BROKER_IP__|\$\{' /tmp/broker.conf >&2 + exit 1 +fi +exec sh mqbroker -n rocketmq-namesrv:9876 -c /tmp/broker.conf diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh new file mode 100644 index 000000000..2320435d8 --- /dev/null +++ b/deploy/scripts/deploy-built-service.sh @@ -0,0 +1,558 @@ +#!/usr/bin/env sh +set -eu + +if [ "$#" -ne 5 ]; then + echo "用法: $0 <环境> <镜像仓库> <镜像标签> <服务> <环境文件>" >&2 + exit 2 +fi + +DEPLOY_ENV="$1" +IMAGE_REPOSITORY="$2" +IMAGE_TAG="$3" +SERVICE="$4" +ENV_FILE="$5" +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|production) ;; + *) + echo "仅支持 development/test/production,当前: $DEPLOY_ENV" >&2 + exit 2 + ;; +esac + +if [ "$DEPLOY_ENV" = "development" ]; then + PUBLISHED_BIND_ADDRESS=127.0.0.1 +else + PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" +fi +export PUBLISHED_BIND_ADDRESS + +case "$SERVICE" in + *[!A-Za-z0-9_-]*|'') + echo "非法服务名: $SERVICE" >&2 + exit 2 + ;; +esac + +if [ ! -f "$ENV_FILE" ]; then + echo "必需文件不存在: $ENV_FILE" >&2 + exit 2 +fi + +env_file_has_key() { + grep -q "^$1=" "$ENV_FILE" +} + +env_file_value() { + sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' +} + +case "$SERVICE" in + gateway-overseas|lastleg-service|warehouse-overseas-service) + COMPOSE_FILE="deploy/docker-compose.overseas.yml" + PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" + ;; + event-bridge-service) + BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" + if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then + BRIDGE_REGION="$(env_file_value REGION_CODE)" + fi + if [ -z "$BRIDGE_REGION" ]; then + echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2 + exit 2 + fi + case "$BRIDGE_REGION" in + CN) ;; + OVERSEAS) + COMPOSE_FILE="deploy/docker-compose.overseas.yml" + PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" + ;; + *) + echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2 + exit 2 + ;; + esac + ;; +esac + +if [ "$SERVICE" = "gateway-overseas" ]; then + if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then + echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2 + exit 2 + fi +fi + +for file in "$ENV_FILE" "$COMPOSE_FILE"; do + if [ ! -f "$file" ]; then + echo "必需文件不存在: $file" >&2 + exit 2 + fi +done + +if [ "$SERVICE" = "finance" ]; then + for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 finance 服务时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + +if [ "$PROJECT_NAME" = "firstleg-$DEPLOY_ENV-overseas" ]; then + for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_USERNAME OVERSEAS_NACOS_PASSWORD OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署海外服务时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + +if [ "$SERVICE" = "firstleg" ]; then + if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then + echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 + exit 2 + fi + if [ "$(env_file_value TERMINAL_DRAYAGE_MAIL_ENABLED)" = "true" ]; then + for key in TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "启用码头车队邮件时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + fi +fi + +if [ "$SERVICE" = "lastleg-service" ]; then + for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL FILE_SERVICE_URL; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + +if [ "$SERVICE" = "warehouse-overseas-service" ]; then + for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + +if [ "$SERVICE" = "event-bridge-service" ]; then + if [ "$PROJECT_NAME" = "firstleg-$DEPLOY_ENV-overseas" ]; then + EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL" + else + EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL" + fi + for key in $EVENT_BRIDGE_KEYS; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + +if [ "$SERVICE" = "file" ]; then + if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then + echo "部署 file 服务时,环境文件必须包含非空 ROCKETMQ_NAME_SERVER" >&2 + exit 2 + fi + if ! env_file_has_key MQ_TOPIC_PREFIX; then + echo "部署 file 服务时,环境文件必须显式包含 MQ_TOPIC_PREFIX" >&2 + exit 2 + fi + MQ_PREFIX=$(env_file_value MQ_TOPIC_PREFIX) + if [ "$DEPLOY_ENV" = "test" ] && [ "$MQ_PREFIX" != "test-" ]; then + echo "测试环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为 test-" >&2 + exit 2 + fi + if [ "$DEPLOY_ENV" = "development" ] && { [ -z "$MQ_PREFIX" ] || [ "$MQ_PREFIX" = "test-" ]; }; then + echo "开发环境部署 file 服务时 MQ_TOPIC_PREFIX 必须非空且不能使用 test-" >&2 + exit 2 + fi + if [ "$DEPLOY_ENV" = "production" ] && [ -n "$MQ_PREFIX" ]; then + echo "生产环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为空,使用正式 Topic" >&2 + exit 2 + fi +fi + +: "${REGISTRY_USER:?REGISTRY_USER 未设置}" +: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}" + +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 + 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 + echo "镜像已在目标服务器本机,直接部署: $IMAGE" + deploy_local + exit 0 +fi + +SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}" +SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}" +SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}" +REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}" + +for file in "$SSH_KEY" "$SSH_KNOWN_HOSTS"; do + if [ ! -f "$file" ]; then + echo "SSH 文件不存在: $file" >&2 + exit 2 + fi +done + +run_ssh() { + ssh -i "$SSH_KEY" \ + -o BatchMode=yes \ + -o Compression=no \ + -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ + -o ConnectTimeout=10 \ + "$SSH_TARGET" "$@" +} + +run_scp() { + scp -i "$SSH_KEY" \ + -o BatchMode=yes \ + -o Compression=no \ + -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ + -o ConnectTimeout=10 \ + "$@" +} + +run_ssh install -d -m 750 "$REMOTE_DIR" + +SUFFIX="${IMAGE_TAG}.$$.${SERVICE}" +REMOTE_ENV_NAME=".env.$PROJECT_NAME" +REMOTE_COMPOSE_NAME="docker-compose.$PROJECT_NAME.yml" +REMOTE_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_ENV_TMP" "$REMOTE_COMPOSE_TMP" "$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" <<'REMOTE_PREPARE' +set -eu +env_tmp="$1" +compose_tmp="$2" +env_target="$3" +compose_target="$4" +chmod 600 "$env_tmp" +chmod 644 "$compose_tmp" +mv -f "$env_tmp" "$env_target" +mv -f "$compose_tmp" "$compose_target" +REMOTE_PREPARE + +remote_registry_logout() { + run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true +} + +echo "目标服务器从共享仓库拉取增量镜像: $IMAGE" +printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" +trap remote_registry_logout EXIT HUP INT TERM +run_ssh docker pull "$IMAGE" + +run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$PROJECT_NAME" "$REMOTE_ENV_NAME" "$REMOTE_COMPOSE_NAME" "$HEALTH_TIMEOUT_SECONDS" "$HEALTH_POLL_SECONDS" <<'REMOTE_DEPLOY' +set -eu +remote_dir="$1" +deploy_env="$2" +image_repository="$3" +image_tag="$4" +service="$5" +project_name="$6" +env_file="$7" +compose_file="$8" +health_timeout_seconds="$9" +health_poll_seconds="${10}" +cd "$remote_dir" +export DEPLOY_ENV="$deploy_env" +export IMAGE_REPOSITORY="$image_repository" +export IMAGE_TAG="$image_tag" +if [ "$deploy_env" = "development" ]; then + export PUBLISHED_BIND_ADDRESS=127.0.0.1 +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="$(container_id || true)" +if [ -n "$failed_container_id" ]; then + echo "新镜像容器最近日志(最多 200 行):" >&2 + docker logs --tail 200 "$failed_container_id" 2>&1 || true +else + echo "新镜像容器不存在,无法读取启动日志" >&2 +fi +expected_prefix="$image_repository/$service:" +case "$previous_image" in + "$expected_prefix"*) previous_tag="${previous_image#"$expected_prefix"}" ;; + '') + echo "首次部署没有可回滚镜像,部署失败" >&2 + compose stop "$service" >/dev/null 2>&1 || true + exit 1 + ;; + *) + echo "旧镜像不属于当前服务,拒绝自动回滚: $previous_image" >&2 + exit 1 + ;; +esac +if [ "$previous_image" = "$new_image" ]; then + echo "旧镜像与新镜像相同,无法自动回滚: $new_image" >&2 + exit 1 +fi + +echo "开始自动回滚到: $previous_image" >&2 +if ! docker pull "$previous_image"; then + echo "自动回滚失败,无法拉取旧镜像: $previous_image" >&2 + exit 1 +fi +IMAGE_TAG="$previous_tag" +export IMAGE_TAG +if ! compose up -d --no-deps --pull never "$service"; then + echo "自动回滚失败,旧镜像启动命令失败: $previous_image" >&2 + exit 1 +fi +if wait_for_health; then + echo "自动回滚成功: $previous_image" >&2 + compose ps "$service" || true +else + echo "自动回滚失败,旧镜像未恢复健康: $previous_image" >&2 +fi +exit 1 +REMOTE_DEPLOY + +remote_registry_logout +trap - EXIT HUP INT TERM diff --git a/deploy/scripts/deploy-development-service.sh b/deploy/scripts/deploy-development-service.sh new file mode 100644 index 000000000..23343f7a4 --- /dev/null +++ b/deploy/scripts/deploy-development-service.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env sh +set -eu + +if [ "$#" -ne 4 ]; then + echo "用法: $0 <镜像仓库> <镜像标签> <逗号分隔服务> <环境文件>" >&2 + exit 2 +fi + +IMAGE_REPOSITORY="$1" +IMAGE_TAG="$2" +SERVICE_CSV="$3" +ENV_FILE="$4" + +SSH_TARGET="${DEVELOPMENT_SSH_TARGET:-deploy@172.31.198.8}" +SSH_KEY="${DEVELOPMENT_SSH_KEY:-/var/jenkins_home/.ssh/dev_deploy}" +SSH_KNOWN_HOSTS="${DEVELOPMENT_SSH_KNOWN_HOSTS:-/var/jenkins_home/.ssh/known_hosts}" +REMOTE_DIR="${DEVELOPMENT_DEPLOY_DIR:-/opt/firstleg/application}" +COMPOSE_FILE="${DEPLOY_COMPOSE_FILE:-deploy/docker-compose.yml}" +PROJECT_NAME="${DEPLOY_PROJECT_NAME:-firstleg-development}" + +if [ ! -f "$SSH_KEY" ]; then + echo "开发环境 SSH 私钥不存在: $SSH_KEY" >&2 + exit 2 +fi +if [ ! -f "$SSH_KNOWN_HOSTS" ]; then + echo "SSH 主机指纹文件不存在: $SSH_KNOWN_HOSTS" >&2 + exit 2 +fi +if [ ! -f "$ENV_FILE" ]; then + echo "开发环境变量文件不存在: $ENV_FILE" >&2 + exit 2 +fi +if [ ! -f "$COMPOSE_FILE" ]; then + echo "Compose 文件不存在: $COMPOSE_FILE" >&2 + exit 2 +fi + +SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ') +for service in $SERVICES; do + case "$service" in + *[!A-Za-z0-9_-]*|'') + echo "非法服务名: $service" >&2 + exit 2 + ;; + esac +done + +if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then + echo "开发镜像已在开发节点本机构建,直接部署: $SERVICE_CSV" + install -d -m 750 "$REMOTE_DIR" + + SUFFIX="${IMAGE_TAG}.$$.${SERVICE_CSV}" + LOCAL_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp" + LOCAL_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp" + install -m 600 "$ENV_FILE" "$LOCAL_ENV_TMP" + install -m 644 "$COMPOSE_FILE" "$LOCAL_COMPOSE_TMP" + mv -f "$LOCAL_ENV_TMP" "$REMOTE_DIR/.env" + mv -f "$LOCAL_COMPOSE_TMP" "$REMOTE_DIR/docker-compose.yml" + + for service in $SERVICES; do + docker image inspect "$IMAGE_REPOSITORY/$service:$IMAGE_TAG" >/dev/null + done + + cd "$REMOTE_DIR" + export DEPLOY_ENV=development + export IMAGE_REPOSITORY IMAGE_TAG + export PUBLISHED_BIND_ADDRESS=127.0.0.1 + + docker compose \ + --project-name "$PROJECT_NAME" \ + --env-file .env \ + -f docker-compose.yml \ + pull $SERVICES + + docker compose \ + --project-name "$PROJECT_NAME" \ + --env-file .env \ + -f docker-compose.yml \ + up -d --remove-orphans --pull never $SERVICES + + docker compose \ + --project-name "$PROJECT_NAME" \ + --env-file .env \ + -f docker-compose.yml \ + ps $SERVICES + exit 0 +fi + +run_ssh() { + ssh -i "$SSH_KEY" \ + -o BatchMode=yes \ + -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ + -o ConnectTimeout=10 \ + "$SSH_TARGET" "$@" +} + +run_scp() { + scp -i "$SSH_KEY" \ + -o BatchMode=yes \ + -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ + -o ConnectTimeout=10 \ + "$@" +} + +run_ssh install -d -m 750 "$REMOTE_DIR" + +SUFFIX="${IMAGE_TAG}.$$.${SERVICE_CSV}" +REMOTE_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp" +REMOTE_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp" + +run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP" +run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP" + +run_ssh sh -s -- "$REMOTE_DIR" "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" <<'REMOTE_PREPARE' +set -eu +remote_dir="$1" +env_tmp="$2" +compose_tmp="$3" +chmod 600 "$env_tmp" +chmod 644 "$compose_tmp" +mv -f "$env_tmp" "$remote_dir/.env" +mv -f "$compose_tmp" "$remote_dir/docker-compose.yml" +REMOTE_PREPARE + +for service in $SERVICES; do + image="$IMAGE_REPOSITORY/$service:$IMAGE_TAG" + docker image inspect "$image" >/dev/null + echo "传输开发镜像: $image" + docker save "$image" | gzip -1 | run_ssh 'gzip -dc | docker load' +done + +run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$PROJECT_NAME" <<'REMOTE_DEPLOY' +set -eu +remote_dir="$1" +image_repository="$2" +image_tag="$3" +service_csv="$4" +project_name="$5" +services=$(printf '%s' "$service_csv" | tr ',' ' ') + +cd "$remote_dir" +export DEPLOY_ENV=development +export IMAGE_REPOSITORY="$image_repository" +export IMAGE_TAG="$image_tag" +export PUBLISHED_BIND_ADDRESS=127.0.0.1 + +docker compose \ + --project-name "$project_name" \ + --env-file .env \ + -f docker-compose.yml \ + up -d --remove-orphans --pull never $services + +docker compose \ + --project-name "$project_name" \ + --env-file .env \ + -f docker-compose.yml \ + ps $services +REMOTE_DEPLOY diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index 465be68c3..c18282116 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -18,26 +18,190 @@ if [ ! -f "$ENV_FILE" ]; then 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" +} + +env_file_value() { + sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' +} + +DEPLOY_REGION=CN +for service in $SERVICES; do + case "$service" in + gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; + event-bridge-service) + BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" + if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then + BRIDGE_REGION="$(env_file_value REGION_CODE)" + fi + if [ -z "$BRIDGE_REGION" ]; then + echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2 + exit 2 + fi + case "$BRIDGE_REGION" in + CN) ;; + OVERSEAS) DEPLOY_REGION=OVERSEAS ;; + *) echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2; exit 2 ;; + esac + ;; + esac +done +if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then + for service in $SERVICES; do + case "$service" in + gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; + *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; + esac + done + COMPOSE_FILE="deploy/docker-compose.overseas.yml" + PROJECT_NAME="firstleg-${DEPLOY_ENV}-overseas" +else + COMPOSE_FILE="deploy/docker-compose.yml" + PROJECT_NAME="firstleg-${DEPLOY_ENV}" +fi + +if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then + for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_USERNAME OVERSEAS_NACOS_PASSWORD OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署海外服务时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + case " $SERVICES " in - *" gateway "*) SERVICES="$SERVICES" ;; - *) SERVICES="$SERVICES gateway" ;; + *" gateway-overseas "*) + if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then + echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2 + exit 2 + fi + ;; +esac + +case " $SERVICES " in + *" finance "*) + for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 finance 服务时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + +case " $SERVICES " in + *" firstleg "*) + if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then + echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 + exit 2 + fi + ;; +esac + +case " $SERVICES " in + *" lastleg-service "*) + for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL FILE_SERVICE_URL; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + +case " $SERVICES " in + *" warehouse-overseas-service "*) + for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + +case " $SERVICES " in + *" event-bridge-service "*) + if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then + EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL" + else + EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL" + fi + for key in $EVENT_BRIDGE_KEYS; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + +case " $SERVICES " in + *" file "*) + if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then + echo "部署 file 服务时,环境文件必须包含非空 ROCKETMQ_NAME_SERVER" >&2 + exit 2 + fi + if ! env_file_has_key MQ_TOPIC_PREFIX; then + echo "部署 file 服务时,环境文件必须显式包含 MQ_TOPIC_PREFIX:测试填 test-,生产填空字符串" >&2 + exit 2 + fi + MQ_PREFIX=$(env_file_value MQ_TOPIC_PREFIX) + case "$DEPLOY_ENV" in + test) + if [ "$MQ_PREFIX" != "test-" ]; then + echo "测试环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为 test-" >&2 + exit 2 + fi + ;; + production) + if [ -n "$MQ_PREFIX" ]; then + echo "生产环境部署 file 服务时 MQ_TOPIC_PREFIX 必须显式为空字符串" >&2 + exit 2 + fi + ;; + *) + if [ -z "$MQ_PREFIX" ] || [ "$MQ_PREFIX" = "test-" ]; then + echo "预发布环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为非空且不能使用 test-" >&2 + exit 2 + fi + ;; + esac + ;; esac export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG + +if [ "$DEPLOY_ENV" = "development" ]; then + DEPLOY_COMPOSE_FILE="$COMPOSE_FILE" DEPLOY_PROJECT_NAME="$PROJECT_NAME" exec sh deploy/scripts/deploy-development-service.sh \ + "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE" +fi + 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 \ - up -d --remove-orphans $SERVICES + -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/scripts/initialize-test-databases.sh b/deploy/scripts/initialize-test-databases.sh new file mode 100644 index 000000000..9e97a52b6 --- /dev/null +++ b/deploy/scripts/initialize-test-databases.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env sh +set -eu + +# 仅用于测试基础设施容器。所有 SQL 均使用 CREATE ... IF NOT EXISTS 或幂等扩展语句, +# 不删除已有业务数据;仓库样例数据使用固定 ID 重复写入。 +MYSQL_CONTAINER="${TEST_MYSQL_CONTAINER:-firstleg-test-mysql}" + +if ! docker inspect --type container "$MYSQL_CONTAINER" >/dev/null 2>&1; then + echo "测试 MySQL 容器不存在:$MYSQL_CONTAINER" >&2 + echo "请先执行 docker compose -f docker-compose-infra.yml up -d" >&2 + exit 1 +fi + +if [ "$(docker inspect -f '{{.State.Running}}' "$MYSQL_CONTAINER")" != "true" ]; then + echo "测试 MySQL 容器未运行:$MYSQL_CONTAINER" >&2 + exit 1 +fi + +for sql_file in \ + auth/src/main/resources/db/auth.sql \ + iam/src/main/resources/db/iam.sql \ + auth/src/main/resources/db/phase1-admin-accounts.sql \ + iam/src/main/resources/db/phase1-admin-rbac.sql \ + iam/src/main/resources/db/phase1-onboarding.sql \ + iam/src/main/resources/db/phase1-customer-profile.sql \ + iam/src/main/resources/db/phase1-member-permissions.sql \ + iam/src/main/resources/db/rbac-role-management-v2.sql \ + iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql \ + order/src/main/resources/db/order.sql \ + order/src/main/resources/db/phase1-order-extension.sql \ + order/src/main/resources/db/phase1-masterdata-extension.sql \ + warehouse/src/main/resources/db/warehouse.sql \ + warehouse/src/main/resources/db/warehouse-partner.sql \ + warehouse/src/main/resources/db/warehouse-express-company.sql \ + warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql \ + warehouse/src/main/resources/db/warehouse-seed.sql \ + file/src/main/resources/db/file.sql +do + if [ ! -f "$sql_file" ]; then + echo "找不到初始化 SQL:$sql_file" >&2 + exit 1 + fi + echo "初始化测试数据库:$sql_file" + docker exec -i "$MYSQL_CONTAINER" sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" --default-character-set=utf8mb4' < "$sql_file" +done + +echo "测试数据库初始化完成。" diff --git a/deploy/service-map.yml b/deploy/service-map.yml index 6995fcb9d..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,11 +40,26 @@ 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 port: 9700 + finance: + artifactId: finance-service + jar: finance/target/finance-service-1.0.0-SNAPSHOT.jar + port: 9203 diff --git a/deploy/sql/README.md b/deploy/sql/README.md new file mode 100644 index 000000000..d5fc094b2 --- /dev/null +++ b/deploy/sql/README.md @@ -0,0 +1,76 @@ +# 区域数据库空库初始化 + +## 唯一入口与区域边界 + +当前开发阶段只维护纯初始化,不提供存量升级、数据回填、清理或兼容入口。两个 SQL 文件沿用原文件名,名称中的“增量”不表示可向已有业务库重放。 + +| 区域 | 完整入口 | 服务 schema | 表数 | +|---|---|---|---:| +| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 208 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 136 | + +- 国内和海外是两个部署区域,不是把微服务合并为两个 schema。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 +- 两区域可分别在独立空实例执行,没有跨区域 SQL 依赖。海外不访问国内 IAM/Warehouse;国内不创建、查询或修改海外业务表。 +- 国内 IAM 是全部平台与客户权限的唯一权威,包括海外仓、海外业务、海外主数据及财务权限;这些权限保留在国内种子中。 +- 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。 +- Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。 + +## 执行前提与失败边界 + +1. 获得目标环境、精确数据库范围的当轮授权。初始化授权不等于清库授权;两个 SQL 本身不删除数据库、表或业务数据。 +2. MySQL 8.0+,目标区域 schema 必须不存在或为空。已有同名表时报错,不使用 `CREATE TABLE IF NOT EXISTS` 绕过旧结构。 +3. 执行用户需要目标区域建库、建表、索引、引用、触发器及种子插入权限,不需要另一地区的访问权。 +4. 客户端须支持 `DELIMITER`。完整执行文件,关闭“遇错继续”,禁止 `mysql --force`。不要选中片段执行,不要前后串行执行模块 SQL。 +5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 +6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 + +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/136`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 + +## 平台管理员密码 + +国内只初始化一个 `platform_admin` 及对应 IAM 用户、成员和角色关系,不创建测试客户或客户账号。密码不在源码内置。 + +在**同一连接**中,先由安全外部配置设置会话变量 `@platform_admin_password_hash`,再执行国内文件。值须为实际初始密码生成的 bcrypt 哈希(60 字符);不要传明文、示例哈希或把真实值存进仓库。`password_change_required=1`,初次登录必须改密。 + +变量缺失或格式错误会在 Auth 账号插入时被非空约束拒绝;Auth 表可能已创建,属于失败的半初始化,应停止。海外文件不需要密码变量。 + +本地验收入口读取当前进程环境变量 `INIT_PLATFORM_ADMIN_PASSWORD_HASH`,经标准输入传入同一 MySQL 会话,不写入命令行、暂存 SQL 或日志。数据库连接认证独立由外部登录配置或 `MYSQL_PWD` 提供,不要与平台账号密码混淆。 + +## 审核保留的系统种子 + +| 所属库 | 保留内容 | 不包含 | +|---|---|---| +| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、346 个权限、521 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 | +| Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | +| Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | +| File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | +| Lastleg | 国内固定管理员对应的 USER/AUTHORIZATION version=0 安全投影,当前角色及完整 106 项海外权限 | 客户、订单、库存、发运门禁事实或新账号 | + +海外管理员投影只是国内已定义身份的初始化快照,不是独立账号或权限中心。后续变更仍由 IAM 事件同步,不提供历史投影回填。 + +扣货写权限只默认授予平台管理员,与 IAM 扣货权限合同一致;旧脚本重放时通过范围授权额外授予订单运营的那一条关联不进入新基线。 + +示例 `TP-001`、`TP-002`、`YW-TP-002`、`DG-TP-002`、A1/A0 库位、`EXP-TEST`、`CAR-001` 不再初始化。验收资源通过业务入口配置;既有只复用托盘和库位的造数脚本须先满足各自前置条件,不能默认清库后已存在。 + +## 维护规则 + +- 最终字段、类型、默认值、索引、外键、CHECK、注释直接维护在 CREATE TABLE;64 个国内业务触发器保留最终定义。不追加 ALTER、迁移过程、动态 DDL、UPDATE 回填或 DELETE 清理段。 +- 系统种子直接维护最终 INSERT,不保留退役 ID 清理或 ON DUPLICATE KEY UPDATE 升级。权限变更联查 IAM 来源、Controller、OpenAPI 与调用端。 +- 原 `权限点初始化-MySQL8.sql` 独立补丁已撤销,订单申报权限和默认授权已纳入国内 IAM 基线,不另设存量权限发布入口。 +- 模块 `src/main/resources/db/` 用作结构来源和开发追溯,不是区域部署前置;不再向历史迁移文件追加发布步骤。修改当前字段时同步模块有效建表定义及其合同。 +- 不在 SQL 中写入真实客户数据、环境地址、固定密码或 Token。 + +## 本地验证 + +离线合同不连接数据库: + +```powershell +node --test scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs scripts/inbound-fee-unified-sql-contract.test.mjs scripts/inbound-hold-unified-sql-contract.test.mjs +.\scripts\local-db-full-acceptance.ps1 -DryRun +``` + +获得精确本地库清理授权并配置认证、管理员哈希后,可用 `local-db-full-acceptance.ps1 -Port <已授权端口> -ConfirmResetProjectSchemas -Rounds 2`。它删除列出的 12 个项目 schema,不得在有待保留数据的实例运行;详见 `scripts/README.md`。该入口不会自动启动隔离实例。 + +本次整理使用隔离 MySQL 8.4,国内与海外分别在不存在对方区域 schema 的空库执行两轮,逐表比对整理前最终字段、默认值、索引、约束、注释及业务触发器。SHOW CREATE 的冗余字符集显示与运行态自增计数不视为结构差异;移除示例种子和强制改密是明确行为变更。这不代表已部署或完成真实 HTTP 验收。 + +码头车队邮件配置见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql new file mode 100644 index 000000000..82ba95643 --- /dev/null +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -0,0 +1,7662 @@ +-- CN 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 +-- MySQL 8.0+;只允许目标区域的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 +-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 +-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 +-- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。 +SET NAMES utf8mb4 COLLATE utf8mb4_0900_ai_ci; +SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; + +-- 同一连接须预先设置 @platform_admin_password_hash(外部生成的 bcrypt),不得写入固定密码。 +-- 缺少或格式无效时 auth_account 的 NOT NULL 约束拒绝初始化;初次登录必须改密。 + +-- 服务 schema:baiyue_auth +CREATE DATABASE IF NOT EXISTS baiyue_auth DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_auth; + +CREATE TABLE auth_account ( + id bigint NOT NULL COMMENT '统一登录账号ID(雪花ID)', + username varchar(64) NOT NULL COMMENT '全局唯一登录名', + phone varchar(32) DEFAULT NULL COMMENT '全局唯一中国大陆登录手机号', + password_hash varchar(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码', + display_name varchar(64) NOT NULL COMMENT '登录后展示名称', + account_status varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定', + failed_login_count int NOT NULL DEFAULT '0' COMMENT '连续登录失败次数', + locked_until datetime(3) DEFAULT NULL COMMENT '临时锁定截止时间', + token_version int NOT NULL DEFAULT '1' COMMENT '令牌版本,递增后可使历史令牌失效', + password_change_required tinyint NOT NULL DEFAULT '0' COMMENT '管理员重置后是否强制下次登录改密', + default_tenant_id bigint NOT NULL COMMENT '默认业务作用域ID:0为平台,正数为客户ID', + operator_tenant_id bigint NOT NULL COMMENT '操作主体作用域ID:0为平台,正数为客户ID,用于平台端与客户端身份隔离', + default_membership_id bigint DEFAULT NULL COMMENT '默认客户成员关系ID;平台账号可为空', + default_app_code varchar(20) NOT NULL COMMENT '默认应用端:MERCHANT企业客户端、PLATFORM平台后台', + deleted tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + last_login_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_auth_account_username (username), + UNIQUE KEY uk_auth_account_phone (phone), + KEY idx_auth_account_status (account_status,locked_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态'; + +CREATE TABLE auth_refresh_token ( + id bigint NOT NULL COMMENT '刷新令牌记录ID(雪花ID)', + token_hash char(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文', + user_id bigint NOT NULL COMMENT '认证账号ID', + tenant_id bigint NOT NULL COMMENT '令牌当前业务作用域ID:0为平台,正数为客户ID', + operator_tenant_id bigint NOT NULL COMMENT '令牌操作主体作用域ID:0为平台,正数为客户ID', + app_code varchar(20) NOT NULL COMMENT '令牌所属应用端:MERCHANT企业客户端、PLATFORM平台后台', + expires_at datetime(3) NOT NULL COMMENT '到期时间', + revoked_at datetime(3) DEFAULT NULL COMMENT '吊销时间,非空表示不可继续使用', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '签发时间', + PRIMARY KEY (id), + UNIQUE KEY uk_auth_refresh_hash (token_hash), + KEY idx_auth_refresh_user (user_id,revoked_at,expires_at), + KEY idx_auth_refresh_expire (expires_at,revoked_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='刷新令牌表:保存令牌摘要、平台或客户作用域、有效期和轮换/吊销状态'; + +CREATE TABLE sys_audit_outbox ( + event_id varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '审计事件全局唯一ID', + event_type varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '审计事件类型', + resource_type varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '资源类型', + resource_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '资源ID', + service_name varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '来源服务', + payload_json json NOT NULL COMMENT '脱敏后的审计事件', + status varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING待发送、SENDING发送中、SENT已发送、DEAD进入死信', + attempt_count int NOT NULL DEFAULT '0' COMMENT '当前周期发送次数', + compensation_count int NOT NULL DEFAULT '0' COMMENT '自动补偿次数', + next_retry_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下一次重试时间', + lease_owner varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '当前租约持有者', + lease_until datetime(3) DEFAULT NULL COMMENT '发送租约截止时间', + last_error varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最近一次发送错误信息', + dead_at datetime(3) DEFAULT NULL COMMENT '进入死信时间', + sent_at datetime(3) DEFAULT NULL COMMENT '成功发送时间', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录更新时间', + PRIMARY KEY (event_id), + KEY idx_audit_outbox_publish (status,next_retry_at,lease_until,created_at), + KEY idx_audit_outbox_retention (status,sent_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='可靠审计事件本地发送箱'; + +-- 系统账号:仅平台管理员,密码从会话变量传入。 +INSERT INTO auth_account (id, username, phone, password_hash, display_name, account_status, failed_login_count, locked_until, token_version, password_change_required, default_tenant_id, operator_tenant_id, default_membership_id, default_app_code, deleted, last_login_at, created_at, updated_at) VALUES (1000001,'platform_admin',NULL,IF(CAST(@platform_admin_password_hash AS CHAR CHARACTER SET utf8mb4) REGEXP '^[$]2[aby][$](0[4-9]|[12][0-9]|3[01])[$][./A-Za-z0-9]{53}$', @platform_admin_password_hash, NULL),'平台系统管理员','ACTIVE',0,NULL,1,1,0,0,3000001,'PLATFORM',0,NULL,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); + +-- 服务 schema:baiyue_iam +CREATE DATABASE IF NOT EXISTS baiyue_iam DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_iam; + +CREATE TABLE 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) DEFAULT NULL COMMENT '当前任务租约持有者', + lease_until datetime(3) DEFAULT NULL COMMENT '当前任务租约截止时间', + last_error_code varchar(32) DEFAULT NULL COMMENT '最后下游错误码', + last_error_message varchar(1000) DEFAULT NULL COMMENT '最后诊断错误,不保存敏感凭证', + remote_config_id bigint DEFAULT 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) DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态'; + +CREATE TABLE iam_data_scope ( + id bigint NOT NULL COMMENT '数据范围规则ID', + role_id bigint NOT NULL COMMENT '角色ID', + tenant_id bigint NOT NULL COMMENT '规则作用域:0为平台,正数为客户ID和数据隔离键', + scope_type varchar(20) NOT NULL DEFAULT 'SELF' COMMENT '数据范围类型:SELF本人、DEPARTMENT部门、TENANT当前客户、CUSTOM自定义', + scope_expression json DEFAULT NULL COMMENT '自定义数据范围表达式', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_scope_role (role_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色数据范围表:在功能权限之外限制角色可查看和操作的平台或客户范围内数据集合'; + +CREATE TABLE iam_membership ( + id bigint NOT NULL COMMENT '客户成员关系ID', + tenant_id bigint NOT NULL COMMENT '客户ID和数据隔离键', + user_id bigint NOT NULL COMMENT 'IAM用户资料ID', + member_type varchar(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等', + member_no varchar(64) DEFAULT NULL COMMENT '客户企业内部成员编号', + overseas_warehouse_id bigint DEFAULT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID', + status varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开', + joined_at datetime(3) DEFAULT NULL COMMENT '加入客户企业时间', + left_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 '更新时间', + deleted tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + created_by bigint unsigned NOT NULL DEFAULT '0' COMMENT '客户成员关系创建人ID;系统创建时为0', + updated_by bigint unsigned NOT NULL DEFAULT '0' COMMENT '客户成员关系最后修改人ID;系统修改时为0', + PRIMARY KEY (id), + UNIQUE KEY uk_membership_tenant_user (tenant_id,user_id), + UNIQUE KEY uk_membership_tenant_no (tenant_id,member_no), + KEY idx_membership_user (user_id,status), + KEY idx_membership_tenant (tenant_id,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体'; + +CREATE TABLE iam_membership_role ( + membership_id bigint NOT NULL COMMENT '客户成员关系ID', + role_id bigint NOT NULL COMMENT '角色ID', + tenant_id bigint NOT NULL COMMENT '冗余客户ID和数据隔离键,用于授权归属校验和查询', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '授权时间', + PRIMARY KEY (membership_id,role_id), + KEY idx_mr_tenant (tenant_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='成员角色关联表:将客户成员分配到对应应用端的一个或多个角色'; + +CREATE TABLE iam_notification_event_inbox ( + id bigint NOT NULL, + source_event_id varchar(128) NOT NULL, + event_type varchar(128) NOT NULL, + payload_hash char(64) NOT NULL, + task_id bigint DEFAULT NULL, + status varchar(16) NOT NULL DEFAULT 'PROCESSING', + last_error varchar(1000) DEFAULT NULL, + processed_at datetime(3) DEFAULT NULL, + 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_notification_event (source_event_id,event_type), + KEY idx_notification_event_status (status,updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='业务事件消费幂等记录'; + +CREATE TABLE iam_notification_inbox ( + id bigint NOT NULL, + task_id bigint NOT NULL, + recipient_id bigint NOT NULL, + tenant_id bigint NOT NULL, + membership_id bigint NOT NULL, + app_code varchar(16) NOT NULL, + category varchar(64) NOT NULL, + title varchar(256) NOT NULL, + body varchar(10000) NOT NULL, + source_type varchar(128) DEFAULT NULL, + source_id varchar(128) DEFAULT NULL, + status varchar(16) NOT NULL DEFAULT 'UNREAD', + sent_at datetime(3) NOT NULL, + read_at datetime(3) DEFAULT NULL, + 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_notification_inbox_recipient (recipient_id), + KEY idx_notification_inbox_list (tenant_id,membership_id,app_code,status,sent_at), + KEY idx_notification_inbox_category (tenant_id,membership_id,app_code,category,sent_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='按客户成员隔离的站内信'; + +CREATE TABLE iam_notification_recipient ( + id bigint NOT NULL, + task_id bigint NOT NULL, + tenant_id bigint NOT NULL, + membership_id bigint NOT NULL, + user_id bigint NOT NULL, + display_name varchar(128) NOT NULL, + app_code varchar(16) NOT NULL, + channel varchar(16) NOT NULL DEFAULT 'IN_APP', + status varchar(24) NOT NULL DEFAULT 'RESOLVED', + failure_reason varchar(1000) DEFAULT NULL, + delivered_at datetime(3) DEFAULT NULL, + 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_notification_recipient (task_id,membership_id,channel), + KEY idx_notification_recipient_member (tenant_id,membership_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='消息接收人不可变快照'; + +CREATE TABLE iam_notification_task ( + id bigint NOT NULL, + task_no varchar(64) NOT NULL, + tenant_id bigint NOT NULL, + template_id bigint NOT NULL, + template_code varchar(64) NOT NULL, + category varchar(64) NOT NULL, + title varchar(256) NOT NULL, + body varchar(10000) NOT NULL, + recipient_type varchar(32) NOT NULL, + variables_json json NOT NULL, + idempotency_key varchar(128) NOT NULL, + source_event_id varchar(128) DEFAULT NULL, + source_event_type varchar(128) DEFAULT NULL, + status varchar(16) NOT NULL, + recipient_count int NOT NULL DEFAULT '0', + attempt_count int NOT NULL DEFAULT '0', + scheduled_at datetime(3) DEFAULT NULL, + sent_at datetime(3) DEFAULT NULL, + last_error varchar(1000) DEFAULT NULL, + created_by bigint NOT NULL, + 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_notification_task_no (task_no), + UNIQUE KEY uk_notification_task_idempotency (idempotency_key), + UNIQUE KEY uk_notification_source_event (source_event_id,source_event_type), + KEY idx_notification_task_dispatch (status,scheduled_at,created_at), + KEY idx_notification_task_tenant (tenant_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='消息任务与幂等状态机'; + +CREATE TABLE iam_notification_template ( + id bigint NOT NULL, + tenant_id bigint NOT NULL DEFAULT '0', + template_code varchar(64) NOT NULL, + template_name varchar(128) NOT NULL, + category varchar(64) NOT NULL, + channel varchar(16) NOT NULL DEFAULT 'IN_APP', + title_template varchar(256) NOT NULL, + body_template varchar(10000) NOT NULL, + variable_whitelist_json json NOT NULL, + status varchar(16) NOT NULL DEFAULT 'DRAFT', + version int NOT NULL DEFAULT '1', + created_by bigint NOT NULL, + updated_by bigint NOT NULL, + 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_notification_template_code (tenant_id,template_code), + KEY idx_notification_template_list (tenant_id,status,updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='消息模板;首期仅IN_APP'; + +CREATE TABLE iam_number_sequence ( + sequence_key varchar(64) NOT NULL COMMENT '业务编号类型', + next_value bigint unsigned NOT NULL COMMENT '下一待分配数字', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (sequence_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IAM业务编号序列表'; + +CREATE TABLE iam_permission ( + id bigint NOT NULL COMMENT '权限资源ID', + tenant_id bigint NOT NULL DEFAULT '0' COMMENT '权限作用域:0为平台预置权限,正数为客户ID和数据隔离键', + app_code varchar(20) NOT NULL COMMENT '所属应用端:MERCHANT企业客户端、PLATFORM平台后台', + permission_type varchar(20) NOT NULL COMMENT '权限类型:MENU、PAGE、BUTTON或API', + parent_id bigint NOT NULL DEFAULT '0' COMMENT '父权限ID,用于构造菜单和页面树', + permission_code varchar(128) NOT NULL COMMENT '鉴权使用的唯一权限码', + permission_name varchar(128) NOT NULL COMMENT '权限名称', + route_path varchar(255) DEFAULT NULL COMMENT '前端路由或API路径', + http_method varchar(16) DEFAULT NULL COMMENT 'API权限对应的HTTP方法', + sort_no int NOT NULL DEFAULT '0' COMMENT '同级显示顺序', + status varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '权限状态:ACTIVE启用、DISABLED停用', + built_in tinyint 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 '更新时间', + deleted tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + PRIMARY KEY (id), + UNIQUE KEY uk_permission_scope_code (tenant_id,app_code,permission_code), + KEY idx_permission_parent (parent_id), + KEY idx_permission_api (http_method,route_path) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='统一权限资源表:支持商户端和平台后台两套菜单、页面、按钮及API权限,权限最细可控制到按钮'; + +CREATE TABLE iam_role ( + id bigint NOT NULL COMMENT '角色ID', + tenant_id bigint NOT NULL DEFAULT '0' COMMENT '角色作用域:0为平台预置模板,正数为客户ID和数据隔离键', + app_code varchar(20) NOT NULL COMMENT '应用端:MERCHANT企业客户端、PLATFORM平台后台', + role_code varchar(64) NOT NULL COMMENT '作用域内唯一角色编码', + role_name varchar(128) NOT NULL COMMENT '角色名称', + role_type varchar(20) NOT NULL DEFAULT 'CUSTOM' COMMENT '角色类型:SYSTEM或CUSTOM', + status varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '角色状态:ACTIVE启用、DISABLED停用', + description varchar(255) DEFAULT NULL COMMENT '角色用途说明', + created_by bigint DEFAULT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint DEFAULT NULL COMMENT '更新人ID', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_role_scope_code (tenant_id,app_code,role_code), + KEY idx_role_tenant (tenant_id,app_code,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色表:按客户或平台作用域及应用端隔离企业客户端、平台后台两套角色体系'; + +CREATE TABLE iam_role_permission ( + role_id bigint NOT NULL COMMENT '角色ID', + permission_id bigint NOT NULL COMMENT '权限资源ID', + tenant_id bigint NOT NULL COMMENT '冗余客户ID或平台作用域ID,用于授权归属校验和查询', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '授权时间', + PRIMARY KEY (role_id,permission_id), + KEY idx_rp_tenant (tenant_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色权限关联表:定义角色可访问的菜单、页面、按钮和API资源'; + +CREATE TABLE iam_security_event_outbox ( + event_id varchar(64) NOT NULL COMMENT '全局事件ID,消费者幂等键', + event_type varchar(64) NOT NULL COMMENT '安全事件类型', + aggregate_type varchar(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION', + aggregate_id varchar(64) NOT NULL COMMENT '聚合标识', + payload_json json NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集', + status varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING、SENDING、SENT、DEAD', + attempt_count int NOT NULL DEFAULT '0' COMMENT '发送尝试次数', + next_retry_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次重试时间', + lease_owner varchar(128) DEFAULT NULL COMMENT '租约持有者', + lease_until datetime(3) DEFAULT NULL COMMENT '租约到期时间', + last_error varchar(1000) DEFAULT NULL COMMENT '最近一次错误', + sent_at datetime(3) DEFAULT NULL COMMENT '发送成功时间', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (event_id), + KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at), + KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IAM可靠安全事件本地发送箱'; + +CREATE TABLE iam_security_subject_version ( + auth_user_id bigint NOT NULL COMMENT '认证账号ID', + 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='安全主体权限版本'; + +CREATE TABLE 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户状态事件版本'; + +CREATE TABLE iam_tenant ( + id bigint NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域', + tenant_code varchar(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM', + tenant_name varchar(128) NOT NULL COMMENT '客户企业名称', + customer_nickname varchar(32) NOT NULL COMMENT '客户昵称:创建时默认取公司名称中文拼音大写首字母,可修改', + tenant_type varchar(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', + status varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用', + dimension_initialization_status varchar(16) NOT NULL DEFAULT 'READY' COMMENT '默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败', + legal_name varchar(128) DEFAULT NULL COMMENT '企业法定名称', + legal_representative varchar(64) DEFAULT NULL COMMENT '法定代表人', + unified_social_credit_code varchar(32) DEFAULT NULL COMMENT '统一社会信用代码', + contact_name varchar(64) DEFAULT NULL COMMENT '企业联系人', + contact_phone varchar(32) DEFAULT NULL COMMENT '联系电话', + contact_email varchar(128) DEFAULT NULL COMMENT '联系邮箱', + service_package varchar(64) NOT NULL DEFAULT '基础版' COMMENT '客户服务套餐', + contact_wechat varchar(64) DEFAULT NULL COMMENT '联系微信', + sales_owner_name varchar(64) DEFAULT NULL COMMENT '销售负责人姓名', + payment_term_days smallint unsigned DEFAULT NULL COMMENT '账期天数', + enterprise_type varchar(64) DEFAULT NULL COMMENT '企业类型', + established_date date DEFAULT NULL COMMENT '成立日期', + registered_capital decimal(18,2) DEFAULT NULL COMMENT '注册资本', + registered_capital_currency char(3) DEFAULT NULL COMMENT '注册资本币种 ISO 4217', + registered_address varchar(500) DEFAULT NULL COMMENT '注册地址文本', + internal_remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注', + approved_at datetime(3) DEFAULT NULL COMMENT '客户资料审核通过时间', + approved_by bigint DEFAULT NULL COMMENT '客户资料审核人ID', + reject_reason varchar(500) DEFAULT NULL COMMENT '客户资料审核驳回原因', + created_by bigint DEFAULT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint DEFAULT NULL COMMENT '最后修改人ID', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_iam_tenant_code (tenant_code), + UNIQUE KEY uk_iam_tenant_credit (unified_social_credit_code), + KEY idx_iam_tenant_status (status,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户企业主档表:保存企业资料、审核结果和启停状态;ID同时作为客户数据隔离键'; + +CREATE TABLE iam_tenant_address ( + id bigint NOT NULL COMMENT '客户地址ID', + tenant_id bigint NOT NULL COMMENT '客户ID和数据隔离键', + address_type varchar(20) NOT NULL COMMENT '地址类型:注册地址、办公地址等', + country_code char(2) NOT NULL DEFAULT 'CN' COMMENT '国家二字码', + province varchar(64) DEFAULT NULL COMMENT '省/州', + city varchar(64) DEFAULT NULL COMMENT '城市', + district varchar(64) DEFAULT NULL COMMENT '区县', + detail varchar(255) NOT NULL COMMENT '详细地址', + postal_code varchar(16) DEFAULT NULL COMMENT '邮政编码', + is_default tinyint NOT NULL DEFAULT '0' COMMENT '是否默认地址:0否、1是', + 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 '更新时间', + deleted tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + PRIMARY KEY (id), + KEY idx_address_tenant (tenant_id,address_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户地址表:保存企业注册地址、办公地址等客户资料'; + +CREATE TABLE iam_tenant_qualification ( + id bigint NOT NULL COMMENT '资质记录ID', + tenant_id bigint NOT NULL COMMENT '客户ID和数据隔离键', + qualification_type varchar(32) NOT NULL COMMENT '客户资质类型', + certificate_no varchar(128) DEFAULT NULL COMMENT '证书编号', + document_url varchar(500) DEFAULT NULL COMMENT '资质文件存储地址', + original_file_name varchar(255) NOT NULL COMMENT '上传时原始文件名', + valid_from date DEFAULT NULL COMMENT '有效期开始日期', + valid_to date DEFAULT NULL COMMENT '有效期结束日期', + status varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回', + review_comment varchar(500) 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 '更新时间', + deleted tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + PRIMARY KEY (id), + KEY idx_qualification_tenant (tenant_id,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户资质表:保存企业证照、有效期、文件地址和审核状态'; + +CREATE TABLE iam_tenant_review ( + id bigint NOT NULL COMMENT '审核记录ID', + tenant_id bigint NOT NULL COMMENT '被审核客户ID和数据隔离键', + review_type varchar(20) NOT NULL DEFAULT 'REGISTER' COMMENT '审核类型:REGISTER注册审核等', + status varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回', + reviewer_id bigint DEFAULT NULL COMMENT '审核人ID', + review_comment varchar(500) DEFAULT NULL COMMENT '审核意见', + reviewed_at datetime(3) DEFAULT NULL COMMENT '审核完成时间', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '进入审核队列时间', + PRIMARY KEY (id), + KEY idx_review_queue (status,created_at), + KEY idx_review_tenant (tenant_id), + KEY idx_tenant_review_status (tenant_id,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户审核记录表:保存企业注册、资料变更等审核过程和审核意见'; + +CREATE TABLE iam_user ( + id bigint NOT NULL COMMENT 'IAM用户资料ID', + auth_user_id bigint NOT NULL COMMENT '认证服务账号逻辑ID', + user_type varchar(20) NOT NULL DEFAULT 'ENTERPRISE' COMMENT '用户类型:ENTERPRISE企业用户、PLATFORM平台用户', + display_name varchar(64) NOT NULL COMMENT '用户展示名称', + phone varchar(32) DEFAULT NULL COMMENT '手机号', + email varchar(128) DEFAULT NULL COMMENT '邮箱', + status varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '用户资料状态:ACTIVE正常、DISABLED停用', + 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 '更新时间', + deleted tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_iam_user_auth (auth_user_id), + KEY idx_iam_user_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IAM用户资料表:连接统一认证账号与业务身份资料,不保存密码'; + +CREATE TABLE sys_audit_outbox ( + event_id varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '审计事件全局唯一ID', + event_type varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '审计事件类型', + resource_type varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '被审计资源类型', + resource_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '被审计资源ID', + service_name varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '来源服务名称', + payload_json json NOT NULL COMMENT '脱敏后的审计事件内容', + status varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING待发送、SENDING发送中、SENT已发送、DEAD进入死信', + attempt_count int NOT NULL DEFAULT '0' COMMENT '当前重试周期发送次数', + compensation_count int NOT NULL DEFAULT '0' COMMENT '自动补偿发送次数', + next_retry_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下一次重试时间', + lease_owner varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '当前租约持有者', + lease_until datetime(3) DEFAULT NULL COMMENT '发送租约截止时间', + last_error varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最近一次发送错误信息', + dead_at datetime(3) DEFAULT NULL COMMENT '进入死信时间', + sent_at datetime(3) DEFAULT NULL COMMENT '成功发送时间', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录更新时间', + PRIMARY KEY (event_id), + KEY idx_audit_outbox_publish (status,next_retry_at,lease_until,created_at), + KEY idx_audit_outbox_retention (status,sent_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='可靠审计事件本地发送箱'; + +-- 服务 schema:baiyue_order +CREATE DATABASE IF NOT EXISTS baiyue_order DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_order; + +CREATE TABLE ord_customer_address ( + id bigint unsigned NOT NULL COMMENT '客户地址ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + address_type varchar(32) NOT NULL COMMENT 'PICKUP取货地址或PRIVATE_DESTINATION私人目的仓', + address_name varchar(64) NOT NULL COMMENT '客户自定义地址名称', + company_name varchar(128) NOT NULL COMMENT '公司名称', + contact_name varchar(64) NOT NULL COMMENT '联系人', + phone varchar(32) NOT NULL COMMENT '联系电话', + email varchar(128) DEFAULT NULL COMMENT '联系邮箱', + country_code char(2) NOT NULL COMMENT '国家二字码', + province varchar(64) DEFAULT NULL COMMENT '省州', + city varchar(64) NOT NULL COMMENT '城市', + district varchar(64) DEFAULT NULL COMMENT '区县', + address_line varchar(255) NOT NULL COMMENT '详细地址', + postal_code varchar(20) DEFAULT NULL COMMENT '邮政编码', + default_address tinyint(1) NOT NULL DEFAULT '0' COMMENT '同类型默认地址', + active tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否可选', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT 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 '逻辑删除标记:0未删除、1已删除', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + KEY idx_customer_address_scope (tenant_id,address_type,active,updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户自有下单地址簿;不承载平台仓库主档'; + +CREATE TABLE ord_idempotency_record ( + id bigint unsigned NOT NULL COMMENT '幂等记录 ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + operator_id bigint unsigned NOT NULL COMMENT '发起写操作的用户ID', + operation varchar(32) NOT NULL COMMENT '幂等操作类型', + resource_id bigint unsigned NOT NULL DEFAULT '0' COMMENT '操作目标资源ID;创建前未知时为0', + idempotency_key varchar(128) NOT NULL COMMENT '客户端幂等键', + request_hash char(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要,用于识别同键不同请求', + status varchar(16) NOT NULL COMMENT '处理状态:PROCESSING处理中、COMPLETED已完成', + result_order_id bigint unsigned DEFAULT NULL COMMENT '成功操作生成或更新的订单ID,用于幂等重放', + completed_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_order_idempotency_scope (tenant_id,operator_id,operation,idempotency_key), + KEY idx_order_idempotency_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单写接口持久化幂等记录'; + +CREATE TABLE ord_inbound_code_daily_sequence ( + business_date date NOT NULL COMMENT '上海时区业务日期', + current_value bigint unsigned NOT NULL COMMENT '当天全平台已分配票数', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '当天首次分配时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最近分配时间', + PRIMARY KEY (business_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入仓码每日票序;不同客户和物流渠道共用,按上海日期重新从1开始'; + +CREATE TABLE ord_order ( + id bigint unsigned NOT NULL COMMENT '订单ID(雪花ID)', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + order_no varchar(32) NOT NULL COMMENT '客户范围内唯一订单号', + inbound_code varchar(40) DEFAULT NULL COMMENT '全平台唯一入仓码;来源由全局模式决定,提交后固定', + customer_id bigint unsigned DEFAULT NULL COMMENT '客户逻辑ID', + customer_name varchar(128) NOT NULL COMMENT '下单时客户名称快照', + status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消', + transport_mode varchar(16) NOT NULL COMMENT '运输方式', + trade_term varchar(16) DEFAULT NULL COMMENT '贸易条款', + destination_country_code char(2) NOT NULL COMMENT '目的国家二字码', + destination_warehouse_id bigint unsigned DEFAULT NULL COMMENT '目的仓逻辑ID', + destination_warehouse_name varchar(128) NOT NULL COMMENT '目的仓名称快照', + expected_ship_date date DEFAULT NULL COMMENT '预计发运日期', + expected_arrival_at datetime(3) DEFAULT NULL COMMENT '客户申报预计到货时间', + estimated_volume_cbm decimal(18,4) DEFAULT NULL COMMENT '平台运营预估体积(立方米),独立于客户下单及实测体积', + customer_remark varchar(1000) DEFAULT NULL COMMENT '客户可见备注', + internal_remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注', + warehouse_remark varchar(1000) DEFAULT NULL COMMENT '仓库备注', + customer_service_remark varchar(1000) DEFAULT NULL COMMENT '客服备注', + pickup_method varchar(24) DEFAULT NULL COMMENT '取货方式', + requested_pickup_at datetime(3) DEFAULT NULL COMMENT '客户要求平台上门提货的预约时间', + receiving_warehouse_id bigint unsigned DEFAULT NULL COMMENT '国内收货仓逻辑ID', + receiving_warehouse_name varchar(128) DEFAULT NULL COMMENT '下单时国内收货仓名称快照;主数据变化不回写', + receiving_warehouse_address varchar(600) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '国内收货仓完整地址快照', + receiving_time varchar(128) DEFAULT NULL COMMENT '国内收货仓收货时间快照', + channel_code varchar(64) DEFAULT NULL COMMENT '运输渠道编码', + channel_name varchar(128) DEFAULT NULL COMMENT '下单时运输渠道名称快照;渠道主数据变化不回写', + channel_changed tinyint(1) NOT NULL DEFAULT '0' COMMENT '订单历史上是否由平台变更过渠道', + carrier_id bigint unsigned DEFAULT NULL COMMENT '船司逻辑ID快照', + carrier_name varchar(128) DEFAULT NULL COMMENT '船司名称快照', + tax_mode varchar(24) DEFAULT NULL COMMENT '报关税务方式:BUY_DECLARATION买单、TAX_REFUND退税', + pricing_tax_mode varchar(16) DEFAULT NULL COMMENT '报价包税维度:TAX_INCLUDED或TAX_EXCLUDED;独立于tax_mode', + container_mode varchar(24) DEFAULT NULL COMMENT '装运方式:FULL_CONTAINER整柜、LCL散货拼箱', + customs_inspection tinyint(1) DEFAULT NULL COMMENT '是否需要商检:0否、1是', + destination_type varchar(24) DEFAULT NULL COMMENT '目的仓类型:FBA亚马逊仓、OVERSEAS海外仓、PRIVATE私人地址', + fulfillment_type varchar(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', + lx_code varchar(128) DEFAULT NULL COMMENT '一件代发领星入库单代码', + amazon_warehouse_code varchar(32) DEFAULT NULL COMMENT 'Amazon目的仓编码', + shipment_id varchar(64) DEFAULT NULL COMMENT '订单级Amazon Shipment ID', + reference_id varchar(64) DEFAULT NULL COMMENT '订单级Amazon Reference ID', + express_company varchar(64) DEFAULT NULL COMMENT '履约快递公司名称快照', + express_tracking_no varchar(128) DEFAULT NULL COMMENT '履约快递单号', + submitted_at datetime(3) DEFAULT NULL COMMENT '提交时间', + completed_at datetime(3) DEFAULT NULL COMMENT '完成时间', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT 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 '乐观锁版本号', + order_role varchar(12) NOT NULL DEFAULT 'MASTER' COMMENT '订单层级:MASTER主订单、CHILD子订单', + parent_order_id bigint unsigned DEFAULT NULL COMMENT '直属父订单ID;主订单为空;禁止子订单嵌套', + root_order_id bigint unsigned DEFAULT NULL COMMENT '根主订单ID', + split_sequence int unsigned DEFAULT NULL COMMENT '同一父订单下的同级子订单顺序', + PRIMARY KEY (id), + UNIQUE KEY uk_order_tenant_no (tenant_id,order_no), + UNIQUE KEY uk_order_inbound_code (inbound_code), + UNIQUE KEY uk_order_parent_sequence (tenant_id,parent_order_id,split_sequence), + KEY idx_order_tenant_status_time (tenant_id,status,created_at), + KEY idx_order_tenant_customer (tenant_id,customer_id), + KEY idx_order_tenant_warehouse (tenant_id,destination_warehouse_id), + KEY idx_order_root_role (tenant_id,root_order_id,order_role,created_at,id), + KEY fk_order_parent (parent_order_id), + CONSTRAINT fk_order_parent FOREIGN KEY (parent_order_id) REFERENCES ord_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; + +CREATE TABLE ord_order_cargo ( + id bigint unsigned NOT NULL COMMENT '货物明细ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + order_id bigint unsigned NOT NULL COMMENT '所属订单ID', + item_no int NOT NULL COMMENT '订单内明细序号', + cargo_name varchar(128) DEFAULT NULL COMMENT '货物名称;草稿可空,提交前必填', + hs_code varchar(20) DEFAULT NULL COMMENT '海关商品编码', + package_type varchar(32) DEFAULT NULL COMMENT '包装类型;草稿可空,提交前必填', + package_count int unsigned NOT NULL COMMENT '包装件数', + gross_weight_kg decimal(18,3) DEFAULT NULL COMMENT '毛重(千克);草稿可空,提交前必须大于0', + volume_cbm decimal(18,4) DEFAULT NULL COMMENT '体积(立方米);草稿可空,提交前必须大于0', + length_cm decimal(12,2) DEFAULT NULL COMMENT '单件长度(厘米)', + width_cm decimal(12,2) DEFAULT NULL COMMENT '单件宽度(厘米)', + height_cm decimal(12,2) DEFAULT NULL COMMENT '单件高度(厘米)', + declared_value decimal(18,2) DEFAULT NULL COMMENT '申报价值', + currency char(3) DEFAULT NULL COMMENT '币种三字码', + dangerous_goods tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否危险品', + cargo_remark varchar(500) DEFAULT NULL COMMENT '货物备注', + sku_no varchar(64) DEFAULT NULL COMMENT '货物SKU编码', + upc_no varchar(64) DEFAULT NULL COMMENT '货物UPC条码', + cargo_name_en varchar(128) DEFAULT NULL COMMENT '货物英文品名', + customs_hs_code varchar(20) DEFAULT NULL COMMENT '目的国清关HS编码', + units_per_package int unsigned DEFAULT NULL COMMENT '每箱产品数量', + total_quantity int unsigned DEFAULT NULL COMMENT '产品总数量', + net_weight_kg decimal(18,3) DEFAULT NULL COMMENT '货物净重,单位千克', + unit_declared_value decimal(18,2) DEFAULT NULL COMMENT '单件申报价值;币种取订单申报币种', + material varchar(128) DEFAULT NULL COMMENT '产品材质', + product_usage varchar(128) DEFAULT NULL COMMENT '产品用途', + model varchar(64) DEFAULT NULL COMMENT '产品型号', + brand varchar(64) DEFAULT NULL COMMENT '产品品牌', + product_length_cm decimal(12,2) DEFAULT NULL COMMENT '单件产品长度,单位厘米', + product_width_cm decimal(12,2) DEFAULT NULL COMMENT '单件产品宽度,单位厘米', + product_height_cm decimal(12,2) DEFAULT NULL COMMENT '单件产品高度,单位厘米', + shipment_id varchar(64) DEFAULT NULL COMMENT '本货物明细对应的Amazon Shipment ID', + reference_id varchar(64) DEFAULT NULL COMMENT '本货物明细对应的Amazon Reference ID', + product_image_file_id bigint unsigned DEFAULT NULL COMMENT 'File服务中的产品图片逻辑文件ID', + wooden_product tinyint(1) NOT NULL DEFAULT '0' COMMENT '木制品权威判定快照;1时订单必须商检', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT 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 '乐观锁版本号', + packing_group_no varchar(32) DEFAULT NULL COMMENT '混箱组合号;同组货物行共享同一批物理箱', + PRIMARY KEY (id), + UNIQUE KEY uk_cargo_item (tenant_id,order_id,item_no), + KEY idx_cargo_order (tenant_id,order_id), + KEY fk_cargo_order (order_id), + CONSTRAINT fk_cargo_order FOREIGN KEY (order_id) REFERENCES ord_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单货物明细表:记录件数、重量、体积、申报价值和危险品属性'; + +CREATE TABLE ord_order_fulfillment_split ( + id bigint unsigned NOT NULL COMMENT '订单履约分片台账ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + parent_order_id bigint unsigned NOT NULL COMMENT '直属父订单ID;主订单自身作为拆分根', + source_order_id bigint unsigned NOT NULL COMMENT '实际被本次分片消费的来源订单ID', + child_order_id bigint unsigned NOT NULL COMMENT '本次分片生成或承接的子订单ID', + split_role varchar(12) NOT NULL COMMENT '分片角色:LOADED已装柜部分、REMAINDER剩余部分', + loading_plan_id bigint unsigned DEFAULT NULL COMMENT 'Firstleg装柜计划逻辑ID', + container_booking_id bigint unsigned DEFAULT NULL COMMENT 'Firstleg柜次逻辑ID', + source_package_count int unsigned NOT NULL COMMENT '来源订单本次分片前的总包装件数', + loaded_package_count int unsigned NOT NULL COMMENT '本次已装柜分片的包装件数', + remaining_package_count int unsigned NOT NULL COMMENT '本次分片后剩余包装件数', + source_detail_json json DEFAULT NULL COMMENT '来源货物明细及数量快照;来源订单变化不回写', + operation_id varchar(64) NOT NULL COMMENT '履约分片业务操作ID', + idempotency_key varchar(128) NOT NULL COMMENT '客户端幂等键', + request_hash char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '请求内容SHA-256;存量记录允许为空', + expected_parent_version int NOT NULL COMMENT '执行前预期父订单乐观锁版本号', + created_by bigint unsigned NOT NULL COMMENT '创建该分片台账的用户ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '台账创建时间', + version int NOT NULL DEFAULT '0' COMMENT '台账乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_order_split_child_role (tenant_id,child_order_id,split_role), + UNIQUE KEY uk_order_split_operation (tenant_id,operation_id,split_role), + UNIQUE KEY uk_order_split_idempotency (tenant_id,parent_order_id,idempotency_key,split_role), + KEY idx_order_split_parent (tenant_id,parent_order_id,created_at,id), + KEY idx_order_split_source (tenant_id,source_order_id,split_role), + KEY idx_order_split_container (tenant_id,container_booking_id,created_at,id), + KEY fk_order_split_parent (parent_order_id), + KEY fk_order_split_child (child_order_id), + CONSTRAINT fk_order_split_child FOREIGN KEY (child_order_id) REFERENCES ord_order (id), + CONSTRAINT fk_order_split_parent FOREIGN KEY (parent_order_id) REFERENCES ord_order (id), + CONSTRAINT chk_order_split_quantity CHECK (((source_package_count > 0) and (loaded_package_count > 0) and ((loaded_package_count + remaining_package_count) = source_package_count))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='父订单→子订单→柜次数量台账'; + +CREATE TABLE ord_order_internal_note ( + id bigint unsigned NOT NULL COMMENT '内部备注ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + order_id bigint unsigned NOT NULL COMMENT '所属订单ID', + content varchar(2000) NOT NULL COMMENT '仅内部人员可见的备注内容', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT 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), + KEY idx_note_order_time (tenant_id,order_id,created_at), + KEY fk_note_order (order_id), + CONSTRAINT fk_note_order FOREIGN KEY (order_id) REFERENCES ord_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单内部备注表:保存平台运营或履约人员的内部协作记录,不向商户客户展示'; + +CREATE TABLE ord_order_party ( + id bigint unsigned NOT NULL COMMENT '参与方快照ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + order_id bigint unsigned NOT NULL COMMENT '所属订单ID', + party_type varchar(16) NOT NULL COMMENT '参与方类型:SHIPPER或CONSIGNEE', + company_name varchar(128) NOT NULL COMMENT '公司名称快照', + contact_name varchar(64) NOT NULL COMMENT '联系人快照', + phone varchar(32) NOT NULL COMMENT '联系电话快照', + email varchar(128) DEFAULT NULL COMMENT '联系邮箱快照', + country_code char(2) NOT NULL COMMENT '国家二字码', + province varchar(64) DEFAULT NULL COMMENT '省/州', + city varchar(64) NOT NULL COMMENT '城市', + district varchar(64) DEFAULT NULL COMMENT '区县', + address_line varchar(255) NOT NULL COMMENT '详细地址', + postal_code varchar(20) DEFAULT NULL COMMENT '邮政编码', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT 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_party_order_type (tenant_id,order_id,party_type), + KEY fk_party_order (order_id), + CONSTRAINT fk_party_order FOREIGN KEY (order_id) REFERENCES ord_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单收发货方快照表:固化下单时发货人和收货人信息,避免主数据变化影响历史订单'; + +CREATE TABLE ord_order_status_history ( + id bigint unsigned NOT NULL COMMENT '状态历史ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + order_id bigint unsigned NOT NULL COMMENT '所属订单ID', + from_status varchar(24) DEFAULT NULL COMMENT '变更前状态,首次创建可为空', + to_status varchar(24) NOT NULL COMMENT '变更后状态', + action varchar(24) NOT NULL COMMENT '触发状态变化的业务动作', + reason varchar(500) DEFAULT NULL COMMENT '变更原因', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + occurred_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态变化时间', + PRIMARY KEY (id), + KEY idx_history_order_time (tenant_id,order_id,occurred_at), + KEY fk_history_order (order_id), + CONSTRAINT fk_history_order FOREIGN KEY (order_id) REFERENCES ord_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单状态不可变历史表:按时间记录每次状态迁移、动作、原因和操作人'; + +CREATE TABLE ord_outbox_event ( + id bigint unsigned NOT NULL COMMENT '领域事件全局ID,同时作为消费幂等键', + tenant_id bigint unsigned NOT NULL COMMENT '事件所属客户ID和数据隔离键', + aggregate_type varchar(32) NOT NULL COMMENT '聚合类型,例如 ORDER', + aggregate_id bigint unsigned NOT NULL COMMENT '发生变更的聚合根ID,例如订单ID', + event_type varchar(64) NOT NULL COMMENT '领域事件类型,例如 ORDER_SUBMITTED', + payload_json json 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) DEFAULT NULL COMMENT '允许再次投递的最早时间', + published_at datetime(3) DEFAULT NULL COMMENT '成功发布到消息中间件的时间', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件在本地事务中的创建时间', + PRIMARY KEY (id), + KEY idx_outbox_publish (publish_status,next_retry_at,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单事务Outbox表:与订单业务变更在同一本地事务写入,供后台发布器可靠投递RocketMQ,保证数据库状态与领域事件最终一致;不是普通操作日志'; + +CREATE TABLE sys_audit_outbox ( + event_id varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '审计事件全局唯一ID', + event_type varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '审计事件类型', + resource_type varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '被审计资源类型', + resource_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '被审计资源ID', + service_name varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '来源服务名称', + payload_json json NOT NULL COMMENT '脱敏后的审计事件内容', + status varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING待发送、SENDING发送中、SENT已发送、DEAD进入死信', + attempt_count int NOT NULL DEFAULT '0' COMMENT '当前重试周期发送次数', + compensation_count int NOT NULL DEFAULT '0' COMMENT '自动补偿发送次数', + next_retry_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下一次重试时间', + lease_owner varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '当前租约持有者', + lease_until datetime(3) DEFAULT NULL COMMENT '发送租约截止时间', + last_error varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最近一次发送错误信息', + dead_at datetime(3) DEFAULT NULL COMMENT '进入死信时间', + sent_at datetime(3) DEFAULT NULL COMMENT '成功发送时间', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录更新时间', + PRIMARY KEY (event_id), + KEY idx_audit_outbox_publish (status,next_retry_at,lease_until,created_at), + KEY idx_audit_outbox_retention (status,sent_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='可靠审计事件本地发送箱'; + +-- 服务 schema:baiyue_warehouse +CREATE DATABASE IF NOT EXISTS baiyue_warehouse DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_warehouse; + +CREATE TABLE sys_audit_outbox ( + event_id varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '审计事件全局唯一ID', + event_type varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '审计事件类型', + resource_type varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '被审计资源类型', + resource_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '被审计资源ID', + service_name varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '产生事件的服务名', + payload_json json NOT NULL COMMENT '待投递审计事件载荷', + status varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待发送、PROCESSING处理中、SENT已发送、FAILED失败', + attempt_count int NOT NULL DEFAULT '0' COMMENT '发送尝试次数', + compensation_count int NOT NULL DEFAULT '0' COMMENT '补偿处理次数', + next_retry_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许重试时间', + lease_owner varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '当前租约持有实例', + lease_until datetime(3) DEFAULT NULL COMMENT '当前租约截止时间', + last_error varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最近一次发送错误', + dead_at datetime(3) DEFAULT NULL COMMENT '进入死信时间', + sent_at datetime(3) DEFAULT NULL COMMENT '发送成功时间', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (event_id), + KEY idx_audit_outbox_publish (status,next_retry_at,lease_until,created_at), + KEY idx_audit_outbox_retention (status,sent_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='可靠审计事件本地发送箱'; + +CREATE TABLE wh_domestic_container_idempotency ( + id bigint unsigned NOT NULL COMMENT '幂等记录ID', + tenant_id bigint unsigned NOT NULL DEFAULT '0' COMMENT '平台公共作用域,固定为0', + operation_type varchar(16) NOT NULL COMMENT '操作类型:INBOUND入库、OUTBOUND出库', + idempotency_key varchar(128) NOT NULL COMMENT '幂等键', + request_hash char(64) NOT NULL COMMENT '请求哈希', + resource_id bigint unsigned DEFAULT NULL COMMENT '业务资源ID', + resource_no varchar(40) DEFAULT NULL COMMENT '业务资源编号', + operation_status varchar(16) NOT NULL COMMENT '状态:PROCESSING处理中、COMPLETED已完成', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + completed_at datetime(3) DEFAULT NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dc_idempotency (tenant_id,operation_type,idempotency_key), + CONSTRAINT chk_dc_idempotency_scope CHECK ((tenant_id = 0)), + CONSTRAINT chk_dc_idempotency_status CHECK ((operation_status in (_utf8mb4'PROCESSING',_utf8mb4'COMPLETED'))), + CONSTRAINT chk_dc_idempotency_type CHECK ((operation_type in (_utf8mb4'INBOUND',_utf8mb4'OUTBOUND'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内仓整柜写操作幂等记录'; + +CREATE TABLE wh_domestic_container_inventory ( + id bigint unsigned NOT NULL COMMENT '库存ID', + tenant_id bigint unsigned NOT NULL DEFAULT '0' COMMENT '平台公共作用域,固定为0', + warehouse_id bigint unsigned NOT NULL COMMENT '平台国内仓ID', + warehouse_name varchar(120) NOT NULL COMMENT '仓库名称', + container_no varchar(32) NOT NULL COMMENT '柜号', + cargo_name varchar(128) NOT NULL COMMENT '货物名称', + inbound_boxes int unsigned NOT NULL COMMENT '入库箱数', + outbound_boxes int unsigned NOT NULL DEFAULT '0' COMMENT '出库箱数', + available_boxes int unsigned NOT NULL COMMENT '可用箱数', + inbound_volume_cbm decimal(16,4) NOT NULL COMMENT '入库体积(立方米)', + inventory_status varchar(24) NOT NULL COMMENT '库存状态:IN_STOCK在库、PARTIALLY_OUTBOUND部分出库、OUTBOUND已出库', + 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_dc_inventory_container (tenant_id,warehouse_id,container_no,deleted), + KEY idx_dc_inventory_list (tenant_id,warehouse_id,inventory_status,updated_at), + CONSTRAINT chk_dc_inventory_balance CHECK ((inbound_boxes = (outbound_boxes + available_boxes))), + CONSTRAINT chk_dc_inventory_scope CHECK ((tenant_id = 0)), + CONSTRAINT chk_dc_inventory_status CHECK ((inventory_status in (_utf8mb4'IN_STOCK',_utf8mb4'PARTIALLY_OUTBOUND',_utf8mb4'OUTBOUND'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台国内仓整柜库存台账'; + +CREATE TABLE wh_domestic_container_order ( + id bigint unsigned NOT NULL COMMENT '整柜订单ID', + tenant_id bigint unsigned NOT NULL DEFAULT '0' COMMENT '平台公共作用域,固定为0', + business_type varchar(16) NOT NULL COMMENT '业务类型:INBOUND入库、OUTBOUND出库', + order_no varchar(40) NOT NULL COMMENT '业务单号', + warehouse_id bigint unsigned NOT NULL COMMENT '平台国内仓ID', + warehouse_name varchar(120) NOT NULL COMMENT '仓库名称', + container_no varchar(32) NOT NULL COMMENT '柜号', + cargo_name varchar(128) NOT NULL COMMENT '货物名称', + box_count int unsigned NOT NULL COMMENT '箱数', + volume_cbm decimal(16,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', + operation_time datetime(3) NOT NULL COMMENT '操作时间', + status varchar(16) NOT NULL COMMENT '状态:PENDING待处理、COMPLETED已完成', + 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_dc_order_no (tenant_id,order_no,deleted), + KEY idx_dc_order_list (tenant_id,business_type,warehouse_id,status,operation_time), + KEY idx_dc_order_container (tenant_id,warehouse_id,container_no,business_type), + CONSTRAINT chk_dc_order_quantity CHECK (((box_count > 0) and (volume_cbm >= 0))), + CONSTRAINT chk_dc_order_scope CHECK ((tenant_id = 0)), + CONSTRAINT chk_dc_order_status CHECK ((status in (_utf8mb4'PENDING',_utf8mb4'COMPLETED'))), + CONSTRAINT chk_dc_order_type CHECK ((business_type in (_utf8mb4'INBOUND',_utf8mb4'OUTBOUND'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内仓独立整柜入出库单'; + +CREATE TABLE wh_domestic_container_transaction ( + id bigint unsigned NOT NULL COMMENT '流水ID', + tenant_id bigint unsigned NOT NULL DEFAULT '0' COMMENT '平台公共作用域,固定为0', + inventory_id bigint unsigned NOT NULL COMMENT '库存台账ID', + warehouse_id bigint unsigned NOT NULL COMMENT '平台国内仓ID', + container_no varchar(32) NOT NULL COMMENT '柜号', + business_type varchar(16) NOT NULL COMMENT '业务类型:INBOUND入库、OUTBOUND出库', + order_id bigint unsigned NOT NULL COMMENT '业务单ID', + order_no varchar(40) NOT NULL COMMENT '业务单号', + quantity int unsigned NOT NULL COMMENT '变动箱数', + operation_time datetime(3) NOT NULL COMMENT '操作时间', + 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_dc_transaction_order (tenant_id,business_type,order_id), + KEY idx_dc_transaction_detail (tenant_id,inventory_id,operation_time), + CONSTRAINT chk_dc_transaction_quantity CHECK ((quantity > 0)), + CONSTRAINT chk_dc_transaction_scope CHECK ((tenant_id = 0)), + CONSTRAINT chk_dc_transaction_type CHECK ((business_type in (_utf8mb4'INBOUND',_utf8mb4'OUTBOUND'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内仓整柜不可变进销存交易流水'; + +CREATE TABLE wh_express_company ( + id bigint unsigned NOT NULL COMMENT '快递公司记录ID', + tenant_id bigint unsigned NOT NULL COMMENT '平台公共快递公司固定使用 tenant_id=0', + company_code varchar(50) NOT NULL COMMENT '平台内唯一快递公司编码', + company_name varchar(128) NOT NULL COMMENT '快递公司名称', + service_phone varchar(32) DEFAULT NULL COMMENT '客服电话', + website varchar(255) DEFAULT NULL COMMENT '官方网站', + tracking_url_template varchar(500) DEFAULT NULL COMMENT '轨迹查询地址模板,可包含{trackingNo}', + status varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '快递公司状态', + 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_wh_express_tenant_code (tenant_id,company_code,deleted), + UNIQUE KEY uk_wh_express_tenant_name (tenant_id,company_name,deleted), + KEY idx_wh_express_tenant_status (tenant_id,status,deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共快递公司主数据,固定归属 tenant_id=0'; + +CREATE TABLE wh_overseas_storage_fee_rule ( + id bigint unsigned NOT NULL COMMENT '海外仓费用规则ID(雪花ID)', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键;客户费用规则不得使用0', + customer_name varchar(128) NOT NULL COMMENT '客户名称快照', + currency char(3) NOT NULL COMMENT '费用币种三字码;首版为USD', + initialization_key varchar(191) 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 '逻辑删除标记:0未删除、1已删除', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + active_customer_id bigint unsigned GENERATED ALWAYS AS ((case when (deleted = 0) then tenant_id else NULL end)) STORED COMMENT '未删除规则的客户唯一索引值', + PRIMARY KEY (id), + UNIQUE KEY uk_overseas_fee_active_customer (active_customer_id), + KEY idx_overseas_fee_page (deleted,updated_at,id), + KEY idx_overseas_fee_customer_name (customer_name,deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户海外仓费用规则'; + +CREATE TABLE wh_overseas_storage_fee_tier ( + id bigint unsigned NOT NULL COMMENT '海外仓费用阶梯ID(雪花ID)', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键;与规则归属一致', + rule_id bigint unsigned NOT NULL COMMENT '所属海外仓费用规则ID', + start_day smallint unsigned NOT NULL COMMENT '计费阶梯起始存储天数,含边界', + end_day smallint unsigned DEFAULT NULL COMMENT '计费阶梯结束存储天数,含边界;NULL表示以上', + fee_type varchar(32) NOT NULL DEFAULT 'STORAGE' COMMENT '费用类型;首版为STORAGE仓储费', + billing_unit varchar(16) NOT NULL DEFAULT 'CBM' COMMENT '计费单位;首版为CBM立方米', + unit_price decimal(18,6) NOT NULL COMMENT '每计费单位单价,非负', + sort_no smallint unsigned NOT 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 '逻辑删除标记:0未删除、1已删除', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + active_start_day smallint unsigned GENERATED ALWAYS AS ((case when (deleted = 0) then start_day else NULL end)) STORED COMMENT '未删除阶梯的起始天唯一索引值', + PRIMARY KEY (id), + UNIQUE KEY uk_overseas_fee_tier_start (rule_id,fee_type,active_start_day), + KEY idx_overseas_fee_tier_rule (tenant_id,rule_id,deleted,sort_no,id), + CONSTRAINT chk_overseas_fee_tier_days CHECK (((start_day >= 1) and ((end_day is null) or (end_day >= start_day)))), + CONSTRAINT chk_overseas_fee_tier_price CHECK ((unit_price >= 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户海外仓费用阶梯'; + +CREATE TABLE wh_pallet_location_release_operation ( + id bigint unsigned NOT NULL COMMENT '精确释放幂等操作ID', + tenant_id bigint unsigned NOT NULL COMMENT '仓储公共资源作用域,固定为0', + source_service varchar(40) NOT NULL COMMENT '来源服务', + business_id bigint unsigned NOT NULL COMMENT '入库单ID', + idempotency_key varchar(128) NOT NULL COMMENT '调用方幂等键', + request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要', + operation_status varchar(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中,COMPLETED已完成', + response_snapshot json DEFAULT NULL COMMENT '完成后的精确释放结果快照,用于重复请求重放', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + completed_at datetime(3) DEFAULT NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_location_release_idempotency (tenant_id,source_service,business_id,idempotency_key), + KEY idx_pallet_location_release_business (tenant_id,source_service,business_id,operation_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA已拣满托盘库位精确释放的持久化幂等操作记录'; + +CREATE TABLE wh_pallet_location_settlement_operation ( + id bigint unsigned NOT NULL COMMENT 'PDA资源结算幂等操作ID', + tenant_id bigint unsigned NOT NULL COMMENT '仓储公共资源作用域,固定为0', + source_service varchar(40) NOT NULL COMMENT '来源服务', + source_business_id bigint unsigned NOT NULL COMMENT '主入库单ID', + target_business_id bigint unsigned NOT NULL COMMENT '二级子入库单ID;全拣完成时为0,仅作审计快照', + idempotency_key varchar(128) NOT NULL COMMENT 'PDA结算幂等键', + request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要', + operation_status varchar(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中,COMPLETED已完成', + response_snapshot json DEFAULT NULL COMMENT '完成后的释放和转移组合结果快照,用于重复请求重放', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + completed_at datetime(3) DEFAULT NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_location_settlement_idempotency (tenant_id,source_service,source_business_id,idempotency_key), + KEY idx_pallet_location_settlement_source (tenant_id,source_service,source_business_id,operation_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA拣货资源原子结算的持久化幂等操作记录'; + +CREATE TABLE wh_pallet_location_transfer_operation ( + id bigint unsigned NOT NULL COMMENT '调拨幂等操作ID', + tenant_id bigint unsigned NOT NULL COMMENT '仓储公共资源域,固定为0', + source_service varchar(40) NOT NULL COMMENT '来源服务', + source_business_id bigint unsigned NOT NULL COMMENT '来源入仓单ID', + target_business_id bigint unsigned NOT NULL COMMENT '目标子入仓单ID', + idempotency_key varchar(128) NOT NULL COMMENT '调用方幂等键', + request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要', + operation_status varchar(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中、COMPLETED已完成', + response_snapshot json DEFAULT NULL COMMENT '完成后的调拨结果快照,用于重复请求重放', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + completed_at datetime(3) DEFAULT NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_location_transfer_idempotency (tenant_id,source_service,source_business_id,target_business_id,idempotency_key), + KEY idx_pallet_location_transfer_source (tenant_id,source_service,source_business_id,operation_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='托盘库位归属转移的持久化幂等操作记录'; + +CREATE TABLE wh_partner ( + id bigint unsigned NOT NULL COMMENT '往来单位ID(雪花ID)', + tenant_id bigint unsigned NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0', + 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) DEFAULT 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) DEFAULT NULL COMMENT '联系邮箱', + country_code char(2) NOT NULL COMMENT '国家二字码', + province varchar(64) DEFAULT NULL COMMENT '省或州', + city varchar(64) NOT NULL COMMENT '城市', + district varchar(64) DEFAULT NULL COMMENT '区县', + address_line varchar(255) NOT NULL COMMENT '详细地址', + postal_code varchar(20) DEFAULT NULL COMMENT '邮政编码', + status varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用', + 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 '逻辑删除标记: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_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))), + CONSTRAINT chk_wh_partner_type CHECK ((partner_type in (_utf8mb4'CARRIER',_utf8mb4'SHIPPER',_utf8mb4'CONSIGNEE'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台统一维护、供全部客户共享的往来单位主数据'; + +CREATE TABLE wh_partner_type ( + partner_id bigint unsigned NOT NULL COMMENT '往来单位ID', + tenant_id bigint unsigned NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0', + data_scope_id bigint unsigned NOT NULL COMMENT '所属业务数据范围,平台公共数据为0', + partner_type varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '稳定业务类型:CUSTOMER客户、SHIPPER发货方等', + PRIMARY KEY (partner_id,partner_type), + KEY idx_wh_partner_type_scope (tenant_id,data_scope_id,partner_type,partner_id), + CONSTRAINT chk_wh_partner_type_value CHECK ((partner_type in (_utf8mb4'CARRIER',_utf8mb4'SHIPPER',_utf8mb4'CONSIGNEE'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台共享往来单位稳定业务类型'; + +CREATE TABLE wh_service_capability ( + id bigint unsigned NOT NULL COMMENT '服务能力ID', + tenant_id bigint unsigned NOT NULL COMMENT '平台公共仓库服务能力固定使用 tenant_id=0', + warehouse_id bigint unsigned NOT NULL COMMENT '所属仓库ID', + capability_code varchar(50) NOT NULL COMMENT '能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等', + capability_name varchar(100) NOT NULL COMMENT '能力名称', + enabled tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', + cutoff_time time DEFAULT NULL COMMENT '当日作业截单时间', + description 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_capability (tenant_id,warehouse_id,capability_code,deleted), + KEY idx_capability_query (tenant_id,warehouse_id,enabled,deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; + +CREATE TABLE wh_supplier ( + id bigint unsigned NOT NULL COMMENT '供应商ID', + tenant_id bigint unsigned NOT NULL DEFAULT '0' COMMENT '平台公共作用域,固定为0', + name varchar(160) NOT NULL COMMENT '供应商名称', + category varchar(20) NOT NULL COMMENT '供应商类别:BOOKING订舱、CUSTOMS报关、TRUCKING拖车、CERTIFICATE认证', + trucking_scope varchar(16) DEFAULT NULL COMMENT '拖车类型:DOMESTIC国内拖车、OVERSEAS海外车队;非拖车为空', + region varchar(80) NOT NULL COMMENT '地区', + legal_representative varchar(80) NOT NULL COMMENT '法人代表', + contact_name varchar(80) NOT NULL COMMENT '联系人', + phone varchar(40) NOT NULL COMMENT '联系电话', + email varchar(160) DEFAULT NULL COMMENT '电子邮箱', + address varchar(255) DEFAULT NULL COMMENT '公司地址', + business_license_no varchar(64) DEFAULT NULL COMMENT '营业执照号', + license_file_id varchar(128) DEFAULT NULL COMMENT 'File服务营业执照文件ID', + status varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、INACTIVE停用', + settlement_period varchar(32) NOT NULL COMMENT '结算周期', + 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 NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + PRIMARY KEY (id), + UNIQUE KEY uk_wh_supplier_name (tenant_id,name), + KEY idx_wh_supplier_filter (tenant_id,category,trucking_scope,status,region), + CONSTRAINT chk_wh_supplier_category CHECK ((category in (_utf8mb4'BOOKING',_utf8mb4'CUSTOMS',_utf8mb4'TRUCKING',_utf8mb4'CERTIFICATE'))), + CONSTRAINT chk_wh_supplier_settlement CHECK ((settlement_period in (_utf8mb4'MONTHLY_30',_utf8mb4'MONTHLY_45',_utf8mb4'MONTHLY_60',_utf8mb4'MONTHLY_90',_utf8mb4'PER_TRANSACTION',_utf8mb4'IMMEDIATE'))), + CONSTRAINT chk_wh_supplier_status CHECK ((status in (_utf8mb4'ACTIVE',_utf8mb4'INACTIVE'))), + CONSTRAINT chk_wh_supplier_trucking_scope CHECK ((((category = _utf8mb4'TRUCKING') and (trucking_scope is not null) and (trucking_scope in (_utf8mb4'DOMESTIC',_utf8mb4'OVERSEAS'))) or ((category <> _utf8mb4'TRUCKING') and (trucking_scope is null)))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共供应商业务档案'; + +CREATE TABLE wh_supplier_carrier ( + supplier_id bigint unsigned NOT NULL COMMENT 'BOOKING供应商ID', + tenant_id bigint unsigned NOT NULL DEFAULT '0' COMMENT '平台公共作用域,固定为0', + carrier_code varchar(16) NOT NULL COMMENT '船司标准编码', + created_by bigint unsigned DEFAULT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (supplier_id,carrier_code), + KEY idx_wh_supplier_carrier_code (tenant_id,carrier_code,supplier_id), + CONSTRAINT chk_wh_supplier_carrier_code CHECK ((carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER'))), + CONSTRAINT chk_wh_supplier_carrier_tenant CHECK ((tenant_id = 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='BOOKING供应商与标准船司编码多对多关联'; + +CREATE TABLE wh_warehouse ( + id bigint unsigned NOT NULL COMMENT '仓库ID(雪花ID)', + tenant_id bigint unsigned NOT NULL COMMENT '平台公共仓库固定使用 tenant_id=0', + warehouse_code varchar(50) NOT NULL COMMENT '平台内唯一仓库编码', + internal_code varchar(16) DEFAULT NULL COMMENT 'FBA内部编号,仅FBA仓可配置', + warehouse_name varchar(120) NOT NULL COMMENT '仓库名称', + 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 '仓库联系人', + contact_phone varchar(32) DEFAULT NULL COMMENT '联系电话', + contact_email varchar(128) DEFAULT NULL COMMENT '联系邮箱', + status varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', + 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_wh_tenant_code (tenant_id,warehouse_code,deleted), + KEY idx_wh_tenant_status (tenant_id,status,deleted), + KEY idx_wh_tenant_type_status (tenant_id,warehouse_type,status,deleted), + KEY idx_wh_country (tenant_id,country_code,deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共仓库主数据表:统一归属平台作用域 tenant_id=0'; + +CREATE TABLE wh_location ( + id bigint unsigned NOT NULL COMMENT '库位ID(雪花ID)', + tenant_id bigint unsigned NOT NULL COMMENT '平台公共库位固定使用 tenant_id=0', + warehouse_id bigint unsigned NOT NULL COMMENT '所属仓库ID', + zone_code varchar(50) DEFAULT NULL COMMENT '库区编码', + zone_name varchar(100) DEFAULT NULL COMMENT '库区名称', + location_code varchar(50) NOT NULL COMMENT '平台仓库内唯一库位编码', + location_name varchar(100) DEFAULT NULL COMMENT '库位名称', + location_type varchar(32) NOT NULL DEFAULT 'PALLET' COMMENT '库位类型,二期为PALLET', + max_weight_kg decimal(18,3) DEFAULT NULL COMMENT '最大承重展示值,二期不作容量拦截', + shared_overflow tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否为系统共享溢出位A0', + status varchar(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE、RESERVED、OCCUPIED或DISABLED', + remark varchar(500) DEFAULT NULL COMMENT '备注', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT 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_tenant_warehouse_code (tenant_id,warehouse_id,location_code), + KEY idx_location_available (tenant_id,warehouse_id,status,deleted), + KEY idx_location_zone (tenant_id,warehouse_id,zone_code,status,deleted), + KEY fk_location_warehouse (warehouse_id), + CONSTRAINT fk_location_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), + CONSTRAINT ck_location_max_weight CHECK (((max_weight_kg is null) or (max_weight_kg > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='二期库位基础资料,默认一托盘对应一个库位且不作容量校验'; + +CREATE TABLE wh_location_block ( + id bigint unsigned NOT NULL COMMENT '整票库位块ID', + tenant_id bigint unsigned NOT NULL DEFAULT '0' COMMENT '平台公共仓储资源域,固定为0', + warehouse_id bigint unsigned NOT NULL COMMENT '仓库ID', + source_service varchar(40) NOT NULL COMMENT '来源服务', + business_id bigint unsigned NOT NULL COMMENT '当前归属入库单业务ID', + inbound_code varchar(40) NOT NULL COMMENT '当前归属入仓码', + slot_count int unsigned NOT NULL COMMENT '整票独占槽位数', + idempotency_key varchar(128) NOT NULL COMMENT '预占幂等键', + block_status varchar(20) NOT NULL COMMENT '整票块状态:RESERVED、OCCUPIED或RELEASED', + expires_at datetime(3) NOT NULL COMMENT '预占过期时间', + last_operation_key varchar(128) DEFAULT NULL COMMENT '最近确认、转移或释放幂等键', + created_by bigint unsigned NOT 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 '更新时间', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + active_business_block tinyint GENERATED ALWAYS AS ((case when (block_status in (_utf8mb4'RESERVED',_utf8mb4'OCCUPIED')) then 1 else NULL end)) STORED COMMENT '同一来源入库单仅允许一个活动整票块', + PRIMARY KEY (id), + UNIQUE KEY uk_location_block_idempotency (tenant_id,source_service,idempotency_key), + UNIQUE KEY uk_location_block_active_business (tenant_id,source_service,business_id,active_business_block), + KEY idx_location_block_business (tenant_id,source_service,business_id,block_status), + KEY fk_location_block_warehouse (warehouse_id), + CONSTRAINT fk_location_block_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), + CONSTRAINT ck_location_block_slot_count CHECK ((slot_count between 1 and 200)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一票入仓码原子持有的稳定库位块'; + +CREATE TABLE wh_location_block_slot ( + id bigint unsigned NOT NULL COMMENT '槽位预占ID,对外作为reservationId', + tenant_id bigint unsigned NOT NULL DEFAULT '0' COMMENT '平台公共仓储资源域,固定为0', + block_id bigint unsigned NOT NULL COMMENT '整票库位块ID', + slot_no int unsigned NOT NULL COMMENT '块内稳定槽位序号,从1开始', + location_id bigint unsigned NOT NULL COMMENT '库位ID', + location_code varchar(50) NOT NULL COMMENT '库位编码快照', + shared_overflow tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否落入同仓共享A0', + resource_status varchar(20) NOT NULL COMMENT '资源状态:RESERVED、OCCUPIED或RELEASED', + 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 '乐观锁版本号', + active_normal_slot tinyint GENERATED ALWAYS AS ((case when ((resource_status in (_utf8mb4'RESERVED',_utf8mb4'OCCUPIED')) and (shared_overflow = 0)) then 1 else NULL end)) STORED COMMENT '普通库位活动块槽唯一标记;A0返回NULL', + PRIMARY KEY (id), + UNIQUE KEY uk_location_block_slot_no (tenant_id,block_id,slot_no), + UNIQUE KEY uk_location_block_active_normal (tenant_id,location_id,active_normal_slot), + KEY idx_location_block_slot_block (tenant_id,block_id,resource_status), + KEY fk_location_block_slot_block (block_id), + KEY fk_location_block_slot_location (location_id), + CONSTRAINT fk_location_block_slot_block FOREIGN KEY (block_id) REFERENCES wh_location_block (id), + CONSTRAINT fk_location_block_slot_location FOREIGN KEY (location_id) REFERENCES wh_location (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='整票库位块槽位;空槽也由当前入仓码独占'; + +CREATE TABLE wh_pallet ( + id bigint unsigned NOT NULL COMMENT '托盘ID(雪花ID)', + tenant_id bigint unsigned NOT NULL COMMENT '平台公共托盘固定使用 tenant_id=0', + warehouse_id bigint unsigned NOT NULL COMMENT '所属仓库ID', + pallet_code varchar(50) NOT NULL COMMENT '平台仓库内唯一托盘编码', + pallet_type varchar(32) NOT NULL COMMENT '托盘类型', + pallet_name varchar(100) DEFAULT NULL COMMENT '托盘名称', + capacity_cbm decimal(18,6) NOT NULL COMMENT '托盘可用容积CBM', + weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '托盘皮重kg', + color varchar(32) NOT NULL DEFAULT '' COMMENT '托盘颜色', + remark varchar(500) DEFAULT NULL COMMENT '备注', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT 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_tenant_code (tenant_id,pallet_code), + KEY idx_pallet_warehouse (tenant_id,warehouse_id,deleted,pallet_code), + KEY fk_pallet_warehouse (warehouse_id), + KEY idx_pallet_type (tenant_id,warehouse_id,pallet_type,capacity_cbm,deleted), + CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), + CONSTRAINT ck_pallet_capacity CHECK ((capacity_cbm > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='可复用托盘标识和称重主数据,不承载动态资源状态'; + +CREATE TABLE wh_location_reservation ( + id bigint unsigned NOT NULL COMMENT '库位预占ID', + tenant_id bigint unsigned NOT NULL COMMENT '平台公共库位预占固定使用 tenant_id=0', + reservation_no varchar(40) NOT NULL COMMENT '平台内唯一预占业务号', + idempotency_key varchar(128) NOT NULL COMMENT '调用方幂等键', + source_service varchar(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务', + business_id bigint unsigned NOT NULL COMMENT '来源服务入库单ID', + inbound_code varchar(40) NOT NULL COMMENT '入仓码快照', + warehouse_id bigint unsigned NOT NULL COMMENT '仓库ID', + pallet_id bigint unsigned NOT NULL COMMENT '托盘ID', + pallet_code varchar(50) NOT NULL COMMENT '托盘编码快照', + location_id bigint unsigned NOT NULL COMMENT '库位ID', + location_code varchar(50) NOT NULL COMMENT '库位编码快照', + shared_overflow tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否使用共享溢出位A0', + reservation_status varchar(20) NOT NULL DEFAULT 'RESERVED' COMMENT 'RESERVED、CONFIRMED、RELEASED或EXPIRED', + expires_at datetime(3) NOT NULL COMMENT '预占过期时间', + confirmed_at datetime(3) DEFAULT NULL COMMENT '确认占用时间', + released_at datetime(3) DEFAULT NULL COMMENT '释放时间', + release_reason varchar(500) DEFAULT NULL COMMENT '释放或过期原因', + created_by bigint unsigned NOT 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 '更新时间', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + active_reservation tinyint GENERATED ALWAYS AS ((case when ((reservation_status = _utf8mb4'RESERVED') and (shared_overflow = 0)) then 1 else NULL end)) STORED COMMENT '普通库位活动预占唯一标记,A0 返回 NULL', + PRIMARY KEY (id), + UNIQUE KEY uk_location_reservation_no (tenant_id,reservation_no), + UNIQUE KEY uk_location_reservation_idempotency (tenant_id,source_service,idempotency_key), + UNIQUE KEY uk_location_active_reservation (tenant_id,location_id,active_reservation), + KEY idx_location_reservation_business (tenant_id,source_service,business_id,reservation_status), + KEY idx_location_reservation_expire (reservation_status,expires_at), + KEY idx_location_reservation_target (tenant_id,warehouse_id,location_id,reservation_status), + KEY fk_reservation_warehouse (warehouse_id), + KEY fk_reservation_pallet (pallet_id), + KEY fk_reservation_location (location_id), + CONSTRAINT fk_reservation_location FOREIGN KEY (location_id) REFERENCES wh_location (id), + CONSTRAINT fk_reservation_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), + CONSTRAINT fk_reservation_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='跨服务入库Saga的库位临时预占,托盘字段仅为主数据快照'; + +CREATE TABLE wh_location_occupancy ( + id bigint unsigned NOT NULL COMMENT '库位占用ID', + tenant_id bigint unsigned NOT NULL COMMENT '平台公共库位占用固定使用 tenant_id=0', + occupancy_no varchar(40) NOT NULL COMMENT '平台内唯一占用业务号', + reservation_id bigint unsigned NOT NULL COMMENT '来源预占ID', + source_service varchar(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务', + business_id bigint unsigned NOT NULL COMMENT '来源服务入库单ID', + inbound_code varchar(40) NOT NULL COMMENT '入仓码快照', + warehouse_id bigint unsigned NOT NULL COMMENT '仓库ID', + pallet_id bigint unsigned NOT NULL COMMENT '托盘ID', + pallet_code varchar(50) NOT NULL COMMENT '托盘编码快照', + location_id bigint unsigned NOT NULL COMMENT '库位ID', + location_code varchar(50) NOT NULL COMMENT '库位编码快照', + shared_overflow tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否使用共享溢出位A0', + allocated_volume_cbm decimal(18,6) NOT NULL COMMENT '本托盘分配体积CBM', + allocated_package_count int unsigned DEFAULT NULL COMMENT '本托盘分配箱数', + occupancy_status varchar(20) NOT NULL DEFAULT 'OCCUPIED' COMMENT 'OCCUPIED或RELEASED', + occupied_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '占用开始时间', + released_at datetime(3) DEFAULT NULL COMMENT '释放时间', + release_reason varchar(500) DEFAULT NULL COMMENT '释放原因', + created_by bigint unsigned NOT 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 '更新时间', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + active_location_occupancy tinyint GENERATED ALWAYS AS ((case when ((occupancy_status = _utf8mb4'OCCUPIED') and (shared_overflow = 0)) then 1 else NULL end)) STORED COMMENT '普通库位活动占用唯一标记', + PRIMARY KEY (id), + UNIQUE KEY uk_location_occupancy_no (tenant_id,occupancy_no), + UNIQUE KEY uk_occupancy_reservation (tenant_id,reservation_id), + UNIQUE KEY uk_location_active_occupancy (tenant_id,location_id,active_location_occupancy), + KEY idx_occupancy_business (tenant_id,source_service,business_id,occupancy_status), + KEY idx_occupancy_warehouse (tenant_id,warehouse_id,occupancy_status,occupied_at), + KEY fk_occupancy_reservation (reservation_id), + KEY fk_occupancy_warehouse (warehouse_id), + KEY fk_occupancy_pallet (pallet_id), + KEY fk_occupancy_location (location_id), + CONSTRAINT fk_occupancy_location FOREIGN KEY (location_id) REFERENCES wh_location (id), + CONSTRAINT fk_occupancy_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), + CONSTRAINT fk_occupancy_reservation FOREIGN KEY (reservation_id) REFERENCES wh_location_reservation (id), + CONSTRAINT fk_occupancy_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), + CONSTRAINT ck_occupancy_volume CHECK ((allocated_volume_cbm > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='已确认的库位占用事实记录,托盘字段仅为主数据快照'; + +CREATE TABLE wh_warehouse_address ( + id bigint unsigned NOT NULL COMMENT '仓库地址ID', + tenant_id bigint unsigned NOT NULL COMMENT '平台公共仓库地址固定使用 tenant_id=0', + 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) DEFAULT NULL COMMENT '省或州', + city varchar(100) NOT NULL COMMENT '城市', + district varchar(100) DEFAULT NULL COMMENT '区县', + postal_code varchar(20) DEFAULT NULL COMMENT '邮政编码', + address_line1 varchar(255) NOT NULL COMMENT '主地址行', + address_line2 varchar(255) DEFAULT NULL COMMENT '补充地址行', + latitude decimal(10,7) DEFAULT NULL COMMENT '纬度', + longitude decimal(10,7) 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_address_type (tenant_id,warehouse_id,address_type,deleted), + KEY idx_address_warehouse (tenant_id,warehouse_id,deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; + +-- 服务 schema:baiyue_firstleg +CREATE DATABASE IF NOT EXISTS baiyue_firstleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_firstleg; + +-- 测量项与拆分项互为外键,仅建空表期间暂缓依赖顺序检查。 +SET SESSION foreign_key_checks = 0; + +CREATE TABLE fl_carrier_fba_rate ( + id bigint unsigned NOT NULL COMMENT '船司FBA价格ID', + tenant_id bigint unsigned NOT NULL COMMENT '平台价格作用域ID,船司价格固定为0', + price_book_version_id bigint unsigned DEFAULT NULL COMMENT '整表价格版本ID;历史兼容价格可为空', + rate_code varchar(50) NOT NULL COMMENT '平台公共价格编码', + rate_version int unsigned NOT NULL COMMENT '价格版本号', + origin_warehouse_id bigint unsigned DEFAULT NULL COMMENT '确认前可为空;确认后绑定平台公共起运仓逻辑ID', + origin_warehouse_code varchar(64) DEFAULT NULL COMMENT '起运仓稳定编码', + origin_warehouse_name varchar(128) NOT NULL COMMENT '起运仓名称快照', + business_type varchar(32) NOT NULL COMMENT '业务或计费类型', + channel_code varchar(64) DEFAULT NULL COMMENT '渠道编码', + price_model varchar(32) NOT NULL DEFAULT 'FBA_WAREHOUSE_RATE' COMMENT '价格模型', + carrier_id bigint unsigned DEFAULT NULL COMMENT '船司逻辑ID', + carrier_name varchar(128) DEFAULT NULL COMMENT '历史承运商名称;渠道价格可为空', + destination_type varchar(16) DEFAULT NULL COMMENT '目的地类型:FBA或OVERSEAS;REGION_WEIGHT_TIER区域阶梯行为空', + destination_code varchar(32) DEFAULT NULL COMMENT 'FBA目的仓编码;区域阶梯模型为空', + destination_warehouse_id bigint unsigned DEFAULT NULL COMMENT '平台海外仓稳定ID;不存环境专属ID', + destination_name varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + region_code varchar(16) DEFAULT NULL COMMENT '区域阶梯模型区域编码', + pricing_tax_mode varchar(16) DEFAULT NULL COMMENT '报价包税维度;独立于订单tax_mode', + weight_from_kg decimal(18,6) DEFAULT NULL COMMENT '区域阶梯起始计费重量KG', + charge_unit varchar(16) NOT NULL COMMENT '计费单位:KG或CBM', + unit_price decimal(18,6) NOT NULL COMMENT '基础单价', + currency char(3) NOT NULL COMMENT '币种', + additional_fee decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '固定附加费', + source_sheet_name varchar(128) DEFAULT NULL COMMENT '原始Excel Sheet', + source_row_number int unsigned DEFAULT NULL COMMENT '原始Excel行号', + source_destination_text varchar(1000) DEFAULT NULL COMMENT '原始组合仓库或区域文本', + effective_from datetime(3) NOT NULL COMMENT '生效开始时间', + effective_to datetime(3) DEFAULT NULL COMMENT '生效结束时间', + status varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态:PENDING_CONFIRMATION、ACTIVE或SUPERSEDED;分别表示待确认、已生效、已被替代;FAILED表示解析失败', + remark varchar(500) DEFAULT NULL COMMENT '价格备注', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT 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_fba_rate_version (tenant_id,rate_code,rate_version), + KEY idx_fba_rate_match (tenant_id,origin_warehouse_id,business_type,channel_code,carrier_id,destination_type,destination_code,destination_warehouse_id,charge_unit,status,effective_from,effective_to), + KEY idx_fba_rate_book (price_book_version_id,channel_code,origin_warehouse_id,price_model,status), + KEY idx_fba_rate_destination (price_book_version_id,channel_code,origin_warehouse_id,destination_type,destination_warehouse_id,destination_code), + KEY idx_fba_rate_region (price_book_version_id,channel_code,origin_warehouse_id,region_code,weight_from_kg) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船司FBA目的仓价格版本'; + +CREATE TABLE fl_consumed_event ( + event_id bigint unsigned NOT NULL COMMENT '已消费领域事件ID,与生产端Outbox事件ID一致', + event_type varchar(64) NOT NULL COMMENT '已消费事件类型', + consumed_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功消费时间', + PRIMARY KEY (event_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表'; + +CREATE TABLE fl_container_booking ( + id bigint unsigned NOT NULL COMMENT '货柜预订ID(雪花ID)', + booking_no varchar(40) NOT NULL COMMENT '平台唯一货柜预订单号', + container_no varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '集装箱号;确认前允许修订', + cabinet_no varchar(64) NOT NULL COMMENT '平台唯一内部柜号', + source_type varchar(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', + record_status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史', + merged_into_container_booking_id bigint unsigned DEFAULT NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID', + channel_group_code varchar(64) DEFAULT NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留', + channel_code varchar(64) DEFAULT NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装', + channel_name varchar(128) DEFAULT NULL COMMENT '订舱渠道名称快照', + ocean_booking_id bigint unsigned DEFAULT NULL COMMENT '来源订舱ID', + ocean_booking_requirement_id bigint unsigned DEFAULT NULL COMMENT '来源订舱箱型需求ID', + customer_id bigint unsigned DEFAULT NULL COMMENT '已关联真实订舱指定的客户范围', + customer_name varchar(128) DEFAULT NULL COMMENT '已关联真实订舱客户名称快照', + warehouse_id bigint unsigned NOT NULL COMMENT '装柜仓库ID;平台公共资源', + warehouse_name varchar(128) NOT NULL COMMENT '装柜仓库名称快照', + bill_of_lading_no varchar(128) DEFAULT NULL COMMENT '提单号', + vessel_name varchar(128) DEFAULT NULL COMMENT '船名快照', + voyage_no varchar(64) DEFAULT NULL COMMENT '航次;确认前允许修订', + container_type varchar(32) NOT NULL COMMENT '平台固定柜型:20GP、40GP、40HC、40NR、45HC', + shipping_company_id bigint unsigned DEFAULT NULL COMMENT '船司ID快照', + shipping_company_name varchar(128) DEFAULT NULL COMMENT '船司名称快照', + departure_port_code varchar(32) DEFAULT NULL COMMENT '起运港编码快照', + departure_port_name varchar(128) DEFAULT NULL COMMENT '起运港名称快照', + destination_port_code varchar(32) DEFAULT NULL COMMENT '目的港编码快照', + destination_port_name varchar(128) DEFAULT NULL COMMENT '目的港名称快照', + destination_warehouse_code varchar(64) DEFAULT NULL COMMENT '目的仓编码快照', + destination_warehouse_name varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + capacity_cbm decimal(18,4) DEFAULT NULL COMMENT '货柜容量m3', + max_weight_kg decimal(18,3) DEFAULT NULL COMMENT '货柜最大承重kg', + terminal_trucking_company_id bigint unsigned DEFAULT NULL COMMENT '码头拖车Supplier逻辑ID快照;历史数据无法推导时为空', + terminal_trucking_company varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '码头拖车公司', + loading_at datetime(3) DEFAULT NULL COMMENT '计划或实际装柜时间', + estimated_sailing_at datetime(3) DEFAULT NULL COMMENT '预计开船时间', + actual_sailing_at datetime(3) DEFAULT NULL COMMENT '实际开船时间', + estimated_port_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到港时间', + actual_port_arrival_at datetime(3) DEFAULT NULL COMMENT '实际到港时间', + estimated_warehouse_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到仓时间', + actual_warehouse_arrival_at datetime(3) DEFAULT NULL COMMENT '实际到仓时间', + unloading_completed_at datetime(3) DEFAULT NULL COMMENT '海外仓卸柜完成时间(UTC)', + unloading_photo_uploaded_at datetime(3) DEFAULT NULL COMMENT '海外仓卸柜照片上传成功时间(UTC)', + estimated_empty_reported_at datetime(3) DEFAULT NULL COMMENT '预计还空时间(卸柜完成时间加72小时,UTC)', + actual_empty_reported_at datetime(3) DEFAULT NULL COMMENT '实际报空时间(UTC)', + empty_report_source_version bigint unsigned DEFAULT NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖', + pickup_code varchar(64) DEFAULT NULL COMMENT 'PU数字码;按字符串保存以保留前导零', + pickup_code_updated_by bigint unsigned DEFAULT NULL COMMENT 'PU码最后填写人ID', + pickup_code_updated_by_name varchar(64) DEFAULT NULL COMMENT 'PU码最后填写人名称快照', + pickup_code_updated_at datetime(3) DEFAULT NULL COMMENT 'PU码最后填写时间', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT NULL COMMENT '最后修改人ID', + updated_by_name varchar(64) DEFAULT NULL COMMENT '最后修改人名称快照', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_container_booking_no (booking_no), + UNIQUE KEY uk_container_booking_cabinet (cabinet_no), + UNIQUE KEY uk_container_booking_container_no (container_no), + KEY idx_container_booking_merge (record_status,merged_into_container_booking_id), + KEY idx_container_booking_warehouse (warehouse_id,updated_at,id), + KEY idx_container_booking_voyage (voyage_no,updated_at), + KEY idx_container_booking_transit_summary (actual_sailing_at,actual_warehouse_arrival_at,ocean_booking_id,container_type), + CONSTRAINT chk_container_booking_capacity CHECK (((capacity_cbm is null) or (capacity_cbm > 0))), + CONSTRAINT chk_container_booking_iso6346 CHECK (((container_no is null) or regexp_like(container_no,_utf8mb4'^[A-Z]{3}[UJZ][0-9]{7}$'))), + CONSTRAINT chk_container_booking_pickup_code CHECK (((pickup_code is null) or regexp_like(pickup_code,_utf8mb4'^[0-9]{1,64}$'))), + CONSTRAINT chk_container_booking_record_status CHECK ((record_status in (_utf8mb4'ACTIVE',_utf8mb4'MERGED'))), + CONSTRAINT chk_container_booking_source CHECK ((source_type in (_utf8mb4'SIMULATION',_utf8mb4'OCEAN_BOOKING'))), + CONSTRAINT chk_container_booking_weight CHECK (((max_weight_kg is null) or (max_weight_kg > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; + +CREATE TABLE fl_container_document ( + id bigint unsigned NOT NULL COMMENT '货柜文件关联ID(雪花ID)', + container_booking_id bigint unsigned NOT NULL COMMENT '货柜预订ID', + document_type varchar(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单', + file_id bigint unsigned NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', + file_name varchar(255) NOT NULL COMMENT '文件名称快照', + file_hash varchar(64) DEFAULT NULL COMMENT 'File服务SHA-256快照', + file_url varchar(1000) DEFAULT NULL COMMENT '文件访问地址快照', + uploaded_by bigint unsigned NOT NULL COMMENT '上传操作人ID', + uploaded_by_name varchar(64) DEFAULT NULL COMMENT '上传操作人名称快照', + completed_by bigint unsigned DEFAULT NULL COMMENT '一键完成操作人ID', + completed_by_name varchar(64) DEFAULT NULL COMMENT '一键完成操作人名称快照', + completed_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 '货柜文件关联最后更新时间', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_container_document_type (container_booking_id,document_type), + KEY idx_container_document_file (file_id), + CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), + CONSTRAINT chk_container_document_type CHECK ((document_type in (_utf8mb4'DESTINATION_CLEARANCE',_utf8mb4'DELIVERY_ORDER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台货柜清关与DO文件关联'; + +CREATE TABLE 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) DEFAULT NULL COMMENT '处理完成时间(UTC)', + PRIMARY KEY (event_id), + KEY idx_container_event_aggregate (aggregate_id,aggregate_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='海外到国内业务事件消费Inbox'; + +CREATE TABLE fl_container_exception_history ( + id bigint unsigned NOT NULL COMMENT '货柜异常历史ID', + container_booking_id bigint unsigned NOT NULL COMMENT '物理货柜ID', + action varchar(16) NOT NULL COMMENT '操作类型:MARK标记异常、CANCEL取消异常', + reason varchar(500) NOT NULL COMMENT '异常原因', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + operation_id varchar(128) NOT NULL COMMENT '同一操作人幂等键', + request_hash char(64) NOT NULL COMMENT '规范化请求摘要', + occurred_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作发生时间', + PRIMARY KEY (id), + UNIQUE KEY uk_container_exception_operation (container_booking_id,operator_id,operation_id), + KEY idx_container_exception_history (container_booking_id,occurred_at,id), + CONSTRAINT fk_container_exception_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), + CONSTRAINT chk_container_exception_action CHECK ((action in (_utf8mb4'MARK',_utf8mb4'CANCEL'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='货柜异常标记及取消原因历史'; + +CREATE TABLE fl_container_fee_detail ( + id bigint unsigned NOT NULL COMMENT '柜次费用来源ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + allocation_group_id bigint unsigned DEFAULT NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空', + allocation_order_id bigint unsigned DEFAULT NULL COMMENT '本分摊行业务订单ID', + allocation_order_no varchar(64) DEFAULT NULL COMMENT '本分摊行业务订单号', + allocation_inbound_id bigint unsigned DEFAULT NULL COMMENT '本分摊行对应入仓单ID', + allocation_inbound_code varchar(64) DEFAULT NULL COMMENT '本分摊行对应入仓码', + allocation_group_amount decimal(18,2) DEFAULT NULL COMMENT '柜级原始费用总额快照', + loading_plan_id bigint unsigned NOT NULL COMMENT '柜次ID,对应fl_loading_plan.id', + container_booking_id bigint unsigned DEFAULT NULL COMMENT '订舱柜ID快照', + container_no varchar(64) DEFAULT NULL COMMENT '柜号快照', + fee_item_id bigint unsigned NOT NULL COMMENT '平台公共费用项目ID', + fee_item_code_snapshot varchar(50) NOT NULL COMMENT '费用项目稳定编码快照', + fee_item_name_snapshot varchar(128) NOT NULL COMMENT '费用项目名称快照', + direction varchar(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付', + supplier_id bigint unsigned DEFAULT NULL COMMENT '应付Warehouse供应商ID快照;应收为空', + supplier_name varchar(200) DEFAULT NULL COMMENT '应付Warehouse供应商名称快照;应收为空', + supplier_category varchar(16) DEFAULT NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + settlement_mode varchar(16) DEFAULT NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空', + paid_at datetime(3) DEFAULT NULL COMMENT 'DIRECT_PAID业务已付时间', + payment_method varchar(32) DEFAULT NULL COMMENT 'DIRECT_PAID付款方式', + payment_reference_no varchar(128) DEFAULT NULL COMMENT 'DIRECT_PAID付款流水号', + payment_voucher_file_ids json DEFAULT NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', + business_date date NOT NULL COMMENT '费用业务日期', + amount decimal(18,2) NOT NULL COMMENT 'CNY费用金额', + currency char(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', + review_status varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED通过、REJECTED驳回', + source_status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '来源状态:ACTIVE有效、VOIDED作废', + source_version int unsigned NOT NULL DEFAULT '1' COMMENT '费用来源版本;调整递增,作废携带当前版本', + remark varchar(500) DEFAULT NULL COMMENT '人工备注', + reviewer_id bigint unsigned DEFAULT NULL COMMENT '审核人ID', + reviewer_name varchar(64) DEFAULT NULL COMMENT '审核人名称快照', + reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', + review_reason varchar(500) DEFAULT NULL COMMENT '审核驳回原因', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT NULL COMMENT '最后更新人ID', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (id), + KEY idx_container_fee_customer_plan (tenant_id,loading_plan_id,source_status), + KEY idx_container_fee_review (tenant_id,review_status,source_status), + CONSTRAINT chk_container_fee_amount CHECK ((amount >= 0)), + CONSTRAINT chk_container_fee_currency CHECK ((currency = _utf8mb4'CNY')), + CONSTRAINT chk_container_fee_direction CHECK ((direction in (_utf8mb4'RECEIVABLE',_utf8mb4'PAYABLE'))), + CONSTRAINT chk_container_fee_review CHECK ((review_status in (_utf8mb4'PENDING',_utf8mb4'APPROVED',_utf8mb4'REJECTED'))), + CONSTRAINT chk_container_fee_settlement CHECK ((((direction = _utf8mb4'RECEIVABLE') and (settlement_mode is null) and (paid_at is null) and (payment_method is null) and (payment_reference_no is null) and (payment_voucher_file_ids is null)) or ((direction = _utf8mb4'PAYABLE') and (settlement_mode = _utf8mb4'ON_ACCOUNT') and (paid_at is null) and (payment_method is null) and (payment_reference_no is null) and (payment_voucher_file_ids is null)) or ((direction = _utf8mb4'PAYABLE') and (settlement_mode = _utf8mb4'DIRECT_PAID') and (paid_at is not null) and (payment_method in (_utf8mb4'ONLINE',_utf8mb4'BANK_TRANSFER',_utf8mb4'CASH',_utf8mb4'OTHER')) and ((payment_reference_no is not null) or (json_length(payment_voucher_file_ids) > 0))))), + CONSTRAINT chk_container_fee_source CHECK ((source_status in (_utf8mb4'ACTIVE',_utf8mb4'VOIDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance'; + +CREATE TABLE fl_container_fee_source_snapshot ( + id bigint unsigned NOT NULL COMMENT '柜次费用快照ID', + source_fee_id bigint unsigned NOT NULL COMMENT '柜次费用来源ID', + source_version int unsigned NOT NULL COMMENT '费用来源版本', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + allocation_group_id bigint unsigned DEFAULT NULL COMMENT '柜级公共费用分摊组ID', + allocation_order_id bigint unsigned DEFAULT NULL COMMENT '分摊业务订单ID快照', + allocation_order_no varchar(64) DEFAULT NULL COMMENT '分摊业务订单号快照', + allocation_inbound_id bigint unsigned DEFAULT NULL COMMENT '分摊入仓单ID快照', + allocation_inbound_code varchar(64) DEFAULT NULL COMMENT '分摊入仓码快照', + allocation_group_amount decimal(18,2) DEFAULT NULL COMMENT '柜级原始费用总额快照', + loading_plan_id bigint unsigned NOT NULL COMMENT '柜次ID', + container_booking_id bigint unsigned DEFAULT NULL COMMENT '订舱柜ID快照', + container_no varchar(64) DEFAULT NULL COMMENT '柜号快照', + fee_item_id bigint unsigned NOT NULL COMMENT '费用项目ID快照', + fee_item_code varchar(50) NOT NULL COMMENT '费用项目编码快照', + fee_item_name varchar(128) NOT NULL COMMENT '费用项目名称快照', + direction varchar(16) NOT NULL COMMENT '费用方向', + supplier_id bigint unsigned DEFAULT NULL COMMENT 'Warehouse供应商ID快照', + supplier_name varchar(200) DEFAULT NULL COMMENT 'Warehouse供应商名称快照', + supplier_category varchar(16) DEFAULT NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + settlement_mode varchar(16) DEFAULT NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空', + paid_at datetime(3) DEFAULT NULL COMMENT 'DIRECT_PAID业务已付时间', + payment_method varchar(32) DEFAULT NULL COMMENT 'DIRECT_PAID付款方式', + payment_reference_no varchar(128) DEFAULT NULL COMMENT 'DIRECT_PAID付款流水号', + payment_voucher_file_ids json DEFAULT NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', + business_date date NOT NULL COMMENT '费用业务日期', + amount decimal(18,2) NOT NULL COMMENT 'CNY费用金额', + currency char(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', + review_status varchar(16) NOT NULL COMMENT '审核状态快照', + reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '快照创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_container_fee_source_version (loading_plan_id,source_fee_id,source_version), + KEY idx_container_fee_snapshot_tenant (tenant_id,loading_plan_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次费用不可变版本快照'; + +CREATE TABLE fl_container_transport_history ( + id bigint unsigned NOT NULL COMMENT '运输字段变更历史ID', + container_booking_id bigint unsigned NOT NULL COMMENT '稳定柜次ID', + before_container_no varchar(64) DEFAULT NULL COMMENT '变更前真实集装箱号', + after_container_no varchar(64) DEFAULT NULL COMMENT '变更后真实集装箱号', + before_voyage_no varchar(64) DEFAULT NULL COMMENT '变更前航次号', + after_voyage_no varchar(64) DEFAULT NULL COMMENT '变更后航次号', + operation_id varchar(64) NOT NULL COMMENT '运输字段操作幂等ID', + request_hash char(64) NOT NULL COMMENT '运输字段请求哈希', + reason varchar(500) DEFAULT NULL COMMENT '运输字段变更原因', + operator_id bigint unsigned NOT NULL COMMENT '变更操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '变更操作人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '变更记录创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_container_transport_history_operation (container_booking_id,operator_id,operation_id), + KEY idx_container_transport_history (container_booking_id,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次真实运输字段不可变变更审计'; + +CREATE TABLE fl_customer_charge_rule ( + id bigint unsigned NOT NULL COMMENT '客户计费规则ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + rule_code varchar(50) NOT NULL COMMENT '客户内唯一计费规则编码', + rule_version int unsigned NOT NULL COMMENT '规则版本号', + customer_id bigint unsigned NOT NULL COMMENT '客户逻辑ID', + customer_name varchar(128) NOT NULL COMMENT '客户名称快照', + business_type varchar(32) NOT NULL COMMENT '业务类型', + charge_mode varchar(16) NOT NULL COMMENT '计费口径:KG或CBM', + tax_mode varchar(24) DEFAULT NULL COMMENT '包税或不包税类型', + conversion_factor decimal(18,6) NOT NULL COMMENT '重量体积换算系数', + length_factor decimal(18,6) NOT NULL DEFAULT '1.000000' COMMENT '长度计算系数', + width_factor decimal(18,6) NOT NULL DEFAULT '1.000000' COMMENT '宽度计算系数', + height_factor decimal(18,6) NOT NULL DEFAULT '1.000000' COMMENT '高度计算系数', + rounding_scale tinyint unsigned NOT NULL DEFAULT '3' COMMENT '结果保留小数位数', + rounding_mode varchar(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式', + currency char(3) NOT NULL COMMENT '默认币种', + effective_from datetime(3) NOT NULL COMMENT '生效开始时间', + effective_to datetime(3) DEFAULT NULL COMMENT '生效结束时间', + status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用', + default_rule tinyint(1) NOT NULL DEFAULT '0' COMMENT '客户默认生效规则', + remark varchar(500) DEFAULT NULL COMMENT '规则备注', + initialization_key varchar(128) DEFAULT NULL COMMENT '客户创建执行器幂等键及补偿归属', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT 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_charge_rule_version (tenant_id,rule_code,rule_version), + KEY idx_charge_rule_initialization (tenant_id,initialization_key), + KEY idx_charge_rule_match (tenant_id,customer_id,business_type,status,effective_from,effective_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户头程计费规则版本'; + +CREATE TABLE fl_customer_dimension_config ( + id bigint unsigned NOT NULL COMMENT '配置ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + box_type varchar(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE', + inner_length_cm decimal(9,3) NOT NULL COMMENT '箱规内长(厘米)', + inner_width_cm decimal(9,3) NOT NULL COMMENT '箱规内宽(厘米)', + inner_height_cm decimal(9,3) NOT NULL COMMENT '箱规内高(厘米)', + edge_addition_cm decimal(6,3) NOT NULL DEFAULT '2.000' COMMENT '每个尺寸方向的单侧加宽厘米数', + default_calculation_mode varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTUAL' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY', + remark varchar(500) DEFAULT NULL COMMENT '备注', + initialization_key varchar(128) NOT NULL COMMENT '创建流程幂等键及补偿归属', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT 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_customer_dimension_config_tenant_box (tenant_id,box_type), + KEY idx_customer_dimension_config_initialization (tenant_id,initialization_key), + CONSTRAINT chk_customer_dimension_box_type CHECK ((box_type in (_utf8mb4'SMALL',_utf8mb4'MEDIUM',_utf8mb4'LARGE',_utf8mb4'EXTRA_LARGE'))), + CONSTRAINT chk_customer_dimension_edge_addition CHECK (((edge_addition_cm >= 0.001) and (edge_addition_cm <= 100.000))), + CONSTRAINT chk_customer_dimension_inner_size CHECK (((inner_length_cm >= 0.001) and (inner_width_cm >= 0.001) and (inner_height_cm >= 0.001))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户固定四箱规尺寸配置'; + +CREATE TABLE fl_customs_command ( + id bigint unsigned NOT NULL COMMENT '报关幂等命令ID', + actor_id bigint unsigned NOT NULL COMMENT '命令操作人ID', + action varchar(80) NOT NULL COMMENT '命令动作类型', + idempotency_key varchar(128) NOT NULL COMMENT '客户端幂等键', + request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要', + status varchar(16) NOT NULL COMMENT '命令状态:PROCESSING处理中、SUCCESS成功', + result_id bigint unsigned DEFAULT NULL COMMENT '命令结果业务记录ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', + completed_at datetime(3) DEFAULT NULL COMMENT '命令完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_customs_command (actor_id,action,idempotency_key), + CONSTRAINT chk_customs_command_status CHECK ((status in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='整柜报关写命令幂等事实'; + +CREATE TABLE fl_customs_container ( + id bigint unsigned NOT NULL COMMENT '整柜报关聚合ID', + loading_plan_id bigint unsigned NOT NULL COMMENT '已完成装柜计划ID;报关柜级聚合唯一键', + container_booking_id bigint unsigned NOT NULL COMMENT '物理货柜ID', + status varchar(40) NOT NULL COMMENT '报关状态:WAITING_CUSTOMER_DOCUMENTS待资料、READY_TO_START可开始、DECLARING申报中、COMPLETED已完成', + started_by bigint unsigned DEFAULT NULL COMMENT '开始报关操作人ID', + started_by_name varchar(64) DEFAULT NULL COMMENT '开始报关操作人名称快照', + started_at datetime(3) DEFAULT NULL COMMENT '开始报关时间', + completed_by bigint unsigned DEFAULT NULL COMMENT '完成报关操作人ID', + completed_by_name varchar(64) DEFAULT NULL COMMENT '完成报关操作人名称快照', + completed_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 '聚合更新时间', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_customs_container_plan (loading_plan_id), + UNIQUE KEY uk_customs_container_booking (container_booking_id), + KEY idx_customs_container_status (status,updated_at), + CONSTRAINT chk_customs_container_status CHECK ((status in (_utf8mb4'WAITING_CUSTOMER_DOCUMENTS',_utf8mb4'READY_TO_START',_utf8mb4'DECLARING',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='装柜完成后的整柜出口报关聚合'; + +CREATE TABLE fl_customs_container_history ( + id bigint unsigned NOT NULL COMMENT '报关历史记录ID', + customs_container_id bigint unsigned NOT NULL COMMENT '所属整柜报关聚合ID', + action varchar(40) NOT NULL COMMENT '报关操作类型', + from_status varchar(40) DEFAULT NULL COMMENT '变更前报关状态', + to_status varchar(40) DEFAULT NULL COMMENT '变更后报关状态', + remark varchar(500) DEFAULT NULL COMMENT '操作备注', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史创建时间', + PRIMARY KEY (id), + KEY idx_customs_container_history (customs_container_id,created_at,id), + CONSTRAINT fk_customs_container_history FOREIGN KEY (customs_container_id) REFERENCES fl_customs_container (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='整柜报关状态与系统触发历史'; + +CREATE TABLE fl_customs_customer_document ( + id bigint unsigned NOT NULL COMMENT '客户报送资料ID', + customs_container_id bigint unsigned NOT NULL COMMENT '所属整柜报关聚合ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户隔离键;只允许关联同客户退税票', + file_id bigint unsigned NOT NULL COMMENT 'File服务文件ID', + file_name varchar(255) NOT NULL COMMENT '文件名称快照', + file_hash char(64) NOT NULL COMMENT '文件SHA-256摘要', + content_type varchar(128) DEFAULT NULL COMMENT '文件媒体类型', + size_bytes bigint unsigned NOT NULL COMMENT '文件大小(字节)', + status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '资料状态:ACTIVE有效、DELETED已删除', + uploaded_by bigint unsigned NOT NULL COMMENT '上传操作人ID', + uploaded_at datetime(3) NOT NULL COMMENT '上传时间', + frozen_version int unsigned DEFAULT NULL COMMENT '开始报关时冻结的资料版本', + frozen_at datetime(3) DEFAULT NULL COMMENT '资料冻结时间', + deleted_by bigint unsigned DEFAULT NULL COMMENT '删除操作人ID', + deleted_at datetime(3) DEFAULT NULL COMMENT '删除时间', + version int unsigned NOT NULL DEFAULT '1' 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_customs_customer_document_container (customs_container_id,status,tenant_id), + CONSTRAINT fk_customs_customer_document_container FOREIGN KEY (customs_container_id) REFERENCES fl_customs_container (id), + CONSTRAINT chk_customs_customer_document_freeze CHECK ((((frozen_at is null) and (frozen_version is null)) or ((frozen_at is not null) and (frozen_version is not null)))), + CONSTRAINT chk_customs_customer_document_status CHECK ((status in (_utf8mb4'ACTIVE',_utf8mb4'DELETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户整份退税报送资料逻辑根'; + +CREATE TABLE fl_customs_completion_document ( + id bigint unsigned NOT NULL COMMENT '报关完成文件ID', + source_document_id bigint unsigned NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根', + file_id bigint unsigned NOT NULL COMMENT 'File服务文件ID', + file_name varchar(255) NOT NULL COMMENT '完成文件名称快照', + file_hash char(64) NOT NULL COMMENT '完成文件SHA-256摘要', + content_type varchar(128) DEFAULT NULL COMMENT '完成文件媒体类型', + size_bytes bigint unsigned NOT NULL COMMENT '完成文件大小(字节)', + uploaded_by bigint unsigned NOT NULL COMMENT '上传操作人ID', + uploaded_by_name varchar(64) DEFAULT NULL COMMENT '上传操作人名称快照', + uploaded_at datetime(3) NOT NULL COMMENT '上传时间', + version int unsigned NOT NULL DEFAULT '1' 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_customs_completion_source (source_document_id), + CONSTRAINT fk_customs_completion_source FOREIGN KEY (source_document_id) REFERENCES fl_customs_customer_document (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关完成文件当前版本'; + +CREATE TABLE fl_customs_completion_document_version ( + id bigint unsigned NOT NULL COMMENT '完成文件版本记录ID', + completion_document_id bigint unsigned NOT NULL COMMENT '报关完成文件ID', + document_version int unsigned NOT NULL COMMENT '完成文件版本号', + file_id bigint unsigned NOT NULL COMMENT '本版本File服务文件ID', + file_name varchar(255) NOT NULL COMMENT '本版本文件名称快照', + file_hash char(64) NOT NULL COMMENT '本版本文件SHA-256摘要', + content_type varchar(128) DEFAULT NULL COMMENT '本版本文件媒体类型', + size_bytes bigint unsigned NOT NULL COMMENT '本版本文件大小(字节)', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_customs_completion_version (completion_document_id,document_version), + CONSTRAINT fk_customs_completion_version FOREIGN KEY (completion_document_id) REFERENCES fl_customs_completion_document (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关完成文件不可变覆盖版本'; + +CREATE TABLE fl_customs_customer_document_inbound ( + document_id bigint unsigned NOT NULL COMMENT '客户报送资料ID', + inbound_id bigint unsigned NOT NULL COMMENT '覆盖入仓单ID', + tenant_id bigint unsigned NOT NULL COMMENT '覆盖入仓单客户ID', + order_id bigint unsigned NOT NULL COMMENT '覆盖订单ID', + inbound_code varchar(40) NOT NULL COMMENT '覆盖入仓码快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关联创建时间', + PRIMARY KEY (document_id,inbound_id), + KEY idx_customs_document_inbound (inbound_id,tenant_id), + CONSTRAINT fk_customs_document_inbound_document FOREIGN KEY (document_id) REFERENCES fl_customs_customer_document (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户报送资料与当前柜退税入仓码多对多关系'; + +CREATE TABLE fl_customs_customer_document_version ( + id bigint unsigned NOT NULL COMMENT '资料版本记录ID', + document_id bigint unsigned NOT NULL COMMENT '客户报送资料ID', + document_version int unsigned NOT NULL COMMENT '资料版本号', + file_id bigint unsigned NOT NULL COMMENT '本版本File服务文件ID', + file_name varchar(255) NOT NULL COMMENT '本版本文件名称快照', + file_hash char(64) NOT NULL COMMENT '本版本文件SHA-256摘要', + content_type varchar(128) DEFAULT NULL COMMENT '本版本文件媒体类型', + size_bytes bigint unsigned NOT NULL COMMENT '本版本文件大小(字节)', + scope_snapshot_json json NOT NULL COMMENT '本版本覆盖入仓码范围快照', + action varchar(24) NOT NULL COMMENT '版本动作:UPLOAD上传、REPLACE替换、SCOPE_CHANGE范围变化、DELETE删除', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_customs_customer_document_version (document_id,document_version), + CONSTRAINT fk_customs_customer_document_version FOREIGN KEY (document_id) REFERENCES fl_customs_customer_document (id), + CONSTRAINT chk_customs_customer_document_version_action CHECK ((action in (_utf8mb4'UPLOAD',_utf8mb4'REPLACE',_utf8mb4'SCOPE_CHANGE',_utf8mb4'DELETE'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户报送资料文件与覆盖范围不可变版本'; + +CREATE TABLE fl_customs_frozen_document_inbound ( + document_id bigint unsigned NOT NULL COMMENT '冻结的客户报送资料ID', + inbound_id bigint unsigned NOT NULL COMMENT '冻结覆盖入仓单ID', + tenant_id bigint unsigned NOT NULL COMMENT '冻结覆盖客户ID', + order_id bigint unsigned NOT NULL COMMENT '冻结覆盖订单ID', + inbound_code varchar(40) NOT NULL COMMENT '冻结覆盖入仓码快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '冻结关联创建时间', + PRIMARY KEY (document_id,inbound_id), + CONSTRAINT fk_customs_frozen_document FOREIGN KEY (document_id) REFERENCES fl_customs_customer_document (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='开始报关时冻结的客户资料覆盖范围'; + +CREATE TABLE fl_document_sequence ( + sequence_key varchar(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如CAB:20260818', + current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_fl_document_sequence_value CHECK ((current_value > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号'; + +CREATE TABLE fl_fee_item ( + id bigint unsigned NOT NULL COMMENT '费用项目ID', + tenant_id bigint unsigned NOT NULL COMMENT '平台公共费用项目作用域,固定为0;物理字段名沿用 tenant_id', + category varchar(64) NOT NULL COMMENT '费用分类', + trade_scope varchar(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际', + fee_code varchar(50) NOT NULL COMMENT '稳定费用编码;系统费用使用固定编码', + fee_type_name varchar(128) NOT NULL COMMENT '费用类型名称', + supplier_category varchar(16) DEFAULT NULL COMMENT 'PAYABLE供应商类别约束;为空表示任意启用供应商', + pickup_payable tinyint(1) NOT NULL DEFAULT '0' COMMENT '提柜应付是否参与', + pickup_receivable tinyint(1) NOT NULL DEFAULT '0' COMMENT '提柜应收是否参与', + warehouse_payable tinyint(1) NOT NULL DEFAULT '0' COMMENT '仓储应付是否参与', + warehouse_receivable tinyint(1) NOT NULL DEFAULT '0' COMMENT '仓储应收是否参与', + allocation_fee tinyint(1) NOT NULL DEFAULT '0' COMMENT '平摊费用是否参与', + description varchar(500) DEFAULT NULL COMMENT '说明', + status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用', + created_by bigint DEFAULT NULL COMMENT '创建人ID;系统初始化时可为空', + created_by_name varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint DEFAULT NULL COMMENT '最后修改人ID;系统初始化时可为空', + updated_by_name varchar(64) DEFAULT NULL COMMENT '最后修改人名称快照', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + deleted tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0未删除、1已删除', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_fee_item_code (fee_code), + KEY idx_fee_item_query (tenant_id,trade_scope,category,status,deleted), + KEY idx_fee_item_name (tenant_id,fee_type_name,deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共费用项目主数据'; + +CREATE TABLE fl_finance_fee_source_command ( + id bigint unsigned NOT NULL COMMENT 'Finance费用来源命令ID', + customer_id bigint unsigned NOT NULL COMMENT '客户隔离键,由Firstleg费用事实再次校验', + operator_id bigint unsigned NOT NULL COMMENT 'Finance操作人ID', + operator_name varchar(64) NOT NULL COMMENT 'Finance操作人名称快照', + action varchar(24) NOT NULL COMMENT '命令动作;当前仅VOID', + idempotency_key varchar(128) NOT NULL COMMENT 'Finance透传的幂等键', + request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256', + source_type varchar(24) NOT NULL COMMENT 'INBOUND_FEE或CONTAINER_FEE', + source_fee_id bigint unsigned NOT NULL COMMENT '稳定费用来源ID', + source_version int unsigned NOT NULL COMMENT '精确费用来源版本', + inbound_id bigint unsigned DEFAULT NULL COMMENT '入库费用所属入库单ID', + container_id bigint unsigned DEFAULT NULL COMMENT '柜次费用所属柜次ID', + reason varchar(500) NOT NULL COMMENT '操作原因', + status varchar(16) NOT NULL COMMENT 'PROCESSING或SUCCEEDED', + result_status varchar(16) DEFAULT NULL COMMENT '成功业务结果;当前为VOIDED', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', + completed_at datetime(3) DEFAULT NULL COMMENT '命令成功完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_finance_fee_command_idem (customer_id,operator_id,action,idempotency_key), + KEY idx_finance_fee_command_source (source_type,source_fee_id,source_version,created_at), + CONSTRAINT chk_finance_fee_command_action CHECK ((action = _utf8mb4'VOID')), + CONSTRAINT chk_finance_fee_command_source CHECK ((source_type in (_utf8mb4'INBOUND_FEE',_utf8mb4'CONTAINER_FEE'))), + CONSTRAINT chk_finance_fee_command_status CHECK ((status in (_utf8mb4'PROCESSING',_utf8mb4'SUCCEEDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Finance发起、Firstleg执行业务费用命令的幂等审计记录'; + +CREATE TABLE fl_fulfillment_task ( + id bigint unsigned NOT NULL COMMENT '履约任务ID(雪花ID)', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + order_id bigint unsigned NOT NULL COMMENT '订单服务中的订单逻辑ID,不建立跨库外键', + order_no varchar(32) NOT NULL COMMENT '订单号快照', + order_snapshot_json json NOT NULL COMMENT '创建履约任务时所需的订单业务快照', + status varchar(24) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '履约状态:PENDING_ASSIGNMENT待指派、ASSIGNED已指派、PROCESSING处理中、COMPLETED已完成、CANCELLED已取消', + assignee_id bigint unsigned DEFAULT NULL COMMENT '当前负责人ID', + assignee_name varchar(64) DEFAULT NULL COMMENT '当前负责人名称快照', + assigned_at datetime(3) DEFAULT NULL COMMENT '最近指派时间', + started_at datetime(3) DEFAULT NULL COMMENT '开始履约时间', + completed_at datetime(3) DEFAULT NULL COMMENT '履约完成时间', + created_by bigint unsigned NOT NULL COMMENT '创建人或系统主体ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT 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_task_tenant_order (tenant_id,order_id), + KEY idx_task_tenant_created (tenant_id,created_at), + KEY idx_task_assignee_status (tenant_id,assignee_id,status,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程履约任务表:接收订单事件后形成独立履约任务,管理指派、执行和完成状态'; + +CREATE TABLE fl_assignment_history ( + id bigint unsigned NOT NULL COMMENT '指派历史ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + task_id bigint unsigned NOT NULL COMMENT '所属履约任务ID', + previous_assignee_id bigint unsigned DEFAULT NULL COMMENT '原负责人ID', + assignee_id bigint unsigned NOT NULL COMMENT '新负责人ID', + assignee_name varchar(64) NOT NULL COMMENT '新负责人名称快照', + reason varchar(500) DEFAULT NULL COMMENT '指派或改派原因', + operator_id bigint unsigned NOT NULL COMMENT '执行指派的操作人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '指派发生时间', + PRIMARY KEY (id), + KEY idx_assignment_task_time (tenant_id,task_id,created_at), + KEY fk_assignment_task (task_id), + CONSTRAINT fk_assignment_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程任务指派历史表:不可变记录每次负责人变更及原因,用于追踪和审计'; + +CREATE TABLE fl_inbound_idempotency ( + id bigint unsigned NOT NULL COMMENT '入库写操作幂等记录ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + operation varchar(40) NOT NULL COMMENT '操作类型', + operator_id bigint unsigned NOT NULL DEFAULT '0' COMMENT '操作者或外部应用ID', + inbound_id bigint unsigned NOT NULL DEFAULT '0' COMMENT '入库单ID,创建前可为0', + idempotency_key varchar(128) NOT NULL COMMENT '幂等键', + request_hash char(64) NOT NULL COMMENT '请求内容摘要', + status varchar(16) NOT NULL COMMENT '幂等处理状态:PROCESSING处理中、SUCCEEDED已成功、FAILED已失败', + result_json json DEFAULT NULL COMMENT '成功结果摘要', + result_resource_id bigint unsigned DEFAULT NULL COMMENT '成功结果资源ID', + completed_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_inbound_idempotency (tenant_id,operation,operator_id,idempotency_key), + KEY idx_inbound_idempotency_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方测量、审核、费用发送和入库写操作幂等记录'; + +CREATE TABLE fl_inbound_order ( + id bigint unsigned NOT NULL COMMENT '入库单ID(雪花ID)', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + task_id bigint unsigned DEFAULT NULL COMMENT '头程履约任务ID', + order_id bigint unsigned NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', + order_no varchar(32) NOT NULL COMMENT '订单号快照', + inbound_code varchar(40) NOT NULL COMMENT '全平台唯一入仓码', + inbound_role varchar(16) NOT NULL DEFAULT 'MASTER' COMMENT '入仓单层级:MASTER主单、CHILD子单', + parent_inbound_id bigint unsigned DEFAULT NULL COMMENT '直属主入仓单ID;主单为空', + root_inbound_id bigint unsigned DEFAULT NULL COMMENT '根主入仓单ID;存量主单可为空,按自身ID解释', + split_sequence int unsigned DEFAULT NULL COMMENT '同一主单下的子单顺序号', + split_status varchar(24) NOT NULL DEFAULT 'NOT_SPLIT' COMMENT '拆分状态:NOT_SPLIT、PARTIALLY_SPLIT、FULLY_SPLIT', + process_status varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜', + order_status_snapshot varchar(24) NOT NULL COMMENT '订单状态快照', + measure_review_status varchar(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回', + fee_status varchar(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废', + fee_review_status varchar(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回、CONFIRMED已确认', + notification_status varchar(24) NOT NULL DEFAULT 'NOT_CREATED' COMMENT '客户通知状态:NOT_CREATED未创建、PENDING待通知', + expected_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间', + customer_id bigint unsigned DEFAULT NULL COMMENT '客户逻辑ID', + customer_name varchar(128) NOT NULL COMMENT '客户名称快照', + company_id bigint unsigned DEFAULT NULL COMMENT '运营公司逻辑ID', + company_name varchar(128) DEFAULT NULL COMMENT '运营公司名称快照', + warehouse_id bigint unsigned NOT NULL COMMENT '实际作业仓库ID', + warehouse_name varchar(128) NOT NULL COMMENT '实际作业仓库名称快照', + delivery_warehouse_id bigint unsigned DEFAULT NULL COMMENT '交货仓逻辑ID', + delivery_warehouse_name varchar(128) DEFAULT NULL COMMENT '交货仓名称快照', + business_type varchar(32) NOT NULL COMMENT '业务类型', + fulfillment_type varchar(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', + lx_code varchar(128) DEFAULT NULL COMMENT '一件代发领星入库单代码', + channel_code varchar(64) DEFAULT NULL COMMENT '渠道编码', + channel_name varchar(128) DEFAULT NULL COMMENT '渠道名称快照', + carrier_id bigint unsigned DEFAULT NULL COMMENT '计费船司逻辑ID', + carrier_name varchar(128) DEFAULT NULL COMMENT '计费船司名称快照', + tax_mode varchar(24) DEFAULT NULL COMMENT '买单或退税模式', + pricing_tax_mode varchar(16) DEFAULT NULL COMMENT '报价包税维度:TAX_INCLUDED/TAX_EXCLUDED', + container_mode varchar(24) DEFAULT NULL COMMENT '整柜或散货模式', + customs_inspection tinyint(1) DEFAULT NULL COMMENT '是否商检', + destination_type varchar(24) DEFAULT NULL COMMENT '目的仓类型', + destination_warehouse_id bigint unsigned DEFAULT NULL COMMENT '目的仓逻辑ID', + destination_code varchar(32) DEFAULT NULL COMMENT '目的仓编码,例如FBA仓编码', + destination_name varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + hold_flag tinyint(1) NOT NULL DEFAULT '0' COMMENT '当前是否扣货', + current_hold_reason varchar(1000) DEFAULT NULL COMMENT '当前扣货理由快照', + last_overseas_hold_version bigint unsigned DEFAULT NULL COMMENT '海外暂扣事件最新聚合版本,用于阻止乱序覆盖', + total_package_count int unsigned DEFAULT NULL COMMENT '确认后的总箱数', + package_count int unsigned DEFAULT NULL COMMENT '入仓单箱数;三期唯一数量口径', + split_purpose varchar(16) NOT NULL DEFAULT 'MASTER' COMMENT '履约分片用途:MASTER、LOADED、REMAINDER', + actual_gross_weight_kg decimal(18,3) DEFAULT NULL COMMENT '确认后的实际总毛重kg', + chargeable_weight_kg decimal(18,3) DEFAULT NULL COMMENT '当前费用快照的计费重量kg', + actual_volume_cbm decimal(18,4) DEFAULT NULL COMMENT '确认后的实际总体积m3', + chargeable_volume_cbm decimal(18,4) DEFAULT NULL COMMENT '当前费用快照的计费体积m3', + pallet_count int unsigned DEFAULT NULL COMMENT '已分配总托盘数快照', + volume_weight_kg decimal(18,3) DEFAULT NULL COMMENT '总材积重kg', + actual_net_weight_kg decimal(18,3) DEFAULT NULL COMMENT '总净重kg', + measure_completed_at datetime(3) DEFAULT NULL COMMENT '测量完成时间', + inbounded_at datetime(3) DEFAULT NULL COMMENT '入库完成时间', + express_company_code varchar(64) DEFAULT NULL COMMENT '快递公司编码快照', + express_company_name varchar(128) DEFAULT NULL COMMENT '快递公司名称快照', + express_tracking_no varchar(128) DEFAULT NULL COMMENT '快递单号快照', + public_remark varchar(1000) DEFAULT NULL COMMENT '客户可见备注', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT 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 '乐观锁版本号', + master_order_id bigint unsigned GENERATED ALWAYS AS ((case when ((inbound_role = _utf8mb4'MASTER') and (deleted = 0)) then order_id else NULL end)) STORED COMMENT '仅用于约束有效主单订单唯一性', + active_remainder_root_id bigint unsigned GENERATED ALWAYS AS ((case when ((inbound_role = _utf8mb4'CHILD') and (split_purpose = _utf8mb4'REMAINDER') and (split_status = _utf8mb4'NOT_SPLIT') and (deleted = 0)) then root_inbound_id else NULL end)) STORED COMMENT '有效余量唯一约束键;历史已完成分片为空', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_code (inbound_code), + UNIQUE KEY uk_inbound_master_order (tenant_id,master_order_id), + UNIQUE KEY uk_inbound_one_active_remainder (tenant_id,active_remainder_root_id), + UNIQUE KEY uk_inbound_parent_split_sequence (tenant_id,parent_inbound_id,split_sequence), + KEY idx_inbound_process_time (tenant_id,process_status,created_at,id), + KEY idx_inbound_measure_time (tenant_id,measure_review_status,created_at,id), + KEY idx_inbound_fee_time (tenant_id,fee_review_status,created_at,id), + KEY idx_inbound_warehouse_status (tenant_id,warehouse_id,process_status,created_at), + KEY idx_inbound_customer_time (tenant_id,customer_id,created_at), + KEY idx_inbound_company_time (tenant_id,company_id,created_at), + KEY idx_inbound_arrival (tenant_id,expected_arrival_at), + KEY idx_inbound_destination (tenant_id,destination_code,created_at), + KEY idx_inbound_channel (tenant_id,channel_code,created_at), + KEY idx_inbound_smart_loading (warehouse_id,process_status,channel_code,expected_arrival_at,id), + KEY fk_inbound_task (task_id), + KEY idx_inbound_root_role (tenant_id,root_inbound_id,inbound_role,created_at,id), + KEY fk_inbound_parent_order (parent_inbound_id), + KEY fk_inbound_root_order (root_inbound_id), + CONSTRAINT fk_inbound_parent_order FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_root_order FOREIGN KEY (root_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='二期头程入库单聚合根和列表查询快照'; + +CREATE TABLE fl_dropship_pallet_task ( + id bigint unsigned NOT NULL COMMENT '合托任务ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '一件代发入仓单ID', + warehouse_id bigint unsigned NOT NULL COMMENT '作业仓库ID', + task_status varchar(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成', + total_cartons int unsigned NOT NULL COMMENT '测量确认总箱数', + counted_cartons int unsigned NOT NULL DEFAULT '0' COMMENT '已逐箱确认实收数量的箱数', + allocated_cartons int unsigned NOT NULL DEFAULT '0' COMMENT '已上托箱数', + discrepancy_cartons int unsigned NOT NULL DEFAULT '0' COMMENT '已确认差异箱数', + assignee_id bigint unsigned DEFAULT NULL COMMENT '任务领取人ID', + assignee_name varchar(64) DEFAULT NULL COMMENT '任务领取人姓名快照', + claimed_at datetime(3) DEFAULT NULL COMMENT '任务领取时间', + completed_at datetime(3) DEFAULT NULL COMMENT '任务完成时间', + version int NOT NULL DEFAULT '1' 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_dropship_task_inbound (tenant_id,inbound_id), + KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at), + KEY fk_dropship_task_inbound (inbound_id), + CONSTRAINT fk_dropship_task_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT chk_dropship_task_cartons CHECK (((allocated_cartons <= counted_cartons) and (counted_cartons <= total_cartons))), + CONSTRAINT chk_dropship_task_status CHECK ((task_status in (_utf8mb4'PENDING',_utf8mb4'CLAIMED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发测量后PDA合托任务'; + +CREATE TABLE fl_dropship_pallet ( + id bigint unsigned NOT NULL COMMENT '合托托盘ID', + task_id bigint unsigned NOT NULL COMMENT '所属合托任务ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '一件代发入仓单ID', + warehouse_id bigint unsigned NOT NULL COMMENT '作业仓库ID', + pallet_no varchar(32) NOT NULL COMMENT '现场扫描的系统DP托盘码', + pallet_status varchar(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托', + open_operation_id varchar(128) NOT NULL COMMENT '创建托盘幂等操作ID', + seal_operation_id varchar(128) DEFAULT NULL COMMENT '封托幂等操作ID', + created_by bigint unsigned NOT NULL COMMENT '托盘创建人ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '托盘创建人姓名快照', + completed_by bigint unsigned DEFAULT NULL COMMENT '封托操作人ID', + completed_by_name varchar(64) DEFAULT NULL COMMENT '封托操作人姓名快照', + completed_at datetime(3) DEFAULT NULL COMMENT '封托完成时间', + version int NOT NULL DEFAULT '1' 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_dropship_pallet_no (pallet_no), + UNIQUE KEY uk_dropship_pallet_open_op (open_operation_id), + UNIQUE KEY uk_dropship_pallet_seal_op (seal_operation_id), + KEY idx_dropship_pallet_inbound (tenant_id,inbound_id,pallet_status), + KEY fk_dropship_pallet_task (task_id), + CONSTRAINT fk_dropship_pallet_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task (id), + CONSTRAINT chk_dropship_pallet_status CHECK ((pallet_status in (_utf8mb4'OPEN',_utf8mb4'SEALED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发合托形成的可打印托盘'; + +CREATE TABLE fl_inbound_carton_label ( + id bigint unsigned NOT NULL COMMENT '实际箱唛头映射ID(雪花ID)', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + order_id bigint unsigned NOT NULL COMMENT '订单服务订单ID快照', + inbound_detail_id bigint unsigned NOT NULL COMMENT '订单货物明细ID,不建立跨库外键', + cargo_item_no int unsigned NOT NULL COMMENT '订单货物明细序号快照', + cargo_name varchar(255) DEFAULT NULL COMMENT '货物名称快照', + sku_code varchar(128) DEFAULT NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', + units_per_carton int unsigned DEFAULT NULL COMMENT '每箱SKU数量快照;一件代发必须为正数', + detail_package_count int unsigned NOT NULL COMMENT '该订单货物明细箱数快照', + inbound_code varchar(40) NOT NULL COMMENT '入仓码快照', + box_code varchar(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', + total_carton_count int unsigned NOT NULL COMMENT '首次分配时整单总箱数', + carton_index int unsigned NOT NULL COMMENT '整单从1开始的稳定箱下标', + label_version int unsigned NOT NULL DEFAULT '1' COMMENT '内部兼容批次字段,固定为1且不进入箱码', + batch_status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废', + cargo_fingerprint char(64) DEFAULT NULL COMMENT '影响标签字段的SHA-256指纹', + voided_at datetime(3) DEFAULT NULL COMMENT '批次作废时间', + box_specification varchar(255) NOT NULL COMMENT '订单箱规或货物明细描述快照', + length_cm decimal(12,3) DEFAULT NULL COMMENT '下单箱规长度cm快照', + width_cm decimal(12,3) DEFAULT NULL COMMENT '下单箱规宽度cm快照', + height_cm decimal(12,3) DEFAULT NULL COMMENT '下单箱规高度cm快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carton_label_box_code (tenant_id,box_code), + UNIQUE KEY uk_carton_label_index (tenant_id,inbound_id,label_version,carton_index), + KEY idx_carton_label_detail (tenant_id,inbound_id,inbound_detail_id), + KEY fk_carton_label_inbound (inbound_id), + KEY idx_carton_label_active (tenant_id,inbound_id,batch_status,label_version), + CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='首次打印前冻结的实际箱号与订单货物明细映射'; + +CREATE TABLE fl_dropship_pallet_carton ( + id bigint unsigned NOT NULL COMMENT '托盘箱明细ID', + task_id bigint unsigned NOT NULL COMMENT '所属合托任务ID', + pallet_id bigint unsigned DEFAULT NULL COMMENT '所属组托托盘ID;已清点待绑托时为空', + tenant_id bigint unsigned NOT NULL COMMENT '客户隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '一件代发入仓单ID', + inbound_detail_id bigint unsigned NOT NULL COMMENT '入仓货物明细ID', + carton_label_id bigint unsigned NOT NULL COMMENT '稳定箱唛记录ID', + sku_code varchar(128) NOT NULL COMMENT '箱唛冻结SKU编码', + cargo_name varchar(255) DEFAULT NULL COMMENT '货物名称快照', + box_code varchar(80) NOT NULL COMMENT '稳定箱码', + expected_sku_quantity int unsigned NOT NULL COMMENT '箱唛冻结的应收SKU数量', + actual_sku_quantity int unsigned NOT NULL COMMENT 'PDA确认的实收SKU数量', + discrepancy_status varchar(24) NOT NULL COMMENT '差异状态:MATCH或ACCEPTED_VARIANCE', + discrepancy_remark varchar(500) DEFAULT NULL COMMENT '差异接受说明', + count_operation_id varchar(128) NOT NULL COMMENT '逐箱清点幂等操作ID', + bind_operation_id varchar(128) DEFAULT NULL COMMENT '批量绑托幂等操作ID', + last_operation_id varchar(128) NOT NULL COMMENT '最后一次绑托或换托操作幂等号', + counted_by bigint unsigned NOT NULL COMMENT '清点操作人ID', + counted_by_name varchar(64) DEFAULT NULL COMMENT '清点操作人姓名快照', + counted_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清点时间', + bound_by bigint unsigned DEFAULT NULL COMMENT '上托操作人ID', + bound_by_name varchar(64) DEFAULT NULL COMMENT '上托操作人姓名快照', + bound_at datetime(3) DEFAULT NULL COMMENT '上托时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id), + UNIQUE KEY uk_dropship_carton_count_operation (count_operation_id), + KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id), + KEY idx_dropship_carton_inbound (tenant_id,inbound_id,pallet_id), + KEY idx_dropship_carton_bind_operation (task_id,bind_operation_id), + KEY fk_dropship_carton_label (carton_label_id), + CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label (id), + CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet (id), + CONSTRAINT fk_dropship_carton_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发托盘内逐箱SKU和入仓单明细'; + +CREATE TABLE fl_dropship_pallet_carton_sku ( + id bigint unsigned NOT NULL COMMENT '清点箱SKU明细ID', + carton_id bigint unsigned NOT NULL COMMENT '清点物理箱ID', + inbound_detail_id bigint unsigned NOT NULL COMMENT '入仓货物明细ID', + sku_code varchar(128) NOT NULL COMMENT 'SKU编码快照', + cargo_name varchar(255) DEFAULT NULL COMMENT '货物名称快照', + expected_quantity int unsigned NOT NULL COMMENT '应收数量', + actual_quantity int unsigned NOT NULL COMMENT '实收数量', + discrepancy_status varchar(24) NOT NULL COMMENT 'MATCH或ACCEPTED_VARIANCE', + discrepancy_remark varchar(500) DEFAULT NULL COMMENT '差异处理说明', + line_no int unsigned NOT NULL COMMENT '箱内SKU顺序', + PRIMARY KEY (id), + UNIQUE KEY uk_dropship_carton_sku_detail (carton_id,inbound_detail_id), + KEY idx_dropship_carton_sku_code (sku_code,carton_id), + CONSTRAINT fk_dropship_carton_sku_carton FOREIGN KEY (carton_id) REFERENCES fl_dropship_pallet_carton (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发物理箱内逐SKU清点事实'; + +CREATE TABLE fl_inbound_carton_label_sku ( + id bigint unsigned NOT NULL COMMENT '箱唛SKU快照ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + carton_label_id bigint unsigned NOT NULL COMMENT '物理箱唛ID', + inbound_detail_id bigint unsigned NOT NULL COMMENT '订单货物明细ID', + sku_code varchar(128) NOT NULL COMMENT 'SKU编码快照', + cargo_name varchar(255) DEFAULT NULL COMMENT '货物名称快照', + expected_quantity int unsigned NOT NULL COMMENT '该物理箱内应收SKU数量', + line_no int unsigned NOT NULL COMMENT '箱内SKU展示顺序', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carton_label_sku_detail (carton_label_id,inbound_detail_id), + KEY idx_carton_label_sku_code (tenant_id,inbound_id,sku_code), + CONSTRAINT fk_carton_label_sku_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='物理箱唛内的多SKU冻结快照'; + +CREATE TABLE fl_inbound_destination_snapshot ( + id bigint unsigned NOT NULL COMMENT '目的仓地址快照ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + destination_type varchar(24) DEFAULT NULL COMMENT '目的仓类型', + destination_code varchar(32) DEFAULT NULL COMMENT '目的仓编码', + destination_name varchar(128) DEFAULT NULL COMMENT '目的仓名称', + country_code char(2) DEFAULT NULL COMMENT '国家二字码', + state_province varchar(100) DEFAULT NULL COMMENT '省或州', + city varchar(100) DEFAULT NULL COMMENT '城市', + address_line varchar(512) DEFAULT NULL COMMENT '详细地址', + postal_code varchar(20) DEFAULT NULL COMMENT '邮编', + contact_name varchar(64) DEFAULT NULL COMMENT '联系人', + contact_phone varchar(32) DEFAULT NULL COMMENT '联系人电话', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_destination_inbound (tenant_id,inbound_id), + KEY fk_destination_inbound (inbound_id), + CONSTRAINT fk_destination_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单目的仓地址和联系人快照'; + +CREATE TABLE fl_inbound_fee ( + id bigint unsigned NOT NULL COMMENT '入库费用ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + measurement_id bigint unsigned DEFAULT NULL COMMENT '费用绑定的测量批次ID快照', + measurement_source varchar(32) DEFAULT NULL COMMENT '计费尺寸来源快照:OUTER为外箱规数据', + fee_no varchar(40) NOT NULL COMMENT '客户内唯一费用单号', + fee_version int unsigned NOT NULL DEFAULT '1' COMMENT '费用快照版本', + charge_rule_id bigint unsigned DEFAULT NULL COMMENT '历史客户计费规则ID;渠道价格可为空', + charge_rule_version int unsigned DEFAULT NULL COMMENT '历史客户规则版本快照', + carrier_rate_id bigint unsigned NOT NULL COMMENT '船司FBA价格ID', + carrier_rate_version int unsigned NOT NULL COMMENT '船司价格版本快照', + price_book_version_id bigint unsigned DEFAULT NULL COMMENT '整表价格版本ID快照', + rule_code varchar(50) DEFAULT NULL COMMENT '历史客户规则编码快照', + customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID快照', + customer_name varchar(128) NOT NULL COMMENT '客户名称快照', + business_type varchar(32) NOT NULL COMMENT '业务类型快照', + tax_mode varchar(24) DEFAULT NULL COMMENT '订单买单或退税模式快照', + pricing_tax_mode varchar(16) DEFAULT NULL COMMENT '报价包税维度快照', + rounding_scale tinyint unsigned DEFAULT NULL COMMENT '历史客户规则计费量保留位数快照', + rounding_mode varchar(24) DEFAULT NULL COMMENT '历史客户规则舍入方式快照', + rule_effective_from datetime(3) DEFAULT NULL COMMENT '历史客户规则生效开始快照', + rule_effective_to datetime(3) DEFAULT NULL COMMENT '客户规则生效结束快照', + rate_code varchar(50) NOT NULL COMMENT '船司价格编码快照', + origin_warehouse_id bigint unsigned NOT NULL COMMENT '起运仓ID快照', + origin_warehouse_name varchar(128) NOT NULL COMMENT '起运仓名称快照', + channel_code varchar(64) NOT NULL COMMENT '渠道编码快照', + carrier_id bigint unsigned DEFAULT NULL COMMENT '历史承运商ID;渠道价格可为空', + carrier_name varchar(128) DEFAULT NULL COMMENT '历史承运商名称;渠道价格可为空', + destination_type varchar(16) DEFAULT NULL COMMENT '目的地类型快照:FBA或OVERSEAS', + destination_warehouse_id bigint unsigned DEFAULT NULL COMMENT '海外仓或FBA目的仓ID快照', + destination_name varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + destination_code varchar(32) DEFAULT NULL COMMENT 'FBA仓编码快照;海外仓可为空', + rate_effective_from datetime(3) NOT NULL COMMENT '价格生效开始快照', + rate_effective_to datetime(3) DEFAULT NULL COMMENT '价格生效结束快照', + charge_mode varchar(16) DEFAULT NULL COMMENT '计费口径:KG或CBM', + conversion_factor decimal(18,6) DEFAULT NULL COMMENT '历史客户规则使用的换算系数快照', + actual_weight_kg decimal(18,3) NOT NULL COMMENT '试算时实际重量kg', + actual_volume_cbm decimal(18,4) NOT NULL COMMENT '试算时实际体积m3', + converted_quantity decimal(18,4) NOT NULL COMMENT '换算后的重量或体积', + baseline_chargeable_quantity decimal(18,6) NOT NULL COMMENT '系统按权威测量与生效报价计算的原始计费量', + tolerance_percent decimal(5,2) NOT NULL DEFAULT '10.00' COMMENT '人工调整允许的最大偏差百分比,边界包含', + chargeable_quantity decimal(18,4) NOT NULL COMMENT '最终计费量', + charge_unit varchar(16) NOT NULL COMMENT '计费单位', + currency char(3) NOT NULL COMMENT '币种', + subtotal_amount decimal(18,2) NOT NULL COMMENT '基础费用金额', + additional_amount decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '附加费金额', + total_amount decimal(18,2) NOT NULL COMMENT '总金额', + fee_status varchar(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态:CALCULATED已计算、CONFIRMED已确认、SENT已发送、SUPERSEDED已被新版本替代、VOIDED已作废', + review_status varchar(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回、CONFIRMED已确认', + exception_release tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否基于测量异常放行生成', + calculated_by bigint unsigned NOT NULL COMMENT '试算人ID', + calculated_at datetime(3) NOT NULL COMMENT '试算时间', + confirmed_by bigint unsigned DEFAULT NULL COMMENT '确认人ID', + confirmed_at datetime(3) DEFAULT NULL COMMENT '确认时间', + sent_by bigint unsigned DEFAULT NULL COMMENT '发送人ID', + sent_at datetime(3) DEFAULT NULL COMMENT '发送客户时间', + customer_confirmed_by bigint unsigned DEFAULT NULL COMMENT '客户确认人用户ID', + customer_confirmed_by_name varchar(64) DEFAULT NULL COMMENT '客户确认人名称快照', + customer_confirmed_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_inbound_fee_no (tenant_id,fee_no), + UNIQUE KEY uk_inbound_fee_version (tenant_id,inbound_id,fee_version), + KEY idx_inbound_fee_status (tenant_id,inbound_id,fee_status,review_status), + KEY fk_fee_inbound (inbound_id), + KEY fk_fee_charge_rule (charge_rule_id), + KEY fk_fee_carrier_rate (carrier_rate_id), + CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id), + CONSTRAINT fk_fee_charge_rule FOREIGN KEY (charge_rule_id) REFERENCES fl_customer_charge_rule (id), + CONSTRAINT fk_fee_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库头程费用计算和确认快照'; + +CREATE TABLE fl_inbound_fee_detail ( + id bigint unsigned NOT NULL COMMENT '入库费用明细ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID(客户隔离键);费用明细引用平台公共费用项目', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + fee_item_id bigint unsigned NOT NULL COMMENT '绑定费用项目ID', + source_type varchar(32) NOT NULL DEFAULT 'MANUAL' COMMENT 'MANUAL或FBA_PRICING', + source_fee_id bigint unsigned DEFAULT NULL COMMENT '来源计费快照ID', + source_fee_version int unsigned DEFAULT NULL COMMENT '来源计费快照版本', + source_fee_no varchar(40) DEFAULT NULL COMMENT '来源费用单号', + inbound_code varchar(40) NOT NULL COMMENT '入仓码快照', + direction varchar(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付', + supplier_id bigint unsigned DEFAULT NULL COMMENT '应付Warehouse供应商ID快照;应收为空', + supplier_name varchar(200) DEFAULT NULL COMMENT '应付Warehouse供应商名称快照;应收为空', + supplier_category varchar(16) DEFAULT NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + settlement_mode varchar(16) DEFAULT NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID', + paid_at datetime(3) DEFAULT NULL COMMENT 'DIRECT_PAID业务已付时间', + payment_method varchar(32) DEFAULT NULL COMMENT 'DIRECT_PAID付款方式', + payment_reference_no varchar(128) DEFAULT NULL COMMENT 'DIRECT_PAID付款流水号', + payment_voucher_file_ids json DEFAULT NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', + business_date date NOT NULL COMMENT '业务发生日', + customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID快照', + customer_name varchar(128) DEFAULT NULL COMMENT '客户公司名称快照', + invoice_title varchar(256) DEFAULT NULL COMMENT '发票抬头快照', + fee_category_snapshot varchar(64) NOT NULL COMMENT '费用分类快照', + trade_scope_snapshot varchar(20) NOT NULL COMMENT '国内国际快照', + fee_type_name_snapshot varchar(128) NOT NULL COMMENT '费用类型名称快照', + channel_code varchar(64) DEFAULT NULL COMMENT '渠道编码快照', + channel_name varchar(128) DEFAULT NULL COMMENT '渠道名称快照', + destination_code varchar(32) DEFAULT NULL COMMENT '目的仓编码快照', + destination_name varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + package_count int DEFAULT NULL COMMENT '件数快照', + actual_gross_weight_kg decimal(18,4) DEFAULT NULL COMMENT '仓库实测毛重快照,单位千克', + actual_volume_cbm decimal(18,6) DEFAULT NULL COMMENT '实际体积快照,单位立方米', + volume_weight_kg decimal(18,4) DEFAULT NULL COMMENT '材积重快照,单位千克', + chargeable_weight_kg decimal(18,4) DEFAULT NULL COMMENT '费用计算采用的计费重量,单位千克', + chargeable_volume_cbm decimal(18,6) DEFAULT NULL COMMENT '费用计算采用的计费体积,单位立方米', + chargeable_quantity decimal(18,4) DEFAULT NULL COMMENT '本费用最终计费量', + charge_unit varchar(16) DEFAULT NULL COMMENT 'KG或CBM', + unit_price decimal(18,4) NOT NULL COMMENT '费用单价;计价单位由对应费用项目规则确定', + currency char(3) NOT NULL COMMENT '金额币种三字码,如CNY', + amount decimal(18,2) NOT NULL COMMENT '该费用明细金额,币种由currency字段指定', + remark varchar(500) DEFAULT NULL COMMENT '备注', + review_status varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回', + reviewer_id bigint DEFAULT NULL COMMENT '审核人ID', + reviewer_name varchar(64) DEFAULT NULL COMMENT '审核人名称快照', + reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', + review_reason varchar(500) DEFAULT NULL COMMENT '审核驳回原因', + publication_status varchar(16) NOT NULL DEFAULT 'DRAFT' COMMENT '费用发布状态:DRAFT草稿、PUBLISHED已发布、SUPERSEDED已替代', + published_by bigint DEFAULT NULL COMMENT '发布人ID', + published_by_name varchar(64) DEFAULT NULL COMMENT '发布人名称快照', + published_at datetime(3) DEFAULT NULL COMMENT '发布时间', + inbounded_at datetime(3) DEFAULT NULL COMMENT '入库完成时间快照', + created_by bigint DEFAULT NULL COMMENT '创建人或系统主体ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint 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 '逻辑删除标记:0未删除、1已删除', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_fee_source (tenant_id,source_type,source_fee_id,source_fee_version), + KEY idx_inbound_fee_detail (tenant_id,inbound_id,business_date,deleted), + KEY idx_inbound_fee_review (tenant_id,review_status,business_date,deleted), + KEY idx_inbound_fee_item (tenant_id,fee_item_id,deleted), + KEY fk_inbound_fee_detail_order (inbound_id), + KEY fk_inbound_fee_detail_item (fee_item_id), + KEY idx_inbound_fee_published (tenant_id,inbound_id,publication_status,source_fee_version), + CONSTRAINT fk_inbound_fee_detail_item FOREIGN KEY (fee_item_id) REFERENCES fl_fee_item (id), + CONSTRAINT fk_inbound_fee_detail_order FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单应收应付费用明细'; + +CREATE TABLE fl_inbound_fee_item ( + id bigint unsigned NOT NULL COMMENT '费用明细ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + fee_id bigint unsigned NOT NULL COMMENT '费用头ID', + item_no int unsigned NOT NULL COMMENT '费用单内序号', + fee_code varchar(50) NOT NULL COMMENT '费用项目编码', + fee_name varchar(128) NOT NULL COMMENT '费用项目名称快照', + quantity decimal(18,4) NOT NULL COMMENT '计费数量', + unit varchar(16) NOT NULL COMMENT '计费单位', + unit_price decimal(18,6) NOT NULL COMMENT '单价', + amount decimal(18,2) NOT NULL COMMENT '金额', + currency char(3) NOT NULL COMMENT '币种', + automatic_flag tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否自动计算', + remark varchar(500) DEFAULT NULL COMMENT '费用备注', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fee_item_no (tenant_id,fee_id,item_no), + KEY fk_fee_item_header (fee_id), + CONSTRAINT fk_fee_item_header FOREIGN KEY (fee_id) REFERENCES fl_inbound_fee (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库头程费用项目明细'; + +CREATE TABLE fl_inbound_file ( + id bigint unsigned NOT NULL COMMENT '入库单文件关系ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + file_id bigint unsigned NOT NULL COMMENT '文件服务文件ID', + file_type varchar(24) NOT NULL COMMENT '文件类型:RELATED、MARK、PC_ORDER、MEASURE或WEIGHT', + file_name_snapshot varchar(255) NOT NULL COMMENT '文件名快照', + visibility varchar(16) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围', + file_version int unsigned NOT NULL DEFAULT '1' COMMENT '业务文件版本', + uploaded_by bigint unsigned NOT NULL COMMENT '上传人ID', + uploaded_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', + deleted tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_file_relation (tenant_id,inbound_id,file_id,file_type), + KEY idx_inbound_file_type (tenant_id,inbound_id,file_type,deleted), + KEY fk_file_inbound (inbound_id), + CONSTRAINT fk_file_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单与文件中心对象的分类关系'; + +CREATE TABLE fl_inbound_hold_record ( + id bigint unsigned NOT NULL COMMENT '扣货记录ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + hold_action varchar(16) NOT NULL COMMENT '动作:HOLD或RELEASE', + reason varchar(1000) NOT NULL COMMENT '扣货或解除原因', + evidence_file_id bigint unsigned DEFAULT NULL COMMENT '证据文件ID', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + operated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + KEY idx_hold_inbound_time (tenant_id,inbound_id,operated_at), + KEY fk_hold_inbound (inbound_id), + CONSTRAINT fk_hold_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单扣货和解除扣货不可变历史'; + +CREATE TABLE fl_inbound_internal_note ( + id bigint unsigned NOT NULL COMMENT '客服内部备注ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + content varchar(2000) NOT NULL COMMENT '仅内部可见备注', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + KEY idx_internal_note_time (tenant_id,inbound_id,created_at), + KEY fk_internal_note_inbound (inbound_id), + CONSTRAINT fk_internal_note_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单客服内部备注历史'; + +CREATE TABLE fl_inbound_loading_pick_task ( + id bigint unsigned NOT NULL COMMENT '入库单级PDA拣货任务ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '关联入库单逻辑ID', + status varchar(24) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:PENDING待拣货、IN_PROGRESS拣货中、COMPLETED已完成', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + expected_count int unsigned DEFAULT NULL COMMENT '计划应拣箱数', + picked_count int unsigned DEFAULT NULL COMMENT '实际已拣箱数', + remark varchar(1000) DEFAULT NULL COMMENT '拣货结果或异常补充说明', + updated_by bigint unsigned NOT NULL COMMENT '最后操作人ID', + updated_by_name varchar(64) DEFAULT NULL COMMENT '最后操作人姓名快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货任务创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货任务最后更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_tenant_inbound (tenant_id,inbound_id), + KEY idx_loading_pick_status (tenant_id,status,updated_at), + KEY fk_loading_pick_inbound (inbound_id), + CONSTRAINT fk_loading_pick_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA拣货任务及结果'; + +CREATE TABLE fl_inbound_outbox ( + id bigint unsigned NOT NULL COMMENT '领域事件全局ID', + tenant_id bigint unsigned NOT NULL COMMENT '事件所属客户ID和数据隔离键', + aggregate_type varchar(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型', + aggregate_id bigint unsigned NOT NULL COMMENT '入库单ID', + event_type varchar(64) NOT NULL COMMENT '事件类型', + event_key varchar(160) DEFAULT NULL COMMENT '费用来源事件幂等键;历史事件允许为空', + event_version int unsigned NOT NULL DEFAULT '1' COMMENT '事件信封版本', + aggregate_sequence int unsigned DEFAULT NULL COMMENT '同一费用来源的单调版本序号', + payload_hash varchar(71) DEFAULT NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀', + 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) DEFAULT NULL COMMENT '下次重试时间', + published_at datetime(3) DEFAULT NULL COMMENT '发布时间', + locked_at datetime(3) DEFAULT NULL COMMENT 'Relay抢占租约时间', + locked_by varchar(64) DEFAULT NULL COMMENT 'Relay抢占实例ID', + last_error varchar(500) DEFAULT NULL COMMENT '最近一次投递失败摘要,不记录令牌', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_outbox_event_key (event_key), + KEY idx_inbound_outbox_publish (publish_status,next_retry_at,created_at), + KEY idx_inbound_outbox_finance_relay (publish_status,next_retry_at,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库事务Outbox事件表'; + +CREATE TABLE fl_inbound_pallet_allocation ( + id bigint unsigned NOT NULL COMMENT '头程托盘分配引用ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + allocation_no int unsigned NOT NULL COMMENT '入库单内分配序号', + block_id bigint unsigned NOT NULL COMMENT 'Warehouse整票连续库位块ID', + slot_no int unsigned NOT NULL COMMENT '连续块内稳定槽位序号', + weight_result_id bigint unsigned DEFAULT NULL COMMENT '已绑定称重结果ID;预留空槽为空', + pallet_sequence int unsigned DEFAULT NULL COMMENT '已绑定称重托盘在整票内的稳定顺序;空槽为空', + warehouse_id bigint unsigned NOT NULL COMMENT '仓库服务仓库ID', + warehouse_resource_tenant_id bigint unsigned NOT NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键', + pallet_id bigint unsigned DEFAULT NULL COMMENT '仓库服务托盘ID;预留空槽为空', + pallet_code varchar(50) DEFAULT NULL COMMENT '托盘编码快照;预留空槽为空', + pallet_capacity_cbm decimal(18,4) DEFAULT NULL COMMENT '托盘可用容积快照;预留空槽为空', + allocated_volume_cbm decimal(18,4) NOT NULL COMMENT '本托盘分配体积', + allocated_package_count int unsigned DEFAULT NULL COMMENT '本托盘分配箱数', + picked_package_count int unsigned NOT NULL DEFAULT '0' COMMENT '已拣货箱数', + location_id bigint unsigned NOT NULL COMMENT '仓库服务库位ID', + location_code varchar(50) NOT NULL COMMENT '库位编码快照', + shared_overflow tinyint(1) NOT NULL DEFAULT '0' COMMENT '普通连续位不足时是否使用共享A0', + reservation_id bigint unsigned NOT NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分槽位', + slot_status varchar(24) NOT NULL DEFAULT 'RESERVED_EMPTY' COMMENT '槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED', + allocation_status varchar(24) NOT NULL DEFAULT 'RESERVED' COMMENT '托盘库位分配状态:RESERVED已预占、CONFIRMED已确认、TRANSFERRED已转移、RELEASED已释放', + 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_pallet_allocation_no (tenant_id,inbound_id,allocation_no), + UNIQUE KEY uk_pallet_allocation_slot (tenant_id,inbound_id,block_id,slot_no), + UNIQUE KEY uk_pallet_allocation_weight (tenant_id,inbound_id,weight_result_id), + KEY idx_allocation_location (tenant_id,warehouse_id,location_id,allocation_status), + KEY idx_allocation_reservation (reservation_id), + KEY fk_allocation_inbound (inbound_id), + CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程侧托盘、库位和仓库预占结果引用'; + +CREATE TABLE fl_inbound_pickup_snapshot ( + id bigint unsigned NOT NULL COMMENT '提货信息快照ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + pickup_method varchar(24) DEFAULT NULL COMMENT '提货方式', + pickup_at datetime(3) DEFAULT NULL COMMENT '提货时间', + contact_name varchar(64) DEFAULT NULL COMMENT '提货联系人', + contact_phone varchar(32) DEFAULT NULL COMMENT '提货联系人电话', + contact_email varchar(128) DEFAULT NULL COMMENT '提货联系人邮箱', + country_code char(2) DEFAULT NULL COMMENT '国家二字码', + province varchar(64) DEFAULT NULL COMMENT '省或州', + city varchar(64) DEFAULT NULL COMMENT '城市', + district varchar(64) DEFAULT NULL COMMENT '区县', + address_line varchar(512) DEFAULT NULL COMMENT '提货详细地址', + postal_code varchar(20) DEFAULT NULL COMMENT '提货邮编', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_pickup_inbound (tenant_id,inbound_id), + KEY fk_pickup_inbound (inbound_id), + CONSTRAINT fk_pickup_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单提货信息不可变快照'; + +CREATE TABLE fl_inbound_split_batch ( + id bigint unsigned NOT NULL COMMENT '拆分批次ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + parent_inbound_id bigint unsigned NOT NULL COMMENT '主入仓单ID', + source_inbound_id bigint unsigned DEFAULT NULL COMMENT '本批次实际消耗的MASTER或REMAINDER分片ID', + operation_id varchar(64) NOT NULL COMMENT '业务操作幂等号', + idempotency_key varchar(128) NOT NULL COMMENT '请求幂等键', + expected_parent_version int NOT NULL COMMENT '操作时主单乐观锁版本', + package_count int unsigned NOT NULL COMMENT '本批次拆分箱数', + loaded_child_inbound_id bigint unsigned DEFAULT NULL COMMENT '本批次实装子单ID', + remainder_child_inbound_id bigint unsigned DEFAULT NULL COMMENT '本批次余量子单ID', + loaded_package_count int unsigned NOT NULL DEFAULT '0' COMMENT '本批次实装箱数', + remaining_package_count int unsigned NOT NULL DEFAULT '0' COMMENT '本批次未装箱数', + loading_plan_id bigint unsigned DEFAULT NULL COMMENT '关联装柜计划/柜次', + container_booking_id bigint unsigned DEFAULT NULL COMMENT '关联柜次标识(兼容查询)', + status varchar(16) NOT NULL DEFAULT 'PREPARED' COMMENT '拆分批次状态:PREPARED已准备、APPLIED已生效、CANCELLED已取消', + remark varchar(500) DEFAULT NULL COMMENT '拆分原因或备注', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拆分批次创建时间', + applied_at datetime(3) DEFAULT NULL COMMENT '批次生效时间', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_operation (tenant_id,operation_id), + UNIQUE KEY uk_inbound_split_idempotency (tenant_id,parent_inbound_id,idempotency_key), + UNIQUE KEY uk_inbound_split_source_once (tenant_id,source_inbound_id), + KEY idx_inbound_split_parent (tenant_id,parent_inbound_id,created_at,id), + KEY fk_inbound_split_parent (parent_inbound_id), + KEY fk_inbound_split_loaded_child (loaded_child_inbound_id), + KEY fk_inbound_split_source (source_inbound_id), + KEY fk_inbound_split_remainder_child (remainder_child_inbound_id), + KEY idx_inbound_split_loading_plan (tenant_id,loading_plan_id,container_booking_id,status,id), + CONSTRAINT fk_inbound_split_loaded_child FOREIGN KEY (loaded_child_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_remainder_child FOREIGN KEY (remainder_child_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_source FOREIGN KEY (source_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT chk_inbound_split_package CHECK ((package_count > 0)), + CONSTRAINT chk_inbound_split_status CHECK ((status in (_utf8mb4'PREPARED',_utf8mb4'APPLIED',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='主入仓单拆分批次;数量口径仅为箱数'; + +CREATE TABLE fl_inbound_measurement ( + id bigint unsigned NOT NULL COMMENT '测量批次ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + split_batch_id bigint unsigned DEFAULT NULL COMMENT '产生该测量批次的入仓拆分批次ID', + source_measurement_id bigint unsigned DEFAULT NULL COMMENT '子单继承或复核时的来源测量批次ID', + source_type varchar(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL', + source_code varchar(64) DEFAULT NULL COMMENT '第三方应用或来源编码', + external_event_no varchar(128) DEFAULT NULL COMMENT '第三方事件号', + external_request_hash char(64) DEFAULT NULL COMMENT '第三方data原文和图片内容联合请求哈希', + device_code varchar(64) DEFAULT NULL COMMENT '测量设备编码', + warehouse_code varchar(50) DEFAULT NULL COMMENT '第三方回传仓库编码', + measured_at datetime(3) DEFAULT NULL COMMENT '设备测量时间', + status varchar(24) NOT NULL DEFAULT 'RECEIVED' COMMENT '测量批次状态:DRAFT草稿、RECEIVED已接收、SUBMITTED已提交、APPROVED已通过、REJECTED已驳回', + raw_payload_json json DEFAULT NULL COMMENT '第三方原始报文', + original_document_no varchar(64) DEFAULT NULL COMMENT '原单据号码快照', + original_gross_weight_kg decimal(18,3) DEFAULT NULL COMMENT '原单据毛重kg', + original_volume_cbm decimal(18,4) DEFAULT NULL COMMENT '原单据体积或材积m3', + actual_goods_name varchar(255) DEFAULT NULL COMMENT '实际品名', + total_package_count int unsigned DEFAULT NULL COMMENT '本批次总箱数', + total_volume_raw_cbm decimal(18,6) DEFAULT NULL COMMENT '本批次原始总体积m3', + total_volume_cbm decimal(18,4) DEFAULT NULL COMMENT '本批次业务总体积m3', + volume_weight_kg decimal(18,3) DEFAULT NULL COMMENT '本批次总材积重kg', + created_by bigint unsigned DEFAULT 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 '更新时间', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_measure_external_event (tenant_id,source_code,external_event_no), + KEY idx_measure_inbound_time (tenant_id,inbound_id,created_at), + KEY idx_measure_inbound_status_id (tenant_id,inbound_id,status,id), + KEY fk_measure_inbound (inbound_id), + KEY fk_measure_split_batch (split_batch_id), + KEY fk_measure_source_batch (source_measurement_id), + KEY idx_measure_split_source (tenant_id,split_batch_id,source_measurement_id,created_at), + CONSTRAINT fk_measure_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_measure_source_batch FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id), + CONSTRAINT fk_measure_split_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库长宽高测量批次和第三方原始数据'; + +CREATE TABLE fl_inbound_split_detail ( + id bigint unsigned NOT NULL COMMENT '部分锁定明细ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + parent_inbound_id bigint unsigned NOT NULL COMMENT '主入库单ID', + child_inbound_id bigint unsigned DEFAULT NULL COMMENT '完成拆单后生成的二级子入库单ID', + inbound_detail_id bigint unsigned NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键', + source_package_count int unsigned NOT NULL COMMENT '拆分前该明细总箱数快照', + locked_package_count int unsigned NOT NULL COMMENT '本次装柜锁定箱数', + remaining_package_count int unsigned GENERATED ALWAYS AS ((source_package_count - locked_package_count)) STORED COMMENT '锁定后剩余箱数,由来源箱数减锁定箱数生成', + status varchar(16) NOT NULL DEFAULT 'LOCKED' COMMENT '分柜明细状态:LOCKED已锁定、COMPLETED已完成拆分', + operation_id varchar(128) NOT NULL COMMENT '部分锁定操作幂等号', + created_by bigint unsigned NOT NULL COMMENT '创建部分锁定明细的操作人ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照', + completed_by bigint unsigned DEFAULT NULL COMMENT '完成拆分的操作人ID', + completed_by_name varchar(64) DEFAULT NULL COMMENT '完成操作人姓名快照', + completed_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 '部分锁定明细最后更新时间', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_parent_detail (tenant_id,parent_inbound_id,inbound_detail_id), + UNIQUE KEY uk_inbound_split_operation (operation_id), + UNIQUE KEY uk_inbound_split_child_detail (tenant_id,child_inbound_id,inbound_detail_id), + KEY idx_inbound_split_status (tenant_id,parent_inbound_id,status), + KEY fk_inbound_split_detail_parent (parent_inbound_id), + KEY fk_inbound_split_detail_child (child_inbound_id), + CONSTRAINT fk_inbound_split_detail_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_detail_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT chk_inbound_split_completion CHECK ((((status = _utf8mb4'LOCKED') and (child_inbound_id is null) and (completed_at is null)) or ((status = _utf8mb4'COMPLETED') and (child_inbound_id is not null) and (completed_at is not null)))), + CONSTRAINT chk_inbound_split_partial CHECK (((source_package_count > 1) and (locked_package_count > 0) and (locked_package_count < source_package_count))), + CONSTRAINT chk_legacy_inbound_split_status CHECK ((status in (_utf8mb4'LOCKED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='按箱数部分锁定及剩余箱数生成二级子入库单的不可变契约'; + +CREATE TABLE fl_inbound_split_history ( + id bigint unsigned NOT NULL COMMENT '拆分历史ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + split_batch_id bigint unsigned NOT NULL COMMENT '拆分批次ID', + operation_id varchar(64) NOT NULL COMMENT '历史操作幂等号', + action_type varchar(24) NOT NULL COMMENT '拆分动作:CREATE创建批次、APPLY生效、CANCEL取消', + before_snapshot json DEFAULT NULL COMMENT '操作前快照', + after_snapshot json NOT NULL COMMENT '操作后快照', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + operation_reason varchar(500) DEFAULT NULL COMMENT '操作原因', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变拆分历史创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_history_operation (tenant_id,operation_id), + KEY idx_inbound_split_history (tenant_id,split_batch_id,created_at,id), + KEY fk_inbound_split_history_batch (split_batch_id), + CONSTRAINT fk_inbound_split_history_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id), + CONSTRAINT chk_inbound_split_action CHECK ((action_type in (_utf8mb4'CREATE',_utf8mb4'APPLY',_utf8mb4'CANCEL'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='不可变主子入仓单拆分审计历史'; + +CREATE TABLE fl_inbound_status_history ( + id bigint unsigned NOT NULL COMMENT '入库状态历史ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + from_status varchar(24) DEFAULT NULL COMMENT '变更前状态', + to_status varchar(24) NOT NULL COMMENT '变更后状态', + action varchar(32) NOT NULL COMMENT '触发动作', + reason varchar(1000) DEFAULT NULL COMMENT '变更原因', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + occurred_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间', + PRIMARY KEY (id), + KEY idx_inbound_history_time (tenant_id,inbound_id,occurred_at), + KEY fk_history_inbound (inbound_id), + CONSTRAINT fk_history_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单流程状态不可变历史'; + +CREATE TABLE fl_inbound_transport_snapshot ( + id bigint unsigned NOT NULL COMMENT '运输信息快照ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + loading_status varchar(24) DEFAULT NULL COMMENT '配载状态快照', + container_no varchar(64) DEFAULT NULL COMMENT '集装箱号', + cabinet_no varchar(64) DEFAULT NULL COMMENT '柜号或内部柜单号', + vessel_name varchar(128) DEFAULT NULL COMMENT '船名', + voyage_no varchar(64) DEFAULT NULL COMMENT '航次', + sailing_at datetime(3) DEFAULT NULL COMMENT '开船时间', + port_arrival_at datetime(3) DEFAULT NULL COMMENT '到港时间', + source_updated_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_transport_inbound (tenant_id,inbound_id), + KEY idx_transport_container (tenant_id,container_no), + KEY idx_transport_cabinet (tenant_id,cabinet_no), + KEY idx_transport_loading (tenant_id,loading_status,sailing_at), + KEY fk_transport_inbound (inbound_id), + CONSTRAINT fk_transport_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单关联运输信息只读快照'; + +CREATE TABLE fl_inbound_weight_result ( + id bigint unsigned NOT NULL COMMENT 'OCR重量结果ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + file_id bigint unsigned NOT NULL COMMENT '称重图片文件ID', + pallet_id bigint unsigned NOT NULL COMMENT 'Warehouse托盘ID', + pallet_code varchar(50) NOT NULL COMMENT '托盘编码快照', + pallet_type varchar(32) NOT NULL COMMENT '托盘类型快照', + pallet_capacity_cbm decimal(18,4) NOT NULL COMMENT '托盘容量CBM快照', + pallet_tare_weight_kg decimal(18,3) NOT NULL COMMENT '托盘皮重KG快照', + ocr_provider varchar(64) DEFAULT NULL COMMENT 'OCR服务商编码', + ocr_raw_text varchar(1000) DEFAULT NULL COMMENT 'OCR原始文本', + ocr_weight_value decimal(18,3) DEFAULT NULL COMMENT 'OCR识别原始重量值', + ocr_weight_unit varchar(8) DEFAULT NULL COMMENT 'OCR识别原始单位', + confidence decimal(6,5) DEFAULT NULL COMMENT '识别置信度', + adjusted_weight_value decimal(18,3) DEFAULT NULL COMMENT '人工修正后的原单位重量', + adjusted_weight_unit varchar(8) DEFAULT NULL COMMENT '人工修正后的单位', + gross_weight_kg decimal(18,3) DEFAULT NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg decimal(18,3) DEFAULT NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', + recognition_status varchar(24) NOT NULL DEFAULT 'PENDING' COMMENT '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认', + adjustment_reason varchar(500) DEFAULT NULL COMMENT '人工修正原因', + adjusted_by bigint unsigned DEFAULT NULL COMMENT '修正人ID', + adjusted_at datetime(3) DEFAULT NULL COMMENT '修正时间', + confirmed_by bigint unsigned DEFAULT NULL COMMENT '确认人ID', + confirmed_at datetime(3) DEFAULT NULL COMMENT '确认时间', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT 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_weight_inbound_file (tenant_id,inbound_id,file_id), + KEY idx_weight_inbound_status (tenant_id,inbound_id,recognition_status), + KEY idx_weight_inbound_pallet (tenant_id,inbound_id,pallet_id), + KEY fk_weight_inbound (inbound_id), + CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='称重图片、托盘皮重快照、OCR毛重和确认净重'; + +CREATE TABLE fl_inbound_weight_revision ( + id bigint unsigned NOT NULL COMMENT 'OCR重量修订记录ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + weight_result_id bigint unsigned NOT NULL COMMENT 'OCR重量结果ID', + revision_no int unsigned NOT NULL COMMENT '结果版本号,与乐观锁版本一致', + action varchar(24) NOT NULL COMMENT '动作:CORRECT或CONFIRM', + before_weight_value decimal(18,3) DEFAULT NULL COMMENT '变更前原单位重量', + before_weight_unit varchar(8) DEFAULT NULL COMMENT '变更前单位', + after_weight_value decimal(18,3) DEFAULT NULL COMMENT '变更后原单位重量', + after_weight_unit varchar(8) DEFAULT NULL COMMENT '变更后单位', + gross_weight_kg decimal(18,3) DEFAULT NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg decimal(18,3) DEFAULT NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', + reason varchar(500) DEFAULT NULL COMMENT '人工修正原因', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + operated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + UNIQUE KEY uk_weight_revision_no (tenant_id,weight_result_id,revision_no), + KEY idx_weight_revision_time (tenant_id,inbound_id,operated_at), + KEY fk_weight_revision_inbound (inbound_id), + KEY fk_weight_revision_result (weight_result_id), + CONSTRAINT fk_weight_revision_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_weight_revision_result FOREIGN KEY (weight_result_id) REFERENCES fl_inbound_weight_result (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='OCR重量人工修正和确认的不可变版本历史'; + +CREATE TABLE fl_loading_operation ( + id bigint unsigned NOT NULL COMMENT '装柜写操作记录ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '操作目标入库单逻辑ID', + operator_id bigint unsigned NOT NULL COMMENT '执行写操作的平台用户ID', + operation varchar(48) NOT NULL COMMENT '装柜或证据写操作类型', + idempotency_key varchar(128) NOT NULL COMMENT '客户端幂等键,同一操作重试复用', + request_hash char(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要', + status varchar(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功、FAILED失败', + result_json json DEFAULT NULL COMMENT '成功结果或失败信息JSON快照,用于幂等重放', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作声明创建时间', + completed_at datetime(3) DEFAULT NULL COMMENT '操作完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_operation (tenant_id,inbound_id,operator_id,operation,idempotency_key), + KEY idx_loading_operation_time (tenant_id,inbound_id,created_at), + KEY fk_loading_operation_inbound (inbound_id), + CONSTRAINT fk_loading_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='装柜与派送写操作幂等记录'; + +CREATE TABLE fl_loading_plan ( + id bigint unsigned NOT NULL COMMENT '智能装柜计划ID(雪花ID)', + plan_no varchar(40) NOT NULL COMMENT '平台唯一装柜计划号', + container_booking_id bigint unsigned NOT NULL COMMENT '货柜预订ID', + plan_status varchar(24) NOT NULL DEFAULT 'EDITING' COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认', + preload_completed_by bigint unsigned DEFAULT NULL COMMENT '预装完成操作人ID', + preload_completed_by_name varchar(64) DEFAULT NULL COMMENT '预装完成操作人名称快照', + preload_completed_at datetime(3) DEFAULT NULL COMMENT '预装完成时间', + confirmed_by bigint unsigned DEFAULT NULL COMMENT '确认操作人ID', + confirmed_by_name varchar(64) DEFAULT NULL COMMENT '确认操作人名称快照', + confirmed_at datetime(3) DEFAULT NULL COMMENT '确认时间', + remark varchar(500) DEFAULT NULL COMMENT '平台装柜计划备注', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT NULL COMMENT '最后修改人ID', + updated_by_name varchar(64) DEFAULT NULL COMMENT '最后修改人名称快照', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜计划最后更新时间', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_plan_no (plan_no), + UNIQUE KEY uk_loading_plan_booking (container_booking_id), + KEY idx_loading_plan_status (plan_status,updated_at,id), + CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), + CONSTRAINT chk_loading_plan_status CHECK ((plan_status in (_utf8mb4'EDITING',_utf8mb4'LOADING',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台智能装柜计划与唯一预装状态源'; + +CREATE TABLE fl_coload_group ( + id bigint unsigned NOT NULL COMMENT '同柜不可拆组ID', + group_no varchar(40) NOT NULL COMMENT '稳定组号CLG-{id}', + group_status varchar(16) NOT NULL COMMENT '组状态:ACTIVE、LOCKED、CONSUMED、CANCELLED', + warehouse_id bigint unsigned NOT NULL COMMENT '国内收货仓ID快照', + channel_code varchar(64) NOT NULL COMMENT '物流渠道编码快照', + locked_plan_id bigint unsigned DEFAULT NULL COMMENT 'LOCKED/CONSUMED绑定装柜计划ID', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观版本', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_by_name varchar(64) 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 '更新时间', + locked_by bigint unsigned DEFAULT NULL COMMENT '锁定操作人ID', + locked_by_name varchar(64) DEFAULT NULL COMMENT '锁定操作人名称快照', + locked_at datetime(3) DEFAULT NULL COMMENT '锁定时间', + consumed_by bigint unsigned DEFAULT NULL COMMENT '消费操作人ID', + consumed_by_name varchar(64) DEFAULT NULL COMMENT '消费操作人名称快照', + consumed_at datetime(3) DEFAULT NULL COMMENT '消费完成时间', + cancelled_by bigint unsigned DEFAULT NULL COMMENT '取消操作人ID', + cancelled_by_name varchar(64) DEFAULT NULL COMMENT '取消操作人名称快照', + cancelled_at datetime(3) DEFAULT NULL COMMENT '取消时间', + PRIMARY KEY (id), + UNIQUE KEY uk_coload_group_no (group_no), + KEY idx_coload_group_status (group_status,updated_at,id), + KEY idx_coload_group_plan (locked_plan_id,group_status), + CONSTRAINT fk_coload_group_plan FOREIGN KEY (locked_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_coload_group_lock CHECK ((((group_status in (_utf8mb4'ACTIVE',_utf8mb4'CANCELLED')) and (locked_plan_id is null)) or ((group_status in (_utf8mb4'LOCKED',_utf8mb4'CONSUMED')) and (locked_plan_id is not null)))), + CONSTRAINT chk_coload_group_status CHECK ((group_status in (_utf8mb4'ACTIVE',_utf8mb4'LOCKED',_utf8mb4'CONSUMED',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='同柜且不可拆业务组聚合根'; + +CREATE TABLE fl_coload_command ( + id bigint unsigned NOT NULL COMMENT '幂等命令ID', + actor_id bigint unsigned NOT NULL COMMENT '平台或内部操作人ID', + action_type varchar(16) NOT NULL COMMENT '命令动作类型', + idempotency_key varchar(128) NOT NULL COMMENT '幂等键', + request_hash char(64) NOT NULL COMMENT '请求摘要', + command_status varchar(16) NOT NULL COMMENT '命令状态:PROCESSING、SUCCESS', + result_group_id bigint unsigned DEFAULT NULL COMMENT '命令结果组ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', + completed_at datetime(3) DEFAULT NULL COMMENT '命令完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_coload_command (actor_id,action_type,idempotency_key), + KEY fk_coload_command_group (result_group_id), + CONSTRAINT fk_coload_command_group FOREIGN KEY (result_group_id) REFERENCES fl_coload_group (id), + CONSTRAINT chk_coload_command_status CHECK ((command_status in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='同柜不可拆组幂等写命令'; + +CREATE TABLE fl_coload_group_history ( + id bigint unsigned NOT NULL COMMENT '状态历史ID', + group_id bigint unsigned NOT NULL COMMENT '同柜不可拆组ID', + action_type varchar(16) NOT NULL COMMENT '操作类型:CREATE、CANCEL、LOCK、UNLOCK、CONSUME', + from_status varchar(16) DEFAULT NULL COMMENT '变更前组状态', + to_status varchar(16) NOT NULL COMMENT '变更后组状态', + plan_id bigint unsigned DEFAULT NULL COMMENT '关联装柜计划ID', + operation_id varchar(128) NOT NULL COMMENT '稳定幂等操作ID', + remark varchar(500) DEFAULT NULL COMMENT '操作原因或备注', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录时间', + PRIMARY KEY (id), + UNIQUE KEY uk_coload_history_operation (group_id,action_type,operation_id), + KEY idx_coload_history_group (group_id,created_at,id), + CONSTRAINT fk_coload_history_group FOREIGN KEY (group_id) REFERENCES fl_coload_group (id), + CONSTRAINT chk_coload_history_action CHECK ((action_type in (_utf8mb4'CREATE',_utf8mb4'CANCEL',_utf8mb4'LOCK',_utf8mb4'UNLOCK',_utf8mb4'CONSUME'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='同柜不可拆组不可变状态历史'; + +CREATE TABLE fl_coload_group_member ( + id bigint unsigned NOT NULL COMMENT '组成员ID', + group_id bigint unsigned NOT NULL COMMENT '同柜不可拆组ID', + tenant_id bigint unsigned NOT NULL COMMENT '成员客户ID;允许跨客户成组', + order_id bigint unsigned NOT NULL COMMENT '订单ID', + inbound_id bigint unsigned NOT NULL COMMENT '创建时权威入库单ID', + full_package_count int unsigned NOT NULL COMMENT '必须整组分配的满箱数快照', + active_flag tinyint unsigned DEFAULT '1' COMMENT '当前约束唯一标识;取消或消费后置NULL', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '成员创建时间', + closed_at datetime(3) DEFAULT NULL COMMENT '成员约束关闭时间', + PRIMARY KEY (id), + UNIQUE KEY uk_coload_group_member (group_id,inbound_id), + UNIQUE KEY uk_coload_active_inbound (tenant_id,inbound_id,active_flag), + KEY idx_coload_member_order (order_id,created_at,group_id), + KEY fk_coload_member_inbound (inbound_id), + CONSTRAINT fk_coload_member_group FOREIGN KEY (group_id) REFERENCES fl_coload_group (id), + CONSTRAINT fk_coload_member_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT chk_coload_member_active CHECK (((active_flag is null) or (active_flag = 1))), + CONSTRAINT chk_coload_member_packages CHECK ((full_package_count > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='同柜不可拆组成员与满箱数快照'; + +CREATE TABLE fl_loading_pick_task ( + id bigint unsigned NOT NULL COMMENT 'PDA拣货任务ID', + loading_plan_id bigint unsigned NOT NULL COMMENT '装柜计划ID', + container_booking_id bigint unsigned NOT NULL COMMENT '稳定柜次ID', + cabinet_no varchar(64) NOT NULL COMMENT '稳定内部柜次号快照', + container_no varchar(64) DEFAULT NULL COMMENT '废弃兼容字段;不得作为任务身份', + task_status varchar(24) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING、COMPLETING、COMPLETION_FAILED、COMPLETED', + started_by bigint unsigned NOT NULL COMMENT '开始装柜人', + started_by_name varchar(64) DEFAULT NULL COMMENT '开始装柜人名称', + started_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间', + completed_by bigint unsigned DEFAULT NULL COMMENT '完成装柜人', + completed_by_name varchar(64) DEFAULT NULL COMMENT '完成装柜人名称', + completed_at datetime(3) DEFAULT NULL COMMENT '完成时间', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id), + UNIQUE KEY uk_loading_pick_task_booking (container_booking_id), + KEY idx_loading_pick_container (container_no,task_status,started_at), + CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_pick_task_status CHECK ((task_status in (_utf8mb4'LOADING',_utf8mb4'COMPLETING',_utf8mb4'COMPLETION_FAILED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA装柜拣货任务'; + +CREATE TABLE fl_loading_cabinet_evidence ( + id bigint unsigned NOT NULL COMMENT '柜次证据记录ID', + container_booking_id bigint unsigned NOT NULL COMMENT '证据唯一归属柜次ID', + source_task_id bigint unsigned NOT NULL COMMENT '上传来源PDA任务ID,仅用于审计', + evidence_type varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'PHOTO_STEP_1至PHOTO_STEP_4或SEAL', + file_id bigint unsigned NOT NULL COMMENT 'File服务文件ID', + file_name varchar(255) NOT NULL COMMENT '文件名称快照', + seal_no varchar(64) DEFAULT NULL COMMENT '带锁码照对应锁码', + remark varchar(1000) DEFAULT NULL COMMENT '证据备注', + status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废', + active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,保证每柜每类型仅一条有效事实', + operation_key varchar(128) NOT NULL COMMENT '任务范围内幂等操作键', + request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要', + void_reason varchar(500) DEFAULT NULL COMMENT '作废原因', + voided_by bigint unsigned DEFAULT NULL COMMENT '作废操作人ID', + voided_by_name varchar(128) DEFAULT NULL COMMENT '作废操作人名称快照', + voided_at datetime(3) DEFAULT NULL COMMENT '作废时间', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + created_by bigint unsigned NOT NULL COMMENT '创建操作人ID', + created_by_name varchar(128) DEFAULT NULL COMMENT '创建操作人名称快照', + updated_by bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + updated_by_name varchar(128) 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_cabinet_evidence_operation (source_task_id,operation_key), + UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag), + KEY idx_cabinet_evidence_container (container_booking_id,status), + KEY idx_cabinet_evidence_seal (seal_no,active_flag), + CONSTRAINT fk_cabinet_evidence_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), + CONSTRAINT fk_cabinet_evidence_task FOREIGN KEY (source_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT chk_cabinet_evidence_status CHECK ((status in (_utf8mb4'ACTIVE',_utf8mb4'VOID'))), + CONSTRAINT chk_cabinet_evidence_type CHECK ((evidence_type in (_utf8mb4'PHOTO_STEP_1',_utf8mb4'PHOTO_STEP_2',_utf8mb4'PHOTO_STEP_3',_utf8mb4'PHOTO_STEP_4',_utf8mb4'SEAL'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次物理装柜证据;任务仅记录来源审计'; + +CREATE TABLE fl_loading_completion_command ( + id bigint unsigned NOT NULL COMMENT '可靠装柜完成命令ID', + pick_task_id bigint unsigned NOT NULL COMMENT 'PDA任务ID', + operation_id varchar(128) NOT NULL COMMENT '客户端全局幂等操作ID', + request_hash char(64) NOT NULL COMMENT '规范化请求摘要', + command_status varchar(16) NOT NULL COMMENT '命令状态:PROCESSING处理中、RETRYABLE可重试、FAILED失败、COMPLETED已完成', + expected_task_version int unsigned NOT NULL COMMENT '受理时客户端任务版本', + remark varchar(500) DEFAULT NULL COMMENT '完成备注', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + attempt_count int unsigned NOT NULL DEFAULT '0' COMMENT 'worker认领次数', + next_retry_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次处理时间', + lease_owner varchar(128) DEFAULT NULL COMMENT 'worker租约持有者', + lease_until datetime(3) DEFAULT NULL COMMENT 'worker租约截止时间', + failure_stage varchar(40) DEFAULT NULL COMMENT '脱敏失败阶段', + failure_code varchar(32) DEFAULT NULL COMMENT '下游或业务错误码', + failure_request_id varchar(64) DEFAULT NULL COMMENT '下游请求ID', + failure_trace_id varchar(64) DEFAULT 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 '命令更新时间', + completed_at datetime(3) DEFAULT NULL COMMENT '命令完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_completion_operation (operation_id), + KEY idx_loading_completion_dispatch (command_status,next_retry_at,lease_until), + KEY fk_loading_completion_task (pick_task_id), + CONSTRAINT fk_loading_completion_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT chk_loading_completion_status CHECK ((command_status in (_utf8mb4'PROCESSING',_utf8mb4'RETRYABLE',_utf8mb4'FAILED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA装柜完成可靠异步命令'; + +CREATE TABLE fl_loading_completion_checkpoint ( + command_id bigint unsigned NOT NULL COMMENT '可靠装柜完成命令ID', + tenant_id bigint unsigned NOT NULL COMMENT '入仓单客户ID', + inbound_id bigint unsigned NOT NULL COMMENT '入仓单ID', + owner_sequence int unsigned NOT NULL COMMENT '稳定处理顺序', + checkpoint_stage varchar(24) NOT NULL COMMENT '处理阶段:READY待处理、SETTLED资源已结算、INBOUND_COMPLETED入仓已完成', + 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 (command_id,tenant_id,inbound_id), + KEY idx_loading_completion_owner (command_id,owner_sequence), + CONSTRAINT fk_loading_completion_checkpoint_command FOREIGN KEY (command_id) REFERENCES fl_loading_completion_command (id), + CONSTRAINT chk_loading_completion_checkpoint CHECK ((checkpoint_stage in (_utf8mb4'READY',_utf8mb4'SETTLED',_utf8mb4'INBOUND_COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA装柜完成按入库单恢复检查点'; + +CREATE TABLE fl_loading_pick_assignment ( + pick_task_id bigint unsigned NOT NULL COMMENT '柜次PDA拣货任务ID;同一任务仅保留一名作业员', + assignee_id bigint unsigned NOT NULL COMMENT '当前唯一作业员ID', + assignee_name varchar(64) NOT NULL COMMENT '当前作业员姓名快照', + assigned_by bigint unsigned NOT NULL COMMENT '领取任务的作业员或执行分配的平台用户ID', + assigned_by_name varchar(64) DEFAULT NULL COMMENT '领取或分配操作人姓名快照', + assignment_type varchar(16) NOT NULL COMMENT '分配方式:CLAIMED作业员领取、ASSIGNED平台指派', + version int unsigned 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 (pick_task_id), + KEY idx_loading_pick_assignee (assignee_id,updated_at), + CONSTRAINT fk_loading_pick_assignment_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT chk_loading_pick_assignment_type CHECK ((assignment_type in (_utf8mb4'CLAIMED',_utf8mb4'ASSIGNED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次PDA任务领取与作业员分配'; + +CREATE TABLE fl_loading_pick_operation ( + id bigint unsigned NOT NULL COMMENT 'PDA任务操作审计记录ID', + pick_task_id bigint unsigned NOT NULL COMMENT '关联柜次PDA拣货任务ID', + operation_id varchar(128) NOT NULL COMMENT '全局唯一操作ID,用于幂等重放', + request_hash char(64) NOT NULL COMMENT '完整批量请求或任务操作内容SHA-256摘要', + action_type varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '动作类型:CLAIM领取、ASSIGN分配、REVIEW复核、BATCH批量提交、COMPLETE_LOADING人工确认装柜完成', + operator_id bigint unsigned NOT NULL COMMENT '执行PDA任务操作的用户ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人姓名快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变操作审计创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_operation_id (operation_id), + KEY idx_loading_pick_operation_task (pick_task_id,created_at), + CONSTRAINT fk_loading_pick_operation_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT chk_loading_pick_operation_action CHECK ((action_type in (_utf8mb4'CLAIM',_utf8mb4'ASSIGN',_utf8mb4'ITEM_CLAIM',_utf8mb4'ITEM_ASSIGN',_utf8mb4'REVIEW',_utf8mb4'BATCH',_utf8mb4'PICK_PALLET',_utf8mb4'COMPLETE_LOADING'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次PDA任务级幂等审计'; + +CREATE TABLE fl_loading_pick_review ( + pick_task_id bigint unsigned NOT NULL COMMENT '已完成的柜次PDA拣货任务ID', + review_status varchar(16) NOT NULL DEFAULT 'APPROVED' COMMENT '复核状态:APPROVED已通过;记录创建后不可修改或删除', + reviewed_by bigint unsigned NOT NULL COMMENT '执行完成复核的平台用户ID', + reviewed_by_name varchar(64) DEFAULT NULL COMMENT '复核人姓名快照', + remark varchar(500) DEFAULT NULL COMMENT 'PDA任务复核备注', + version int unsigned 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 (pick_task_id), + KEY idx_loading_pick_reviewer (reviewed_by,created_at), + CONSTRAINT fk_loading_pick_review_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT chk_loading_pick_review_status CHECK ((review_status = _utf8mb4'APPROVED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次PDA任务完成复核'; + +CREATE TABLE fl_loading_plan_item ( + id bigint unsigned NOT NULL COMMENT '装柜计划明细ID(雪花ID)', + loading_plan_id bigint unsigned NOT NULL COMMENT '智能装柜计划ID', + account_id bigint unsigned NOT NULL COMMENT '客户账号ID;装柜业务唯一客户归属字段', + customer_name varchar(128) NOT NULL COMMENT '客户名称快照', + inbound_id bigint unsigned NOT NULL COMMENT 'Firstleg入库单ID', + inbound_detail_id bigint unsigned NOT NULL COMMENT '订单货物或入库货物明细ID,不建立跨库外键', + order_id bigint unsigned NOT NULL COMMENT '订单服务订单ID快照,不建立跨库外键', + order_no varchar(32) NOT NULL COMMENT '订单号快照', + inbound_code varchar(40) NOT NULL COMMENT '入仓码快照', + destination_warehouse_id bigint unsigned DEFAULT NULL COMMENT '目的仓ID快照', + destination_warehouse_code varchar(64) DEFAULT NULL COMMENT '目的仓编码快照', + destination_warehouse_name varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + sku_code varchar(128) DEFAULT NULL COMMENT 'SKU编码快照', + product_name_zh varchar(255) DEFAULT NULL COMMENT '中文品名快照', + product_name_en varchar(255) DEFAULT NULL COMMENT '英文品名快照', + product_image_file_id bigint unsigned DEFAULT NULL COMMENT '产品图片File对象ID', + product_image_url varchar(1000) DEFAULT NULL COMMENT '产品图片访问地址快照', + package_count int unsigned NOT NULL COMMENT '本次装柜箱数', + product_quantity int unsigned DEFAULT NULL COMMENT '产品总数量', + unit_type varchar(24) NOT NULL DEFAULT 'PACKAGE' COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托', + pallet_id bigint unsigned DEFAULT NULL COMMENT '一件代发封托ID;普通箱为空', + pallet_no varchar(32) DEFAULT NULL COMMENT '一件代发系统托盘码快照', + indivisible tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否为不可拆整托单元', + units_per_carton int unsigned DEFAULT NULL COMMENT '每箱产品数', + pallet_count int unsigned DEFAULT NULL COMMENT '托盘数', + gross_weight_kg decimal(18,3) DEFAULT NULL COMMENT '本次装柜毛重kg', + net_weight_kg decimal(18,3) DEFAULT NULL COMMENT '本次装柜净重kg', + dimensional_weight_kg decimal(18,3) DEFAULT NULL COMMENT '本次装柜材积重kg', + volume_cbm decimal(18,4) DEFAULT NULL COMMENT '本次装柜体积m3', + fba_tracking_no varchar(128) DEFAULT NULL COMMENT 'FBA追踪号快照', + shipment_no varchar(128) DEFAULT NULL COMMENT '货件编号快照', + shipping_mark varchar(500) DEFAULT NULL COMMENT '唛头快照', + load_sequence int unsigned NOT NULL DEFAULT '0' COMMENT '装柜顺序', + position_snapshot json DEFAULT NULL COMMENT '智能装柜位置与摆放结果快照', + internal_remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注', + customer_remark varchar(1000) DEFAULT NULL COMMENT '客户备注快照', + warehouse_remark varchar(1000) DEFAULT NULL COMMENT '仓库备注快照', + customer_service_remark varchar(1000) DEFAULT NULL COMMENT '客服备注快照', + frozen_at datetime(3) DEFAULT NULL COMMENT '装柜完成后的永久冻结时间', + frozen_by bigint unsigned DEFAULT NULL COMMENT '冻结操作人ID', + removed_by bigint unsigned DEFAULT NULL COMMENT '推送前逻辑删除操作人ID', + removed_by_name varchar(64) DEFAULT NULL COMMENT '推送前逻辑删除操作人名称快照', + removed_at datetime(3) DEFAULT NULL COMMENT '推送前逻辑删除时间', + deleted tinyint(1) NOT NULL DEFAULT '0' COMMENT '推送前逻辑删除标记;冻结后不可修改', + active_inbound_detail_id bigint unsigned GENERATED ALWAYS AS ((case when (deleted = 0) then inbound_detail_id else NULL end)) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格', + active_assignment_key varchar(96) GENERATED ALWAYS AS ((case when (deleted = 0) then concat((case when (pallet_id is null) then _utf8mb4'D:0' else concat(_utf8mb4'P:',pallet_id) end),_utf8mb4':',inbound_detail_id) else NULL end)) STORED COMMENT '普通明细或已封托托盘明细的有效唯一锁', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT NULL COMMENT '最后修改人ID', + updated_by_name varchar(64) DEFAULT NULL COMMENT '最后修改人名称快照', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜明细最后更新时间', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key), + KEY idx_loading_item_active_detail (account_id,active_inbound_detail_id), + KEY idx_loading_item_plan (loading_plan_id,deleted,load_sequence,id), + KEY idx_loading_item_account (account_id,created_at,id), + KEY idx_loading_item_inbound (account_id,inbound_id,deleted), + KEY idx_loading_item_candidate (account_id,inbound_id,inbound_detail_id,deleted), + KEY idx_loading_item_pallet (pallet_id,deleted), + KEY idx_loading_item_order (account_id,order_id,deleted), + CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_item_account CHECK ((account_id > 0)), + CONSTRAINT chk_loading_item_deleted CHECK ((deleted in (0,1))), + CONSTRAINT chk_loading_item_package CHECK ((package_count > 0)), + CONSTRAINT chk_loading_item_pallet CHECK (((pallet_count is null) or (pallet_count >= 0))), + CONSTRAINT chk_loading_item_product_quantity CHECK (((product_quantity is null) or (product_quantity > 0))), + CONSTRAINT chk_loading_item_units CHECK (((units_per_carton is null) or (units_per_carton > 0))), + CONSTRAINT chk_loading_item_volume CHECK (((volume_cbm is null) or (volume_cbm >= 0))), + CONSTRAINT chk_loading_item_weight CHECK ((((gross_weight_kg is null) or (gross_weight_kg >= 0)) and ((net_weight_kg is null) or (net_weight_kg >= 0)) and ((dimensional_weight_kg is null) or (dimensional_weight_kg >= 0)))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='智能装柜客户货物明细;客户归属仅在明细层'; + +CREATE TABLE fl_loading_pallet_label ( + id bigint unsigned NOT NULL COMMENT '散货托盘标签ID(雪花ID)', + loading_plan_id bigint unsigned NOT NULL COMMENT '装柜计划ID', + loading_plan_item_id bigint unsigned NOT NULL COMMENT '标签归属装柜明细ID', + account_id bigint unsigned NOT NULL COMMENT '客户ID快照', + inbound_id bigint unsigned NOT NULL COMMENT '入仓单ID快照', + inbound_code varchar(40) NOT NULL COMMENT '入仓码快照', + container_no varchar(64) NOT NULL COMMENT '真实集装箱号快照', + destination_warehouse_code varchar(64) NOT NULL COMMENT 'FBA仓编码快照', + destination_warehouse_name varchar(128) DEFAULT NULL COMMENT 'FBA仓名称快照', + pallet_index int unsigned NOT NULL COMMENT '当前柜当前FBA仓内从1开始的序号', + pallet_code varchar(128) NOT NULL COMMENT '托盘码:集装箱号-FBA仓编码-三位序号', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pallet_code (pallet_code), + UNIQUE KEY uk_loading_pallet_index (loading_plan_id,destination_warehouse_code,pallet_index), + KEY idx_loading_pallet_item (loading_plan_item_id,pallet_index), + KEY idx_loading_pallet_inbound (account_id,inbound_id), + CONSTRAINT fk_loading_pallet_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), + CONSTRAINT fk_loading_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_pallet_index CHECK ((pallet_index between 1 and 999)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='散货按柜号和FBA仓冻结的可打印托盘码'; + +CREATE TABLE fl_loading_pick_item ( + id bigint unsigned NOT NULL COMMENT 'PDA拣货明细ID', + pick_task_id bigint unsigned NOT NULL COMMENT 'PDA拣货任务ID', + loading_plan_item_id bigint unsigned NOT NULL COMMENT '装柜计划明细ID', + inbound_id bigint unsigned NOT NULL COMMENT '父入库单ID', + inbound_detail_id bigint unsigned NOT NULL COMMENT '订单货物明细ID', + planned_package_count int unsigned NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整', + picked_package_count int unsigned NOT NULL DEFAULT '0' COMMENT '已拣货箱数', + item_status varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '拣货明细状态:PENDING待拣、PICKING拣货中、PICKED已拣完、PARTIAL部分拣货、SKIPPED跳拣', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细最后更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id), + KEY idx_loading_pick_item_task (pick_task_id,item_status,id), + CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), + CONSTRAINT fk_loading_pick_item_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT chk_loading_pick_item_count CHECK ((picked_package_count <= planned_package_count)), + CONSTRAINT chk_loading_pick_item_planned CHECK ((planned_package_count > 0)), + CONSTRAINT chk_loading_pick_item_status CHECK ((item_status in (_utf8mb4'PENDING',_utf8mb4'PICKING',_utf8mb4'PICKED',_utf8mb4'PARTIAL',_utf8mb4'SKIPPED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA装柜拣货明细'; + +CREATE TABLE fl_loading_pick_history ( + id bigint unsigned NOT NULL COMMENT 'PDA拣货操作历史ID', + pick_task_id bigint unsigned NOT NULL COMMENT 'PDA拣货任务ID', + pick_item_id bigint unsigned NOT NULL COMMENT 'PDA拣货明细ID', + operation_id varchar(128) NOT NULL COMMENT '客户端幂等操作号', + request_hash char(64) NOT NULL COMMENT '完整批量请求内容SHA-256摘要', + action_type varchar(24) NOT NULL DEFAULT 'PICK' COMMENT '拣货动作:PICK正常拣货、PARTIAL部分拣货、SKIP跳拣', + picked_package_count int unsigned NOT NULL COMMENT '本次拣货箱数', + operation_reason varchar(500) DEFAULT NULL COMMENT '部分拣货或跳拣原因', + operator_id bigint unsigned NOT NULL COMMENT '执行PDA拣货操作的作业员ID', + operator_name varchar(64) DEFAULT NULL COMMENT '作业员姓名快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变拣货操作历史创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_history_operation (operation_id), + KEY idx_loading_pick_history_item (pick_item_id,created_at), + KEY fk_loading_pick_history_task (pick_task_id), + CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id), + CONSTRAINT fk_loading_pick_history_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA拣货幂等和操作审计历史'; + +CREATE TABLE fl_loading_pick_item_assignment ( + pick_item_id bigint unsigned NOT NULL COMMENT 'PDA拣货明细ID,同时作为本表主键', + assignee_id bigint unsigned NOT NULL COMMENT '当前领取或被分配的作业员ID', + assignee_name varchar(64) NOT NULL COMMENT '当前作业员姓名快照', + assigned_by bigint unsigned NOT NULL COMMENT '执行领取或分配操作的用户ID', + assigned_by_name varchar(64) DEFAULT NULL COMMENT '执行领取或分配操作的用户姓名快照', + assignment_type varchar(16) NOT NULL COMMENT '分配类型:CLAIMED作业员领取、ASSIGNED管理员分配', + version int unsigned 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 (pick_item_id), + KEY idx_loading_pick_item_assignee (assignee_id,updated_at), + CONSTRAINT fk_loading_pick_item_assignment_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id), + CONSTRAINT chk_loading_pick_item_assignment_type CHECK ((assignment_type in (_utf8mb4'CLAIMED',_utf8mb4'ASSIGNED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA拣货明细领取与作业员分配'; + +CREATE TABLE fl_loading_plan_history ( + id bigint unsigned NOT NULL COMMENT '装柜操作历史ID(雪花ID)', + loading_plan_id bigint unsigned NOT NULL COMMENT '智能装柜计划ID', + loading_plan_item_id bigint unsigned DEFAULT NULL COMMENT '装柜计划明细ID', + operation_id varchar(128) NOT NULL COMMENT '操作幂等ID', + action_type varchar(32) NOT NULL COMMENT '动作类型:CREATE_PLAN创建计划、ADD_ITEM新增明细、ADJUST_ITEM调整明细、REMOVE_ITEM移除明细、START_LOADING开始装柜、COMPLETE_LOADING完成装柜、PICK_ITEM拣货', + from_status varchar(24) DEFAULT NULL COMMENT '变更前状态', + to_status varchar(24) DEFAULT NULL COMMENT '变更后状态', + before_snapshot json DEFAULT NULL COMMENT '变更前快照', + after_snapshot json DEFAULT NULL COMMENT '变更后快照', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + operation_reason varchar(500) DEFAULT NULL COMMENT '操作原因', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_history_operation (operation_id), + KEY idx_loading_history_plan (loading_plan_id,created_at,id), + KEY idx_loading_history_item (loading_plan_item_id,created_at,id), + CONSTRAINT fk_loading_history_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), + CONSTRAINT fk_loading_history_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='智能装柜追加式不可变操作历史'; + +CREATE TABLE fl_measure_review_record ( + id bigint unsigned NOT NULL COMMENT '测量审核记录ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + measurement_id bigint unsigned DEFAULT NULL COMMENT '被审核的测量批次ID', + review_result varchar(24) NOT NULL COMMENT '审核结果', + review_reason varchar(1000) DEFAULT NULL COMMENT '审核或驳回原因', + exception_release tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否异常放行', + release_reason varchar(1000) DEFAULT NULL COMMENT '异常放行原因', + allow_rejected_config tinyint(1) NOT NULL DEFAULT '0' COMMENT '操作时Nacos异常放行开关值', + reviewer_id bigint unsigned NOT NULL COMMENT '审核人ID', + reviewer_name varchar(64) DEFAULT NULL COMMENT '审核人名称快照', + reviewed_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审核时间', + PRIMARY KEY (id), + KEY idx_review_inbound_time (tenant_id,inbound_id,reviewed_at), + KEY idx_review_exception_release (tenant_id,inbound_id,exception_release,reviewed_at), + KEY fk_review_inbound (inbound_id), + KEY fk_review_measurement (measurement_id), + CONSTRAINT fk_review_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_review_measurement FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='测量审核、驳回和异常放行不可变记录'; + +CREATE TABLE fl_mojia_measurement_receipt ( + id bigint unsigned NOT NULL COMMENT '墨家联调收件记录ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + subject_id bigint unsigned NOT NULL COMMENT '设备应用操作主体ID', + box_code varchar(128) NOT NULL COMMENT '设备扫描的原始箱码', + scanned_shipment_id varchar(64) DEFAULT NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', + length_cm decimal(12,3) NOT NULL COMMENT '设备实测长度cm', + width_cm decimal(12,3) NOT NULL COMMENT '设备实测宽度cm', + height_cm decimal(12,3) NOT NULL COMMENT '设备实测高度cm', + weight_kg decimal(18,3) DEFAULT NULL COMMENT '设备实测重量kg', + machine_no varchar(64) NOT NULL COMMENT '设备编号', + measured_at datetime(3) NOT NULL COMMENT '设备测量时间或服务接收时间', + image_file_id bigint unsigned NOT NULL COMMENT 'File服务中的墨家联调图片ID', + status varchar(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '测量回执匹配状态:UNMATCHED未匹配;当前首版仅记录未匹配回执', + match_failure_code varchar(16) NOT NULL COMMENT '正式业务匹配失败码', + match_failure_reason varchar(500) NOT NULL COMMENT '脱敏后的匹配失败原因', + request_id varchar(128) DEFAULT NULL COMMENT '请求链路ID', + trace_id varchar(128) DEFAULT 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), + KEY idx_mojia_receipt_status_time (tenant_id,status,created_at), + KEY idx_mojia_receipt_box_time (tenant_id,box_code,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='墨家模拟或未匹配入库单的原始测量收件箱'; + +CREATE TABLE fl_ocean_booking ( + id bigint unsigned NOT NULL COMMENT '海运订舱ID', + booking_no varchar(40) NOT NULL COMMENT '平台海运订舱编号', + status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读', + customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID和数据隔离键', + customer_name varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户名称快照', + channel_code varchar(64) DEFAULT NULL COMMENT '订舱计划物流渠道组编码快照', + channel_name varchar(128) DEFAULT NULL COMMENT '订舱计划物流渠道组名称快照', + warehouse_id bigint unsigned DEFAULT NULL COMMENT '国内中转仓ID快照', + warehouse_name varchar(128) DEFAULT NULL COMMENT '国内中转仓名称快照', + bill_of_lading_no varchar(128) DEFAULT NULL COMMENT '提单号', + shipping_company_id bigint unsigned DEFAULT NULL COMMENT '船公司逻辑ID快照', + shipping_company_name varchar(128) DEFAULT NULL COMMENT '船公司名称快照', + carrier_code varchar(16) DEFAULT NULL COMMENT '船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填', + booking_agent_id bigint unsigned DEFAULT NULL COMMENT '订舱代理逻辑ID快照', + booking_agent_name varchar(128) DEFAULT NULL COMMENT '订舱代理名称快照', + vessel_name varchar(128) DEFAULT NULL COMMENT '当前确认船名快照', + voyage_no varchar(64) DEFAULT NULL COMMENT '当前确认航次号快照', + departure_port_code varchar(32) DEFAULT NULL COMMENT '起运港编码快照', + departure_port_name varchar(128) DEFAULT NULL COMMENT '起运港名称快照', + destination_port_code varchar(32) DEFAULT NULL COMMENT '目的港编码快照', + destination_port_name varchar(128) DEFAULT NULL COMMENT '目的港名称快照', + place_of_delivery varchar(128) DEFAULT NULL COMMENT '最终交货地快照', + estimated_sailing_at datetime(3) DEFAULT NULL COMMENT '当前确认预计开船时间', + estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '当前确认预计到港时间', + remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注', + created_by bigint unsigned NOT NULL COMMENT '创建操作人ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间', + updated_by bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + updated_by_name varchar(64) DEFAULT NULL COMMENT '最后修改操作人姓名快照', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '订舱记录最后更新时间', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_ocean_booking_no (booking_no), + KEY idx_ocean_booking_customer (customer_id,status,updated_at,id), + KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at), + CONSTRAINT chk_ocean_booking_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))), + CONSTRAINT chk_ocean_booking_customer CHECK ((customer_id > 0)), + CONSTRAINT chk_ocean_booking_schedule CHECK (((estimated_sailing_at is null) or (estimated_arrival_at is null) or (estimated_arrival_at >= estimated_sailing_at))), + CONSTRAINT chk_ocean_booking_status CHECK ((status in (_utf8mb4'DRAFT',_utf8mb4'RELEASED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; + +CREATE TABLE fl_ocean_booking_container_requirement ( + id bigint unsigned NOT NULL COMMENT '订舱箱型需求ID', + ocean_booking_id bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + container_type varchar(32) NOT NULL COMMENT '所需集装箱尺寸类型编码', + container_kind varchar(32) NOT NULL COMMENT '所需集装箱箱种编码', + capacity_cbm decimal(18,4) NOT NULL COMMENT '该需求柜型可用容量快照m3', + max_weight_kg decimal(18,3) NOT NULL COMMENT '该需求柜型最大承重快照kg', + quantity int unsigned NOT NULL COMMENT '该箱型箱种所需柜数', + sort_no int unsigned NOT NULL DEFAULT '0' COMMENT '订舱需求展示顺序', + PRIMARY KEY (id), + KEY idx_ocean_requirement_booking (ocean_booking_id,sort_no,id), + CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE, + CONSTRAINT chk_ocean_requirement_quantity CHECK ((quantity > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订舱箱型箱种及数量需求'; + +CREATE TABLE fl_ocean_booking_container_allocation ( + id bigint unsigned NOT NULL COMMENT '订舱柜次分配关系ID', + ocean_booking_id bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + requirement_id bigint unsigned NOT NULL COMMENT '对应订舱箱型需求ID', + container_booking_id bigint unsigned NOT NULL COMMENT '明确引用现有柜次ID', + allocated_by bigint unsigned NOT NULL COMMENT '分配操作人ID', + allocated_by_name varchar(64) DEFAULT NULL COMMENT '分配操作人姓名快照', + allocated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '柜次分配时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ocean_allocation_container (container_booking_id), + KEY idx_ocean_allocation_booking (ocean_booking_id,requirement_id,allocated_at), + KEY fk_ocean_allocation_requirement (requirement_id), + CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE, + CONSTRAINT fk_ocean_allocation_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), + CONSTRAINT fk_ocean_allocation_requirement FOREIGN KEY (requirement_id) REFERENCES fl_ocean_booking_container_requirement (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订舱需求与现有柜次的唯一分配关系'; + +CREATE TABLE fl_ocean_booking_document ( + id bigint unsigned NOT NULL COMMENT '订舱文件关联ID', + ocean_booking_id bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + file_id bigint unsigned NOT NULL COMMENT 'File服务文件逻辑ID', + document_type varchar(32) NOT NULL COMMENT '文件类型:BOOKING_CONFIRMATION订舱确认、BILL_OF_LADING提单', + file_name varchar(255) NOT NULL COMMENT 'File返回的原始文件名快照', + file_hash char(64) DEFAULT NULL COMMENT 'File返回的SHA-256快照', + size_bytes bigint unsigned DEFAULT NULL COMMENT 'File返回的文件大小快照', + content_type varchar(128) DEFAULT NULL COMMENT 'File返回的内容类型快照', + valid tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否为有效文件关联', + uploaded_by bigint unsigned NOT NULL COMMENT '上传或关联操作人ID', + uploaded_by_name varchar(64) DEFAULT NULL COMMENT '上传或关联操作人姓名快照', + uploaded_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传或关联时间', + invalidated_by bigint unsigned DEFAULT NULL COMMENT '作废操作人ID', + invalidated_by_name varchar(64) DEFAULT NULL COMMENT '作废操作人姓名快照', + invalidated_at datetime(3) DEFAULT NULL COMMENT '作废时间', + PRIMARY KEY (id), + KEY idx_ocean_document_booking (ocean_booking_id,valid,uploaded_at,id), + KEY idx_ocean_document_file (file_id), + CONSTRAINT fk_ocean_document_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE, + CONSTRAINT chk_ocean_document_type CHECK ((document_type in (_utf8mb4'BOOKING_CONFIRMATION',_utf8mb4'BILL_OF_LADING'))), + CONSTRAINT chk_ocean_document_valid CHECK ((valid in (0,1))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订舱聚合拥有的订舱确认与提单文件关联'; + +CREATE TABLE fl_ocean_booking_operation ( + id bigint unsigned NOT NULL COMMENT '订舱写操作幂等记录ID', + ocean_booking_id bigint unsigned DEFAULT NULL COMMENT '关联海运订舱ID;创建请求完成前允许为空', + operator_id bigint unsigned NOT NULL COMMENT '发起写操作的平台操作人ID', + operation varchar(40) NOT NULL COMMENT '幂等写操作类型', + idempotency_key varchar(128) NOT NULL COMMENT '客户端写操作幂等键', + request_hash char(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求', + status varchar(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功', + result_json json DEFAULT NULL COMMENT '成功结果JSON快照;供幂等重放返回', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', + completed_at datetime(3) DEFAULT NULL COMMENT '写操作成功完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key), + KEY idx_ocean_operation_booking (ocean_booking_id,created_at), + CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE, + CONSTRAINT chk_ocean_operation_status CHECK ((status in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='海运订舱写操作幂等与请求哈希'; + +CREATE TABLE fl_ocean_booking_party ( + id bigint unsigned NOT NULL COMMENT '订舱提单关系方ID', + ocean_booking_id bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + party_type varchar(24) NOT NULL COMMENT '关系方类型:SHIPPER发货人、CONSIGNEE收货人、NOTIFY_PARTY通知人', + party_name varchar(255) NOT NULL COMMENT '关系方名称快照', + address varchar(1000) DEFAULT NULL COMMENT '关系方地址快照', + contact_name varchar(128) DEFAULT NULL COMMENT '关系方联系人快照', + contact_phone varchar(64) DEFAULT NULL COMMENT '关系方联系电话快照', + PRIMARY KEY (id), + UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type), + CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE, + CONSTRAINT chk_ocean_booking_party_type CHECK ((party_type in (_utf8mb4'SHIPPER',_utf8mb4'CONSIGNEE',_utf8mb4'NOTIFY_PARTY'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订舱提单关系方快照'; + +CREATE TABLE fl_ocean_booking_status_history ( + id bigint unsigned NOT NULL COMMENT '订舱状态历史ID', + ocean_booking_id bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + from_status varchar(24) DEFAULT NULL COMMENT '变更前订舱状态;创建时为空', + to_status varchar(24) NOT NULL COMMENT '变更后订舱状态', + action varchar(40) NOT NULL COMMENT '触发状态变更的业务动作', + reason varchar(1000) DEFAULT NULL COMMENT '状态变更原因', + before_snapshot json DEFAULT NULL COMMENT '状态变更前订舱数据快照;创建时为空', + after_snapshot json NOT NULL COMMENT '状态变更后订舱数据快照', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人姓名快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间', + PRIMARY KEY (id), + KEY idx_ocean_history_booking (ocean_booking_id,created_at,id), + CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='海运订舱不可变状态历史'; + +CREATE TABLE fl_order_declaration_event ( + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键', + event_id varchar(128) NOT NULL COMMENT '永久保留的Order Outbox事件ID', + order_id bigint unsigned NOT NULL COMMENT 'Order订单ID', + order_version int NOT NULL COMMENT '事件携带的Order聚合版本', + request_hash char(64) NOT NULL COMMENT '完整事件SHA-256,用于异参重放拒绝', + consumed_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件消费完成时间', + PRIMARY KEY (tenant_id,event_id), + KEY idx_order_declaration_event_order (tenant_id,order_id,order_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Order客户申报事件永久幂等账本'; + +CREATE TABLE fl_order_declaration_snapshot ( + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键', + order_id bigint unsigned NOT NULL COMMENT 'Order订单ID', + event_id varchar(128) NOT NULL COMMENT 'Order Outbox事件ID', + order_version int NOT NULL COMMENT 'Order聚合版本,严格单调消费', + request_hash char(64) NOT NULL COMMENT '完整事件SHA-256', + customer_remark varchar(1000) DEFAULT NULL COMMENT '客户备注快照', + expected_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到仓时间快照', + channel_code varchar(64) NOT NULL COMMENT '渠道编码快照', + channel_name varchar(128) NOT NULL COMMENT '渠道名称快照', + cargoes_json json NOT NULL COMMENT '客户申报货物行完整快照,不覆盖实测事实', + changes_json json DEFAULT NULL COMMENT '本版本申报字段变化摘要', + occurred_at datetime(3) NOT NULL COMMENT 'Order事件发生时间', + 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 (tenant_id,order_id), + UNIQUE KEY uk_order_declaration_event (tenant_id,event_id), + KEY idx_order_declaration_version (tenant_id,order_id,order_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Order客户申报版本快照;与箱码、实测、托盘、PDA和计费事实隔离'; + +CREATE TABLE fl_outbound_dispatch ( + id bigint unsigned NOT NULL COMMENT '国内出仓调度记录ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + order_id bigint unsigned NOT NULL COMMENT 'Order服务订单逻辑ID,不复制订单主数据', + inbound_id bigint unsigned NOT NULL COMMENT '关联入库单逻辑ID', + container_booking_id bigint unsigned NOT NULL COMMENT '关联柜次逻辑ID', + status varchar(32) NOT NULL DEFAULT 'READY_FOR_DISPATCH' COMMENT '出仓状态:READY_FOR_DISPATCH待确认出仓、WAITING_FOR_LOADING等待装柜', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + confirmed_by bigint unsigned DEFAULT NULL COMMENT '确认国内出仓的操作人ID', + confirmed_by_name varchar(64) DEFAULT NULL COMMENT '确认国内出仓的操作人姓名快照', + confirmed_at datetime(3) DEFAULT NULL COMMENT '国内出仓确认时间', + reversed_by bigint unsigned DEFAULT NULL COMMENT '撤销国内出仓的操作人ID', + reversed_by_name varchar(64) DEFAULT NULL COMMENT '撤销国内出仓的操作人姓名快照', + reversed_at datetime(3) DEFAULT NULL COMMENT '国内出仓撤销时间', + remark varchar(500) 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_outbound_dispatch_owner (tenant_id,inbound_id,container_booking_id), + KEY idx_outbound_dispatch_order (tenant_id,order_id,updated_at), + KEY idx_outbound_dispatch_container (container_booking_id,status,updated_at), + KEY fk_outbound_dispatch_inbound (inbound_id), + CONSTRAINT fk_outbound_dispatch_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), + CONSTRAINT fk_outbound_dispatch_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT chk_outbound_dispatch_status CHECK ((status in (_utf8mb4'READY_FOR_DISPATCH',_utf8mb4'WAITING_FOR_LOADING'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='三期国内出仓派车状态,不重复创建出仓单'; + +CREATE TABLE fl_outbound_dispatch_history ( + id bigint unsigned NOT NULL COMMENT '出仓状态历史记录ID', + dispatch_id bigint unsigned NOT NULL COMMENT '关联国内出仓调度记录ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '关联入库单逻辑ID快照', + container_booking_id bigint unsigned NOT NULL COMMENT '关联柜次逻辑ID快照', + action varchar(32) NOT NULL COMMENT '出仓状态变更动作', + from_status varchar(32) DEFAULT NULL COMMENT '变更前出仓状态;首次创建时为空', + to_status varchar(32) NOT NULL COMMENT '变更后出仓状态', + remark varchar(500) DEFAULT NULL COMMENT '状态变更原因或备注', + operator_id bigint unsigned NOT NULL COMMENT '执行状态变更的平台用户ID', + operator_name varchar(64) DEFAULT NULL COMMENT '执行状态变更的平台用户姓名快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间', + PRIMARY KEY (id), + KEY idx_outbound_dispatch_history (tenant_id,inbound_id,container_booking_id,created_at,id), + KEY fk_outbound_dispatch_history (dispatch_id), + CONSTRAINT fk_outbound_dispatch_history FOREIGN KEY (dispatch_id) REFERENCES fl_outbound_dispatch (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内出仓追加式审计历史'; + +CREATE TABLE fl_outbound_dispatch_operation ( + id bigint unsigned NOT NULL COMMENT '出仓幂等操作记录ID', + dispatch_id bigint unsigned DEFAULT NULL COMMENT '首次声明幂等键时允许为空', + operator_id bigint unsigned NOT NULL COMMENT '执行写操作的平台用户ID', + operation varchar(32) NOT NULL COMMENT '国内出仓写操作类型', + idempotency_key varchar(128) NOT NULL COMMENT '客户端幂等键,同一操作重试复用', + request_hash char(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要', + status varchar(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等声明创建时间', + completed_at datetime(3) DEFAULT NULL COMMENT '幂等操作完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_outbound_dispatch_operation (operator_id,operation,idempotency_key), + KEY idx_outbound_dispatch_operation_resource (dispatch_id,created_at), + CONSTRAINT fk_outbound_dispatch_operation FOREIGN KEY (dispatch_id) REFERENCES fl_outbound_dispatch (id), + CONSTRAINT chk_outbound_dispatch_operation_status CHECK ((status in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内出仓写操作幂等声明'; + +CREATE TABLE fl_port_schedule ( + id bigint unsigned NOT NULL COMMENT '港务船期记录ID', + schedule_no varchar(40) NOT NULL COMMENT '港务船期记录编号', + customer_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + ocean_booking_id bigint unsigned NOT NULL COMMENT '关联海运订舱ID', + container_booking_id bigint unsigned DEFAULT NULL COMMENT '关联柜次ID;订舱整票船期时为空', + source_type varchar(24) NOT NULL COMMENT '船期来源类型:MANUAL人工录入、CARRIER船公司、TERMINAL码头、API外部接口', + source_name varchar(128) NOT NULL COMMENT '船期来源名称', + source_ref varchar(255) NOT NULL COMMENT '来源侧记录唯一引用或查询凭据摘要', + vessel_name varchar(128) NOT NULL COMMENT '船名快照', + voyage_no varchar(64) NOT NULL COMMENT '航次号快照', + departure_port_code varchar(32) NOT NULL COMMENT '起运港编码快照', + departure_port_name varchar(128) DEFAULT NULL COMMENT '起运港名称快照', + destination_port_code varchar(32) NOT NULL COMMENT '目的港编码快照', + destination_port_name varchar(128) DEFAULT NULL COMMENT '目的港名称快照', + etd_at datetime(3) DEFAULT NULL COMMENT '预计离港时间', + eta_at datetime(3) DEFAULT NULL COMMENT '预计到港时间', + booking_cutoff_at datetime(3) DEFAULT NULL COMMENT '截单时间', + customs_cutoff_at datetime(3) DEFAULT NULL COMMENT '截关时间', + port_cutoff_at datetime(3) DEFAULT NULL COMMENT '截港时间', + observed_at datetime(3) NOT NULL COMMENT '来源数据观测时间', + expires_at datetime(3) NOT NULL COMMENT '超过该时间即由API计算为过期', + remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注', + created_by bigint unsigned NOT NULL COMMENT '创建操作人ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '船期记录创建时间', + updated_by bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + updated_by_name varchar(64) DEFAULT NULL COMMENT '最后修改操作人姓名快照', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '船期记录最后更新时间', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_port_schedule_no (schedule_no), + UNIQUE KEY uk_port_schedule_source (source_type,source_name,source_ref), + KEY idx_port_schedule_customer (customer_id,observed_at,id), + KEY idx_port_schedule_booking (ocean_booking_id,observed_at,id), + KEY idx_port_schedule_container (container_booking_id,observed_at,id), + KEY idx_port_schedule_expiry (expires_at,id), + CONSTRAINT fk_port_schedule_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id), + CONSTRAINT fk_port_schedule_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), + CONSTRAINT chk_port_schedule_booking_cutoff CHECK (((booking_cutoff_at is null) or (etd_at is null) or (booking_cutoff_at <= etd_at))), + CONSTRAINT chk_port_schedule_customer CHECK ((customer_id > 0)), + CONSTRAINT chk_port_schedule_customs_cutoff CHECK (((customs_cutoff_at is null) or (etd_at is null) or (customs_cutoff_at <= etd_at))), + CONSTRAINT chk_port_schedule_eta CHECK (((etd_at is null) or (eta_at is null) or (eta_at >= etd_at))), + CONSTRAINT chk_port_schedule_expiry CHECK ((expires_at > observed_at)), + CONSTRAINT chk_port_schedule_port_cutoff CHECK (((port_cutoff_at is null) or (etd_at is null) or (port_cutoff_at <= etd_at))), + CONSTRAINT chk_port_schedule_source_type CHECK ((source_type in (_utf8mb4'MANUAL',_utf8mb4'CARRIER',_utf8mb4'TERMINAL',_utf8mb4'API'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立港务船期来源聚合,不替代订舱快照'; + +CREATE TABLE fl_carrier_schedule_query ( + id bigint unsigned NOT NULL COMMENT '船司官网查询任务ID', + query_no varchar(40) NOT NULL COMMENT '船司官网查询任务编号', + customer_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + ocean_booking_id bigint unsigned NOT NULL COMMENT '关联海运订舱ID', + container_booking_id bigint unsigned DEFAULT NULL COMMENT '指定查询的柜次ID;整票订舱查询时为空', + container_scope_id bigint unsigned GENERATED ALWAYS AS (coalesce(container_booking_id,0)) STORED COMMENT '柜次唯一作用域计算值;整票订舱查询固定为0', + carrier_code varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德', + 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) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API', + 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) DEFAULT NULL COMMENT '最近实际标准船节点;预计事实不推进', + latest_node_observed_at datetime(3) DEFAULT NULL COMMENT '最近实际标准节点发生时间', + stopped_at datetime(3) DEFAULT NULL COMMENT '任务停止时间', + stop_reason varchar(500) DEFAULT NULL COMMENT '任务停止原因', + lease_owner varchar(64) DEFAULT NULL COMMENT '当前调度租约持有者', + lease_until datetime(3) DEFAULT 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) DEFAULT NULL COMMENT '下一正常槽位或槽内技术退避时间;停止后为空', + last_attempt_at datetime(3) DEFAULT NULL COMMENT '最近一次查询尝试时间', + last_success_at datetime(3) DEFAULT NULL COMMENT '最近一次取得有效结果时间', + source_updated_at datetime(3) DEFAULT NULL COMMENT '船公司官网展示的结果更新时间', + expires_at datetime(3) DEFAULT NULL COMMENT '当前最近有效结果过期时间;到期后仍保留但标记过期', + result_port_schedule_id bigint unsigned DEFAULT NULL COMMENT '最近有效结果对应的船期记录ID', + last_attempt_status varchar(24) DEFAULT NULL COMMENT '最近查询尝试状态快照', + manual_reason varchar(1000) DEFAULT NULL COMMENT '需要人工查询或录入的原因', + last_error_code varchar(64) DEFAULT NULL COMMENT '最近查询失败错误码', + last_error_message varchar(1000) DEFAULT NULL COMMENT '最近查询失败错误说明', + remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注', + created_by bigint unsigned NOT NULL COMMENT '创建操作人ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间', + updated_by bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + updated_by_name varchar(64) DEFAULT NULL COMMENT '最后修改操作人姓名快照', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务最后更新时间', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + 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), + 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), + KEY fk_carrier_schedule_query_booking (ocean_booking_id), + KEY fk_carrier_schedule_query_container (container_booking_id), + KEY fk_carrier_schedule_query_result (result_port_schedule_id), + CONSTRAINT fk_carrier_schedule_query_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id), + CONSTRAINT fk_carrier_schedule_query_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), + CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule (id), + CONSTRAINT chk_carrier_schedule_query_access CHECK ((access_mode in (_utf8mb4'PUBLIC_FORM',_utf8mb4'PUBLIC_WEB_PROXY',_utf8mb4'PUBLIC_HTML',_utf8mb4'HCAPTCHA',_utf8mb4'OFFICIAL_API',_utf8mb4'MANUAL'))), + CONSTRAINT chk_carrier_schedule_query_attempt CHECK ((attempt_count between 0 and 3)), + CONSTRAINT chk_carrier_schedule_query_carrier CHECK ((carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER'))), + CONSTRAINT chk_carrier_schedule_query_customer CHECK ((customer_id >= 0)), + CONSTRAINT chk_carrier_schedule_query_node CHECK (((latest_standard_node is null) or (latest_standard_node in (_utf8mb4'DEPARTED_WAREHOUSE',_utf8mb4'AT_ORIGIN_PORT',_utf8mb4'LOADED_ON_VESSEL',_utf8mb4'DEPARTED_ORIGIN_PORT',_utf8mb4'IN_TRANSIT',_utf8mb4'ARRIVED_DESTINATION_PORT',_utf8mb4'DISCHARGED_FROM_VESSEL',_utf8mb4'PICKUP_READY',_utf8mb4'PICKED_UP',_utf8mb4'EMPTY_RETURNED')))), + CONSTRAINT chk_carrier_schedule_query_status CHECK ((status in (_utf8mb4'READY',_utf8mb4'MANUAL_REQUIRED',_utf8mb4'STOPPED'))), + CONSTRAINT chk_carrier_schedule_query_tracking CHECK ((tracking_type in (_utf8mb4'BOOKING_NO',_utf8mb4'BILL_OF_LADING',_utf8mb4'CONTAINER_NO'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='六家船公司固定槽位与技术退避查询任务'; + +CREATE TABLE fl_carrier_schedule_exception_fact ( + id bigint unsigned NOT NULL COMMENT '船节点异常事实ID', + query_id bigint unsigned NOT NULL COMMENT '所属查询任务ID', + event_key varchar(192) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '供应商异常事件幂等键', + 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) DEFAULT NULL COMMENT '地点编码', + location_name varchar(128) DEFAULT NULL COMMENT '地点名称', + source_reference varchar(500) DEFAULT 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_active CHECK ((active in (0,1))), + CONSTRAINT chk_carrier_schedule_exception_code CHECK ((exception_code in (_utf8mb4'CUSTOMS_HOLD',_utf8mb4'TERMINAL_HOLD',_utf8mb4'DOCUMENT_HOLD',_utf8mb4'UNKNOWN_HOLD'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船节点异常发生与解除不可变事实'; + +CREATE TABLE fl_carrier_schedule_node_fact ( + id bigint unsigned NOT NULL COMMENT '标准船节点事实ID', + query_id bigint unsigned DEFAULT NULL COMMENT '所属查询任务ID', + container_booking_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) DEFAULT NULL COMMENT '供应商预计发生时间', + actual_at datetime(3) DEFAULT NULL COMMENT '供应商确认实际发生时间', + location_code varchar(32) DEFAULT NULL COMMENT '节点地点编码', + location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称', + source_reference varchar(500) DEFAULT 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 (container_booking_id,event_key), + KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id), + KEY idx_container_shipping_node_timeline (container_booking_id,observed_at,id), + CONSTRAINT fk_carrier_schedule_node_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), + CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query (id), + CONSTRAINT chk_carrier_schedule_node_sequence CHECK ((node_sequence in (10,20,30,40,50,60,70,80,90,100))), + CONSTRAINT chk_carrier_schedule_node_standard CHECK ((standard_node in (_utf8mb4'DEPARTED_WAREHOUSE',_utf8mb4'AT_ORIGIN_PORT',_utf8mb4'LOADED_ON_VESSEL',_utf8mb4'DEPARTED_ORIGIN_PORT',_utf8mb4'IN_TRANSIT',_utf8mb4'ARRIVED_DESTINATION_PORT',_utf8mb4'DISCHARGED_FROM_VESSEL',_utf8mb4'PICKUP_READY',_utf8mb4'PICKED_UP',_utf8mb4'EMPTY_RETURNED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='供应商标准船节点不可变事实'; + +CREATE TABLE 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', + 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 '供应商调用结果', + coverage_gap tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否存在自动化覆盖缺口:0否、1是', + coverage_gap_code varchar(64) DEFAULT NULL COMMENT '自动化覆盖缺口编码', + manual_reason varchar(1000) DEFAULT NULL COMMENT '转人工处理原因', + error_code varchar(64) DEFAULT NULL COMMENT '查询失败错误码', + error_message varchar(1000) DEFAULT NULL COMMENT '查询失败错误说明', + operator_id bigint unsigned NOT NULL COMMENT '执行本次尝试的操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '执行操作人姓名快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '尝试记录创建时间', + PRIMARY KEY (id), + KEY idx_carrier_schedule_attempt_query (query_id,created_at,id), + 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 (_utf8mb4'SUCCESS',_utf8mb4'NO_CHANGE',_utf8mb4'RETRYABLE_FAILURE',_utf8mb4'PERMANENT_FAILURE',_utf8mb4'MANUAL_REQUIRED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船司官网查询尝试不可变审计'; + +CREATE TABLE 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 '查询任务业务历史动作', + from_version int unsigned DEFAULT NULL COMMENT '变更前任务版本;创建时为空', + to_version int unsigned NOT NULL COMMENT '变更后任务版本', + reason varchar(1000) DEFAULT NULL COMMENT '操作原因或说明', + before_snapshot json DEFAULT NULL COMMENT '变更前任务数据快照;创建时为空', + after_snapshot json NOT NULL COMMENT '变更后任务数据快照', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人姓名快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', + 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 (_utf8mb4'CREATE',_utf8mb4'AUTO_CREATE',_utf8mb4'MANUAL_RESULT',_utf8mb4'MANUAL_NODE',_utf8mb4'NODE_OBSERVED',_utf8mb4'EXCEPTION_OBSERVED',_utf8mb4'PROVIDER_QUERY',_utf8mb4'STOP'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船司查询任务不可变历史'; + +CREATE TABLE fl_carrier_schedule_query_operation ( + id bigint unsigned NOT NULL COMMENT '船司查询写操作幂等记录ID', + query_id bigint unsigned DEFAULT NULL COMMENT '关联查询任务ID;创建任务落库前允许为空', + operator_id bigint unsigned NOT NULL COMMENT '发起写操作的平台操作人ID', + operation varchar(24) NOT NULL COMMENT '幂等写操作类型', + idempotency_key varchar(128) NOT NULL COMMENT '客户端写操作幂等键', + request_hash char(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求', + status varchar(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功', + result_json json DEFAULT NULL COMMENT '成功结果JSON快照;供幂等重放返回', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', + completed_at datetime(3) DEFAULT NULL COMMENT '写操作成功完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key), + KEY idx_carrier_schedule_operation_query (query_id,created_at,id), + CONSTRAINT fk_carrier_schedule_operation_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query (id), + CONSTRAINT chk_carrier_schedule_operation_status CHECK ((status in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船司查询写操作幂等与请求摘要'; + +CREATE TABLE fl_port_schedule_history ( + id bigint unsigned NOT NULL COMMENT '港务船期变更历史ID', + port_schedule_id bigint unsigned NOT NULL COMMENT '所属港务船期记录ID', + action varchar(24) NOT NULL COMMENT '变更动作:CREATE创建、UPDATE更新', + from_version int unsigned DEFAULT NULL COMMENT '变更前版本;创建时为空', + to_version int unsigned NOT NULL COMMENT '变更后版本', + reason varchar(1000) DEFAULT NULL COMMENT '变更原因或说明', + before_snapshot json DEFAULT NULL COMMENT '变更前船期数据快照;创建时为空', + after_snapshot json NOT NULL COMMENT '变更后船期数据快照', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人姓名快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', + PRIMARY KEY (id), + KEY idx_port_schedule_history (port_schedule_id,created_at,id), + CONSTRAINT fk_port_schedule_history FOREIGN KEY (port_schedule_id) REFERENCES fl_port_schedule (id), + CONSTRAINT chk_port_schedule_history_action CHECK ((action in (_utf8mb4'CREATE',_utf8mb4'UPDATE'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='港务船期不可变变更历史'; + +CREATE TABLE fl_port_schedule_operation ( + id bigint unsigned NOT NULL COMMENT '港务船期写操作幂等记录ID', + port_schedule_id bigint unsigned DEFAULT NULL COMMENT '关联港务船期ID;创建请求完成前允许为空', + operator_id bigint unsigned NOT NULL COMMENT '发起写操作的平台操作人ID', + operation varchar(24) NOT NULL COMMENT '幂等写操作类型', + idempotency_key varchar(128) NOT NULL COMMENT '客户端写操作幂等键', + request_hash char(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求', + status varchar(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功', + result_json json DEFAULT NULL COMMENT '成功结果JSON快照;供幂等重放返回', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', + completed_at datetime(3) DEFAULT NULL COMMENT '写操作成功完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_port_schedule_operation (operator_id,idempotency_key), + KEY idx_port_schedule_operation_resource (port_schedule_id,created_at), + CONSTRAINT fk_port_schedule_operation FOREIGN KEY (port_schedule_id) REFERENCES fl_port_schedule (id), + CONSTRAINT chk_port_schedule_operation_status CHECK ((status in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='港务船期写操作幂等与请求哈希'; + +CREATE TABLE fl_preload_plan ( + id bigint unsigned NOT NULL COMMENT '预装清单ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '关联入库单逻辑ID', + status varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '预装状态:PENDING待确认、CONFIRMING确认中、CONFIRMED已确认', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + items_json json NOT NULL COMMENT '可编辑分柜明细JSON快照;人工确认后作为预装依据', + remark varchar(1000) DEFAULT NULL COMMENT '预装备注', + confirmed_by bigint unsigned DEFAULT NULL COMMENT '确认预装的操作人ID', + confirmed_by_name varchar(64) DEFAULT NULL COMMENT '确认预装的操作人姓名快照', + confirmed_at datetime(3) DEFAULT NULL COMMENT '预装确认时间', + created_by bigint unsigned NOT NULL COMMENT '创建预装清单的操作人ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照', + updated_by bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + updated_by_name varchar(64) 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_preload_tenant_inbound (tenant_id,inbound_id), + KEY idx_preload_status (tenant_id,status,updated_at), + KEY fk_preload_inbound (inbound_id), + CONSTRAINT fk_preload_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单分柜预装清单'; + +CREATE TABLE fl_preload_loading_plan_link ( + id bigint unsigned NOT NULL COMMENT '关联ID', + tenant_id bigint unsigned NOT NULL COMMENT '入库单客户隔离ID', + inbound_id bigint unsigned NOT NULL COMMENT '入库单ID', + preload_plan_id bigint unsigned NOT NULL COMMENT '入库单级预装计划ID', + loading_plan_id bigint unsigned NOT NULL COMMENT '柜次级装柜计划ID', + idempotency_key varchar(128) NOT NULL COMMENT '建立关联幂等键', + request_hash char(64) NOT NULL COMMENT '建立关联请求SHA-256', + created_by bigint unsigned NOT NULL COMMENT '建立预装与装柜计划关联的操作人ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '建立关联的操作人姓名快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关联记录创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_preload_loading_pair (tenant_id,inbound_id,loading_plan_id), + UNIQUE KEY uk_preload_loading_idempotency (created_by,idempotency_key), + KEY idx_preload_loading_preload (preload_plan_id,loading_plan_id), + KEY idx_preload_loading_plan (loading_plan_id,tenant_id,inbound_id), + KEY fk_preload_loading_inbound (inbound_id), + CONSTRAINT fk_preload_loading_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_preload_loading_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT fk_preload_loading_preload FOREIGN KEY (preload_plan_id) REFERENCES fl_preload_plan (id), + CONSTRAINT chk_preload_loading_tenant CHECK ((tenant_id > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单预装与柜次装柜计划多对多关联'; + +CREATE TABLE fl_price_book_channel ( + id bigint unsigned NOT NULL COMMENT '版本渠道快照ID', + price_book_version_id bigint unsigned NOT NULL COMMENT '整表价格版本ID', + channel_code varchar(64) NOT NULL COMMENT '固定渠道编码', + channel_name varchar(128) NOT NULL COMMENT '渠道名称快照', + price_model varchar(32) NOT NULL COMMENT '价格模型快照', + source_sheet_name varchar(128) NOT NULL COMMENT '原始Excel Sheet', + estimated_departure_date date DEFAULT NULL COMMENT '标准预计开船日期', + estimated_departure_text varchar(128) DEFAULT NULL COMMENT '原始预计开船日期文本', + latest_warehouse_entry varchar(128) DEFAULT NULL COMMENT '最晚进仓时间', + vessel_name varchar(128) DEFAULT NULL COMMENT '船名', + voyage_no varchar(64) DEFAULT NULL COMMENT '航次', + reference_transit varchar(128) DEFAULT NULL COMMENT '参考时效', + surcharge_description varchar(2000) DEFAULT NULL COMMENT '附加费说明,仅展示', + remark varchar(2000) DEFAULT NULL COMMENT '渠道备注', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '渠道快照创建时间', + updated_by bigint unsigned NOT 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_price_book_channel (price_book_version_id,channel_code), + KEY idx_price_book_channel_code (channel_code,price_book_version_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='价格版本渠道及船期快照'; + +CREATE TABLE fl_price_book_version ( + id bigint unsigned NOT NULL COMMENT '整表价格版本ID', + tenant_id bigint unsigned NOT NULL DEFAULT '0' COMMENT '平台公共价格作用域固定为0', + version_no int unsigned NOT NULL COMMENT '整表版本号', + original_file_name varchar(255) NOT NULL COMMENT '原始Excel文件名', + source_file_id bigint unsigned DEFAULT NULL COMMENT 'File服务原始文件ID', + source_sha256 char(64) DEFAULT NULL COMMENT '原始文件SHA-256', + status varchar(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、VOIDED已作废、FAILED解析失败', + blocking_error_count int unsigned NOT NULL DEFAULT '0' COMMENT '阻断错误数', + warning_count int unsigned NOT NULL DEFAULT '0' COMMENT '警告数', + parse_issues_json json DEFAULT NULL COMMENT '上传解析问题及待确认重新校验结果', + channel_count int unsigned NOT NULL DEFAULT '0' COMMENT '解析渠道数', + rate_count int unsigned NOT NULL DEFAULT '0' COMMENT '解析价格数', + created_by bigint unsigned NOT NULL COMMENT '上传价格表的操作人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传记录创建时间', + published_by bigint unsigned DEFAULT NULL COMMENT '确认并发布价格版本的操作人ID', + published_at datetime(3) DEFAULT NULL COMMENT '确认并生效时间', + superseded_at datetime(3) DEFAULT NULL COMMENT '被下一版本替换时间', + voided_by bigint unsigned DEFAULT NULL COMMENT '作废操作人ID', + voided_at datetime(3) DEFAULT NULL COMMENT '作废时间', + void_reason varchar(500) DEFAULT NULL COMMENT '作废原因', + updated_by bigint unsigned NOT 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 '逻辑删除标记:0未删除、1已删除', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_price_book_version (tenant_id,version_no), + KEY idx_price_book_status (tenant_id,status,published_at), + KEY idx_price_book_source_hash (tenant_id,source_sha256) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共船期价格整表版本'; + +CREATE TABLE fl_price_channel_catalog ( + id bigint unsigned NOT NULL COMMENT '渠道目录ID', + channel_code varchar(64) NOT NULL COMMENT '稳定渠道编码', + display_name varchar(128) NOT NULL COMMENT '平台渠道名称', + price_model varchar(32) NOT NULL COMMENT '默认价格模型', + source_sheet_name varchar(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet', + aliases_json json NOT NULL COMMENT '上传名称别名数组', + inbound_code_prefix varchar(6) NOT NULL COMMENT '普通目的仓进仓编码前缀;包税与不包税共用', + overseas_inbound_code_prefix varchar(6) DEFAULT NULL COMMENT '海外仓中转进仓编码前缀;包税与不包税共用,为空时使用普通前缀', + status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用', + 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_price_channel_code (channel_code), + KEY idx_price_channel_status (status,price_model) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共船期价格固定渠道目录'; + +CREATE TABLE fl_price_origin_alias ( + id bigint unsigned NOT NULL COMMENT '起运仓别名ID', + warehouse_code varchar(64) NOT NULL COMMENT '平台仓库稳定编码,不存环境专属ID', + alias_name varchar(128) NOT NULL COMMENT 'Excel起运仓名称别名', + status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用', + 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_price_origin_alias (warehouse_code,alias_name), + KEY idx_price_origin_alias_name (alias_name,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='价格表起运仓名称别名映射'; + +CREATE TABLE fl_price_warehouse_mapping ( + id bigint unsigned NOT NULL COMMENT '价格仓映射ID', + source_type varchar(24) NOT NULL COMMENT '来源维度:ORIGIN_CODE或OVERSEAS_NAME', + source_value varchar(128) NOT NULL COMMENT '解析后的来源仓编码或海外仓名称', + warehouse_id bigint unsigned NOT NULL COMMENT '固定平台公共仓库ID', + warehouse_code varchar(64) NOT NULL COMMENT '平台仓库稳定编码快照', + warehouse_type varchar(16) NOT NULL COMMENT 'DOMESTIC或OVERSEAS', + status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用', + 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_price_warehouse_mapping_source (source_type,source_value), + KEY idx_price_warehouse_mapping_target (warehouse_id,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='价格表来源仓与固定平台公共仓映射'; + +CREATE TABLE fl_split_operation ( + id bigint unsigned NOT NULL COMMENT '幂等记录ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id bigint unsigned NOT NULL COMMENT '操作目标入库单逻辑ID', + operator_id bigint unsigned NOT NULL COMMENT '执行分柜或预装写操作的平台用户ID', + operation varchar(40) NOT NULL COMMENT '分柜或预装写操作类型', + idempotency_key varchar(128) NOT NULL COMMENT '客户端幂等键,同一操作重试复用', + status varchar(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功', + result_json json DEFAULT NULL COMMENT '操作结果JSON快照,用于幂等重放', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等操作声明创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_split_operation (tenant_id,inbound_id,operator_id,operation,idempotency_key), + KEY idx_split_operation_time (tenant_id,inbound_id,created_at), + KEY fk_split_operation_inbound (inbound_id), + CONSTRAINT fk_split_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='分柜操作幂等与审计索引'; + +CREATE TABLE fl_terminal_drayage ( + id bigint unsigned NOT NULL COMMENT '目的港码头拖车聚合ID', + container_booking_id bigint unsigned NOT NULL COMMENT '唯一归属物理货柜ID', + fleet_supplier_id bigint unsigned DEFAULT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', + fleet_supplier_name varchar(128) DEFAULT NULL COMMENT 'Warehouse供应商名称快照', + pickup_appointment_at datetime(3) DEFAULT NULL COMMENT '预约提柜时间', + estimated_empty_return_at datetime(3) DEFAULT NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', + actual_empty_return_at datetime(3) DEFAULT NULL COMMENT '实际还空柜时间', + version int unsigned NOT NULL DEFAULT '1' COMMENT '乐观锁版本号', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_by_name varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT NULL COMMENT '最后更新人ID', + updated_by_name varchar(64) DEFAULT NULL COMMENT '最后更新人名称快照', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_terminal_drayage_container (container_booking_id), + KEY idx_terminal_drayage_warning (actual_empty_return_at,estimated_empty_return_at), + CONSTRAINT fk_terminal_drayage_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='目的港货柜级码头拖车与还空事实'; + +CREATE TABLE fl_terminal_drayage_history ( + id bigint unsigned NOT NULL COMMENT '历史ID', + drayage_id bigint unsigned NOT NULL COMMENT '码头拖车聚合ID', + container_booking_id bigint unsigned NOT NULL COMMENT '货柜ID快照', + action varchar(32) NOT NULL COMMENT '业务动作', + from_status varchar(32) NOT NULL COMMENT '操作前状态', + to_status varchar(32) NOT NULL COMMENT '操作后状态', + from_version int unsigned NOT NULL COMMENT '操作前版本', + to_version int unsigned NOT NULL COMMENT '操作后版本', + reason varchar(500) DEFAULT NULL COMMENT '操作说明', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + KEY idx_terminal_drayage_history (container_booking_id,created_at,id), + KEY fk_terminal_drayage_history (drayage_id), + CONSTRAINT fk_terminal_drayage_history FOREIGN KEY (drayage_id) REFERENCES fl_terminal_drayage (id), + CONSTRAINT chk_terminal_drayage_history_action CHECK ((action in (_utf8mb4'SELECT_FLEET',_utf8mb4'SEND_PICKUP_EMAIL',_utf8mb4'SCHEDULE_PICKUP',_utf8mb4'RECORD_EMPTY_RETURN'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='目的港码头拖车不可变操作历史'; + +CREATE TABLE fl_terminal_drayage_mail ( + id bigint unsigned NOT NULL COMMENT '邮件任务ID', + container_booking_id bigint unsigned NOT NULL COMMENT '归属物理货柜ID', + fleet_supplier_id bigint unsigned NOT NULL COMMENT '车队供应商ID快照', + fleet_name varchar(128) NOT NULL COMMENT '车队名称快照', + recipient varchar(254) NOT NULL COMMENT '收件人邮箱', + kind varchar(32) NOT NULL COMMENT '邮件类型:REQUEST_PICKUP提柜请求、CANCEL_PICKUP取消提柜', + subject varchar(255) NOT NULL COMMENT '邮件主题', + body text NOT NULL COMMENT '邮件正文', + status varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待发送、SENDING发送中、SENT已发送、FAILED失败', + attempts int NOT NULL DEFAULT '0' COMMENT '发送尝试次数', + failure_reason varchar(500) DEFAULT NULL COMMENT '发送失败原因', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + updated_by bigint unsigned NOT 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 '更新时间', + sent_at datetime(3) DEFAULT NULL COMMENT '发送成功时间', + PRIMARY KEY (id), + KEY idx_terminal_mail_dispatch (status,id), + KEY idx_terminal_mail_container (container_booking_id,id,status), + CONSTRAINT fk_terminal_mail_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), + CONSTRAINT chk_terminal_mail_kind CHECK ((kind in (_utf8mb4'REQUEST_PICKUP',_utf8mb4'CANCEL_PICKUP'))), + CONSTRAINT chk_terminal_mail_status CHECK ((status in (_utf8mb4'PENDING',_utf8mb4'SENDING',_utf8mb4'SENT',_utf8mb4'FAILED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='码头车队邮件任务与投递记录'; + +CREATE TABLE fl_terminal_drayage_operation ( + id bigint unsigned NOT NULL COMMENT '幂等操作ID', + container_booking_id bigint unsigned NOT NULL COMMENT '货柜ID', + operation varchar(32) NOT NULL COMMENT '业务动作', + idempotency_key varchar(255) NOT NULL COMMENT '客户端幂等键', + request_hash char(64) NOT NULL COMMENT '请求载荷SHA-256', + result_version int unsigned NOT NULL COMMENT '成功结果版本', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_terminal_drayage_operation (container_booking_id,idempotency_key), + KEY idx_terminal_drayage_operation (container_booking_id,created_at), + CONSTRAINT fk_terminal_drayage_operation_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='目的港码头拖车成功幂等操作'; + +CREATE TABLE fl_third_party_call_log ( + id bigint unsigned NOT NULL COMMENT '第三方调用日志ID(雪花ID)', + tenant_id bigint unsigned NOT NULL DEFAULT '0' COMMENT '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权', + app_key varchar(128) DEFAULT NULL COMMENT '第三方应用标识', + signature_subject varchar(128) DEFAULT NULL COMMENT '签名主体快照', + request_method varchar(8) NOT NULL COMMENT 'HTTP方法', + request_path varchar(255) NOT NULL COMMENT '签名使用的规范路径', + request_timestamp varchar(32) DEFAULT NULL COMMENT '请求头Unix毫秒时间戳原值', + nonce varchar(128) DEFAULT NULL COMMENT '请求Nonce', + idempotency_key varchar(128) DEFAULT NULL COMMENT 'HTTP幂等键,查询接口为空', + idempotency_anchor tinyint unsigned DEFAULT NULL COMMENT '签名通过后首个请求为1,重复调用为空', + signature_value varchar(128) DEFAULT NULL COMMENT '第三方签名原值', + request_hash char(64) NOT NULL COMMENT '原始请求体SHA-256小写十六进制', + raw_payload longtext NOT NULL COMMENT '未经重新序列化的UTF-8原始请求体', + processing_status varchar(16) NOT NULL COMMENT 'PROCESSING、ACCEPTED或REJECTED', + result_code varchar(32) DEFAULT NULL COMMENT '统一响应业务码', + result_message varchar(1000) DEFAULT NULL COMMENT '处理结果或失败原因', + result_json json DEFAULT NULL COMMENT '首次成功结果快照', + requested_at datetime(3) NOT NULL COMMENT '服务端接收时间', + processed_at datetime(3) DEFAULT NULL COMMENT '处理完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_third_call_idempotency_owner (tenant_id,app_key,idempotency_key,idempotency_anchor), + KEY idx_third_call_app_time (tenant_id,app_key,requested_at), + KEY idx_third_call_idempotency (tenant_id,app_key,idempotency_key), + KEY idx_third_call_nonce (tenant_id,app_key,nonce) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方测量开放接口原始请求、签名主体和处理结果留档'; + +CREATE TABLE fl_truck_appointment ( + id bigint unsigned NOT NULL COMMENT '拖车预约ID', + appointment_no varchar(40) NOT NULL COMMENT '平台拖车预约编号', + outbound_order_id bigint unsigned DEFAULT NULL COMMENT '关联头程订单逻辑ID;不跨库建外键', + ocean_booking_id bigint unsigned DEFAULT NULL COMMENT '关联海运订舱逻辑ID', + container_booking_id bigint unsigned DEFAULT NULL COMMENT '停用保留列;订舱级拖车单不写入、不读取', + loading_plan_id bigint unsigned DEFAULT NULL COMMENT '停用保留列;订舱级拖车单不写入、不读取', + warehouse_id bigint unsigned DEFAULT NULL COMMENT '预约到仓的国内收货仓逻辑ID', + carrier_id bigint unsigned DEFAULT NULL COMMENT '拖车服务商逻辑ID', + carrier_name varchar(128) DEFAULT NULL COMMENT '拖车服务商名称快照', + bill_of_lading_no varchar(128) DEFAULT NULL COMMENT '派车提单号快照;关联RELEASED订舱时由订舱事实源冻结', + fleet_contact varchar(64) DEFAULT NULL COMMENT '车队联系人', + fleet_phone varchar(32) DEFAULT NULL COMMENT '车队联系电话', + driver_name varchar(64) DEFAULT NULL COMMENT '司机姓名', + driver_phone varchar(32) DEFAULT NULL COMMENT '司机联系电话', + driver_id_card varchar(32) DEFAULT NULL COMMENT '司机身份证号;平台内部敏感信息', + vehicle_no varchar(32) DEFAULT NULL COMMENT '车辆车牌号', + store_name varchar(128) DEFAULT NULL COMMENT '到仓装柜地点名称快照', + store_contact varchar(64) DEFAULT NULL COMMENT '到仓地点联系人快照', + store_phone varchar(32) DEFAULT NULL COMMENT '到仓地点联系电话快照', + store_address varchar(255) DEFAULT NULL COMMENT '到仓装柜地点地址快照', + store_city varchar(64) DEFAULT NULL COMMENT '到仓地点城市快照', + store_zipcode varchar(16) DEFAULT NULL COMMENT '到仓地点邮编快照', + package_count int unsigned DEFAULT NULL COMMENT '预约货物包装件数快照', + gross_weight_kg decimal(18,3) DEFAULT NULL COMMENT '预约货物毛重,单位千克', + volume_cbm decimal(18,4) DEFAULT NULL COMMENT '预约货物体积,单位立方米', + pallet_count int unsigned DEFAULT NULL COMMENT '预约托盘数量', + po_no varchar(255) DEFAULT NULL COMMENT '关联采购单号', + planned_loading_at datetime(3) DEFAULT NULL COMMENT '预约到仓装柜时间', + actual_arrived_warehouse_at datetime(3) DEFAULT NULL COMMENT '车辆实际到达收货仓时间', + actual_loading_at datetime(3) DEFAULT NULL COMMENT '实际开始装柜时间', + actual_departed_at datetime(3) DEFAULT NULL COMMENT '订舱全部要求货柜确认国内离仓的最终时间', + transit_minutes int unsigned DEFAULT NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', + payable_amount decimal(18,2) DEFAULT NULL COMMENT '应付拖车服务商金额;币种由currency指定', + currency varchar(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;国内拖车仅RMB,财务以CNY入池', + status varchar(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', + active_ocean_booking_id bigint unsigned GENERATED ALWAYS AS ((case when (status not in (_utf8mb4'COMPLETED',_utf8mb4'CANCELLED',_utf8mb4'EXCEPTION')) then ocean_booking_id else NULL end)) STORED COMMENT '非终态预约的订舱唯一键', + revision int unsigned NOT NULL DEFAULT '0' COMMENT '拖车预约业务修订号;每次状态或改约递增', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + remark varchar(1000) 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_truck_appointment_no (appointment_no), + UNIQUE KEY uk_truck_active_ocean_booking (active_ocean_booking_id), + KEY idx_truck_appointment_status (status,updated_at), + KEY idx_truck_appointment_ocean (ocean_booking_id), + CONSTRAINT chk_truck_appointment_currency CHECK ((currency = _utf8mb4'RMB')), + CONSTRAINT chk_truck_appointment_status CHECK ((status in (_utf8mb4'DRAFT',_utf8mb4'PENDING_DISPATCH',_utf8mb4'DISPATCHED',_utf8mb4'PICKUP_IN_PROGRESS',_utf8mb4'ARRIVED_WAREHOUSE',_utf8mb4'LOADING',_utf8mb4'COMPLETED',_utf8mb4'RESCHEDULED',_utf8mb4'EXCEPTION',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='三期拖车预约派车聚合'; + +CREATE TABLE fl_truck_appointment_attachment ( + id bigint unsigned NOT NULL COMMENT '拖车附件关系ID', + appointment_id bigint unsigned NOT NULL COMMENT '所属拖车预约ID', + file_id bigint unsigned NOT NULL COMMENT 'File服务文件逻辑ID', + attachment_type varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '附件类型:BILL_OF_LADING提单、DISPATCH_ORDER派车单、DRIVER_DOCUMENT司机证件、VEHICLE_DOCUMENT车辆证件、OTHER其他', + file_name varchar(255) DEFAULT NULL COMMENT '上传时文件名快照', + file_hash char(64) DEFAULT NULL COMMENT '文件内容SHA-256摘要', + visibility varchar(24) NOT NULL DEFAULT 'PLATFORM' COMMENT '可见范围:PLATFORM仅平台内部', + uploaded_by bigint unsigned NOT NULL COMMENT '上传操作人ID', + uploaded_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', + PRIMARY KEY (id), + UNIQUE KEY uk_truck_attachment (appointment_id,file_id), + KEY idx_truck_attachment (appointment_id), + CONSTRAINT chk_truck_attachment_type CHECK ((attachment_type in (_utf8mb4'DISPATCH_ORDER',_utf8mb4'DRIVER_DOCUMENT',_utf8mb4'VEHICLE_DOCUMENT',_utf8mb4'OTHER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='拖车附件文件引用'; + +CREATE TABLE fl_truck_appointment_history ( + id bigint unsigned NOT NULL COMMENT '拖车预约历史ID', + appointment_id bigint unsigned NOT NULL COMMENT '所属拖车预约ID', + revision int unsigned NOT NULL COMMENT '该预约业务修订号', + action varchar(32) NOT NULL COMMENT '触发状态或改约的业务动作', + from_status varchar(32) DEFAULT NULL COMMENT '变更前拖车状态;创建时为空', + to_status varchar(32) DEFAULT NULL COMMENT '变更后拖车状态;改约时可保持原状态', + reason varchar(500) DEFAULT NULL COMMENT '状态或改约原因', + operator_id bigint unsigned NOT NULL COMMENT '执行操作的平台用户ID', + operator_name varchar(64) DEFAULT NULL COMMENT '操作人姓名快照', + request_id varchar(128) DEFAULT NULL COMMENT '请求链路中的请求ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', + PRIMARY KEY (id), + KEY idx_truck_history (appointment_id,revision) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='拖车状态与改约历史'; + +CREATE TABLE fl_truck_appointment_operation ( + id bigint unsigned NOT NULL COMMENT '拖车写操作幂等记录ID', + appointment_id bigint unsigned NOT NULL COMMENT '所属拖车预约ID', + idempotency_key varchar(128) NOT NULL COMMENT '客户端写操作幂等键', + request_hash char(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求', + action varchar(32) NOT NULL COMMENT '执行的拖车业务动作', + response_snapshot json DEFAULT NULL COMMENT '成功响应JSON快照;供相同幂等请求重放', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_truck_operation (idempotency_key), + KEY idx_truck_operation_appointment (appointment_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='拖车操作幂等记录'; + +CREATE TABLE sys_audit_outbox ( + event_id varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '审计事件全局唯一ID', + event_type varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '审计事件类型', + resource_type varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '被审计业务资源类型', + resource_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '被审计业务资源ID;无法确定时为空', + service_name varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '产生审计事件的服务名称', + payload_json json NOT NULL COMMENT '待发送的审计事件完整负载快照', + status varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING待发送、SENDING发送中、SENT已发送、DEAD已转死信', + attempt_count int NOT NULL DEFAULT '0' COMMENT '正常发送累计尝试次数', + compensation_count int NOT NULL DEFAULT '0' COMMENT '人工或自动补偿累计次数', + next_retry_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '允许下次重试的最早时间', + lease_owner varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '当前发送任务租约持有者标识', + lease_until datetime(3) DEFAULT NULL COMMENT '当前发送任务租约到期时间', + last_error varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最近一次发送失败原因', + dead_at datetime(3) DEFAULT NULL COMMENT '转入死信状态的时间', + sent_at datetime(3) DEFAULT NULL COMMENT '首次成功发送时间', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录最后更新时间', + PRIMARY KEY (event_id), + KEY idx_audit_outbox_publish (status,next_retry_at,lease_until,created_at), + KEY idx_audit_outbox_retention (status,sent_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='可靠审计事件本地发送箱'; + +CREATE TABLE fl_inbound_measurement_item ( + id bigint unsigned NOT NULL COMMENT '测量明细ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + measurement_id bigint unsigned NOT NULL COMMENT '测量批次ID', + split_item_id bigint unsigned DEFAULT NULL COMMENT '产生该子单测量明细的拆分台账ID', + source_measurement_item_id bigint unsigned DEFAULT NULL COMMENT '被拆分继承的来源测量箱规明细ID', + inbound_detail_id bigint unsigned DEFAULT NULL COMMENT '绑定的订单货物明细ID,平台手工明细可为空', + external_detail_no varchar(128) DEFAULT NULL COMMENT '第三方测量明细唯一号,平台手工明细可为空', + box_code varchar(128) DEFAULT NULL COMMENT '柏悦唛头箱号;设备单箱测量时同一客户内唯一', + scanned_shipment_id varchar(64) DEFAULT NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', + item_no varchar(32) NOT NULL COMMENT '批次内明细号', + box_specification varchar(64) NOT NULL COMMENT '箱规标识', + package_count int unsigned NOT NULL COMMENT '相同箱规箱数', + length_cm decimal(12,3) NOT NULL COMMENT '原始长度cm', + width_cm decimal(12,3) NOT NULL COMMENT '原始宽度cm', + height_cm decimal(12,3) NOT NULL COMMENT '原始高度cm', + girth_cm decimal(12,3) NOT NULL COMMENT '周长cm,规则为长+2*(宽+高)', + unit_weight_kg decimal(18,3) DEFAULT NULL COMMENT '单箱重量kg,第三方仅回传尺寸时可为空', + unit_volume_cbm decimal(18,4) NOT NULL COMMENT '单箱业务体积m3', + volume_raw_cbm decimal(18,6) NOT NULL COMMENT '原始体积m3', + volume_cbm decimal(18,4) NOT NULL COMMENT '业务体积m3', + total_weight_kg decimal(18,3) DEFAULT NULL COMMENT '本箱规总重量kg', + total_volume_cbm decimal(18,4) NOT NULL COMMENT '本箱规总体积m3', + volume_weight_kg decimal(18,3) NOT NULL COMMENT '本箱规材积重kg', + charge_rule_id bigint unsigned DEFAULT NULL COMMENT '该测量明细绑定的客户计费规则ID', + coefficient_mode varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '系数模式快照:ADD或MULTIPLY', + calculation_rule_version varchar(64) NOT NULL COMMENT '体积计算和舍入规则版本', + source_image_file_id bigint unsigned DEFAULT NULL COMMENT '测量设备图片文件ID', + machine_no varchar(64) DEFAULT NULL COMMENT '本次测量设备编号', + device_measured_at datetime(3) DEFAULT NULL COMMENT '本次设备测量时间', + over_limit_warning varchar(500) DEFAULT NULL COMMENT '超线提醒快照', + outer_length_cm decimal(12,3) DEFAULT NULL COMMENT '策略计算后的最终外箱规长度cm', + outer_width_cm decimal(12,3) DEFAULT NULL COMMENT '策略计算后的最终外箱规宽度cm', + outer_height_cm decimal(12,3) DEFAULT NULL COMMENT '策略计算后的最终外箱规高度cm', + manual_outer_length_cm decimal(12,3) DEFAULT NULL COMMENT '仓库人工外箱规长度计算基数cm', + manual_outer_width_cm decimal(12,3) DEFAULT NULL COMMENT '仓库人工外箱规宽度计算基数cm', + manual_outer_height_cm decimal(12,3) DEFAULT NULL COMMENT '仓库人工外箱规高度计算基数cm', + remark varchar(500) 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_measure_item_no (tenant_id,measurement_id,item_no), + UNIQUE KEY uk_measure_external_detail (tenant_id,inbound_detail_id,external_detail_no), + UNIQUE KEY uk_measure_box_code (tenant_id,box_code), + KEY idx_measure_item_candidate (tenant_id,measurement_id,inbound_detail_id), + KEY fk_measure_item_batch (measurement_id), + KEY fk_measure_item_split_item (split_item_id), + KEY fk_measure_item_source_item (source_measurement_item_id), + KEY idx_measure_item_split_source (tenant_id,split_item_id,source_measurement_item_id), + CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id), + CONSTRAINT fk_measure_item_source_item FOREIGN KEY (source_measurement_item_id) REFERENCES fl_inbound_measurement_item (id), + CONSTRAINT fk_measure_item_split_item FOREIGN KEY (split_item_id) REFERENCES fl_inbound_split_item (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库长宽高和箱数测量明细'; + +CREATE TABLE fl_inbound_split_item ( + id bigint unsigned NOT NULL COMMENT '拆分明细ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + split_batch_id bigint unsigned NOT NULL COMMENT '拆分批次ID', + parent_inbound_id bigint unsigned NOT NULL COMMENT '主入仓单ID', + child_inbound_id bigint unsigned NOT NULL COMMENT '子入仓单ID', + line_no int unsigned NOT NULL COMMENT '同一拆分批次内的行号', + source_inbound_detail_id bigint unsigned DEFAULT NULL COMMENT '订单货物明细逻辑ID', + source_measurement_id bigint unsigned NOT NULL COMMENT '来源测量批次ID', + source_measurement_item_id bigint unsigned NOT NULL COMMENT '来源测量箱规明细ID', + package_count int unsigned NOT NULL COMMENT '分配给子单的箱数', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拆分数量台账创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_batch_line (tenant_id,split_batch_id,line_no), + UNIQUE KEY uk_inbound_split_batch_source_child (tenant_id,split_batch_id,source_measurement_item_id,child_inbound_id), + KEY idx_inbound_split_detail (tenant_id,source_inbound_detail_id,created_at), + KEY idx_inbound_split_candidate (tenant_id,parent_inbound_id,source_inbound_detail_id), + KEY fk_inbound_split_item_batch (split_batch_id), + KEY fk_inbound_split_item_parent (parent_inbound_id), + KEY fk_inbound_split_item_child (child_inbound_id), + KEY fk_inbound_split_item_measurement (source_measurement_id), + KEY fk_inbound_split_item_measure_item (source_measurement_item_id), + CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id), + CONSTRAINT fk_inbound_split_item_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_item_measure_item FOREIGN KEY (source_measurement_item_id) REFERENCES fl_inbound_measurement_item (id), + CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id), + CONSTRAINT fk_inbound_split_item_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT chk_inbound_split_item_package CHECK ((package_count > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='主子入仓单拆分数量台账'; + +SET SESSION foreign_key_checks = 1; + +-- 服务 schema:baiyue_file +CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_file; + +CREATE TABLE file_download_log ( + id bigint unsigned NOT NULL COMMENT '下载日志ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + file_id bigint unsigned NOT NULL COMMENT '文件逻辑ID', + user_id bigint unsigned DEFAULT NULL COMMENT '下载用户ID', + request_id varchar(64) DEFAULT NULL COMMENT '请求ID', + client_ip varchar(64) DEFAULT NULL COMMENT '客户端IP', + result varchar(16) NOT NULL COMMENT '下载结果', + failure_reason varchar(500) DEFAULT NULL COMMENT '失败原因', + downloaded_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下载时间', + PRIMARY KEY (id), + KEY idx_download_log_file (tenant_id,file_id,downloaded_at), + KEY idx_download_log_user (tenant_id,user_id,downloaded_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文件下载审计日志表'; + +CREATE TABLE file_download_token ( + id bigint unsigned NOT NULL COMMENT '下载令牌ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + file_id bigint unsigned NOT NULL COMMENT '文件逻辑ID', + token_hash char(64) NOT NULL COMMENT '下载令牌SHA-256摘要', + expires_at datetime(3) NOT NULL COMMENT '过期时间', + max_downloads int unsigned NOT NULL DEFAULT '1' COMMENT '最大下载次数', + download_count int unsigned NOT NULL DEFAULT '0' COMMENT '已下载次数', + revoked_at datetime(3) DEFAULT NULL COMMENT '撤销时间', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_download_token_hash (token_hash), + KEY idx_download_file (tenant_id,file_id), + KEY idx_download_expiry (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='临时下载授权令牌表'; + +CREATE TABLE file_export_outbox ( + id bigint unsigned NOT NULL COMMENT 'Outbox记录ID', + event_id varchar(64) NOT NULL COMMENT '投递意图唯一标识', + task_id bigint unsigned NOT NULL COMMENT '关联导出任务ID', + dispatch_version int NOT NULL COMMENT '任务派发版本', + topic varchar(128) NOT NULL COMMENT '目标Topic', + tag varchar(64) NOT NULL COMMENT '消息Tag', + message_key varchar(64) NOT NULL COMMENT '消息Key', + payload_json json NOT NULL COMMENT '命令消息体', + status varchar(24) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待发送、CLAIMED已领取、SENT已发送、QUARANTINED隔离', + attempt_count int NOT NULL DEFAULT '0' COMMENT '累计发送尝试次数', + next_retry_at datetime(3) NOT NULL COMMENT '允许发送的最早时间', + lease_owner varchar(128) DEFAULT NULL COMMENT '领取实例标识', + lease_until datetime(3) DEFAULT NULL COMMENT '领取租约截止时间', + sent_at datetime(3) DEFAULT NULL COMMENT '发送成功时间', + last_error varchar(1000) DEFAULT NULL COMMENT '最后发送错误', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'Outbox记录创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'Outbox记录更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_file_export_outbox_event (event_id), + UNIQUE KEY uk_file_export_outbox_task_version (task_id,dispatch_version), + KEY idx_file_export_outbox_publish (status,next_retry_at,id), + KEY idx_file_export_outbox_task (task_id,dispatch_version,status,sent_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='导出命令事务Outbox表'; + +CREATE TABLE file_export_task ( + id bigint unsigned NOT NULL COMMENT '导出任务ID(雪花ID)', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键;平台任务固定为0', + app_code varchar(24) NOT NULL COMMENT '申请端:PLATFORM或MERCHANT', + task_no varchar(40) NOT NULL COMMENT '导出任务编号', + export_type varchar(64) NOT NULL COMMENT '导出类型', + format varchar(16) NOT NULL DEFAULT 'XLSX' COMMENT '导出格式', + task_name varchar(128) NOT NULL COMMENT '任务名称', + parameters_json json NOT NULL COMMENT '筛选条件快照', + status varchar(24) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待处理、RUNNING执行中、SUCCEEDED成功、FAILED失败、EXPIRED已过期', + total_rows bigint unsigned DEFAULT NULL COMMENT '预计总行数', + processed_rows bigint unsigned NOT NULL DEFAULT '0' COMMENT '已处理行数', + file_name varchar(255) DEFAULT NULL COMMENT '下载文件名', + object_key varchar(512) DEFAULT NULL COMMENT '存储对象键', + content_type varchar(128) DEFAULT NULL COMMENT '文件类型', + size_bytes bigint unsigned DEFAULT NULL COMMENT '文件字节数', + failure_message varchar(1000) DEFAULT NULL COMMENT '失败原因', + dispatch_version int NOT NULL DEFAULT '1' COMMENT '派发版本,仅执行重试时递增', + attempt_count int NOT NULL DEFAULT '0' COMMENT '已执行尝试次数', + max_attempts int NOT NULL DEFAULT '3' COMMENT '自动重试上限', + next_retry_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '允许执行的最早时间', + lease_owner varchar(128) DEFAULT NULL COMMENT '当前执行实例标识', + lease_until datetime(3) DEFAULT NULL COMMENT '执行租约截止时间', + last_heartbeat_at datetime(3) DEFAULT NULL COMMENT '最后心跳时间', + execution_deadline_at datetime(3) DEFAULT NULL COMMENT '本次执行截止时间', + requested_by bigint unsigned NOT NULL COMMENT '申请用户ID', + requested_username varchar(128) DEFAULT NULL COMMENT '申请账号快照', + requested_at datetime(3) NOT NULL COMMENT '申请时间', + started_at datetime(3) DEFAULT NULL COMMENT '开始时间', + completed_at datetime(3) DEFAULT NULL COMMENT '完成时间', + expires_at datetime(3) 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_file_export_task_no (task_no), + KEY idx_file_export_owner (tenant_id,app_code,requested_by,requested_at), + KEY idx_file_export_status (status,requested_at), + KEY idx_file_export_dispatch (status,next_retry_at,id), + KEY idx_file_export_lease (status,lease_until), + KEY idx_file_export_expiry (status,expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='公共异步导出任务表'; + +CREATE TABLE file_export_task_guard ( + guard_key varchar(64) NOT NULL COMMENT '任务创建串行化锁键', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '锁记录最后更新时间', + PRIMARY KEY (guard_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='导出任务创建并发保护表:低频创建入口使用单行锁保证重复提交及活动上限'; + +CREATE TABLE file_object ( + id bigint unsigned NOT NULL COMMENT '文件ID(雪花ID)', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + business_type varchar(32) DEFAULT NULL COMMENT '关联业务类型', + business_id varchar(64) DEFAULT NULL COMMENT '关联业务逻辑ID', + original_name varchar(255) NOT NULL COMMENT '原始文件名', + object_key varchar(512) NOT NULL COMMENT '对象存储键', + storage_provider varchar(32) NOT NULL COMMENT '存储提供方', + bucket_name varchar(128) NOT NULL COMMENT '存储桶名称', + content_type varchar(128) NOT NULL COMMENT '文件内容类型', + size_bytes bigint unsigned NOT NULL COMMENT '文件字节数', + sha256 char(64) DEFAULT NULL COMMENT '文件SHA-256摘要', + status varchar(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '文件状态', + created_by bigint unsigned NOT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by bigint unsigned NOT 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_file_tenant_object (tenant_id,object_key), + KEY idx_file_tenant_business (tenant_id,business_type,business_id), + KEY idx_file_tenant_created (tenant_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文件元数据表'; + +-- 服务 schema:baiyue_audit +CREATE DATABASE IF NOT EXISTS baiyue_audit DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_audit; + +CREATE TABLE sys_audit_dead_letter ( + id bigint NOT NULL AUTO_INCREMENT COMMENT '死信记录自增ID', + event_id varchar(64) NOT NULL COMMENT '审计事件全局唯一ID', + payload_text longtext NOT NULL COMMENT 'RocketMQ死信原始载荷', + status varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待补偿、PROCESSING处理中、PROCESSED已处理、DEAD永久失败', + retry_count int NOT NULL DEFAULT '0' COMMENT '死信重试次数', + next_retry_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许补偿时间', + lease_owner varchar(128) DEFAULT NULL COMMENT '当前补偿租约持有实例', + lease_until datetime(3) DEFAULT NULL COMMENT '当前补偿租约截止时间', + last_error varchar(1000) DEFAULT NULL COMMENT '最近一次补偿错误', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '死信记录创建时间', + processed_at datetime(3) DEFAULT NULL COMMENT '补偿处理完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_audit_dead_event (event_id), + KEY idx_audit_dead_retry (status,next_retry_at,lease_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='审计消息死信补偿表'; + +CREATE TABLE sys_audit_log ( + id bigint NOT NULL AUTO_INCREMENT COMMENT '审计日志自增ID', + event_id varchar(64) DEFAULT NULL COMMENT '审计事件全局唯一ID', + event_type varchar(64) NOT NULL COMMENT '审计事件编码', + resource_type varchar(64) NOT NULL COMMENT '被操作资源类型', + resource_id varchar(128) DEFAULT NULL COMMENT '被操作资源ID', + description varchar(255) DEFAULT NULL COMMENT '操作说明', + request_id varchar(128) DEFAULT NULL COMMENT '请求ID,用于单次请求追踪', + trace_id varchar(128) DEFAULT NULL COMMENT '分布式链路追踪ID', + tenant_id bigint DEFAULT NULL COMMENT '客户ID和数据隔离键;平台级审计可为空或为0', + operator_tenant_id bigint DEFAULT NULL COMMENT '操作主体所属客户ID;平台操作可为空或为0', + user_id bigint DEFAULT NULL COMMENT '操作用户ID', + username varchar(128) DEFAULT NULL COMMENT '操作用户名快照', + app_code varchar(64) DEFAULT NULL COMMENT '操作来源应用端', + client_type varchar(32) DEFAULT NULL COMMENT '客户端类型', + http_method varchar(16) DEFAULT NULL COMMENT 'HTTP请求方法', + request_uri varchar(512) DEFAULT NULL COMMENT '请求URI', + client_ip varchar(64) DEFAULT NULL COMMENT '客户端IP', + duration_ms bigint NOT NULL DEFAULT '0' COMMENT '请求耗时(毫秒)', + success tinyint NOT NULL DEFAULT '0' COMMENT '操作是否成功', + error_code varchar(64) DEFAULT NULL COMMENT '失败时的错误码', + error_message varchar(512) DEFAULT NULL COMMENT '脱敏后的失败信息', + before_data json DEFAULT NULL COMMENT '变更前数据快照(已脱敏)', + after_data json DEFAULT NULL COMMENT '变更后数据快照(已脱敏)', + service_name varchar(64) DEFAULT 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_audit_event_id (event_id), + KEY idx_audit_tenant_time (tenant_id,occurred_at), + KEY idx_audit_event_time (event_type,occurred_at), + KEY idx_audit_user_time (user_id,occurred_at), + KEY idx_audit_request (request_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表'; + +-- 服务 schema:baiyue_event_bridge +CREATE DATABASE IF NOT EXISTS baiyue_event_bridge DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_event_bridge; + +CREATE TABLE bridge_delivery_attempt ( + id bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', + source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', + target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', + attempt_no int NOT NULL COMMENT '投递序号', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '投递结果', + response_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '对端响应码', + error_message varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '错误信息', + started_at datetime(3) NOT NULL COMMENT '开始时间', + finished_at datetime(3) DEFAULT NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_bridge_attempt (event_id,attempt_no), + KEY idx_bridge_attempt_event (event_id,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区域事件投递尝试记录'; + +CREATE TABLE bridge_inbox ( + event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', + idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '接收幂等键', + event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', + event_version int NOT NULL COMMENT '事件契约版本', + source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', + target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', + aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', + aggregate_version bigint NOT NULL COMMENT '聚合版本', + handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', + trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', + correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', + causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', + occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', + payload_json json NOT NULL COMMENT '事件负载', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', + lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '消费租约持有者', + lease_until datetime(3) DEFAULT NULL COMMENT '消费租约截止时间', + retry_count int NOT NULL DEFAULT '0' COMMENT '消费重试次数', + next_retry_at datetime(3) DEFAULT NULL COMMENT '下次消费时间', + last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', + received_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at datetime(3) DEFAULT NULL COMMENT '消费完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), + KEY idx_bridge_inbox_consume (status,next_retry_at,lease_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区域事件已接收箱'; + +CREATE TABLE bridge_outbox ( + event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', + idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送幂等键', + event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', + event_version int NOT NULL COMMENT '事件契约版本', + source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', + target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', + aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', + aggregate_version bigint NOT NULL COMMENT '聚合版本', + handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', + trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', + correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', + causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', + occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', + payload_json json NOT NULL COMMENT '事件负载', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', + retry_count int NOT NULL DEFAULT '0' COMMENT '已重试次数', + next_retry_at datetime(3) DEFAULT NULL COMMENT '下次重试时间', + lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '租约持有者', + lease_until datetime(3) DEFAULT NULL COMMENT '租约截止时间', + last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + delivered_at datetime(3) DEFAULT NULL COMMENT '投递成功时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), + KEY idx_bridge_outbox_schedule (target_region,status,next_retry_at,lease_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区域事件待投递箱'; + +-- 服务 schema:baiyue_finance +CREATE DATABASE IF NOT EXISTS baiyue_finance DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_finance; + +CREATE TABLE fin_audit_log ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + resource_type varchar(32) NOT NULL COMMENT '被审计财务资源类型', + resource_id bigint unsigned NOT NULL COMMENT '被审计财务资源ID', + action varchar(64) NOT NULL COMMENT '审计业务动作类型', + reason varchar(500) DEFAULT NULL COMMENT '操作原因或说明', + before_data json DEFAULT NULL COMMENT '操作前业务数据JSON快照', + after_data json DEFAULT NULL COMMENT '操作后业务数据JSON快照', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name_snapshot varchar(128) NOT NULL COMMENT '操作发生时的操作人名称快照', + request_id varchar(128) DEFAULT NULL COMMENT '入口请求ID,用于问题定位', + trace_id varchar(128) DEFAULT NULL COMMENT '分布式调用链追踪ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变审计记录创建时间', + PRIMARY KEY (id), + KEY idx_fin_audit_resource (tenant_id,resource_type,resource_id,created_at), + KEY idx_fin_audit_operator (operator_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Finance不可变业务审计明细'; + +CREATE TABLE fin_bill ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + bill_no varchar(64) NOT NULL COMMENT '客户范围内唯一账单编号', + subject_id bigint unsigned NOT NULL COMMENT '平台财务主体ID', + customer_name_snapshot varchar(200) NOT NULL COMMENT '出账时客户名称快照', + currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + bill_date date NOT NULL COMMENT '账单日期', + due_date date NOT NULL COMMENT '到期日期,由账单日期加客户账期计算', + status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '账单状态:DRAFT草稿、ISSUED已出账、PARTIALLY_PAID部分收款、PAID已收清、VOIDED已作废', + total_amount decimal(20,6) NOT NULL DEFAULT '0.000000' COMMENT '账单全部明细总金额,精度6位小数', + received_amount decimal(20,6) NOT NULL DEFAULT '0.000000' COMMENT '账单累计已收金额,精度6位小数', + outstanding_amount decimal(20,6) NOT NULL DEFAULT '0.000000' COMMENT '账单未收余额,精度6位小数', + remark varchar(500) DEFAULT NULL COMMENT '平台账单内部备注', + issued_at datetime(3) DEFAULT NULL COMMENT '出账时间,未出账时为空', + issued_by bigint unsigned DEFAULT NULL COMMENT '出账操作人ID,未出账时为空', + customer_confirmation_status varchar(24) NOT NULL DEFAULT 'NOT_REQUIRED' COMMENT '客户确认状态:NOT_REQUIRED无需确认、PENDING_CONFIRMATION待确认、CONFIRMED已确认', + customer_confirmed_by bigint unsigned DEFAULT NULL COMMENT '客户确认人用户ID', + customer_confirmed_by_name varchar(128) DEFAULT NULL COMMENT '客户确认人姓名快照', + customer_confirmed_at datetime(3) DEFAULT NULL COMMENT '客户确认时间', + voided_at datetime(3) DEFAULT NULL COMMENT '作废时间,未作废时为空', + voided_by bigint unsigned DEFAULT NULL COMMENT '作废操作人ID,未作废时为空', + void_reason varchar(500) DEFAULT NULL COMMENT '账单作废原因', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + created_by bigint unsigned NOT NULL COMMENT '创建操作人ID', + created_by_name varchar(128) DEFAULT NULL COMMENT '创建操作人名称快照', + updated_by bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + updated_by_name varchar(128) 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_fin_bill_no (tenant_id,bill_no), + KEY idx_fin_bill_customer_status_date (tenant_id,status,bill_date), + CONSTRAINT chk_fin_bill_amounts CHECK (((total_amount >= 0) and (received_amount >= 0) and (outstanding_amount >= 0))), + CONSTRAINT chk_fin_bill_currency CHECK ((currency_code = _utf8mb4'CNY')), + CONSTRAINT chk_fin_bill_tenant CHECK ((tenant_id > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户CNY账单;首期账单即应收台账'; + +CREATE TABLE fin_bill_line ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + bill_id bigint unsigned NOT NULL COMMENT '所属账单ID', + source_type varchar(32) NOT NULL DEFAULT 'FIRSTLEG_FEE' COMMENT '费用来源类型,FIRSTLEG_FEE表示头程费用', + source_id bigint unsigned NOT NULL COMMENT '来源费用ID', + source_version int unsigned NOT NULL COMMENT '出账固化的来源费用版本号', + source_no varchar(64) DEFAULT NULL COMMENT '来源费用单号快照,可为空', + order_id bigint unsigned DEFAULT NULL COMMENT '来源头程订单ID,可为空', + inbound_id bigint unsigned DEFAULT NULL COMMENT '来源入库单ID,可为空', + fee_item_code varchar(64) NOT NULL COMMENT '出账时费用项目编码快照', + fee_item_name varchar(200) NOT NULL COMMENT '出账时费用项目名称快照', + amount decimal(20,6) NOT NULL COMMENT '账单明细金额,精度6位小数', + adjustment_reason varchar(500) DEFAULT NULL COMMENT '最近一次账单调整行原因;完整历史保存在修订与审计记录', + currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_bill_line_source (source_type,source_id,source_version), + KEY idx_fin_bill_line_bill (tenant_id,bill_id), + CONSTRAINT chk_fin_bill_line_amount CHECK ((amount >= 0)), + CONSTRAINT chk_fin_bill_line_currency CHECK ((currency_code = _utf8mb4'CNY')), + CONSTRAINT chk_fin_bill_line_tenant CHECK ((tenant_id > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='账单费用来源快照明细'; + +CREATE TABLE fin_bill_revision ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + bill_id bigint unsigned NOT NULL COMMENT '被调整账单ID', + version int unsigned NOT NULL COMMENT '调整前账单乐观锁版本号', + reason varchar(500) DEFAULT NULL COMMENT '本次账单调整原因', + bill_snapshot json NOT NULL COMMENT '调整前账单主记录不可变JSON快照', + line_snapshot json NOT NULL COMMENT '调整前全部账单明细不可变JSON数组快照', + created_by bigint unsigned NOT NULL COMMENT '执行调整的操作人ID', + created_by_name varchar(128) DEFAULT NULL COMMENT '执行调整的操作人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '修订快照创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_bill_revision (bill_id,version), + KEY idx_fin_bill_revision_tenant (tenant_id,bill_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='出账后调整前的不可变账单及明细版本快照'; + +CREATE TABLE fin_cost ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + cost_no varchar(64) NOT NULL COMMENT '客户范围内唯一成本编号', + cost_type varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票成本、CONTAINER_SHARED整柜公共成本', + cost_origin varchar(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付、BUSINESS_DIRECT_PAID其他业务直接已付', + source_system varchar(32) DEFAULT NULL COMMENT '来源系统,头程直接已付固定FIRSTLEG', + source_type varchar(32) DEFAULT NULL COMMENT '来源费用类型快照', + source_fee_id bigint unsigned DEFAULT NULL COMMENT '来源费用ID', + source_fee_version int unsigned DEFAULT NULL COMMENT '来源费用版本', + source_hash varchar(71) DEFAULT NULL COMMENT '来源不可变快照SHA-256,兼容sha256前缀格式', + order_id bigint unsigned DEFAULT NULL COMMENT '成本归属的头程订单ID,单票成本必填', + inbound_id bigint unsigned DEFAULT NULL COMMENT '成本关联的入库单ID,可为空', + container_id bigint unsigned DEFAULT NULL COMMENT 'Firstleg装柜计划ID,整柜公共成本必填', + warehouse_id bigint unsigned DEFAULT NULL COMMENT '业务直接已付成本关联仓库ID,可为空', + supplier_id bigint unsigned DEFAULT NULL COMMENT 'Warehouse公共往来单位或供应商引用ID', + supplier_name_snapshot varchar(200) DEFAULT NULL COMMENT '登记成本时供应商或成本对象名称快照', + fee_item_code varchar(64) NOT NULL COMMENT '成本费用项目编码快照', + fee_item_name varchar(200) NOT NULL COMMENT '成本费用项目名称快照', + currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + cost_amount decimal(20,6) NOT NULL COMMENT '成本总金额;仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款', + voucher_file_ids json NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON', + paid_at datetime(3) DEFAULT NULL COMMENT '直接已付时间', + payment_method varchar(32) DEFAULT NULL COMMENT '直接已付方式', + payment_reference_no varchar(128) DEFAULT NULL COMMENT '直接已付流水号', + status varchar(16) NOT NULL DEFAULT 'DRAFT' COMMENT '成本状态:DRAFT草稿、CONFIRMED已确认、VOIDED已作废', + remark varchar(500) DEFAULT NULL COMMENT '平台成本内部备注', + confirmed_at datetime(3) DEFAULT NULL COMMENT '成本确认时间', + confirmed_by bigint unsigned DEFAULT NULL COMMENT '成本确认操作人ID', + voided_at datetime(3) DEFAULT NULL COMMENT '成本作废时间', + voided_by bigint unsigned DEFAULT NULL COMMENT '成本作废操作人ID', + void_reason varchar(500) DEFAULT NULL COMMENT '成本作废原因', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + created_by bigint unsigned NOT NULL COMMENT '创建操作人ID', + created_by_name varchar(128) DEFAULT NULL COMMENT '创建操作人名称快照', + updated_by bigint unsigned NOT 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_fin_cost_no (tenant_id,cost_no), + UNIQUE KEY uk_fin_cost_source_version (source_system,source_type,source_fee_id,source_fee_version), + KEY idx_fin_cost_ticket (tenant_id,order_id,inbound_id), + KEY idx_fin_cost_container (tenant_id,container_id), + CONSTRAINT chk_fin_cost_amount CHECK ((cost_amount > 0)), + CONSTRAINT chk_fin_cost_currency CHECK ((currency_code = _utf8mb4'CNY')), + CONSTRAINT chk_fin_cost_origin CHECK ((((cost_origin = _utf8mb4'MANUAL') and (source_system is null) and (source_type is null) and (source_fee_id is null) and (source_fee_version is null) and (source_hash is null)) or ((cost_origin = _utf8mb4'FIRSTLEG_DIRECT_PAID') and (source_system = _utf8mb4'FIRSTLEG') and (source_type is not null) and (source_fee_id is not null) and (source_fee_version is not null) and (source_hash is not null) and (status in (_utf8mb4'CONFIRMED',_utf8mb4'VOIDED')) and (paid_at is not null) and (payment_method in (_utf8mb4'ONLINE',_utf8mb4'BANK_TRANSFER',_utf8mb4'CASH',_utf8mb4'OTHER'))) or ((cost_origin = _utf8mb4'BUSINESS_DIRECT_PAID') and (source_system not in (_utf8mb4'',_utf8mb4'FIRSTLEG')) and (source_type is not null) and (source_fee_id is not null) and (source_fee_version is not null) and (source_hash is not null) and (status in (_utf8mb4'CONFIRMED',_utf8mb4'VOIDED')) and (paid_at is not null) and (payment_method in (_utf8mb4'ONLINE',_utf8mb4'BANK_TRANSFER',_utf8mb4'CASH',_utf8mb4'OTHER'))))), + CONSTRAINT chk_fin_cost_target CHECK ((((cost_type = _utf8mb4'SINGLE_ORDER') and (order_id is not null) and (container_id is null)) or ((cost_type = _utf8mb4'CONTAINER_SHARED') and (container_id is not null)) or ((cost_type = _utf8mb4'SOURCE_DIRECT_PAID') and ((order_id is not null) or (inbound_id is not null) or (container_id is not null) or (warehouse_id is not null))))), + CONSTRAINT chk_fin_cost_tenant CHECK ((tenant_id > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='人工成本;单票直接归属,整柜按体积分摊'; + +CREATE TABLE fin_cost_allocation ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + cost_id bigint unsigned NOT NULL COMMENT '整柜公共成本ID', + order_id bigint unsigned NOT NULL COMMENT '参与分摊的头程订单ID', + inbound_id bigint unsigned DEFAULT NULL COMMENT '参与分摊订单关联的入库单ID,可为空', + volume_cbm decimal(20,6) NOT NULL COMMENT '该订单分摊时有效体积快照,单位CBM、精度6位', + total_volume_cbm decimal(20,6) NOT NULL COMMENT '柜内有效订单总体积快照,单位CBM、精度6位', + allocation_rate decimal(20,10) NOT NULL COMMENT '订单体积占总体积的分摊比例,精度10位', + allocated_amount decimal(20,6) NOT NULL COMMENT '分摊到该订单的成本金额,精度6位', + rounding_adjustment tinyint NOT NULL DEFAULT '0' COMMENT '尾差归集标记:0未归集、1该行为实际尾差归集行', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变分摊快照创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_cost_allocation_order (cost_id,order_id), + KEY idx_fin_cost_allocation_query (tenant_id,order_id), + CONSTRAINT chk_fin_cost_allocation_amount CHECK ((allocated_amount >= 0)), + CONSTRAINT chk_fin_cost_allocation_volume CHECK (((volume_cbm >= 0) and (total_volume_cbm > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='整柜成本按有效体积分摊的可审计结果'; + +CREATE TABLE fin_customer_settlement_profile ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + customer_name_snapshot varchar(200) NOT NULL COMMENT '客户名称快照,客户主数据后续变更不追改', + credit_days int unsigned NOT NULL DEFAULT '30' COMMENT '客户账期自然日数,默认30天', + status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '客户结算配置状态,ACTIVE表示启用', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + created_by bigint unsigned NOT NULL COMMENT '创建操作人ID', + updated_by bigint unsigned NOT 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_fin_customer_profile (tenant_id), + CONSTRAINT chk_fin_customer_profile_days CHECK ((credit_days <= 3650)), + CONSTRAINT chk_fin_customer_profile_tenant CHECK ((tenant_id > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户结算配置;一客户一个结算对象'; + +CREATE TABLE fin_document_sequence ( + sequence_key varchar(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如REC:20260818', + current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_fin_document_sequence_value CHECK ((current_value > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号'; + +CREATE TABLE fin_fee_source ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + source_system varchar(32) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '费用来源系统,当前FIRSTLEG表示头程服务', + source_fee_id bigint unsigned NOT NULL COMMENT '来源系统费用记录ID', + source_fee_version int unsigned NOT NULL COMMENT '来源费用版本号,用于固化不可变来源版本', + customer_id bigint unsigned NOT NULL COMMENT '费用来源所属客户ID', + source_type varchar(32) NOT NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用', + source_no varchar(64) DEFAULT NULL COMMENT '来源入库单号或柜号快照,可为空', + business_type varchar(32) DEFAULT NULL COMMENT '来源业务类型,如海外入仓、采购、卡车或柜级分摊', + business_id bigint unsigned DEFAULT NULL COMMENT '来源业务记录ID,可为空', + business_no varchar(128) DEFAULT NULL COMMENT '来源业务编号快照,可为空', + inbound_code varchar(64) DEFAULT NULL COMMENT '来源入仓码快照;并非所有费用必填', + warehouse_id bigint unsigned DEFAULT NULL COMMENT '来源海外仓或业务仓ID,可为空', + direction varchar(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付', + order_id bigint unsigned DEFAULT NULL COMMENT '来源头程订单ID,可为空', + inbound_id bigint unsigned DEFAULT NULL COMMENT '来源入库单ID,可为空', + container_id bigint unsigned DEFAULT NULL COMMENT '来源Firstleg装柜计划ID,可为空', + container_booking_id bigint unsigned DEFAULT NULL COMMENT '来源订舱柜ID,可为空', + container_no varchar(64) DEFAULT NULL COMMENT '真实集装箱号快照,可为空', + fee_item_code varchar(64) NOT NULL COMMENT '来源费用项目编码快照', + fee_item_name varchar(200) NOT NULL COMMENT '来源费用项目名称快照', + amount decimal(20,6) NOT NULL COMMENT '来源费用金额,精度6位小数', + currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + settlement_mode varchar(16) DEFAULT NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空', + paid_at datetime(3) DEFAULT NULL COMMENT 'DIRECT_PAID业务已付时间', + payment_method varchar(32) DEFAULT NULL COMMENT 'DIRECT_PAID付款方式', + payment_reference_no varchar(128) DEFAULT NULL COMMENT 'DIRECT_PAID付款流水号', + payment_voucher_file_ids json DEFAULT NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', + source_hash varchar(71) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希', + source_snapshot json NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照', + review_status varchar(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回', + review_reason varchar(500) DEFAULT NULL COMMENT 'Finance费用审核说明或驳回原因', + operation_version int unsigned NOT NULL DEFAULT '0' COMMENT '费用编辑、提交、审核、调整和作废操作版本号', + wallet_hold_id bigint unsigned DEFAULT NULL COMMENT '已由客户资金账户结算时关联的资金冻结记录ID', + status varchar(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废', + 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_fin_fee_source (source_system,source_type,source_fee_id,source_fee_version), + KEY idx_fin_fee_source_customer (customer_id,status), + KEY idx_fin_fee_source_wallet_hold (wallet_hold_id), + CONSTRAINT chk_fin_fee_source_amount CHECK ((amount >= 0)), + CONSTRAINT chk_fin_fee_source_currency CHECK ((currency_code = _utf8mb4'CNY')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Firstleg费用来源快照池;账单人工选择并固化明细'; + +CREATE TABLE fin_idempotency_record ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + operator_id bigint unsigned NOT NULL COMMENT '发起幂等写操作的操作人ID', + operation varchar(64) NOT NULL COMMENT '幂等隔离的财务操作类型', + idempotency_key varchar(128) NOT NULL COMMENT '调用方提供的幂等键', + request_hash char(64) NOT NULL COMMENT '规范化完整请求的SHA-256十六进制哈希', + status varchar(16) NOT NULL COMMENT '幂等处理状态:PROCESSING处理中、SUCCEEDED成功、FAILED失败', + result_resource_id bigint unsigned DEFAULT NULL COMMENT '成功操作生成或更新的业务资源ID', + error_code varchar(64) 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_fin_idempotency (tenant_id,operator_id,operation,idempotency_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Finance人工写操作幂等记录'; + +CREATE TABLE fin_inbox_event ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + event_id varchar(128) NOT NULL COMMENT '来源系统事件唯一ID', + source_system varchar(32) NOT NULL COMMENT '事件来源系统标识', + event_type varchar(64) NOT NULL COMMENT '来源事件类型', + payload json NOT NULL COMMENT '来源事件原始JSON载荷快照', + payload_hash varchar(71) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测', + result_resource_id bigint unsigned DEFAULT NULL COMMENT '成功处理生成或定位的费用来源快照ID', + error_code varchar(64) DEFAULT NULL COMMENT '处理失败时的稳定FIN错误码', + status varchar(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '事件处理状态:RECEIVED已接收、PROCESSED已完成、FAILED处理失败', + received_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件接收时间', + processed_at datetime(3) DEFAULT NULL COMMENT '事件处理完成时间,未完成时为空', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_inbox_event (source_system,event_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Firstleg费用事件幂等接收记录'; + +CREATE TABLE fin_receipt ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + receipt_no varchar(64) NOT NULL COMMENT '客户范围内唯一收款编号', + subject_id bigint unsigned NOT NULL COMMENT '实际收款的平台财务主体ID', + currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + received_amount decimal(20,6) NOT NULL COMMENT '本次实际到账金额,精度6位小数', + received_at datetime(3) NOT NULL COMMENT '实际到账时间', + payment_method varchar(32) DEFAULT NULL COMMENT '收款方式编码', + voucher_no varchar(128) DEFAULT NULL COMMENT '银行流水号或线下收款凭证号', + voucher_file_ids json NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON', + status varchar(16) NOT NULL DEFAULT 'CONFIRMED' COMMENT '收款状态:CONFIRMED已确认、VOIDED已作废', + remark varchar(500) DEFAULT NULL COMMENT '平台收款内部备注', + voided_at datetime(3) DEFAULT NULL COMMENT '收款作废时间', + voided_by bigint unsigned DEFAULT NULL COMMENT '收款作废操作人ID', + void_reason varchar(500) DEFAULT NULL COMMENT '收款作废原因', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + created_by bigint unsigned NOT NULL COMMENT '创建操作人ID', + created_by_name varchar(128) DEFAULT NULL COMMENT '创建操作人名称快照', + updated_by bigint unsigned NOT 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_fin_receipt_no (tenant_id,receipt_no), + KEY idx_fin_receipt_customer_time (tenant_id,received_at), + CONSTRAINT chk_fin_receipt_amount CHECK ((received_amount > 0)), + CONSTRAINT chk_fin_receipt_currency CHECK ((currency_code = _utf8mb4'CNY')), + CONSTRAINT chk_fin_receipt_tenant CHECK ((tenant_id > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台财务人工登记收款及凭证'; + +CREATE TABLE fin_receipt_bill ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + receipt_id bigint unsigned NOT NULL COMMENT '收款记录ID', + bill_id bigint unsigned NOT NULL COMMENT '被分配收款的账单ID', + applied_amount decimal(20,6) NOT NULL COMMENT '本笔收款分配到该账单的金额,精度6位小数', + created_by bigint unsigned NOT NULL COMMENT '分配操作人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '收款分配创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_receipt_bill_pair (receipt_id,bill_id), + KEY idx_fin_receipt_bill_bill (tenant_id,bill_id), + CONSTRAINT chk_fin_receipt_bill_amount CHECK ((applied_amount > 0)), + CONSTRAINT chk_fin_receipt_bill_tenant CHECK ((tenant_id > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='人工收款对应账单金额;UI不使用复杂核销术语'; + +CREATE TABLE fin_recharge_request ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键', + request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####', + amount decimal(20,2) NOT NULL COMMENT '申请充值金额', + payment_method varchar(16) NOT NULL COMMENT 'WECHAT或ALIPAY', + voucher_file_id bigint unsigned NOT NULL COMMENT '付款截图File ID', + voucher_file_name varchar(255) NOT NULL COMMENT '付款截图文件名快照', + status varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回', + version int unsigned NOT NULL DEFAULT '0' COMMENT '审核乐观锁版本', + submitted_by bigint unsigned NOT NULL COMMENT '客户提交人ID', + submitted_by_name varchar(128) DEFAULT NULL COMMENT '客户提交人名称快照', + reviewed_by bigint unsigned DEFAULT NULL COMMENT '平台审核人ID', + reviewed_by_name varchar(128) DEFAULT NULL COMMENT '平台审核人名称快照', + review_remark varchar(500) DEFAULT NULL COMMENT '审核说明或驳回原因', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', + reviewed_at datetime(3) DEFAULT NULL COMMENT '平台审核完成时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_recharge_request_no (request_no), + KEY idx_fin_recharge_customer_status (customer_id,status,created_at,id), + CONSTRAINT chk_fin_recharge_amount CHECK ((amount > 0)), + CONSTRAINT chk_fin_recharge_customer CHECK ((customer_id > 0)), + CONSTRAINT chk_fin_recharge_method CHECK ((payment_method in (_utf8mb4'WECHAT',_utf8mb4'ALIPAY'))), + CONSTRAINT chk_fin_recharge_status CHECK ((status in (_utf8mb4'PENDING',_utf8mb4'APPROVED',_utf8mb4'REJECTED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户付款截图充值申请;平台审核通过后入账'; + +CREATE TABLE fin_settlement ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '正式应收或应付结算单ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + settlement_no varchar(64) NOT NULL COMMENT '客户范围内唯一结算单号', + direction varchar(16) NOT NULL COMMENT '结算方向:RECEIVABLE应收、PAYABLE应付', + customer_name_snapshot varchar(200) NOT NULL COMMENT '结算生成时客户名称快照', + supplier_id bigint unsigned DEFAULT NULL COMMENT '应付结算供应商ID;应收结算为空', + supplier_name_snapshot varchar(200) DEFAULT NULL COMMENT '应付结算供应商名称快照', + reconciliation_statement_id bigint unsigned DEFAULT NULL COMMENT '应收结算关联的已出账客户对账单ID', + business_period varchar(32) DEFAULT NULL COMMENT '结算业务期间快照', + currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + total_amount decimal(20,6) NOT NULL COMMENT '正式结算总金额,精度6位小数', + paid_amount decimal(20,6) NOT NULL DEFAULT '0.000000' COMMENT '累计已收或已付金额;PAYABLE已付计入已线下支付供应商款', + outstanding_amount decimal(20,6) NOT NULL COMMENT '未结清金额;RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付', + generated_date date NOT NULL COMMENT '结算单生成日期', + due_date date NOT NULL COMMENT '结算到期日期', + payment_status varchar(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清', + status varchar(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + created_by bigint unsigned NOT NULL COMMENT '创建操作人ID', + updated_by bigint unsigned NOT 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_fin_settlement_no (tenant_id,settlement_no), + UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id,reconciliation_statement_id,direction), + KEY idx_fin_settlement_customer_direction_status (tenant_id,direction,status), + KEY idx_fin_settlement_supplier_status (supplier_id,direction,status), + KEY idx_fin_settlement_receivable_statistics (direction,currency_code,generated_date,status,tenant_id), + CONSTRAINT chk_fin_settlement_amounts CHECK (((total_amount >= 0) and (paid_amount >= 0) and (outstanding_amount >= 0) and (total_amount = (paid_amount + outstanding_amount)))), + CONSTRAINT chk_fin_settlement_currency CHECK ((currency_code = _utf8mb4'CNY')), + CONSTRAINT chk_fin_settlement_direction CHECK ((direction in (_utf8mb4'RECEIVABLE',_utf8mb4'PAYABLE'))), + CONSTRAINT chk_fin_settlement_tenant CHECK ((tenant_id > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='正式应收与应付结算单;客户账户金额由本表派生'; + +CREATE TABLE fin_settlement_line ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '正式结算单明细ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + settlement_id bigint unsigned NOT NULL COMMENT '所属正式结算单ID', + fee_source_id bigint unsigned NOT NULL COMMENT '结算固化的Finance费用来源ID', + amount decimal(20,6) NOT NULL COMMENT '结算明细金额,精度6位小数', + currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '明细创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_settlement_line_source (settlement_id,fee_source_id), + KEY idx_fin_settlement_line_customer (tenant_id,settlement_id), + KEY idx_fin_settlement_line_source (fee_source_id), + CONSTRAINT chk_fin_settlement_line_amount CHECK ((amount > 0)), + CONSTRAINT chk_fin_settlement_line_currency CHECK ((currency_code = _utf8mb4'CNY')), + CONSTRAINT chk_fin_settlement_line_tenant CHECK ((tenant_id > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='正式结算单费用来源金额快照明细'; + +CREATE TABLE fin_settlement_payment ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '正式结算单收付款记录ID', + tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + settlement_id bigint unsigned NOT NULL COMMENT '所属正式结算单ID', + transaction_type varchar(16) NOT NULL COMMENT '交易类型:RECEIPT收款、PAYMENT付款', + transaction_no varchar(64) NOT NULL COMMENT '客户范围内唯一收付款业务号', + transaction_date date NOT NULL COMMENT '实际收款或付款日期', + amount decimal(20,6) NOT NULL COMMENT '本次收款或付款金额,精度6位小数', + currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + method varchar(32) NOT NULL COMMENT '人工登记的收付款方式', + reference_no varchar(128) DEFAULT NULL COMMENT '银行流水号或外部参考号', + voucher_file_ids json NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组', + remark varchar(500) DEFAULT NULL COMMENT '收付款内部备注', + created_by bigint unsigned NOT NULL COMMENT '登记操作人ID', + created_by_name varchar(128) DEFAULT NULL COMMENT '登记操作人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_settlement_payment_no (tenant_id,transaction_no), + KEY idx_fin_settlement_payment_settlement (tenant_id,settlement_id,created_at), + CONSTRAINT chk_fin_settlement_payment_amount CHECK ((amount > 0)), + CONSTRAINT chk_fin_settlement_payment_currency CHECK ((currency_code = _utf8mb4'CNY')), + CONSTRAINT chk_fin_settlement_payment_tenant CHECK ((tenant_id > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='正式结算单人工收付款与凭证记录'; + +CREATE TABLE fin_subject ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + subject_code varchar(64) NOT NULL COMMENT '平台财务主体唯一编码', + subject_name varchar(200) NOT NULL COMMENT '平台收付款及核算主体名称', + status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '财务主体状态,ACTIVE表示启用', + version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + created_by bigint unsigned NOT NULL DEFAULT '0' COMMENT '创建操作人ID,0表示系统初始化', + updated_by bigint unsigned NOT NULL DEFAULT '0' COMMENT '最后修改操作人ID,0表示系统初始化', + 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_fin_subject_code (subject_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台财务主体;首期仅一个有效主体'; + +CREATE TABLE fin_wallet_account ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键', + currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT '当前仅CNY', + actual_balance decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '账户实际余额,授信消费可形成负余额', + frozen_balance decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '冻结总额,包含实际资金与授信冻结', + available_balance decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '当前总可用余额;授信失效后不再计入', + credit_limit decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '授信额度', + credit_used decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '授信有效期内累计已使用额度;到期后并入实际余额', + credit_frozen decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '冻结总额中占用的授信', + credit_expires_on date DEFAULT NULL COMMENT '授信到期日,当天仍有效;次日失效', + version int unsigned NOT NULL DEFAULT '0' COMMENT '余额乐观锁版本', + created_by bigint unsigned NOT NULL DEFAULT '0' COMMENT '创建操作人ID', + updated_by bigint unsigned NOT NULL DEFAULT '0' 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_fin_wallet_account_customer (customer_id,currency_code), + CONSTRAINT chk_fin_wallet_account_balance CHECK (((frozen_balance >= 0) and (credit_limit >= 0) and (credit_used >= 0) and (credit_frozen >= 0) and (frozen_balance >= credit_frozen))), + CONSTRAINT chk_fin_wallet_account_currency CHECK ((currency_code = _utf8mb4'CNY')), + CONSTRAINT chk_fin_wallet_account_customer CHECK ((customer_id > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户CNY资金账户主账'; + +CREATE TABLE fin_wallet_command_inbox ( + event_id varchar(64) NOT NULL COMMENT '海外资金指令事件ID', + event_type varchar(96) NOT NULL COMMENT '事件类型', + payload_hash varchar(71) NOT NULL COMMENT '规范化载荷SHA-256,包含sha256:前缀', + payload_json json NOT NULL COMMENT '冻结事件信封', + status varchar(16) NOT NULL COMMENT 'PROCESSING、SUCCEEDED或FAILED', + result_json json DEFAULT NULL COMMENT '最终资金执行结果', + received_at datetime(3) NOT NULL COMMENT '接收时间', + processed_at datetime(3) DEFAULT NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + KEY idx_fin_wallet_command_status (status,received_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='跨区客户资金指令幂等收件箱'; + +CREATE TABLE fin_wallet_event_outbox ( + event_id varchar(64) NOT NULL COMMENT '资金结果事件ID', + event_key varchar(160) NOT NULL COMMENT '业务幂等键', + payload_json json NOT NULL COMMENT '完整事件信封', + status varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、PUBLISHED或DEAD', + retry_count int NOT NULL DEFAULT '0' COMMENT '失败次数', + next_retry_at datetime(3) NOT NULL COMMENT '下次投递时间', + locked_by varchar(128) DEFAULT NULL COMMENT '租约持有者', + locked_at datetime(3) DEFAULT NULL COMMENT '租约时间', + published_at datetime(3) DEFAULT NULL COMMENT '投递完成时间', + last_error varchar(1000) DEFAULT NULL COMMENT '最后失败摘要', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_fin_wallet_event_key (event_key), + KEY idx_fin_wallet_event_publish (status,next_retry_at,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内Finance到海外资金结果可靠发送箱'; + +CREATE TABLE fin_wallet_hold ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键', + business_type varchar(32) NOT NULL COMMENT '调用方业务类型', + business_no varchar(128) NOT NULL COMMENT '调用方业务单号', + amount decimal(20,2) NOT NULL COMMENT '冻结总额', + actual_frozen_amount decimal(20,2) NOT NULL COMMENT '冻结时占用实际资金', + credit_frozen_amount decimal(20,2) NOT NULL COMMENT '冻结时占用授信', + status varchar(16) NOT NULL COMMENT '冻结状态:FROZEN冻结中、SETTLED已结算、RELEASED已释放', + freeze_ledger_id bigint unsigned DEFAULT NULL COMMENT '冻结流水ID', + action_ledger_id bigint unsigned DEFAULT NULL COMMENT '结算或解冻流水ID', + remark varchar(500) DEFAULT NULL COMMENT '业务说明', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '冻结记录创建时间', + completed_at datetime(3) DEFAULT NULL COMMENT '结算或释放完成时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_wallet_hold_business (customer_id,business_type,business_no), + KEY idx_fin_wallet_hold_status (customer_id,status,created_at,id), + CONSTRAINT chk_fin_wallet_hold_amount CHECK (((amount > 0) and (actual_frozen_amount >= 0) and (credit_frozen_amount >= 0) and (amount = (actual_frozen_amount + credit_frozen_amount)))), + CONSTRAINT chk_fin_wallet_hold_customer CHECK ((customer_id > 0)), + CONSTRAINT chk_fin_wallet_hold_status CHECK ((status in (_utf8mb4'FROZEN',_utf8mb4'SETTLED',_utf8mb4'RELEASED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户资金冻结及结算生命周期'; + +CREATE TABLE fin_wallet_ledger ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键', + business_no varchar(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####', + change_type varchar(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整', + amount decimal(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负', + actual_balance_after decimal(20,2) NOT NULL COMMENT '变动后实际余额', + frozen_balance_after decimal(20,2) NOT NULL COMMENT '变动后冻结金额', + available_balance_after decimal(20,2) NOT NULL COMMENT '变动后可用余额', + credit_limit_after decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '变动后授信额度', + credit_used_after decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '变动后已用授信', + available_credit_after decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '变动后可用授信', + credit_expires_on_after date DEFAULT NULL COMMENT '变动后授信到期日', + source_type varchar(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等', + source_no varchar(128) DEFAULT NULL COMMENT '充值申请号或外部凭证号', + occurred_date date NOT NULL COMMENT '业务发生日期', + payment_method varchar(32) DEFAULT NULL COMMENT '充值或扣款方式', + reference_no varchar(128) DEFAULT NULL COMMENT '收款凭证号或关联单据号', + operator_id bigint unsigned NOT NULL COMMENT '操作人ID', + operator_name varchar(128) DEFAULT NULL COMMENT '操作人名称快照', + remark varchar(500) DEFAULT NULL COMMENT '变动说明', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_wallet_ledger_no (business_no), + KEY idx_fin_wallet_ledger_customer_time (customer_id,created_at,id), + KEY idx_fin_wallet_ledger_source (customer_id,source_type,source_no), + CONSTRAINT chk_fin_wallet_ledger_amount CHECK (((amount <> 0) or (change_type = _utf8mb4'CREDIT_LIMIT'))), + CONSTRAINT chk_fin_wallet_ledger_balance CHECK (((frozen_balance_after >= 0) and (credit_limit_after >= 0) and (credit_used_after >= 0) and (available_credit_after >= 0))), + CONSTRAINT chk_fin_wallet_ledger_customer CHECK ((customer_id > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户资金账户不可变余额流水'; + +-- 业务约束触发器:保留最终定义,不包含发布/迁移过程。 + +USE baiyue_order; +DELIMITER $$ + +CREATE TRIGGER trg_order_fulfillment_split_insert BEFORE INSERT ON ord_order_fulfillment_split FOR EACH ROW BEGIN + IF NEW.split_role NOT IN ('LOADED','REMAINDER') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单履约分片角色非法'; + END IF; + IF NEW.split_role='LOADED' AND NEW.child_order_id<>NEW.source_order_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='实装台账必须由来源订单自身承接'; + END IF; + IF NEW.split_role='REMAINDER' + AND (NEW.child_order_id=NEW.source_order_id OR NEW.remaining_package_count=0) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='余量台账必须指向有剩余数量的新子订单'; + END IF; +END$$ + +CREATE TRIGGER trg_order_fulfillment_split_update BEFORE UPDATE ON ord_order_fulfillment_split FOR EACH ROW BEGIN + IF NOT (NEW.tenant_id<=>OLD.tenant_id) + OR NOT (NEW.parent_order_id<=>OLD.parent_order_id) + OR NOT (NEW.source_order_id<=>OLD.source_order_id) + OR NOT (NEW.child_order_id<=>OLD.child_order_id) + OR NOT (NEW.split_role<=>OLD.split_role) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单履约分片来源、子片与角色不可修改'; + END IF; +END$$ + +CREATE TRIGGER trg_ord_order_parent_insert BEFORE INSERT ON ord_order FOR EACH ROW BEGIN + IF NEW.order_role='MASTER' THEN + SET NEW.parent_order_id=NULL; + IF NEW.root_order_id IS NULL THEN SET NEW.root_order_id=NEW.id; END IF; + ELSEIF NEW.order_role='CHILD' THEN + IF NEW.parent_order_id IS NULL OR NEW.root_order_id IS NULL OR NEW.split_sequence IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='子订单必须有父订单、根订单和顺序号'; + END IF; + IF NEW.root_order_id<>NEW.parent_order_id OR EXISTS( + SELECT 1 FROM ord_order p WHERE p.id=NEW.parent_order_id AND p.order_role<>'MASTER') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='子订单只允许直接挂在主订单下'; + END IF; + IF NOT EXISTS(SELECT 1 FROM ord_order p WHERE p.id=NEW.parent_order_id AND p.tenant_id=NEW.tenant_id AND p.deleted=0) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='父订单不存在或客户归属不一致'; + END IF; + ELSE + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单层级非法'; + END IF; +END$$ + +CREATE TRIGGER trg_ord_order_parent_update BEFORE UPDATE ON ord_order FOR EACH ROW BEGIN + IF NEW.order_role<>OLD.order_role OR NOT (NEW.parent_order_id<=>OLD.parent_order_id) + OR NOT (NEW.root_order_id<=>OLD.root_order_id) OR NOT (NEW.split_sequence<=>OLD.split_sequence) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单父子关系不可修改'; + END IF; +END$$ + +DELIMITER ; + +USE baiyue_firstleg; +DELIMITER $$ + +CREATE TRIGGER trg_carrier_schedule_attempt_before_delete BEFORE DELETE ON fl_carrier_schedule_query_attempt FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可删除'; END$$ + +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$$ + +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$$ + +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_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_history_before_update BEFORE UPDATE ON fl_carrier_schedule_query_history 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_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_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW BEGIN + IF NOT EXISTS (SELECT 1 FROM fl_ocean_booking booking + WHERE booking.id=NEW.ocean_booking_id AND booking.customer_id=NEW.customer_id) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询客户与订舱不一致'; + END IF; + IF NEW.container_booking_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM fl_ocean_booking_container_allocation allocation + WHERE allocation.ocean_booking_id=NEW.ocean_booking_id + AND allocation.container_booking_id=NEW.container_booking_id + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询柜次未分配至订舱'; + END IF; +END$$ + +CREATE TRIGGER trg_container_booking_before_update BEFORE UPDATE ON fl_container_booking FOR EACH ROW BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status + FROM baiyue_firstleg.fl_loading_plan WHERE container_booking_id = OLD.id; + IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( + NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) + OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) + OR NOT (OLD.channel_group_code <=> NEW.channel_group_code) + OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) + OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) + OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) + OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) + OR NOT (OLD.container_type <=> NEW.container_type) + OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) + OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) + OR NOT (OLD.destination_port_code <=> NEW.destination_port_code) OR NOT (OLD.destination_port_name <=> NEW.destination_port_name) + OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code) OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) + OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) + OR NOT (OLD.terminal_trucking_company_id <=> NEW.terminal_trucking_company_id) + OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) + OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) + OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) + OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name) + OR NOT (OLD.created_at <=> NEW.created_at) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜作业开始后仅允许修改集装箱号与放舱运输数据'; + END IF; +END$$ + +CREATE TRIGGER trg_container_document_before_delete BEFORE DELETE ON fl_container_document FOR EACH ROW 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' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可删除'; + END IF; +END$$ + +CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_container_document FOR EACH ROW 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','DELIVERY_ORDER') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可新增'; + END IF; +END$$ + +CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_container_document FOR EACH ROW 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','DELIVERY_ORDER') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可修改'; + END IF; +END$$ + +CREATE TRIGGER trg_container_exception_history_before_delete BEFORE DELETE ON fl_container_exception_history FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可删除'; END$$ + +CREATE TRIGGER trg_container_exception_history_before_update BEFORE UPDATE ON fl_container_exception_history FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可修改'; END$$ + +CREATE TRIGGER trg_inbound_order_parent_before_insert BEFORE INSERT ON fl_inbound_order FOR EACH ROW BEGIN + IF NEW.inbound_role NOT IN ('MASTER', 'CHILD') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级非法'; + END IF; + IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法'; + END IF; + IF NEW.split_purpose NOT IN ('MASTER', 'LOADED', 'REMAINDER') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单分片用途非法'; + END IF; + IF NEW.package_count IS NOT NULL AND NEW.package_count = 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0'; + END IF; + IF NEW.inbound_role = 'MASTER' AND NEW.parent_inbound_id IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入仓单不能绑定父单'; + END IF; + IF NEW.inbound_role = 'MASTER' AND NEW.split_purpose <> 'MASTER' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入仓单用途必须为MASTER'; + END IF; + IF NEW.inbound_role = 'CHILD' THEN + IF NEW.parent_inbound_id IS NULL OR NEW.root_inbound_id IS NULL + OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数'; + END IF; + IF NEW.split_purpose <> 'REMAINDER' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新建子入仓单只能作为余量分片'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM fl_inbound_order parent_order + WHERE parent_order.id = NEW.parent_inbound_id + AND parent_order.tenant_id = NEW.tenant_id + AND parent_order.inbound_role = 'MASTER' + AND parent_order.deleted = 0 + ) OR NEW.root_inbound_id <> NEW.parent_inbound_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须绑定同一客户的有效主单'; + END IF; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_order_parent_before_update BEFORE UPDATE ON fl_inbound_order FOR EACH ROW BEGIN + IF NOT (OLD.tenant_id <=> NEW.tenant_id) + OR NOT (OLD.inbound_role <=> NEW.inbound_role) + OR NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id) + OR NOT (OLD.root_inbound_id <=> NEW.root_inbound_id) + OR NOT (OLD.split_sequence <=> NEW.split_sequence) + OR NOT (OLD.split_purpose <=> NEW.split_purpose) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单主子归属创建后不可修改'; + END IF; + IF NEW.split_purpose NOT IN ('MASTER', 'LOADED', 'REMAINDER') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单分片用途非法'; + END IF; + IF NEW.inbound_role = 'CHILD' + AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0'; + END IF; + IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法'; + END IF; + IF (NEW.inbound_role = 'CHILD' AND NEW.split_purpose = 'MASTER') + OR (NEW.inbound_role = 'MASTER' AND NEW.split_purpose <> 'MASTER') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级与用途不一致'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_batch_before_insert BEFORE INSERT ON fl_inbound_split_batch FOR EACH ROW BEGIN + IF NEW.status <> 'PREPARED' OR NEW.applied_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次必须从PREPARED创建'; + END IF; + IF NEW.loaded_child_inbound_id IS NOT NULL + OR NEW.loaded_package_count = 0 + OR NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count + OR (NEW.remaining_package_count = 0 AND NEW.remainder_child_inbound_id IS NOT NULL) + OR (NEW.remaining_package_count > 0 AND NEW.remainder_child_inbound_id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新拆分批次必须由来源单承接实装且最多创建一个余量子单'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM fl_inbound_order parent_order + WHERE parent_order.id = NEW.parent_inbound_id + AND parent_order.tenant_id = NEW.tenant_id + AND parent_order.inbound_role = 'MASTER' + AND parent_order.deleted = 0 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次主单不存在或客户归属不一致'; + END IF; + IF NEW.source_inbound_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM fl_inbound_order source_order + WHERE source_order.id = NEW.source_inbound_id + AND source_order.tenant_id = NEW.tenant_id + AND source_order.deleted = 0 + AND ((source_order.id = NEW.parent_inbound_id + AND source_order.inbound_role = 'MASTER' + AND ((source_order.split_status = 'NOT_SPLIT' + AND COALESCE(source_order.package_count, source_order.total_package_count) = NEW.package_count) + OR (source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count))) OR + (source_order.inbound_role = 'CHILD' + AND source_order.parent_inbound_id = NEW.parent_inbound_id + AND source_order.split_purpose = 'REMAINDER' + AND ((source_order.split_status = 'NOT_SPLIT' + AND source_order.package_count = NEW.package_count) + OR (source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count)))) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源必须是箱数守恒的根主单或余量分片'; + END IF; + IF NEW.loaded_package_count > 0 AND ( + NEW.loading_plan_id IS NULL OR NEW.container_booking_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM fl_loading_plan plan + WHERE plan.id = NEW.loading_plan_id + AND plan.container_booking_id = NEW.container_booking_id + AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item + WHERE plan_item.loading_plan_id = plan.id + AND plan_item.account_id = NEW.tenant_id + AND plan_item.inbound_id = NEW.source_inbound_id + AND plan_item.deleted = 0) + ) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '实装拆分批次必须绑定匹配的装柜计划和柜次'; + END IF; + IF NEW.remainder_child_inbound_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM fl_inbound_order remainder_order + WHERE remainder_order.id = NEW.remainder_child_inbound_id + AND remainder_order.tenant_id = NEW.tenant_id + AND remainder_order.parent_inbound_id = NEW.parent_inbound_id + AND remainder_order.root_inbound_id = NEW.parent_inbound_id + AND remainder_order.inbound_role = 'CHILD' + AND remainder_order.split_purpose = 'REMAINDER' + AND remainder_order.split_status = 'NOT_SPLIT' + AND remainder_order.package_count = NEW.remaining_package_count + AND remainder_order.deleted = 0 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '余量子入仓单与拆分批次数量或根归属不一致'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_batch_before_update BEFORE UPDATE ON fl_inbound_split_batch FOR EACH ROW BEGIN + IF OLD.status IN ('APPLIED', 'CANCELLED') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已结束拆分批次不可修改'; + END IF; + IF NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id) + OR NOT (OLD.source_inbound_id <=> NEW.source_inbound_id) + OR NOT (OLD.loaded_child_inbound_id <=> NEW.loaded_child_inbound_id) + OR NOT (OLD.remainder_child_inbound_id <=> NEW.remainder_child_inbound_id) + OR NOT (OLD.package_count <=> NEW.package_count) + OR NOT (OLD.loaded_package_count <=> NEW.loaded_package_count) + OR NOT (OLD.remaining_package_count <=> NEW.remaining_package_count) + OR NOT (OLD.loading_plan_id <=> NEW.loading_plan_id) + OR NOT (OLD.container_booking_id <=> NEW.container_booking_id) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源、子单、柜次与数量创建后不可修改'; + END IF; + IF NEW.status = 'APPLIED' THEN + IF NEW.applied_at IS NULL + OR (NEW.loaded_package_count > 0 AND ( + NEW.loading_plan_id IS NULL OR NEW.container_booking_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM fl_loading_plan plan + WHERE plan.id = NEW.loading_plan_id + AND plan.container_booking_id = NEW.container_booking_id + AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item + WHERE plan_item.loading_plan_id = plan.id + AND plan_item.account_id = NEW.tenant_id + AND plan_item.inbound_id = NEW.source_inbound_id + AND plan_item.deleted = 0)))) + OR NOT EXISTS ( + SELECT 1 FROM fl_inbound_order parent_order + WHERE parent_order.id = NEW.parent_inbound_id + AND parent_order.tenant_id = NEW.tenant_id + AND parent_order.inbound_role = 'MASTER' + AND parent_order.version > NEW.expected_parent_version + ) OR NOT EXISTS ( + SELECT 1 FROM fl_inbound_order source_order + WHERE source_order.id = NEW.source_inbound_id + AND source_order.tenant_id = NEW.tenant_id + AND source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count + ) OR + (SELECT COALESCE(SUM(item.package_count), 0) + FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id) <> NEW.remaining_package_count OR + (SELECT COUNT(DISTINCT item.child_inbound_id) + FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id) <> CASE WHEN NEW.remaining_package_count > 0 THEN 1 ELSE 0 END OR + (NEW.loaded_child_inbound_id IS NOT NULL) OR + NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count OR + (NEW.remaining_package_count > 0 AND NEW.remainder_child_inbound_id IS NULL) OR + (NEW.remainder_child_inbound_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM fl_inbound_order c WHERE c.id = NEW.remainder_child_inbound_id + AND c.parent_inbound_id = NEW.parent_inbound_id AND c.root_inbound_id = NEW.parent_inbound_id + AND c.split_purpose = 'REMAINDER' AND c.split_status = 'NOT_SPLIT' + AND c.package_count = NEW.remaining_package_count AND c.deleted = 0)) OR + EXISTS (SELECT 1 FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + AND item.child_inbound_id <> NEW.remainder_child_inbound_id) OR + EXISTS (SELECT 1 + FROM fl_inbound_split_item item + JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + GROUP BY child_order.id, child_order.package_count + HAVING child_order.package_count <> SUM(item.package_count)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入仓单或拆分明细箱数合计不一致'; + END IF; + IF EXISTS ( + SELECT 1 + FROM fl_inbound_split_item split_item + JOIN fl_inbound_measurement_item source_item + ON source_item.id = split_item.source_measurement_item_id + WHERE split_item.tenant_id = NEW.tenant_id + AND split_item.split_batch_id = NEW.id + GROUP BY split_item.source_measurement_item_id, source_item.package_count + HAVING SUM(split_item.package_count) > source_item.package_count + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子单承接箱数超过来源测量箱规箱数'; + END IF; + IF NOT EXISTS ( + SELECT 1 + FROM fl_inbound_order root_order + WHERE root_order.id = NEW.parent_inbound_id + AND root_order.tenant_id = NEW.tenant_id + AND root_order.inbound_role = 'MASTER' + AND root_order.deleted = 0 + AND root_order.total_package_count = + COALESCE(root_order.package_count, 0) + + COALESCE((SELECT SUM(child.package_count) + FROM fl_inbound_order child + WHERE child.tenant_id = NEW.tenant_id + AND child.root_inbound_id = root_order.id + AND child.inbound_role = 'CHILD' + AND child.deleted = 0), 0) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '根入仓单原始箱数与已完成分片及当前余量不守恒'; + END IF; + ELSEIF NEW.status = 'CANCELLED' THEN + IF EXISTS ( + SELECT 1 FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有子单明细的拆分批次不可取消'; + END IF; + ELSEIF NEW.status <> 'PREPARED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次状态流转非法'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_detail_before_delete BEFORE DELETE ON fl_inbound_split_detail FOR EACH ROW BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定与拆单明细不可删除'; +END$$ + +CREATE TRIGGER trg_inbound_split_detail_before_insert BEFORE INSERT ON fl_inbound_split_detail FOR EACH ROW BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧部分锁定台账已退役,请使用主子分片批次'; +END$$ + +CREATE TRIGGER trg_inbound_split_detail_before_update BEFORE UPDATE ON fl_inbound_split_detail FOR EACH ROW BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧部分锁定台账只读,不允许更新'; +END$$ + +CREATE TRIGGER trg_inbound_split_history_before_delete BEFORE DELETE ON fl_inbound_split_history FOR EACH ROW BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可删除'; +END$$ + +CREATE TRIGGER trg_inbound_split_history_before_update BEFORE UPDATE ON fl_inbound_split_history FOR EACH ROW BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可修改'; +END$$ + +CREATE TRIGGER trg_inbound_split_item_before_delete BEFORE DELETE ON fl_inbound_split_item FOR EACH ROW BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可删除'; +END$$ + +CREATE TRIGGER trg_inbound_split_item_before_insert BEFORE INSERT ON fl_inbound_split_item FOR EACH ROW BEGIN + IF NEW.source_measurement_id IS NULL OR NEW.source_measurement_item_id IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细必须绑定真实测量来源'; + END IF; + IF NOT EXISTS ( + SELECT 1 + FROM fl_inbound_split_batch batch + JOIN fl_inbound_order parent_order ON parent_order.id = batch.parent_inbound_id + JOIN fl_inbound_order child_order ON child_order.id = NEW.child_inbound_id + WHERE batch.id = NEW.split_batch_id + AND batch.tenant_id = NEW.tenant_id + AND batch.parent_inbound_id = NEW.parent_inbound_id + AND batch.status = 'PREPARED' + AND batch.loaded_child_inbound_id IS NULL + AND batch.remainder_child_inbound_id IS NOT NULL + AND NEW.child_inbound_id = batch.remainder_child_inbound_id + AND parent_order.tenant_id = NEW.tenant_id + AND parent_order.inbound_role = 'MASTER' + AND child_order.tenant_id = NEW.tenant_id + AND child_order.inbound_role = 'CHILD' + AND child_order.split_purpose = 'REMAINDER' + AND child_order.parent_inbound_id = NEW.parent_inbound_id + AND child_order.package_count >= NEW.package_count + AND child_order.deleted = 0 + AND EXISTS ( + SELECT 1 + FROM fl_inbound_measurement_item source_item + JOIN fl_inbound_measurement source_batch ON source_batch.id = source_item.measurement_id + WHERE source_item.id = NEW.source_measurement_item_id + AND source_item.tenant_id = NEW.tenant_id + AND source_item.measurement_id = NEW.source_measurement_id + AND source_batch.inbound_id = COALESCE(batch.source_inbound_id, batch.parent_inbound_id) + ) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_item_before_update BEFORE UPDATE ON fl_inbound_split_item FOR EACH ROW BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可修改'; +END$$ + +CREATE TRIGGER trg_loading_history_before_delete BEFORE DELETE ON fl_loading_plan_history FOR EACH ROW BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可删除'; +END$$ + +CREATE TRIGGER trg_loading_history_before_update BEFORE UPDATE ON fl_loading_plan_history FOR EACH ROW BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可修改'; +END$$ + +CREATE TRIGGER trg_loading_item_before_delete BEFORE DELETE ON fl_loading_plan_item FOR EACH ROW BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细只允许推送前逻辑删除'; +END$$ + +CREATE TRIGGER trg_loading_item_before_insert BEFORE INSERT ON fl_loading_plan_item FOR EACH ROW BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE id = NEW.loading_plan_id; + IF current_plan_status IS NULL OR current_plan_status NOT IN ('EDITING', 'LOADING') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仅编辑中或装柜中的计划允许新增明细'; + END IF; + IF NEW.deleted <> 0 OR NEW.frozen_at IS NOT NULL OR NEW.frozen_by IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新增装柜明细必须为未删除且未冻结状态'; + END IF; +END$$ + +CREATE TRIGGER trg_loading_item_before_update BEFORE UPDATE ON fl_loading_plan_item FOR EACH ROW BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status + FROM fl_loading_plan WHERE id = OLD.loading_plan_id; + IF current_plan_status = 'LOADING' AND EXISTS ( + SELECT 1 FROM fl_loading_pick_item + WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN + IF NOT (NEW.internal_remark <=> OLD.internal_remark) + OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) + OR NOT (NEW.customer_service_remark <=> OLD.customer_service_remark) THEN + IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted + OR NOT (NEW.customer_name <=> OLD.customer_name) + OR NOT (NEW.inbound_id <=> OLD.inbound_id) + OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id) + OR NOT (NEW.order_id <=> OLD.order_id) + OR NOT (NEW.order_no <=> OLD.order_no) + OR NOT (NEW.inbound_code <=> OLD.inbound_code) + OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id) + OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code) + OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name) + OR NOT (NEW.sku_code <=> OLD.sku_code) + OR NOT (NEW.product_name_zh <=> OLD.product_name_zh) + OR NOT (NEW.product_name_en <=> OLD.product_name_en) + OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id) + OR NOT (NEW.product_image_url <=> OLD.product_image_url) + OR NOT (NEW.product_quantity <=> OLD.product_quantity) + OR NOT (NEW.units_per_carton <=> OLD.units_per_carton) + OR NOT (NEW.pallet_count <=> OLD.pallet_count) + OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg) + OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg) + OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg) + OR NOT (NEW.volume_cbm <=> OLD.volume_cbm) + OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no) + OR NOT (NEW.shipment_no <=> OLD.shipment_no) + OR NOT (NEW.shipping_mark <=> OLD.shipping_mark) + OR NOT (NEW.load_sequence <=> OLD.load_sequence) + OR NOT (NEW.position_snapshot <=> OLD.position_snapshot) + OR NOT (NEW.customer_remark <=> OLD.customer_remark) + OR NOT (NEW.frozen_at <=> OLD.frozen_at) + OR NOT (NEW.frozen_by <=> OLD.frozen_by) + OR NOT (NEW.removed_by <=> OLD.removed_by) + OR NOT (NEW.removed_by_name <=> OLD.removed_by_name) + OR NOT (NEW.removed_at <=> OLD.removed_at) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段'; + END IF; + ELSEIF NOT EXISTS ( + SELECT 1 FROM fl_loading_pick_item + WHERE loading_plan_item_id = OLD.id + AND item_status = 'PARTIAL' + AND NEW.deleted = 0 + AND NEW.package_count = picked_package_count + ) OR NEW.deleted <> OLD.deleted + OR NOT (NEW.customer_name <=> OLD.customer_name) + OR NOT (NEW.inbound_id <=> OLD.inbound_id) + OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id) + OR NOT (NEW.order_id <=> OLD.order_id) + OR NOT (NEW.order_no <=> OLD.order_no) + OR NOT (NEW.inbound_code <=> OLD.inbound_code) + OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id) + OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code) + OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name) + OR NOT (NEW.sku_code <=> OLD.sku_code) + OR NOT (NEW.product_name_zh <=> OLD.product_name_zh) + OR NOT (NEW.product_name_en <=> OLD.product_name_en) + OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id) + OR NOT (NEW.product_image_url <=> OLD.product_image_url) + OR NOT (NEW.product_quantity <=> OLD.product_quantity) + OR NOT (NEW.units_per_carton <=> OLD.units_per_carton) + OR NOT (NEW.pallet_count <=> OLD.pallet_count) + OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg) + OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg) + OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg) + OR NOT (NEW.volume_cbm <=> OLD.volume_cbm) + OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no) + OR NOT (NEW.shipment_no <=> OLD.shipment_no) + OR NOT (NEW.shipping_mark <=> OLD.shipping_mark) + OR NOT (NEW.load_sequence <=> OLD.load_sequence) + OR NOT (NEW.position_snapshot <=> OLD.position_snapshot) + OR NOT (NEW.customer_remark <=> OLD.customer_remark) + OR NOT (NEW.frozen_at <=> OLD.frozen_at) + OR NOT (NEW.frozen_by <=> OLD.frozen_by) + OR NOT (NEW.removed_by <=> OLD.removed_by) + OR NOT (NEW.removed_by_name <=> OLD.removed_by_name) + OR NOT (NEW.removed_at <=> OLD.removed_at) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数'; + END IF; + END IF; + IF OLD.frozen_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结'; + END IF; + IF OLD.deleted = 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已删除装柜明细不可恢复或修改'; + END IF; + IF NEW.loading_plan_id <> OLD.loading_plan_id OR NEW.account_id <> OLD.account_id + OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细归属键不可修改,请删除后重新分配'; + END IF; + IF NEW.deleted = 1 THEN + IF NEW.removed_by IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除装柜明细必须记录操作人'; + END IF; + SET NEW.removed_at = COALESCE(NEW.removed_at, CURRENT_TIMESTAMP(3)); + ELSEIF NEW.removed_by IS NOT NULL OR NEW.removed_by_name IS NOT NULL OR NEW.removed_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除审计字段只能在逻辑删除时写入'; + END IF; +END$$ + +CREATE TRIGGER trg_loading_pick_history_before_delete BEFORE DELETE ON fl_loading_pick_history FOR EACH ROW BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可删除'; +END$$ + +CREATE TRIGGER trg_loading_pick_history_before_update BEFORE UPDATE ON fl_loading_pick_history FOR EACH ROW BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可修改'; +END$$ + +CREATE TRIGGER trg_loading_pick_item_before_delete BEFORE DELETE ON fl_loading_pick_item FOR EACH ROW BEGIN + DECLARE current_task_status VARCHAR(16); + SELECT MAX(task_status) INTO current_task_status + FROM fl_loading_pick_task WHERE id = OLD.pick_task_id; + IF current_task_status = 'COMPLETED' OR OLD.picked_package_count > 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已拣货或已完成的PDA明细不可删除'; + END IF; +END$$ + +CREATE TRIGGER trg_loading_pick_item_before_update BEFORE UPDATE ON fl_loading_pick_item FOR EACH ROW BEGIN + DECLARE current_task_status VARCHAR(16); + SELECT MAX(task_status) INTO current_task_status + FROM fl_loading_pick_task WHERE id = OLD.pick_task_id; + IF current_task_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后PDA拣货明细不可修改'; + END IF; + IF NEW.pick_task_id <> OLD.pick_task_id + OR NEW.loading_plan_item_id <> OLD.loading_plan_item_id + OR NEW.inbound_id <> OLD.inbound_id + OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改'; + END IF; + IF NEW.item_status = 'PARTIAL' AND NOT ( + OLD.item_status IN ('PENDING', 'PICKING') + AND OLD.picked_package_count < NEW.picked_package_count + AND NEW.picked_package_count < OLD.planned_package_count + AND NEW.planned_package_count = OLD.planned_package_count + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货必须在计划箱数内增加实际拣出箱数'; + END IF; + IF NEW.item_status = 'SKIPPED' AND NOT ( + OLD.item_status = 'PENDING' + AND OLD.picked_package_count = 0 + AND NEW.picked_package_count = 0 + AND NEW.planned_package_count = OLD.planned_package_count + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '跳拣仅允许零拣货的待拣明细'; + END IF; + IF OLD.item_status = 'PARTIAL' AND NEW.item_status = 'PICKED' AND NOT ( + NEW.picked_package_count = OLD.picked_package_count + AND NEW.planned_package_count = OLD.picked_package_count + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货收敛后计划箱数必须等于实际拣出箱数'; + END IF; +END$$ + +CREATE TRIGGER trg_loading_pick_operation_before_delete BEFORE DELETE ON fl_loading_pick_operation FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA任务操作历史不可删除'; END$$ + +CREATE TRIGGER trg_loading_pick_operation_before_update BEFORE UPDATE ON fl_loading_pick_operation FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA任务操作历史不可修改'; END$$ + +CREATE TRIGGER trg_loading_pick_review_before_delete BEFORE DELETE ON fl_loading_pick_review FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA复核记录不可删除'; END$$ + +CREATE TRIGGER trg_loading_pick_review_before_update BEFORE UPDATE ON fl_loading_pick_review FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA复核记录不可修改'; END$$ + +CREATE TRIGGER trg_loading_pick_task_before_update BEFORE UPDATE ON fl_loading_pick_task FOR EACH ROW BEGIN + IF OLD.task_status='COMPLETED' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已完成PDA拣货任务不可修改'; END IF; + IF NEW.loading_plan_id<>OLD.loading_plan_id OR NOT(NEW.container_booking_id<=>OLD.container_booking_id) + OR NOT(NEW.cabinet_no<=>OLD.cabinet_no) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA拣货任务稳定归属不可修改'; + END IF; + IF NOT ((OLD.task_status='LOADING' AND NEW.task_status IN ('LOADING','COMPLETING')) + OR (OLD.task_status='COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED')) + OR (OLD.task_status='COMPLETION_FAILED' AND NEW.task_status='COMPLETING')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA拣货任务状态流转非法'; + END IF; +END$$ + +CREATE TRIGGER trg_loading_plan_after_update AFTER UPDATE ON fl_loading_plan FOR EACH ROW BEGIN + IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN + UPDATE fl_loading_plan_item + SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3)), + frozen_by = COALESCE(frozen_by, NEW.updated_by), + updated_by = NEW.updated_by, + updated_by_name = NEW.updated_by_name, + version = version + 1 + WHERE loading_plan_id = NEW.id AND deleted = 0; + UPDATE fl_loading_pick_task + SET task_status = 'COMPLETED', completed_by = NEW.updated_by, + completed_by_name = NEW.updated_by_name, completed_at = CURRENT_TIMESTAMP(3), + version = version + 1 + WHERE loading_plan_id = NEW.id AND task_status = 'LOADING'; + END IF; +END$$ + +CREATE TRIGGER trg_loading_plan_before_update BEFORE UPDATE ON fl_loading_plan FOR EACH ROW BEGIN + IF OLD.plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成装柜计划全部只读'; + END IF; + IF NOT ( + (OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')) + OR (OLD.plan_status = 'LOADING' + AND NEW.plan_status IN ('LOADING', 'COMPLETED')) + OR (OLD.plan_status = 'LOADING' AND NEW.plan_status = 'EDITING' + 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_task.loading_plan_id = OLD.id + AND pick_item.picked_package_count > 0 + )) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进'; + END IF; + IF OLD.plan_status = 'LOADING' AND ( + NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no) + OR NOT (OLD.container_booking_id <=> NEW.container_booking_id) + OR NOT (OLD.remark <=> NEW.remark) + OR NOT (OLD.created_by <=> NEW.created_by) + OR NOT (OLD.created_by_name <=> NEW.created_by_name) + OR NOT (OLD.created_at <=> NEW.created_at) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '装柜中计划仅允许乐观锁触碰或完成装柜'; + END IF; + IF OLD.plan_status = NEW.plan_status + AND OLD.plan_status = 'LOADING' AND ( + NOT (OLD.preload_completed_by <=> NEW.preload_completed_by) + OR NOT (OLD.preload_completed_by_name <=> NEW.preload_completed_by_name) + OR NOT (OLD.preload_completed_at <=> NEW.preload_completed_at) + OR NOT (OLD.confirmed_by <=> NEW.confirmed_by) + OR NOT (OLD.confirmed_by_name <=> NEW.confirmed_by_name) + OR NOT (OLD.confirmed_at <=> NEW.confirmed_at) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段'; + END IF; + IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN + IF EXISTS (SELECT 1 FROM fl_loading_plan_item item + WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 + 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_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)); + END IF; +END$$ + +CREATE TRIGGER trg_ocean_allocation_before_insert BEFORE INSERT ON fl_ocean_booking_container_allocation FOR EACH ROW BEGIN + IF (SELECT ocean_booking_id FROM fl_ocean_booking_container_requirement WHERE id=NEW.requirement_id) <> NEW.ocean_booking_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='箱型需求不属于当前订舱'; + END IF; + IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> 'RELEASED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='当前订舱状态不可分配柜次'; + END IF; +END$$ + +CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW BEGIN + IF OLD.status = 'RELEASED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改'; + END IF; + IF OLD.status <> NEW.status AND NOT ( + (OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海运订舱状态迁移非法'; + END IF; +END$$ + +CREATE TRIGGER trg_ocean_document_before_delete BEFORE DELETE ON fl_ocean_booking_document FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱文件关联不可物理删除'; END$$ + +CREATE TRIGGER trg_ocean_document_before_insert BEFORE INSERT ON fl_ocean_booking_document FOR EACH ROW BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱文件永久只读'; END IF; END$$ + +CREATE TRIGGER trg_ocean_document_before_update BEFORE UPDATE ON fl_ocean_booking_document FOR EACH ROW BEGIN + IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱文件永久只读'; END IF; + IF NEW.ocean_booking_id <> OLD.ocean_booking_id OR NEW.file_id <> OLD.file_id OR NEW.document_type <> OLD.document_type OR NEW.uploaded_by <> OLD.uploaded_by OR NEW.uploaded_at <> OLD.uploaded_at OR NEW.valid > OLD.valid THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱文件只允许在草稿期作废'; END IF; +END$$ + +CREATE TRIGGER trg_ocean_history_before_delete BEFORE DELETE ON fl_ocean_booking_status_history FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除'; END$$ + +CREATE TRIGGER trg_ocean_history_before_update BEFORE UPDATE ON fl_ocean_booking_status_history FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可修改'; END$$ + +CREATE TRIGGER trg_ocean_party_before_delete BEFORE DELETE ON fl_ocean_booking_party FOR EACH ROW BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护订舱方'; END IF; END$$ + +CREATE TRIGGER trg_ocean_party_before_insert BEFORE INSERT ON fl_ocean_booking_party FOR EACH ROW BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护订舱方'; END IF; END$$ + +CREATE TRIGGER trg_ocean_party_before_update BEFORE UPDATE ON fl_ocean_booking_party FOR EACH ROW BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护订舱方'; END IF; END$$ + +CREATE TRIGGER trg_ocean_requirement_before_delete BEFORE DELETE ON fl_ocean_booking_container_requirement FOR EACH ROW BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护箱型需求'; END IF; END$$ + +CREATE TRIGGER trg_ocean_requirement_before_insert BEFORE INSERT ON fl_ocean_booking_container_requirement FOR EACH ROW BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护箱型需求'; END IF; END$$ + +CREATE TRIGGER trg_ocean_requirement_before_update BEFORE UPDATE ON fl_ocean_booking_container_requirement FOR EACH ROW BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护箱型需求'; END IF; END$$ + +CREATE TRIGGER trg_port_schedule_before_insert BEFORE INSERT ON fl_port_schedule FOR EACH ROW BEGIN + IF (SELECT customer_id FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> NEW.customer_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船期客户与订舱客户不一致'; + END IF; + IF NEW.container_booking_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM fl_ocean_booking_container_allocation + WHERE ocean_booking_id=NEW.ocean_booking_id AND container_booking_id=NEW.container_booking_id + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船期柜次未分配至订舱'; + END IF; +END$$ + +CREATE TRIGGER trg_port_schedule_before_update BEFORE UPDATE ON fl_port_schedule FOR EACH ROW BEGIN + IF NEW.customer_id <> OLD.customer_id OR NEW.ocean_booking_id <> OLD.ocean_booking_id + OR NOT (NEW.container_booking_id <=> OLD.container_booking_id) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船期客户、订舱和柜次归属不可变更'; + END IF; +END$$ + +CREATE TRIGGER trg_port_schedule_history_before_delete BEFORE DELETE ON fl_port_schedule_history FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='港务船期历史不可删除'; END$$ + +CREATE TRIGGER trg_port_schedule_history_before_update BEFORE UPDATE ON fl_port_schedule_history FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='港务船期历史不可修改'; END$$ + +DELIMITER ; + +-- 必要系统种子;不写入测试客户、演示订单、示例供应商、快递、托盘或库位。 + +USE baiyue_iam; + +-- 系统基线 iam_data_scope:7 行。 +INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression, created_at) VALUES (6000001,4000001,0,'TENANT',NULL,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression, created_at) VALUES (6000002,4000002,0,'TENANT',NULL,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression, created_at) VALUES (6000003,4000003,0,'TENANT',NULL,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression, created_at) VALUES (6000004,4000004,0,'TENANT',NULL,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression, created_at) VALUES (6000005,4000005,0,'TENANT',NULL,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression, created_at) VALUES (6000006,4000006,0,'TENANT',NULL,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression, created_at) VALUES (6000007,4000007,0,'TENANT',NULL,CURRENT_TIMESTAMP(3)); + +-- 系统基线 iam_membership:1 行。 +INSERT INTO iam_membership (id, tenant_id, user_id, member_type, member_no, overseas_warehouse_id, status, joined_at, left_at, created_at, updated_at, deleted, version, created_by, updated_by) VALUES (3000001,0,2000001,'EMPLOYEE','PLATFORM-ADMIN-001',NULL,'ACTIVE',CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0,0,0,0); + +-- 系统基线 iam_membership_role:1 行。 +INSERT INTO iam_membership_role (membership_id, role_id, tenant_id, created_at) VALUES (3000001,4000001,0,CURRENT_TIMESTAMP(3)); + +-- 系统基线 iam_number_sequence:1 行。 +INSERT INTO iam_number_sequence (sequence_key, next_value, updated_at) VALUES ('CUSTOMER_CODE',1,CURRENT_TIMESTAMP(3)); + +-- 系统基线 iam_permission:346 行。 +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100000,0,'PLATFORM','MENU',0,'platform:customer','客户管理','/customers',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100010,0,'PLATFORM','MENU',5100000,'platform:customer:list','企业客户','/customers/list',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100011,0,'PLATFORM','BUTTON',5100010,'platform:customer:create','新增企业客户',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100012,0,'PLATFORM','BUTTON',5100010,'platform:customer:edit','编辑企业客户',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100013,0,'PLATFORM','BUTTON',5100010,'platform:customer:status','启停企业客户',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100014,0,'PLATFORM','BUTTON',5100010,'platform:customer:view','查看企业客户档案',NULL,NULL,15,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100015,0,'PLATFORM','BUTTON',5100010,'platform:customer:export','导出企业客户',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100016,0,'PLATFORM','BUTTON',5100010,'platform:customer:reset-password','重置客户管理员密码',NULL,NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100017,0,'PLATFORM','BUTTON',5100010,'platform:customer:delete','归档企业客户',NULL,NULL,60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100018,0,'PLATFORM','BUTTON',5100010,'platform:customer:impersonate','跳转客户登录',NULL,NULL,70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100020,0,'PLATFORM','BUTTON',5100010,'platform:customer:review','客户审核',NULL,NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100021,0,'PLATFORM','BUTTON',5100020,'platform:customer:approve','审核通过',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100022,0,'PLATFORM','BUTTON',5100020,'platform:customer:reject','审核驳回',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100030,0,'PLATFORM','MENU',5100000,'platform:overseas-storage-fee:list','海外仓阶梯费用','/customers/overseas-storage-fees','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100031,0,'PLATFORM','BUTTON',5100030,'platform:overseas-storage-fee:create','新增海外仓阶梯费用',NULL,NULL,10,'INACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),1); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100032,0,'PLATFORM','BUTTON',5100030,'platform:overseas-storage-fee:edit','编辑海外仓阶梯费用',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100033,0,'PLATFORM','BUTTON',5100030,'platform:overseas-storage-fee:delete','删除海外仓阶梯费用',NULL,NULL,30,'INACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),1); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101000,0,'PLATFORM','MENU',0,'platform:system','系统管理','/system',NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101010,0,'PLATFORM','MENU',5101000,'platform:user:list','内部用户','/system/users',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101011,0,'PLATFORM','BUTTON',5101010,'platform:user:create','新增用户',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101012,0,'PLATFORM','BUTTON',5101010,'platform:user:edit','编辑用户',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101013,0,'PLATFORM','BUTTON',5101010,'platform:user:status','启停用户',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101014,0,'PLATFORM','BUTTON',5101010,'platform:user:reset-password','重置密码',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101015,0,'PLATFORM','BUTTON',5101010,'platform:user:grant-role','分配内部用户角色',NULL,NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101020,0,'PLATFORM','MENU',5101000,'platform:role:list','角色管理','/system/roles',NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101021,0,'PLATFORM','BUTTON',5101020,'platform:role:create','新增角色',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101022,0,'PLATFORM','BUTTON',5101020,'platform:role:grant','角色授权',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101023,0,'PLATFORM','BUTTON',5101020,'platform:role:edit','编辑角色',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101024,0,'PLATFORM','BUTTON',5101020,'platform:role:status','启停角色',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101025,0,'PLATFORM','BUTTON',5101020,'platform:role:delete','删除角色',NULL,NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101030,0,'PLATFORM','MENU',5101000,'platform:permission:list','菜单按钮','/system/permissions',NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101031,0,'PLATFORM','BUTTON',5101030,'platform:permission:create','新增菜单按钮',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101032,0,'PLATFORM','BUTTON',5101030,'platform:permission:edit','编辑权限资源',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101033,0,'PLATFORM','BUTTON',5101030,'platform:permission:status','启停权限资源',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101034,0,'PLATFORM','BUTTON',5101030,'platform:permission:delete','删除权限资源',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101040,0,'PLATFORM','MENU',5101000,'platform:member:list','平台成员',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101041,0,'PLATFORM','BUTTON',5101040,'platform:member:grant-role','分配成员角色',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102000,0,'PLATFORM','MENU',0,'platform:order','头程订单','/first-leg-orders',NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102010,0,'PLATFORM','MENU',5102000,'platform:order:list','订单列表','/first-leg-orders/list',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102011,0,'PLATFORM','BUTTON',5102010,'platform:order:approve','订单审核通过',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102012,0,'PLATFORM','BUTTON',5102010,'platform:order:reject','订单驳回',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102013,0,'PLATFORM','BUTTON',5102010,'platform:order:accept','订单受理',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102014,0,'PLATFORM','BUTTON',5102010,'platform:order:process','开始处理',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102015,0,'PLATFORM','BUTTON',5102010,'platform:order:complete','完成订单',NULL,NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102016,0,'PLATFORM','BUTTON',5102010,'platform:order:cancel','取消订单',NULL,NULL,60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102017,0,'PLATFORM','BUTTON',5102010,'platform:order:note','内部备注',NULL,NULL,70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102018,0,'PLATFORM','BUTTON',5102010,'platform:order:fulfillment','登记履约快递',NULL,NULL,80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102019,0,'PLATFORM','BUTTON',5102010,'platform:order:create','平台代客创建订单',NULL,NULL,90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102020,0,'PLATFORM','MENU',5102000,'platform:firstleg:task:list','履约任务','/first-leg-tasks/list',NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102021,0,'PLATFORM','BUTTON',5102020,'platform:firstleg:task:view','查看履约任务',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102022,0,'PLATFORM','BUTTON',5102020,'platform:firstleg:assignment:history','查看分配历史',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102023,0,'PLATFORM','BUTTON',5102020,'platform:firstleg:assignment:assign','分配履约任务',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102024,0,'PLATFORM','BUTTON',5102010,'platform:order:edit','平台编辑订单草稿',NULL,NULL,100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102025,0,'PLATFORM','BUTTON',5102010,'platform:order:submit','平台代客提交订单',NULL,NULL,110,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102026,0,'PLATFORM','BUTTON',5102010,'platform:order:export','导出订单列表',NULL,NULL,120,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102027,0,'PLATFORM','BUTTON',5102010,'platform:order:declaration:edit','修改订单申报与运营预估',NULL,NULL,130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102030,0,'PLATFORM','MENU',5102000,'platform:firstleg:inbound:list','入库单','/inbounds','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102031,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:view','查看入库单详情',NULL,'GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102032,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:hold','扣货与解除扣货',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102041,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:measure:view','查看入库测量',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102042,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:measure:edit','保存测量草稿',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102043,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:measure:submit','确认并提交测量',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102044,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:weight:edit','维护OCR重量',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102045,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:weight:confirm','确认OCR重量',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102051,0,'PLATFORM','BUTTON',5102030,'firstleg:inbound:measurement-review','测量审核',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102052,0,'PLATFORM','BUTTON',5102030,'firstleg:inbound:measurement-review-history','查看测量审核历史',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102053,0,'PLATFORM','BUTTON',5102030,'firstleg:inbound:exception-release','测量异常放行',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102054,0,'PLATFORM','BUTTON',5102030,'firstleg:inbound:send-fee','发送费用并入库',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102061,0,'PLATFORM','BUTTON',5102030,'firstleg:fba-pricing:rule:view','查看客户计费规则',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102062,0,'PLATFORM','BUTTON',5102030,'firstleg:fba-pricing:rule:edit','维护客户计费规则',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102063,0,'PLATFORM','BUTTON',5102030,'firstleg:fba-pricing:rate:view','查看承运商价格',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102064,0,'PLATFORM','BUTTON',5102030,'firstleg:fba-pricing:rate:edit','维护承运商价格',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102065,0,'PLATFORM','BUTTON',5102030,'firstleg:fba-pricing:calculate','计算入库费用',NULL,NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102066,0,'PLATFORM','BUTTON',5102030,'firstleg:fba-pricing:confirm','内部确认入库费用',NULL,NULL,60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102067,0,'PLATFORM','BUTTON',5102030,'firstleg:fba-pricing:view','查看入库费用',NULL,NULL,70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102068,0,'PLATFORM','BUTTON',5102030,'firstleg:fba-pricing:adjust','调整已发布费用',NULL,NULL,80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102069,0,'PLATFORM','BUTTON',5102030,'firstleg:inbound:confirm-send-fee','确认并推送客户费用',NULL,NULL,90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102070,0,'PLATFORM','MENU',5103000,'firstleg:fee-item:view','费用项目','/master-data/fee-items',NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102071,0,'PLATFORM','BUTTON',5102070,'firstleg:fee-item:create','新增费用项目',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102072,0,'PLATFORM','BUTTON',5102070,'firstleg:fee-item:edit','编辑或停用费用项目',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102073,0,'PLATFORM','BUTTON',5102070,'firstleg:fee-item:delete','删除费用项目',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102081,0,'PLATFORM','BUTTON',5102030,'firstleg:inbound:fee-detail:view','查看入库费用明细',NULL,NULL,100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102082,0,'PLATFORM','BUTTON',5102030,'firstleg:inbound:fee-detail:edit','新增编辑入库费用明细',NULL,NULL,110,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102083,0,'PLATFORM','BUTTON',5102030,'firstleg:inbound:fee-detail:delete','删除入库费用明细',NULL,NULL,120,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102084,0,'PLATFORM','BUTTON',5102030,'firstleg:inbound:fee-detail:review','审核入库费用明细',NULL,NULL,130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102090,0,'PLATFORM','MENU',5102000,'platform:firstleg:container:list','国内送柜报关','/container-loading/domestic','GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102091,0,'PLATFORM','BUTTON',5102090,'platform:firstleg:container:view','查看货柜',NULL,'GET',10,'INACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),1); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102092,0,'PLATFORM','BUTTON',5102090,'platform:firstleg:container:create','建立国内货柜',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102093,0,'PLATFORM','BUTTON',5102090,'platform:firstleg:container:edit','维护国内货柜资料',NULL,'PUT',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102094,0,'PLATFORM','MENU',5102000,'platform:firstleg:smart-loading:list','智能装柜','/first-leg/smart-loading','GET',50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102097,0,'PLATFORM','BUTTON',5102094,'platform:firstleg:smart-loading:assign','分配装柜明细',NULL,'POST',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102098,0,'PLATFORM','BUTTON',5102094,'platform:firstleg:smart-loading:delete','删除装柜明细',NULL,'DELETE',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102100,0,'PLATFORM','MENU',5102000,'platform:firstleg:preload:list','预装清单','/first-leg/preload-list','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102102,0,'PLATFORM','BUTTON',5102100,'platform:firstleg:preload:view','查看预装详情',NULL,'GET',20,'INACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),1); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102103,0,'PLATFORM','BUTTON',5102100,'platform:firstleg:preload:edit','编辑预装运输字段',NULL,'PUT',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102104,0,'PLATFORM','BUTTON',5102100,'platform:firstleg:preload:complete','完成预装',NULL,'POST',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102105,0,'PLATFORM','BUTTON',5102100,'platform:firstleg:preload:confirm','确认预装',NULL,'POST',50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102106,0,'PLATFORM','MENU',5102000,'platform:firstleg:loading-list:list','装柜清单','/first-leg/loading-list','GET',70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102108,0,'PLATFORM','BUTTON',5102106,'platform:firstleg:loading-list:view','查看装柜详情',NULL,'GET',20,'INACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),1); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102111,0,'PLATFORM','BUTTON',5100010,'firstleg:dimension-config:view','查看客户尺寸计算配置',NULL,NULL,90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102112,0,'PLATFORM','BUTTON',5100010,'firstleg:dimension-config:edit','维护客户尺寸计算配置',NULL,NULL,100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102121,0,'PLATFORM','BUTTON',5102030,'firstleg:inbound:split','智能分柜',NULL,NULL,140,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102122,0,'PLATFORM','BUTTON',5102030,'firstleg:inbound:preload','编辑并确认预装清单',NULL,NULL,150,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102123,0,'PLATFORM','BUTTON',5102030,'firstleg:inbound:preload-view','查看预装清单',NULL,NULL,160,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102124,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:loading:edit','维护装柜证据',NULL,NULL,170,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102125,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:loading:view','查看装柜证据',NULL,NULL,180,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102126,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:loading:complete','完成装柜',NULL,NULL,190,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102127,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:delivery:complete','完成派送',NULL,NULL,200,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102128,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:picking:edit','执行拣货',NULL,NULL,210,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102129,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:picking:view','查看拣货任务',NULL,NULL,220,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑草稿',NULL,'PUT',70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:delete','删除草稿',NULL,'DELETE',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate','分配柜次',NULL,'POST',90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list','拖车预约','/first-leg/truck-appointments','GET',90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102152,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:create','创建拖车预约',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102153,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:edit','编辑拖车预约',NULL,'PUT',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate','操作拖车预约',NULL,'POST',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102160,0,'PLATFORM','MENU',5102000,'platform:firstleg:outbound-dispatch:list','国内出仓','/first-leg/outbound-dispatches','GET',95,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102161,0,'PLATFORM','BUTTON',5102160,'platform:firstleg:outbound-dispatch:view','查看出仓聚合',NULL,'GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102162,0,'PLATFORM','BUTTON',5102160,'platform:firstleg:outbound-dispatch:check','检查出仓门禁',NULL,'GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102163,0,'PLATFORM','BUTTON',5102160,'platform:firstleg:outbound-dispatch:confirm','确认国内出仓',NULL,'POST',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102164,0,'PLATFORM','BUTTON',5102160,'platform:firstleg:outbound-dispatch:reverse','撤销国内出仓',NULL,'POST',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102170,0,'PLATFORM','MENU',5102000,'platform:firstleg:customs:list','整柜报关','/first-leg/customs','GET',100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102172,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:manage','维护整柜报关',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102180,0,'PLATFORM','MENU',5102000,'platform:firstleg:port-schedule:list','港务船期','/first-leg/port-schedules','GET',120,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102182,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:create','新增港务船期',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102183,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:edit','更新港务船期',NULL,'PUT',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102190,0,'PLATFORM','MENU',5102094,'platform:firstleg:pda:view','PDA装柜拣货','/first-leg/pda-loading','GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102191,0,'PLATFORM','BUTTON',5102190,'platform:firstleg:pda:assign','分配PDA任务',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102192,0,'PLATFORM','BUTTON',5102190,'platform:firstleg:pda:work','执行PDA拣货',NULL,'POST',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102193,0,'PLATFORM','BUTTON',5102190,'platform:firstleg:pda:review','复核PDA任务',NULL,'POST',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102194,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:measure:manual-entry','手动录入箱规',NULL,NULL,25,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102195,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:warehouse-suggestion:edit','维护仓库建议测量数据',NULL,NULL,27,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102200,0,'PLATFORM','MENU',5102000,'platform:firstleg:terminal-drayage:list','国外清关提柜','/container-loading/overseas','GET',41,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102201,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:document:edit','维护目的港清关与DO/PU',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102202,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:fleet:edit','维护目的港码头车队',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102203,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup-appointment:edit','预约目的港提柜',NULL,'POST',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102205,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:empty-return:confirm','确认目的港实际还柜',NULL,'POST',50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103000,0,'PLATFORM','MENU',0,'platform:master-data','基础资料','/master-data',NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103010,0,'PLATFORM','MENU',5103000,'platform:warehouse:list','国内仓/FBA仓','/master-data/warehouses',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103011,0,'PLATFORM','BUTTON',5103010,'platform:warehouse:create','新增仓库',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103012,0,'PLATFORM','BUTTON',5103010,'platform:warehouse:edit','编辑仓库',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103013,0,'PLATFORM','BUTTON',5103010,'platform:warehouse:status','启用停用',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103020,0,'PLATFORM','MENU',5103000,'platform:express:list','快递公司','/master-data/express',NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103021,0,'PLATFORM','BUTTON',5103020,'platform:express:create','新增快递公司',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103022,0,'PLATFORM','BUTTON',5103020,'platform:express:edit','编辑快递公司',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103023,0,'PLATFORM','BUTTON',5103020,'platform:express:status','启停快递公司',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103024,0,'PLATFORM','BUTTON',5103020,'platform:express:import','批量导入快递公司',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103030,0,'PLATFORM','MENU',5103000,'platform:counterparty:list','往来单位','/master-data/counterparties',NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103031,0,'PLATFORM','BUTTON',5103030,'platform:counterparty:create','新增往来单位',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103032,0,'PLATFORM','BUTTON',5103030,'platform:counterparty:edit','编辑往来单位',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103033,0,'PLATFORM','BUTTON',5103030,'platform:counterparty:status','启停往来单位',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103034,0,'PLATFORM','BUTTON',5103030,'platform:counterparty:import','批量导入往来单位',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103040,0,'PLATFORM','MENU',5103000,'platform:download:list','下载中心','/downloads',NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103041,0,'PLATFORM','BUTTON',5103040,'platform:download:all','查看全部下载任务',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103050,0,'PLATFORM','MENU',5103000,'platform:pallet:list','托盘管理','/master-data/pallets',NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103051,0,'PLATFORM','BUTTON',5103050,'platform:pallet:create','新增托盘',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103052,0,'PLATFORM','BUTTON',5103050,'platform:pallet:edit','编辑托盘',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103054,0,'PLATFORM','BUTTON',5103050,'platform:pallet:delete','删除托盘',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103060,0,'PLATFORM','MENU',5103000,'platform:location:list','库位管理','/master-data/locations',NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103061,0,'PLATFORM','BUTTON',5103060,'platform:location:create','新增库位',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103062,0,'PLATFORM','BUTTON',5103060,'platform:location:edit','编辑库位',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103063,0,'PLATFORM','BUTTON',5103060,'platform:location:status','启停库位',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103064,0,'PLATFORM','BUTTON',5103060,'platform:location:delete','删除库位',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103070,0,'PLATFORM','MENU',5103000,'platform:domestic-container:list','整柜业务','/domestic-containers','GET',70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103071,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:inbound:create','新建整柜入库单','/api/platform/v1/domestic-containers/inbounds','POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103072,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:outbound:create','新建整柜出库单','/api/platform/v1/domestic-containers/outbounds','POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103080,0,'PLATFORM','MENU',5103000,'platform:supplier:list','供应商列表','/suppliers','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103081,0,'PLATFORM','BUTTON',5103080,'platform:supplier:create','新增供应商',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103082,0,'PLATFORM','BUTTON',5103080,'platform:supplier:edit','编辑供应商',NULL,'PUT',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5103083,0,'PLATFORM','BUTTON',5103080,'platform:supplier:status','启停供应商',NULL,'PATCH',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5104000,0,'PLATFORM','MENU',0,'platform:audit','审计管理','/audit',NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5104010,0,'PLATFORM','MENU',5104000,'platform:audit:list','操作日志','/audit/logs',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105000,0,'PLATFORM','MENU',0,'platform:finance','财务管理','/finance',NULL,60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105012,0,'PLATFORM','BUTTON',5105000,'platform:finance:bill:update','修改草稿账单',NULL,'PUT',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105014,0,'PLATFORM','BUTTON',5105000,'platform:finance:bill:adjust','调整账单',NULL,'POST',50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105015,0,'PLATFORM','BUTTON',5105000,'platform:finance:bill:void','作废账单',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105016,0,'PLATFORM','BUTTON',5105000,'platform:finance:receipt:view','查看收款',NULL,'GET',70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105017,0,'PLATFORM','BUTTON',5105000,'platform:finance:receipt:create','登记收款',NULL,'POST',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105018,0,'PLATFORM','BUTTON',5105000,'platform:finance:cost:view','查看成本',NULL,'GET',90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105019,0,'PLATFORM','BUTTON',5105000,'platform:finance:cost:create','登记成本',NULL,'POST',100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105020,0,'PLATFORM','BUTTON',5105000,'platform:finance:customer-terms:view','查看客户账期',NULL,'GET',110,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105021,0,'PLATFORM','BUTTON',5105000,'platform:finance:customer-terms:update','修改客户账期',NULL,'PUT',120,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105022,0,'PLATFORM','BUTTON',5105000,'platform:finance:account:view','查看客户财务账户及流水(已停用)',NULL,'GET',130,'INACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),1); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105023,0,'PLATFORM','BUTTON',5105000,'platform:finance:fee-source:view','查看财务费用来源(已停用)',NULL,'GET',140,'INACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),1); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105024,0,'PLATFORM','BUTTON',5105041,'platform:finance:fee-source:review','审核财务费用来源',NULL,'POST',150,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105025,0,'PLATFORM','BUTTON',5105041,'platform:finance:fee-source:edit','编辑财务费用来源',NULL,'PUT',160,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105026,0,'PLATFORM','BUTTON',5105042,'platform:finance:reconciliation:create','生成和提交客户对账单',NULL,'POST',170,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105027,0,'PLATFORM','BUTTON',5105042,'platform:finance:reconciliation:review','审核客户对账单',NULL,'POST',180,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105028,0,'PLATFORM','BUTTON',5105042,'platform:finance:reconciliation:issue','出账客户对账单',NULL,'POST',190,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105029,0,'PLATFORM','BUTTON',5105042,'platform:finance:reconciliation:adjust','调整客户对账单',NULL,'POST',200,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105030,0,'PLATFORM','BUTTON',5105042,'platform:finance:reconciliation:void','作废或重开客户对账单',NULL,'POST',210,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105031,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:create','生成应收结算单',NULL,'POST',220,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105032,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:create','生成应付结算单',NULL,'POST',230,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105035,0,'PLATFORM','BUTTON',5105044,'platform:finance:payment:create','登记应付付款',NULL,'POST',260,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105036,0,'PLATFORM','BUTTON',5105000,'platform:finance:settlement:void','作废或重开结算单',NULL,'POST',270,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105040,0,'PLATFORM','MENU',5105000,'platform:finance:customer-account:list','客户账户','/finance/customer-accounts','GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105041,0,'PLATFORM','MENU',5105000,'platform:finance:customer-arap:list','客户应收应付','/finance/customer-arap','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105045,0,'PLATFORM','MENU',5105000,'platform:finance:receivable-statistics:list','应收款统计表','/finance/receivable-statistics','GET',25,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105050,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:export','导出客户账户',NULL,'POST',90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105051,0,'PLATFORM','BUTTON',5105041,'platform:finance:customer-arap:export','导出客户应收应付',NULL,'POST',90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105052,0,'PLATFORM','BUTTON',5105042,'platform:finance:customer-statement:export','导出客户对账单',NULL,'POST',90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105055,0,'PLATFORM','BUTTON',5105045,'platform:finance:receivable-statistics:export','导出应收款统计表',NULL,'POST',90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105056,0,'PLATFORM','BUTTON',5105040,'platform:finance:recharge:review','审核客户充值',NULL,'POST',100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5105057,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:adjust','调整客户资金账户',NULL,'POST',110,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106000,0,'PLATFORM','MENU',0,'platform:notification','消息中心','/notifications',NULL,600,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106001,0,'PLATFORM','API',5106000,'platform:notification:view','查看消息','/api/platform/v1/notifications','GET',601,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106002,0,'PLATFORM','BUTTON',5106000,'platform:notification:create','发送消息','/api/platform/v1/notifications','POST',602,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106003,0,'PLATFORM','MENU',5106000,'platform:notification:template:list','模板管理','/notifications/templates','GET',603,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106004,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:create','新增消息模板','/api/platform/v1/notification-templates','POST',604,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106005,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:edit','编辑消息模板','/api/platform/v1/notification-templates/{id}','PUT',605,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106006,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:status','变更模板状态','/api/platform/v1/notification-templates/{id}/status','PUT',606,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106007,0,'PLATFORM','MENU',5106000,'platform:notification:task:list','消息管理','/notifications/messages','GET',607,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106008,0,'PLATFORM','BUTTON',5106000,'platform:notification:task:cancel','取消消息任务','/api/platform/v1/notification-tasks/{id}/cancel','PUT',608,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106009,0,'PLATFORM','BUTTON',5106000,'platform:notification:task:retry','重试消息任务','/api/platform/v1/notification-tasks/{id}/retry','PUT',609,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106010,0,'MERCHANT','API',0,'merchant:notification:view','查看我的消息','/api/merchant/v1/notifications','GET',900,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106011,0,'PLATFORM','BUTTON',5106001,'platform:notification:read','标记消息已读','/api/platform/v1/notifications/{id}/read','PUT',610,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106012,0,'PLATFORM','BUTTON',5106001,'platform:notification:read-all','全部标记已读','/api/platform/v1/notifications/read-all','PUT',611,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106013,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read','标记消息已读','/api/merchant/v1/notifications/{id}/read','PUT',901,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5106014,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read-all','全部标记已读','/api/merchant/v1/notifications/read-all','PUT',902,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107016,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:edit','编辑海外排车',NULL,'PUT',716,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE',717,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107018,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107030,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:pallet:list','海外托盘','/overseas-warehouse/pallets','GET',730,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107031,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:create','创建海外托盘',NULL,'POST',731,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107032,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:edit','编辑海外托盘',NULL,'PUT',732,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107033,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:delete','删除海外托盘',NULL,'DELETE',733,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107040,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inventory:list','海外库存','/overseas-warehouse/inventory','GET',740,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107041,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:export','导出海外库存',NULL,'GET',741,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107042,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:edit','编辑海外库存状态',NULL,'POST',742,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107043,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:adjust','调整海外库存',NULL,'POST',743,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107050,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:warehouse:list','海外仓资源','/overseas-warehouse/warehouses','GET',750,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107051,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:create','创建海外仓资源',NULL,'POST',751,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107052,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:edit','编辑海外仓资源',NULL,'PUT',752,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107053,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:status','变更海外仓状态',NULL,'PATCH',753,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107060,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:location:list','海外库位','/overseas-warehouse/locations','GET',760,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107064,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:dispatch:list','海外排车单','/overseas-warehouse/dispatches','GET',764,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107072,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107073,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST',773,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107080,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET',780,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107081,0,'PLATFORM','BUTTON',5107080,'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT',781,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107082,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:status','编辑派送单状态',NULL,'PUT',782,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107093,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:cancel','取消海外出库单',NULL,'POST',768,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST',769,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107110,0,'PLATFORM','MENU',0,'platform:overseas-business:truck:list','本土卡车','/overseas-business/trucks','GET',810,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107111,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:create','创建本土卡车单',NULL,'POST',811,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107112,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:edit','编辑本土卡车单',NULL,'PUT',812,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107113,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:status','变更本土卡车状态',NULL,'PATCH',813,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107120,0,'PLATFORM','MENU',0,'platform:overseas-business:looseboard:list','本土散板','/overseas-business/looseboards','GET',820,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107190,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-inventory:list','退货库存管理','/overseas-warehouse/return-inventory','GET',890,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107191,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-cartons:list','退货箱号管理','/overseas-warehouse/return-cartons','GET',891,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107203,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:status','变更海外仓报价状态',NULL,'PATCH',903,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107204,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:export','导出海外仓报价',NULL,'GET',904,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107210,0,'PLATFORM','MENU',0,'platform:overseas-master-data:exchange-rate:list','月度汇率','/overseas-master-data/exchange-rates','GET',910,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107211,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:create','创建月度汇率',NULL,'POST',911,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107212,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:edit','编辑月度汇率',NULL,'PUT',912,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107213,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:status','变更月度汇率状态',NULL,'PATCH',913,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107214,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:export','导出月度汇率',NULL,'GET',914,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107220,0,'PLATFORM','MENU',0,'platform:overseas-master-data:vehicle:list','车辆主数据','/overseas-master-data/vehicles','GET',920,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107221,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:create','创建车辆主数据',NULL,'POST',921,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107222,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:edit','编辑车辆主数据',NULL,'PUT',922,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107223,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:status','变更车辆状态',NULL,'PATCH',923,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107224,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:export','导出车辆主数据',NULL,'GET',924,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300000,0,'MERCHANT','MENU',0,'merchant:enterprise','企业管理','/enterprise',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300010,0,'MERCHANT','MENU',5300000,'merchant:profile:view','企业资料','/enterprise/profile',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300011,0,'MERCHANT','BUTTON',5300010,'merchant:profile:edit','编辑企业资料',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300012,0,'MERCHANT','BUTTON',5300010,'merchant:qualification:upload','上传企业资质',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300020,0,'MERCHANT','MENU',5300000,'merchant:member:list','企业成员','/enterprise/members',NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300021,0,'MERCHANT','BUTTON',5300020,'merchant:member:create','新增成员',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300022,0,'MERCHANT','BUTTON',5300020,'merchant:member:grant-role','分配成员角色',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300023,0,'MERCHANT','BUTTON',5300020,'merchant:member:edit','编辑成员',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300024,0,'MERCHANT','BUTTON',5300020,'merchant:member:status','启停成员',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300025,0,'MERCHANT','BUTTON',5300020,'merchant:member:reset-password','重置成员密码',NULL,NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300030,0,'MERCHANT','MENU',5300000,'merchant:role:list','角色管理','/enterprise/roles',NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300031,0,'MERCHANT','BUTTON',5300030,'merchant:role:create','新增角色',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300032,0,'MERCHANT','BUTTON',5300030,'merchant:role:grant','角色授权',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300033,0,'MERCHANT','BUTTON',5300030,'merchant:role:edit','编辑角色',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300034,0,'MERCHANT','BUTTON',5300030,'merchant:role:status','启停角色',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5300035,0,'MERCHANT','BUTTON',5300030,'merchant:role:delete','删除角色',NULL,NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5301000,0,'MERCHANT','MENU',0,'merchant:order','头程订单','/first-leg-orders',NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5301010,0,'MERCHANT','MENU',5301000,'merchant:order:list','我的订单','/first-leg-orders/list',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5301011,0,'MERCHANT','BUTTON',5301010,'merchant:order:view','查看订单',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5301012,0,'MERCHANT','BUTTON',5301010,'merchant:order:cancel','取消订单',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5301020,0,'MERCHANT','MENU',5301000,'merchant:order:create-page','新建订单','/first-leg-orders/create',NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5301021,0,'MERCHANT','BUTTON',5301020,'merchant:order:create','创建草稿',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5301022,0,'MERCHANT','BUTTON',5301020,'merchant:order:edit','编辑草稿',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5301023,0,'MERCHANT','BUTTON',5301020,'merchant:order:submit','提交订单',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5301024,0,'MERCHANT','BUTTON',5301020,'merchant:order:quick-create','快速下单',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5302000,0,'MERCHANT','MENU',0,'merchant:master-data','基础资料','/master-data',NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5302010,0,'MERCHANT','MENU',5302000,'merchant:warehouse:list','收货仓选择','/master-data/warehouses',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5302020,0,'MERCHANT','MENU',5302000,'merchant:counterparty:list','往来单位','/master-data/counterparties',NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5305000,0,'MERCHANT','MENU',5300000,'merchant:finance:statement','财务账单','/finance/statements',NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5305010,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:list','查看账单',NULL,'GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5305011,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:view','查看账单详情及收款',NULL,'GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5305020,0,'MERCHANT','MENU',5300000,'merchant:finance:account:view','资金账户','/finance/account','GET',50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5305021,0,'MERCHANT','BUTTON',5305020,'merchant:finance:recharge:create','提交充值申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement','海外采购','/cross-border/procurement',NULL,70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking',NULL,80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping',NULL,90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306140,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory','GET',95,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306141,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-cartons:list','退货箱号管理','/overseas-warehouse/return-cartons','GET',96,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); + +-- 系统基线 iam_role:8 行。 +INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000001,0,'PLATFORM','PLATFORM_ADMIN','平台超级管理员','SYSTEM','ACTIVE','一期平台全部菜单和按钮',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000002,0,'PLATFORM','CUSTOMER_REVIEWER','企业审核员','SYSTEM','ACTIVE','企业资料与入驻审核',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000003,0,'PLATFORM','ORDER_OPERATOR','平台订单运营','SYSTEM','ACTIVE','头程订单审核与履约处理',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000004,0,'PLATFORM','WAREHOUSE_OPERATOR','国内仓后台操作员','SYSTEM','ACTIVE','负责查看入仓业务并在后台维护装柜证据',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000005,0,'PLATFORM','OVERSEAS_WAREHOUSE_PDA_OPERATOR','海外仓 PDA 作业员','SYSTEM','ACTIVE','负责使用海外仓 PDA 执行接柜、入仓和库内作业,必须绑定一个海外仓',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000006,0,'PLATFORM','WAREHOUSE_TALLY','国内仓理货复核员','SYSTEM','ACTIVE','负责国内入库测量、PDA复核和装柜证据维护',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM','ACTIVE','负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000100,0,'MERCHANT','MERCHANT_OWNER','企业管理员','SYSTEM','ACTIVE','一期企业全部菜单和操作按钮模板',1000001,CURRENT_TIMESTAMP(3),1000001,CURRENT_TIMESTAMP(3),0,0); + +-- 系统基线 iam_role_permission:521 行。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100011,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100012,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100013,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100014,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100015,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100016,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100017,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100018,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100021,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100022,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100030,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100031,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100032,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100033,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101011,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101012,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101013,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101014,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101015,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101021,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101022,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101023,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101024,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101025,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101030,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101031,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101032,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101033,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101034,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101040,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101041,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102011,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102012,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102013,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102014,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102015,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102016,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102017,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102018,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102019,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102021,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102022,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102023,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102024,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102025,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102026,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102027,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102030,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102031,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102032,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102041,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102042,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102043,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102044,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102045,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102051,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102052,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102053,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102054,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102061,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102062,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102063,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102064,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102065,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102066,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102067,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102068,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102069,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102070,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102071,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102072,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102073,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102081,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102082,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102083,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102084,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102090,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102092,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102093,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102094,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102097,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102098,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102100,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102103,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102104,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102105,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102106,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102111,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102112,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102121,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102122,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102123,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102124,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102125,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102126,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102127,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102128,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102129,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102140,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102142,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102146,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102147,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102148,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102149,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102150,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102152,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102153,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102154,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102160,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102161,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102162,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102163,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102164,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102170,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102172,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102180,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102182,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102183,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102184,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102186,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102188,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102190,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102191,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102192,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102193,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102194,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102195,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102200,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102201,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102202,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102203,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102205,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103011,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103012,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103013,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103021,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103022,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103023,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103024,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103030,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103031,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103032,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103033,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103034,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103040,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103041,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103050,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103051,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103052,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103054,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103060,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103061,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103062,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103063,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103064,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103070,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103071,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103072,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103080,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103081,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103082,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5103083,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5104000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5104010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105012,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105014,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105015,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105016,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105017,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105018,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105019,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105021,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105024,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105025,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105026,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105027,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105028,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105029,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105030,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105031,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105032,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105035,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105036,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105040,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105041,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105042,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105043,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105044,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105045,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105050,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105051,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105052,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105053,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105054,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105055,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105056,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5105057,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5106000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5106001,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5106002,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5106003,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5106004,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5106005,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5106006,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5106007,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5106008,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5106009,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5106011,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5106012,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107001,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107002,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107003,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107011,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107012,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107013,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107014,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107016,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107017,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107018,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107021,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107022,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107030,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107031,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107032,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107033,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107040,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107041,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107042,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107043,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107050,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107051,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107052,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107053,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107060,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107061,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107062,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107063,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107064,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107070,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107071,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107072,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107073,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107080,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107081,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107082,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107090,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107091,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107092,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107093,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107094,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107100,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107101,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107102,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107110,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107111,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107112,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107113,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107120,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107121,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107122,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107123,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107190,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107191,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107200,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107201,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107202,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107203,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107204,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107210,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107211,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107212,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107213,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107214,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107220,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107221,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107222,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107223,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107224,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000002,5100000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000002,5100010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000002,5100014,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000002,5100015,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000002,5100020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000002,5100021,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000002,5100022,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000002,5103000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000002,5103040,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5100012,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5100013,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5100015,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5100016,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5100017,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5100018,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5101015,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5101032,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5101033,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5101034,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102011,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102012,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102013,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102014,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102015,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102016,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102017,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102018,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102019,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102021,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102022,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102023,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102024,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102025,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102026,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102027,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102030,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102031,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102041,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102042,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102043,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102044,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102045,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102051,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102052,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102061,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102063,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102065,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102066,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102067,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102068,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102069,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102090,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102092,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102093,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102094,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102097,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102098,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102100,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102103,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102104,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102105,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102106,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102111,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102121,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102122,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102123,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102124,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102125,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102126,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102127,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102128,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102129,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102140,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102142,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102146,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102147,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102148,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102149,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102150,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102152,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102153,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102154,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102160,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102161,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102162,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102163,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102164,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102170,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102172,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102180,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102182,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102183,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102184,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102186,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102188,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102190,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102191,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102192,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102193,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102194,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102195,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102200,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102201,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102202,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102203,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102205,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5103020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5103021,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5103022,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5103023,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5103024,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5103030,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5103031,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5103032,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5103033,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5103040,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5103041,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102030,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102031,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102124,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102125,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107016,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107017,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107018,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107030,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107040,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107050,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107060,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107064,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107070,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107071,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107072,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107073,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107080,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107081,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107082,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107090,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107091,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107092,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107093,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107094,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107100,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107110,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107120,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000005,5107015,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5102000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5102030,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5102031,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5102041,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5102042,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5102043,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5102044,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5102045,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5102124,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5102125,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5102190,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5102193,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107030,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107040,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107050,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107060,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107064,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107070,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107080,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107090,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107092,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107100,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107110,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000006,5107120,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000007,5102190,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000007,5102192,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5106010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5106013,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5106014,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300011,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300012,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300021,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300022,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300023,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300024,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300025,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300030,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300031,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300032,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300033,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300034,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5300035,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5301000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5301010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5301011,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5301012,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5301020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5301021,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5301022,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5301023,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5301024,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5302000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5302010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5302020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5305000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5305010,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5305011,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5305012,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5305020,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5305021,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306000,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306001,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306100,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306110,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306120,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306130,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306140,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306141,0,CURRENT_TIMESTAMP(3)); + +-- 系统基线 iam_tenant:1 行。 +INSERT INTO iam_tenant (id, tenant_code, tenant_name, customer_nickname, tenant_type, status, dimension_initialization_status, legal_name, legal_representative, unified_social_credit_code, contact_name, contact_phone, contact_email, service_package, contact_wechat, sales_owner_name, payment_term_days, enterprise_type, established_date, registered_capital, registered_capital_currency, registered_address, internal_remark, approved_at, approved_by, reject_reason, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (0,'PLATFORM','虫虫出海平台','平台','PLATFORM','ACTIVE','READY','虫虫出海平台',NULL,NULL,'平台系统管理员',NULL,NULL,'基础版',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),1000001,NULL,1000001,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); + +-- 系统基线 iam_user:1 行。 +INSERT INTO iam_user (id, auth_user_id, user_type, display_name, phone, email, status, created_at, updated_at, deleted, version) VALUES (2000001,1000001,'INTERNAL','平台系统管理员',NULL,NULL,'ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0,0); + +USE baiyue_warehouse; + +-- 系统基线 wh_service_capability:4 行。 +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) VALUES (193000000000400001,0,193000000000200001,'RECEIVING','头程收货',1,'16:00:00','义乌仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +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) VALUES (193000000000400002,0,193000000000200002,'RECEIVING','头程收货',1,'16:00:00','东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +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) VALUES (193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1,'16:00:00','洛杉矶仓海外接柜入仓收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +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) VALUES (193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00','纽约仓海外接柜入仓收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); + +-- 系统基线 wh_warehouse:318 行。 +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000001,0,'ABE2',NULL,'ABE2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000002,0,'ABE3',NULL,'ABE3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000003,0,'ABE4','126','ABE4','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000004,0,'ABE5',NULL,'ABE5','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000005,0,'ABE8','39','ABE8','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000006,0,'ABQ2','76','ABQ2','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000007,0,'ACY2','101','ACY2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000008,0,'AFW1',NULL,'AFW1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000009,0,'AFW5','181','AFW5','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000010,0,'AKR1','153','AKR1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000011,0,'ALB1','102','ALB1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000012,0,'AMA1','132','AMA1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000013,0,'ATL2N','157','ATL2N','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000014,0,'ATL3',NULL,'ATL3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000015,0,'ATL6',NULL,'ATL6','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000016,0,'AVP1','70','AVP1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000017,0,'AVP3',NULL,'AVP3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000018,0,'BDL1',NULL,'BDL1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000019,0,'BFI1',NULL,'BFI1','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000020,0,'BFI3','34','BFI3','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000021,0,'BFI7',NULL,'BFI7','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000022,0,'BJC1','186','BJC1','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000023,0,'BNA1',NULL,'BNA1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000024,0,'BNA2','37','BNA2','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000025,0,'BNA3',NULL,'BNA3','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000026,0,'BNA4',NULL,'BNA4','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000027,0,'BNA6','99','BNA6','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000028,0,'BOS1',NULL,'BOS1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000029,0,'BOS2',NULL,'BOS2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000030,0,'BOS7','103','BOS7','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000031,0,'BWI1',NULL,'BWI1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000032,0,'BWI2',NULL,'BWI2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000033,0,'BWI4','150','BWI4','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000034,0,'CAE1',NULL,'CAE1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000035,0,'CHA1',NULL,'CHA1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000036,0,'CHA2','66','CHA2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000037,0,'CHO1','120','CHO1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000038,0,'CLT2','59','CLT2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000039,0,'CLT3','104','CLT3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000040,0,'CMH2','123','CMH2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000041,0,'CMH3','32','CMH3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000042,0,'CVG1',NULL,'CVG1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000043,0,'CVG2',NULL,'CVG2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000044,0,'CVG3',NULL,'CVG3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000045,0,'CVG5',NULL,'CVG5','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000046,0,'DCA6','135','DCA6','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000047,0,'DEN2','107','DEN2','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000048,0,'DEN8','73','DEN8','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000049,0,'DET1','118','DET1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000050,0,'DET2','136','DET2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000051,0,'DFW5S',NULL,'DFW5S','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000052,0,'DFW6','105','DFW6','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000053,0,'DFW8',NULL,'DFW8','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000054,0,'EWR4',NULL,'EWR4','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000055,0,'EWR5',NULL,'EWR5','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000056,0,'EWR7',NULL,'EWR7','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000057,0,'EWR9',NULL,'EWR9','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000058,0,'FAT2','43','FAT2','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000059,0,'FOE1','117','FOE1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000060,0,'FTW1','24','FTW1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000061,0,'FTW2',NULL,'FTW2','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000062,0,'FTW3',NULL,'FTW3','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000063,0,'FTW5','48','FTW5','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000064,0,'FWA4','58','FWA4','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000065,0,'GEG2','108','GEG2','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000066,0,'GEU2','97','GEU2','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000067,0,'GEU3','81','GEU3','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000068,0,'GEU5','100','GEU5','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000069,0,'GSO1','131','GSO1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000070,0,'GSP1','90','GSP1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000071,0,'GYR2','7','GYR2','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000072,0,'GYR3','8','GYR3','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000073,0,'HGR6','130','HGR6','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000074,0,'HIA1','148','HIA1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000075,0,'HLI2','137','HLI2','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000076,0,'HOU1',NULL,'HOU1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000077,0,'HOU2',NULL,'HOU2','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000078,0,'HOU3',NULL,'HOU3','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000079,0,'HOU8','49','HOU8','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000080,0,'HSV1','80','HSV1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000081,0,'HSV2',NULL,'HSV2','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000082,0,'IAH3','57','IAH3','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000083,0,'IAZ1','179','IAZ1','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000084,0,'ICT2','109','ICT2','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000085,0,'IGA3','197','IGA3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000086,0,'IGQ2','110','IGQ2','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000087,0,'ILG1','35','ILG1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000088,0,'IMO1','178','IMO1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000089,0,'IMS1','189','IMS1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000090,0,'INC1','190','INC1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000091,0,'IND2','127','IND2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000092,0,'IND4',NULL,'IND4','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000093,0,'IND5','140','IND5','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000094,0,'IND6',NULL,'IND6','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000095,0,'IND9','17','IND9','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000096,0,'ITX3','187','ITX3','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000097,0,'IUSF','169','IUSF','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000098,0,'IUSJ','98','IUSJ','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000099,0,'IUSP','83','IUSP','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000100,0,'IUSQ','95','IUSQ','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000101,0,'IUSR','158','IUSR','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000102,0,'IUST','149','IUST','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000103,0,'IUSW','156','IUSW','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000104,0,'IUTE','170','IUTE','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000105,0,'IUTJ','177','IUTJ','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000106,0,'IUTL','188','IUTL','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000107,0,'IWA6','191','IWA6','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000108,0,'JOT1','93','JOT1','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000109,0,'JVL1','125','JVL1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000110,0,'KRB4',NULL,'KRB4','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000111,0,'KY1','60','KY1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000112,0,'LAN2','96','LAN2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000113,0,'LAS1','5','LAS1','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000114,0,'LAS2',NULL,'LAS2','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000115,0,'LAS6','50','LAS6','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000116,0,'LAX2T','19','LAX2T','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000117,0,'LAX9','3','LAX9','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000118,0,'LBE1','112','LBE1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000119,0,'LEX1',NULL,'LEX1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000120,0,'LEX2',NULL,'LEX2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000121,0,'LFT1','31','LFT1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000122,0,'LGB3',NULL,'LGB3','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000123,0,'LGB4','45','LGB4','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000124,0,'LGB6','26','LGB6','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000125,0,'LGB8','2','LGB8','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000126,0,'LIT2','159','LIT2','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000127,0,'MCC1','161','MCC1','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000128,0,'MCE1','65','MCE1','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000129,0,'MCI1',NULL,'MCI1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000130,0,'MCI4','176','MCI4','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000131,0,'MCO2','106','MCO2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000132,0,'MDT1','121','MDT1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000133,0,'MDT4','144','MDT4','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000134,0,'MDW2','46','MDW2','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000135,0,'MDW6','143','MDW6','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000136,0,'MDW7',NULL,'MDW7','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000137,0,'MDW8',NULL,'MDW8','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000138,0,'MDW9','36','MDW9','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000139,0,'MEM1','85','MEM1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000140,0,'MEM6','51','MEM6','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000141,0,'MGE3','133','MGE3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000142,0,'MIT2','82','MIT2','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000143,0,'MKC4','67','MKC4','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000144,0,'MKC6',NULL,'MKC6','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000145,0,'MKE1',NULL,'MKE1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000146,0,'MQJ1','88','MQJ1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000147,0,'MSP1',NULL,'MSP1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000148,0,'NJ3','175','NJ3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000149,0,'OAK3','41','OAK3','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000150,0,'OAK4',NULL,'OAK4','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000151,0,'OKC2','33','OKC2','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000152,0,'OLM1','172','OLM1','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000153,0,'ONT2',NULL,'ONT2','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000154,0,'ONT6',NULL,'ONT6','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000155,0,'ONT8','1','ONT8','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000156,0,'ONT9','25','ONT9','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000157,0,'ORD2','21','ORD2','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000158,0,'ORD6',NULL,'ORD6','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000159,0,'ORF2','38','ORF2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000160,0,'PBI3','113','PBI3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000161,0,'PDX7','68','PDX7','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000162,0,'PHL1',NULL,'PHL1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000163,0,'PHL3',NULL,'PHL3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000164,0,'PHL4','52','PHL4','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000165,0,'PHL5',NULL,'PHL5','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000166,0,'PHL5S','171','PHL5S','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000167,0,'PHL6','69','PHL6','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000168,0,'PHL7',NULL,'PHL7','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000169,0,'PHL8',NULL,'PHL8','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000170,0,'PHX3',NULL,'PHX3','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000171,0,'PHX5','72','PHX5','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000172,0,'PHX6',NULL,'PHX6','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000173,0,'PHX7','74','PHX7','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000174,0,'PIT1',NULL,'PIT1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000175,0,'PIT2','78','PIT2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000176,0,'POC1','128','POC1','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000177,0,'POC2','151','POC2','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000178,0,'POC3','129','POC3','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000179,0,'PPO4','122','PPO4','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000180,0,'PSC2','75','PSC2','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000181,0,'PSP3','162','PSP3','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000182,0,'QXY5','92','QXY5','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000183,0,'QXY8','154','QXY8','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000184,0,'QZZ7','184','QZZ7','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000185,0,'RDU2','56','RDU2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000186,0,'RDU4','145','RDU4','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000187,0,'RFD2','71','RFD2','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000188,0,'RFD4','174','RFD4','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000189,0,'RIC1','142','RIC1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000190,0,'RIC2',NULL,'RIC2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000191,0,'RMN3','84','RMN3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000192,0,'RNO1',NULL,'RNO1','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000193,0,'RNO2',NULL,'RNO2','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000194,0,'RNO4',NULL,'RNO4','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000195,0,'RYY2','94','RYY2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000196,0,'SAT1','23','SAT1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000197,0,'SAT4','61','SAT4','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000198,0,'SAT6','183','SAT6','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000199,0,'SAV3','124','SAV3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000200,0,'SBD1','4','SBD1','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000201,0,'SBD2','15','SBD2','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000202,0,'SCK1','20','SCK1','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000203,0,'SCK3','54','SCK3','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000204,0,'SCK4','10','SCK4','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000205,0,'SCK8','141','SCK8','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000206,0,'SDF1',NULL,'SDF1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000207,0,'SDF2',NULL,'SDF2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000208,0,'SDF4',NULL,'SDF4','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000209,0,'SDF6',NULL,'SDF6','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000210,0,'SDF7',NULL,'SDF7','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000211,0,'SDF8',NULL,'SDF8','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000212,0,'SDF9',NULL,'SDF9','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000213,0,'SEA6',NULL,'SEA6','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000214,0,'SEA8',NULL,'SEA8','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000215,0,'SJC7','30','SJC7','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000216,0,'SLC2','86','SLC2','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000217,0,'SLC3','185','SLC3','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000218,0,'SMF3','9','SMF3','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000219,0,'SMF6','28','SMF6','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000220,0,'SNA4','18','SNA4','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000221,0,'STL3','152','STL3','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000222,0,'STL4','115','STL4','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000223,0,'SWF1','134','SWF1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000224,0,'SWF2','62','SWF2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000225,0,'TCY1','139','TCY1','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000226,0,'TCY2','138','TCY2','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000227,0,'TEB3','47','TEB3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000228,0,'TEB4','55','TEB4','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000229,0,'TEB6','114','TEB6','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000230,0,'TEB9','40','TEB9','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000231,0,'TMB8','116','TMB8','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000232,0,'TOL3','173','TOL3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000233,0,'TPA1',NULL,'TPA1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000234,0,'TPA2','22','TPA2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000235,0,'TPA3','146','TPA3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000236,0,'TUL1',NULL,'TUL1','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000237,0,'VGT2','6','VGT2','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000238,0,'VUBA',NULL,'VUBA','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000239,0,'VUGA',NULL,'VUGA','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000240,0,'WBW2','119','WBW2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000241,0,'XAV3','180','XAV3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000242,0,'XEW5','163','XEW5','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000243,0,'XIN5','182','XIN5','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000244,0,'XLX1','160','XLX1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000245,0,'XLX6','166','XLX6','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000246,0,'XLX7','27','XLX7','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000247,0,'XPB2','165','XPB2','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000248,0,'XRI3','167','XRI3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000249,0,'XUSB',NULL,'XUSB','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000250,0,'XUSC',NULL,'XUSC','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000251,0,'XUSD',NULL,'XUSD','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000252,0,'XUSE',NULL,'XUSE','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000253,0,'ABE8-309',NULL,'ABE8-309','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000254,0,'ABE8-401',NULL,'ABE8-401','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000255,0,'ABQ2-UPS',NULL,'ABQ2-UPS','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000256,0,'ATL1','200','ATL1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000257,0,'ATL1-WALMART',NULL,'ATL1-WALMART','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000258,0,'AZA4','16','AZA4','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000259,0,'CEG2','79','CEG2','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000260,0,'CMH1-11999',NULL,'CMH1-11999','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000261,0,'FEDEX','12','FEDEX','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000262,0,'HEA2','77','HEA2','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000263,0,'HOU7','63','HOU7','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000264,0,'IAK3','44','IAK3','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000265,0,'IMI1','199','IMI1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000266,0,'IND1-AMAZON',NULL,'IND1-AMAZON','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000267,0,'IND1-WALMART',NULL,'IND1-WALMART','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000268,0,'IND2-AMAZON',NULL,'IND2-AMAZON','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000269,0,'IND2-WALMART',NULL,'IND2-WALMART','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000270,0,'IND3','198','IND3','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000271,0,'IND3-亚马逊',NULL,'IND3-亚马逊','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000272,0,'IND3-沃尔玛',NULL,'IND3-沃尔玛','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000273,0,'IPA1','194','IPA1','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000274,0,'IUSP-AWD','83','IUSP-AWD','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000275,0,'JAX3','111','JAX3','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000276,0,'JAX3-32210','111','JAX3-32210','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000277,0,'JAX3-32221',NULL,'JAX3-32221','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000278,0,'LAN2-48917',NULL,'LAN2-48917','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000279,0,'LAX1','192','LAX1','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000280,0,'LAX1-WALMART',NULL,'LAX1-WALMART','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000281,0,'LAX2T-WALMART',NULL,'LAX2T-WALMART','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000282,0,'LBE1-UPS',NULL,'LBE1-UPS','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000283,0,'MCO1-WALMART',NULL,'MCO1-WALMART','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000284,0,'MDW2-60433-3280',NULL,'MDW2-60433-3280','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000285,0,'MEM1S-WALMART',NULL,'MEM1S-WALMART','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000286,0,'MIA1-33054',NULL,'MIA1-33054','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000287,0,'MIA1-33182',NULL,'MIA1-33182','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000288,0,'MQY2','193','MQY2','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000289,0,'NJ3-WALMART',NULL,'NJ3-WALMART','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000290,0,'NJ3T','195','NJ3T','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000291,0,'ORD1S-WALMART',NULL,'ORD1S-WALMART','FBA','US','UTC-6',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000292,0,'ORF2-FEDEX',NULL,'ORF2-FEDEX','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000293,0,'ORF2-UPS',NULL,'ORF2-UPS','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000294,0,'PHL5S-WALMART',NULL,'PHL5S-WALMART','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000295,0,'PSC2-UPS',NULL,'PSC2-UPS','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000296,0,'SLC1N','196','SLC1N','FBA','US','UTC-7',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000297,0,'SMF1-AMAZON',NULL,'SMF1-AMAZON','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000298,0,'SMF1-WALMART',NULL,'SMF1-WALMART','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000299,0,'SMF7','53','SMF7','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000300,0,'STL6','64','STL6','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000301,0,'SWF2-10710',NULL,'SWF2-10710','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000302,0,'SWF2-12533',NULL,'SWF2-12533','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000303,0,'TMB8-UPS',NULL,'TMB8-UPS','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000304,0,'UPS','11','UPS','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000305,0,'VGT2-FEDEX',NULL,'VGT2-FEDEX','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000306,0,'VGT2-UPS',NULL,'VGT2-UPS','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000307,0,'XBN3','91','XBN3','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000308,0,'XCH2','87','XCH2','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000309,0,'XMI3','89','XMI3','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000310,0,'洛杉矶仓一件代发','CADF','洛杉矶仓一件代发','FBA','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000311,0,'海外仓打托','13','海外仓打托','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000312,0,'私人仓','14','私人仓','FBA','US','',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000313,0,'纽约仓',NULL,'纽约仓','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000000314,0,'纽约仓一件代发','NJDF','纽约仓一件代发','FBA','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000200001,0,'CN-YIWU-01',NULL,'义乌仓','DOMESTIC','CN','UTC+8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000200002,0,'CN-DONGGUAN-01',NULL,'东莞仓','DOMESTIC','CN','UTC+8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000200003,0,'US-LAX-01',NULL,'洛杉矶仓','OVERSEAS','US','UTC-8',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse (id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone, contact_name, contact_phone, contact_email, status, remark, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000200004,0,'US-NYC-01',NULL,'纽约仓','OVERSEAS','US','UTC-5',NULL,NULL,NULL,'ACTIVE',NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); + +-- 系统基线 wh_warehouse_address:318 行。 +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100001,0,193000000000000001,'OPERATING','US','PA','Breinigsville',NULL,'18031','705 Boulder Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100002,0,193000000000000002,'OPERATING','US','PA','Breinigsville',NULL,'18031','650 Boulder Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100003,0,193000000000000003,'OPERATING','US','PA','Easton',NULL,'18045','1610 Van Buren Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100004,0,193000000000000004,'OPERATING','US','PA','Harrisburg',NULL,'17112','6455 Allentown Boulevard',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100005,0,193000000000000005,'OPERATING','US','NJ','Florence',NULL,'08518','401 Independence Road 08518-2200',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100006,0,193000000000000006,'OPERATING','US','NM','LOS LUNAS',NULL,'87031','6251 Pioneer Trail NW',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100007,0,193000000000000007,'OPERATING','US','NJ','BURLINGTON',NULL,'08016','1101 E PEARL ST',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100008,0,193000000000000008,'OPERATING','US','TX','FORT WORTH',NULL,'76131','1851 NE LOOP 820',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100009,0,193000000000000009,'OPERATING','US','TX','BALCH SPRINGS',NULL,'75180-1636','12374 LAKE JUNE RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100010,0,193000000000000010,'OPERATING','US','OH','Canton',NULL,'44705','4747 Rebar Ave NE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100011,0,193000000000000011,'OPERATING','US','NY','Castleton',NULL,'12033-9634','1835 US Route 9',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100012,0,193000000000000012,'OPERATING','US','TX','Amarillo',NULL,'79108','8590 NE 24th Avenue',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100013,0,193000000000000013,'OPERATING','US','GA','Carrollton',NULL,'30117','3101 N. Highway 27',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100014,0,193000000000000014,'OPERATING','US','GA','Pendergrass',NULL,'30567','445 Valentine Industrial Parkway',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100015,0,193000000000000015,'OPERATING','US','GA','East Point',NULL,'30344','4200 North Commerce',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100016,0,193000000000000016,'OPERATING','US','PA','Hazle Township',NULL,'18202-9361','550 Oak Ridge Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100017,0,193000000000000017,'OPERATING','US','PA','GOULDSBORO',NULL,'18424-9492','298 1ST AVE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100018,0,193000000000000018,'OPERATING','US','CT','Windsor',NULL,'06095','801 Day Hill Rd.',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100019,0,193000000000000019,'OPERATING','US','WA','Sumner',NULL,'98390','1800 140th Avenue E.',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100020,0,193000000000000020,'OPERATING','US','WA','Dupont',NULL,'98327-9607','2700 Center Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100021,0,193000000000000021,'OPERATING','US','WA','Sumner',NULL,'98390','1901 140th Ave E',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100022,0,193000000000000022,'OPERATING','US','CO','AURORA',NULL,'80019','6300 N Powhaton Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100023,0,193000000000000023,'OPERATING','US','TN','Lebanon',NULL,'37090','14840 Central Pike Suite 190',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100024,0,193000000000000024,'OPERATING','US','TN','Lebanon',NULL,'37090','500 Duke DR',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100025,0,193000000000000025,'OPERATING','US','TN','Murfreesboro',NULL,'37127','2020 Joe B Jackson Pkwy',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100026,0,193000000000000026,'OPERATING','US','TN','Nashville',NULL,'37217','50 airways Blvd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100027,0,193000000000000027,'OPERATING','US','TN','CLARKSVILLE',NULL,'37040-5502','3875 GUTHRIE HWY',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100028,0,193000000000000028,'OPERATING','US','NH','Nashua',NULL,'03063','10 State St.',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100029,0,193000000000000029,'OPERATING','US','MA','Drive Stoughton',NULL,'02072','1000 Tech Centre',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100030,0,193000000000000030,'OPERATING','US','MA','Fall River',NULL,'02720','1180 Innovation Way',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100031,0,193000000000000031,'OPERATING','US','VA','Sterling',NULL,'20166-2058','45121 Global Plaza',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100032,0,193000000000000032,'OPERATING','US','MD','Baltimore',NULL,'21224','2010 Broening Hwy',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100033,0,193000000000000033,'OPERATING','US','VA','CLEARBROOK',NULL,'22624','165 Business Blvd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100034,0,193000000000000034,'OPERATING','US','SC','West Columbia',NULL,'29172','4400 12TH STREET EXT',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100035,0,193000000000000035,'OPERATING','US','TN','Chattanooga',NULL,'37416','7200 Discovery Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100036,0,193000000000000036,'OPERATING','US','TN','Charleston',NULL,'37310','225 Infinity Dr NW',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100037,0,193000000000000037,'OPERATING','US','VA','FISHERSVILLE',NULL,'22939','32 TRADER RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100038,0,193000000000000038,'OPERATING','US','NC','Charlotte',NULL,'28214-8082','10240 Old Dowd Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100039,0,193000000000000039,'OPERATING','US','NC','CONCORD',NULL,'28027','6500 Davidson Hwy',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100040,0,193000000000000040,'OPERATING','US','OH','Groveport',NULL,'43125-9016','6050 Gateway Court',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100041,0,193000000000000041,'OPERATING','US','OH','Monroe',NULL,'45050-1848','700 GATEWAY BLVD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100042,0,193000000000000042,'OPERATING','US','KY','Hebron',NULL,'41048','1155 Worldwide Blvd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100043,0,193000000000000043,'OPERATING','US','KY','Hebron',NULL,'41048','1600 Worldwide Blvd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100044,0,193000000000000044,'OPERATING','US','KY','Hebron',NULL,'41048','3680 Langley Dr.',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100045,0,193000000000000045,'OPERATING','US','KY','Hebron',NULL,'41048','2285 Litton Lane',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100046,0,193000000000000046,'OPERATING','US','MD','BALTIMORE',NULL,'21219','6001 Bethlehem Blvd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100047,0,193000000000000047,'OPERATING','US','CO','Aurora',NULL,'80019','22205 East 19th Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100048,0,193000000000000048,'OPERATING','US','CO','Aurora',NULL,'80018','21000 E 13th Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100049,0,193000000000000049,'OPERATING','US','MI','Livonia',NULL,'48150-1543','39000 Amrhein Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100050,0,193000000000000050,'OPERATING','US','MI','Shelby Township',NULL,'48317-1318','50500 Mound Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100051,0,193000000000000051,'OPERATING','US','TX','Lancaster',NULL,'75146','2500 E Belt Line Rd,',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100052,0,193000000000000052,'OPERATING','US','TX','Coppell',NULL,'75019','940 W Bethel Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100053,0,193000000000000053,'OPERATING','US','TX','DFW Airport',NULL,'75261','2700 Regent Blvd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100054,0,193000000000000054,'OPERATING','US','NJ','Robbinsville',NULL,'08691','50 New Canton Way',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100055,0,193000000000000055,'OPERATING','US','NJ','Avenel',NULL,'07001','301 Blair Rd.',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100056,0,193000000000000056,'OPERATING','US','NJ','Avenel',NULL,'07001','301 Blair Rd.',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100057,0,193000000000000057,'OPERATING','US','NJ','Carteret',NULL,'07008','8003 Industrial Parkway',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100058,0,193000000000000058,'OPERATING','US','CA','VISALIA',NULL,'93291-1882','3315 N KELSEYST',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100059,0,193000000000000059,'OPERATING','US','KS','KANSAS CITY',NULL,'66109-3665','9400 LEAVENWORTH RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100060,0,193000000000000060,'OPERATING','US','TX','Dallas',NULL,'75241','33333 Lyndon B Johnson FREEWAY',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100061,0,193000000000000061,'OPERATING','US','TX','Coppell',NULL,'75019','2701 West Bethel Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100062,0,193000000000000062,'OPERATING','US','TX','Fort Worth',NULL,'76177-2517','15201 Heritage Pkwy',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100063,0,193000000000000063,'OPERATING','US','TX','Forney',NULL,'75126','1475 AKRON WAY',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100064,0,193000000000000064,'OPERATING','US','IN','FORT WAYNE',NULL,'46809-9771','9798 Smith Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100065,0,193000000000000065,'OPERATING','US','WA','SPOKANE',NULL,'99216','18007 E Garland Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100066,0,193000000000000066,'OPERATING','US','AZ','Glendale',NULL,'85307','15301 West Northern Avenue',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100067,0,193000000000000067,'OPERATING','US','AZ','BUCKEYE',NULL,'85396','18900 W McDowell Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100068,0,193000000000000068,'OPERATING','US','AZ','GOODYEAR',NULL,'85395','4660 North Cotton Lane',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100069,0,193000000000000069,'OPERATING','US','NC','KERNERSVILLE',NULL,'27284','1656 OLD GREENSBORO RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100070,0,193000000000000070,'OPERATING','US','SC','Spartanburg',NULL,'29303','402 John Dodd Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100071,0,193000000000000071,'OPERATING','US','AZ','GOODYEAR',NULL,'85395-7672','17341 W MINNEZONA AVE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100072,0,193000000000000072,'OPERATING','US','AZ','PHOENIX',NULL,'85043-2356','8181 W ROOSEVELT ST',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100073,0,193000000000000073,'OPERATING','US','MD','HAGERSTOWN',NULL,'21740-7301','55 West Oak Ridge Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100074,0,193000000000000074,'OPERATING','US','PA','MIDDLETOWN',NULL,'17057','3327 E Harrisburg Pik',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100075,0,193000000000000075,'OPERATING','US','CA','HOLLISTER',NULL,'95023','600 Ernie Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100076,0,193000000000000076,'OPERATING','US','TX','Humble',NULL,'77338','8120 Humble Westfield Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100077,0,193000000000000077,'OPERATING','US','TX','Houston',NULL,'77038-2324','10550 Ella Blvd.',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100078,0,193000000000000078,'OPERATING','US','TX','Brookshire',NULL,'77423','31555 Highway 90 E',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100079,0,193000000000000079,'OPERATING','US','TX','FRESNO',NULL,'77545','2303 Hurricane Lane',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100080,0,193000000000000080,'OPERATING','US','AL','Madison',NULL,'35756','7817 Greenbrier Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100081,0,193000000000000081,'OPERATING','US','AL','MADISON',NULL,'35756','28869 Fanning Dr NW',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100082,0,193000000000000082,'OPERATING','US','TX','Houston',NULL,'77032','15525 Milner Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100083,0,193000000000000083,'OPERATING','US','AZ','GOODYEAR',NULL,'85395-4515','4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100084,0,193000000000000084,'OPERATING','US','KS','PARK CITY',NULL,'67219','7130 N BROADWAY AVE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100085,0,193000000000000085,'OPERATING','US','GA','MACON',NULL,'31216-6427','7001 Skipper Rd 31216-6427 MACON, GA - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100086,0,193000000000000086,'OPERATING','US','IL','University park',NULL,'60484','23257 S. Central Avenue',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100087,0,193000000000000087,'OPERATING','US','DE','NEW CASTLE',NULL,'19720','780 S. DuPont Highway',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100088,0,193000000000000088,'OPERATING','US','MO','LIBERTY',NULL,'64068-8933','2361 S Withers RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100089,0,193000000000000089,'OPERATING','US','MS','OLIVE BRANCH',NULL,'38654','IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100090,0,193000000000000090,'OPERATING','US','NC','FAYETTEVILLE',NULL,'28303-2215','6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100091,0,193000000000000091,'OPERATING','US','IN','Plainfield',NULL,'46168-7442','715 Airtech Pkwy',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100092,0,193000000000000092,'OPERATING','US','IN','Indianapolis',NULL,'46231','710 S. Girls School Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100093,0,193000000000000093,'OPERATING','US','IN','Plainfield',NULL,'46168','800 Perry Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100094,0,193000000000000094,'OPERATING','US','IN','Jeffersonville',NULL,'47130','1453 E 10th St',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100095,0,193000000000000095,'OPERATING','US','IN','GREENWOOD',NULL,'46143-7830','1151 S GRAHAM RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100096,0,193000000000000096,'OPERATING','US','TX','AMARILLO',NULL,'79108-7749','ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100097,0,193000000000000097,'OPERATING','US','TX','DALLAS',NULL,'75241','3351 Balmorhea Dr',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100098,0,193000000000000098,'OPERATING','US','CA','BEAUMONT',NULL,'92223','36900 West 4th Street',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100099,0,193000000000000099,'OPERATING','US','CA','HESPERIA,',NULL,'92344','8140 Caliente Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100100,0,193000000000000100,'OPERATING','US','CA','REDLANDS',NULL,'92374','2125 W San Bernardino Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100101,0,193000000000000101,'OPERATING','US','SC','COWPENS',NULL,'29330','1120 Mt Olive Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100102,0,193000000000000102,'OPERATING','US','PA','GREENCASTLE',NULL,'17225','3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100103,0,193000000000000103,'OPERATING','US','CA','Hesperia',NULL,'92344','8130 Caliente Rd 92344 - Hesperia, CA - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100104,0,193000000000000104,'OPERATING','US','AZ','GOODYEAR',NULL,'85338','16155 W Elwood St',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100105,0,193000000000000105,'OPERATING','US','CA','Fontana',NULL,'92335','9253 Dreamland Drive 92335 - Fontana, CA - United',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100106,0,193000000000000106,'OPERATING','US','CA','Moreno Valley',NULL,'92551','24405 Krameria Ave 92551 - Moreno Valley, CA - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100107,0,193000000000000107,'OPERATING','US','WA','PASCO',NULL,'99301-6161','1202 S Road 40 E 99301-6161 - PASCO, WA - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100108,0,193000000000000108,'OPERATING','US','CA','SAN BERNARDINO',NULL,'92408','1494 S WATERMAN AVE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100109,0,193000000000000109,'OPERATING','US','WI','BELOIT',NULL,'53511','1255 Gateway Blvd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100110,0,193000000000000110,'OPERATING','US','CA','PERRIS',NULL,'92571-9722','4501 PATTERSON AVE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100111,0,193000000000000111,'OPERATING','US','KY','Shepherdsville',NULL,'40165','120 Velocity Way.,',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100112,0,193000000000000112,'OPERATING','US','MI','LANSING',NULL,'48917','6500 W Mt Hope Hwy',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100113,0,193000000000000113,'OPERATING','US','NV','HENDERSON',NULL,'89044','12300 Bermuda Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100114,0,193000000000000114,'OPERATING','US','NV','Las Vegas',NULL,'89030','3837 Bay Lake Trail, Suite 111 North',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100115,0,193000000000000115,'OPERATING','US','NV','Las Vegas',NULL,'89115','4550 Nexus Way',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100116,0,193000000000000116,'OPERATING','US','CA','CHINO',NULL,'91708','6730 Kimball Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100117,0,193000000000000117,'OPERATING','US','CA','FONTANA',NULL,'92337','11263 Oleander Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100118,0,193000000000000118,'OPERATING','US','PA','Greensburg',NULL,'15605','165 Glen Fox Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100119,0,193000000000000119,'OPERATING','US','KY','Lexington',NULL,'40511','1850 Mercer RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100120,0,193000000000000120,'OPERATING','US','KY','Lexington',NULL,'40511','172 Trade St.',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100121,0,193000000000000121,'OPERATING','US','LA','CARENCRO',NULL,'70520-5976','3550 NE EVANGELINE TRWY',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100122,0,193000000000000122,'OPERATING','US','CA','Eastvale',NULL,'91752','4950 Goodman Way',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100123,0,193000000000000123,'OPERATING','US','CA','Redlands',NULL,'92374','27517 Pioneer Avenue',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100124,0,193000000000000124,'OPERATING','US','CA','Riverside',NULL,'92518-1513','20901 Krameria Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100125,0,193000000000000125,'OPERATING','US','CA','Rialto',NULL,'92376','1568 N Linden Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100126,0,193000000000000126,'OPERATING','US','AR','NORTH LITTLE ROCK',NULL,'72117','13001 HIGHWAY 70',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100127,0,193000000000000127,'OPERATING','US','CA','RANCHO CORDOVA',NULL,'95742','3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100128,0,193000000000000128,'OPERATING','US','CA','TURLOCK',NULL,'95380','FULKERTH RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100129,0,193000000000000129,'OPERATING','US','KS','Lenexa',NULL,'66219','113th St & Renner Blvd.',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100130,0,193000000000000130,'OPERATING','US','MO','KANSAS CITY',NULL,'64163','MCI4 - 10501 NW 136th Street',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100131,0,193000000000000131,'OPERATING','US','FL','Deltona',NULL,'32725-2610','2600 N Normandy Blvd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100132,0,193000000000000132,'OPERATING','US','PA','Carlisle',NULL,'17015','2Ames Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100133,0,193000000000000133,'OPERATING','US','PA','York',NULL,'17408','5125 Commerce Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100134,0,193000000000000134,'OPERATING','US','IL','Joliet',NULL,'60433-3280','250 Emerald Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100135,0,193000000000000135,'OPERATING','US','IL','ROMEOVILLE',NULL,'60446-6529','1125 W REMINGTON BLVD ROMEOVILLE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100136,0,193000000000000136,'OPERATING','US','IL','Monee',NULL,'60449','6605 Monee Manhattan Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100137,0,193000000000000137,'OPERATING','US','IL','Waukegan',NULL,'60085','1750 Bridge Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100138,0,193000000000000138,'OPERATING','US','IL','Aurora',NULL,'60502','2865 Duke Parkway',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100139,0,193000000000000139,'OPERATING','US','TN','Memphis',NULL,'38118','3292 E Holmes Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100140,0,193000000000000140,'OPERATING','US','MS','OLIVE BRANCH',NULL,'38654','11505 Progress Way',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100141,0,193000000000000141,'OPERATING','US','GA','Jefferson',NULL,'30549-2010','808 Hog Mountain Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100142,0,193000000000000142,'OPERATING','US','CA','SHAFTER',NULL,'93263','5408 Express Avenue',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100143,0,193000000000000143,'OPERATING','US','KS','Edgerton',NULL,'66021-9588','19645 Waverly Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100144,0,193000000000000144,'OPERATING','US','KS','Kansas City',NULL,'66102','6925 Riverview Avenue',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100145,0,193000000000000145,'OPERATING','US','WI','Kenosha',NULL,'53144','3501 120th Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100146,0,193000000000000146,'OPERATING','US','IN','GREENFIELD',NULL,'46140','4412 W 300 RD N',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100147,0,193000000000000147,'OPERATING','US','MN','Shakopee',NULL,'55379','2601 4th Avenue East',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100148,0,193000000000000148,'OPERATING','US','NJ','Pedricktown',NULL,'08067','3 Sorbello Way',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100149,0,193000000000000149,'OPERATING','US','CA','Patterson',NULL,'95363-8876','255 Park Center Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100150,0,193000000000000150,'OPERATING','US','CA','Tracy',NULL,'95304-9370','1555 N. Chrisman Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100151,0,193000000000000151,'OPERATING','US','OK','OKLAHOMA CITY',NULL,'73159','8991 S PORTLAND AVENUE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100152,0,193000000000000152,'OPERATING','US','WA','OLYMPIA',NULL,'98516','3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100153,0,193000000000000153,'OPERATING','US','CA','San Bernardino',NULL,'92408','1910 E Central Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100154,0,193000000000000154,'OPERATING','US','CA','Moreno Valley',NULL,'92551-9561','24208 San Michele Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100155,0,193000000000000155,'OPERATING','US','CA','Moreno Valley',NULL,'92551-9534','24300 Nandina Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100156,0,193000000000000156,'OPERATING','US','CA','Redlands',NULL,'92374','2125 W. San Bernandino Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100157,0,193000000000000157,'OPERATING','US','IL','CHANNAHON',NULL,'60410-8758','23714 W Amoco Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100158,0,193000000000000158,'OPERATING','US','IL','Wood Dale',NULL,'60191','1250 N Mittel Blvd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100159,0,193000000000000159,'OPERATING','US','VA','CHESAPEAKE',NULL,'23321-1425','ORF2 - 5045 PORTSMOUTH BLVD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100160,0,193000000000000160,'OPERATING','US','FL','Jacksonville',NULL,'32206','21 Jump Street',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100161,0,193000000000000161,'OPERATING','US','OR','Salem',NULL,'97317','4775 Depot Ct SE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100162,0,193000000000000162,'OPERATING','US','DE','New Castle',NULL,'19720','1 Centerpoint Blvd.',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100163,0,193000000000000163,'OPERATING','US','DE','New Castle',NULL,'19720','1600 Johnson Way',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100164,0,193000000000000164,'OPERATING','US','PA','Carlisle',NULL,'17015','21 Roadway Dr',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100165,0,193000000000000165,'OPERATING','US','PA','Lewisberry',NULL,'17339','500 McCarthy Dr',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100166,0,193000000000000166,'OPERATING','US','PA','Greencastle',NULL,'17225','1915 Ebberts Spg Ct, Greencastle, PA 17225',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100167,0,193000000000000167,'OPERATING','US','PA','Carlisle',NULL,'17015','675 Allen Rd.',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100168,0,193000000000000168,'OPERATING','US','DE','Middletown',NULL,'19709','560 Merrimac Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100169,0,193000000000000169,'OPERATING','US','DE','Middletown',NULL,'19709','727 N Broad St',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100170,0,193000000000000170,'OPERATING','US','AZ','Phoenix',NULL,'85043','6835 West Buckeye Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100171,0,193000000000000171,'OPERATING','US','AZ','Goodyear',NULL,'85338','16920 W Commerce Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100172,0,193000000000000172,'OPERATING','US','AZ','Phoenix',NULL,'85043','4750 West Mohave St',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100173,0,193000000000000173,'OPERATING','US','AZ','Phoenix',NULL,'85043','800 N 75th Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100174,0,193000000000000174,'OPERATING','US','PA','Pittsburgh',NULL,'15205','2250 Roswell Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100175,0,193000000000000175,'OPERATING','US','PA','IMPERIAL',NULL,'15126-1002','1200 Westport Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100176,0,193000000000000176,'OPERATING','US','CA','FONTANA',NULL,'92336-1123','4121 LYTLE CREEK RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100177,0,193000000000000177,'OPERATING','US','CA','ONTARIO',NULL,'91761','4000 South Hamner Avenue',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100178,0,193000000000000178,'OPERATING','US','CA','JURUPA VALLEY',NULL,'92509','6120 Clinker Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100179,0,193000000000000179,'OPERATING','US','IN','MERRILLVILLE',NULL,'46410','9850 Mississippi Street',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100180,0,193000000000000180,'OPERATING','US','WA','PASCO',NULL,'99301','1351 S Road 40 E',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100181,0,193000000000000181,'OPERATING','US','CA','DESERT HOT SPRINGS',NULL,'92240','64165 19th AVE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100182,0,193000000000000182,'OPERATING','US','CA','STOCKTON',NULL,'95215','4532 NEWCASTLE RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100183,0,193000000000000183,'OPERATING','US','CA','FRESNO',NULL,'93725-1962','3611 S NORTHPOINTE DR',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100184,0,193000000000000184,'OPERATING','US','AZ','MESA',NULL,'85212-9608','8560 E ELLIOT RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100185,0,193000000000000185,'OPERATING','US','NC','Smithfield',NULL,'27577','2150 US HWY 70 Business West',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100186,0,193000000000000186,'OPERATING','US','NC','FAYETTEVILLE',NULL,'28303','6309 Bragg Blvd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100187,0,193000000000000187,'OPERATING','US','IL','Huntley',NULL,'60142','11500 Freeman Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100188,0,193000000000000188,'OPERATING','US','IL','HUNTLEY',NULL,'60142','11400 Venture Court 60142-8075 - HUNTLEY, IL - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100189,0,193000000000000189,'OPERATING','US','VA','Petersburg',NULL,'23803','5000 Commerce Way',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100190,0,193000000000000190,'OPERATING','US','VA','Chester',NULL,'23836','1901 Meadowville Technology Pkwy',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100191,0,193000000000000191,'OPERATING','US','VA','Fredericksburg',NULL,'22406','220 Centreport Parkway',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100192,0,193000000000000192,'OPERATING','US','NV','Fernley',NULL,'89408','1600 East Newlands RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100193,0,193000000000000193,'OPERATING','US','NV','Reno',NULL,'85906','8000 North Virginia Street',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100194,0,193000000000000194,'OPERATING','US','NV','Reno',NULL,'89506','8000 North Virginia Street',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100195,0,193000000000000195,'OPERATING','US','GA','White',NULL,'30184','1250 Cassville White Road,',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100196,0,193000000000000196,'OPERATING','US','TX','Schertz',NULL,'78154-1461','6000 Enterprise Avenue',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100197,0,193000000000000197,'OPERATING','US','TX','SAN ANTONIO',NULL,'78245','10384 W US HIGHWAY 90',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100198,0,193000000000000198,'OPERATING','US','TX','SAN MARCOS',NULL,'78666','1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100199,0,193000000000000199,'OPERATING','US','GA','Macon',NULL,'31216-6427','7001 Skipper Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100200,0,193000000000000200,'OPERATING','US','CA','BLOOMINGTON',NULL,'92316-3819','3388 S Cactus Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100201,0,193000000000000201,'OPERATING','US','CA','SAN BERNARDINO',NULL,'92408-2805','1494 S WATERMAN AVE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100202,0,193000000000000202,'OPERATING','US','CA','STOCKTON',NULL,'95215','4532 NEWCASTLE RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100203,0,193000000000000203,'OPERATING','US','CA','MANTECA',NULL,'95336','3565 N AIRPORT WAY MANTECA',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100204,0,193000000000000204,'OPERATING','US','CA','STOCKTON',NULL,'95215-8354','6001 S AUSTIN RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100205,0,193000000000000205,'OPERATING','US','CA','OAKLEY',NULL,'94561-2677','4700 WILBUR AVE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100206,0,193000000000000206,'OPERATING','US','KY','Campbellsville',NULL,'42718','1050 South Columbia AVE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100207,0,193000000000000207,'OPERATING','US','KY','Watterson Park',NULL,'40218','4360 Robards Ln',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100208,0,193000000000000208,'OPERATING','US','KY','Shepherdsville',NULL,'40165','376 Zappos.com Blvd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100209,0,193000000000000209,'OPERATING','US','KY','Shepherdsville',NULL,'40165','271 Omega Pkwy',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100210,0,193000000000000210,'OPERATING','US','KY','Shepherdsville',NULL,'40165','300 Omicron Court',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100211,0,193000000000000211,'OPERATING','US','IN','Jeffersonville',NULL,'47130','900 Patrol Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100212,0,193000000000000212,'OPERATING','US','KY','Shepherdsville',NULL,'40165','100 W. Thomas P. Echols Lane',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100213,0,193000000000000213,'OPERATING','US','WA','South Seattle',NULL,'98144','2646 Rainier Ave.',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100214,0,193000000000000214,'OPERATING','US','WA','Northeast Bellevue',NULL,'98005','1475 124th Avenue',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100215,0,193000000000000215,'OPERATING','US','CA','Tracy',NULL,'95377-8906','188 Mountain House Parkway',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100216,0,193000000000000216,'OPERATING','US','UT','West Jordan',NULL,'84081','7148 W. Old Bingham Hwy',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100217,0,193000000000000217,'OPERATING','US','UT','SALT LAKE CITY',NULL,'84116-4413','355 N JOHN GLENN RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100218,0,193000000000000218,'OPERATING','US','CA','Stockton',NULL,'95206-8202','3923 S B ST',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100219,0,193000000000000219,'OPERATING','US','CA','SACRAMENTO',NULL,'95837-9109','4930 ALLBAUGH DR',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100220,0,193000000000000220,'OPERATING','US','CA','Rialto',NULL,'92376-3009','2496 W Walnut Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100221,0,193000000000000221,'OPERATING','US','MO','REPUBLIC',NULL,'65738-2779','3200 E SAWYER RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100222,0,193000000000000222,'OPERATING','US','IL','Edwardsville',NULL,'62025-2815','3050 GATEWAY COMMERCE CENTER DR S',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100223,0,193000000000000223,'OPERATING','US','NY','ROCK TAVERN',NULL,'12575','635 INTERNATIONAL BLVD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100224,0,193000000000000224,'OPERATING','US','NY','Hopewell Junction',NULL,'12533','76 Patriot Way Hopewell Junction',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100225,0,193000000000000225,'OPERATING','US','CA','STOCKTON',NULL,'95206','2690 East Arch Airport Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100226,0,193000000000000226,'OPERATING','US','CA','STOCKTON',NULL,'95215','6201 Newcastle Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100227,0,193000000000000227,'OPERATING','US','NJ','Logan Township',NULL,'08085','2651 Oldmans Creek Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100228,0,193000000000000228,'OPERATING','US','NJ','PENNS GROVE',NULL,'08069','747 COURSES LANDING RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100229,0,193000000000000229,'OPERATING','US','NJ','Cranbury',NULL,'08512','22 Hightstown Cranbury State Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100230,0,193000000000000230,'OPERATING','US','NJ','Somerset',NULL,'08873','601 Randolph Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100231,0,193000000000000231,'OPERATING','US','FL','HOMESTEAD',NULL,'33032-8597','27505 SW 132nd AVE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100232,0,193000000000000232,'OPERATING','US','OH','PERRYSBURG',NULL,'43551','9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100233,0,193000000000000233,'OPERATING','US','FL','Ruskin',NULL,'33570','3350 Laurel Ridge Ave.',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100234,0,193000000000000234,'OPERATING','US','FL','Lakeland',NULL,'33811','1760 County Line Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100235,0,193000000000000235,'OPERATING','US','FL','Auburndale',NULL,'33823','676 C. Fred Jones Boulevard',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100236,0,193000000000000236,'OPERATING','US','KS','Coffeyville',NULL,'67337','2654 North Highway 169',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100237,0,193000000000000237,'OPERATING','US','NV','LAS VEGAS',NULL,'89115-2013','6401 Howdy Wells Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100238,0,193000000000000238,'OPERATING','US','PA','Scranton',NULL,'18512','1000 Keystone Industrial Park',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100239,0,193000000000000239,'OPERATING','US','PA','Pittston',NULL,'18643','508 Delaware Avenue West',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100240,0,193000000000000240,'OPERATING','US','PA','OLYPHANT',NULL,'18447','1300 Corporate Way',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100241,0,193000000000000241,'OPERATING','US','GA','PORT WENTWORTH',NULL,'31407','XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100242,0,193000000000000242,'OPERATING','US','NJ','Cinnaminson',NULL,'08077-2015','995 Taylors Ln 08077-2015',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100243,0,193000000000000243,'OPERATING','US','IN','Avon',NULL,'46123-1700','8838 E COUNTY ROAD 100 S',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100244,0,193000000000000244,'OPERATING','US','MD','HAGERSTOWN',NULL,'21740','1115 Wesel Blvd HAGERSTOWN,MD 21740,USA',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100245,0,193000000000000245,'OPERATING','US','NC','PINEVILLE',NULL,'28134','12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100246,0,193000000000000246,'OPERATING','US','CA','VICTORVILLE',NULL,'92394','18580 Gateway Dr,Victorville,CA 92394,USA',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100247,0,193000000000000247,'OPERATING','US','FL','APOPKA',NULL,'32712','XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100248,0,193000000000000248,'OPERATING','US','VA','Richmond',NULL,'23234','4949 Commerce Rd, Richmond, VA 23234',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100249,0,193000000000000249,'OPERATING','US','TX','Fort Worth',NULL,'76155','14900 Frye Rd.',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100250,0,193000000000000250,'OPERATING','US','PA','Carlisle',NULL,'17013','40 Logistics Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100251,0,193000000000000251,'OPERATING','US','CA','Stockton',NULL,'95206','1909 Zephyr St',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100252,0,193000000000000252,'OPERATING','US','IN','Whitestown',NULL,'46075','5100 S Indianapolis Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100253,0,193000000000000253,'OPERATING','US','NJ','Florence',NULL,'08518-2802','309 Cedar Lane',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100254,0,193000000000000254,'OPERATING','US','NJ','Florence',NULL,'08518','401 Independence Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100255,0,193000000000000255,'OPERATING','US','AZ','GOODYEAR',NULL,'85395','16601 W. SELLS DRIVE',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100256,0,193000000000000256,'OPERATING','US','GA','Atlanta',NULL,'30349','ATL1, 6055 S Fulton Pkwy, Atlanta, GA, 30349',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100257,0,193000000000000257,'OPERATING','US','GA','Atlanta',NULL,'30349','6055 S Fulton Pkwy,',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100258,0,193000000000000258,'OPERATING','US','','',NULL,'','',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100259,0,193000000000000259,'OPERATING','US','','',NULL,'','',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100260,0,193000000000000260,'OPERATING','US','OH','Etna',NULL,'43062-7793','11999 National Road SW',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100261,0,193000000000000261,'OPERATING','US','','',NULL,'92337','10767',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100262,0,193000000000000262,'OPERATING','US','','',NULL,'18031','650 BOULDER DR Suite A 18031-1536 - BREINIGSVILLE, PA - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100263,0,193000000000000263,'OPERATING','US','','',NULL,'','',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100264,0,193000000000000264,'OPERATING','US','','',NULL,'','',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100265,0,193000000000000265,'OPERATING','US','MI','LANSING',NULL,'48917-8539','6500 W Mt Hope Hwy 48917-8539 - LANSING, MI - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100266,0,193000000000000266,'OPERATING','US','IN','Whitestown',NULL,'46075','4255 Anson Blvd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100267,0,193000000000000267,'OPERATING','US','IN','Avon',NULL,'46123','9590 AllPoints Pkwy',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100268,0,193000000000000268,'OPERATING','US','IN','Plainfield',NULL,'46168','715 Airtech Pkwy',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100269,0,193000000000000269,'OPERATING','US','IN','Avon',NULL,'46123','9360 AllPoints Pkwy',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100270,0,193000000000000270,'OPERATING','US','IN','Mccordsville',NULL,'46055','5756 Enterprise Dr',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100271,0,193000000000000271,'OPERATING','US','IN','Pkwy Plainfield',NULL,'46168','717 Airtech Pkwy Plainfield IN 46168,USA',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100272,0,193000000000000272,'OPERATING','US','IN','Mccordsville',NULL,'46055','5756 Enterprise Dr, Mccordsville, IN 46055',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100273,0,193000000000000273,'OPERATING','US','PA','OLYPHANT',NULL,'18447-2140','1300 Corporate Way',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100274,0,193000000000000274,'OPERATING','US','CA','HESPERIA',NULL,'92344','8140 Caliente Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100275,0,193000000000000275,'OPERATING','US','','',NULL,'32210','JAX3-32210',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100276,0,193000000000000276,'OPERATING','US','FL','Jacksonville',NULL,'32210-8686','13333 103rd Street',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100277,0,193000000000000277,'OPERATING','US','FL','Jacksonville',NULL,'32221','13333 103rd Street',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100278,0,193000000000000278,'OPERATING','US','MI','LANSING',NULL,'48917','6500 W Mt Hope Hwy',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100279,0,193000000000000279,'OPERATING','US','CA','Chino',NULL,'91708','6750 Kimball Avenue, Chino,CA 91708,USA',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100280,0,193000000000000280,'OPERATING','US','CA','Chino',NULL,'91708','6750 Kimball Avenue',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100281,0,193000000000000281,'OPERATING','US','CA','Chino',NULL,'91708','6720 Kimball Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100282,0,193000000000000282,'OPERATING','US','PA','New Stanton',NULL,'15672','165 GLENN FOX RD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100283,0,193000000000000283,'OPERATING','US','FL','Davenport',NULL,'33897','5100 NORTH RIDGE TRAIL',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100284,0,193000000000000284,'OPERATING','US','IL','Joliet',NULL,'60433-3280','250 Emerald Drive',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100285,0,193000000000000285,'OPERATING','US','MS','OLIVE BRANCH',NULL,'38654','9200 Alexander Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100286,0,193000000000000286,'OPERATING','US','FL','Opa-Locka',NULL,'33054','14000 NW 37th Avenue',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100287,0,193000000000000287,'OPERATING','US','FL','Miami',NULL,'33182','1900 NW 132nd Place Doral',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100288,0,193000000000000288,'OPERATING','US','TN','LEBANON',NULL,'37090','MQY2 - 1301 Richard Petty Way 37090 - LEBANON, TN - United States',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100289,0,193000000000000289,'OPERATING','US','NJ','Pedricktown',NULL,'08067','3 Sorbello Way',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100290,0,193000000000000290,'OPERATING','US','NJ','Pedricktown',NULL,'08067','7 Sorbello Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100291,0,193000000000000291,'OPERATING','US','IL','Joliet',NULL,'60436','3501 Brandon Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100292,0,193000000000000292,'OPERATING','US','VA','CHESAPEAKE',NULL,'23321-1425','ORF2 - 5045 PORTSMOUTH BLVD',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100293,0,193000000000000293,'OPERATING','US','VA','Richmond',NULL,'23297','9603 Coach Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100294,0,193000000000000294,'OPERATING','US','PA','Greencastle',NULL,'17225','1915 Ebberts Spg Ct',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100295,0,193000000000000295,'OPERATING','US','OR','Portland',NULL,'97242','9930 NE Alderwood Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100296,0,193000000000000296,'OPERATING','US','UT','Salt Lake City',NULL,'84116','990 N 6550 W',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100297,0,193000000000000297,'OPERATING','US','CA','Sacramento',NULL,'95835-9505','4900 W Elkhorn Blvd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100298,0,193000000000000298,'OPERATING','US','CA','Sacramento',NULL,'95837','7000 Powerline Rd',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100299,0,193000000000000299,'OPERATING','US','','',NULL,'','',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100300,0,193000000000000300,'OPERATING','US','','',NULL,'','',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100301,0,193000000000000301,'OPERATING','US','NY','Yonkers',NULL,'10710','557 Tuckahoe Road',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100302,0,193000000000000302,'OPERATING','US','NY','Hopewell Junction',NULL,'12533-6159','76 Patriot Way',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100303,0,193000000000000303,'OPERATING','US','FL','Miami Gardens',NULL,'33055','8675309 Jenny Lane',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100304,0,193000000000000304,'OPERATING','US','','',NULL,'92337','10767',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100305,0,193000000000000305,'OPERATING','US','NV','LAS VEGAS',NULL,'89115-201','6401 Howdy Wells Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100306,0,193000000000000306,'OPERATING','US','CA','March Air Reserve Base',NULL,'92518','20701 Krameria Ave',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100307,0,193000000000000307,'OPERATING','US','','',NULL,'','',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100308,0,193000000000000308,'OPERATING','US','','',NULL,'','',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100309,0,193000000000000309,'OPERATING','US','','',NULL,'','',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100310,0,193000000000000310,'OPERATING','US','CA','RANCHO CUCAMONGA',NULL,'91730','10767 7TH STREET',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100311,0,193000000000000311,'OPERATING','US','','',NULL,'91730','91730',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100312,0,193000000000000312,'OPERATING','US','','',NULL,'私人仓','私人仓',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100313,0,193000000000000313,'OPERATING','US','NJ','New Jersey',NULL,'08817','13、14号门75 Carter Dr Edison,',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000100314,0,193000000000000314,'OPERATING','US','NJ','New Jersey',NULL,'08817','Door13-14,75 Carter Dr Edison,',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000300001,0,193000000000200001,'OPERATING','CN','浙江省','义乌市',NULL,'322000','义乌仓通用地址待维护',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000300002,0,193000000000200002,'OPERATING','CN','广东省','东莞市',NULL,'523000','东莞仓通用地址待维护',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000300003,0,193000000000200003,'OPERATING','US','California','Rancho Cucamonga',NULL,'91730','10767 7th Street',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); +INSERT INTO wh_warehouse_address (id, tenant_id, warehouse_id, address_type, country_code, state_province, city, district, postal_code, address_line1, address_line2, latitude, longitude, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (193000000000300004,0,193000000000200004,'OPERATING','US','New Jersey','Edison',NULL,'08817','Door 13-14, 75 Carter Dr',NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); + +USE baiyue_firstleg; + +-- 系统基线 fl_fee_item:112 行。 +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000001,0,'洛杉矶仓储成本','INTERNATIONAL','FEE_TYPE_001','拖车费2',NULL,0,0,1,0,0,NULL,'ACTIVE',NULL,'admin','2023-07-10 00:00:00.000',NULL,'admin','2023-07-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000002,0,'洛杉矶仓储成本','INTERNATIONAL','FEE_TYPE_002','仓库水电费',NULL,0,0,1,0,0,NULL,'ACTIVE',NULL,'admin','2023-07-08 00:00:00.000',NULL,'admin','2023-07-08 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000003,0,'洛杉矶仓储成本','INTERNATIONAL','FEE_TYPE_003','人工费',NULL,0,0,1,0,0,'人工工资费用','ACTIVE',NULL,'admin','2023-07-08 00:00:00.000',NULL,'admin','2023-07-08 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000004,0,'洛杉矶仓储成本','INTERNATIONAL','FEE_TYPE_004','仓库租赁费',NULL,0,0,1,0,0,'应付 仓库租赁费','ACTIVE',NULL,'admin','2023-07-08 00:00:00.000',NULL,'admin','2023-07-08 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000005,0,'默认','DOMESTIC','FEE_TYPE_005','删单费',NULL,1,1,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2026-05-29 00:00:00.000',15268360352,'15268360352','2026-05-29 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000006,0,'默认','INTERNATIONAL','FEE_TYPE_006','换FBA标费用',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'zhaoting','2026-03-04 00:00:00.000',NULL,'zhaoting','2026-03-04 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000007,0,'默认','INTERNATIONAL','FEE_TYPE_007','换SKU费用',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'zhaoting','2026-03-04 00:00:00.000',NULL,'zhaoting','2026-03-04 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000008,0,'默认','INTERNATIONAL','FEE_TYPE_008','开箱费',NULL,0,1,1,1,1,NULL,'ACTIVE',NULL,'zhaoting','2026-03-04 00:00:00.000',NULL,'zhaoting','2026-03-04 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000009,0,'默认','DOMESTIC','FEE_TYPE_009','天翔八提',NULL,1,1,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-08-19 00:00:00.000',15268360352,'15268360352','2025-08-19 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000010,0,'默认','DOMESTIC','FEE_TYPE_010','天翔五提',NULL,1,1,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-08-05 00:00:00.000',15268360352,'15268360352','2025-08-05 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000011,0,'默认','DOMESTIC','FEE_TYPE_011','荣盛三提',NULL,1,1,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-08-05 00:00:00.000',15268360352,'15268360352','2025-08-05 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000012,0,'默认','DOMESTIC','FEE_TYPE_012','两地费',NULL,1,1,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-08-04 00:00:00.000',15268360352,'15268360352','2025-08-04 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000013,0,'默认','DOMESTIC','FEE_TYPE_013','绕路费',NULL,1,1,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-08-04 00:00:00.000',15268360352,'15268360352','2025-08-04 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000014,0,'默认','DOMESTIC','FEE_TYPE_014','朝华提',NULL,1,1,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-07-31 00:00:00.000',15268360352,'15268360352','2025-07-31 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000015,0,'默认','DOMESTIC','FEE_TYPE_015','长胜提',NULL,1,1,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-07-31 00:00:00.000',15268360352,'15268360352','2025-07-31 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000016,0,'默认','DOMESTIC','FEE_TYPE_016','港杂费',NULL,1,1,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-07-24 00:00:00.000',15268360352,'15268360352','2025-07-24 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000017,0,'默认','DOMESTIC','FEE_TYPE_017','极偏远费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'zhaoting','2025-06-11 00:00:00.000',NULL,'zhaoting','2025-06-11 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000018,0,'默认','DOMESTIC','FEE_TYPE_018','退舱费',NULL,1,0,0,0,0,NULL,'ACTIVE',15690305912,'15690305912','2025-05-22 00:00:00.000',15690305912,'15690305912','2025-05-22 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000019,0,'默认','DOMESTIC','FEE_TYPE_019','推送费',NULL,1,1,0,0,0,NULL,'ACTIVE',15690305912,'15690305912','2025-04-07 00:00:00.000',15690305912,'15690305912','2025-04-07 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000020,0,'默认','DOMESTIC','FEE_TYPE_020','产品附加费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'zhaoting','2025-04-02 00:00:00.000',NULL,'zhaoting','2025-04-02 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000021,0,'默认','DOMESTIC','FEE_TYPE_021','联单费',NULL,1,1,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-04-01 00:00:00.000',15268360352,'15268360352','2025-04-01 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000022,0,'默认','DOMESTIC','FEE_TYPE_022','竹木制品小额商检',NULL,1,1,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-04-01 00:00:00.000',15268360352,'15268360352','2025-04-01 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000023,0,'默认','DOMESTIC','FEE_TYPE_023','放单费',NULL,1,0,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-03-27 00:00:00.000',15268360352,'15268360352','2025-03-27 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000024,0,'默认','DOMESTIC','FEE_TYPE_024','退关费',NULL,1,1,1,1,1,NULL,'ACTIVE',15690305912,'15690305912','2025-03-17 00:00:00.000',15690305912,'15690305912','2025-03-17 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000025,0,'默认','DOMESTIC','FEE_TYPE_025','补收税金',NULL,0,1,0,0,0,NULL,'ACTIVE',NULL,'lijiayin','2025-03-07 00:00:00.000',NULL,'lijiayin','2025-03-07 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000026,0,'默认','INTERNATIONAL','FEE_TYPE_026','HC',NULL,1,1,1,1,1,NULL,'ACTIVE',15268360352,'15268360352','2025-02-27 00:00:00.000',15268360352,'15268360352','2025-02-27 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000027,0,'默认','DOMESTIC','FEE_TYPE_027','舱单数据传输费',NULL,1,1,1,1,1,NULL,'ACTIVE',15268360352,'15268360352','2025-02-27 00:00:00.000',15268360352,'15268360352','2025-02-27 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000028,0,'默认','DOMESTIC','FEE_TYPE_028','转船费',NULL,1,1,1,1,1,NULL,'ACTIVE',15268360352,'15268360352','2025-02-27 00:00:00.000',15268360352,'15268360352','2025-02-27 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000029,0,'默认','DOMESTIC','FEE_TYPE_029','改配费',NULL,1,1,1,1,1,NULL,'ACTIVE',15268360352,'15268360352','2025-02-27 00:00:00.000',15268360352,'15268360352','2025-02-27 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000030,0,'默认','INTERNATIONAL','FEE_TYPE_030','巴拿马附加费',NULL,1,1,1,1,1,NULL,'ACTIVE',15268360352,'15268360352','2025-02-21 00:00:00.000',15268360352,'15268360352','2025-02-21 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000031,0,'默认','DOMESTIC','FEE_TYPE_031','报关退点',NULL,1,1,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-02-12 00:00:00.000',15268360352,'15268360352','2025-02-12 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000032,0,'默认','INTERNATIONAL','FEE_TYPE_032','滞箱费',NULL,1,1,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-02-11 00:00:00.000',15268360352,'15268360352','2025-02-11 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000033,0,'默认','DOMESTIC','FEE_TYPE_033','大榭提',NULL,1,0,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-02-10 00:00:00.000',15268360352,'15268360352','2025-02-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000034,0,'默认','DOMESTIC','FEE_TYPE_034','进四期',NULL,1,0,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2025-02-10 00:00:00.000',15268360352,'15268360352','2025-02-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000035,0,'默认','DOMESTIC','FEE_TYPE_035','入仓费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'zhaoting','2025-01-22 00:00:00.000',NULL,'zhaoting','2025-01-22 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000036,0,'默认','DOMESTIC','FEE_TYPE_036','晚截关',NULL,1,0,0,0,0,NULL,'ACTIVE',15268360352,'15268360352','2025-01-16 00:00:00.000',15268360352,'15268360352','2025-01-16 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000037,0,'默认','DOMESTIC','FEE_TYPE_037','装柜费',NULL,0,1,0,0,1,NULL,'ACTIVE',15268360352,'15268360352','2024-12-14 00:00:00.000',15268360352,'15268360352','2024-12-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000038,0,'默认','DOMESTIC','FEE_TYPE_038','亏舱费',NULL,0,1,0,0,1,NULL,'ACTIVE',NULL,'zhaoting','2024-09-23 00:00:00.000',NULL,'zhaoting','2024-09-23 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000039,0,'默认','INTERNATIONAL','FEE_TYPE_039','暂扣费用',NULL,0,1,0,0,0,NULL,'ACTIVE',NULL,'lvhonglu','2024-08-08 00:00:00.000',NULL,'lvhonglu','2024-08-08 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000040,0,'默认','DOMESTIC','FEE_TYPE_040','改单费',NULL,1,1,0,0,1,NULL,'ACTIVE',NULL,'zhaoting','2024-07-30 00:00:00.000',NULL,'zhaoting','2024-07-30 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000041,0,'默认','DOMESTIC','FEE_TYPE_041','叉车费',NULL,1,1,0,0,0,NULL,'ACTIVE',NULL,'zhaoting','2024-07-03 00:00:00.000',NULL,'zhaoting','2024-07-03 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000042,0,'默认','INTERNATIONAL','FEE_TYPE_042','全包价',NULL,1,0,1,0,1,'提柜+派送','ACTIVE',NULL,'huangmengting','2024-06-27 00:00:00.000',NULL,'huangmengting','2024-06-27 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000043,0,'默认','INTERNATIONAL','FEE_TYPE_043','快递费',NULL,1,0,0,0,0,'快递打单费用','ACTIVE',NULL,'lvhonglu','2024-05-10 00:00:00.000',NULL,'lvhonglu','2024-05-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000044,0,'默认','DOMESTIC','FEE_TYPE_044','超偏远费',NULL,0,1,0,0,0,'海派超偏远地区收费(超偏远费+¥50/件)','ACTIVE',NULL,'lijiayin','2024-05-10 00:00:00.000',NULL,'lijiayin','2024-05-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000045,0,'默认','DOMESTIC','FEE_TYPE_045','偏远费',NULL,0,1,0,0,0,'海派偏远地区收费(偏远费+¥30/件)','ACTIVE',NULL,'lijiayin','2024-05-10 00:00:00.000',NULL,'lijiayin','2024-05-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000046,0,'默认','DOMESTIC','FEE_TYPE_046','超周长附加费',NULL,0,1,0,0,0,'(最短边+次长边)*2+最长边>260CM','ACTIVE',NULL,'lijiayin','2024-05-10 00:00:00.000',NULL,'lijiayin','2024-05-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000047,0,'默认','DOMESTIC','FEE_TYPE_047','超重费',NULL,0,1,0,0,0,'海派单箱超22KG','ACTIVE',NULL,'lijiayin','2024-05-10 00:00:00.000',NULL,'lijiayin','2024-05-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000048,0,'默认','DOMESTIC','FEE_TYPE_048','超长费',NULL,0,1,0,0,0,'箱子单边超120cm','ACTIVE',NULL,'lijiayin','2024-05-10 00:00:00.000',NULL,'lijiayin','2024-05-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000049,0,'默认','INTERNATIONAL','FEE_TYPE_049','ISF',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'lvhonglu','2023-07-21 00:00:00.000',NULL,'lvhonglu','2023-07-21 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000050,0,'默认','INTERNATIONAL','FEE_TYPE_050','国外查验费',NULL,1,1,0,0,0,NULL,'ACTIVE',NULL,'lvhonglu','2023-07-21 00:00:00.000',NULL,'lvhonglu','2023-07-21 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000051,0,'默认','INTERNATIONAL','FEE_TYPE_051','目的港关税',NULL,1,1,0,0,0,NULL,'ACTIVE',NULL,'lvhonglu','2023-07-21 00:00:00.000',NULL,'lvhonglu','2023-07-21 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000052,0,'默认','DOMESTIC','FEE_TYPE_052','停车费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-14 00:00:00.000',NULL,'yingchaoqun','2023-07-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000053,0,'默认','DOMESTIC','FEE_TYPE_053','过磅费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-14 00:00:00.000',NULL,'yingchaoqun','2023-07-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000054,0,'默认','DOMESTIC','FEE_TYPE_054','提箱费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-14 00:00:00.000',NULL,'yingchaoqun','2023-07-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000055,0,'默认','DOMESTIC','FEE_TYPE_055','进港费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-14 00:00:00.000',NULL,'yingchaoqun','2023-07-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000056,0,'默认','DOMESTIC','FEE_TYPE_056','保险费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-14 00:00:00.000',NULL,'yingchaoqun','2023-07-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000057,0,'默认','DOMESTIC','FEE_TYPE_057','舱单费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-14 00:00:00.000',NULL,'yingchaoqun','2023-07-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000058,0,'默认','DOMESTIC','FEE_TYPE_058','EDI',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-14 00:00:00.000',NULL,'yingchaoqun','2023-07-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000059,0,'默认','DOMESTIC','FEE_TYPE_059','箱单费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-14 00:00:00.000',NULL,'yingchaoqun','2023-07-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000060,0,'默认','DOMESTIC','FEE_TYPE_060','文件费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-14 00:00:00.000',NULL,'yingchaoqun','2023-07-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000061,0,'默认','DOMESTIC','FEE_TYPE_061','THC',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-14 00:00:00.000',NULL,'yingchaoqun','2023-07-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000062,0,'默认','DOMESTIC','FEE_TYPE_062','订舱费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-14 00:00:00.000',NULL,'yingchaoqun','2023-07-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000063,0,'默认','DOMESTIC','FEE_TYPE_063','包干费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-14 00:00:00.000',NULL,'yingchaoqun','2023-07-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000064,0,'默认','DOMESTIC','FEE_TYPE_064','换单费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-14 00:00:00.000',NULL,'yingchaoqun','2023-07-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000065,0,'默认','DOMESTIC','FEE_TYPE_065','整柜运费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'lvhonglu','2023-07-13 00:00:00.000',NULL,'lvhonglu','2023-07-13 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000066,0,'默认','DOMESTIC','FEE_TYPE_066','查验费',NULL,1,1,1,1,0,'国外海外查验','ACTIVE',NULL,'zhaoting','2023-02-16 00:00:00.000',NULL,'admin','2023-05-18 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000067,0,'默认','DOMESTIC','FEE_TYPE_067','FDA申报费',NULL,1,1,1,1,0,'FDA申报费','ACTIVE',NULL,'zhaoting','2023-02-16 00:00:00.000',NULL,'admin','2023-05-18 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000068,0,'默认','DOMESTIC','FEE_TYPE_068','LACEY ACT申报费',NULL,1,1,1,1,0,'LACEY ACT申报费','ACTIVE',NULL,'zhaoting','2023-02-16 00:00:00.000',NULL,'zhaoting','2023-02-16 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000069,0,'默认','DOMESTIC','FEE_TYPE_069','报关费',NULL,1,1,1,1,0,'退税报关','ACTIVE',NULL,'zhaoting','2023-02-16 00:00:00.000',NULL,'zhaoting','2023-02-16 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000070,0,'默认','DOMESTIC','FEE_TYPE_070','提货费',NULL,1,1,1,1,0,'提货费','ACTIVE',NULL,'zhaoting','2023-02-16 00:00:00.000',NULL,'admin','2023-07-06 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000071,0,'默认','INTERNATIONAL','FEE_TYPE_071','贴标费',NULL,1,0,1,1,0,'贴标费','ACTIVE',NULL,'admin','2022-11-25 00:00:00.000',NULL,'admin','2023-07-05 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000072,0,'默认','DOMESTIC','FEE_TYPE_072','补运费',NULL,1,1,1,1,0,'移仓补运费','ACTIVE',NULL,'admin','2022-11-23 00:00:00.000',NULL,'admin','2022-11-23 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000073,0,'默认','DOMESTIC','FEE_TYPE_073','其他扣费',NULL,1,1,1,1,0,'其他扣费','ACTIVE',NULL,'admin','2022-11-23 00:00:00.000',NULL,'admin','2022-11-23 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000074,0,'默认','DOMESTIC','FEE_TYPE_074','抵扣费用',NULL,1,1,1,1,0,'应付-抵扣费用','ACTIVE',NULL,'admin','2022-11-23 00:00:00.000',NULL,'admin','2022-11-23 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000075,0,'默认','INTERNATIONAL','FEE_TYPE_075','派送费',NULL,1,0,1,1,0,'派送费','ACTIVE',NULL,'admin','2022-04-16 00:00:00.000',NULL,'admin','2023-07-05 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000076,0,'默认','DOMESTIC','FEE_TYPE_076','运费',NULL,1,1,1,1,0,'头程运费','ACTIVE',NULL,'admin','2022-04-16 00:00:00.000',NULL,'admin','2023-07-06 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000077,0,'默认','INTERNATIONAL','FEE_TYPE_077','仓储费',NULL,1,0,1,1,0,'仓储费','ACTIVE',NULL,'admin','2022-04-16 00:00:00.000',NULL,'admin','2023-07-05 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000078,0,'集装箱费用','INTERNATIONAL','FEE_TYPE_078','提柜-额外费用',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'jinshanshan','2024-12-05 00:00:00.000',NULL,'jinshanshan','2024-12-05 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000079,0,'集装箱费用','INTERNATIONAL','FEE_TYPE_079','移柜费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'jinshanshan','2024-11-22 00:00:00.000',NULL,'jinshanshan','2024-11-22 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000080,0,'集装箱费用','DOMESTIC','FEE_TYPE_080','四期提',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-12 00:00:00.000',NULL,'yingchaoqun','2024-08-12 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000081,0,'集装箱费用','DOMESTIC','FEE_TYPE_081','进大榭',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-12 00:00:00.000',NULL,'yingchaoqun','2024-08-12 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000082,0,'集装箱费用','DOMESTIC','FEE_TYPE_082','过夜费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-10 00:00:00.000',NULL,'yingchaoqun','2024-08-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000083,0,'集装箱费用','DOMESTIC','FEE_TYPE_083','搞定费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-10 00:00:00.000',NULL,'yingchaoqun','2024-08-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000084,0,'集装箱费用','DOMESTIC','FEE_TYPE_084','梅山提',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-10 00:00:00.000',NULL,'yingchaoqun','2024-08-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000085,0,'集装箱费用','DOMESTIC','FEE_TYPE_085','进梅山',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-10 00:00:00.000',NULL,'yingchaoqun','2024-08-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000086,0,'集装箱费用','DOMESTIC','FEE_TYPE_086','舱单录入费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-10 00:00:00.000',NULL,'yingchaoqun','2024-08-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000087,0,'集装箱费用','DOMESTIC','FEE_TYPE_087','进洋山',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-10 00:00:00.000',NULL,'yingchaoqun','2024-08-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000088,0,'集装箱费用','DOMESTIC','FEE_TYPE_088','洋山提',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-10 00:00:00.000',NULL,'yingchaoqun','2024-08-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000089,0,'集装箱费用','DOMESTIC','FEE_TYPE_089','落箱费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-10 00:00:00.000',NULL,'yingchaoqun','2024-08-10 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000090,0,'集装箱费用','DOMESTIC','FEE_TYPE_090','预进港',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-09 00:00:00.000',NULL,'yingchaoqun','2024-08-09 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000091,0,'集装箱费用','DOMESTIC','FEE_TYPE_091','改运抵',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-09 00:00:00.000',NULL,'yingchaoqun','2024-08-09 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000092,0,'集装箱费用','DOMESTIC','FEE_TYPE_092','预提费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-08 00:00:00.000',NULL,'yingchaoqun','2024-08-08 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000093,0,'集装箱费用','DOMESTIC','FEE_TYPE_093','设备交接单',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-08 00:00:00.000',NULL,'yingchaoqun','2024-08-08 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000094,0,'集装箱费用','DOMESTIC','FEE_TYPE_094','操作费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-08 00:00:00.000',NULL,'yingchaoqun','2024-08-08 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000095,0,'集装箱费用','DOMESTIC','FEE_TYPE_095','VGM',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2024-08-08 00:00:00.000',NULL,'yingchaoqun','2024-08-08 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000096,0,'集装箱费用','INTERNATIONAL','FEE_TYPE_096','托盘费',NULL,1,0,0,0,1,NULL,'ACTIVE',NULL,'jinshanshan','2023-08-14 00:00:00.000',NULL,'jinshanshan','2023-08-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000097,0,'集装箱费用','INTERNATIONAL','FEE_TYPE_097','拆柜费',NULL,1,0,0,0,1,NULL,'ACTIVE',NULL,'jinshanshan','2023-08-14 00:00:00.000',NULL,'jinshanshan','2023-08-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000098,0,'集装箱费用','DOMESTIC','FEE_TYPE_098','加封费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-08-03 00:00:00.000',NULL,'yingchaoqun','2023-08-03 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000099,0,'集装箱费用','DOMESTIC','FEE_TYPE_099','电子锁',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-08-03 00:00:00.000',NULL,'yingchaoqun','2023-08-03 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000100,0,'集装箱费用','DOMESTIC','FEE_TYPE_100','吊机费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-19 00:00:00.000',NULL,'yingchaoqun','2023-07-19 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000101,0,'集装箱费用','DOMESTIC','FEE_TYPE_101','条码费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-19 00:00:00.000',NULL,'yingchaoqun','2023-07-19 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000102,0,'集装箱费用','DOMESTIC','FEE_TYPE_102','AMS',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-19 00:00:00.000',NULL,'yingchaoqun','2023-07-19 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000103,0,'集装箱费用','DOMESTIC','FEE_TYPE_103','海运费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-19 00:00:00.000',NULL,'yingchaoqun','2023-07-19 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000104,0,'集装箱费用','DOMESTIC','FEE_TYPE_104','堆存费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-19 00:00:00.000',NULL,'yingchaoqun','2023-07-19 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000105,0,'集装箱费用','DOMESTIC','FEE_TYPE_105','电子装箱单',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'yingchaoqun','2023-07-19 00:00:00.000',NULL,'yingchaoqun','2023-07-19 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000106,0,'集装箱费用','INTERNATIONAL','FEE_TYPE_106','提柜费',NULL,1,1,1,1,0,'提柜费','ACTIVE',NULL,'admin','2023-05-18 00:00:00.000',NULL,'admin','2023-05-18 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000107,0,'集装箱费用','INTERNATIONAL','FEE_TYPE_107','清关费',NULL,1,1,1,1,0,'清关费','ACTIVE',NULL,'admin','2023-05-18 00:00:00.000',NULL,'admin','2023-05-18 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000108,0,'集装箱费用','DOMESTIC','FEE_TYPE_108','拖车费',NULL,1,1,1,1,0,'拖车费','ACTIVE',NULL,'admin','2023-05-18 00:00:00.000',NULL,'admin','2023-07-06 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000109,0,'集装箱费用','DOMESTIC','FEE_TYPE_109','加载费',NULL,1,1,1,1,0,'加载费','ACTIVE',NULL,'admin','2023-05-18 00:00:00.000',NULL,'admin','2023-05-18 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000110,0,'BOL费用','INTERNATIONAL','FEE_TYPE_110','装车费',NULL,1,1,1,1,1,NULL,'ACTIVE',NULL,'jinshanshan','2025-01-17 00:00:00.000',NULL,'jinshanshan','2025-01-17 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000111,0,'头程运费','INTERNATIONAL','FBA_HEAD_FREIGHT','FBA头程运费',NULL,0,0,0,1,0,'系统计费发布使用的固定应收费用项目','ACTIVE',0,'system','2026-08-14 00:00:00.000',0,'system','2026-08-14 00:00:00.000',0,0); +INSERT INTO fl_fee_item (id, tenant_id, category, trade_scope, fee_code, fee_type_name, supplier_category, pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at, deleted, version) VALUES (193000000001000112,0,'尾程派送费','INTERNATIONAL','PRIVATE_LAST_MILE_DELIVERY','私人仓尾程派送费',NULL,0,0,1,1,0,'仅用于destinationType=PRIVATE的入库单','ACTIVE',0,'SYSTEM',CURRENT_TIMESTAMP(3),0,'SYSTEM',CURRENT_TIMESTAMP(3),0,0); + +-- 系统基线 fl_price_channel_catalog:15 行。 +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (1,'MATSON_CLX_TRUCK','美森正班海卡','FBA_WAREHOUSE_RATE','美森正班海卡','[\"美森正班海卡\"]','ZBYHK','ZBYHWK','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (2,'MATSON_MAX_TRUCK','美森加班海卡','FBA_WAREHOUSE_RATE','美森加班海卡','[\"美森加班海卡\"]','BYHK','BYHWK','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (3,'EXX_TRUCK','EXX海卡','FBA_WAREHOUSE_RATE','EXX海卡','[\"EXX海卡\"]','EXXHK','EXXHWK','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (4,'HEDE_TRUCK','合德海卡','FBA_WAREHOUSE_RATE','合德海卡','[\"合德海卡\"]','HDHK','HDHWK','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (5,'EXX_RAIL_NY','纽约EXX海铁','FBA_WAREHOUSE_RATE','纽约EXX海铁','[\"纽约EXX海铁\"]','NYEXX',NULL,'ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (6,'OA_LA_TRUCK','洛杉矶普船海卡','FBA_WAREHOUSE_RATE','洛杉矶普船海卡','[\"洛杉矶普船海卡\"]','PCHK','PCHWK','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (7,'OA_LA_FULL_TRUCK','洛杉矶普船全卡','FBA_WAREHOUSE_RATE','洛杉矶普船全卡','[\"洛杉矶普船全卡\"]','PCKK',NULL,'ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (8,'OA_CHICAGO_RAIL','芝加哥普船海铁','FBA_WAREHOUSE_RATE','芝加哥普船海铁','[\"芝加哥普船海铁\"]','CPHT',NULL,'ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (9,'OA_HOUSTON_TRUCK','休斯顿普船海卡','FBA_WAREHOUSE_RATE','休斯顿普船海卡','[\"休斯顿普船海卡\"]','HPHK',NULL,'ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (10,'OA_NEW_YORK_TRUCK','纽约普船海卡','FBA_WAREHOUSE_RATE','纽约普船海卡','[\"纽约普船海卡\"]','NPHK','NPHWK','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (11,'OA_SAVANNAH_TRUCK','萨凡纳普船海卡','FBA_WAREHOUSE_RATE','萨凡纳普船海卡','[\"萨凡纳普船海卡\"]','SPHK',NULL,'ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (12,'MATSON_CLX_PARCEL','美森正班海派','REGION_WEIGHT_TIER','海派','[\"美森正班海派\"]','ZBYHP',NULL,'ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (13,'MATSON_MAX_PARCEL','美森加班海派','REGION_WEIGHT_TIER','海派','[\"美森加班海派\"]','BYHP',NULL,'ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (14,'HEDE_PARCEL','合德海派','REGION_WEIGHT_TIER','海派','[\"合德海派\"]','HDHP',NULL,'ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_channel_catalog (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, inbound_code_prefix, overseas_inbound_code_prefix, status, created_at, updated_at) VALUES (15,'OA_PARCEL','普船海派','REGION_WEIGHT_TIER','海派','[\"普船海派\"]','PCHP',NULL,'ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); + +-- 系统基线 fl_price_origin_alias:7 行。 +INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status, created_at, updated_at) VALUES (1,'CN-YIWU-01','华东仓(义乌)','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status, created_at, updated_at) VALUES (2,'CN-YIWU-01','义乌仓','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status, created_at, updated_at) VALUES (3,'CN-YIWU-01','义乌','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status, created_at, updated_at) VALUES (4,'CN-DONGGUAN-01','华南仓(深圳)','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status, created_at, updated_at) VALUES (5,'CN-DONGGUAN-01','东莞仓','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status, created_at, updated_at) VALUES (6,'CN-DONGGUAN-01','深圳仓','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status, created_at, updated_at) VALUES (7,'CN-DONGGUAN-01','华南仓(深圳/东莞)','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); + +-- 系统基线 fl_price_warehouse_mapping:4 行。 +INSERT INTO fl_price_warehouse_mapping (id, source_type, source_value, warehouse_id, warehouse_code, warehouse_type, status, created_at, updated_at) VALUES (1,'ORIGIN_CODE','CN-YIWU-01',193000000000200001,'CN-YIWU-01','DOMESTIC','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_warehouse_mapping (id, source_type, source_value, warehouse_id, warehouse_code, warehouse_type, status, created_at, updated_at) VALUES (2,'ORIGIN_CODE','CN-DONGGUAN-01',193000000000200002,'CN-DONGGUAN-01','DOMESTIC','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_warehouse_mapping (id, source_type, source_value, warehouse_id, warehouse_code, warehouse_type, status, created_at, updated_at) VALUES (3,'OVERSEAS_NAME','洛杉矶仓',193000000000200003,'US-LAX-01','OVERSEAS','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); +INSERT INTO fl_price_warehouse_mapping (id, source_type, source_value, warehouse_id, warehouse_code, warehouse_type, status, created_at, updated_at) VALUES (4,'OVERSEAS_NAME','纽约仓',193000000000200004,'US-NYC-01','OVERSEAS','ACTIVE',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); + +USE baiyue_file; + +-- 系统基线 file_export_task_guard:1 行。 +INSERT INTO file_export_task_guard (guard_key, updated_at) VALUES ('GLOBAL_CREATE',CURRENT_TIMESTAMP(3)); + +USE baiyue_finance; + +-- 系统基线 fin_subject:1 行。 +INSERT INTO fin_subject (id, subject_code, subject_name, status, version, created_by, updated_by, created_at, updated_at) VALUES (1,'PLATFORM_DEFAULT','平台','ACTIVE',0,0,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); + +SELECT 'PASS' AS initialization_status, 'CN' AS region, 208 AS initialized_tables; diff --git a/deploy/sql/权限点初始化-MySQL8.sql b/deploy/sql/权限点初始化-MySQL8.sql new file mode 100644 index 000000000..651f45d01 --- /dev/null +++ b/deploy/sql/权限点初始化-MySQL8.sql @@ -0,0 +1,66 @@ +-- 通用 IAM 权限点独立初始化入口(MySQL 8.0)。 +-- 国内与海外业务的 Admin/Client 权限主数据均在 baiyue_iam 统一管理。 +-- 只允许写入 baiyue_iam 的权限资源和角色授权; +-- 不建表、不创建账号/客户/仓库,不写业务测试数据。 +-- 前置:IAM 基础表、相关父菜单及系统角色已由全量基线创建。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS apply_permission_points; +DELIMITER $$ +CREATE PROCEDURE apply_permission_points() +BEGIN + IF NOT EXISTS (SELECT 1 FROM iam_permission WHERE id = 5102010 AND tenant_id = 0 + AND permission_code = 'platform:order:list' AND permission_type = 'MENU') THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '权限初始化失败:订单列表父菜单不存在,请先执行全量基线'; + END IF; + IF (SELECT COUNT(*) FROM iam_role WHERE id IN (4000001, 4000003) AND tenant_id = 0 + AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '权限初始化失败:平台管理员或订单运营角色不存在'; + END IF; + IF EXISTS (SELECT 1 FROM iam_permission WHERE id = 5102027 + AND permission_code <> 'platform:order:declaration:edit') + OR EXISTS (SELECT 1 FROM iam_permission + WHERE permission_code = 'platform:order:declaration:edit' AND id <> 5102027) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '权限初始化失败:5102027 或权限码已被其他语义占用'; + END IF; + + START TRANSACTION; + 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 + (5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit', + '修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0) + ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + permission_type = VALUES(permission_type), app_code = VALUES(app_code), + sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; + + INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) + SELECT role_id, 5102027, 0 + FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles + ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + + IF (SELECT COUNT(*) FROM iam_permission WHERE id = 5102027 AND tenant_id = 0 + AND app_code = 'PLATFORM' AND permission_type = 'BUTTON' AND parent_id = 5102010 + AND permission_code = 'platform:order:declaration:edit' + AND status = 'ACTIVE' AND deleted = 0) <> 1 + OR (SELECT COUNT(DISTINCT role_id) FROM iam_role_permission + WHERE tenant_id = 0 AND permission_id = 5102027 + AND role_id IN (4000001, 4000003)) <> 2 THEN + ROLLBACK; + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '权限初始化失败:订单申报与运营预估权限未完整写入'; + END IF; + COMMIT; +END$$ +DELIMITER ; + +CALL apply_permission_points(); +DROP PROCEDURE IF EXISTS apply_permission_points; + +SELECT 'PASS' AS permission_initialization_status; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql new file mode 100644 index 000000000..6e50d87f2 --- /dev/null +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -0,0 +1,2998 @@ +-- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 +-- MySQL 8.0+;只允许目标区域的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 +-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 +-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 +-- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。 +SET NAMES utf8mb4 COLLATE utf8mb4_0900_ai_ci; +SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; + +-- 服务 schema:baiyue_event_bridge_overseas +CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_event_bridge_overseas; + +CREATE TABLE bridge_delivery_attempt ( + id bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', + source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', + target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', + attempt_no int NOT NULL COMMENT '投递序号', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '投递结果', + response_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '对端响应码', + error_message varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '错误信息', + started_at datetime(3) NOT NULL COMMENT '开始时间', + finished_at datetime(3) DEFAULT NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_bridge_attempt (event_id,attempt_no), + KEY idx_bridge_attempt_event (event_id,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外跨区域事件投递尝试记录'; + +CREATE TABLE bridge_inbox ( + event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', + idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '接收幂等键', + event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', + event_version int NOT NULL COMMENT '事件契约版本', + source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', + target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户标识', + aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', + aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', + aggregate_version bigint NOT NULL COMMENT '聚合版本', + handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', + trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', + correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', + causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', + occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', + payload_json json NOT NULL COMMENT '事件负载', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', + lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '消费租约持有者', + lease_until datetime(3) DEFAULT NULL COMMENT '消费租约截止时间', + retry_count int NOT NULL DEFAULT '0' COMMENT '消费重试次数', + next_retry_at datetime(3) DEFAULT NULL COMMENT '下次消费时间', + last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', + received_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at datetime(3) DEFAULT NULL COMMENT '消费完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), + KEY idx_bridge_inbox_consume (status,next_retry_at,lease_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外跨区域事件已接收箱'; + +CREATE TABLE bridge_outbox ( + event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', + idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送幂等键', + event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', + event_version int NOT NULL COMMENT '事件契约版本', + source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', + target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户标识', + aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', + aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', + aggregate_version bigint NOT NULL COMMENT '聚合版本', + handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', + trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', + correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', + causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', + occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', + payload_json json NOT NULL COMMENT '事件负载', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', + retry_count int NOT NULL DEFAULT '0' COMMENT '已重试次数', + next_retry_at datetime(3) DEFAULT NULL COMMENT '下次重试时间', + lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '租约持有者', + lease_until datetime(3) DEFAULT NULL COMMENT '租约截止时间', + last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + delivered_at datetime(3) DEFAULT NULL COMMENT '投递成功时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), + KEY idx_bridge_outbox_schedule (target_region,status,next_retry_at,lease_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外跨区域事件待投递箱'; + +-- 服务 schema:baiyue_lastleg +CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE ll_cargo_discard_application ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + discard_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '废弃申请单号', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', + overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', + inbound_codes varchar(2000) COLLATE utf8mb4_unicode_ci 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) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '币种', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', + reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', + review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注', + review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键', + review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希', + reviewed_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_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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='货物废弃申请单'; + +CREATE TABLE 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + carton_id bigint NOT NULL COMMENT '箱唛记录ID', + carton_mark varchar(128) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='货物废弃锁定箱明细'; + +CREATE TABLE ll_cargo_discard_item ( + id bigint NOT NULL COMMENT '主键ID', + application_id bigint NOT NULL COMMENT '申请单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + order_id bigint NOT NULL COMMENT '订单ID', + inbound_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号快照', + profile_id bigint NOT NULL COMMENT '托盘档案ID', + sku_inventory_id bigint NOT NULL COMMENT 'SKU库存记录ID', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称', + available_packages_snapshot int NOT NULL COMMENT '可用箱数快照', + discard_packages int NOT NULL COMMENT '申请废弃箱数', + actual_discard_packages int DEFAULT NULL COMMENT 'PDA确认实际废弃箱数', + pda_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED', + pda_operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA操作人', + pda_completed_at datetime(3) DEFAULT NULL COMMENT 'PDA完成时间', + 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='货物废弃SKU明细'; + +CREATE TABLE ll_cargo_discard_status_history ( + id bigint NOT NULL COMMENT '主键ID', + application_id bigint NOT NULL COMMENT '申请单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + from_status varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + to_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='货物废弃审批历史'; + +CREATE TABLE ll_container_arrival_time_operation ( + id bigint NOT NULL COMMENT '柜级预计到仓时间修改操作ID', + source_container_booking_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定来源柜预订ID', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台请求幂等键', + request_hash varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化修改请求SHA-256摘要', + result_version int NOT NULL COMMENT '修改成功后的柜级时间版本', + operator_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台操作人名称', + remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '预计到仓时间修改备注', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_container_arrival_time_idempotency (idempotency_key), + KEY idx_ll_container_arrival_time_container (source_container_booking_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜级预计到仓时间修改幂等审计记录'; + +CREATE TABLE ll_container_loading_evidence ( + id bigint NOT NULL COMMENT '海外整柜装柜照片投影ID', + source_container_booking_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内稳定来源柜预订ID', + evidence_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PHOTO_STEP_1至PHOTO_STEP_4或SEAL', + source_file_id bigint NOT NULL COMMENT '国内File服务文件ID', + file_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内文件名快照', + created_at datetime(3) NOT NULL COMMENT '投影创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_container_loading_evidence_type (source_container_booking_id,evidence_type), + UNIQUE KEY uk_ll_container_loading_evidence_file (source_container_booking_id,source_file_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外拆柜只读国内装柜照片引用'; + +CREATE TABLE ll_container_plan_conversion_command ( + id bigint NOT NULL COMMENT '柜级转换命令ID', + source_container_booking_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定来源柜预订ID', + idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '调用方幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '确认请求SHA-256摘要', + snapshot_version bigint NOT NULL COMMENT '确认的柜级快照版本', + snapshot_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '确认的柜级快照SHA-256', + warehouse_id bigint NOT NULL COMMENT '海外接柜仓ID', + batch_key varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓托盘批次幂等键', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED', + lease_token varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离', + preparation_json json NOT NULL COMMENT '稳定计划号与托盘业务键快照', + result_json json DEFAULT NULL COMMENT '本地计划提交结果', + last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次跨服务错误', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令状态或租约更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_container_conversion_command (source_container_booking_id), + UNIQUE KEY uk_ll_container_conversion_batch (warehouse_id,batch_key), + KEY idx_ll_container_conversion_status (status,updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜级入库计划转换Saga与幂等命令'; + +CREATE TABLE ll_container_shipping_gate ( + container_booking_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源头程柜预订ID', + ocean_booking_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源海运订舱ID;货柜未关联订舱时为空', + container_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际集装箱号', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', + standard_node varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最新有效标准船节点', + node_sequence int NOT NULL DEFAULT '0' COMMENT '最新有效节点单调序号', + observed_at datetime(3) DEFAULT NULL COMMENT '供应商节点观测时间', + actual_at datetime(3) DEFAULT NULL COMMENT '节点实际发生时间', + location_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '节点地点编码', + location_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '节点地点名称', + provider_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '船节点供应商编码', + provider_event_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商事件ID', + provider_node_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商原始节点编码', + source_reference varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商来源引用', + raw_payload_hash varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT 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) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近采纳的跨区事件ID', + payload_hash varchar(71) COLLATE utf8mb4_unicode_ci 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 (container_booking_id), + KEY idx_ll_shipping_gate_version (container_booking_id,aggregate_version,node_sequence), + KEY idx_ll_shipping_gate_candidate (container_no,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA柜号船节点收货资格快照'; + +CREATE TABLE ll_customer_visit ( + id bigint NOT NULL COMMENT '客户回访记录ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + delivery_order_id bigint NOT NULL COMMENT '关联派送单ID', + delivery_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联派送单号', + customer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户联系人姓名快照', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户联系人电话快照', + destination varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送目的仓编码汇总快照', + delivery_completed_at datetime(3) NOT NULL COMMENT '派送完成时间', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访', + selected_last_mile_order_id bigint DEFAULT NULL COMMENT '本次回访选择的尾程订单ID', + selected_last_mile_order_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次回访选择的尾程订单号', + selected_inbound_order_id bigint DEFAULT NULL COMMENT '本次回访选择的入仓单ID', + selected_inbound_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次回访选择的入仓码', + visit_result varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次回访结果', + customer_feedback varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次客户反馈', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次回访备注', + attachment_json json DEFAULT NULL COMMENT '当前回访资料文件元数据列表', + visited_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次回访操作人ID', + visited_at datetime(3) DEFAULT NULL COMMENT '最近一次回访时间', + visit_count 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_customer_visit_delivery (customer_id,delivery_order_id), + KEY idx_ll_customer_visit_list (customer_id,status,delivery_completed_at), + KEY idx_ll_customer_visit_delivery_no (customer_id,delivery_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派送单维度客户回访记录'; + +CREATE TABLE ll_delivery_document_revision ( + id bigint NOT NULL COMMENT '派送资料历史ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '派送所属客户隔离键', + delivery_order_id bigint NOT NULL COMMENT '派送单ID', + version bigint NOT NULL COMMENT '保存成功时派送单版本', + idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资料保存幂等键', + request_hash varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '完整保存请求哈希', + snapshot_json json NOT NULL COMMENT '不可变三类资料及BOL号结果快照', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '保存操作人ID', + created_at datetime(3) NOT NULL COMMENT '资料保存时间', + PRIMARY KEY (id), + UNIQUE KEY uk_delivery_document_command (customer_id,delivery_order_id,idempotency_key), + UNIQUE KEY uk_delivery_document_version (customer_id,delivery_order_id,version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派送资料版本历史与幂等保存记录'; + +CREATE TABLE ll_delivery_operation ( + id bigint NOT NULL COMMENT '派送操作主键', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + delivery_order_id bigint NOT NULL COMMENT '派送单ID', + operation_node varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作节点', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + operator_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人名称', + result varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '处理结果', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + KEY idx_ll_delivery_operation_order (customer_id,delivery_order_id,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派送单操作记录'; + +CREATE TABLE ll_delivery_order ( + id bigint NOT NULL COMMENT '派送单主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + delivery_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '派送单号', + outbound_order_id bigint NOT NULL COMMENT '关联出库单ID', + outbound_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联出库单号', + dispatch_order_id bigint NOT NULL COMMENT '关联派车单ID', + dispatch_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联派车单号', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓ID', + overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', + customer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', + destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓ID快照', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本派送单目的FBA仓编码快照', + destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓名称快照', + delivery_group_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', + waybill_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '承运运单号', + bol_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送资料BOL号,不作为文件归属标识', + documents_json json DEFAULT NULL COMMENT '派送三类资料当前绑定及权威文件元数据快照', + delivery_method varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送方式', + carrier_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送承运商', + pickup_method varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货方式', + loading_type varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '装车类型', + vehicle_type varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车辆类型', + vehicle_size varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车辆大小', + vehicle_plate varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车牌号', + driver_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机或车队名称', + driver_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车队联系电话', + appointment_account varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '预约账号', + seller_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '卖家姓名', + package_count int NOT NULL DEFAULT '0' COMMENT '派送箱数', + pallet_count int NOT NULL DEFAULT '0' COMMENT '派送托盘数', + total_weight_kg decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '总重量(千克)', + total_volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '总体积(立方米)', + planned_delivery_at datetime(3) DEFAULT NULL COMMENT '预计派送时间', + dispatched_at datetime(3) DEFAULT NULL COMMENT '实际派送时间', + expected_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间', + signed_at datetime(3) DEFAULT NULL COMMENT '签收时间', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送备注', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', + 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_delivery_no (customer_id,delivery_no), + UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key), + UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key), + KEY idx_ll_delivery_list (customer_id,status,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程派送单'; + +CREATE TABLE ll_destuff_pallet_cost ( + id bigint NOT NULL COMMENT '主键ID', + source_container_booking_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源柜次业务ID', + container_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '集装箱号', + warehouse_id bigint DEFAULT NULL COMMENT '海外仓ID', + pallet_count int NOT NULL COMMENT '实际打托数', + total_actual_volume_cbm decimal(20,6) NOT NULL COMMENT '客户实收总体积', + total_cost decimal(20,2) NOT NULL COMMENT '打托总成本', + currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '成本币种', + status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '生成状态', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_destuff_cost_booking (source_container_booking_id), + CONSTRAINT chk_ll_destuff_cost CHECK (((pallet_count > 0) and (total_actual_volume_cbm > 0) and (total_cost > 0) and (currency = _utf8mb4'CNY'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='拆柜打托实际成本汇总'; + +CREATE TABLE ll_destuff_pallet_cost_allocation ( + id bigint NOT NULL COMMENT '主键ID', + cost_id bigint NOT NULL COMMENT '打托成本汇总ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', + actual_volume_cbm decimal(20,6) NOT NULL COMMENT '客户实收体积', + allocated_amount decimal(20,2) NOT NULL COMMENT '客户分摊金额', + fee_source_id bigint NOT NULL COMMENT '海外费用来源ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_destuff_customer (cost_id,customer_id), + UNIQUE KEY uk_ll_destuff_fee_source (fee_source_id), + CONSTRAINT chk_ll_destuff_allocation CHECK (((actual_volume_cbm > 0) and (allocated_amount >= 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='拆柜打托成本按客户实收体积分摊'; + +CREATE TABLE ll_dispatch_draft ( + id bigint NOT NULL COMMENT '主键ID', + draft_token varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '智能排车草稿随机标识', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户租户ID', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿操作人ID', + plan_ids_json varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '所选入仓计划ID列表JSON', + containers_json longtext COLLATE utf8mb4_unicode_ci COMMENT '草稿装车布局JSON快照', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '草稿状态', + expires_at datetime(3) DEFAULT 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_ll_dispatch_draft_token (draft_token), + KEY idx_ll_dispatch_draft_operator (operator_id,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿'; + +CREATE TABLE ll_dispatch_draft_container ( + id bigint NOT NULL COMMENT '主键ID', + draft_id bigint NOT NULL COMMENT '智能排车草稿ID', + draft_token varchar(64) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿装车托盘'; + +CREATE TABLE ll_dispatch_item ( + id bigint NOT NULL COMMENT '主键ID', + dispatch_order_id bigint NOT NULL COMMENT '派车单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + plan_id bigint DEFAULT NULL COMMENT '入仓计划ID', + plan_pallet_id bigint DEFAULT NULL COMMENT '计划托盘ID', + warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘ID', + order_id bigint DEFAULT NULL COMMENT '订单ID', + order_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '订单号', + order_refs varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联订单信息', + inbound_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓单号快照', + pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '托盘号', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓ID', + overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', + destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA仓库编码', + package_count int NOT NULL DEFAULT '0' COMMENT '箱数', + volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', + source_stage varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '数据来源阶段', + carton_marks varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标箱唛映射快照', + source_business_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务类型', + source_business_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务单号', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车货物冻结明细'; + +CREATE TABLE ll_dispatch_order ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + dispatch_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '派车单号', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓ID', + overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', + vehicle_plate varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车牌号', + driver_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机姓名', + driver_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机电话', + carrier_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '承运商名称', + route varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '运输路线', + depart_at datetime(3) DEFAULT NULL COMMENT '发车时间', + fba_appointment_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA预约号', + fba_appointment_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT 'FBA预约状态', + vehicle_appointment_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '车辆预约状态', + estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间', + status varchar(32) COLLATE utf8mb4_unicode_ci 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) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '部分完成原因', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + 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 (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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程派车单'; + +CREATE TABLE ll_dispatch_pallet_lock ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + source_pallet_key varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源托盘业务键', + active_key varchar(160) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '生效中的资源排他键', + plan_id bigint DEFAULT NULL COMMENT '入仓计划ID', + plan_pallet_id bigint DEFAULT NULL COMMENT '计划托盘ID', + warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘ID', + dispatch_order_id bigint DEFAULT NULL COMMENT '正式派车单ID,草稿锁阶段为空', + draft_container_id bigint DEFAULT NULL COMMENT '智能排车草稿装车托盘ID', + draft_token varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '智能排车草稿随机标识', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿锁操作人ID', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', + locked_at datetime(3) NOT NULL COMMENT '锁定时间', + expires_at datetime(3) DEFAULT 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_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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车托盘排他锁'; + +CREATE TABLE ll_dispatch_pda_operation ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + task_id bigint DEFAULT NULL COMMENT 'PDA任务ID', + operation_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作类型', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', + result_json longtext COLLATE utf8mb4_unicode_ci COMMENT '处理结果JSON', + status varchar(32) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车PDA操作幂等记录'; + +CREATE TABLE ll_dispatch_pda_pick ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci 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) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车PDA拣货记录'; + +CREATE TABLE ll_dispatch_pda_pick_order ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + pick_id bigint NOT NULL COMMENT 'PDA拣货记录ID', + receipt_id bigint NOT NULL COMMENT '入仓实收记录ID', + 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA拣货订单归属记录'; + +CREATE TABLE ll_dispatch_pda_task ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + dispatch_order_id bigint NOT NULL COMMENT '派车单ID', + task_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA任务号', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务状态', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Token绑定的海外仓库ID', + operator_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人姓名', + pushed_at datetime(3) NOT NULL COMMENT '任务下发时间', + completed_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_ll_dispatch_pda_task_dispatch (customer_id,dispatch_order_id), + UNIQUE KEY uk_ll_dispatch_pda_task_no (task_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车PDA拣货任务'; + +CREATE TABLE ll_dropship_carton ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + order_id bigint NOT NULL COMMENT '订单ID', + profile_id bigint NOT NULL COMMENT '托盘档案ID', + warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘ID', + pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '托盘号', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛', + product_quantity bigint NOT NULL COMMENT '该箱实收商品件数', + carton_sequence int NOT NULL COMMENT '箱唛序号', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', + outbound_order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发箱唛明细'; + +CREATE TABLE ll_dropship_carton_sku ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + order_id bigint NOT NULL COMMENT '尾程订单ID', + profile_id bigint NOT NULL COMMENT '一件代发托盘档案ID', + carton_id bigint NOT NULL COMMENT '一件代发物理箱ID', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱内SKU编码', + product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称快照', + product_quantity bigint 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_dropship_carton_sku (carton_id,sku_code), + KEY idx_ll_dropship_carton_sku_order (customer_id,order_id,sku_code), + KEY idx_ll_dropship_carton_sku_profile (customer_id,profile_id,sku_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发物理箱内SKU明细'; + +CREATE TABLE ll_dropship_carton_ledger ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + order_id bigint NOT NULL COMMENT '订单ID', + carton_id bigint NOT NULL COMMENT '箱唛记录ID', + carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + operation_type varchar(32) COLLATE utf8mb4_unicode_ci 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) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '业务单号', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发箱唛库存流水'; + +CREATE TABLE ll_dropship_order_profile ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + order_id bigint NOT NULL COMMENT '订单ID', + warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘ID', + pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '托盘号', + source_pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源托盘号快照', + manifest_version int NOT NULL COMMENT '整托清单版本', + manifest_package_count int NOT NULL COMMENT '整托总箱数', + manifest_fragment_count int NOT NULL COMMENT '整托客户分片总数', + manifest_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '整托完整清单摘要', + inventory_position_id bigint DEFAULT NULL COMMENT '库存仓位记录ID', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci 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), + KEY idx_ll_dropship_profile_manifest (source_pallet_no,manifest_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发托盘档案'; + +CREATE TABLE ll_dropship_sku_inventory ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + order_id bigint NOT NULL COMMENT '订单ID', + profile_id bigint NOT NULL COMMENT '托盘档案ID', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发SKU库存汇总'; + +CREATE TABLE ll_event_conflict ( + id bigint NOT NULL COMMENT '冲突记录主键', + event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '冲突事件 ID', + customer_id bigint NOT NULL COMMENT '租户 ID', + conflict_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '冲突类型', + conflict_message varchar(512) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件冲突记录'; + +CREATE TABLE ll_event_inbox ( + id bigint NOT NULL COMMENT '事件接收记录主键', + event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局唯一 ID', + customer_id bigint NOT NULL COMMENT '租户 ID', + event_type varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', + aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', + aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合 ID', + aggregate_version bigint NOT NULL COMMENT '聚合版本', + payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '负载摘要', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消费状态', + error_message varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通用事件接收幂等箱'; + +CREATE TABLE ll_event_outbox ( + id bigint NOT NULL COMMENT '事件主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', + aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', + event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', + event_version int NOT NULL COMMENT '事件版本', + event_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + payload_json longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件载荷JSON', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', + retry_count int NOT NULL DEFAULT '0' COMMENT '重试次数', + next_retry_at datetime(3) DEFAULT NULL COMMENT '下次重试时间', + published_at datetime(3) DEFAULT NULL COMMENT '发布时间', + locked_at datetime(3) DEFAULT NULL COMMENT '投递租约获取时间', + locked_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '投递租约持有者', + last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后一次发布错误', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程跨区业务事件发件箱'; + +CREATE TABLE ll_exception_order ( + id bigint NOT NULL COMMENT '异常订单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + exception_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '异常订单号', + exception_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '异常类型', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '异常处理状态', + reason varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '异常原因', + last_mile_order_id bigint DEFAULT NULL COMMENT '关联尾程订单ID', + last_mile_order_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联尾程订单号', + inbound_order_id bigint DEFAULT NULL COMMENT '关联入仓单ID', + inbound_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联入仓码', + delivery_order_id bigint DEFAULT NULL COMMENT '关联派送单ID', + delivery_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联派送单号', + outbound_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联出库单号', + customer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓ID快照', + overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '异常货物SKU', + package_count int DEFAULT NULL COMMENT '异常箱数', + return_direction varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '退回方向', + return_logistics_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '退回物流单号', + handling_action varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '异常处理措施', + handling_result varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '异常处理结果', + evidence_file_ids_json json DEFAULT NULL COMMENT '异常处理证据文件ID列表', + inventory_adjustment_id bigint DEFAULT NULL COMMENT '退回库存恢复操作ID', + handler_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '处理人ID', + handler_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '处理人名称', + source_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'MANUAL' COMMENT '异常来源类型', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', + exception_at datetime(3) NOT NULL COMMENT '异常发生时间', + completed_at datetime(3) DEFAULT 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_exception_no (customer_id,exception_no), + UNIQUE KEY uk_ll_exception_delivery_return (customer_id,delivery_order_id,exception_type), + KEY idx_ll_exception_list (customer_id,status,exception_at), + KEY idx_ll_exception_tail (customer_id,last_mile_order_no), + KEY idx_ll_exception_inbound (customer_id,inbound_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外仓异常订单'; + +CREATE TABLE ll_exception_order_command ( + id bigint NOT NULL COMMENT '异常处理命令ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + exception_order_id bigint NOT NULL COMMENT '异常订单ID', + action varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '异常处理动作', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '幂等键', + request_hash varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA256哈希', + result_json json NOT NULL COMMENT '命令结果快照', + created_at datetime(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_exception_command_key (customer_id,action,idempotency_key), + KEY idx_ll_exception_command_order (customer_id,exception_order_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常处理命令幂等审计'; + +CREATE TABLE ll_exception_return_item ( + id bigint NOT NULL COMMENT '异常退回明细ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + exception_order_id bigint NOT NULL COMMENT '异常订单ID', + outbound_pallet_item_id bigint NOT NULL COMMENT '原出库托盘明细ID', + inventory_position_id bigint NOT NULL COMMENT '原出库库存位置ID', + pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库托盘码快照', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库SKU快照', + max_return_packages bigint NOT NULL COMMENT '原出库可退箱数上限', + approved_return_packages bigint NOT NULL COMMENT '批准退回箱数', + created_at datetime(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_exception_return_source (customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id), + KEY idx_ll_exception_return_order (customer_id,exception_order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常退回原出库库存冻结明细'; + +CREATE TABLE ll_finance_fee_command ( + id bigint NOT NULL COMMENT '幂等命令ID', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID', + action varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', + idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256', + source_fee_id bigint NOT NULL COMMENT '稳定费用来源ID', + source_version int NOT NULL COMMENT '命令结果版本', + created_at datetime(3) NOT NULL COMMENT '命令创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_finance_fee_command (operator_id,action,idempotency_key), + KEY idx_ll_finance_fee_command_source (source_fee_id,source_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外费用来源写操作幂等审计'; + +CREATE TABLE ll_finance_fee_source ( + id bigint NOT NULL COMMENT '费用版本记录ID', + source_fee_id bigint NOT NULL COMMENT '跨版本稳定费用来源ID', + source_version int NOT NULL COMMENT '费用来源版本,从1递增', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + customer_name varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', + source_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外费用来源类型', + source_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源业务编号或入仓码快照', + business_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源业务类型', + business_id bigint DEFAULT NULL COMMENT '来源业务记录ID', + business_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源业务编号', + order_id bigint DEFAULT NULL COMMENT '关联订单ID', + inbound_id bigint DEFAULT NULL COMMENT '关联海外入仓计划或入仓记录ID', + inbound_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联入仓码;并非所有费用必填', + container_id bigint DEFAULT NULL COMMENT '关联柜次ID', + container_booking_id bigint DEFAULT NULL COMMENT '来源订舱柜ID', + container_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实集装箱号快照', + warehouse_id bigint DEFAULT NULL COMMENT '关联海外仓ID', + fee_item_code varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目编码快照', + fee_item_name varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目名称快照', + direction varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', + supplier_id bigint DEFAULT NULL COMMENT '应付供应商ID', + supplier_name varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '应付供应商名称快照', + supplier_category varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '应付供应商类别约束', + settlement_mode varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付', + paid_at datetime(3) DEFAULT NULL COMMENT '直接已付时间', + payment_method varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '直接已付方式', + payment_reference_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '直接已付流水号', + payment_voucher_file_ids json DEFAULT NULL COMMENT '直接已付凭证文件ID快照', + business_date date NOT NULL COMMENT '费用业务日期', + currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY', + amount decimal(20,6) NOT NULL COMMENT '人民币结算金额', + original_currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务原币种,支持CNY或USD', + original_amount decimal(20,6) NOT NULL COMMENT '业务原币金额', + exchange_rate decimal(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1', + exchange_rate_month char(7) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '汇率月份yyyy-MM', + chargeable_quantity decimal(20,6) DEFAULT NULL COMMENT '计费数量,如箱数、托数、体积或天数', + quantity_unit varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '计费数量单位', + unit_price decimal(20,6) DEFAULT NULL COMMENT '原币计费单价', + period_start date DEFAULT NULL COMMENT '仓租等期间费用开始日期', + period_end date DEFAULT NULL COMMENT '仓租等期间费用结束日期', + review_status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED', + publication_status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', + source_status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED', + review_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核或作废原因', + reviewer_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', + reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', + created_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人ID', + updated_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后更新人ID', + created_at datetime(3) NOT NULL COMMENT '创建时间', + updated_at datetime(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_finance_fee_version (source_fee_id,source_version), + KEY idx_ll_finance_fee_customer (customer_id,source_status,created_at), + KEY idx_ll_finance_fee_business (business_type,business_no,created_at), + KEY idx_ll_finance_fee_container_customer (container_booking_id,customer_id,source_status,created_at), + CONSTRAINT chk_ll_finance_fee_amount CHECK ((amount > 0)), + CONSTRAINT chk_ll_finance_fee_currency CHECK ((currency = _utf8mb4'CNY')), + CONSTRAINT chk_ll_finance_fee_direction CHECK ((direction in (_utf8mb4'RECEIVABLE',_utf8mb4'PAYABLE'))), + CONSTRAINT chk_ll_finance_fee_original_currency CHECK ((original_currency in (_utf8mb4'CNY',_utf8mb4'USD'))), + CONSTRAINT chk_ll_finance_fee_period CHECK ((((period_start is null) and (period_end is null)) or ((period_start is not null) and (period_end is not null) and (period_start <= period_end)))), + CONSTRAINT chk_ll_finance_fee_publication CHECK ((publication_status in (_utf8mb4'UNPUBLISHED',_utf8mb4'PUBLISHED'))), + CONSTRAINT chk_ll_finance_fee_review CHECK ((review_status in (_utf8mb4'DRAFT',_utf8mb4'PENDING',_utf8mb4'APPROVED',_utf8mb4'REJECTED'))), + CONSTRAINT chk_ll_finance_fee_status CHECK ((source_status in (_utf8mb4'ACTIVE',_utf8mb4'SUPERSEDED',_utf8mb4'VOIDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; + +CREATE TABLE ll_inbound_fba_appointment ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + appointment_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '预约单号', + appointment_at datetime(3) NOT NULL COMMENT '预约时间', + expected_loading_at datetime(3) NOT NULL COMMENT '预计装车时间', + appointment_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '预约状态', + appointment_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '预约备注', + fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'FBA仓库编码', + selected_count int NOT NULL DEFAULT '0' COMMENT '已选明细数量', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA入仓预约单'; + +CREATE TABLE 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号快照', + fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA入仓预约明细'; + +CREATE TABLE ll_inbound_order ( + id bigint NOT NULL COMMENT '入仓单主键', + customer_id bigint NOT NULL COMMENT '租户 ID', + source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', + source_inbound_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源入仓分片 ID', + root_inbound_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '根入仓单 ID', + source_order_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源订单 ID', + source_order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源订单号', + root_order_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '根订单 ID', + inbound_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓码', + lx_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发领星入库单代码', + destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓 ID', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', + destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称', + destination_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + package_count int NOT NULL DEFAULT '0' COMMENT '箱数', + product_quantity int DEFAULT NULL COMMENT '数量', + pallet_count int DEFAULT NULL COMMENT '托数', + gross_weight_kg decimal(18,3) DEFAULT NULL COMMENT '毛重(千克)', + net_weight_kg decimal(18,3) DEFAULT NULL COMMENT '净重(千克)', + volume_cbm decimal(18,6) DEFAULT NULL COMMENT '体积(立方米)', + source_event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源事件 ID', + source_loading_plan_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '装柜计划 ID', + source_loading_plan_version bigint NOT NULL COMMENT '装柜计划版本', + handover_version bigint NOT NULL COMMENT '交接版本', + payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '负载摘要', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单状态', + exception_flag varchar(32) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入仓单快照'; + +CREATE TABLE 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源入仓明细ID', + package_count int NOT NULL COMMENT '箱数', + product_quantity int DEFAULT NULL COMMENT '数量', + pallet_count int DEFAULT NULL COMMENT '托数', + gross_weight_kg decimal(18,3) DEFAULT NULL COMMENT '毛重(千克)', + net_weight_kg decimal(18,3) DEFAULT NULL COMMENT '净重(千克)', + dimensional_weight_kg decimal(18,3) DEFAULT NULL COMMENT '体积重量(千克)', + volume_cbm decimal(18,6) DEFAULT NULL COMMENT '体积(立方米)', + destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', + destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称', + fba_tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA跟踪号', + shipment_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货件编号', + shipping_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '唛头', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU编码', + product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + product_name_en varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入仓商品明细快照'; + +CREATE TABLE ll_inbound_pallet_merge ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + task_id bigint NOT NULL COMMENT 'PDA任务ID', + merge_group_no varchar(64) COLLATE utf8mb4_unicode_ci 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.0000' COMMENT '移动体积(立方米)', + source_pallet_scrapped tinyint NOT NULL DEFAULT '0' COMMENT '来源托盘是否已报废', + status varchar(32) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓托盘合托记录'; + +CREATE TABLE ll_inbound_pda_operation ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + task_id bigint NOT NULL COMMENT 'PDA任务ID', + allocation_id bigint DEFAULT NULL COMMENT '分配记录ID', + action varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作动作', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + request_hash varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', + request_json longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容JSON', + result_json longtext COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓PDA操作幂等记录'; + +CREATE TABLE ll_inbound_pda_task ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + plan_id bigint NOT NULL COMMENT '入仓计划ID', + task_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA任务号', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '业务状态', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + operator_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人姓名', + pushed_at datetime(3) DEFAULT NULL COMMENT '任务下发时间', + started_at datetime(3) DEFAULT NULL COMMENT '开始时间', + completed_at datetime(3) DEFAULT NULL COMMENT '完成时间', + unload_photo_file_ids json DEFAULT NULL COMMENT '卸柜照片文件ID列表', + unload_photo_uploaded_at datetime(3) DEFAULT NULL COMMENT '卸柜照片上传成功时间', + estimated_empty_return_at datetime(3) DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓PDA作业任务'; + +CREATE TABLE ll_inbound_plan ( + id bigint NOT NULL COMMENT '入库计划ID(雪花ID)', + plan_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入库单号', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '租户/客户账号标识', + source_order_id bigint NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划', + container_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '集装箱号', + overseas_warehouse_id bigint DEFAULT NULL COMMENT '从尾程订单继承的平台预分配海外接柜仓ID', + planned_arrival_date date DEFAULT NULL COMMENT '人工计划预计到仓日期', + pallet_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '人工计划托盘类型', + assigned_flag tinyint NOT NULL DEFAULT '0' COMMENT '人工计划是否已分配:0否1是', + merge_required tinyint NOT NULL DEFAULT '0' COMMENT '人工计划是否要求合托:0否1是', + urgent_flag tinyint NOT NULL DEFAULT '0' COMMENT '人工计划是否加急:0否1是', + creation_mode varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工', + plan_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓计划备注', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '入库计划状态:PENDING_ASSIGNMENT待分配及后续正式状态', + 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.0000' 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_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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; + +CREATE TABLE ll_inbound_plan_carton ( + id bigint NOT NULL COMMENT '箱唛作业主键', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + order_id bigint DEFAULT NULL COMMENT '生成尾程订单时绑定的尾程订单ID', + source_inbound_detail_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源入库明细ID', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU编码快照', + carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内冻结箱唛', + destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + plan_id bigint DEFAULT NULL COMMENT '海外入库计划ID', + line_id bigint DEFAULT NULL COMMENT '入库计划明细ID', + plan_pallet_id bigint DEFAULT NULL COMMENT '海外计划托盘ID', + warehouse_pallet_id bigint DEFAULT NULL COMMENT '海外实物托盘ID', + warehouse_location_id bigint DEFAULT NULL COMMENT '海外库位ID', + pda_task_id bigint DEFAULT NULL COMMENT '首次扫码PDA任务ID', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', + version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + bound_at datetime(3) DEFAULT NULL COMMENT '绑定托盘时间', + received_at datetime(3) DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜次冻结箱唛及海外入库箱级作业账'; + +CREATE TABLE ll_inbound_plan_line ( + id bigint NOT NULL COMMENT '入库计划明细ID(雪花ID)', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户账号标识', + plan_id bigint NOT NULL COMMENT '入库计划ID', + order_id bigint NOT NULL COMMENT '尾程订单ID', + inbound_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '头程入仓码快照', + fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA仓库编码', + destination_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'OVERSEAS' COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' 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), + KEY idx_ll_inbound_line_customer_plan (customer_id,plan_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; + +CREATE TABLE ll_inbound_plan_pallet ( + id bigint NOT NULL COMMENT '计划托盘ID(雪花ID)', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户账号标识', + plan_id bigint NOT NULL COMMENT '入库计划ID', + pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Warehouse权威实物托盘码', + source_pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发不可变来源托盘号快照', + pallet_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '人工计划托盘类型', + destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + capacity_cbm decimal(18,4) NOT NULL DEFAULT '2.0000' COMMENT '托盘容量,固定为2立方米', + allocated_volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '已分配体积(立方米)', + sequence_no int NOT NULL COMMENT '计划内托盘序号', + version int NOT NULL DEFAULT '0' COMMENT '收货累计值乐观锁版本', + warehouse_pallet_id bigint DEFAULT NULL COMMENT '海外仓实物托盘ID', + warehouse_location_id bigint DEFAULT NULL COMMENT '海外仓库位ID', + warehouse_resource_lock_id bigint DEFAULT NULL COMMENT '海外仓资源锁ID', + assignment_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNASSIGNED' COMMENT '托盘分配状态:UNASSIGNED未分配及后续正式状态', + locked_at datetime(3) DEFAULT NULL COMMENT '资源锁定时间', + actual_volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '实收体积(立方米)', + actual_package_count int NOT NULL DEFAULT '0' COMMENT '实收箱数', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id,sequence_no), + KEY idx_ll_inbound_pallet_no (pallet_no), + KEY idx_ll_inbound_pallet_plan (plan_id), + KEY idx_ll_inbound_pallet_customer_plan (customer_id,plan_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; + +CREATE TABLE ll_inbound_plan_pallet_allocation ( + id bigint NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci 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 '该明细分配到托盘的体积(立方米)', + 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.0000' COMMENT '实收体积(立方米)', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_COUNT' COMMENT '分配作业状态:PENDING_COUNT待清点及后续正式状态', + 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_customer_plan (customer_id,plan_id), + KEY idx_ll_inbound_allocation_pallet (pallet_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; + +CREATE TABLE ll_inbound_stock_receipt ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号快照', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码快照', + product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名快照', + product_name_en varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '英文品名快照', + warehouse_pallet_id bigint NOT NULL COMMENT '仓库实物托盘ID', + pallet_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓实物托盘码', + source_pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发不可变来源托盘号快照', + warehouse_location_id bigint DEFAULT NULL COMMENT '仓库库位ID', + warehouse_location_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '仓库库位编码', + batch_no varchar(128) COLLATE utf8mb4_unicode_ci 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) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓PDA库存实收快照'; + +CREATE TABLE ll_inventory_balance ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + customer_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户名称不可变快照', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', + overseas_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓编码快照', + overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', + destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '目的仓编码快照', + destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + inbound_order_id bigint NOT NULL COMMENT '入仓单ID', + inbound_order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓单号快照', + inbound_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号快照', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNSPECIFIED' COMMENT 'SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度', + product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名快照', + product_name_en varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户入仓单与海外目的仓维度实时库存余额'; + +CREATE TABLE ll_inventory_ledger ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + customer_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户名称不可变快照', + balance_id bigint NOT NULL COMMENT '库存余额记录ID', + position_id bigint DEFAULT NULL COMMENT '库存仓位记录ID', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '目的仓编码快照', + inbound_order_id bigint NOT NULL COMMENT '入仓单ID', + pallet_id bigint DEFAULT NULL COMMENT '托盘ID', + pallet_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '托盘号', + batch_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '库存批次号', + warehouse_location_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '仓库库位编码', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU编码', + carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱唛', + carton_packages bigint DEFAULT NULL COMMENT '箱内箱数', + business_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务类型', + business_order_id bigint DEFAULT NULL COMMENT '业务单据ID', + business_order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '业务单号', + inbound_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓单号快照', + inbound_order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓单号快照', + outbound_order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '出库单号', + source_event_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源事件ID', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + status_from varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作前状态', + status_to varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作后状态', + before_packages bigint NOT NULL COMMENT '变动前箱数', + delta_packages bigint NOT NULL COMMENT '箱数变动量', + after_packages bigint NOT NULL COMMENT '变动后箱数', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='不可变库存变动流水'; + +CREATE TABLE ll_inventory_operation ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + operation_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型', + request_hash varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '库存命令处理状态', + result_json longtext COLLATE utf8mb4_unicode_ci COMMENT '处理结果JSON', + last_error varchar(2000) COLLATE utf8mb4_unicode_ci 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_ll_inventory_operation_key (customer_id,idempotency_key), + KEY idx_ll_inventory_operation_status (customer_id,status,updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='库存命令幂等记录'; + +CREATE TABLE ll_inventory_position ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + balance_id bigint NOT NULL COMMENT '库存余额记录ID', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '目的仓编码快照', + pallet_id bigint NOT NULL COMMENT '托盘ID', + pallet_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '托盘号', + batch_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '库存批次号', + warehouse_location_id bigint DEFAULT NULL COMMENT '仓库库位ID', + warehouse_location_code varchar(128) COLLATE utf8mb4_unicode_ci 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) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='托盘库位批次库存明细'; + +CREATE TABLE ll_loading_event_inbox ( + event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局唯一标识', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '租户/客户账号标识', + event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', + event_version int NOT NULL COMMENT '事件契约版本', + payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化事件负载 SHA-256 摘要', + payload_json json NOT NULL COMMENT '事件冻结负载 JSON', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + received_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at datetime(3) DEFAULT NULL COMMENT '处理完成时间', + last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次处理错误', + PRIMARY KEY (event_id), + UNIQUE KEY uk_ll_loading_inbox_tenant_key (customer_id,event_type,event_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程装柜完成事件接收幂等箱'; + +CREATE TABLE ll_loading_order_status_history ( + id bigint NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + order_id bigint NOT NULL COMMENT '尾程订单 ID', + from_status varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + to_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + event_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '触发事件 ID', + remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单状态变更历史'; + +CREATE TABLE ll_local_looseboard_history ( + id bigint NOT NULL COMMENT '主键ID', + order_id bigint NOT NULL COMMENT '本土散板订单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + operation_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', + from_status varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + to_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作幂等键', + change_detail json DEFAULT NULL COMMENT '字段变更前后值JSON', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板操作流水'; + +CREATE TABLE ll_local_looseboard_operation ( + id bigint NOT NULL COMMENT '操作记录ID', + order_id bigint NOT NULL COMMENT '本土散板订单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + operation_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作请求内容哈希', + result_version bigint NOT NULL COMMENT '操作完成后的订单版本', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单幂等操作账'; + +CREATE TABLE ll_local_looseboard_order ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID', + order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号', + transport_mode varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输方式:拼车或优先拼载', + pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '是否需要打盘:YES或NO', + transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型', + origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地', + origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址', + destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地', + destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的详细地址、码头或仓号', + container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', + package_count int NOT NULL DEFAULT '0' COMMENT '货物件数', + total_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '总重量(千克)', + total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)', + budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算总价', + currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '预算币种', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人姓名', + contact_info varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人联系方式', + requested_date date DEFAULT NULL COMMENT '要求服务日期', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求内容哈希', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单'; + +CREATE TABLE ll_monthly_exchange_rate ( + id bigint NOT NULL COMMENT '月度汇率ID', + rate_month char(7) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '汇率月份,格式yyyy-MM', + base_currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '基准币种', + quote_currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '报价币种', + usd_to_cny_average decimal(18,8) NOT NULL COMMENT '美元兑人民币月平均汇率', + status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + record_version bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + created_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人', + updated_by varchar(128) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美元兑人民币月度平均汇率'; + +CREATE TABLE ll_monthly_exchange_rate_history ( + id bigint NOT NULL COMMENT '汇率变更历史ID', + exchange_rate_id bigint NOT NULL COMMENT '月度汇率ID', + before_rate decimal(18,8) DEFAULT NULL COMMENT '变更前汇率', + after_rate decimal(18,8) NOT NULL COMMENT '变更后汇率', + before_status varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + after_status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='月度汇率变更历史'; + +CREATE TABLE ll_order ( + id bigint NOT NULL COMMENT '雪花 ID', + order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程单号', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '租户/客户账号标识', + order_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发', + lx_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发领星入库单代码', + destination_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + inbound_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空', + source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域/系统', + source_inbound_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', + root_inbound_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '根入仓单 ID', + source_order_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源订单 ID', + source_order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源订单号', + root_order_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '根订单 ID', + customer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货主名称', + source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源国内装柜仓 ID 快照', + source_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源国内装柜仓名称快照', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外接柜仓ID;新建尾程订单默认洛杉矶仓', + overseas_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外接柜仓业务编码快照', + overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外接柜仓名称快照', + receiving_warehouse_version int NOT NULL DEFAULT '0' COMMENT '柜级接柜仓分配乐观锁版本', + transfer_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '转入目的仓ID', + transfer_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '转入目的仓编码', + transfer_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '转入目的仓名称', + fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA 仓库编码', + container_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '集装箱号', + abnormal tinyint NOT NULL DEFAULT '0' COMMENT '是否异常:0否1是', + detained tinyint NOT NULL DEFAULT '0' COMMENT '是否暂扣:0否1是', + hold_chargeable tinyint NOT NULL DEFAULT '0' COMMENT '当前暂扣是否计费:0否1是', + hold_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前暂扣原因;解除后为空', + manual_node_override tinyint NOT NULL DEFAULT '0' COMMENT '是否启用单票人工计划节点覆盖:0否1是,启用后不再继承柜级节点', + version 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 '累计已拣箱数', + shipped_package_count int NOT NULL DEFAULT '0' COMMENT '累计已出库箱数', + volume_cbm decimal(18,4) DEFAULT NULL COMMENT '体积(立方米)', + product_quantity int DEFAULT NULL COMMENT '货物数量', + volume_weight_kg decimal(18,4) DEFAULT NULL COMMENT '体积重量(千克)', + estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到港时间', + actual_arrival_at datetime(3) DEFAULT NULL COMMENT '实际到港时间', + estimated_warehouse_at datetime(3) DEFAULT NULL COMMENT '预计到仓时间', + arrival_time_version int NOT NULL DEFAULT '0' COMMENT '柜级预计到仓时间乐观锁版本', + inbound_order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入库单号', + outbound_order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '出库单号', + delivery_order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送单号', + relabel_order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标单号', + relabel_status varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标状态', + pallet_merge_flag varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合托标记', + pallet_merge_batch_id varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '平台预标记合托分组号', + current_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态:PENDING_ASSIGNMENT待分配、PENDING_INBOUND待入库、INBOUND_COMPLETED入库完成、PENDING_DELIVERY待派送、DELIVERING派送中、DELIVERED派送完成', + sub_order_status varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '子单据状态汇总', + private_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注(仅自己)', + customer_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注(客户)', + warehouse_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注(仓库)', + source_event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源事件 ID', + source_loading_plan_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源装柜计划 ID', + source_container_booking_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源头程货柜预订ID', + source_loading_plan_version bigint DEFAULT NULL COMMENT '来源装柜计划版本', + source_handover_version bigint DEFAULT NULL COMMENT '交接版本', + source_payload_hash varchar(71) COLLATE utf8mb4_unicode_ci 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), + KEY idx_ll_order_container_progress (source_container_booking_id,estimated_warehouse_at,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单主表'; + +CREATE TABLE ll_order_command_record ( + id bigint NOT NULL COMMENT '尾程订单命令幂等记录ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + order_id bigint NOT NULL COMMENT '尾程订单ID', + action varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '命令动作:MANUAL_PLAN、HOLD、MANUAL_NODES', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台操作人ID', + idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户端幂等键', + request_hash varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化请求SHA-256摘要', + result_ref varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '结果业务引用', + result_json longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '首次成功响应JSON快照', + result_version int NOT NULL COMMENT '命令成功后的订单版本', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_order_command_key (customer_id,order_id,action,operator_id,idempotency_key), + KEY idx_ll_order_command_history (customer_id,order_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单写命令幂等与结果审计记录'; + +CREATE TABLE ll_order_detail ( + id bigint NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + order_id bigint NOT NULL COMMENT '尾程订单 ID', + source_inbound_detail_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源入仓明细 ID', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU 编码', + product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + product_name_en varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '英文品名', + package_count int NOT NULL DEFAULT '0' COMMENT '箱数', + product_quantity int DEFAULT NULL COMMENT '数量', + pallet_count int DEFAULT NULL COMMENT '托数', + gross_weight_kg decimal(18,4) DEFAULT NULL COMMENT '毛重(千克)', + net_weight_kg decimal(18,4) DEFAULT NULL COMMENT '净重(千克)', + dimensional_weight_kg decimal(18,4) DEFAULT NULL COMMENT '体积重量(千克)', + volume_cbm decimal(18,4) DEFAULT NULL COMMENT '体积(立方米)', + destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓 ID', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', + destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称', + fba_tracking_no varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA跟踪号', + shipment_no varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货件编号', + shipping_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单实装商品冻结明细'; + +CREATE TABLE ll_order_fee ( + id bigint NOT NULL COMMENT '费用明细主键', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + order_id bigint NOT NULL COMMENT '尾程订单ID', + fee_item varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目', + currency varchar(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '币种代码', + amount decimal(18,2) NOT NULL COMMENT '费用金额', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '费用状态:PENDING_REVIEW待审核、APPROVED审核通过、REJECTED已驳回', + supplier_description varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商或费用说明', + created_by varchar(128) COLLATE utf8mb4_unicode_ci 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_order_fee_customer_order (customer_id,order_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单费用明细'; + +CREATE TABLE ll_order_hold_history ( + id bigint NOT NULL COMMENT '尾程订单暂扣历史ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + order_id bigint NOT NULL COMMENT '尾程订单ID', + held tinyint NOT NULL COMMENT '操作后是否暂扣:0否1是', + chargeable tinyint NOT NULL COMMENT '本次暂扣是否计费:0否1是', + reason varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '暂扣或解除原因', + order_version int NOT NULL COMMENT '操作成功后的订单版本', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID', + operator_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人名称', + idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户端幂等键', + created_at datetime(3) NOT NULL COMMENT '操作时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_order_hold_key (customer_id,order_id,operator_id,idempotency_key), + KEY idx_ll_order_hold_history (customer_id,order_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单暂扣与解除不可变历史'; + +CREATE TABLE ll_order_manual_node ( + id bigint NOT NULL COMMENT '尾程订单人工节点ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + order_id bigint NOT NULL COMMENT '尾程订单ID', + node_code varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '八行人工展示节点稳定代码,与Carrier标准节点隔离', + node_at datetime(3) DEFAULT NULL COMMENT '人工填写节点时间,允许为空', + source varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'MANUAL' COMMENT '节点来源,固定MANUAL', + order_version int NOT NULL COMMENT '最近编辑后的订单版本', + updated_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最近操作人ID', + updated_at datetime(3) NOT NULL COMMENT '最近编辑时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_order_manual_node (customer_id,order_id,node_code), + KEY idx_ll_order_manual_node_order (customer_id,order_id,node_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单当前人工节点覆盖事实'; + +CREATE TABLE ll_order_manual_node_history ( + id bigint NOT NULL COMMENT '尾程订单人工节点历史ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + order_id bigint NOT NULL COMMENT '尾程订单ID', + nodes_json longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次全量人工节点JSON快照', + order_version int NOT NULL COMMENT '操作成功后的订单版本', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID', + operator_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人名称', + idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户端幂等键', + created_at datetime(3) NOT NULL COMMENT '操作时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_order_manual_node_history_key (customer_id,order_id,operator_id,idempotency_key), + KEY idx_ll_order_manual_node_history (customer_id,order_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单人工节点覆盖不可变历史'; + +CREATE TABLE ll_order_no_daily_sequence ( + business_date date NOT NULL COMMENT '业务日期(Asia/Shanghai)', + last_sequence int NOT NULL COMMENT '当日已分配尾程单流水号', + PRIMARY KEY (business_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水'; + +CREATE TABLE ll_order_operation_log ( + id bigint NOT NULL COMMENT '操作记录主键', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + order_id bigint NOT NULL COMMENT '尾程订单ID', + operation_node varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作节点', + operator_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人名称', + result varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '处理结果', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + KEY idx_ll_order_operation_customer_order (customer_id,order_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单操作记录'; + +CREATE TABLE ll_order_source_pallet ( + id bigint NOT NULL COMMENT '来源托盘快照ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + order_id bigint NOT NULL COMMENT '尾程订单ID', + source_pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '头程冻结托盘码', + sequence_no int NOT NULL COMMENT '订单内稳定顺序', + source_event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源装柜完成事件ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_source_pallet_no (source_pallet_no), + UNIQUE KEY uk_ll_source_pallet_sequence (customer_id,order_id,sequence_no), + KEY idx_ll_source_pallet_order (customer_id,order_id), + KEY fk_ll_source_pallet_order (order_id), + CONSTRAINT fk_ll_source_pallet_order FOREIGN KEY (order_id) REFERENCES ll_order (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程交接的不可变托盘码快照'; + +CREATE TABLE 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) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + to_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + reason varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更原因', + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间', + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单状态历史'; + +CREATE TABLE ll_outbound_order ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + outbound_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '出库单号', + dispatch_order_id bigint DEFAULT NULL COMMENT '派车单ID,一件代发为空', + dispatch_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派车单号,一件代发为空', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓ID', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', + package_count int NOT NULL DEFAULT '0' COMMENT '箱数', + pallet_count int NOT NULL DEFAULT '0' COMMENT '托盘数', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + outbound_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', + lx_codes varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联一件代发领星入库单代码汇总', + recipient_info varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发收件信息', + request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发创建请求摘要,其他出库类型为空', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_outbound_no (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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程出库单'; + +CREATE TABLE ll_outbound_pallet_item ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + outbound_order_id bigint NOT NULL COMMENT '出库单ID', + warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘ID', + pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '托盘号', + destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + package_count int NOT NULL DEFAULT '0' COMMENT '箱数', + picked_package_count int NOT NULL DEFAULT '0' COMMENT '已拣箱数', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发SKU', + order_refs varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联订单信息', + inbound_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT 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,inbound_code,sku_code), + KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), + KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出库托盘明细'; + +CREATE TABLE ll_outbound_pda_pick ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + task_id bigint NOT NULL COMMENT 'PDA任务ID', + outbound_pallet_item_id bigint NOT NULL COMMENT '出库托盘明细ID', + inventory_position_id bigint NOT NULL COMMENT '库存仓位ID', + packages bigint NOT NULL COMMENT '拣货箱数', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '拣货幂等键', + picked_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拣货时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_outbound_pda_pick_key (customer_id,idempotency_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA托盘拣货记录'; + +CREATE TABLE ll_outbound_pda_task ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + outbound_order_id bigint NOT NULL COMMENT '出库单ID', + task_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA任务号', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:待拣货、拣货中、已完成', + completion_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '完成命令幂等键', + pushed_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间', + completed_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_ll_outbound_pda_task_order (customer_id,outbound_order_id), + UNIQUE KEY uk_ll_outbound_pda_task_no (task_no), + UNIQUE KEY uk_ll_outbound_pda_complete_key (customer_id,completion_idempotency_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA拣货任务'; + +CREATE TABLE ll_overseas_procurement_fee ( + id bigint NOT NULL COMMENT '主键ID', + order_id bigint NOT NULL COMMENT '采购订单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', + fee_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用业务号', + fee_item_code varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项编码', + fee_item_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项名称', + amount decimal(20,2) NOT NULL COMMENT 'CNY金额', + currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种', + voucher_file_id bigint NOT NULL COMMENT '凭证文件ID', + voucher_file_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '凭证文件名', + remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '说明', + wallet_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金状态', + wallet_error_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误码', + wallet_error_message varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误说明', + wallet_command_event_id varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前资金指令事件ID', + created_by varchar(128) COLLATE utf8mb4_unicode_ci 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_ll_procurement_fee_no (fee_no), + KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at), + CONSTRAINT chk_ll_procurement_fee_amount CHECK ((amount > 0)), + CONSTRAINT chk_ll_procurement_fee_currency CHECK ((currency = _utf8mb4'CNY')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购费用明细及资金生命周期'; + +CREATE TABLE ll_overseas_procurement_file ( + id bigint NOT NULL COMMENT '主键ID', + order_id bigint NOT NULL COMMENT '采购订单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', + category varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT签收文件或VOUCHER费用凭证', + file_id bigint NOT NULL COMMENT 'File服务文件ID', + file_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件名快照', + content_type varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '媒体类型', + size_bytes bigint NOT NULL DEFAULT '0' COMMENT '文件大小', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上传人', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_procurement_file (order_id,file_id), + KEY idx_ll_procurement_file_order (customer_id,order_id,category,created_at), + CONSTRAINT chk_ll_procurement_file_category CHECK ((category in (_utf8mb4'RECEIPT',_utf8mb4'VOUCHER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购签收文件及费用凭证'; + +CREATE TABLE ll_overseas_procurement_order ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + customer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', + order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '订单号', + purchase_category varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采购品类', + product_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品名称', + product_specification varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品规格', + product_url varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品链接', + purchase_quantity bigint NOT NULL COMMENT '采购数量', + budget_unit_price decimal(18,2) DEFAULT NULL COMMENT '预算单价', + currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '币种', + receiving_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '收货地址', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人姓名', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人电话', + purchase_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '采购入仓仓库ID', + purchase_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '采购入仓仓库名称', + outbound_details json DEFAULT NULL COMMENT '出库明细', + inbound_quantity bigint NOT NULL DEFAULT '0' COMMENT '采购单据累计入库数量', + domestic_carrier varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '境内承运商', + domestic_tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '境内物流单号', + overseas_carrier varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外承运商', + overseas_tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外物流单号', + estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', + reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', + review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注', + review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键', + review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希', + reviewed_at datetime(3) DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购订单'; + +CREATE TABLE ll_overseas_procurement_status_history ( + id bigint NOT NULL COMMENT '主键ID', + order_id bigint NOT NULL COMMENT '订单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + operation_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', + from_status varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + to_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作幂等键', + change_detail json DEFAULT NULL COMMENT '字段变更前后值JSON', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购操作流水'; + +CREATE TABLE ll_overseas_quote ( + id bigint NOT NULL COMMENT '报价ID', + name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价名称', + warehouse_id bigint NOT NULL COMMENT '国内平台海外合作仓ID', + warehouse_code varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '合作仓编码快照', + warehouse_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '合作仓名称快照', + country_code varchar(2) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国家二字码快照', + provider_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价方', + currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '币种', + effective_start date NOT NULL COMMENT '生效开始日期', + effective_end date NOT NULL COMMENT '生效结束日期', + version_code varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务版本号', + status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'INACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + record_version bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + created_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人', + updated_by varchar(128) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外仓报价主表'; + +CREATE TABLE 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目', + billing_unit varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '计费单位', + unit_price decimal(18,4) NOT NULL COMMENT '单价', + minimum_charge decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '最低收费', + remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外仓报价仓库费用'; + +CREATE TABLE 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'FBA仓编码', + delivery_method varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价运输方式', + estimated_miles decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '预估英里数', + price decimal(18,4) NOT NULL COMMENT '报价金额', + minimum_charge decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '最低收费', + remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外仓报价中转价格明细'; + +CREATE TABLE ll_pda_inventory_move ( + id bigint NOT NULL COMMENT '移库操作ID', + move_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库操作单号', + overseas_warehouse_id bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + pallet_id bigint NOT NULL COMMENT '整托移库的实物托盘ID', + pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实物托盘码快照', + source_location_id bigint NOT NULL COMMENT '来源库位ID', + source_location_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源库位码快照', + target_location_id bigint NOT NULL COMMENT '目标库位ID', + target_location_code varchar(128) COLLATE utf8mb4_unicode_ci 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 DEFAULT NULL COMMENT '迁移后托盘版本', + operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作账号标识', + reason varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库原因', + idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库幂等键', + request_hash varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库请求哈希', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库处理状态', + result_json longtext COLLATE utf8mb4_unicode_ci COMMENT '成功响应JSON', + created_at datetime(3) NOT NULL COMMENT '创建时间', + completed_at datetime(3) DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA同仓整托移库记录'; + +CREATE TABLE ll_pda_operation_history ( + id bigint NOT NULL COMMENT 'PDA作业历史ID', + overseas_warehouse_id bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '作业涉及的客户标识', + operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作账号标识', + operation_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作业类型', + business_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作业业务键', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作业结果状态', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '写操作幂等键', + request_hash varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '写请求内容哈希', + summary varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA真实作业历史'; + +CREATE TABLE ll_pda_pallet_operation ( + id bigint NOT NULL COMMENT 'PDA托盘作业ID', + overseas_warehouse_id bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作账号标识', + idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '设备幂等键', + operation_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '托盘作业类型', + request_hash varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容摘要', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED', + result_json longtext COLLATE utf8mb4_unicode_ci COMMENT '成功响应快照', + remote_transitions_json longtext COLLATE utf8mb4_unicode_ci COMMENT '实物托盘状态变更回执', + last_error varchar(2000) COLLATE utf8mb4_unicode_ci 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_ll_pda_pallet_idempotency (operator_key,idempotency_key), + KEY idx_ll_pda_pallet_warehouse (overseas_warehouse_id,status,updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA托盘作业幂等、补偿与结果记录'; + +CREATE TABLE ll_pda_stocktake_action ( + id bigint NOT NULL COMMENT '盘点动作ID', + overseas_warehouse_id bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作账号标识', + idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作幂等键', + action_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点动作类型', + task_id bigint DEFAULT NULL COMMENT '盘点任务ID', + request_hash varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作请求哈希', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作处理状态', + result_json longtext COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA盘点动作幂等记录'; + +CREATE TABLE ll_pda_stocktake_item ( + id bigint NOT NULL COMMENT '盘点明细ID', + task_id bigint NOT NULL COMMENT '盘点任务ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户标识', + balance_id bigint NOT NULL COMMENT '库存余额ID', + position_id bigint NOT NULL COMMENT '库存位置ID', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU快照', + product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '品名快照', + inbound_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓码快照', + pallet_id bigint NOT NULL COMMENT '实物托盘ID', + pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实物托盘码快照', + location_id bigint NOT NULL COMMENT '库位ID快照', + location_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库位码快照', + book_packages bigint NOT NULL COMMENT '任务创建时账面总箱数', + actual_packages bigint DEFAULT NULL COMMENT '现场实盘总箱数', + difference_packages bigint DEFAULT NULL COMMENT '实盘与账面箱数差异', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点明细状态', + position_version bigint NOT NULL COMMENT '任务创建时库存位置版本', + counted_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '盘点操作账号标识', + version bigint NOT NULL DEFAULT '0' COMMENT '明细乐观锁版本', + counted_at datetime(3) DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点位置明细'; + +CREATE TABLE ll_pda_stocktake_task ( + id bigint NOT NULL COMMENT '盘点任务ID', + task_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点任务号', + overseas_warehouse_id bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + scope_type varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点范围类型LOCATION或PALLET', + scope_resource_id bigint NOT NULL COMMENT '盘点库位或托盘资源ID', + scope_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点库位码或托盘码快照', + status varchar(32) COLLATE utf8mb4_unicode_ci 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建操作账号标识', + submitted_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提交操作账号标识', + submit_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '盘点差异提交原因', + idempotency_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', + request_hash varchar(128) COLLATE utf8mb4_unicode_ci 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) DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点任务'; + +CREATE TABLE ll_procurement_daily_sequence ( + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', + business_date date NOT NULL COMMENT '北京时间业务日期', + sequence_value int NOT NULL COMMENT '客户当日已分配最大序号', + PRIMARY KEY (customer_id,business_date), + CONSTRAINT chk_ll_procurement_sequence CHECK ((sequence_value between 1 and 9999)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购客户日序列'; + +CREATE TABLE ll_receiving_container_operation ( + id bigint NOT NULL COMMENT '柜号收货操作ID', + session_id bigint NOT NULL COMMENT '柜号签收会话ID', + operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号唯一标识', + action varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作动作', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户端幂等键', + request_hash varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', + request_json longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容快照', + result_json longtext COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA柜号收货幂等审计记录'; + +CREATE TABLE ll_receiving_container_session ( + id bigint NOT NULL COMMENT '柜号签收会话ID', + source_container_booking_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定来源柜预订ID', + container_fact_hash varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '领取时整柜订单与计划事实摘要', + container_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '集装箱号', + overseas_warehouse_id bigint NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', + overseas_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外接柜仓业务编码快照', + overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照', + operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号唯一标识', + operator_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作账号名称', + active_operator_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + active_container_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '未完成会话柜号全局唯一锁键', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED、COMPLETED_WITH_DIFFERENCE', + sign_photos json DEFAULT NULL COMMENT '签收卸柜照片角色与文件ID列表', + prepared_at datetime(3) DEFAULT NULL COMMENT '选择柜号时间', + signed_at datetime(3) DEFAULT NULL COMMENT '签收时间', + receiving_at datetime(3) DEFAULT NULL COMMENT '首次收货时间', + completed_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 '更新时间', + 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 (container_no,status), + KEY idx_ll_receiving_operator (operator_key,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA柜号签收收货会话'; + +CREATE TABLE ll_receiving_fba_pallet_count ( + id bigint NOT NULL COMMENT 'FBA托盘现场累计箱数ID', + session_id bigint NOT NULL COMMENT '柜号签收会话ID', + group_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '柜号与FBA仓组成的收货组键', + pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '现有FBA托盘码', + actual_packages 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), + UNIQUE KEY uk_ll_receiving_fba_count (session_id,group_key,pallet_code), + KEY idx_ll_receiving_fba_group (session_id,group_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA普通FBA托盘现场累计与超收标记'; + +CREATE TABLE ll_receiving_sku_receipt ( + id bigint NOT NULL COMMENT 'FBA SKU实收进度ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓码快照', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码快照', + product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA柜号FBA SKU实收进度'; + +CREATE TABLE ll_receiving_warehouse_operation ( + id bigint NOT NULL COMMENT '柜级接柜仓修改操作ID', + source_container_booking_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定来源柜预订ID', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台请求幂等键', + request_hash varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化修改请求SHA-256摘要', + result_version int NOT NULL COMMENT '修改成功后的柜级分配版本', + operator_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台操作人名称', + remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '接柜仓修改备注', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_receiving_warehouse_idempotency (idempotency_key), + KEY idx_ll_receiving_warehouse_container (source_container_booking_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜级海外接柜仓修改幂等审计记录'; + +CREATE TABLE ll_relabel_application ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + relabel_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标申请单号', + lastleg_order_id bigint NOT NULL COMMENT '尾程订单ID', + customer_order_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户订单号', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', + label_deadline datetime(3) NOT NULL COMMENT '换标截止时间', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人姓名', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人电话', + delivery_method varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填', + print_shipping_label tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要打印面单', + shipping_label_file_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单文件ID', + shipping_label_file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单文件名', + shipping_label_file_url varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单下载地址', + fulfillment_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_RELABEL' COMMENT '换标后履约状态', + outbound_order_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '自提或快递生成的换标出库单号', + customer_confirmation_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户确认完成幂等键', + customer_confirmed_at datetime(3) DEFAULT NULL COMMENT '客户确认换标完成时间', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + total_file_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '总换标PDF文件ID', + total_file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '总换标PDF文件名', + total_file_url varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '总换标PDF预览地址', + carton_total_file_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱唛总文件ID', + carton_total_file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱唛总文件名', + carton_total_file_url varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱唛总文件地址', + sku_total_file_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU总文件ID', + sku_total_file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU总文件名', + sku_total_file_url varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU总文件地址', + status varchar(32) COLLATE utf8mb4_unicode_ci 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 '已通知箱数', + charge_amount decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '换标费用CNY', + charge_currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种', + wallet_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'FREEZE_PENDING' COMMENT '资金状态', + wallet_error_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误码', + wallet_error_message varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误说明', + wallet_command_event_id varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前资金指令事件ID', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户换标申请单'; + +CREATE TABLE ll_relabel_item ( + return_carton_id bigint DEFAULT NULL COMMENT '退货换箱唛来源退货库存箱ID', + source_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '退货换箱唛原箱唛快照', + new_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '退货换箱唛新箱唛', + id bigint NOT NULL COMMENT '主键ID', + application_id bigint NOT NULL COMMENT '申请单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU编码(选填)', + product_image_file_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品图片文件ID', + product_image_file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品图片文件名', + product_image_url varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品图片地址', + product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品中文名称', + total_package_count int NOT NULL COMMENT '总箱数', + notified_package_count int NOT NULL COMMENT '已通知箱数', + shipment_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货件编号(无FBA去向时为空)', + tracking_code varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '物流跟踪编码', + new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU编码', + label_requirement varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标要求', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货物明细备注', + relabel_file_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标文件ID', + relabel_file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标文件名', + relabel_file_url varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标文件地址', + carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户自有箱唛', + fba_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联FBA箱唛', + destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '该箱目的海外仓ID', + destination_fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA目的仓编码(与海外操作仓分离)', + operation_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CARTON_LABEL' COMMENT '换标模式:箱唛、SKU或组合换标', + sku_relabel_items_json json DEFAULT NULL COMMENT '同箱SKU换标子明细快照(数量按件)', + source_inventory_balance_id bigint DEFAULT NULL COMMENT '原库存余额ID(预入仓换标任务可为空)', + source_inventory_position_id bigint DEFAULT NULL COMMENT '原库存库位ID(预入仓换标任务可为空)', + source_inbound_order_id bigint DEFAULT NULL COMMENT '原入仓单ID(预入仓换标任务可为空)', + source_inbound_order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原入仓单号快照', + source_inbound_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原进仓编码快照', + source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原海外中转仓ID', + source_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原海外中转仓编码快照', + source_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT 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), + KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id), + KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='换标货物冻结明细'; + +CREATE TABLE 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + action varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '现场动作类型', + from_status varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '动作前任务状态', + to_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作后任务状态', + operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号标识', + operator_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作员名称快照', + detail_json longtext COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA换标任务不可变操作历史'; + +CREATE TABLE ll_relabel_pda_operation ( + id bigint NOT NULL COMMENT '换标PDA幂等操作ID', + task_id bigint NOT NULL COMMENT '换标PDA任务ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求操作账号标识', + action varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '幂等操作动作', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '设备生成的幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', + result_json longtext COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA换标写操作幂等记录'; + +CREATE TABLE ll_relabel_pda_task ( + id bigint NOT NULL COMMENT '换标PDA任务ID', + application_id bigint NOT NULL COMMENT '换标申请单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + relabel_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标申请单号快照', + overseas_warehouse_id bigint NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED', + operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '认领操作账号标识', + operator_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '认领操作员名称快照', + reviewer_key varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '复核账号标识', + reviewer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT NULL COMMENT '换标完成现场照片文件ID列表', + completion_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '完成提交备注', + review_decision varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近复核结论:APPROVE或REWORK', + review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近复核备注', + claimed_at datetime(3) NOT NULL COMMENT '任务认领时间', + submitted_at datetime(3) DEFAULT NULL COMMENT '提交复核时间', + reviewed_at datetime(3) DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA换标现场作业任务'; + +CREATE TABLE 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + old_label varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '扫描的实物旧标签或箱标', + new_label varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '扫描的换标后新标签', + result varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION', + exception_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '异常编码', + reason varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '跳过或异常原因', + operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号标识', + operator_name varchar(255) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA逐件换标扫码与异常事实'; + +CREATE TABLE ll_relabel_status_history ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + application_id bigint NOT NULL COMMENT '申请单ID', + from_status varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + to_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='换标状态变更历史'; + +CREATE TABLE ll_return_inventory_balance ( + id bigint NOT NULL COMMENT '退货库存余额ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + total_quantity bigint NOT NULL DEFAULT '0' COMMENT '实物总库存,仅实际出库扣减', + available_quantity bigint NOT NULL COMMENT '未合箱可用上架数量', + carton_quantity bigint NOT NULL COMMENT '包含该SKU的可用箱数', + created_at datetime(3) NOT NULL COMMENT '创建时间', + updated_at datetime(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_return_inventory_balance (customer_id,overseas_warehouse_id,sku_code), + CONSTRAINT chk_ll_return_inventory_balance_quantity CHECK (((total_quantity >= available_quantity) and (available_quantity >= 0) and (carton_quantity >= 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存按客户海外仓SKU汇总余额'; + +CREATE TABLE ll_return_inventory_carton ( + new_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后新箱唛', + id bigint NOT NULL COMMENT '退货库存箱ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', + source_receipt_id bigint DEFAULT NULL COMMENT '来源PDA签收单ID', + source_batch_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', + source_return_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源退货单号,多个单号逗号分隔', + carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码', + fba_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA箱唛', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', + destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称', + relabel_application_id bigint DEFAULT NULL COMMENT '绑定换标申请单ID', + relabel_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '绑定换标单号', + relabel_status varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标状态', + relabel_completed_at datetime(3) DEFAULT NULL COMMENT '换标完成时间', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', + sku_types int NOT NULL COMMENT '箱内SKU种类数', + total_quantity bigint NOT NULL COMMENT '箱内总件数', + length decimal(12,3) DEFAULT NULL COMMENT '箱长CM', + width decimal(12,3) DEFAULT NULL COMMENT '箱宽CM', + height decimal(12,3) DEFAULT NULL COMMENT '箱高CM', + weight decimal(12,3) DEFAULT NULL COMMENT '箱重KG', + created_at datetime(3) NOT NULL COMMENT '创建时间', + updated_at datetime(3) NOT NULL COMMENT '最后更新时间', + merged_at datetime(3) DEFAULT NULL COMMENT '合箱完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_return_inventory_carton_mark (carton_mark), + UNIQUE KEY uk_ll_return_inventory_carton_receipt (source_receipt_id), + KEY idx_ll_return_inventory_carton_customer (customer_id,overseas_warehouse_id,status,created_at), + CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'OUTBOUNDED')) and (sku_types > 0) and (total_quantity > 0)) or ((status = _utf8mb4'MERGED') and (sku_types = 0) and (total_quantity = 0)))), + CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物库存箱及箱唛'; + +CREATE TABLE ll_return_inventory_carton_item ( + id bigint NOT NULL COMMENT '退货库存箱SKU明细ID', + carton_id bigint NOT NULL COMMENT '退货库存箱ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + quantity bigint NOT NULL COMMENT '该SKU箱内数量', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_return_inventory_carton_item (carton_id,sku_code), + KEY idx_ll_return_inventory_carton_item_customer (customer_id,sku_code), + CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量'; + +CREATE TABLE ll_return_inventory_operation ( + id bigint NOT NULL COMMENT '退货库存PDA操作ID', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作人ID', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256', + operation_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型,当前MERGE', + result_carton_id bigint DEFAULT NULL COMMENT '合箱结果退货库存箱ID', + created_at datetime(3) NOT NULL COMMENT '创建时间', + updated_at datetime(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_return_inventory_operation_operator_key (operator_id,idempotency_key), + KEY idx_ll_return_inventory_operation_result (result_carton_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存PDA合箱幂等操作审计'; + +CREATE TABLE ll_return_order ( + id bigint NOT NULL COMMENT '退货订单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + return_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货订单号', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID', + warehouse_contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中转仓联系人快照', + warehouse_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中转仓联系电话快照', + customer_reference_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户参考号', + need_relabel tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要换标', + relabel_application_id bigint DEFAULT NULL COMMENT '绑定换标申请单ID', + relabel_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '绑定换标单号', + relabel_status varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标状态', + lastleg_order_id bigint DEFAULT NULL COMMENT '尾程订单ID', + lastleg_order_no varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '尾程订单号', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', + expected_sku_types int NOT NULL COMMENT '预报SKU种类数', + expected_quantity bigint NOT NULL COMMENT '预报总数量', + received_quantity bigint NOT NULL DEFAULT '0' COMMENT '累计实收总数量', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户备注', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求SHA-256', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建操作人ID', + created_at datetime(3) NOT NULL COMMENT '创建时间', + updated_at datetime(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_return_order_customer_no (customer_id,return_no), + UNIQUE KEY uk_ll_return_order_customer_key (customer_id,idempotency_key), + KEY idx_ll_return_order_warehouse_status (overseas_warehouse_id,status,created_at), + CONSTRAINT chk_ll_return_order_quantity CHECK (((expected_sku_types > 0) and (expected_quantity > 0) and (received_quantity >= 0) and (received_quantity <= expected_quantity))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户退货订单及SKU实收汇总'; + +CREATE TABLE ll_return_order_item ( + id bigint NOT NULL COMMENT '退货预报明细ID', + return_order_id bigint NOT NULL COMMENT '退货订单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + expected_quantity bigint NOT NULL COMMENT 'SKU预报数量', + received_quantity bigint NOT NULL DEFAULT '0' COMMENT 'SKU累计实收数量', + created_at datetime(3) NOT NULL COMMENT '创建时间', + updated_at datetime(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_return_item_order_sku (return_order_id,sku_code), + KEY idx_ll_return_item_sku (sku_code,received_quantity), + CONSTRAINT chk_ll_return_item_quantity CHECK (((expected_quantity > 0) and (received_quantity >= 0) and (received_quantity <= expected_quantity))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户退货订单的SKU预报与实收明细'; + +CREATE TABLE ll_return_receipt ( + id bigint NOT NULL COMMENT 'PDA退货签收单ID', + return_order_id bigint NOT NULL COMMENT '退货订单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + receipt_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号', + receipt_batch_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收批次号', + carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收生成或绑定的退货箱号', + tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收包裹的可选快递单号', + bound_by varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收状态,当前SIGNED', + received_quantity bigint NOT NULL COMMENT '本次实收数量', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作人ID', + operator_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA操作人名称', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA确认幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA确认请求SHA-256', + signed_at datetime(3) NOT NULL COMMENT '签收时间', + created_at datetime(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_return_receipt_no (receipt_no), + UNIQUE KEY uk_ll_return_receipt_batch (receipt_batch_no), + UNIQUE KEY uk_ll_return_receipt_operator_key (operator_id,idempotency_key), + KEY idx_ll_return_receipt_order (return_order_id,signed_at), + CONSTRAINT chk_ll_return_receipt_bound CHECK ((bound_by in (_utf8mb4'RETURN_NO',_utf8mb4'SKU'))), + CONSTRAINT chk_ll_return_receipt_quantity CHECK ((received_quantity > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货实收签收事实'; + +CREATE TABLE ll_return_receipt_batch ( + id bigint NOT NULL COMMENT '退货仓储计费批次ID', + return_order_id bigint NOT NULL COMMENT '退货订单ID', + receipt_id bigint NOT NULL COMMENT 'PDA退货签收单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + batch_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收批次号', + overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID', + storage_billing_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '仓储计费状态,初始PENDING_PRICING', + storage_started_at datetime(3) NOT NULL COMMENT '仓储计费起算时间', + created_at datetime(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_return_batch_no (batch_no), + UNIQUE KEY uk_ll_return_batch_receipt (receipt_id), + KEY idx_ll_return_batch_billing (customer_id,overseas_warehouse_id,storage_billing_status,storage_started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每次退货签收生成的仓储费起算批次'; + +CREATE TABLE ll_return_receipt_item ( + id bigint NOT NULL COMMENT '签收SKU明细ID', + receipt_id bigint NOT NULL COMMENT 'PDA退货签收单ID', + return_order_item_id bigint NOT NULL COMMENT '退货预报明细ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + received_quantity bigint NOT NULL COMMENT '本次SKU实收数量', + PRIMARY KEY (id), + KEY idx_ll_return_receipt_item_receipt (receipt_id), + KEY idx_ll_return_receipt_item_order_item (return_order_item_id), + CONSTRAINT chk_ll_return_receipt_item_quantity CHECK ((received_quantity > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货签收SKU实际数量明细'; + +CREATE TABLE ll_security_event_inbox ( + event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '跨区安全事件ID', + event_type varchar(96) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'IAM安全事件类型', + payload_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化载荷SHA-256哈希', + payload_json json NOT NULL COMMENT '冻结事件载荷JSON', + status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', + result varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', + received_at datetime(3) NOT NULL COMMENT '接收时间', + processed_at datetime(3) DEFAULT NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + KEY idx_security_inbox_type_time (event_type,received_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外IAM安全事件消费者收件箱'; + +CREATE TABLE ll_security_projection ( + aggregate_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', + aggregate_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户或客户聚合ID', + tenant_id bigint NOT NULL COMMENT '平台0或客户ID', + security_status varchar(24) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前安全状态:ACTIVE或DISABLED', + permission_version bigint DEFAULT NULL COMMENT '当前单调权限版本', + roles_json json NOT NULL COMMENT 'IAM投影的当前角色快照', + authorities_json json NOT NULL COMMENT 'IAM投影的当前海外权限快照', + aggregate_version bigint NOT NULL COMMENT '事件聚合单调版本', + payload_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '当前版本载荷哈希', + last_event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后应用的事件ID', + changed_at datetime(3) NOT NULL COMMENT '国内IAM事实发生时间', + updated_at datetime(3) NOT NULL COMMENT '投影更新时间', + PRIMARY KEY (aggregate_type,aggregate_id), + KEY idx_security_projection_tenant (tenant_id), + KEY idx_security_projection_permission (aggregate_id,permission_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外IAM安全状态与权限版本投影'; + +CREATE TABLE ll_us_truck_order ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '订单号', + transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型', + origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地', + origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地址', + destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地', + destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地址', + container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', + pallet_count int NOT NULL DEFAULT '1' COMMENT '客户申报托盘数', + package_count int DEFAULT 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.000' COMMENT '总重量(千克)', + total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)', + budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算金额', + currency varchar(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '币种', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人姓名', + contact_info varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人联系方式', + requested_date date NOT NULL COMMENT '要求服务日期', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '美国卡车订单状态', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车运输订单'; + +CREATE TABLE ll_us_truck_order_operation ( + id bigint NOT NULL COMMENT '操作记录ID', + order_id bigint NOT NULL COMMENT '美国卡车订单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + operation_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '具名操作类型', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作请求内容哈希', + result_version bigint NOT NULL COMMENT '操作完成后的订单版本', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车订单幂等操作账'; + +CREATE TABLE ll_us_truck_order_pallet ( + id bigint NOT NULL COMMENT '托盘记录ID', + order_id bigint NOT NULL COMMENT '美国卡车订单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务托盘号', + sequence_no int NOT NULL COMMENT '订单内托盘序号', + source varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '托盘来源:订单计划或PDA超收', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '托盘状态:待收、已收或超收待审核', + warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘资源ID', + package_count int DEFAULT NULL COMMENT '托盘箱数', + received_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_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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车订单托盘实收记录'; + +CREATE TABLE ll_us_truck_order_status_history ( + id bigint NOT NULL COMMENT '主键ID', + order_id bigint NOT NULL COMMENT '订单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + operation_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '具名操作类型', + from_status varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + to_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作幂等键', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车订单状态历史'; + +CREATE TABLE ll_vehicle ( + id bigint NOT NULL COMMENT '车辆档案ID', + plate_number varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车牌号', + model varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车型', + fuel_consumption_per100_km decimal(10,2) NOT NULL COMMENT '百公里油耗,单位升', + mileage_km bigint NOT NULL COMMENT '当前公里码,单位公里', + driver_or_dispatch_company varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机姓名或外派单位', + phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机或外派单位联系电话', + region varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '区域:DOMESTIC/OVERSEAS', + ownership varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车辆归属:OWNED/DISPATCHED', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '状态:IN_SERVICE/DISPATCHED/MAINTENANCE/INACTIVE/RETURNED', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + record_version bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + created_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人', + updated_by varchar(128) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台车辆主数据'; + +CREATE TABLE ll_wallet_event_inbox ( + event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内Finance资金结果事件ID', + command_event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原海外资金指令事件ID', + payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化载荷SHA-256,包含sha256:前缀', + payload_json json NOT NULL COMMENT '冻结事件信封', + status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIVED或PROCESSED', + received_at datetime(3) NOT NULL COMMENT '接收时间', + processed_at datetime(3) DEFAULT NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_ll_wallet_command_result (command_event_id), + KEY idx_ll_wallet_event_status (status,received_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区客户资金结果幂等收件箱'; + +CREATE TABLE ll_warehouse_transfer_carton ( + id bigint NOT NULL COMMENT '预报箱主键', + transfer_order_id bigint NOT NULL COMMENT '转运单ID', + transfer_item_id bigint NOT NULL COMMENT '转运申报明细ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + carton_mark varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统生成的一箱一码箱唛', + customer_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户原箱唛,用于换标现场绑定', + carton_index int NOT NULL COMMENT '全单箱序号', + total_cartons int NOT NULL COMMENT '首次生成时全单总箱数', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收', + 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_carton_mark (customer_id,carton_mark), + UNIQUE KEY uk_ll_wh_transfer_carton_index (transfer_order_id,carton_index), + KEY idx_ll_wh_transfer_carton_list (customer_id,transfer_order_id,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运系统箱唛预报'; + +CREATE TABLE ll_warehouse_transfer_inventory_allocation ( + id bigint NOT NULL COMMENT '雪花主键', + transfer_order_id bigint NOT NULL COMMENT '转仓单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源入仓单号快照', + pallet_id bigint NOT NULL COMMENT '来源托盘ID', + pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源托盘号快照', + package_count int NOT NULL COMMENT '分配箱数', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', + active_position_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '生效中的仓位排他键', + allocated_at datetime(3) NOT NULL COMMENT '分配时间', + shipped_at datetime(3) DEFAULT NULL COMMENT '出库时间', + received_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_ll_wh_transfer_allocation_position (transfer_order_id,inventory_position_id), + UNIQUE KEY uk_ll_wh_transfer_active_position (customer_id,active_position_key), + KEY idx_ll_wh_transfer_allocation_list (customer_id,transfer_order_id,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓单来源库存分配记录'; + +CREATE TABLE ll_warehouse_transfer_item ( + id bigint NOT NULL COMMENT '主键ID', + transfer_order_id bigint NOT NULL COMMENT '转仓单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品中文名称', + package_count int NOT NULL COMMENT '箱数', + shipping_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓货物冻结明细'; + +CREATE TABLE ll_warehouse_transfer_order ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + transfer_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '转仓单号', + inbound_order_id bigint NOT NULL COMMENT '入仓单ID', + inbound_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号快照', + lastleg_order_id bigint NOT NULL COMMENT '尾程订单ID', + lastleg_order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程订单号', + operating_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入库中转仓ID', + fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA目的仓编码', + delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '提货地址或中转仓收货地址', + package_count int NOT NULL COMMENT '箱数', + delivery_method varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓方式', + estimated_arrival_at datetime(3) NOT NULL COMMENT '预计入仓时间', + estimated_ship_at datetime(3) DEFAULT NULL COMMENT '预计发货时间', + fba_shipment_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA货件编号', + tracking_no varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '物流跟踪号', + fba_box_nos varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA箱号集合', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + need_relabel tinyint NOT NULL DEFAULT '0' COMMENT '是否需要换标', + relabel_application_id bigint DEFAULT NULL COMMENT '换标申请单ID', + relabel_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标申请单号', + relabel_completed_at datetime(3) DEFAULT NULL COMMENT '关联贴标完成时间', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', + idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + allocation_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '库存分配幂等键', + allocation_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '库存分配请求哈希', + outbound_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '出库幂等键', + outbound_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '出库请求哈希', + outbound_at datetime(3) DEFAULT NULL COMMENT '出库时间', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + label_deadline datetime(3) DEFAULT NULL COMMENT '换标截止时间', + relabel_contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标联系人', + relabel_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标联系电话', + relabel_charge_amount decimal(20,2) DEFAULT NULL COMMENT '换标费用CNY', + relabel_delivery_method varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后派送方式', + palletize_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA打托幂等键', + palletize_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA打托请求哈希', + confirmation_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户测量确认幂等键', + confirmation_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户测量确认请求哈希', + customer_confirmed_at datetime(3) DEFAULT NULL 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户转仓单'; + +CREATE TABLE ll_warehouse_transfer_pallet ( + id bigint NOT NULL COMMENT '打托事实ID', + transfer_order_id bigint NOT NULL COMMENT '转运单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + sequence_no int NOT NULL COMMENT '托序号', + warehouse_pallet_id bigint NOT NULL COMMENT '海外仓真实托盘ID', + pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '真实托盘码', + warehouse_location_id bigint NOT NULL COMMENT '真实库位ID', + warehouse_location_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实库位码', + warehouse_resource_lock_id bigint NOT NULL COMMENT '仓储资源锁ID', + carton_count int NOT NULL COMMENT '本托实收箱数', + length_cm decimal(12,2) NOT NULL COMMENT '实测长cm', + width_cm decimal(12,2) NOT NULL COMMENT '实测宽cm', + height_cm decimal(12,2) NOT NULL COMMENT '实测高cm', + gross_weight_kg decimal(12,3) NOT NULL COMMENT '实测毛重kg', + volume_cbm decimal(18,6) NOT NULL COMMENT '实测体积CBM', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '打托状态', + completed_at datetime(3) 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_transfer_pallet_seq (transfer_order_id,sequence_no), + UNIQUE KEY uk_ll_transfer_pallet_physical (customer_id,warehouse_pallet_id), + KEY idx_ll_transfer_pallet_order (customer_id,transfer_order_id,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运PDA实测打托事实'; + +CREATE TABLE ll_warehouse_transfer_pallet_item ( + id bigint NOT NULL COMMENT '主键ID', + transfer_order_id bigint NOT NULL COMMENT '转运单ID', + transfer_pallet_id bigint NOT NULL COMMENT '转运托盘事实ID', + transfer_item_id bigint NOT NULL COMMENT '转运申报明细ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + package_count int NOT NULL COMMENT '本托该SKU实收箱数', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_transfer_pallet_item (transfer_pallet_id,transfer_item_id), + KEY idx_ll_transfer_pallet_item_order (customer_id,transfer_order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运每托SKU箱数回填'; + +CREATE TABLE 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) COLLATE utf8mb4_unicode_ci 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '收货幂等键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '收货请求哈希', + inventory_operation_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存操作幂等键', + created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓来源仓位的目标库存收货记录'; + +CREATE TABLE ll_warehouse_transfer_relabel_binding ( + id bigint NOT NULL COMMENT '主键ID', + transfer_order_id bigint NOT NULL COMMENT '转运单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + operation_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标类型', + customer_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户原箱唛', + original_sku varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '旧SKU', + new_sku varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '新SKU', + quantity int NOT NULL COMMENT '换标件数', + fba_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '新FBA箱唛', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA目的仓', + remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_transfer_relabel_mark (transfer_order_id,customer_carton_mark), + KEY idx_ll_transfer_relabel_order (customer_id,transfer_order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运换标预绑定明细'; + +CREATE TABLE ll_warehouse_transfer_source_order ( + id bigint NOT NULL COMMENT '主键ID', + transfer_order_id bigint NOT NULL COMMENT '转仓单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + source_lastleg_order_id bigint NOT NULL COMMENT '来源尾程订单ID', + source_lastleg_order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源尾程订单号', + inbound_order_id bigint NOT NULL COMMENT '入仓单ID', + inbound_code varchar(128) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓来源历史订单绑定'; + +-- 服务 schema:baiyue_lastlag_warehouse +CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastlag_warehouse; + +CREATE TABLE 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '仓库分区编码', + location_code varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '仓库内唯一库位编码', + location_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库位类型', + max_load_kg decimal(18,3) NOT NULL COMMENT '最大承重(千克)', + status varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + remark varchar(500) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; + +CREATE TABLE 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '仓库内唯一托盘编码', + pallet_type varchar(32) COLLATE utf8mb4_unicode_ci 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) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; + +CREATE TABLE wh_pallet_batch ( + id bigint NOT NULL COMMENT '批量托盘预留ID', + warehouse_id bigint NOT NULL COMMENT '实际海外仓ID', + batch_key varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '柜级入库计划批次稳定业务键', + request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化批量请求SHA-256摘要', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '批次状态:RESERVED、CONFIRMED、COMPENSATED', + attempt_no int NOT NULL DEFAULT '1' COMMENT '同批次重新预留尝试次数', + confirm_command_key varchar(160) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '确认命令幂等键', + confirm_command_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '确认命令规范化SHA-256摘要', + compensate_command_key varchar(160) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '补偿命令幂等键', + compensate_command_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '补偿命令规范化SHA-256摘要', + compensate_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '补偿原因', + confirmed_at datetime(3) DEFAULT NULL COMMENT '确认绑定时间', + compensated_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_wh_pallet_batch_key (warehouse_id,batch_key), + KEY idx_wh_pallet_batch_status (warehouse_id,status,updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜级入库计划批量托盘预留与确认记录'; + +CREATE TABLE wh_pallet_batch_item ( + id bigint NOT NULL COMMENT '批量托盘明细ID', + batch_id bigint NOT NULL COMMENT '所属批量托盘预留ID', + customer_id bigint NOT NULL COMMENT '实际占用客户ID', + warehouse_id bigint NOT NULL COMMENT '实际海外仓ID', + business_key varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '柜号入库计划序号组成的稳定业务键', + inbound_plan_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入库计划号快照', + sequence_no int NOT NULL COMMENT '计划内托盘序号', + pallet_id bigint NOT NULL COMMENT '公共实物托盘ID', + pallet_code varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资源服务生成的规范托盘码快照', + location_id bigint NOT NULL COMMENT '公共托盘当前预留库位ID', + resource_lock_id bigint NOT NULL COMMENT '按客户隔离的资源锁ID', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '明细状态:RESERVED、CONFIRMED、RELEASED', + 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_batch_item_business (customer_id,warehouse_id,business_key), + UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), + KEY idx_wh_pallet_batch_item_pallet (pallet_id), + KEY idx_wh_pallet_batch_item_batch (batch_id,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜级入库计划批量托盘明细与资源锁关联'; + +CREATE TABLE 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务类型', + business_id varchar(128) COLLATE utf8mb4_unicode_ci 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), + KEY idx_wh_pallet_binding_pallet (pallet_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='托盘业务绑定关系'; + +CREATE TABLE wh_pallet_operation ( + id bigint NOT NULL COMMENT '托盘状态操作ID', + operation_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '跨服务幂等操作键', + request_hash varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '状态变更请求摘要', + warehouse_id bigint NOT NULL COMMENT '实际海外仓ID', + pallet_id bigint NOT NULL COMMENT '实物托盘ID', + pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实物托盘码快照', + before_status varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更前托盘状态', + after_status varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后托盘状态', + before_location_id bigint DEFAULT NULL COMMENT '变更前库位ID,无库位托盘为空', + after_location_id bigint DEFAULT NULL COMMENT '变更后库位ID,无库位托盘为空', + before_version int NOT NULL COMMENT '变更前托盘版本', + after_version int NOT NULL COMMENT '变更后托盘版本', + operation_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿', + reason varchar(500) COLLATE utf8mb4_unicode_ci 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_wh_pallet_operation_key (operation_key), + KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='实物托盘状态幂等变更与补偿记录'; + +CREATE TABLE 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务类型', + business_id varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务记录ID', + reservation_key varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资源预留幂等键', + status varchar(20) COLLATE utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储资源锁定记录'; + +-- 必要系统种子;不写入测试客户、演示订单、示例供应商、快递、托盘或库位。 + +USE baiyue_lastleg; + +-- 系统基线 ll_security_projection:2 行。 +INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('AUTHORIZATION','1000001',0,NULL,0,'[\"PLATFORM_ADMIN\"]','[\"platform:overseas-warehouse:warehouse-transfer:approve\", \"platform:finance:bill:adjust\", \"platform:finance:bill:update\", \"platform:finance:bill:void\", \"platform:finance:cost:create\", \"platform:finance:cost:view\", \"platform:finance:customer-account:adjust\", \"platform:finance:customer-account:export\", \"platform:finance:customer-account:list\", \"platform:finance:customer-arap:export\", \"platform:finance:customer-arap:list\", \"platform:finance:customer-statement:export\", \"platform:finance:customer-statement:list\", \"platform:finance:customer-terms:update\", \"platform:finance:customer-terms:view\", \"platform:finance:fee-source:edit\", \"platform:finance:fee-source:review\", \"platform:finance:payment:create\", \"platform:finance:receipt:create\", \"platform:finance:receipt:view\", \"platform:finance:receivable-statistics:export\", \"platform:finance:receivable-statistics:list\", \"platform:finance:recharge:review\", \"platform:finance:reconciliation:adjust\", \"platform:finance:reconciliation:create\", \"platform:finance:reconciliation:issue\", \"platform:finance:reconciliation:review\", \"platform:finance:reconciliation:void\", \"platform:finance:settlement-ap:create\", \"platform:finance:settlement-ap:export\", \"platform:finance:settlement-ap:list\", \"platform:finance:settlement-ar:create\", \"platform:finance:settlement-ar:export\", \"platform:finance:settlement-ar:list\", \"platform:finance:settlement:void\", \"platform:overseas-business:looseboard:create\", \"platform:overseas-business:looseboard:edit\", \"platform:overseas-business:looseboard:list\", \"platform:overseas-business:looseboard:status\", \"platform:overseas-business:purchase:edit\", \"platform:overseas-business:purchase:list\", \"platform:overseas-business:purchase:review\", \"platform:overseas-business:truck:create\", \"platform:overseas-business:truck:edit\", \"platform:overseas-business:truck:list\", \"platform:overseas-business:truck:status\", \"platform:overseas-master-data:exchange-rate:create\", \"platform:overseas-master-data:exchange-rate:edit\", \"platform:overseas-master-data:exchange-rate:export\", \"platform:overseas-master-data:exchange-rate:list\", \"platform:overseas-master-data:exchange-rate:status\", \"platform:overseas-master-data:quote:create\", \"platform:overseas-master-data:quote:edit\", \"platform:overseas-master-data:quote:export\", \"platform:overseas-master-data:quote:list\", \"platform:overseas-master-data:quote:status\", \"platform:overseas-master-data:vehicle:create\", \"platform:overseas-master-data:vehicle:edit\", \"platform:overseas-master-data:vehicle:export\", \"platform:overseas-master-data:vehicle:list\", \"platform:overseas-master-data:vehicle:status\", \"platform:overseas-warehouse:customer-visit:edit\", \"platform:overseas-warehouse:customer-visit:list\", \"platform:overseas-warehouse:delivery:documents\", \"platform:overseas-warehouse:delivery:list\", \"platform:overseas-warehouse:delivery:return\", \"platform:overseas-warehouse:delivery:status\", \"platform:overseas-warehouse:dispatch:cancel\", \"platform:overseas-warehouse:dispatch:edit\", \"platform:overseas-warehouse:dispatch:list\", \"platform:overseas-warehouse:dispatch:transfer-delivery\", \"platform:overseas-warehouse:exception-order:create\", \"platform:overseas-warehouse:exception-order:list\", \"platform:overseas-warehouse:exception-order:process\", \"platform:overseas-warehouse:inbound-order:create\", \"platform:overseas-warehouse:inbound-order:edit\", \"platform:overseas-warehouse:inbound-order:fba-appointment\", \"platform:overseas-warehouse:inbound-order:list\", \"platform:overseas-warehouse:inbound-order:smart-dispatch\", \"platform:overseas-warehouse:inventory:adjust\", \"platform:overseas-warehouse:inventory:edit\", \"platform:overseas-warehouse:inventory:export\", \"platform:overseas-warehouse:inventory:list\", \"platform:overseas-warehouse:last-mile-order:edit\", \"platform:overseas-warehouse:last-mile-order:export\", \"platform:overseas-warehouse:last-mile-order:list\", \"platform:overseas-warehouse:location:create\", \"platform:overseas-warehouse:location:delete\", \"platform:overseas-warehouse:location:edit\", \"platform:overseas-warehouse:location:list\", \"platform:overseas-warehouse:outbound:cancel\", \"platform:overseas-warehouse:outbound:create\", \"platform:overseas-warehouse:outbound:list\", \"platform:overseas-warehouse:pallet:create\", \"platform:overseas-warehouse:pallet:delete\", \"platform:overseas-warehouse:pallet:edit\", \"platform:overseas-warehouse:pallet:list\", \"platform:overseas-warehouse:relabel-order:create\", \"platform:overseas-warehouse:relabel-order:list\", \"platform:overseas-warehouse:relabel-order:status\", \"platform:overseas-warehouse:return-cartons:list\", \"platform:overseas-warehouse:return-inventory:list\", \"platform:overseas-warehouse:warehouse:create\", \"platform:overseas-warehouse:warehouse:edit\", \"platform:overseas-warehouse:warehouse:list\", \"platform:overseas-warehouse:warehouse:status\"]',0,'f45d2c4b22e46dd866045caf4c2050dafae456a58065367b06c180032df65c22','baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000',CURRENT_TIMESTAMP(3)); +INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('USER','1000001',0,'ACTIVE',0,'[]','[]',0,'c7522473b3b940c94397b23fa08bd8f3f56ca646e395314393853bd599c300c7','baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 137 AS initialized_tables; diff --git a/docker-compose-infra.yml b/docker-compose-infra.yml index b8cfe398c..5b4bc537e 100644 --- a/docker-compose-infra.yml +++ b/docker-compose-infra.yml @@ -1,9 +1,15 @@ -name: baiyue-infra +name: firstleg-test-infra + +x-bounded-logging: &bounded-logging + driver: ${DOCKER_LOG_DRIVER:-json-file} + options: + max-size: ${DOCKER_LOG_MAX_SIZE:-50m} + max-file: "${DOCKER_LOG_MAX_FILE:-3}" services: nacos: - image: nacos/nacos-server:v2.3.2 - container_name: baiyue-nacos + image: docker.m.daocloud.io/nacos/nacos-server:v2.3.2 + container_name: firstleg-test-nacos environment: MODE: standalone PREFER_HOST_MODE: hostname @@ -22,24 +28,24 @@ services: retries: 20 start_period: 30s restart: unless-stopped + logging: *bounded-logging networks: [baiyue] mysql: - image: mysql:8.0.39 - container_name: baiyue-mysql - profiles: [database] + image: docker.m.daocloud.io/library/mysql:8.0.39 + container_name: firstleg-test-mysql environment: TZ: Asia/Shanghai - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-change-me-before-use} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?请在.env中设置MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: ${MYSQL_DATABASE:-baiyue} MYSQL_USER: ${MYSQL_USER:-baiyue} - MYSQL_PASSWORD: ${MYSQL_PASSWORD:-change-me-before-use} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?请在.env中设置MYSQL_PASSWORD} command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_0900_ai_ci - --default-time-zone=+08:00 ports: ["3306:3306"] - volumes: [baiyue-mysql-data:/var/lib/mysql] + volumes: [firstleg-test-mysql-data:/var/lib/mysql] healthcheck: test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"] interval: 10s @@ -47,27 +53,32 @@ services: retries: 20 start_period: 30s restart: unless-stopped + logging: *bounded-logging networks: [baiyue] redis: - image: redis:7.2.5-alpine - container_name: baiyue-redis - profiles: [cache] - command: ["redis-server", "--appendonly", "yes"] + image: docker.m.daocloud.io/library/redis:7.2.5-alpine + container_name: firstleg-test-redis + command: ["sh", "-c", "exec redis-server --appendonly yes --requirepass \"$${REDIS_PASSWORD}\""] + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?请在.env中设置REDIS_PASSWORD} ports: ["6379:6379"] - volumes: [baiyue-redis-data:/data] + volumes: [firstleg-test-redis-data:/data] healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep PONG"] interval: 10s timeout: 5s retries: 10 restart: unless-stopped + logging: *bounded-logging networks: [baiyue] volumes: - baiyue-mysql-data: - baiyue-redis-data: + firstleg-test-mysql-data: + name: firstleg-test-mysql-data + firstleg-test-redis-data: + name: firstleg-test-redis-data networks: baiyue: - name: baiyue-network + name: ${INFRA_NETWORK:-firstleg-test-infra-network} diff --git a/docs/2026-07-31-P0-P2问题修复回归报告.md b/docs/2026-07-31-P0-P2问题修复回归报告.md new file mode 100644 index 000000000..8fb914b43 --- /dev/null +++ b/docs/2026-07-31-P0-P2问题修复回归报告.md @@ -0,0 +1,90 @@ +# P0-P2 问题修复回归报告 + +日期:2026-07-31 +正式仓库:`D:\baiyue-firstleg` +范围:IAM 成员授权、Order/Warehouse 主数据链路、File 资质存储、Gateway CORS、服务文件日志。 +测试边界:未连接或写入测试数据库,未启动业务服务,未占用业务端口。 + +## 1. 验证结论 + +本轮报告中的问题均属实,已完成代码、配置和自动化测试修复。目标模块自动化回归全部通过;测试环境是否达到最终业务验收,还需要发布 Nacos 配置、重新部署对应实例并执行真实接口联调,不能用单元测试结果替代环境验收。 + +## 2. P0 修复 + +### 2.1 成员越权授予角色 + +- 新增成员携带非空 `roleIds` 时,除 `merchant:member:create` 外还必须具有 `merchant:member:grant-role`。 +- 编辑成员时,仅当角色集合实际变化时要求 `merchant:member:grant-role`;空集合清空已有角色也属于角色变更。 +- 只编辑姓名、联系方式等资料且角色集合不变时,不额外要求授角权限。 +- 跨租户成员先按当前租户查询并返回 404,不在确认租户归属前读取角色关系。 + +### 2.2 订单草稿主数据链路 + +- `config/nacos/local/order-service.yml` 和 `config/nacos/cn/order-service.yml` 的 Warehouse URL 默认值统一为空。 +- 默认由 OpenFeign 使用注册名 `warehouse-service` 通过 Nacos 调用;只有隔离端口回归时才显式设置 `WAREHOUSE_SERVICE_URL`。 +- 修复了容器内默认访问 `127.0.0.1:9205`、实际回环到 Order 容器自身的问题。 +- Warehouse 内部接口统一返回:不存在或跨租户为 404,已停用为 409;Order 分别转换为业务不存在和业务冲突,下游 5xx、解码失败或内部令牌不一致转换为 `S0503`。 + +## 3. P1 修复 + +### 3.1 File 服务与资质文件 + +- File 启动时不仅检查目录属性,还会在存储根目录真实创建、写入并删除随机探针文件;挂载不可写时直接启动失败,不再出现实例看似存活、首次上传才报错。 +- `config/nacos/cn/file-service.yml` 删除当前代码未实现的 OSS 假配置,改为显式读取 `FILE_STORAGE_ROOT`。 +- 运行期文件写入异常保持返回 `S0503`,避免把磁盘或挂载故障包装成普通业务错误。 +- 历史资质迁移脚本生成的前向和回滚 SQL 不再自动 `COMMIT`;执行人员必须核对 `affected_rows=1` 和真实文件预览后显式提交,否则回滚。 +- Gateway 的 File 路由契约已通过测试确认指向 `lb://file-service`。 + +宿主机 bind mount 不会继承镜像内目录属主。发布前必须保证 File 上传目录和各服务日志目录由容器用户 UID 10001 可写;历史 `/phase1-seed/...` 记录必须在取得真实脱敏文件后按《企业资质文件迁移说明》迁移,不能只改 URL。 + +## 4. P2 修复 + +- Gateway 继续只允许明确配置的 CORS 来源,禁止通配符 `*`。 +- 精确覆盖 `OPTIONS /api/auth/login`、`Origin: http://localhost:5174`,预检无需 Token 并返回允许来源、方法和请求头。 +- 新增响应式请求标识过滤器,预检成功、拒绝来源和安全失败响应均携带 `X-Request-Id`。 +- 非白名单来源仍返回 403,不会因为放行 `OPTIONS` 而绕过 CORS 白名单。 + +## 5. 文件日志 + +Gateway、Auth、IAM、Firstleg、Order、Warehouse、File、Print、Integration、Event Bridge、Audit 共 11 个服务均在自己的 `src/main/resources/application.yml` 配置日志文件和滚动策略。 + +- 默认本地路径:`./logs/{spring.application.name}.log` +- 容器路径:由 `LOG_PATH=/app/logs` 覆盖,并使用现有 Docker Compose 宿主机挂载。 +- 默认单文件上限:100 MB +- 默认保留:30 天 +- 默认总容量上限:5 GB +- 所有值均可通过 `LOGGING_*` 环境变量覆盖。 + +## 6. 自动化回归 + +正式仓串行执行: + +```powershell +mvn -o "-Dmaven.repo.local=C:\Users\25137\.m2\repository" -pl iam,warehouse,order,file,gateway -am clean test +``` + +结果:11 个 Reactor 模块全部 `SUCCESS`,总耗时 59.223 秒。 + +| 目标模块 | 测试数 | 失败 | 错误 | 跳过 | +|---|---:|---:|---:|---:| +| Gateway | 9 | 0 | 0 | 0 | +| IAM | 64 | 0 | 0 | 0 | +| Order | 24 | 0 | 0 | 0 | +| Warehouse | 32 | 0 | 0 | 0 | +| File | 5 | 0 | 0 | 0 | +| 合计 | 134 | 0 | 0 | 0 | + +`git diff --check` 通过。测试只使用单元测试和随机端口测试上下文,没有启动固定业务端口,也没有访问数据库。 + +## 7. 测试环境最终验收清单 + +1. 发布更新后的 `local/cn` Nacos 配置并重新部署 Order、Warehouse、File、Gateway、IAM。 +2. 在宿主机预建 File 上传目录和 11 个日志目录,确认 UID 10001 可以创建和删除文件。 +3. 确认 Nacos 中只有当前版本实例,并验证 Gateway 到 `file-service`、`warehouse-service` 的负载均衡路由。 +4. 使用受限商户成员重放新增和编辑成员请求:携带角色但无授角权限必须返回 403;角色不变的资料编辑必须成功。 +5. 使用平台公共作用域 `0` 的启用仓库、启用往来单位创建头程草稿;再分别验证不存在、非平台公共作用域、停用和重复提交。 +6. 上传一张脱敏营业执照并完成商户、平台预览;不得继续使用 `/phase1-seed/...` 路径。 +7. 从 `http://localhost:5174` 直连 Gateway 验证登录预检、登录、401、403 和非白名单来源。 +8. 检查宿主机固定日志目录,确认每个服务产生独立日志文件并按策略滚动。 + +完成以上环境联调后,才能把本轮状态从“代码级回归通过”提升为“测试环境业务成熟验收通过”。 diff --git a/docs/2026-07-31后端缺口修复回归报告.md b/docs/2026-07-31后端缺口修复回归报告.md new file mode 100644 index 000000000..8f02a702f --- /dev/null +++ b/docs/2026-07-31后端缺口修复回归报告.md @@ -0,0 +1,109 @@ +# 后端缺口修复回归报告 + +日期:2026-07-31 +范围:Auth、IAM、Order、Firstleg、Warehouse、File 及其公共依赖 +测试边界:未连接或写入测试数据库,未启动业务服务,未占用现有服务端口。 + +## 1. 修复结论 + +### 1.1 订单权限与草稿稳定性 + +- 商户订单列表增加 `merchant:order:list`,详情增加 `merchant:order:view`。 +- 平台订单列表和详情统一增加已初始化的 `platform:order:list`。 +- 订单号改为基于雪花 ID 生成,消除原秒级时间加三位随机数在并发下重复的风险。 +- 仓库、往来单位返回空名称、错误仓库 ID、空地址元素或空能力元素时,转换为受控业务错误,不再进入未捕获空指针。 +- 地址和收货能力快照格式化增加空元素防护。 + +### 1.2 平台内部用户与权限 + +- 内部用户列表改为明确 DTO,返回 `membershipId`、`userId`、`authUserId`、登录名、姓名、电话、邮箱、成员编号、状态和 `roleIds`。 +- 列表增加 `platform:user:list` 服务端权限校验。 +- 保留原新增路径,兼容关联已有 Auth 账号,同时支持完整创建平台账号。 +- 新增编辑、启停和重置密码接口,分别校验 `platform:user:edit`、`platform:user:status`、`platform:user:reset-password`。 +- Auth 支持平台应用域账号、账号创建幂等、显示名同步、状态变更和密码重置。 +- IAM/Auth 创建、资料和状态写入增加补偿处理;成功写入后不再执行可能破坏一致性的远程查询。 +- 企业资料查询增加 `merchant:profile:view` 权限校验。 + +### 1.3 企业资质文件契约 + +- File multipart 接口成为唯一公开资质上传入口。 +- 删除 IAM 旧 JSON POST 路由、DTO、Service 固定冲突方法以及 OpenAPI 成功声明。 +- 初始化 SQL 不再生成 `/phase1-seed/...` 虚假已审核资质。 +- 新增 `企业资质文件迁移说明.md`,要求取得真实原文件、写入 File 存储并校验后,才能事务更新旧记录 URL。 + +### 1.4 订单持久化幂等与错误语义 + +- 创建草稿和提交订单强制接收 `Idempotency-Key`,唯一范围为租户、操作者、操作和幂等键。 +- 请求摘要包含资源与请求内容;同键同请求回放首次结果,同键不同请求或处理中请求返回 409。 +- 幂等记录、业务写入和完成状态位于同一本地事务,数据库唯一键负责并发互斥。 +- 不存在订单优先返回 404;Warehouse 下游异常、空响应和无效主数据转换为受控 404、403、422 或 503。 + +### 1.5 Firstleg 与 Warehouse 管理闭环 + +- Firstleg 增加任务分页、详情、按订单查询、分配历史和分配/改派权限,所有仓储查询均带目标租户。 +- `idx_task_tenant_created` 同时写入新库建表脚本和可重复执行的既有库增量迁移。 +- Warehouse 增加往来单位、快递公司分页、详情、新增、编辑和启停;该报告产生时曾按客户作用域实现,现已于 2026-08-05 统一改为平台公共作用域 `0`,停用仍保留历史主数据。 +- IAM 同步新增六个 Warehouse 按钮权限及四个 Firstleg 权限,并保持平台管理员、订单运营角色授权一致。 + +## 2. 自动化回归 + +独立总审执行命令: + +```powershell +mvn -pl iam,warehouse,order,firstleg -am clean test +``` + +结果:在完整纯 ASCII 临时构建副本中执行,10 个 Reactor 模块全部构建成功。副本文件数与实施工作树一致;未连接数据库、未启动服务。正式仓同步后还会再次执行同一 `clean test`。 + +| 模块 | 测试数 | 结果 | +|---|---:|---| +| common-core | 2 | 通过 | +| common-web | 4 | 通过 | +| common-security | 6 | 通过 | +| auth-service | 10 | 通过 | +| iam-service | 48 | 通过 | +| order-service | 14 | 通过 | +| firstleg-service | 6 | 通过 | +| warehouse-service | 23 | 通过 | + +重点新增覆盖:订单持久化幂等、404/409/503 语义、平台用户完整生命周期、权限初始化、企业分页、Firstleg 租户过滤与权限、Warehouse CRUD/唯一性/Schema 契约,以及 OpenAPI YAML 解析。 + +静态检查:`git diff --check` 通过;仅存在工作树既有的 CRLF/LF 转换提示,不属于补丁空白错误。 + +## 3. 仍需环境联调确认 + +- 历史两次订单 `S0500` 的完整异常栈仍未取得,因此不能声称已还原历史唯一根因;本次已消除代码中能够确定的编号碰撞和空数据未受控风险。 +- 测试库旧 `/phase1-seed/...` 记录没有原文件,不能安全自动改成随机 UUID。本次没有写测试库,需取得真实营业执照文件后按迁移说明处理。 +- 本地 Warehouse 五张表与 Entity/SQL 的只读核验一致;历史查询 500 没有异常栈,不能把其唯一根因归结为 Schema 漂移。 +- `PENDING`、`REJECTED` 资质样本仍需由测试负责人准备脱敏数据后,完成浏览器五状态验收。 +- Dashboard 数量统计、并发请求隔离、示例 Store 和前端强制授予 `dashboard:view` 属于前端问题,不在后端增加虚假兼容逻辑。 + +## 4. 索引同步 + +- 已更新 `order/AGENTS.md`、`iam/AGENTS.md`、`auth/AGENTS.md`、`file/AGENTS.md`。 +- 新增平台内部用户 DTO、Service 和测试入口后,已同步更新根目录 `AGENTS.md`。 +- 企业资质迁移说明和本报告均为中文文档。 +- 已更新 `warehouse/AGENTS.md`、`firstleg/AGENTS.md` 和根 `AGENTS.md`,并在中文 API 文档补充 IAM、订单幂等、Firstleg 与 Warehouse 管理契约。 +- 正式仓同步与最终测试结果在同步完成后写入本报告末尾。 + +## 5. 正式仓同步与最终复核 + +- 审核通过的文件已按显式文件清单安全同步至 `D:\baiyue-firstleg`;未使用目录覆盖、`reset` 或清理操作,正式仓原有未提交修改已保留。 +- 正式仓已于 2026-07-31 14:52:33 执行并通过: + +```powershell +mvn "-DargLine=-Djdk.attach.allowAttachSelf=true" -pl iam,warehouse,order,firstleg -am clean test +``` + + 共 10 个 Reactor 模块构建成功;该 JVM 参数仅规避本机 Mockito 动态代理加载异常缓慢,不改变业务配置。 +- 正式仓 `git diff --check` 通过。本轮未写数据库、未启动业务服务、未占用业务端口。 + +### 5.1 未提交 SQL 执行清单(尚未执行) + +1. `order/src/main/resources/db/order.sql`:创建 `ord_idempotency_record`。 +2. `iam/src/main/resources/db/phase1-admin-rbac.sql`:补齐 Firstleg、Warehouse 权限和角色授权。 +3. `warehouse/src/main/resources/db/warehouse-express-company.sql`:创建快递公司表。 +4. `firstleg/src/main/resources/db/firstleg.sql`:新库基础结构。 +5. `firstleg/src/main/resources/db/phase1-firstleg-task-list-index.sql`:对既有 Firstleg 库可重复执行,补齐 `fl_fulfillment_task.idx_task_tenant_created`;新库已在基础建表脚本中包含该索引。 + +如后续获得明确数据库写入授权,应先开发库、校验后测试库,并逐项保留执行结果;本报告不构成实际写库记录。 diff --git a/docs/2026-07-31平台管理与头程履约补齐回归报告.md b/docs/2026-07-31平台管理与头程履约补齐回归报告.md new file mode 100644 index 000000000..4810f7697 --- /dev/null +++ b/docs/2026-07-31平台管理与头程履约补齐回归报告.md @@ -0,0 +1,95 @@ +# 平台管理与头程履约补齐回归报告 + +日期:2026-07-31 +范围:IAM、Warehouse、Order、Firstleg、File 及共享依赖 +边界:只连接本机开发数据库;已执行项目内可重复迁移和可回收联调数据,未连接远程 test 数据库。服务仅使用 `19100/19101/19104/19105/19106` 测试专用端口,回归结束后全部停止,不占用日常 `9000/920x` 端口。 + +## 一、问题核验结论 + +前端报告中的后端缺口基本属实:平台客户档案、平台快递公司、平台往来单位、权限资源维护、平台代客订单写入和履约任务查询此前均不完整。平台权限快照虽然在说明中出现,但 Controller 和 OpenAPI 曾发生漂移,导致登录后权限初始化存在失败风险。 + +本轮已补齐: + +- 当前用户有效权限查询,不依赖角色列表权限,按有效成员、有效角色和有效权限实时计算。 +- 平台客户档案分页、详情、编辑、启停,并阻止通过启停接口绕过企业审核。 +- 平台内部用户的成员主键和角色回显,以及新增、编辑、启停、重置密码、角色分配闭环。 +- 权限资源编辑、启停、删除;系统资源、存在子节点或已绑定角色的资源禁止删除。 +- 历史实现曾要求平台快递公司和往来单位的所有读写携带目标租户;该口径已于 2026-08-05 废止,两类主数据现统一归属平台作用域 `0`。 +- 平台代客创建、编辑、提交头程订单;商户端和平台端创建、提交均消费 `Idempotency-Key`。 +- 订单不存在统一返回 HTTP 404;Warehouse 下游不可用统一映射为受控 503。 +- Firstleg 履约任务列表、详情、按订单查询、分配历史和分页查询。 +- File 企业资质上传改为流式落盘,服务启动时校验存储根目录;跨平台默认目录不再硬编码 Windows 路径,磁盘故障统一返回 `S0503` 并记录底层异常。 +- `Long/long` 由 `common-web` 统一序列化为 JSON 字符串,请求继续接受字符串 ID;`common-openapi` 同步把长整型声明为字符串,避免 19 位雪花 ID 被浏览器截断。 + +## 二、本地数据库迁移与核验 + +使用各服务 `application-dev.yml` 的本地连接配置,先读取 `information_schema`,再按第四节顺序执行可重复迁移,最后重新核验关键字段和索引。MySQL 版本为 `8.4.11`。 + +| 数据库 | 关键表结果 | 当前数据量 | +|---|---|---| +| `baiyue_iam` | 成员、角色、权限及关联表均存在;`iam_permission.built_in` 已迁移 | 成员 22、角色 15、权限 115、租户 17 | +| `baiyue_warehouse` | 仓库、地址、能力、往来单位、快递公司表及必需字段均存在 | 仓库 1、地址 1、能力 1、往来单位 1、启用快递公司 1 | +| `baiyue_order` | 订单快照字段、快递字段和幂等记录表均已迁移 | 原有订单 5;回归订单已精确清理 | +| `baiyue_firstleg` | 履约任务表和分配历史表均存在 | 两表均为 0 | + +本地 File 存储目录 `D:\baiyue-firstleg-runtime\uploads` 已存在租户 `10001` 的 UUID 资质图片。使用商户 Token 请求 `/api/merchant/v1/files/qualifications/a1d6caa0-14df-4988-831c-d1a8f5d142ff.png` 返回 HTTP 200、`image/png`、47772 字节。旧 `/phase1-seed/demo-business-license.jpg` 记录状态已是 `SUPERSEDED`,不再作为当前有效资质。 + +关键索引只读核验结果:订单幂等唯一索引、Firstleg 客户隔离任务索引、往来单位和快递公司的兼容作用域编码唯一索引均已存在;后两者当前固定使用平台公共作用域 `0`。 + +迁移实测发现 MySQL 不支持 `ADD COLUMN IF NOT EXISTS`,IAM 与 Warehouse 增量脚本已改为 `information_schema + 动态 SQL` 的可重复写法,并在本地成功执行。远程测试环境部署时仍必须核对迁移版本;健康检查成功不能证明业务表结构已同步。 + +## 三、自动化回归结果 + +为避免多窗口并行构建清理同一 `target`,先安装共享模块快照,再隔离执行四个业务模块的 `clean test`。 + +| 模块 | 测试数 | 结果 | +|---|---:|---| +| 共享模块 | 16 | 通过 | +| IAM | 58 | 通过 | +| Order | 18 | 通过 | +| Firstleg | 6 | 通过 | +| Warehouse | 28 | 通过 | +| File | 5 | 通过 | +| 全仓合计 | 169 | 全部通过,0 失败、0 错误、0 跳过 | + +业务模块最终命令: + +```powershell +mvn -o "-Dmaven.repo.local=C:\Users\25137\.m2\repository" clean test +``` + +最终结果:19 个 Reactor 模块全部构建成功,169 项测试无失败、无错误、无跳过;`git diff --check` 通过。 + +## 四、真实接口回归 + +本地启动 Auth、IAM、Order、Warehouse、File,统一连接本机数据库和 Redis,并使用测试专用端口完成以下验证: + +- 商户端、平台端统一登录均成功;平台权限快照返回 61 项权限,不依赖角色列表权限。 +- 平台客户分页返回 16 条;内部用户返回 `membershipId` 和 `roleIds`,ID 的 JSON 类型为字符串。 +- 商户仓库、往来单位候选、平台仓库、平台往来单位、平台快递公司均返回真实本地数据;19 位仓库 ID 保持 `190000000000000001`,未发生精度截断。 +- 平台往来单位和快递公司均完成新增、编辑、停用、详情回读;该历史回归当时按客户作用域清理,当前两类主数据已统一归平台公共作用域 `0`。 +- 商户头程订单完成创建草稿、相同 `Idempotency-Key` 重放返回同一订单 ID、提交、相同提交键重放、详情读取和取消;测试订单及子表、Outbox、幂等记录随后精确清理。 +- 不存在订单返回 HTTP 404;Order OpenAPI 已声明必填 `Idempotency-Key`,`OrderEntity.id` 类型为 `string`,平台提交路径正确。 +- 有效资质文件鉴权预览返回 HTTP 200,文件内容与元数据路径一致。 + +服务启动期间 Nacos 本地配置读取出现一次 `MalformedInputException`,但五个服务均正常启动且健康检查为 `UP`。这是本地 Nacos 配置内容编码问题,不影响本轮源码与数据库结论;应单独把 Nacos YAML 统一保存为 UTF-8。 + +## 五、SQL 脚本位置与执行顺序 + +脚本均放在所属服务的 `src/main/resources/db/`,没有放入临时目录,也没有自动写入本地数据库。 + +1. IAM 已有库:依次执行 `phase1-admin-rbac.sql`、`rbac-role-management-v2.sql`、`phase1-platform-master-data-and-permission-management.sql`。 +2. Warehouse 新库:依次执行 `warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`warehouse-seed.sql`。 +3. Warehouse 已有库:在基础表脚本后执行 `phase1-warehouse-schema-migration.sql`,再按需执行种子脚本。 +4. Order 新库执行 `order.sql`;已有库执行 `phase1-order-extension.sql`。 +5. Firstleg 新库执行 `firstleg.sql`;已有库执行 `phase1-firstleg-task-list-index.sql`。 + +File 本轮不涉及数据库迁移脚本。非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;Docker 部署保持 `/data/files` 卷挂载。 + +以上脚本已在本机开发库按顺序成功执行。其他环境仍需按发布流程独立执行并记录结果,不得以本地执行结果代替环境迁移记录。 + +## 六、仍需环境联调 + +- 部署后的 Gateway 仍需做浏览器级跨租户 403、停用账号、停用权限即时生效及移动端验收。 +- PENDING/REJECTED 企业、普通成员和自定义角色需要在专用测试环境准备可回收样本,完成五状态浏览器验收。 +- 远程 test 环境需按第五节执行迁移并部署同一构建版本;本轮没有连接或写入远程 test 数据库。 diff --git a/docs/2026-08-04-无状态PDF打印服务设计.md b/docs/2026-08-04-无状态PDF打印服务设计.md new file mode 100644 index 000000000..691081deb --- /dev/null +++ b/docs/2026-08-04-无状态PDF打印服务设计.md @@ -0,0 +1,417 @@ +# 无状态 PDF 打印服务设计 + +> 日期:2026-08-04 +> 状态:待评审 +> 适用模块:`print-service` +> 首版模板:`INBOUND_MARK_V1` +> 参考文件:`PCHK2608040174-19件-MCC1.pdf` 及同名标签截图 + +## 1. 结论 + +`print-service` 调整为无状态 PDF 渲染服务:调用方传入完整的 `labels[]`,服务校验后在内存中生成一个多页 PDF,并在同一次 HTTP 响应中返回 PDF 二进制。 + +首版边界如下: + +- 不连接数据库,不保存模板、任务、历史或文件。 +- 不调用 Firstleg、Order、File、IAM 等业务服务,不反查或修正业务数据。 +- 不连接打印机,不处理驱动、打印队列、重试、补打或设备状态。 +- 不上传 File 服务,不写本地临时文件,不提供二次下载地址。 +- 一次请求生成一个 PDF;`labels[]` 中一个元素对应一页,数组顺序就是 PDF 页序。 +- 每页默认且首版只支持 `100mm x 100mm`,所有内容水平居中。 +- 最多支持 2000 页;超限时整次请求失败,不生成部分结果。 +- 成功直接返回 `application/pdf`;失败返回统一 JSON 错误响应。 + +现有 `print_template`、`print_job`、`print_job_history` 数据库草案不再属于本方案。实施时应移除 Print 模块的数据源、MyBatis、MySQL 依赖及环境配置,数据库脚本不进入部署流程。 + +## 2. 需求依据 + +### 2.1 已确认规则 + +1. 调用方传入完整 `labels[]`,Print 不自行按箱数扩展页面。 +2. `CTNS:218-1` 表示总计 218 箱中的第 1 箱。 +3. 二维码内容由调用方生成,例如 `PCHK2607211476#218-1`。 +4. 入仓码只在页面顶部展示一次;`MADE IN CHINA` 是固定文案。 +5. 仓库、渠道等字段由调用方传最终展示文案,Print 不维护代码映射。 +6. 首版不处理普通展示文字的长度适配;调用方保证文案合理,Print 使用固定字号直接居中绘制。 +7. 页面尺寸作为请求参数显式传入,首版只接受 `100mm x 100mm`。 +8. PDF 仅临时使用;需要再次下载时由调用方重新生成。 +9. 单次最多约 2000 箱,即最多 2000 页。 +10. 调用方对页面数据的真实性、页序、唯一性和业务权限负责。 +11. 中文字体随服务打包并嵌入 PDF,不依赖容器或宿主机字体。 +12. 首版只实现固定模板,不建设模板管理系统。 + +### 2.2 参考 PDF 核验结果 + +参考 PDF 共 19 页: + +- 每页 MediaBox 为 `282.96pt x 282.96pt`,约 `99.822mm x 99.822mm`。 +- 页码内容从 `CTNS:19-1` 连续变化到 `CTNS:19-19`。 +- 每页依次展示顶部入仓码、仓库文案、箱号文案、渠道文案、二维码、二维码明文和固定产地文案。 +- 参考文件使用 Microsoft YaHei 子集字体。 + +本服务以明确的 `100mm x 100mm` 契约为准,输出 MediaBox 使用 `mm * 72 / 25.4` 换算,即约 `283.465pt x 283.465pt`,不复制参考文件约 `0.178mm` 的尺寸误差。 + +## 3. 架构与调用边界 + +### 3.1 推荐调用方式 + +Admin 浏览器不直接调用 Print。公开业务接口由 Firstleg 或其他业务归属服务提供;业务服务完成登录态、按钮权限、租户、企业、仓库和业务数据校验,组装最终 `labels[]` 后,通过服务发现调用 Print 内部接口,再把 PDF 响应透传给浏览器。 + +```mermaid +sequenceDiagram + participant A as "Admin 浏览器" + participant B as "业务归属服务" + participant P as "Print Service" + + A->>B: 请求生成业务标签 PDF + B->>B: 校验 JWT、权限和数据范围 + B->>B: 组装完整 labels[] + B->>P: 内部令牌 + PDF 请求 + P->>P: 全量校验并在内存渲染 + P-->>B: application/pdf + B-->>A: application/pdf +``` + +采用内部调用的原因: + +- Print 不持有业务数据,无法判断浏览器提交的入仓码、仓库或箱数是否真实。 +- 若直接暴露给 Admin,Print 必须复制业务权限和数据范围规则,破坏服务边界。 +- 内部接口能避免任意已登录用户把 Print 当作通用 PDF/二维码生成器滥用。 +- 各业务服务可以复用同一个 Print 渲染能力,同时继续拥有自己的公开接口和权限码。 + +### 3.2 网络与鉴权 + +- 内部接口路径不加入 Gateway 公网路由。 +- 调用方通过 Nacos 服务发现访问 `print-service`。 +- 请求必须携带共享的 `X-Internal-Service-Token`。 +- Print 使用与现有内部接口一致的 `INTERNAL_SERVICE_TOKEN` 配置,令牌至少 32 字节。 +- 令牌缺失或不匹配返回 `A0403`,不得在日志中打印令牌。 +- Print 不接受浏览器 JWT,也不新增 Print 业务权限码;权限由调用方业务服务校验。 + +## 4. 接口契约 + +### 4.1 生成接口 + +```http +POST /internal/print/v1/pdfs/inbound-marks +Content-Type: application/json +Accept: application/pdf, application/json +X-Internal-Service-Token: *** +X-Request-Id: 可选 +``` + +`Accept` 必须同时列出两种类型:成功响应是 PDF,失败响应是 JSON `ApiResponse`。若只声明 `application/pdf`,Spring MVC 在异常解析阶段重新做内容协商时会因为无法接受 `application/json` 而返回 406 空响应体,调用方拿不到本设计约定的错误码。原因与实现约束见 4.4。 + +请求示例: + +```json +{ + "templateCode": "INBOUND_MARK_V1", + "pageSpec": { + "widthMm": 100, + "heightMm": 100 + }, + "downloadFileName": "PCHK2608040174-19件-MCC1.pdf", + "labels": [ + { + "topCode": "PCHK2608040174", + "warehouseText": "MCC1", + "cartonText": "CTNS:19-1", + "routeText": "洛杉矶普船海卡", + "qrContent": "PCHK2608040174#19-1" + }, + { + "topCode": "PCHK2608040174", + "warehouseText": "MCC1", + "cartonText": "CTNS:19-2", + "routeText": "洛杉矶普船海卡", + "qrContent": "PCHK2608040174#19-2" + } + ] +} +``` + +`qrContent` 同时用于生成二维码和二维码下方的明文,避免两处内容不一致。`MADE IN CHINA` 由模板固定输出,不作为请求字段。 + +### 4.2 请求字段 + +| 字段 | 必填 | 首版规则 | +|---|---|---| +| `templateCode` | 是 | 只能为 `INBOUND_MARK_V1` | +| `pageSpec.widthMm` | 是 | 只能为数值 `100` | +| `pageSpec.heightMm` | 是 | 只能为数值 `100` | +| `downloadFileName` | 否 | 1-120 个 Unicode 码点;只允许文件基本名,必须以 `.pdf` 结尾 | +| `labels` | 是 | 1-2000 项;数组顺序即页序 | +| `labels[].topCode` | 是 | 非空;单行;禁止控制字符 | +| `labels[].warehouseText` | 是 | 非空;单行;禁止控制字符 | +| `labels[].cartonText` | 是 | 非空;单行;禁止控制字符 | +| `labels[].routeText` | 是 | 非空;单行;禁止控制字符 | +| `labels[].qrContent` | 是 | 1-34 个可打印 ASCII 字符;单行 | + +普通展示字段不设置业务字符数上限,也不因实际文字宽度拒绝请求。调用方负责保证文案适合固定版式;若文字超过页面安全区,Print 仍按固定字号和中心点绘制,不换行、不缩小、不截断,也不把版面越界判定为接口错误。`qrContent` 的限制继续保留,因为它同时影响固定尺寸二维码的扫描密度和二维码下方明文的可读性。 + +补充规则: + +- 所有字符串先做 Unicode NFC 规范化,再按各字段规则进行非空、长度和字符校验;普通展示字段不增加长度校验。 +- 不允许 `\r`、`\n`、制表符、双向控制符、NUL 或其他不可见控制字符。该规则适用于全部请求字符串,`downloadFileName` 同样适用。 +- `pageSpec.widthMm` 和 `heightMm` 按数值比较,`100` 与 `100.0` 都接受,非整数或其他数值一律 `A0422`。 +- Print 不解析 `cartonText`,不校验箱号是否连续,也不校验 `qrContent` 与其他字段的业务关系。 +- 调用方必须保证页面没有重复、缺失或乱序;Print 只保证按数组顺序渲染。 +- 文件名剥离路径语义,禁止 `/`、`\`、`:` 及其他文件系统保留字符,避免调用方把带路径的名字直接落到磁盘。 +- 未传文件名时使用 `inbound-marks-{requestId}.pdf`。`requestId` 取自 `CorrelationContext`,实施前需确认请求未带 `X-Request-Id` 时该组件会生成一个,否则文件名会出现 `null`。 + +### 4.3 成功响应 + +```http +HTTP/1.1 200 OK +Content-Type: application/pdf +Content-Disposition: attachment; filename="inbound-marks.pdf"; filename*=UTF-8''PCHK2608040174-19%E4%BB%B6-MCC1.pdf +Content-Length: ... +Cache-Control: no-store, max-age=0 +Pragma: no-cache +X-Content-Type-Options: nosniff +X-Print-Page-Count: 19 +X-Request-Id: ... +``` + +响应头实现约定: + +- 复用项目现有下载写法 `ContentDisposition.attachment().filename(name, StandardCharsets.UTF_8)`(见 `file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java:66`),由它统一完成 RFC 5987 编码并同时输出 ASCII `filename=` 回退与 `filename*=`,不手工拼接响应头。 +- 控制器方法**不得**声明 `produces = "application/pdf"`,否则会把该方法的可产出类型钉死为 PDF,异常处理阶段无法写出 JSON 错误体。成功分支显式调用 `.contentType(MediaType.APPLICATION_PDF)` 设置类型。 +- `Cache-Control` 使用 `CacheControl.noStore()`,与现有下载接口一致。 + +PDF 完整生成并通过大小检查后才写入 HTTP 响应,禁止边生成边返回半个 PDF。 + +### 4.4 失败响应 + +成功响应是 PDF,失败响应是项目统一的 JSON `ApiResponse`。调用方必须同时检查 HTTP 状态、`Content-Type` 和 JSON `code`,不能只判断 HTTP 200。 + +| 场景 | 响应约定 | +|---|---| +| JSON、字段或字符校验失败 | HTTP 200 + `application/json` + `A0422` | +| 模板或页面尺寸不支持 | HTTP 200 + `application/json` + `A0422` | +| 内部令牌缺失或错误 | HTTP 200 + `application/json` + `A0403` | +| 请求体超过容器限制 | HTTP 413 + `application/json` | +| 并发生成槽位已满 | HTTP 200 + `application/json` + `A0429` | +| PDF 生成失败或超过输出上限 | HTTP 200 + `application/json` + `S0500` | + +错误信息只指出字段位置和规则,例如 `labels[18].routeText contains a line break`,不回显完整标签数组或内部异常堆栈。 + +## 5. PDF 渲染契约 + +### 5.1 页面与坐标 + +- 页面物理尺寸:严格 `100mm x 100mm`。 +- PDF 坐标换算:`points = millimeters * 72 / 25.4`。 +- 页面旋转:`0`。 +- 背景:纯白。 +- 前景:纯黑,不使用灰色、透明度、渐变或彩色元素。 +- 内容安全区:四周至少 `5mm`;所有文本和二维码水平居中。 +- 每个字段只占一行,不自动换行,不截断,不输出省略号。 + +首版布局基线。**下表所有 `y` 均为「自页面顶部向下量取」的中心线位置**,与阅读顺序一致,不是 PDF 原生坐标: + +| 区域 | 顶部向下中心或范围 | 初始字号/尺寸 | 规则 | +|---|---:|---:|---| +| 顶部入仓码 | 中心 `y = 11mm` | `24pt` 粗体 | 单行居中 | +| 仓库文案 | 中心 `y = 26mm` | `36pt` 粗体 | 单行居中 | +| 箱号文案 | 中心 `y = 37mm` | `22pt` 粗体 | 单行居中 | +| 渠道文案 | 中心 `y = 47mm` | `21pt` 粗体 | 单行居中 | +| 二维码画布 | `48.72mm-84mm` | `35.28mm x 35.28mm`(100pt) | 水平居中,含 4 modules quiet zone | +| 二维码明文 | 中心 `y = 85.5mm` | `12pt` 粗体 | 内容取 `qrContent` | +| 固定产地 | 中心 `y = 92mm` | `14pt` 粗体 | 固定 `MADE IN CHINA` | + +PDF 原生坐标原点在**左下角**,y 轴向上,与上表方向相反。二者混用会让整张标签上下颠倒,因此坐标换算必须收敛到 `PdfUnits` 一处: + +- `PdfUnits` 只对外暴露 `topDownMmToPdfY(double mmFromTop)` 与 `mmToPt(double mm)` 两个入口,前者内部完成 `pageHeightPt - mmToPt(mmFromTop)` 翻转。 +- 渲染代码一律以「自顶向下的毫米值」思考和传参,禁止在绘制方法中出现手写 point 常量,也禁止绕过 `PdfUnits` 直接使用 PDF 原生 y。 +- 文本按中心线垂直居中时,基线由字体上升距离和字号在 `PdfUnits` 内换算,绘制方法不自行加减半个字号。 + +上述位置是依据参考 PDF 得到的首版基线,最终以渲染后的金样验收为准。 + +### 5.2 文本绘制 + +- 所有普通展示文字使用 5.1 约定的固定字号和中心点直接绘制。 +- 不测量文字是否超出安全区,不动态缩小字号,不换行、不截断、不输出省略号,也不横向压缩字体。 +- Print 只保证请求中的文字被交给 PDFBox 绘制,不保证任意长度文字都能落在页面范围内;版式内容质量由调用方负责。 +- 固定字号或位置需要调整时,通过新增模板版本完成,不得静默改变 `INBOUND_MARK_V1`。 + +### 5.3 字体 + +- 随服务打包 `Noto Sans SC Bold` 静态 TTF 字体及对应 OFL 许可证。 +- 每个 PDF 文档只加载一次字体对象,启用字体子集并嵌入 PDF。 +- 不使用 Windows 的 Microsoft YaHei,不从系统字体目录动态加载字体。 +- 启动时校验字体资源存在且可解析;缺失时 readiness 失败,不能静默回退到不支持中文的字体。 + +### 5.4 二维码 + +- 使用 ZXing 根据 `qrContent` 生成 QR Code。 +- 字符集固定为 UTF-8;首版字段限制为可打印 ASCII。 +- 纠错等级使用 `M`,保留 4 modules quiet zone。 +- 二维码在 PDF 中按模块直接绘制黑色矢量矩形,不先生成低分辨率 PNG,不进行插值缩放。 +- 二维码下方明文与编码内容来自同一个 `qrContent` 值。 +- 每页二维码必须可由 ZXing 反向解码,并与请求值完全一致。 + +## 6. Java 实现方案 + +### 6.1 依赖选型 + +- PDF:Apache PDFBox。 +- QR Code:ZXing Core。 +- 输入校验:Jakarta Validation 加服务层全量校验。 +- HTTP:现有 Spring Boot MVC、Common Web 和内部令牌模式。 + +不采用 HTML/CSS 转 PDF 或无头浏览器:标签要求精确物理尺寸,HTML 打印受 DPI、浏览器分页和字体环境影响更大。不采用 iText:当前需求不需要其高级能力,同时需要避免额外商业许可边界。 + +### 6.2 建议包结构 + +```text +com.baiyue.print +├─ controller +│ └─ InternalPdfController.java +├─ dto +│ └─ InboundMarkPdfDtos.java +├─ service +│ ├─ InboundMarkPdfService.java +│ ├─ InboundMarkRequestValidator.java +│ └─ PdfGenerationLimiter.java +├─ renderer +│ ├─ InboundMarkV1Renderer.java +│ ├─ PdfQrRenderer.java +│ └─ PdfUnits.java +└─ config + └─ PrintPdfProperties.java +``` + +建议资源: + +```text +print/src/main/resources/fonts/NotoSansSC-Bold.ttf +print/src/main/resources/fonts/OFL.txt +print/src/main/resources/openapi/print-internal-v1.yaml +``` + +### 6.3 处理流程 + +1. 校验内部令牌。 +2. 校验请求体大小、模板、页面尺寸、文件名和页数。 +3. 对全部页面字符串进行规范化、非空和控制字符校验。 +4. 获取并发生成槽位。 +5. 在并发保护范围内生成并校验全部 QR BitMatrix;任一失败则终止。 +6. 创建一个 PDF 文档,加载一次嵌入字体。 +7. 按 `labels[]` 顺序创建页面并渲染。 +8. 保存到受上限保护的内存输出流。 +9. 校验 PDF 字节数、页数和基本结构。 +10. 构造下载响应,释放文档、字体和内存对象。 + +全过程不创建数据库事务、任务 ID、磁盘文件或异步队列。 + +## 7. 容量与资源保护 + +首版默认配置建议: + +```yaml +print: + pdf: + max-pages: 2000 + max-request-bytes: 2097152 + max-output-bytes: 33554432 + max-concurrent-generations: 4 + acquire-timeout: 1s + template-code: INBOUND_MARK_V1 + supported-width-mm: 100 + supported-height-mm: 100 +``` + +- `max-pages` 是硬上限,不能通过请求覆盖。 +- `max-request-bytes` 同时在应用和入口代理配置,防止超大 JSON 在反序列化前占用内存。 +- 内存输出流在超过 `max-output-bytes` 时立即抛出受控异常。 +- 使用进程内 Semaphore 限制并发生成数;槽位满时快速失败,不在 JVM 内形成无界等待队列。 +- 字体只在单个 PDF 文档中加载一次,不得每页创建一份字体子集。 +- 服务保持无状态,可以水平扩容;单实例并发数应根据容器内存压测后调整。 + +发布前容量验收至少覆盖 1、19、218 和 2000 页。2000 页请求的目标是 30 秒内完成且 PDF 小于 32MiB;实际 P95、峰值堆内存和容器 CPU 以测试环境压测结果回填,不在未经测量时承诺更激进指标。 + +## 8. 日志、监控与隐私 + +允许记录: + +- `requestId`、调用服务名、模板编码、页面尺寸、页数。 +- 生成耗时、PDF 字节数、成功/失败结果和有限错误码。 +- 当前并发数、等待槽位时间和内存上限拒绝次数。 + +禁止记录: + +- 完整 `labels[]`、二维码内容、内部令牌和 PDF 字节。 +- 将生成的 PDF 写入日志、异常附件或故障目录。 + +建议指标: + +- `print_pdf_requests_total{result,template}` +- `print_pdf_generation_seconds{template}` +- `print_pdf_pages_total{template}` +- `print_pdf_output_bytes{template}` +- `print_pdf_rejections_total{reason}` +- `print_pdf_active_generations` + +## 9. 测试与验收 + +### 9.1 自动化测试 + +- DTO 校验:空数组、1001 页、空字段、控制字符、超长二维码内容、非法文件名和错误页面尺寸。 +- 接口契约:内部令牌、成功 PDF、JSON 错误、响应头和 `Cache-Control: no-store`。 +- PDF 结构:页数等于 `labels.size()`,每页精确 `100mm x 100mm`,旋转为 0。 +- 文本内容:每页六个文本区完整,顶部入仓码不在底部重复。 +- 页序:输入第 N 项必须成为 PDF 第 N 页。 +- 二维码:渲染页面后用 ZXing 解码,结果必须等于该页 `qrContent`。 +- 字体:PDF 中存在嵌入字体,中文渲染无方框、乱码或系统字体依赖。 +- 金样渲染:对固定业务样本渲染 PNG,检查内容边界、居中和布局容差;不比较易受元数据影响的完整 PDF 二进制。 +- 容量:1、19、218、2000 页生成成功,输出不超过配置上限。 +- 并发:超过生成槽位时快速返回受控错误,完成后槽位必定释放。 +- 失败原子性:任一页无效时不返回部分 PDF,也不产生磁盘文件。 + +### 9.2 人工验收 + +1. 用参考文件的 19 页数据生成 PDF,逐页比对页面内容和页序。 +2. 渲染第一页、中间页和最后一页,确认全部元素水平居中且无重叠、裁切或乱码。 +3. 在实际打印环境按 100% 比例打印,确认成品尺寸为 `100mm x 100mm`,关闭“适合页面/缩放”选项。 +4. 使用现场扫码设备扫描首、中、末页二维码。 +5. 使用真实业务中的典型较长文案确认固定字号下的展示结果可接受。 +6. 生成 2000 页并记录耗时、PDF 大小、峰值堆内存和 CPU。 + +## 10. 实施范围 + +### 10.1 本次实现应包含 + +- 移除 Print 数据库、MyBatis 和 MySQL 依赖及配置。 +- 新增 PDFBox、ZXing 和合法授权的中文字体资源。 +- 实现内部 PDF 接口、请求校验、固定模板渲染和资源保护。 +- 增加内部 OpenAPI、单元测试、契约测试、金样渲染测试和 2000 页容量测试。 +- 保留 Actuator 健康检查;现有逻辑状态接口可继续用于平台运维,但不承担业务能力。 +- 更新 Print 模块指引、部署配置和 Gateway 说明,明确内部业务接口不公开。 + +### 10.2 本次不包含 + +- Admin 页面、业务服务公开下载接口及业务权限码。 +- 打印模板 CRUD、模板版本或数据库表。 +- 文件中心、对象存储、下载中心或下载次数管理。 +- 打印机发现、USB/网络打印、本地代理、驱动和设备状态。 +- 打印任务、失败重试、补打、换标、作废和审计历史。 +- 任意页面尺寸、任意字段布局或通用报表生成。 + +## 11. 后续兼容策略 + +- `templateCode` 和 `pageSpec` 从首版进入契约,但首版只接受一个固定组合。 +- 新尺寸或新版布局必须新增模板编码,例如 `INBOUND_MARK_V2`,不得静默改变 V1 的位置、字号或固定文案。 +- 若未来需要直接从 Admin 调用,必须先定义独立的授权与数据真实性方案,不能直接开放本内部接口。 +- 若未来需要暂存或重复下载,应由 File/下载中心承担文件生命周期,不能在当前纯渲染接口中逐步加入数据库状态。 +- 若未来需要控制佳博等热敏打印机,应新增本地打印代理或设备服务,本服务仍只负责生成可打印内容。 + +## 12. 待评审项 + +以下事项不阻塞设计评审,但应在编码或上线前确认: + +1. `Noto Sans SC Bold` 的最终字体文件版本、仓库体积和许可证归档位置。 +2. 业务归属服务的公开下载接口路径与按钮权限码。 +3. 测试环境单实例 CPU、内存限制及 `max-concurrent-generations` 最终值。 +4. 现场打印驱动是否把 `100mm x 100mm` PDF 保持为 100% 比例,是否存在硬件不可打印边缘。 diff --git a/docs/2026-08-05-Java单租户多客户设计审计汇总结论.md b/docs/2026-08-05-Java单租户多客户设计审计汇总结论.md new file mode 100644 index 000000000..03ef13c44 --- /dev/null +++ b/docs/2026-08-05-Java单租户多客户设计审计汇总结论.md @@ -0,0 +1,68 @@ +# Java 单租户多客户设计审计汇总结论 + +## 结论 + +当前 Java 主体模型整体符合“业务单租户、平台管理多客户、客户严格隔离”: + +- `iam_tenant` 的 `MERCHANT` 记录当前可以继续直接作为客户主档。 +- Auth/JWT 和各业务表的 `tenant_id > 0` 当前承载客户 ID,是客户隔离键。 +- 平台技术作用域和 RBAC 模板使用 `tenant_id = 0`。 +- Order/Firstleg 中的 `customer_id` 多数承载往来单位或业务客户快照,不是 IAM 客户隔离 ID。 +- 未发现可通过当前正常公开链路直接完成跨客户读写的 P0,不需要为上线重构全部主体模型。 + +## 已完成的上线前 P1 + +1. 公共仓库、往来单位、快递公司和部分种子数据使用 `tenant_id=10001`,而 IAM 又把 `10001` 建成普通 `MERCHANT` 演示客户。现已确认平台公共仓库、往来单位、快递公司、托盘、库位等公共资源统一归属平台技术作用域 `tenant_id=0`;必须同步新库种子、配置、消费者和可重复执行的存量迁移,客户订单、文件和费用等私有数据不得随之改为 `0`。 +2. Warehouse 存量子表迁移新增 `tenant_id` 时直接默认填 `0`,却没有按父仓库或父记录回填,可能导致存量地址、能力、往来单位和快递公司丢失归属。 +3. 统一迁移脚本在真正结束前已经输出 `PASS`,后面仍继续建表;成功标志和最终结构校验必须收敛到脚本末尾。 +4. IAM/Auth 的部分内部 Controller 在内部令牌为空时可能把“空配置”和“缺失请求头”比较为相等,必须统一启动失败校验。 +5. 平台身份只检查 `app_code=PLATFORM`,部分 Gateway/模块守卫没有再次断言登录主体 `tenant_id=0`;客户身份也应断言 `tenant_id>0`。 +6. 平台代客业务审计当前可能记录平台主体 `tenant_id=0`,导致按目标客户查询审计日志漏记;应同时保留操作者作用域和目标客户归属。 +7. Integration 内部令牌存在可预测默认值,必须改为环境显式注入并保持启动失败保护。 +8. 模块级 `AGENTS.md`、README、分期文档和 OpenAPI 仍把兼容 `tenant_id` 描述成真实上级租户,国内货柜履约存在重新按三级租户开发的返工风险。 + +## P2 最小加固 + +- 删除或拒绝 Gateway 未分端域的 `/api/iam/**` 通用路由。 +- Auth 校验 IAM 授权快照回显的 `userId/tenantId/membershipId/appCode` 与请求完全一致。 +- IAM 商户资料读取和修改再次限定 `tenant_type=MERCHANT`。 +- Order/Firstleg/Warehouse/File 的平台守卫统一校验平台主体为 `tenant_id=0`。 +- 对少量 `updateById`、按裸主键删除、Partner 类型关联操作增加仓储层客户条件。 +- Order Outbox 投递前核对事件行和 Payload 的客户、订单及聚合类型。 +- Event Bridge 重复事件比较同时核对客户和信封身份字段。 +- File 平台测量图片上传增加目标入库单客户归属校验,避免孤儿或错误归属文件。 +- 公共 OpenAPI 不再把可信身份头描述成普通客户端请求头。 +- 增加客户 A/B、异常平台 Token、内部 API 错误客户、Outbox 归属漂移和迁移结果的负向测试。 +- 修正 Order 取消事件长期停留 `PENDING` 的非隔离功能问题,或明确当前不发布该事件的终态。 + +## 本轮不实施 + +- 不新增真实租户表、租户切换、租户级 RBAC 或租户级配置。 +- 不批量重命名 `tenant`、`merchant`、`Tenant*`、`tenant_id`。 +- 不把所有现有 `tenant_id` 统一成默认租户 ID。 +- 往来单位与快递公司均由平台联系和统一维护,`wh_partner`、`wh_partner_type` 与其他公共仓储主数据一起迁移为 `0`;客户订单、文件、费用等私有数据保持客户 ID。迁移必须按明确的公共资源表和父子关系执行。 +- 不触碰生产环境;测试环境已按用户明确授权执行统一增量 SQL,并完成迁移后断言。 + +## 测试环境迁移结果 + +- 统一增量 SQL 已在测试 MySQL 执行,最终输出 `migration_status = PASS`。 +- `wh_partner` 4 行、`wh_partner_type` 8 行、`wh_express_company` 4 行均已归平台公共作用域 `0`,非平台作用域违规数均为 0。 +- 公共仓库、地址、能力、托盘、库位、预占和占用的非平台作用域违规数均为 0。 +- 订单、文件和费用等客户私有表的 `tenant_id = 0` 违规数均为 0。 + +## 验证结果 + +- `mvn test`:19 个 Reactor 模块全部 `SUCCESS`,共执行 634 项测试,0 失败、0 错误、1 项跳过。 +- 重点业务模块:IAM 128 项、Order 67 项、Firstleg 112 项、Warehouse 102 项、File 81 项均通过。 +- 其余服务:Print 35 项(1 项跳过)、Audit 15 项、Integration 11 项、Event Bridge 10 项均通过。 +- `mvn -DskipTests validate` 与 `git diff --check` 均通过。 + +## 第二轮文件所有权 + +- 身份安全:`iam/`、`auth/`、`gateway/`、`common-security/` 的生产代码与测试。 +- 订单履约:`order/`、`firstleg/` 的生产代码与测试,不修改 SQL、OpenAPI、README、AGENTS。 +- 公共审计事件:`common-audit/`、`audit-service/`、`integration-service/`、`event-bridge-service/`、`common-openapi/` 的生产代码与测试。 +- Warehouse/File/迁移:`warehouse/`、`file/` 生产代码与测试,以及各模块数据库脚本、`deploy/sql/`、`config/nacos/`;平台公共资源(含往来单位、快递公司)作用域迁移为 `0`,客户私有资源保持客户 ID。 +- 文档契约:模块 `AGENTS.md`、README、OpenAPI 和订单与客户/入库测量与计费/国内货柜履约指导性文档;不修改 Java 或 SQL。 + +跨模块新接口、公共 DTO、事件结构和公共仓库作用域变更由主对话统一裁决,单个修改对话不得自行扩展范围。 diff --git a/docs/2026-08-05-Java单租户多客户设计审计计划.md b/docs/2026-08-05-Java单租户多客户设计审计计划.md new file mode 100644 index 000000000..d7a25f247 --- /dev/null +++ b/docs/2026-08-05-Java单租户多客户设计审计计划.md @@ -0,0 +1,130 @@ +# Java 单租户多客户设计审计计划 + +> 状态说明:本文保留第一轮只读审计的原始计划。用户随后明确授权修改代码、初始化 SQL 与测试环境数据;实际执行结果以同日《Java 单租户多客户设计审计汇总结论》和统一增量 SQL 为准。 + +## 一、目标 + +对 `baiyue-firstleg/` 全部 Java 服务、数据库脚本和跨服务契约做一次系统性只读审计,确认当前实现符合根 `AGENTS.md` 已确定的阶段模型: + +- 当前业务单租户,平台管理多个客户,客户之间严格隔离。 +- `iam_tenant` 中 `id > 0 && tenant_type = MERCHANT` 的记录当前直接作为客户主档。 +- Auth、JWT 和业务表中的 `tenant_id > 0` 当前承载客户 ID,是客户隔离键。 +- `tenant_id = 0` 是平台或 RBAC 模板技术作用域,不是未来的默认租户。 +- 平台公共仓库、往来单位、快递公司、托盘、库位等公共资源归属平台技术作用域 `tenant_id = 0`;客户订单、文件和费用等私有数据仍使用客户 ID。 +- 当前不新增真实租户层,不重命名现有兼容字段,不做大规模架构改造。 + +审计完成后,主对话统一汇总结论,再以最小改造原则安排修改。第一轮各审计对话一律只读,不修改任何文件。 + +## 二、非目标 + +- 不在本轮设计或实现真实多租户。 +- 不把现有 `tenant_id` 统一替换为默认租户 ID。 +- 不批量重命名 `tenant`、`merchant`、`Tenant*`、`tenant_id`。 +- 不因为未来可能支持多租户而提前增加租户表、租户切换、租户级 RBAC 或租户级配置。 +- 第一轮审计阶段不修改测试环境数据;后续已按用户在当前对话中的明确授权执行统一迁移。 + +## 三、统一审计判定标准 + +每个业务入口和数据链路都按以下问题检查: + +1. 平台账号是否固定为 `app_code = PLATFORM && tenant_id = 0`。 +2. 客户账号是否固定为 `app_code = MERCHANT && tenant_id > 0`,成员关系是否属于同一客户。 +3. 商户端是否只从可信认证上下文取得当前客户 ID,而不是信任请求中的目标 `tenantId`。 +4. 平台端跨客户查询或代客操作是否同时校验平台应用域、权限码和目标客户条件。 +5. Repository、Mapper、SQL 是否在客户数据的查询、更新、删除和唯一约束中携带当前客户 ID。 +6. 子表、历史、审计、Outbox、幂等、文件和缓存键是否继承主聚合的客户 ID。 +7. 内部 API、Feign DTO 和事件是否完整传递客户 ID,并在被调用方再次按归属校验。 +8. `tenant_id = 0` 是否只用于平台或全局模板,是否被错误当成客户或未来默认租户。 +9. `customer_id`、`data_scope_id`、`operator_tenant_id`、`public-tenant-id` 等相邻字段是否被误用为 IAM 客户隔离键。 +10. 是否存在通用路由、可选过滤、按主键裸查、跨库查询或平台/商户端域混用造成的跨客户访问风险。 +11. 当前实现是否偷偷引入真实租户层,或制造一年后无法通过新增上级租户字段迁移的不可逆结构。 +12. 自动化测试是否至少覆盖客户 A 不能读取或修改客户 B 数据,以及平台合法跨客户操作。 + +## 四、第一轮多对话分工(全部只读) + +### 对话 A:IAM、Auth、Common Security、Gateway + +负责目录:`iam/`、`auth/`、`common-security/`、`gateway/`。 + +重点检查账号归属、注册、成员、角色权限模板、授权快照、JWT Claim、刷新撤销、可信身份头、平台/商户访问域和 Gateway 路由。确认平台 `0`、客户 `>0` 的规则在所有入口一致,检查历史账号脏数据风险及通用路由绕过。 + +### 对话 B:Order 客户隔离 + +负责目录:`order/`。 + +重点检查商户下单、平台代客、列表详情、状态流转、货物/参与方/历史/备注、幂等、Outbox、IAM 资格查询和 Warehouse 主数据调用。必须区分当前隔离键 `tenant_id` 与往来单位语义的 `customer_id`。 + +### 对话 C:Firstleg 履约与入库 + +负责目录:`firstleg/`。 + +重点检查履约任务、入库单、测量、审核、FBA 计费、费用发布、托盘库位协作、第三方测量、内部接口、幂等和 Outbox。核对所有状态推进、详情和图片链路是否使用同一个客户 ID。 + +### 对话 D:Warehouse 与 File 资源归属 + +负责目录:`warehouse/`、`file/`。 + +重点检查仓库、往来单位、快递公司、托盘、库位、预占占用、资质文件、测量图片和导出任务。单独核对 `DEFAULT_TENANT_ID` / `public-tenant-id` 是否固定为平台作用域 `0`,确保公共仓库、往来单位和快递公司等资源不再绑定普通客户 `10001`;客户文件仍按客户隔离。 + +### 对话 E:其余服务与公共契约 + +负责目录:`common-core/`、`common-web/`、`common-audit/`、`common-event-contract/`、`common-openapi/`、`audit-service/`、`integration-service/`、`event-bridge-service/`、`print/`。 + +重点检查公共实体、审计上下文、事件信封、内部令牌、OpenAPI 自动配置、第三方适配和打印下载链路是否丢失、混淆或信任外部提交的客户 ID。 + +### 对话 F:数据库、迁移与部署配置 + +只负责各模块 `src/main/resources/db/`、`deploy/sql/`、`deploy/`、`config/nacos/`、相关初始化脚本,不审计 Java 业务实现。 + +重点检查表、索引、唯一键、增量迁移、种子数据和环境配置中的客户隔离维度;盘点 `tenant_id = 0`、`10001` 等保留值的真实用途;提出上线前只读数据核验 SQL,不执行写操作。 + +### 对话 G:测试、OpenAPI、文档与国内货柜履约防返工 + +只负责 `src/test/`、各模块 `src/main/resources/openapi/`、`docs/` 和模块 `README` / `AGENTS.md`,不修改生产代码。 + +检查测试是否覆盖跨客户拒绝、平台跨客户、内部调用归属和端域隔离;检查契约及文档是否仍把当前 `tenant_id` 描述成真实上级租户;定向核对国内货柜履约文档,列出可能诱导后续按三级租户开发的内容。 + +## 五、每个审计对话的统一输出格式 + +每项发现必须包含: + +- 编号和风险级别。 +- 结论:符合、缺口或需要业务确认。 +- 可复核证据:绝对文件路径和准确行号。 +- 触发场景及可能造成的跨客户或上线影响。 +- 最小修复建议,明确涉及的文件和消费者。 +- 建议增加或调整的测试。 +- 是否应延后到约一年后的真实租户迭代。 + +风险级别统一为: + +- `P0`:可直接造成未授权跨客户读写、身份伪造或生产数据破坏,阻断上线。 +- `P1`:明确的隔离缺口、端域绕过或高概率配置错误,应在上线前修复。 +- `P2`:防御性加固、测试缺口、易误用设计或运维核验项,可在上线前排期完成。 +- `Deferred`:只与未来真实多租户有关,当前禁止提前改造,仅记录迁移边界。 + +没有发现问题的负责范围也必须给出检查过的入口、隔离链路和测试证据,不能只回复“未发现问题”。 + +## 六、汇总与裁决流程 + +1. 主对话等待全部只读审计完成,去重并处理跨模块结论冲突。 +2. 以当前根 `AGENTS.md` 为事实来源,把发现分成“必须上线前修改、上线前建议加固、仅需数据核验、延后到真实租户阶段”。 +3. 对跨模块字段和契约,由主对话确定唯一兼容策略;任何审计对话不得自行决定重定义 `tenant_id`。 +4. 输出修改清单、文件所有权、依赖顺序、验证命令和回滚边界,用户确认后进入修改阶段。 + +## 七、第二轮修改对话安排原则 + +- 修改对话数量由最终发现决定,不预先假设每个审计对话都有代码要改。 +- 每个修改对话拥有互斥的生产文件范围;公共 DTO、事件、OpenAPI 和统一 SQL 入口由主对话协调。 +- 优先处理 P0/P1,再处理测试和文档;`Deferred` 项不得实现。 +- 每个修改对话只运行目标模块实际声明的测试;主对话最后执行受影响模块及跨模块消费者验证。 +- 所有修改完成后再次定向检索 `tenant_id`、`customer_id`、`operator_tenant_id`、`DEFAULT_TENANT_ID` 和通用 `/api/iam/**` 路由,确认没有新增语义漂移。 + +## 八、最终交付物 + +- 全仓单租户多客户设计审计结论。 +- 按优先级排序的最小修改清单及完成状态。 +- 上线前只读数据核验 SQL 和配置核对清单。 +- 受影响模块测试与跨服务契约验证结果。 +- 更新后的根/模块 `AGENTS.md`、必要 OpenAPI 和回归记录。 +- 约一年后引入真实租户时必须保留的迁移边界清单。 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/business-domain-and-phase1-plan.md b/docs/business-domain-and-phase1-plan.md deleted file mode 100644 index 959c8cbd1..000000000 --- a/docs/business-domain-and-phase1-plan.md +++ /dev/null @@ -1,166 +0,0 @@ -# 业务域梳理与一期任务实施审核稿 - -更新时间:2026-07-30 -审核状态:一期前端联调能力已验收,完整头程生产闭环待审核 -目标:一期在国内服务器形成“注册与审核、下单、国内仓、头程交接、事件通知”的可演示闭环,并为后期尾程迁移海外保留稳定边界。 - -## 1. 一期业务主链路 - -```text -商户注册 - -> 提交企业资料和资质文件 - -> 平台审核并激活租户 - -> 商户管理员登录并配置成员权限 - -> 商户创建、提交头程订单 - -> 平台审核、受理订单 - -> 生成头程履约任务 - -> 国内仓预约到货、收货测量和差异处理 - -> 排柜、订舱、拖车、装柜、报关 - -> 装船发运并固化交接快照 - -> Outbox 可靠发布交接事件 - -> 一期国内尾程 Inbox 幂等接收并回执 - -> 国内统一轨迹投影 - -> 异步订单通知 -``` - -一期不要求立刻把服务物理部署到海外,但要求尾程交接不依赖跨库联表、同步跨区 Feign 或共享 Redis,以便后期只迁移尾程执行单元。 - -## 2. 业务域边界 - -| 业务域 | 唯一主数据 | 对外输入 | 对外输出 | 明确不负责 | 当前状态 | -| --- | --- | --- | --- | --- | --- | -| 租户权限 | 租户、成员、角色、菜单、按钮、数据范围、资质审核 | 注册资料、成员和授权变更 | 授权快照、租户下单资格 | 密码校验、订单业务状态 | 个人资料、成员、角色、按钮权限和租户隔离已通过真实回归 | -| 统一登录 | 账号、密码哈希、刷新令牌、登录会话 | 注册、登录、刷新、退出 | JWT 和会话状态 | 菜单权限主数据 | 登录、刷新轮换、退出、改密和会话撤销已通过真实回归;海外本地验签待完成 | -| 订单 | 商户委托、货物、收发方、订单状态和历史 | 商户下单、平台审核和受理 | 订单事件、履约创建指令、查询视图 | 仓内作业、头程执行细节、尾程事实 | 主要接口与状态机已存在,可靠事件发布未闭环 | -| 国内仓 | 国内仓资料、往来单位配置、联系人地址、库位及仓内作业事实 | 到仓预约、收货、测量、装柜指令 | 有效仓库与往来单位候选、入库结果、差异、装柜证据 | 订单审核、订舱、报关、尾程 | 仓库和往来单位资料能力已有,仓内作业待实现 | -| 头程 | 头程履约任务、订舱、柜、航次、报关和发运交接 | 已受理订单、仓储作业结果 | 发运交接事件和头程里程碑 | 商户订单主档、海外派送 | 任务分配基础已有,完整履约待实现 | -| 尾程交接 | 尾程聚合、交接版本、执行区域、到港至签收事实 | 不可变发运交接事件 | 接收/拒绝回执、尾程里程碑、完成事件 | 修改国内订单和头程事实 | 当前主要为骨架,Inbox 与业务流程未完成 | -| 文件 | 文件元数据、存储定位、版本、摘要、下载授权和日志 | 文件上传或受控复制 | 文件标识、短期下载授权 | 在事件中传输文件正文、业务审批 | 资质上传下载已有,本地磁盘需迁对象存储 | -| 打印 | 模板、打印任务、状态历史 | 业务单据数据和模板版本 | 生成文件标识、打印结果 | 保存订单或仓储主数据 | 表结构与健康接口已有,执行能力待实现 | -| 审计 | 操作人、租户、动作、对象、结果和追踪号 | 各服务异步或内部审计事件 | 审计查询与留痕 | 代替业务状态历史 | 采集落库基础已有,关键写动作覆盖需核对 | -| 集成 | 提供方配置、外部请求映射、回调幂等键 | 业务服务抽象调用、第三方回调 | 标准化结果和外部事件 | 持有订单、头程、尾程主状态 | 提供方状态骨架已有,真实适配器待实现 | -| 事件通知 | Outbox、Inbox、投递、重试、死信、通知订阅和发送记录 | 领域事件 | 站内信、短信/邮件/回调结果和统一轨迹 | 直接推进源聚合状态 | 公共事件契约已存在,可靠桥和通知链路未完成 | - -## 3. 服务间协作规则 - -1. `auth-service` 登录时向 `iam-service` 获取授权快照;JWT 携带可信租户、端和权限信息。 -2. `order-service` 只通过 IAM 内部接口判断租户是否允许下单,不跨库查询 IAM 表。 -3. `order-service` 通过 `warehouse-service` 内部接口校验仓库和往来单位;二者均属于 `baiyue_warehouse`,订单只保存不可变业务快照,不跨库读取配置表。 -4. 订单受理后通过事件或明确的内部契约创建头程任务,头程不得修改订单表。 -5. 国内仓回传测量、差异和装柜结果;头程维护履约编排,仓储维护仓内事实。 -6. 只有“装船发运且交接快照固化”才能产生尾程交接事件,订单审核通过事件不能替代尾程交接。 -7. 尾程以 `event_id + event_version` 幂等接收,以聚合版本拒绝旧事件覆盖新状态。 -8. 海外里程碑通过事件回到国内只读轨迹投影,客户端只查询国内统一时间线。 -9. 文件事件只传 `file_id`、版本、摘要和用途;打印任务只保存业务引用和 `file_id`。 -10. 通知消费者失败不能回滚订单或履约事务,必须重试、死信并可人工补投。 -11. 每个写操作同时校验登录身份、端、租户和按钮权限,不能只依赖前端隐藏按钮。 - -## 4. 一期范围 - -### 4.1 必须完成 - -- 平台端和商户端统一登录、租户隔离、菜单与按钮权限。 -- 商户注册、资质提交、平台审核、租户激活、允许下单联动。 -- 普通头程订单创建、编辑、提交、审核、受理和取消。 -- 国内仓到仓、收货、测量、差异确认、装柜证据的最小闭环。 -- 头程排柜、订舱、装柜、报关、装船发运和交接快照。 -- 国内回环的 Outbox、事件桥、Inbox、回执、统一轨迹和订单通知。 -- 文件对象存储抽象、审计、监控、自动化验收和部署手册。 - -### 4.2 延后到后续迭代 - -- 完整快递、换标、海外仓增值服务产品线。 -- 全量 Amazon SP-API、各国海关、港务和多承运商适配。 -- 海外正式生产切流、多区域高可用和历史执行中聚合迁移。 -- 高级智能排柜、智能计费、复杂结算和财务对账。 - -## 5. 一期详细任务节点 - -状态取值:`已有基础` 表示代码或表已存在但仍需按验收标准补齐;`待实现` 表示尚未形成可用闭环。 - -| 编号 | 任务 | 输入 | 输出 | 依赖 | 验收标准 | 优先级 | 建议迭代 | 当前状态 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | -| P1-001 | 固化一期状态机与跨服务契约 | 原型、订单状态、头尾程节点 | 状态表、接口契约、事件目录 | 产品审核 | 非法流转有明确错误码;契约评审通过并版本化 | P0 | 迭代 0 | 待审核 | -| P1-002 | 统一环境与数据库初始化 | 服务清单、SQL、Nacos 模板 | 可重复初始化脚本和环境检查 | MySQL、Redis、Nacos | 空环境一键初始化;重复执行无副作用;密码不入库 | P0 | 迭代 0 | 已有基础 | -| P1-003 | 统一登录回归 | 账号、密码、端类型 | Access Token、Refresh Token、退出结果 | Auth、IAM、Redis | 平台/商户登录刷新退出全通过;错误端和禁用账号被拒绝 | P0 | 迭代 1 | 已完成真实回归 | -| P1-004 | 注册审核激活闭环 | 注册资料、资质文件 | 待审核租户、审核记录、激活账号 | Auth、IAM、File | 注册后不可下单;审核通过自动激活;拒绝原因可查;重复请求幂等 | P0 | 迭代 1 | 部分实现,需闭环回归 | -| P1-005 | 两端菜单按钮授权 | 角色、资源、成员 | 授权快照和 JWT 权限 | IAM、Auth | 平台与商户资源完全隔离;菜单和按钮权限正确下发 | P0 | 迭代 1 | 已有基础并有验收记录 | -| P1-006 | 写接口后端鉴权全覆盖 | 按钮权限码、写接口清单 | `@PreAuthorize` 或等效策略 | P1-005 | 未授权直调所有写接口均返回 403;跨租户访问为 403/404 | P0 | 迭代 1 | 本次成员、角色、仓库、往来单位和订单接口已通过;其余后续业务写接口随功能实现验收 | -| P1-007 | 首次登录改密和凭据治理 | 初始账号、密码策略 | 强制改密状态、修改密码接口 | Auth | 初始账号未改密不能进入业务;密码不记录日志 | P1 | 迭代 1 | 待实现 | -| P1-008 | 商户头程订单最小闭环 | 可下单租户、货物和收发方 | 草稿、提交、审核、受理、取消状态 | P1-004、P1-006 | 正常路径和拒绝/取消路径通过;状态历史完整;租户隔离通过 | P0 | 迭代 2 | 商户草稿创建、编辑、提交、快照和异常路径已通过;平台审核、受理、取消仍待闭环 | -| P1-009 | 订单 Outbox 状态机 | 订单本地事务和领域事件 | 待投递、投递中、成功、重试、死信记录 | P1-001、P1-008 | 业务提交与 Outbox 同事务;进程崩溃后可恢复;无事件丢失 | P0 | 迭代 2 | 有表和写入基础,发布未完成 | -| P1-010 | 头程任务创建与幂等消费 | 订单受理事件 | 唯一头程任务、消费记录 | P1-009 | 同一事件重复 10 次只创建一个任务;失败可重试 | P0 | 迭代 2 | 任务、消费表和分配基础已有 | -| P1-011 | 国内仓到仓预约与收货 | 头程任务、预约信息、实收件 | 入库单、收货时间和操作人 | Warehouse、Firstleg | 可登记预约和实收;租户/订单引用有效;完整审计 | P0 | 迭代 2 | 待实现 | -| P1-012 | 测量与差异处理 | 实收货物、实测重量体积 | 测量记录、差异结果、确认记录 | P1-011 | 原始值和实测值可追溯;超阈值必须人工确认后继续 | P0 | 迭代 2 | 待实现 | -| P1-013 | 仓库及往来单位基础数据完善 | 仓库、地址、能力、往来单位联系人与地址、状态 | 可供商户选择的有效仓库和往来单位列表 | Warehouse | 租户隔离、停用过滤、商户只读查询、订单快照校验通过 | P1 | 迭代 2 | 已补查询、过滤、样本和订单校验 | -| P1-014 | 头程排柜与订舱 | 合格入库货物、航线和柜型 | 柜计划、订舱单、航次信息 | P1-012、Integration | 容量校验、重复分配保护、订舱失败可重试且不重复 | P0 | 迭代 3 | 待实现 | -| P1-015 | 拖车、装柜和证据链 | 柜计划、装柜货物 | 装柜明细、柜号、封条号、照片文件引用 | P1-014、File | 装柜数量一致;照片摘要可校验;柜号封条唯一;留审计 | P0 | 迭代 3 | 待实现 | -| P1-016 | 报关与放行 | 货物、企业资质、装柜数据 | 报关单、申报结果、放行状态 | File、Integration | 资料版本可追溯;失败可重提;未放行禁止发运完成 | P0 | 迭代 3 | 待实现 | -| P1-017 | 装船发运与不可变交接快照 | 放行状态、航次、柜和文件清单 | `FirstLegShipmentDeparted.v1` 与快照 | P1-015、P1-016 | 仅满足前置条件可发运;重复操作同一结果;快照有版本和摘要 | P0 | 迭代 3 | 待实现 | -| P1-018 | 事件信封与兼容性测试 | 事件目录、字段定义 | 版本化契约、示例、校验测试 | P1-001 | 必填字段、区域码、版本、摘要校验通过;旧消费者兼容新增字段 | P0 | 迭代 3 | 基础契约已实现 | -| P1-019 | 事件桥可靠发送基础 | Outbox 事件 | 签名请求、投递状态、重试和死信 | P1-009、P1-018 | 超时重试有上限;签名失败拒绝;支持人工补投和监控 | P0 | 迭代 4 | 内部令牌、目标区域和 Inbox 幂等已实现并通过 8 项测试;mTLS、跨区发送、RocketMQ、Outbox 扫描待实现 | -| P1-020 | 尾程 Inbox 与交接回执 | 发运交接事件 | 幂等 Inbox、尾程聚合、接受/拒绝回执 | P1-017、P1-019 | 重复 10 次只创建一次;坏摘要拒绝;旧版本不覆盖新版本 | P0 | 迭代 4 | 待实现 | -| P1-021 | 尾程一期最小阶段 | 已接受交接 | 到港前、到港、到仓、出仓、签收状态 | P1-020 | 五阶段正常流转;非法逆序拒绝;异常原因和操作者可查 | P0 | 迭代 4 | 待实现 | -| P1-022 | 国内统一轨迹投影 | 订单、头程和尾程里程碑事件 | 商户订单时间线 | P1-018、P1-021 | 最终一致;乱序不回退;重复不新增;展示最后同步时间 | P0 | 迭代 4 | 待实现 | -| P1-023 | 异步订单通知 | 领域事件、订阅规则、模板 | 站内通知及发送记录 | P1-022 | 通知失败不回滚业务;可重试、死信、去重;租户隔离 | P0 | 迭代 4 | 待实现 | -| P1-024 | 文件对象存储适配 | 现有本地文件接口 | 存储抽象、对象键、短期下载授权 | 对象存储 | 本地与对象存储实现可切换;SHA-256、大小、类型和下载审计通过 | P0 | 迭代 3 | 当前本地磁盘,待改造 | -| P1-025 | 打印最小闭环 | 模板、订单/仓储数据 | 打印任务、生成文件、结果历史 | File、Print | 可创建模板版本和任务;失败可重试;任务关联 `file_id` | P1 | 迭代 4 | 表结构已有,执行待实现 | -| P1-026 | 关键动作审计覆盖 | 写接口和领域事件清单 | 审计记录 | Audit、各业务服务 | 登录、授权、审核、状态推进、交接、补投均记录租户和追踪号 | P0 | 迭代 1-4 | 采集基础已有,覆盖待核验 | -| P1-027 | 可观测性和告警 | 服务、Outbox、Inbox、数据库指标 | 仪表盘和告警规则 | 全部核心服务 | 事件积压、死信、5xx、鉴权失败、数据库连接异常可告警 | P0 | 迭代 4 | 待实现 | -| P1-028 | 一期自动化业务验收 | 测试账号、测试订单和故障场景 | 验收报告与证据 | P1-003 至 P1-027 | 主链路、重复、乱序、断网 30 分钟、跨租户、回滚场景全部通过 | P0 | 迭代 5 | 前端联调范围 105 项真实 API 检查通过;完整履约、跨区断网和乱序验收待补 | -| P1-029 | 国内生产部署与回滚演练 | 镜像、配置、SQL、备份 | 国内上线版本和回滚记录 | P1-028 | 标准端口无冲突;密钥不入库;备份恢复和旧版本回滚演练通过 | P0 | 迭代 5 | 待实现 | -| P1-030 | 海外迁移准备包 | 区域配置、数据分类和事件链路 | 海外资源清单、切流门槛、回滚手册 | P1-019 至 P1-029 | 不依赖国内 MySQL/Redis/Nacos;影子环境可重复部署 | P1 | 迭代 5 | 结构已拆,生产准备未完成 | - -## 6. 建议迭代与出口条件 - -| 迭代 | 目标 | 出口条件 | -| --- | --- | --- | -| 迭代 0:边界冻结 | 冻结状态机、事件、数据库和部署边界 | 审核本文及区域方案;契约有负责人和版本 | -| 迭代 1:账号权限 | 完成注册审核、激活、登录和按钮鉴权 | 两端权限与跨租户自动化通过,关键动作有审计 | -| 迭代 2:订单到入仓 | 订单受理后创建头程任务并完成到仓测量 | 正常、拒绝、取消、重复事件和差异处理通过 | -| 迭代 3:头程发运 | 完成订舱、装柜、报关、对象存储和发运快照 | 可稳定生成唯一且可校验的交接事件 | -| 迭代 4:尾程回环 | 在国内打通事件桥、Inbox、尾程五阶段、轨迹和通知 | 断网恢复、重复、乱序、死信补投和跨租户测试通过 | -| 迭代 5:上线准备 | 自动化验收、容量、安全、备份和回滚 | 国内一期上线审批通过;海外准备包可审核 | - -## 7. 一期验收数据与场景 - -至少准备两个商户租户、一个平台管理员、一个审核员、一个订单运营、两个企业成员、两个国内仓、一条正常订单、一条拒绝订单和一条差异订单。 - -必须自动化覆盖: - -1. 商户甲不能读取或操作商户乙的成员、订单、货物、文件和轨迹。 -2. 无按钮权限的用户直接调用写接口返回 403。 -3. 未审核、被拒绝、禁用租户不能提交订单。 -4. 同一订单事件重复投递 10 次只生成一个头程任务。 -5. 同一发运交接事件重复投递 10 次只生成一个尾程聚合。 -6. 尾程里程碑乱序到达时,旧版本不能覆盖新状态。 -7. 事件通道中断 30 分钟不影响国内订单和头程提交,恢复后自动补投。 -8. 通知发送失败不回滚履约状态,并能重试或进入死信。 -9. 文件下载必须短期授权、校验租户并写下载审计。 -10. 关键状态推进、人工覆盖和人工补投均可按追踪号还原操作链。 - -## 8. 当前可复用的验收基线 - -已有一期管理员、角色、菜单和按钮初始化记录,以及登录、权限查询、跨端隔离等 19 项自动化冒烟记录;根工程曾有 20 个测试通过记录。这些结果可以作为权限基线,但不能替代本方案中的注册审核、仓内作业、头程发运、尾程交接、可靠事件、轨迹和通知验收。 - -一期在国内同时运行尾程时,应使用已提供的国内兼容 Profile 构建: - -```powershell -cd D:\baiyue\deployments\cn -.\build.ps1 -IncludePhase1Lastleg -``` - -默认国内聚合为 18 个 Reactor 模块;上述命令启用 `phase1-domestic-lastleg` 后为 19 个 Reactor 模块并包含 `lastleg`。国内和海外聚合均已包含 `common-event-contract` 与 `event-bridge-service`,不需要另行手工加入。 - -执行新一轮验收前,应先清理旧端口进程并以最新代码、正确数据库密码和标准端口重新启动,避免把临时 9001/9301 验收环境当作正式运行配置。 - -## 9. 审核后立即执行的 P0 顺序 - -1. 审核并冻结 `P1-001` 的业务状态与事件交接点。 -2. 完成 `P1-004`、`P1-006`,关闭注册审核与后端按钮鉴权缺口。 -3. 完成 `P1-009`、`P1-010`,先把订单到头程任务的可靠链路跑通。 -4. 完成 `P1-011` 至 `P1-017`,形成真实发运交接快照。 -5. 完成 `P1-019` 至 `P1-023`,在国内回环验证尾程、轨迹和通知。 -6. 完成 `P1-027` 至 `P1-029`,通过故障演练后部署国内一期。 - -海外服务器、海外数据库和正式切流放在国内回环可靠性验收之后,避免在业务闭环尚未形成时同时引入跨国网络问题。 diff --git a/docs/cross-region-firstleg-lastleg-deployment-plan.md b/docs/cross-region-firstleg-lastleg-deployment-plan.md deleted file mode 100644 index e108a3dd9..000000000 --- a/docs/cross-region-firstleg-lastleg-deployment-plan.md +++ /dev/null @@ -1,494 +0,0 @@ -# 头程国内、尾程海外的跨区域微服务部署方案 - -更新时间:2026-07-29 -依据:当前 `D:/baiyue` Spring Cloud Alibaba 微服务、`悦/头程物流流程图.svg`、头程下单原型和货代后台原型。 - -> 审核状态:待用户审核。本文是项目拆分、部署和迁移的实施方案;文中目标架构、海外资源和切流步骤不代表已经完成生产部署。当前生成的部署目录仅是同一 Git 仓库内的 Maven 聚合和配置骨架。 - -## 1. 结论 - -推荐采用“国内业务单元 + 海外业务单元 + 跨区域可靠事件桥”,不要把同一套 Nacos、MySQL、Redis 或一个微服务调用链直接横跨国内外服务器。 - -这里的“国内业务单元、海外业务单元”首先是代码、数据库和数据所有权边界,不等于一期必须立即购买海外服务器。当前项目应立即按两个部署单元拆分配置、服务职责和事件契约,但一期允许所有服务都部署在国内同一机房:`lastleg-service`、海外仓实例和海外集成实例先以 `REGION_CODE=CN` 运行。待事件链路、鉴权和对象存储满足迁移门槛后,再把尾程写入单元迁到海外,不需要重新拆项目。 - -```text -国内单元:客户、订单、头程、国内仓、认证和权限的主数据 -海外单元:尾程、海外仓、清关、提柜、派送、POD、Amazon SP-API -跨区交接:本地事务 Outbox -> HTTPS/mTLS 事件中继 -> Inbox 去重 -> 处理回执 -统一轨迹:海外发布里程碑事件,国内保存只读投影供客户查询 -``` - -这一方案符合原型的业务边界: - -- 头程在国内完成“下单 → 到仓 → 出仓 → 装船发运”。 -- 尾程在海外完成“到港前 → 到港 → 到仓 → 出仓 → 签收”。 -- “装船发运”是两个区域的数据所有权交接点。 - -### 1.1 当前已生成的部署骨架 - -| 路径 | 当前用途 | 当前状态 | -| --- | --- | --- | -| `D:/baiyue/deployments/cn` | 国内部署单元 Maven 聚合、环境变量示例和构建脚本 | 已生成,待审核;一期实际部署入口 | -| `D:/baiyue/deployments/overseas` | 海外部署单元 Maven 聚合、环境变量示例和构建脚本 | 已生成,待审核;暂不代表海外服务器已部署 | -| `D:/baiyue/common-event-contract` | 国内外共享的事件信封、类型和区域契约 | 已生成基础源码,6 个单元测试通过;尚未接入业务事件链路,不得视为可用于生产 | -| `D:/baiyue/event-bridge-service` | 跨区事件接收幂等、待发送事件和投递尝试的独立服务边界 | 已生成基础骨架,8 项测试通过;内部令牌、目标区域校验和 Inbox 幂等接收已实现,生产传输开关为关闭 | - -两个部署目录引用根仓库现有模块,不复制业务源码,也不建立第二个 Git 仓库。构建命令为: - -国内和海外聚合均已包含 `common-event-contract` 与 `event-bridge-service`。国内默认构建为 18 个 Reactor 模块;一期尾程临时在国内运行时,使用 `phase1-domestic-lastleg` Profile 构建 19 个 Reactor 模块并包含 `lastleg`。 - -```powershell -cd D:\baiyue\deployments\cn -.\build.ps1 - -# 一期尾程临时部署在国内 -.\build.ps1 -IncludePhase1Lastleg - -cd D:\baiyue\deployments\overseas -.\build.ps1 -``` - -只校验默认聚合关系时,在对应目录执行 `mvn -f pom.xml -DskipTests validate`;校验一期国内尾程兼容聚合时执行 `mvn -f pom.xml -Pphase1-domestic-lastleg -DskipTests validate`。事件契约可在根目录执行 `mvn -pl common-event-contract -am test`。以上构建通过只说明代码依赖和测试通过,不等于生产部署、网络连通或数据迁移验收通过。 - -## 2. 当前代码现状判断 - -| 模块 | 当前情况 | 对跨区部署的影响 | -| --- | --- | --- | -| `gateway` | 已按服务名走 Nacos 和 `lb://` 路由 | 可以分别部署国内、海外 Gateway,但必须连接各自区域的 Nacos | -| `auth-service` | 国内统一登录,HS256 JWT | 可先放国内;正式跨区前建议改为 RS256/ES256 + JWKS | -| `iam-service` | 租户、角色、菜单、按钮权限 | 主库建议放国内,海外通过 Token 权限快照工作,不应每次跨区查询 IAM | -| `order-service` | 已有下单和状态机,也有 `ord_outbox_event` | Outbox 只是表和写入逻辑,尚缺可靠发布器、MQ/中继和跨区消费者 | -| `firstleg-service` | 目前只有健康检查、任务分配等少量能力 | 原型中的到仓、测量、排柜、订舱、装柜、报关等还需实现 | -| `lastleg-service` | 当前基本只有健康检查 | 还没有到港前、清关、提柜、海外仓、派送、POD 的业务闭环 | -| `warehouse-service` | 当前是一个服务边界 | 需要区分国内仓实例和海外仓实例,各自独占区域数据库 | -| `file-service` | 当前默认使用本地磁盘 | 跨区域部署不能继续依赖单机 `D:/...`,需要迁移到 OSS/S3 类对象存储 | -| `integration-service` | 已预留百度 OCR、Amazon SP-API 配置 | 国内实例启用百度 OCR;海外实例启用 Amazon SP-API 和当地承运商接口 | -| `event-bridge-service` | 已有端口 9300、独立库和三张基础表;内部服务令牌、目标区域、事件信封与摘要校验及 Inbox 幂等已实现,8 项测试通过 | mTLS、来源白名单、RocketMQ、真实跨公网 HTTP 投递、Outbox 扫描调度和业务消费者尚未实现,不能用于生产交接 | - -因此现在可以先搭好部署骨架和事件契约,但不能把 `lastleg-service` 部署到海外后就认定尾程业务已经完成。 - -## 3. 推荐部署拓扑 - -```mermaid -flowchart LR - FE[客户/平台前端] --> EDGE[统一域名 + CDN/WAF/全局路由] - - subgraph CN[国内业务单元] - CNGW[gateway-cn] - AUTH[auth-service] - IAM[iam-service] - ORDER[order-service] - FIRST[firstleg-service] - CNWMS[warehouse-service CN] - CNFILE[file-service CN] - CNOCR[integration-service Baidu OCR] - CNDB[(国内独立 MySQL)] - CNREDIS[(国内 Redis)] - CNNACOS[Nacos prod-cn] - CNOUT[Outbox Publisher] - CNPROJ[统一轨迹投影] - end - - subgraph OS[海外业务单元] - OSGW[gateway-overseas] - LAST[lastleg-service] - OSWMS[warehouse-service OVERSEAS] - OSFILE[file-service OVERSEAS] - OSINT[integration-service Amazon/海关/承运商] - OSDB[(海外独立 MySQL)] - OSREDIS[(海外 Redis)] - OSNACOS[Nacos prod-overseas] - OSIN[Inbox Consumer] - OSOUT[Outbox Publisher] - end - - EDGE -->|Auth/IAM/Order/First-leg| CNGW - EDGE -->|Last-leg/海外仓作业| OSGW - CNGW --> AUTH & IAM & ORDER & FIRST & CNWMS & CNFILE - OSGW --> LAST & OSWMS & OSFILE & OSINT - FIRST --> CNOUT - CNOUT -->|HTTPS 443 + mTLS| OSIN - OSIN --> LAST - LAST --> OSOUT - OSOUT -->|里程碑/回执 HTTPS 443 + mTLS| CNPROJ -``` - -### 3.1 国内部署的服务 - -| 服务 | 是否国内主部署 | 说明 | -| --- | --- | --- | -| `gateway` | 是 | 国内客户、平台后台入口 | -| `auth-service` | 是 | 账号登录、刷新、退出的唯一写入口 | -| `iam-service` | 是 | 企业、成员、角色、菜单和按钮权限主数据 | -| `order-service` | 是 | 商户下单、审核、受理以及订单主档 | -| `firstleg-service` | 是 | 到国内仓、测量差异、排柜、订舱、拖车、装柜和国内报关 | -| `warehouse-service` | 是 | 国内仓资料和国内仓作业数据 | -| `file-service` | 是 | 企业资质、国内报关资料、装柜照片等国内文件入口 | -| `integration-service` | 是 | 百度 OCR、国内报关代理或港务接口适配 | -| `audit-service` | 是 | 国内业务审计主记录 | -| `print-service` | 按需 | 国内标签、箱唛、作业单打印 | - -### 3.2 海外部署的服务 - -| 服务 | 是否海外部署 | 说明 | -| --- | --- | --- | -| `gateway` | 是 | 海外运营后台、海外 PDA 和承运商回调入口 | -| `lastleg-service` | 是 | 到港前、清关、提柜、到仓、派送、签收和异常主服务 | -| `warehouse-service` | 是 | 海外仓预约、开柜、清点、打托、拣货和装车 | -| `file-service` | 是 | BOL、DO/PU、开柜照片、POD 等海外文件 | -| `integration-service` | 是 | Amazon SP-API、FBA 预约、海关、码头和海外承运商适配 | -| `audit-service` | 是 | 海外操作本地审计;摘要可异步回传国内 | -| `print-service` | 按需 | BOL、派送单、海外标签和仓内作业单 | - -海外不部署 `order-service` 和 `firstleg-service` 的写实例,国内也不部署 `lastleg-service` 的写实例,避免同一业务聚合在两个区域同时写入。 - -### 3.3 一期与目标物理归属 - -| 服务 | 一期物理位置 | 后期主写位置 | 迁移说明 | -| --- | --- | --- | --- | -| `auth-service`、`iam-service` | 国内 | 国内 | 统一登录和权限主数据保持国内唯一写入 | -| `order-service`、`firstleg-service` | 国内 | 国内 | 订单和头程事实保持国内唯一写入 | -| `lastleg-service` | 国内 | 海外 | 一期先在国内运行;后期只把新创建的尾程聚合切到海外,旧单在国内排空 | -| `warehouse-service` | 国内单实例 | 国内仓实例在国内,海外仓实例在海外 | 同一代码按区域配置部署,数据库完全隔离 | -| `file-service` | 国内 | 国内、海外各一套 | 文件元数据和对象桶按数据产生区域归属,按业务需要复制 | -| `integration-service` | 国内 | 国内、海外各一套 | 国内启用 OCR/港务适配,海外启用 Amazon/海关/承运商适配 | -| `audit-service`、`print-service` | 国内 | 按区域各自部署 | 本地操作本地留痕和打印,不跨区同步调用 | -| `gateway`、Nacos、Redis | 国内 | 国内、海外各一套 | 两区互不注册、互不直接访问 Redis | - -## 4. 原型节点与服务归属 - -### 4.1 国内头程 - -| 原型阶段 | 原型动作 | 主要服务 | -| --- | --- | --- | -| 下单 | 客户下单、委托下单、快速下单、换标下单 | `order-service`;一期仅实现普通头程订单,不扩展快速/换标完整产品 | -| 到仓 | 预约取货、送货上门 | `firstleg-service` + 国内 `warehouse-service` | -| 到仓 | 入库测量、差异核对、指定库位 | 国内 `warehouse-service`,结果事件回写 `firstleg-service` | -| 出仓 | 智能排柜、订舱、预约拖车 | `firstleg-service` | -| 出仓 | PDA 拣货、装柜、柜照和铅封照 | 国内 `warehouse-service` + `file-service` | -| 出仓 | 报关资料、代理报关、回执 | `firstleg-service` + 国内 `integration-service` + `file-service` | -| 出仓 | 港务信息、预计开船、到港 | `firstleg-service` + 船司/港务适配器 | -| 交接 | 装船发运 | `firstleg-service` 生成不可变交接快照并写 Outbox | - -### 4.2 海外尾程 - -| 原型阶段 | 原型动作 | 主要服务 | -| --- | --- | --- | -| 到港前 | FBA 仓预约、BOL 预清货、清关资料、货物中转备注 | `lastleg-service` + 海外 `integration-service` | -| 到港 | 清关放行、DO/PU、通知码头车队提柜 | `lastleg-service` | -| 到仓 | 开柜拍照比对、PDA 打托、派送车队预约 | `lastleg-service` + 海外 `warehouse-service` + `file-service` | -| 出仓 | BOL 送货、PDA 拣货装车、整柜派送、自提出仓 | `lastleg-service` + 海外 `warehouse-service` | -| 签收 | 私人仓/FBA 签收回执、送仓异常 | `lastleg-service` + `file-service` + Amazon/承运商适配器 | - -## 5. 跨区交接设计 - -### 5.1 正确交接点 - -当前 `order-service` 在订单 `APPROVED` 时写 `FIRST_LEG_ORDER_APPROVED`,这不是尾程交接事件。尾程只能在真实“装船发运”完成后建立预到港任务。 - -建议新增: - -```text -FirstLegShipmentDeparted.v1 -LastMileHandoffAccepted.v1 -LastMileHandoffRejected.v1 -LastMileMilestoneChanged.v1 -LastMileCompleted.v1 -``` - -### 5.2 交接事件示例 - -```json -{ - "eventId": "01J...ULID", - "eventType": "FirstLegShipmentDeparted", - "eventVersion": 1, - "occurredAt": "2026-07-29T16:30:00+08:00", - "traceId": "trace-xxx", - "sourceRegion": "CN", - "operatorTenantId": 10001, - "customerTenantId": 20001, - "aggregateId": 90001, - "aggregateVersion": 7, - "handoverVersion": 1, - "payload": { - "firstLegOrderIds": [80001, 80002], - "containerNo": "MSCU1234567", - "sealNo": "SEAL123456", - "vesselName": "EXAMPLE VESSEL", - "voyageNo": "V001", - "mblNo": "MBL001", - "hblNos": ["HBL001"], - "etd": "2026-07-30T10:00:00+08:00", - "eta": "2026-08-18T09:00:00-07:00", - "destinationPortCode": "USLAX", - "destinationWarehouse": { - "type": "FBA", - "code": "LGB8" - }, - "cargoSnapshot": [], - "documentRefs": [], - "checksum": "sha256:..." - } -} -``` - -文件正文不要放进消息,只传文件 ID、版本、SHA-256 和一次性下载凭证。海外确认保存后再返回 `LastMileHandoffAccepted`。 - -### 5.3 可靠性规则 - -1. 头程状态更新、状态历史和 Outbox 必须在一个国内 MySQL 本地事务中完成。 -2. 国内发布器轮询 `PENDING/RETRY` Outbox,使用 HTTPS 443 + mTLS 投递到海外事件入口。 -3. 海外先按 `eventId` 写 Inbox,唯一索引去重,再创建尾程交接记录。 -4. 消费失败时不确认;重复投递必须返回相同业务结果。 -5. 海外校验通过后写本地 Outbox,异步回传 Accepted/Rejected 回执。 -6. 国内收到 Accepted 后才把交接状态标记为 `ACCEPTED`,但不能回滚已经真实发生的装船事实。 -7. 修改交接数据要发布更高 `handoverVersion` 的新事件,不能覆盖旧快照。 -8. 跨区断网时,两个区域本地业务继续运行;Outbox 积压告警,网络恢复后自动补投。 - -不建议让 `firstleg-service` 通过 OpenFeign 跨公网同步调用 `lastleg-service` 创建尾程单。国际链路一次超时就会把真实装船操作拖入不确定状态。 - -### 5.4 Topic、事件桥和订单通知 - -一期不强制引入跨区 MQ,可由 Outbox 发布器直接通过 HTTPS/mTLS 投递;引入 RocketMQ 后,国内和海外各使用独立集群,禁止一个 Broker 集群横跨两区。建议 Topic 如下: - -| Topic | 方向 | 主要事件 | -| --- | --- | --- | -| `shipment-handover-cn` | 国内 → 海外 | `FirstLegShipmentDeparted.v1` | -| `shipment-handover-result-os` | 海外 → 国内 | `LastMileHandoffAccepted.v1`、`LastMileHandoffRejected.v1` | -| `shipment-milestone-os` | 海外 → 国内 | `LastMileMilestoneChanged.v1`、`LastMileCompleted.v1` | -| `security-session-cn` | 国内 → 海外 | 会话撤销、账号禁用、权限版本变化 | - -同一尾程聚合以 `aggregateId` 作为顺序键;只能保证同一聚合有序,不追求全局有序。事件入 Inbox 并提交本地事务后才返回成功,失败采用有上限的指数退避,超过阈值进入死信并告警。 - -短信、邮件、站内信和企业微信等客户通知不得放在订单事务内同步发送。国内 `notification-service` 或通知消费者订阅国内订单事件及海外回传的里程碑事件,按 `eventId + channel + recipient` 幂等发送;承运商回调在海外本地处理,再通过里程碑事件回传国内。 - -## 6. Gateway 与流量路由 - -建议对外保持一个域名,例如 `api.example.com`,由 CDN/WAF/全局流量入口按路径转发: - -| 路径 | 目标区域 | -| --- | --- | -| `/api/auth/**` | 国内 Gateway | -| `/api/platform/v1/iam/**`、`/api/merchant/v1/iam/**` | 国内 Gateway | -| `/api/merchant/v1/orders/**`、`/api/platform/v1/orders/**` | 国内 Gateway | -| `/api/merchant/v1/first-leg/**`、`/api/platform/v1/first-leg/**` | 国内 Gateway | -| `/api/merchant/v1/last-leg/**`、`/api/platform/v1/last-leg/**` | 海外 Gateway | -| 海外仓/PDA 专用路径 | 海外 Gateway | - -国内 Gateway 只通过国内 Nacos 发现本地服务;海外 Gateway 只通过海外 Nacos 发现本地服务。跨区内部事件入口不暴露在普通业务 Gateway 上,应使用独立内部域名、WAF 白名单、mTLS 和消息签名。 - -如果暂时没有全局流量产品,可以先使用两个域名: - -```text -https://api-cn.example.com -https://api-us.example.com -``` - -前端通过同一个 API SDK 根据业务域选择基础地址,不要让浏览器知道 Nacos 服务名。 - -一期即固定对外域名和证书,域名先指向国内 WAF/负载均衡;计划切换前至少 7 天把 DNS TTL 调整到 300 秒。后期统一域名由全局流量入口按路径转发,`auth/iam/order/first-leg` 固定国内,`last-leg` 才允许按灰度规则转发海外。DNS 只负责稳定入口,不用 DNS 权重承担租户或订单级灰度,因为 DNS 缓存无法保证同一业务聚合始终进入同一区域。 - -灰度期可先使用 `api-us-canary.example.com` 供指定运营账号和租户验证,正式流量由 Gateway 根据可信的 `execution_region` 路由。Gateway 必须删除客户端伪造的区域头。若前端需要访问两个域名,CORS 只允许明确的生产前端域名、必要方法和请求头;启用 Cookie 时禁止 `Access-Control-Allow-Origin: *`,并统一处理预检请求。优先使用同一对外域名,减少浏览器跨域和登录态问题。 - -## 7. Nacos 设计 - -不要使用一个 Nacos 集群横跨国内和海外,也不要让海外服务连接国内 `8848`。 - -| 区域 | Nacos | Namespace | Group | 注册服务示例 | -| --- | --- | --- | --- | --- | -| 国内 | `nacos-cn.internal` | `prod-cn` | `BAIYUE` | gateway、auth、iam、order、firstleg、国内 warehouse/file/integration | -| 海外 | `nacos-overseas.internal` | `prod-overseas` | `BAIYUE` | gateway、lastleg、海外 warehouse/file/integration | - -两个区域可以保留相同服务名,例如都叫 `warehouse-service`,因为它们位于不同 Nacos 集群。配置必须增加: - -```text -REGION_CODE=CN / OVERSEAS -DEPLOYMENT_CELL=prod-cn / prod-overseas -DATA_RESIDENCY=CN / OVERSEAS -``` - -当前各服务的 `spring.config.import` 固定读取 `group=BAIYUE`,所以两个区域先保持相同 Group,通过独立集群和 Namespace 隔离。若后续希望使用 `BAIYUE-CN/BAIYUE-US`,必须先把所有服务配置导入 URI 中的 Group 改成环境变量。 - -生产密钥不得放进 Nacos 明文配置,应进入云 KMS、Secret Manager 或主机受限环境变量。 - -## 8. 数据库、Redis 和文件边界 - -### 8.1 数据库 - -- 国内 MySQL:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_firstleg`、`baiyue_warehouse_cn`、`baiyue_file_cn`、`baiyue_integration_cn`、`baiyue_audit_cn`、`baiyue_print_cn`。 -- 一期尾程仍在国内使用 `baiyue_lastleg_cn`,库名提前带区域后缀,避免迁移时混淆数据所有权。 -- 海外 MySQL:`baiyue_lastleg_os`、`baiyue_warehouse_os`、`baiyue_file_os`、`baiyue_integration_os`、`baiyue_audit_os`、`baiyue_print_os`。 -- 禁止跨区数据库外键、跨区事务和双写。 -- 国内只保存海外状态的只读轨迹投影;海外仍是尾程事实的唯一写入者。 -- ID 继续使用应用生成的雪花 ID/ULID,避免区域数据库自增 ID 冲突。 - -数据库迁移优先采用“旧单排空、新单切换”,不移动执行中的尾程聚合。确需迁移历史数据时,先全量复制,再用 Binlog/CDC 追平;切换前短暂停止对应聚合写入,确认延迟为零、行数和关键字段校验一致后,才允许海外库转为主写。国内旧库在回滚观察期保持只读,不得与海外库同时写入。 - -### 8.2 Redis 与登录会话 - -当前各 Servlet 服务会访问 Redis 检查 Access Token 的 `jti` 会话键。若海外 `lastleg-service` 直接读取国内 Redis,跨区断网会导致海外所有请求鉴权失败。 - -推荐目标方案: - -1. Auth 使用 RS256/ES256 签发短期 Access Token,海外 Gateway/服务通过 JWKS 本地缓存验签。 -2. Access Token 有效期缩短到 10~15 分钟。 -3. 登录、撤销、禁用账号发布 `SessionIssued/SessionRevoked/AccountDisabled` 事件,复制到海外本地 Redis。 -4. 海外服务只访问海外 Redis,不访问国内 Redis。 - -以上改造是海外影子环境上线前置条件,不应放到海外正式切流之后。当前代码仍使用 HS256,并要求通过 Redis 中存在 `jti` 才允许访问;迁移时先支持带 `kid` 的 RS256/ES256 Token 和 JWKS 本地缓存,再将海外校验改为“本地验签 + 短有效期 + 本地撤销集合”。私钥只保存在国内 Auth,海外只持有公钥。过渡期国内可短暂兼容旧 Token,但海外灰度账号必须重新登录取得新 Token,稳定后移除 HS256。 - -PoC 阶段可以通过专线/VPN临时读取国内 Redis,但不能作为正式生产方案;Redis 的 `6379` 绝不能暴露公网。 - -### 8.3 文件 - -当前本地磁盘文件存储不适合双区域。建议: - -- 国内文件进入国内 OSS/COS/S3 兼容桶。 -- 海外文件进入海外区域对象存储桶。 -- 只复制尾程履约必需的报关资料、BOL、箱单和授权照片。 -- 文件下载使用短期预签名 URL,跨区域复制前做病毒扫描、MIME/魔数和 SHA-256 校验。 -- POD 等海外原始文件保留在海外,国内保存合规副本或文件引用。 - -跨境传输企业、联系人、收货地址和报关资料前,应完成数据分类、最小化、访问审计和适用地区的合规评估。 - -## 9. 统一客户轨迹 - -客户不应为了看完整物流轨迹分别查询国内、海外两套数据库。建议国内新增“统一轨迹只读投影”,可以先放在 `order-service`,后续独立为 `tracking-query-service`。 - -```text -头程本地状态 -> 国内轨迹投影 -海外 LastMileMilestoneChanged -> 跨区事件 -> 国内轨迹投影 -客户 GET /api/merchant/v1/orders/{id}/timeline -> 只查国内投影 -``` - -轨迹投影允许最终一致,目标延迟 P95 小于 30 秒。页面应展示“最后同步时间”,跨区中断时显示“海外轨迹同步延迟”,不能把旧状态误报成业务失败。 - -## 10. integration-service 的区域部署 - -现有 `integration-service` 不需要再增加 `common-sdk`。同一模块按区域配置不同适配器: - -| 国内实例 | 海外实例 | -| --- | --- | -| `BAIDU_OCR_ENABLED=true` | `BAIDU_OCR_ENABLED=false` | -| `AMAZON_SP_API_ENABLED=false` | `AMAZON_SP_API_ENABLED=true` | -| 国内港务/报关代理适配器 | Amazon SP-API、海外海关、码头、车队/承运商适配器 | - -各业务服务只调用内部抽象接口,不直接依赖百度或 Amazon SDK。第三方回调必须校验签名、保存原始回调摘要,并按第三方事件 ID 幂等处理。 - -## 11. 网络和安全 - -1. 国内与海外通过站点到站点 VPN、云企业网或专线连接;跨区业务事件只开放 HTTPS 443。 -2. `3306`、`6379`、`8848`、MQ Broker 端口不得暴露公网。 -3. 跨区事件入口使用 mTLS、IP 白名单、时间戳、nonce 和 HMAC/数字签名,防重放。 -4. Gateway 清除外部伪造的 `X-User-Id`、`X-Tenant-Id`、`X-App-Code` 等内部头。 -5. 国内和海外使用不同数据库账号、Redis密码、内部服务 Token 和对象存储凭证。 -6. 所有交接、清关放行、提柜、装车、签收和人工覆盖动作写不可篡改审计日志。 -7. 跨区链路设置独立限流、超时、熔断和重试策略,禁止无限重试。 - -## 12. 高可用与灾备规格 - -### 12.1 正式生产推荐 - -每个区域至少: - -- 2 个应用节点,前置负载均衡/WAF。 -- 3 节点 Nacos 集群或云托管注册配置中心。 -- MySQL 主备/托管高可用,开启 Binlog 和时间点恢复。 -- Redis 高可用或托管版。 -- 区域本地 MQ/事件中继服务。 -- 区域对象存储和跨可用区备份。 - -目标建议:核心写接口可用性 99.9%,数据库 RPO ≤ 5 分钟、RTO ≤ 60 分钟,跨区事件 P95 ≤ 30 秒。 - -### 12.2 低成本一期过渡 - -可以先使用国内、海外各一台应用服务器加托管 MySQL/Redis/对象存储,但必须接受单机故障风险,并至少做到: - -- Docker Compose 或 systemd 自动拉起。 -- 每日全备、持续 Binlog、异地备份。 -- HTTPS、WAF/安全组、只开放 80/443。 -- Outbox/Inbox、失败重试和人工补投工具先完成。 -- 上线前做跨区断网 30 分钟并恢复补投的演练。 - -## 13. 分阶段实施顺序 - -### 阶段 A:国内完成逻辑拆分并上线 - -1. 所有服务仍可只部署国内,但按 `CN/OVERSEAS` 部署单元拆分 Nacos 配置、库名、对象存储目录和服务启动清单。 -2. 为尾程聚合增加不可变的 `execution_region`,创建时确定归属,执行过程中不允许改变。 -3. 实现尾程五个阶段的国内版本,并将本地磁盘文件迁到国内对象存储。 -4. 国内先使用独立 `baiyue_lastleg_cn`,禁止尾程表与订单、头程或仓储跨库联表。 - -### 阶段 B:在国内打通可靠事件 - -1. 实现 `FirstLegShipmentDeparted.v1`、交接回执和里程碑事件。 -2. 完成 Outbox 发布器、Inbox 幂等、聚合版本检查、重试、死信、人工补投和通知消费者。 -3. 事件桥先在国内回环运行,验证跨服务契约,不把第一次验证留到跨国网络。 -4. 完成重复投递、乱序、30 分钟断网、文件复制失败和恢复补投演练。 - -### 阶段 C:建立海外影子环境 - -1. 建立独立海外 Nacos、Gateway、MySQL、Redis、对象存储和监控,不注册到国内 Nacos。 -2. 在切流前完成 RS256/ES256 + JWKS、海外本地撤销集合以及对象存储按需复制。 -3. 海外服务先只消费脱敏测试事件或生产镜像事件,禁止产生真实外部操作和业务回执。 -4. 对比国内结果与海外影子结果,连续 7 天满足数据、权限、性能和事件延迟门槛后才能灰度。 - -### 阶段 D:按新尾程聚合灰度 - -1. 先选择内部测试租户,再按 `1% → 10% → 30% → 100%` 放量,每档至少观察一个完整业务周期。 -2. 只有新创建的尾程聚合进入海外并永久写入 `execution_region=OVERSEAS`;已经在国内执行的旧单继续留在国内直至完成。 -3. Gateway 按聚合归属路由,不按一次请求随机分流;国内订单和统一轨迹查询入口保持不变。 -4. 每档验证交接成功率、事件积压、鉴权失败率、文件可用率和租户隔离后再继续放量。 - -### 阶段 E:旧单排空与正式收口 - -1. 国内停止创建新的尾程聚合,只保留旧单处理、查询和应急能力。 -2. 旧单全部完成并超过售后观察期后,将 `baiyue_lastleg_cn` 改为只读归档。 -3. 停止临时兼容路由和 HS256,海外成为尾程唯一写入单元;完成灾备和回滚演练后收口。 - -## 14. 上线验收门槛 - -- 国内 Nacos 停止海外注册后,国内服务仍能正常下单和执行头程。 -- 国际链路断开时,真实装船操作成功且事件保留在 Outbox,不产生半完成尾程单。 -- 网络恢复后,同一事件重复投递 10 次只创建一条尾程交接记录。 -- 海外拒绝不完整交接时,国内能收到结构化拒绝原因并进入人工处理。 -- 海外里程碑乱序到达时,较旧版本不能覆盖较新状态。 -- 海外 MySQL/Redis短暂不可用时,不影响国内订单和头程。 -- 商户 A 不能访问商户 B 的交接、柜、货物、文件和 POD。 -- 未授权用户即使直接调用清关放行、装车、签收接口也返回 403。 -- POD、清关资料和装柜照片下载均使用短期授权并留下审计记录。 -- 跨区事件积压、死信、延迟和回执缺失均有告警和补投手册。 - -### 14.1 灰度继续和回滚阈值 - -- 继续放量:跨区事件 P95 小于 30 秒、P99 小于 2 分钟,交接成功率不低于 99.9%,Inbox 重复创建业务记录为 0,必需文件 SHA-256 校验通过率 100%。 -- 暂停放量:最老 Outbox 积压超过 5 分钟、海外接口 P95 比国内基线升高 30%、新增 401/403 比基线升高 0.2 个百分点,或出现无法自动补投的死信。 -- 立即回滚:连续 5 分钟 5xx 超过 1%、发现跨租户访问、数据丢失/重复写入、错误业务状态推进或关键文件不可恢复。 -- 回滚先停止创建新的海外聚合,已有海外聚合原则上继续由海外处理,不能直接路由到国内旧库。若海外无法继续写入,必须先冻结写入,通过反向事件或 CDC 补齐国内应急库并校验后再切回;禁止两区同时写同一聚合。 -- 国内旧库、旧版本应用和路由配置至少保留 7 天回滚观察期。每次切换和回滚均记录审批人、时间点、数据校验结果和路由版本。 - -## 15. 近期 P0 清单 - -当前 P0 基线:部署聚合入口、公共事件契约和事件桥 Inbox 幂等基础骨架已经生成;头程真实发运、尾程聚合、可靠发布传输、统一轨迹和通知仍未闭环。 - -1. `P0-01`:冻结装船发运交接点和 `FirstLegShipmentDeparted.v1` 字段,给尾程聚合增加不可变 `execution_region`、聚合版本和唯一幂等键。验收:重复交接只创建一个聚合,旧版本不能覆盖新版本。 -2. `P0-02`:完成头程到仓、测量、排柜、订舱、装柜、报关和装船发运最小流程,生成带版本与摘要的不可变交接快照。验收:前置条件不满足不能发运,关键证据可审计。 -3. `P0-03`:在现有 `event-bridge-service` 基础上补齐 Outbox 扫描和发布状态机、有限重试、死信、人工补投及指标。验收:业务提交和事件不丢失,进程重启后可继续投递。 -4. `P0-04`:实现 Inbox 到尾程业务消费者和接受/拒绝回执,并完成尾程到港前、到港、到仓、出仓、签收五阶段。验收:重复、乱序、坏摘要和非法状态流转均有自动化测试。 -5. `P0-05`:先在国内回环打通事件桥,完成事件签名、来源白名单和 mTLS;RocketMQ 发布消费与真实跨区 HTTPS 投递按最终传输选型实现。验收:断网 30 分钟后可自动恢复补投,链路无跨库和同步跨区 Feign。 -6. `P0-06`:将文件存储抽象为对象存储,完成版本、病毒扫描、SHA-256 和短期下载授权。验收:交接所需文件可按清单受控复制且下载有租户审计。 -7. `P0-07`:把 JWT 改为 RS256/ES256 + JWKS,移除海外服务对国内 Redis 正向会话键的依赖。验收:海外断开国内 Redis 后仍可校验短期令牌和本地撤销信息。 -8. `P0-08`:建立 `CN/OVERSEAS` 配置模板、区域化数据库名和 `REGION_CODE/DEPLOYMENT_CELL/DATA_RESIDENCY` 启动校验。验收:区域错配时服务拒绝启动,两区不共享 MySQL、Redis 和 Nacos。 -9. `P0-09`:实现国内统一轨迹投影和异步订单通知。验收:重复不新增、乱序不回退、通知失败不回滚业务且可死信补投。 -10. `P0-10`:建立覆盖断网、重复、乱序、跨租户、文件失败和回滚的自动化验收。验收:国内回环全部通过后才允许建设海外影子环境。 - -## 16. 最终建议 - -现在最合适的落地路线不是立即购买很多服务器,而是先完成三个基础能力: - -1. 把 `firstleg-service` 的真实“装船发运”节点做出来。 -2. 把现有 Outbox 表补成可发布、可重试、可告警的可靠事件链路。 -3. 在国内先为 `lastleg-service` 实现 Inbox + 交接回执并验证五个尾程阶段,再将同一部署单元迁到海外影子环境。 - -完成这三项后,国内和海外才能真正成为两个可以独立运行、网络中断后仍可恢复的业务单元。 diff --git a/docs/database-schema.md b/docs/database-schema.md deleted file mode 100644 index eed58a2ad..000000000 --- a/docs/database-schema.md +++ /dev/null @@ -1,128 +0,0 @@ -# 数据库结构与表用途 - -## 数据归属原则 - -本项目采用单 Git 仓库、多 Maven 模块、多服务数据库架构。每个业务服务只访问自己的数据库,禁止跨库查询、跨库外键和共享业务表。服务之间只交换业务 ID,并通过 OpenAPI 或领域事件协作。开发环境可以共用一个 MySQL 8 实例,但 schema、数据库账号和迁移历史应保持独立。 - -商户业务数据必须包含 `tenant_id`。商户端接口只能从已认证 JWT 获取可信租户 ID,并将其加入每个数据查询条件;调用方不能通过请求参数自行选择租户。平台后台接口使用独立路由及 `PLATFORM` 应用身份,并显式指定目标租户。 - -## 数据库与表总览 - -| 服务 | 数据库 | 表 | 数据所有权 | -| --- | --- | --- | --- | -| `auth-service` | `baiyue_auth` | `auth_account`、`auth_refresh_token` | 登录凭据、账号安全状态、刷新令牌生命周期 | -| `iam-service` | `baiyue_iam` | `iam_*` | 租户、企业资料、用户、成员、双端角色、菜单/页面/按钮/API权限、数据范围 | -| `order-service` | `baiyue_order` | `ord_*` | 订单聚合、状态历史和待发布领域事件 | -| `warehouse-service` | `baiyue_warehouse` | `wh_*` | 仓库主数据、地址和服务能力 | -| `firstleg-service` | `baiyue_firstleg` | `fl_*` | 头程履约任务、指派过程和消息消费幂等 | -| `file-service` | `baiyue_file` | `file_*` | 文件元数据、临时下载授权和下载审计 | -| `print-service` | `baiyue_print` | `print_*` | 打印模板、打印任务和任务状态历史 | -| `event-bridge-service` | `baiyue_event_bridge` | `bridge_*` | 跨区事件接收幂等、待发送事件和投递尝试记录 | -| `audit-service` | `baiyue_audit` | `sys_audit_log` | 集中式、追加写入的业务审计轨迹 | -| `lastleg-service` | 后续阶段创建 | 暂无业务表 | 尾程派单、配送轨迹和签收凭证 | - -SQL 入口位于各服务的 `src/main/resources/db` 目录。 - -## 认证服务 - -| 表名 | 用途 | 关键字段 | -| --- | --- | --- | -| `auth_account` | 平台端与商户端共用的统一登录账号;保存密码哈希、锁定状态、令牌版本和默认租户/应用上下文。IAM用户资料与本表通过逻辑ID关联,本表不保存角色权限。 | `username`、`password_hash`、`account_status`、`locked_until`、`token_version`、`default_tenant_id`、`operator_tenant_id`、`default_app_code` | -| `auth_refresh_token` | 保存刷新令牌摘要以及签发时的租户和应用上下文,用于刷新令牌轮换、吊销和到期清理;不保存令牌明文。 | `token_hash`、`user_id`、`tenant_id`、`app_code`、`expires_at`、`revoked_at` | - -## IAM服务 - -| 表名 | 用途 | 关键字段 | -| --- | --- | --- | -| `iam_tenant` | SaaS租户主表,是商户业务数据隔离的根边界,保存企业基本资料、审核结果和启停状态。 | `tenant_code`、`tenant_type`、`status`、`unified_social_credit_code` | -| `iam_tenant_review` | 保存企业注册或资料变更的审核队列、审核人、意见和结果。 | `tenant_id`、`review_type`、`status`、`reviewer_id`、`reviewed_at` | -| `iam_tenant_address` | 保存企业注册地址、办公地址等租户级地址资料。 | `tenant_id`、`address_type`、`is_default` | -| `iam_tenant_qualification` | 保存企业证照、文件地址、有效期和资质审核状态。 | `tenant_id`、`qualification_type`、`document_url`、`valid_to`、`status` | -| `iam_user` | IAM用户资料,连接认证账号与业务身份;不保存登录密码。 | `auth_user_id`、`user_type`、`display_name`、`status` | -| `iam_membership` | 表示某用户在某企业租户中的成员身份,是分配商户端角色的主体。一个用户可加入多个租户。 | `tenant_id`、`user_id`、`member_type`、`status` | -| `iam_role` | 按 `tenant_id + app_code` 隔离角色,支持商户端 `MERCHANT` 与后台端 `PLATFORM` 两套角色体系。 | `tenant_id`、`app_code`、`role_code`、`role_type`、`status` | -| `iam_membership_role` | 成员与角色的多对多关系,描述成员在当前租户和应用端拥有的角色。 | `membership_id`、`role_id`、`tenant_id` | -| `iam_permission` | 统一权限资源树;`permission_type` 支持 `MENU`、`PAGE`、`BUTTON`、`API`,实现权限控制到按钮并将前端权限与后端API权限关联。 | `tenant_id`、`app_code`、`permission_type`、`parent_id`、`permission_code`、`route_path`、`http_method` | -| `iam_role_permission` | 角色与菜单、页面、按钮、API权限的多对多授权关系。 | `role_id`、`permission_id`、`tenant_id` | -| `iam_data_scope` | 在功能权限之外定义角色可查看/操作的数据范围。 | `role_id`、`tenant_id`、`scope_type`、`scope_expression` | - -## 订单服务 - -| 表名 | 用途 | 关键字段 | -| --- | --- | --- | -| `ord_order` | 头程订单聚合根,保存客户、仓库名称与地址、收货时间等下单快照,以及只读履约快递信息和当前状态。 | `tenant_id`、`order_no`、`customer_id`、`customer_name`、`receiving_warehouse_address`、`receiving_time`、`express_company`、`express_tracking_no`、`status`、`version` | -| `ord_order_party` | 固化下单时的发货人和收货人信息,避免外部主数据变化影响历史订单。 | `tenant_id`、`order_id`、`party_type` | -| `ord_order_cargo` | 保存订单货物行、包装件数、重量、体积、申报价值和危险品属性。 | `tenant_id`、`order_id`、`item_no`、`gross_weight_kg`、`volume_cbm` | -| `ord_order_internal_note` | 保存平台运营或履约人员的内部协作备注,不作为客户公开备注。 | `tenant_id`、`order_id`、`content`、`created_by` | -| `ord_order_status_history` | 以不可变记录保存订单每次状态迁移、业务动作、原因和操作人。 | `tenant_id`、`order_id`、`from_status`、`to_status`、`action`、`occurred_at` | -| `ord_outbox_event` | **事务 Outbox 可靠消息表**。订单服务在订单状态变更的同一本地事务中写入待发布领域事件,后台发布器再投递 RocketMQ,从而避免“数据库提交成功但消息发送失败”。它不是普通操作日志,也不是消费者幂等表。 | `aggregate_type`、`aggregate_id`、`event_type`、`payload_json`、`publish_status`、`retry_count`、`next_retry_at`、`published_at` | - -## 头程履约服务 - -| 表名 | 用途 | 关键字段 | -| --- | --- | --- | -| `fl_fulfillment_task` | 接收订单领域事件后形成独立头程履约任务,保存订单快照并管理指派、执行和完成状态。 | `tenant_id`、`order_id`、`order_snapshot_json`、`status`、`assignee_id`、`version` | -| `fl_assignment_history` | 不可变记录头程任务每次负责人指派/改派、原因和操作人。 | `tenant_id`、`task_id`、`previous_assignee_id`、`assignee_id`、`operator_id` | -| `fl_consumed_event` | **消费者幂等去重表**。记录已成功处理的领域事件ID,防止 RocketMQ 重复投递造成重复创建履约任务;它不是消息发布表。 | `event_id`、`event_type`、`consumed_at` | - -## 仓库服务 - -| 表名 | 用途 | 关键字段 | -| --- | --- | --- | -| `wh_warehouse` | 租户维度仓库聚合根,保存仓库编码、名称、类型、国家、时区、联系人和状态。 | `tenant_id`、`warehouse_code`、`warehouse_type`、`country_code`、`status` | -| `wh_warehouse_address` | 保存仓库运营、退货等不同用途的地址和可选经纬度。 | `tenant_id`、`warehouse_id`、`address_type` | -| `wh_service_capability` | 声明仓库可提供的收货、仓储、拣包、退货等服务能力及截单时间。 | `tenant_id`、`warehouse_id`、`capability_code`、`enabled`、`cutoff_time` | -| `wh_partner` | 仓储域内的租户往来单位配置,供订单选择客户、联系人和地址;商户候选查询强制过滤停用数据。 | `tenant_id`、`partner_code`、`partner_name`、`partner_type`、`contact_name`、`contact_phone`、`address_line`、`status` | - -## 文件中心服务 - -| 表名 | 用途 | 关键字段 | -| --- | --- | --- | -| `file_object` | 保存文件元数据、对象存储定位信息、校验值和生命周期状态;文件二进制存放在对象存储中。 | `tenant_id`、`original_name`、`storage_provider`、`object_key`、`content_type`、`size_bytes`、`sha256`、`status` | -| `file_download_token` | 保存临时下载授权及其有效期、使用次数和吊销状态,用于生成受控下载链接。 | `tenant_id`、`file_id`、`token_hash`、`expires_at`、`max_downloads`、`download_count`、`revoked_at` | -| `file_download_log` | 记录文件下载申请与执行结果,供安全追踪和下载审计使用。 | `tenant_id`、`file_id`、`user_id`、`request_id`、`client_ip`、`result`、`failure_reason`、`downloaded_at` | - -## 打印服务 - -| 表名 | 用途 | 关键字段 | -| --- | --- | --- | -| `print_template` | 保存租户级打印模板、模板类型、版本、内容和启停状态。 | `tenant_id`、`template_code`、`template_type`、`content`、`version_no`、`status` | -| `print_job` | 保存打印任务、业务来源、模板快照、打印参数、生成文件标识和当前状态。 | `tenant_id`、`job_no`、`template_id`、`business_type`、`business_id`、`payload_json`、`output_file_id`、`status` | -| `print_job_history` | 追加记录打印任务的状态迁移、执行结果、失败原因和操作主体。 | `tenant_id`、`print_job_id`、`from_status`、`to_status`、`message`、`operator_id`、`occurred_at` | - -## 跨区事件桥服务 - -| 表名 | 用途 | 关键字段 | -| --- | --- | --- | -| `bridge_outbox` | 保存事件桥待发送事件及发布状态,为后续扫描、重试、死信和人工补投保留持久化边界。当前仅有基础表结构,真实扫描调度和跨区发送尚未实现。 | `event_id`、`idempotency_key`、`event_type`、`event_version`、`source_region`、`target_region`、`payload_hash`、`payload_json`、`status`、`retry_count`、`next_retry_at`、`lease_until` | -| `bridge_inbox` | 保存事件桥已接收的事件信封和摘要,用 `event_id` 和 `idempotency_key` 唯一约束实现幂等;相同事件且相同摘要返回重复,相同事件但摘要不同拒绝覆盖。 | `event_id`、`idempotency_key`、`event_type`、`event_version`、`source_region`、`target_region`、`payload_hash`、`payload_json`、`status`、`received_at`、`processed_at` | -| `bridge_delivery_attempt` | 追加记录每一次投递尝试、目标区域、结果、响应码和错误信息,为重试审计与故障定位提供依据。当前真实投递器尚未实现。 | `event_id`、`source_region`、`target_region`、`attempt_no`、`status`、`response_code`、`error_message`、`started_at`、`finished_at` | - -`event-bridge-service` 当前是可靠事件桥基础骨架,不代表跨区链路可用于生产。内部服务令牌、目标区域校验和 Inbox 幂等接收已实现并通过事件桥 8 项测试;mTLS、来源白名单、RocketMQ 发布消费、真实跨公网 HTTP 投递、Outbox 扫描调度和业务消费者仍待实现。 - -## 审计服务 - -| 表名 | 用途 | 关键字段 | -| --- | --- | --- | -| `sys_audit_log` | **集中业务审计表**。追加记录敏感操作的主体、租户、资源、请求链路、结果及脱敏后的前后快照,用于合规审计和问题追踪;它不同于 Outbox 和消费幂等表。 | `event_type`、`resource_type`、`resource_id`、`tenant_id`、`operator_tenant_id`、`user_id`、`success`、`before_data`、`after_data`、`occurred_at` | - -## 三类事件/日志表的区别 - -| 表 | 写入方 | 核心目标 | 数据生命周期 | -| --- | --- | --- | --- | -| `ord_outbox_event` | 订单业务事务 | 保证业务数据与待发布领域事件最终一致 | 成功发布后保留一段时间,再归档或清理 | -| `fl_consumed_event` | 头程消息消费者 | 对重复投递进行幂等去重 | 至少覆盖消息可能重复投递的最长时间 | -| `bridge_outbox` | 事件桥生产端 | 保存等待投递、重试或死信的版本化跨区事件 | 成功投递并超过审计与补偿窗口后归档 | -| `bridge_inbox` | 事件桥接收端 | 按事件标识和摘要幂等接收,拒绝同标识不同内容 | 至少覆盖跨区重试与人工补投窗口 | -| `sys_audit_log` | 各服务审计组件/审计服务 | 记录谁在何时对什么资源做了什么以及结果 | 按合规要求长期留存,原则上只追加不修改 | - -## 服务边界 - -- `order-service` 拥有客户委托、参与方、货物、商业状态和权威订单号。 -- `firstleg-service` 拥有受理后的指派、订舱、集货、干线运输和清关节点。 -- `warehouse-service` 拥有仓库主数据和能力;后续入库、库存、库位和出库记录也归属该服务。 -- `lastleg-service` 后续负责派单、配送轨迹和签收凭证。 -- `file-service` 拥有文件元数据、下载授权和下载审计;对象二进制存放在对象存储中。 -- `print-service` 拥有打印模板和打印任务;生成文件只保存 `file_id`,不跨库读取文件表。 -- `event-bridge-service` 只负责跨区事件信封的接收幂等、投递记录和可靠传输边界,不拥有订单、头程或尾程业务状态,也不能直接推进业务聚合。 -- `order_id`、`warehouse_id` 等跨服务字段只是逻辑标识,不建立跨数据库外键。 diff --git a/docs/gitlab-repository-creation.md b/docs/gitlab-repository-creation.md deleted file mode 100644 index 8a8f039c4..000000000 --- a/docs/gitlab-repository-creation.md +++ /dev/null @@ -1,98 +0,0 @@ -# 头程 GitLab 建仓清单 - -## 1. 建仓结论 - -头程项目创建一个 GitLab 仓库,不按服务拆成多个 Git 仓库。 - -原因:当前是一个 Maven Reactor 多模块工程,公共组件、平台 BOM、事件契约和 11 个可运行服务共享根父工程并统一构建。此时按服务拆仓会破坏父 POM、公共版本管理和现有流水线。服务仍然可以独立打包、独立镜像和独立部署,Git 仓库数量不等于微服务数量。 - -GitLab 创建参数: - -| 字段 | 填写内容 | -| --- | --- | -| 项目名称 | 百悦头程平台 | -| 项目标识 | `baiyue-firstleg` | -| 项目描述 | 百悦物流国内头程微服务平台,包含统一登录、租户权限、订单、头程履约、国内仓储、文件、打印、审计、外部集成与跨区域事件桥。 | -| 可见性 | 私有 | -| 默认分支 | `main` | -| 使用 README 初始化 | 否,本地项目已有 README | -| Git 忽略模板 | 不选择,本地已有 `.gitignore` | -| 许可证 | 不选择,内部商业项目 | - -本地目录:`D:\baiyue-firstleg`。 - -## 2. 每个可运行服务的描述 - -以下 11 个模块都是独立运行服务,但属于同一个 GitLab 仓库。 - -| 模块 | 建议制品或镜像名 | 默认端口 | 中文描述 | -| --- | --- | ---: | --- | -| `gateway` | `firstleg-gateway` | 9000 | 头程统一 API 入口,负责路由、跨域、统一鉴权入口、限流与 Swagger 聚合。 | -| `auth` | `auth-service` | 9100 | 统一认证服务,负责注册、登录、Access Token、Refresh Token、退出、修改密码和会话撤销。 | -| `iam` | `iam-service` | 9201 | 租户与权限服务,负责企业、成员、角色、菜单、按钮权限、数据隔离和企业资质审核。 | -| `firstleg` | `firstleg-service` | 9202 | 头程履约服务,负责揽收、入仓、装载、发运和国内到海外的交接事实。 | -| `order` | `order-service` | 9204 | 订单服务,负责头程订单草稿、提交、状态编排、主数据快照、快递履约信息和订单事件。 | -| `warehouse` | `warehouse-service` | 9205 | 国内仓储服务,负责仓库、地址、收货能力和往来单位配置;往来单位表位于 `baiyue_warehouse.wh_partner`。 | -| `file` | `file-service` | 9206 | 文件中心,负责企业资质和业务文件的上传、下载、元数据、授权与后续对象存储适配。 | -| `print` | `print-service` | 9207 | 打印中心,负责打印模板、打印任务、状态记录及生成文件关联。 | -| `audit-service` | `audit-service` | 按配置 | 审计服务,负责关键操作、租户、操作者、业务对象和追踪号的审计留痕。 | -| `integration-service` | `integration-service` | 按配置 | 外部集成服务,负责承运商、渠道和第三方接口的适配、映射、回调与幂等。 | -| `event-bridge-service` | `event-bridge-service` | 按配置 | 跨区域事件桥,负责版本化事件、Inbox 幂等、投递记录、重试和后续国内外通信。 | - -## 3. 不单独创建 GitLab 仓库的公共模块 - -| 模块 | 说明 | -| --- | --- | -| `platform-bom` | 统一公共制品版本清单。 | -| `common-core` | 统一返回、错误码和核心基础模型。 | -| `common-web` | 全局异常、请求追踪和 Web 公共能力。 | -| `common-security` | JWT、安全上下文、租户和审计操作者能力。 | -| `common-openapi` | Swagger/OpenAPI 公共配置。 | -| `common-audit` | 审计事件公共模型与切面。 | -| `common-event-contract` | 国内与海外共享的版本化事件契约。 | - -这些模块随头程仓发布到 Maven 私有仓库,尾程只能按固定版本引用,不能复制源码或再次发布同一坐标。 - -## 4. GitLab 仓库设置建议 - -1. 保护 `main` 分支,禁止直接推送和强制推送。 -2. 合并必须通过流水线,并至少由一名维护者审核。 -3. 开启提交记录、合并请求和发布标签保护。 -4. CI/CD 变量中保存测试或部署环境密码,不提交 `.env`、本地配置或密钥。 -5. 制品保留 7 天;正式发布通过版本标签长期保留。 -6. 服务使用独立镜像标签和部署版本,但共同关联同一个 Git 提交号。 - -## 5. 第一次推送命令 - -先在 GitLab 创建空项目,不要勾选 README 初始化。将下面地址中的组织名替换为实际 GitLab 组: - -```powershell -Set-Location D:\baiyue-firstleg -git status -git add . -git commit -m "初始化百悦头程微服务平台" -git branch -M main -git remote add origin https://gitlab.example.com/你的组织/baiyue-firstleg.git -git push -u origin main -``` - -如果本地已经存在 `origin`: - -```powershell -git remote set-url origin https://gitlab.example.com/你的组织/baiyue-firstleg.git -git push -u origin main -``` - -推送前必须确认 `git status` 中没有 `target`、`.idea`、日志、上传文件、数据库导出或包含密码的本地配置。 - -## 6. 后续确需按服务拆仓时的前置条件 - -只有满足以下条件后,才建议把 11 个服务拆成独立 Git 仓库: - -1. `platform-bom` 和全部 `common-*` 已发布到可用的 Maven 私有仓库。 -2. 每个服务拥有独立父 POM 或只依赖已发布父 POM。 -3. 服务间 DTO 已替换为版本化 API 或事件契约,不存在源码级依赖。 -4. 每个服务都有独立流水线、镜像、配置、数据库迁移和负责人。 -5. 已完成跨仓版本兼容和回滚演练。 - -当前不满足全部前置条件,因此本次只创建 `baiyue-firstleg` 一个 GitLab 仓库。 diff --git a/docs/jenkins-multi-service-deployment.md b/docs/jenkins-multi-service-deployment.md deleted file mode 100644 index c27f47ff7..000000000 --- a/docs/jenkins-multi-service-deployment.md +++ /dev/null @@ -1,86 +0,0 @@ -# 头程单仓多服务构建部署说明 - -## 一、适用范围 - -当前 `baiyue-firstleg` 是一个 Git 仓库,包含 11 个可独立运行的服务和 7 个公共模块。部署时无需拆成多个仓库,Jenkins 使用 Maven `-pl ... -am` 只构建目标服务及其依赖,并为每个服务生成独立 Docker 镜像。 - -MySQL、Redis、Nacos 是外部基础设施,不包含在业务 Compose 文件中。各业务库仍使用独立数据库,往来单位数据继续归属 `baiyue_warehouse`。 - -## 二、文件说明 - -| 文件 | 用途 | -| --- | --- | -| `Jenkinsfile` | 自动识别变更、测试、打包、构建镜像、推送和部署 | -| `deploy/docker/Dockerfile` | 11 个服务共用的 Java 17 非 root 镜像模板 | -| `deploy/service-map.yml` | 模块、JAR 和端口的唯一映射清单 | -| `deploy/docker-compose.yml` | 11 个业务服务的统一编排文件 | -| `deploy/env/.env.test.example` | 测试环境变量模板,不含真实密码 | -| `deploy/scripts/deploy-services.sh` | Jenkins 节点上的增量部署脚本 | - -## 三、Jenkins 前置条件 - -Jenkins 新建“多分支流水线”或“流水线”,脚本路径填写 `Jenkinsfile`。执行节点标签为 `docker-java17`,节点需要安装: - -- JDK 17 -- Maven 3.8.8 以上 -- Docker Engine -- Docker Compose V2 -- Git - -Jenkins 执行用户必须有运行 Docker 的权限。部署脚本当前在 Jenkins 节点执行,因此该节点应位于目标服务器,或将同一标签绑定到对应环境的部署节点。 - -## 四、必须创建的 Jenkins 凭据 - -| Credentials ID | 类型 | 内容 | -| --- | --- | --- | -| `gitlab-registry-credentials` | Username with password | GitLab Deploy Token 用户名和令牌,至少有 `read_registry`、`write_registry` 权限 | -| `firstleg-test-env` | Secret file | 根据 `.env.test.example` 创建的测试环境 `.env` | -| `firstleg-staging-env` | Secret file | 预发布环境 `.env`,使用预发布基础设施地址和密钥 | -| `firstleg-production-env` | Secret file | 生产环境 `.env`,使用生产基础设施地址和密钥 | - -GitLab 仓库拉取如果不是由 Jenkins GitLab 集成统一授权,还需单独创建只读仓库凭据。任何 MySQL、Redis、Nacos、JWT 和第三方平台密钥都不得直接填写在 Jenkinsfile 或提交到 Git。 - -## 五、流水线参数 - -| 参数 | 推荐值 | 说明 | -| --- | --- | --- | -| `BUILD_MODE` | `AUTO` | 自动分析上次成功构建之后的 Git 变更 | -| `SERVICE` | 按需 | `SELECT` 时填写,如 `order,warehouse` | -| `ENVIRONMENT` | `test` | 可选 `test`、`staging`、`production` | -| `DEPLOY` | 测试环境勾选 | 是否在镜像推送后执行部署 | -| `SKIP_TESTS` | 不勾选 | 临时调试才跳过测试 | -| `IMAGE_REPOSITORY` | GitLab 项目镜像地址 | 示例 `gitlab.example.com/logistics/baiyue-firstleg` | - -公共模块、根 POM 或 Dockerfile 变化会构建全部 11 个服务。仅 `order/**` 变化时执行: - -```bash -mvn -pl order -am clean package -``` - -`order` 与 `warehouse` 同时变化时只调用一次 Maven: - -```bash -mvn -pl order,warehouse -am clean package -``` - -这样可避免多个并发 Maven 任务同时改写公共模块的 `target` 目录。 - -## 六、镜像与部署规则 - -每个镜像使用 Git 提交短 SHA 作为不可变标签,例如: - -```text -gitlab.example.com/logistics/baiyue-firstleg/order:a1b2c3d4e5f6 -``` - -测试和预发布环境勾选 `DEPLOY` 后自动部署。生产环境会额外出现人工确认,确认内容包含服务列表和镜像标签。增量部署会自动带上 `gateway`,保证网关配置与业务服务保持可用;不会重启未选择的其他业务服务。 - -首次部署建议使用 `BUILD_MODE=ALL`。基础设施必须先可用,并已创建以下数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_firstleg`、`baiyue_order`、`baiyue_warehouse`、`baiyue_file`、`baiyue_print`、`baiyue_event_bridge`、`baiyue_audit`。 - -## 七、GitLab CI 与 Jenkins 职责 - -现有 `.gitlab-ci.yml` 仍执行全量校验和打包,适合合并请求质量检查;Jenkins 负责镜像构建与部署。正式启用 Jenkins 后,建议把 GitLab CI 的“打包服务”任务限制为合并请求或删除,避免一次提交重复进行两次完整打包。 - -## 八、回滚 - -回滚不重新编译代码。在 Jenkins 用 `SELECT` 选中服务,将部署环境 `.env` 中的镜像标签临时改为已验证的历史 SHA,或在服务器执行同一 Compose 文件指定历史 `IMAGE_TAG` 后 `up -d`。生产回滚仍应保留人工确认和操作记录。 diff --git a/docs/local-runtime.md b/docs/local-runtime.md deleted file mode 100644 index fda8c1e9d..000000000 --- a/docs/local-runtime.md +++ /dev/null @@ -1,24 +0,0 @@ -# 头程本地运行环境 - -运行目录为 `D:\baiyue-firstleg-runtime`。 - -| 组件 | 地址 | -|---|---| -| Redis | `127.0.0.1:6379` | -| Nacos | `127.0.0.1:8848` | -| Nacos 控制台 | `http://127.0.0.1:8848/nacos/` | - -先启动基础设施: - -```powershell -powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg-runtime\start-infra.ps1 -``` - -在项目 PowerShell 中加载本地变量: - -```powershell -cd D:\baiyue-firstleg -. .\scripts\local-env.ps1 -``` - -头程业务端口保持项目现有默认值,文件上传目录改为 `D:\baiyue-firstleg-runtime\uploads`。 diff --git a/docs/order-warehouse-real-api-regression.md b/docs/order-warehouse-real-api-regression.md deleted file mode 100644 index ca2d6c234..000000000 --- a/docs/order-warehouse-real-api-regression.md +++ /dev/null @@ -1,37 +0,0 @@ -# 订单、仓库与往来单位真实 API 回归说明 - -脚本:`scripts/order-warehouse-real-api-regression.ps1`。 - -## 运行环境 - -- Gateway:`19002` -- Order:`19204` -- Warehouse:`19205` -- Auth、IAM:本地已启动服务,由 Gateway 发现 -- MySQL:`127.0.0.1:3306` -- 默认本地管理员密码和 MySQL 密码仅通过脚本参数传入,生成报告不会记录密码或令牌 - -```powershell -powershell -ExecutionPolicy Bypass -File .\scripts\order-warehouse-real-api-regression.ps1 ` - -GatewayBaseUrl http://127.0.0.1:19002 ` - -OrderBaseUrl http://127.0.0.1:19204 ` - -WarehouseBaseUrl http://127.0.0.1:19205 -``` - -## 覆盖范围 - -脚本真实访问 HTTP 和 MySQL,覆盖: - -1. 仓库与往来单位租户隔离、启用过滤和停用详情限制。 -2. 仓库地址、收货能力和截单时间回显。 -3. 订单草稿创建、编辑、重新保存、详情和提交。 -4. 企业审核未通过时禁止创建订单。 -5. 创建、编辑和提交阶段的仓库及往来单位停用校验。 -6. 客户名称、仓库名称、仓库地址、收货时间和参与方地址历史快照。 -7. 重复提交返回 HTTP 409 和统一错误码 `A0409`。 -8. 平台履约权限写入快递信息,商户身份禁止写入且详情只读回显。 -9. Order、Warehouse 直连 `api-docs`,并静态审查 Gateway 聚合路由源配置。备用 Gateway 不要求实际加载 docs 转发路由。 - -脚本使用固定高位 ID 初始化仓库和往来单位测试数据,可重复执行。无论成功或失败,`finally` 清理都会恢复演示企业、仓库和往来单位状态,删除临时仓库、往来单位及测试订单;若履约权限原本不存在,也会删除临时权限和角色关系。报告只保留脱敏的测试证据。 - -这是本地测试联调脚本,不创建数据库备份,不执行生产迁移或发布操作。 diff --git a/docs/phase1-business-maturity-regression-report.md b/docs/phase1-business-maturity-regression-report.md deleted file mode 100644 index f68d6bec6..000000000 --- a/docs/phase1-business-maturity-regression-report.md +++ /dev/null @@ -1,71 +0,0 @@ -# 一期业务成熟度回归测试报告 - -测试时间:2026-07-30 -测试环境:本地标准端口、MySQL、Redis、Nacos -结论:本次前端联调报告列出的认证、个人资料、成员、角色、仓库、往来单位、订单草稿及快递信息缺口已通过成熟度验收。 - -本次属于本地测试联调,不执行数据库备份或生产发布操作。脚本只创建可识别的测试数据,并在 `finally` 中恢复状态、删除可删除数据;不能物理删除的成员和租户按测试数据规则停用保留。 - -## 1. 验收结果 - -| 验收层级 | 结果 | 证据 | -| --- | --- | --- | -| 全仓构建 | 通过 | 19 个 Reactor 项目全部 `SUCCESS` | -| 单元及组件测试 | 通过 | 75 项通过,失败 0,错误 0,跳过 0 | -| 认证、个人资料、成员、角色、租户隔离 | 通过 | 68 项真实 API 检查通过,失败 0 | -| 仓库、往来单位、订单、快递 | 通过 | 37 项真实 API 检查通过,失败 0 | -| 服务健康 | 通过 | Gateway、Auth、IAM、Firstleg、Order、Warehouse、File、Print 共 8 个服务均为 `UP` | -| Swagger | 通过 | Gateway UI、配置和 11 个业务分组共 13 个入口全部 HTTP 200 | -| CORS | 通过 | 白名单来源 HTTP 200,非白名单来源 HTTP 403 | - -结构化报告: - -- `reports/phase1-business-maturity-regression-20260730171959885.json` -- `reports/订单仓库往来单位真实API回归-20260730-171805.json` - -## 2. 已通过的业务能力 - -- 统一登录、刷新令牌轮换、退出、过期会话和旧令牌失效。 -- 本人资料查询、编辑、回显和恢复;错误旧密码被拒绝,正确修改密码后全部旧会话失效。 -- 成员新增、编辑、列表、详情、姓名联系方式及 `roleIds` 回显、启停、重置密码。 -- 普通成员不能停用本人,企业所有者不能被停用,停用账号不能登录。 -- 角色新增、编辑、启停、删除、`permissionIds` 回显;已分配成员的角色禁止删除。 -- 平台端与商户端隔离、按钮权限、跨租户成员/仓库/往来单位访问隔离。 -- 启用仓库查询、地址及收货截单时间回显,停用仓库不可选择。 -- 往来单位位于 `warehouse-service` 和 `baiyue_warehouse.wh_partner`,启用过滤和租户隔离通过。 -- 订单草稿创建、编辑后重新保存、提交、重复提交 409、审核未通过禁止下单。 -- 仓库和往来单位停用后禁止保存,订单使用服务端可信主数据快照,不信任客户端伪造名称。 -- 订单历史参与方地址快照、仓库地址及收货时间快照通过。 -- 平台写入履约快递信息,商户详情只读展示,商户不能调用平台写接口。 - -## 3. 回归中发现并修复的问题 - -1. Auth 修改密码接口使用了错误的认证主体类型,已统一使用公共 JWT 认证过滤器。 -2. 权限列表同时返回租户权限和全局模板,已按权限码合并并优先当前租户。 -3. 成员审计字段可能为空,安全上下文审计提供器已设为主提供器。 -4. 企业所有者停用校验顺序错误,已优先执行所有者保护。 -5. `/members/roles` 被 `/members/{membershipId}` 动态路由吞掉,成员路径已限制为数字。 -6. Windows PowerShell 5 对错误响应、UTF-8 请求和响应处理不稳定,两套回归脚本已统一修复。 -7. MySQL 短参数在 PowerShell 下解析异常,回归脚本已改为长参数并使用进程内密码环境变量。 - -## 4. 测试数据清理 - -- 临时自定义角色均已删除,未删除角色数为 0。 -- 临时测试成员因当前没有成员物理删除接口,已全部停用并清空角色,保留可审计记录。 -- 跨租户注册测试会保留停用前的租户与账号数据;后续可增加测试数据归档任务。 -- 订单、临时仓库、临时往来单位和临时权限关系已由脚本 `finally` 恢复或删除。 - -## 5. 尚未达到生产成熟的范围 - -本报告只确认前端联调清单对应能力成熟,不代表整个国内外头尾程平台已经可以直接生产上线。以下仍需后续迭代: - -- 注册和成员创建的跨服务写操作仍为同步调用加补偿,需要 Outbox/Saga 进一步保证极端失败一致性。 -- 内部服务凭据仍为共享静态密钥,生产应使用密钥轮换和 mTLS。 -- 营业执照真实图片的桌面端、移动端浏览器查看与替换仍需人工浏览器验收。 -- 文件服务仍为本地磁盘,生产需切换对象存储并完成短期授权与下载审计。 -- 完整头程仓内作业、订舱、装柜、报关、发运交接、跨区域 Outbox/Inbox、轨迹和通知尚未形成生产闭环。 -- 往来单位一期为单联系人、单地址平面配置,复杂联系人和地址簿属于后续范围。 - -## 6. 验收结论 - -本次前端联调缺口可以进入稳定浏览器联调和一期演示验收。生产上线审批仍应以跨服务可靠性、文件对象存储、完整头程履约、跨区域事件、备份恢复和安全演练全部完成为前提。 diff --git a/docs/phase1-business-maturity-regression.md b/docs/phase1-business-maturity-regression.md deleted file mode 100644 index 7fb0610ab..000000000 --- a/docs/phase1-business-maturity-regression.md +++ /dev/null @@ -1,23 +0,0 @@ -# 一期业务成熟度真实 API 回归脚本 - -脚本位置:`scripts/phase1-business-maturity-regression.ps1`。 - -脚本覆盖登录、刷新、退出、个人资料、修改密码、企业成员新增/编辑/查询/角色回显/启停/重置密码、角色新增/编辑/启停/删除保护/权限回显、本人和企业所有者保护、按钮权限、应用端隔离和跨租户越权。 - -运行前应完成数据库迁移并启动网关、认证服务、IAM 服务、MySQL、Redis 和 Nacos。建议使用专用测试环境,不要在生产环境执行。脚本会修改并恢复商户管理员密码,创建带 `p1m_时间戳` 前缀的成员、角色和第二租户。由于当前没有删除成员和租户接口,脚本会停用新成员、清空其角色并保留成员和租户记录,以便审计和人工清理;临时自定义角色会自动删除。 - -```powershell -powershell -ExecutionPolicy Bypass -File .\scripts\phase1-business-maturity-regression.ps1 ` - -RepositoryRoot "D:\baiyue-firstleg" ` - -GatewayBaseUrl "http://127.0.0.1:9000" ` - -MerchantAdminPassword "实际商户管理员密码" ` - -PlatformAdminPassword "实际平台管理员密码" -``` - -可通过 `-TemporaryAdminPassword`、`-MemberInitialPassword` 和 `-MemberResetPassword` 传入符合策略的测试密码。密码及令牌只保存在进程内存中,不会写入控制台或 JSON 报告。 - -默认报告输出到仓库的 `reports` 目录。报告包含每个检查项的分类、通过状态、HTTP 状态、业务码、脱敏测试数据标识和汇总结果。任一关键检查失败时脚本返回退出码 `1`,便于接入流水线。 - -无论主流程成功或失败,脚本都会在 `finally` 阶段再次登录商户管理员并执行最佳努力清理。历史成员只有同时满足成员编号为 `P1-*` 且显示名为 `一期验收成员*` 才会被停用和清空角色;历史角色只有编码为 `P1_SELF_*` 且类型为 `CUSTOM` 才会被删除。脚本不会按单一模糊条件处理正常业务数据。无法通过现有 API 删除的测试成员和第二测试租户会记录在报告的 `residualData` 中。 - - diff --git a/docs/phase1-frontend-integration-tasks.md b/docs/phase1-frontend-integration-tasks.md deleted file mode 100644 index 0b2b439b5..000000000 --- a/docs/phase1-frontend-integration-tasks.md +++ /dev/null @@ -1,145 +0,0 @@ -# 一期前端本地联调任务 - -## 1. 文档范围 - -本文只用于本地开发联调,不包含测试环境、生产环境、国内外部署和跨区域事件同步。 - -一期业务闭环:商户注册 → 登录 → 完善企业资料 → 上传营业执照 → 平台审核 → 审核通过后创建头程订单 → 编辑草稿 → 提交订单。 - -## 2. 本地联调入口 - -| 项目 | 地址 | -|---|---| -| 前端统一 API 地址 | `http://127.0.0.1:9000` | -| 局域网前端统一 API 地址 | `http://192.168.110.149:9000` | -| Swagger | `http://127.0.0.1:9000/swagger-ui.html` | -| MySQL | `127.0.0.1:3306`,本地密码 `123456` | -| Redis | `127.0.0.1:6379` | -| Nacos | `http://127.0.0.1:8848/nacos` | - -前端不得直连 9100、9201 等业务端口,所有请求统一经过 Gateway 的 9000 端口。 - -当前允许的前端 Origin: - -- `http://localhost:5173`、`http://localhost:5174` -- `http://127.0.0.1:5173`、`http://127.0.0.1:5174` -- `http://192.168.110.149:5173`、`http://192.168.110.149:5174` - -如果前端实际 IP 或端口不同,修改 `scripts/local-env.ps1` 中的 `CORS_ALLOWED_ORIGINS`,然后只重启 Gateway。 - -## 3. 本地启动顺序 - -先启动 MySQL、Redis、Nacos,再在 IDEA 中启动以下应用: - -1. `BaiyueIamApplication`,端口 9201 -2. `BaiyueAuthApplication`,端口 9100 -3. `BaiyueFirstlegApplication`,端口 9202 -4. `BaiyueOrderApplication`,端口 9204 -5. `BaiyueWarehouseApplication`,端口 9205 -6. `FileServiceApplication`,端口 9206 -7. `PrintServiceApplication`,端口 9207 -8. `BaiyueGatewayApplication`,端口 9000,最后启动 - -一期最小主流程必须启动 Gateway、Auth、IAM、Order、File。为了让一期前端菜单、仓储选择、打印与完整 Swagger 不出现缺失,日常联调建议启动上面 8 个应用。 - -启动后检查: - -```text -http://127.0.0.1:9000/actuator/health -http://127.0.0.1:9100/actuator/health -http://127.0.0.1:9201/actuator/health -http://127.0.0.1:9202/actuator/health -http://127.0.0.1:9204/actuator/health -http://127.0.0.1:9205/actuator/health -http://127.0.0.1:9206/actuator/health -http://127.0.0.1:9207/actuator/health -``` - -预期均返回 `status=UP`。 - -## 4. 联调账号 - -| 端 | 用户名 | 密码 | -|---|---|---| -| 平台端 | `platform_admin` | `ChangeMe123!` | -| 商户端 | `merchant_admin` | `ChangeMe123!` | - -两端共用登录接口,但 JWT 中 `app_code`、租户、角色和按钮权限不同。平台 Token 不能访问商户接口,商户 Token 不能访问平台接口。 - -## 5. 前端公共能力任务 - -- 配置统一请求基地址,不在页面中写死业务服务端口。 -- 成功登录后保存 `accessToken` 和 `refreshToken`,业务请求发送 `Authorization: Bearer `。 -- 遇到 401 时尝试刷新一次 Token;刷新失败则清理会话并回到登录页,禁止无限重试。 -- 统一解析 `{code,message,data,requestId,traceId,timestamp}`。 -- HTTP 200 仍需判断业务 `code` 是否为 `0`。 -- 统一处理 400、401、403、404、409、422、429、500、503。 -- 根据登录返回的角色和权限生成菜单、按钮;按钮隐藏不是安全边界,后端仍执行权限校验。 -- 商户页面不允许传入或切换 `tenantId`,租户从 JWT 获取。 -- 平台查询租户业务时必须显式传目标 `tenantId`。 -- 文件上传使用 `multipart/form-data`,不要手工设置 multipart boundary。 - -## 6. 一期页面任务 - -### 6.1 公共登录与注册 - -- 完成商户注册表单及前端校验。 -- 完成平台、商户统一登录页。 -- 完成 Token 刷新、退出登录和登录态恢复。 -- 根据 `app_code` 进入平台端或商户端,禁止跨端复用页面权限。 - -### 6.2 商户端企业入驻 - -- 企业资料详情页:查询并编辑企业名称、法定名称、联系人、电话、邮箱。 -- 资质页:上传营业执照,显示上传结果和资质审核状态。 -- 资质图片预览/下载必须携带商户 Token。 -- 企业未审核通过时,头程下单入口置灰,并展示后端返回的禁止原因。 - -状态展示:`PROFILE_REQUIRED`/`PENDING` 表示待完善或待审,`ACTIVE` 表示审核通过,`REJECTED` 表示已驳回。 - -### 6.3 平台端企业审核 - -- 待审核列表。 -- 查询指定租户资质列表。 -- 携带平台 Token 预览资质图片。 -- 审核通过、审核驳回及审核意见输入。 -- 按钮权限:`platform:customer:review`、`platform:customer:approve`、`platform:customer:reject`。 - -### 6.4 商户端头程订单 - -- 订单列表:关键字、状态、客户、目的仓、时间和分页筛选。 -- 订单详情:订单、货物、发收货方、状态历史。 -- 新建草稿、编辑草稿、提交订单。 -- 仅 `DRAFT`、`REJECTED` 可编辑;一期主流程状态为 `DRAFT → SUBMITTED`。 -- 商户详情不得展示 `internalNotes`;前端模型即使收到未知字段也不得渲染平台内部备注。 -- 按钮权限:`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit`。 - -## 7. 一期验收清单 - -- 8 个应用健康检查全部 `UP`。 -- 平台账号、商户账号均能通过 Gateway 登录。 -- 两类 Token 跨端访问返回 403。 -- 商户能查询、编辑企业资料。 -- 商户能上传真实 JPG/PNG 营业执照,非图片或超过 5MB 被拒绝。 -- 平台能查询待审核记录、查看资质并通过或驳回。 -- 企业审核前创建订单被拒绝,审核后可创建草稿。 -- 商户可查询、编辑和提交自己的订单,不能读取其他租户订单。 -- 商户订单详情不返回平台内部备注。 -- 局域网前端 Origin 的 OPTIONS 预检返回 200,非白名单 Origin 返回 403。 -- Swagger UI 和一期服务 API 文档均可通过 Gateway 打开。 - -## 8. 本次本地实测结果 - -2026-07-30 已完成本机验证:8 个健康检查均为 `UP`;平台/商户登录成功;IAM、Order、Firstleg、Warehouse、File、Print 状态接口及商户资料、订单列表、仓库列表均能通过 Gateway 返回成功;13 个 Swagger 页面/分组返回 200;`http://192.168.110.149:5173` 跨域预检返回 200,非白名单返回 403。 - -另创建本地联调订单并添加 1 条平台内部备注:平台详情可见 1 条,商户详情返回 0 条,租户端数据隔离验证通过。 - -2026-07-30 17:20 使用最新代码重新完成成熟度回归: - -- 全仓 19 个 Reactor 项目构建成功,75 项单元及组件测试全部通过。 -- 认证、个人资料、修改密码、成员、角色、按钮权限、租户隔离共 68 项真实 API 检查全部通过。 -- 仓库、往来单位、订单草稿、可信快照、停用限制、重复提交、快递只读共 37 项真实 API 检查全部通过。 -- Gateway Swagger UI、配置及 11 个业务分组共 13 个入口全部返回 200。 -- 白名单 CORS 预检返回 200,非白名单返回 403。 - -详细报告见 `docs/phase1-business-maturity-regression-report.md`。营业执照真实图片的桌面端和移动端浏览器查看、替换仍需前端执行人工验收。 diff --git a/docs/phase1-local-api.md b/docs/phase1-local-api.md deleted file mode 100644 index 1f11e78bc..000000000 --- a/docs/phase1-local-api.md +++ /dev/null @@ -1,267 +0,0 @@ -# 一期本地联调 API 文档 - -## 1. 通用约定 - -统一基地址:`http://127.0.0.1:9000`;局域网前端使用 `http://192.168.110.149:9000`。除登录、注册、刷新和 Swagger 外,接口均携带: - -```http -Authorization: Bearer -Content-Type: application/json -``` - -普通成功响应: - -```json -{ - "code": "0", - "message": "success", - "data": {}, - "requestId": null, - "traceId": null, - "timestamp": "2026-07-30T12:00:00" -} -``` - -分页数据: - -```json -{"records": [], "page": 1, "size": 20, "total": 0, "pages": 0} -``` - -常用业务错误码:`A0400` 参数错误、`A0401` 未登录、`A0403` 无权限、`A0404` 不存在、`A0409` 冲突、`A0422` 业务校验失败、`A0429` 请求过多、`B0001` 业务失败、`S0500` 服务异常、`S0503` 服务不可用。 - -## 2. 认证接口 - -| 方法 | 路径 | 登录要求 | 说明 | -|---|---|---|---| -| POST | `/api/auth/register` | 匿名 | 注册商户及租户 | -| POST | `/api/auth/login` | 匿名 | 平台、商户统一登录 | -| POST | `/api/auth/refresh` | 匿名 | 刷新令牌 | -| POST | `/api/auth/logout` | Access Token | 注销刷新令牌 | - -注册请求: - -```json -{ - "username": "merchant_demo", - "password": "ChangeMe123!", - "displayName": "张三", - "companyName": "深圳示例贸易有限公司", - "legalPerson": "张三", - "legalPhone": "13800138000", - "contactEmail": "demo@example.com", - "countryCode": "CN", - "address": "广东省深圳市南山区测试路1号" -} -``` - -用户名为 4 至 64 位字母、数字或下划线,密码为 8 至 72 位。注册响应 `data`:`accountId,tenantId,membershipId,tenantStatus,nextStep`。 - -登录请求: - -```json -{"username":"merchant_admin","password":"ChangeMe123!"} -``` - -登录/刷新响应 `data`:`tokenType,accessToken,expiresIn,refreshToken,refreshExpiresIn`。刷新和退出请求均为: - -```json -{"refreshToken":""} -``` - -## 3. IAM 商户端 - -| 方法 | 路径 | 请求/响应 | -|---|---|---| -| GET | `/api/merchant/v1/iam/tenant/profile` | 返回当前 JWT 租户资料 | -| PUT | `/api/merchant/v1/iam/tenant/profile` | 更新当前租户资料 | -| GET | `/api/merchant/v1/iam/tenant/qualifications` | 返回当前租户资质列表 | - -个人资料与修改密码: - -| 方法 | 路径 | 说明 | -| --- | --- | --- | -| GET | `/api/merchant/v1/iam/profile` | 查询当前登录成员资料 | -| PUT | `/api/merchant/v1/iam/profile` | 编辑姓名、电话和邮箱 | -| PUT | `/api/auth/password` | 校验旧密码并修改本人密码,成功后撤销全部旧会话 | - -成员与角色: - -| 方法 | 路径 | 说明 | -| --- | --- | --- | -| GET/POST | `/api/merchant/v1/iam/members` | 成员列表、创建成员和认证账号 | -| GET/PUT | `/api/merchant/v1/iam/members/{membershipId}` | 成员详情、编辑资料和角色 | -| PUT | `/api/merchant/v1/iam/members/{membershipId}/status` | 启用或停用普通成员 | -| PUT | `/api/merchant/v1/iam/members/{membershipId}/password` | 管理员重置成员密码 | -| GET/POST | `/api/merchant/v1/iam/roles` | 角色列表、新建角色 | -| GET/PUT/DELETE | `/api/merchant/v1/iam/roles/{roleId}` | 角色详情、编辑、删除 | -| PUT | `/api/merchant/v1/iam/roles/{roleId}/status` | 启用或停用自定义角色 | -| GET | `/api/merchant/v1/iam/permissions` | 查询去重后的权限资源 | -| PUT | `/api/merchant/v1/iam/roles/permissions` | 全量保存角色权限 | -| GET | `/api/merchant/v1/iam/members/{membershipId}/roles` | 查询成员当前角色 | -| PUT | `/api/merchant/v1/iam/members/roles` | 全量保存或清空成员角色 | - -成员列表和详情均返回 `displayName,phone,email,roleIds`;角色列表和详情均返回 `permissionIds`。企业所有者和系统角色受保护,已分配成员的自定义角色不能删除。 - -资料更新请求: - -```json -{ - "tenantName": "深圳示例贸易有限公司", - "legalName": "深圳示例贸易有限公司", - "contactName": "李四", - "contactPhone": "13900139000", - "contactEmail": "service@example.com" -} -``` - -资料响应字段:`id,tenantCode,tenantName,status,legalName,unifiedSocialCreditCode,contactName,contactPhone,contactEmail`。 - -## 4. IAM 平台审核 - -| 方法 | 路径 | 权限 | 说明 | -|---|---|---|---| -| GET | `/api/platform/v1/iam/reviews` | 平台身份 | 待审核记录 | -| GET | `/api/platform/v1/iam/tenants/{tenantId}/qualifications` | `platform:customer:review` | 指定租户资质 | -| POST | `/api/platform/v1/iam/reviews/{id}/approve` | `platform:customer:approve` | 审核通过 | -| POST | `/api/platform/v1/iam/reviews/{id}/reject` | `platform:customer:reject` | 审核驳回 | - -审核请求: - -```json -{"comment":"资料完整,审核通过"} -``` - -审核通过后租户状态为 `ACTIVE`、资质为 `APPROVED`;驳回后租户和资质为 `REJECTED`。 - -## 5. 资质文件 - -| 方法 | 路径 | 说明 | -|---|---|---| -| POST | `/api/merchant/v1/files/qualifications` | 上传当前租户营业执照 | -| GET | `/api/merchant/v1/files/qualifications/{fileName}` | 商户下载自己的资质 | -| GET | `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` | 平台查看指定租户资质 | - -上传采用 `multipart/form-data`:`file` 必填,`certificateNo` 可选。只接受文件内容真实为 JPG/PNG 的图片,最大 5MB。上传成功 `data`: - -```json -{ - "qualificationId": 1, - "reviewId": 1, - "tenantStatus": "PENDING", - "documentUrl": "/api/merchant/v1/files/qualifications/文件名.png" -} -``` - -下载接口直接返回图片二进制,不使用统一 JSON 包装。 - -## 6. 商户头程订单 - -基路径:`/api/merchant/v1/orders/first-leg`。 - -| 方法 | 路径 | 权限 | 说明 | -|---|---|---|---| -| POST | `/api/merchant/v1/orders/first-leg` | `merchant:order:create` | 创建草稿,`data` 为订单 ID | -| PUT | `/api/merchant/v1/orders/first-leg/{id}` | `merchant:order:edit` | 编辑草稿 | -| GET | `/api/merchant/v1/orders/first-leg` | 商户身份 | 分页查询自己的订单 | -| GET | `/api/merchant/v1/orders/first-leg/{id}` | 商户身份 | 查询自己的订单详情 | -| POST | `/api/merchant/v1/orders/first-leg/{id}/submit` | `merchant:order:submit` | 提交订单 | - -列表参数:`keyword,status,customerId,destinationWarehouseId,createdFrom,createdTo,page,size`;`page` 从 1 开始,`size` 为 1 至 200。 - -创建/编辑请求示例: - -```json -{ - "customerId": 10001, - "customerName": "深圳示例贸易有限公司", - "transportMode": "SEA", - "tradeTerm": "FOB", - "destinationCountryCode": "US", - "destinationWarehouseId": 1001, - "destinationWarehouseName": "ONT8", - "expectedShipDate": "2026-08-15", - "customerRemark": "提货前联系", - "pickupMethod": "PICKUP", - "receivingWarehouseId": 2001, - "receivingWarehouseName": "深圳收货仓", - "channelCode": "SEA-US-FBA", - "channelName": "美国海运渠道", - "taxMode": "BUY_DECLARATION", - "containerMode": "LCL", - "customsInspection": false, - "destinationType": "FBA", - "amazonWarehouseCode": "ONT8", - "shipmentId": "FBA20260730001", - "referenceId": "REF20260730001", - "sender": { - "companyName": "深圳示例贸易有限公司", - "contactName": "张三", - "phone": "13800138000", - "email": "sender@example.com", - "countryCode": "CN", - "province": "广东", - "city": "深圳", - "district": "南山", - "addressLine": "测试路1号", - "postalCode": "518000" - }, - "receiver": { - "companyName": "Amazon", - "contactName": "ONT8", - "phone": "1234567890", - "email": "ont8@example.com", - "countryCode": "US", - "province": "CA", - "city": "Moreno Valley", - "district": "Riverside", - "addressLine": "24300 Nandina Ave", - "postalCode": "92551" - }, - "cargoes": [{ - "cargoName": "蓝牙音箱", - "hsCode": "8518210000", - "packageType": "CARTON", - "packageCount": 2, - "grossWeightKg": 20.5, - "volumeCbm": 0.2, - "declaredValue": 200, - "currency": "USD", - "dangerousGoods": false, - "totalQuantity": 20, - "netWeightKg": 18 - }] -} -``` - -枚举约束:`transportMode=SEA|AIR|RAIL|TRUCK`;`pickupMethod=SELF_EXPRESS|PICKUP|SELF_TRUCK`;`taxMode=BUY_DECLARATION|TAX_REFUND`;`containerMode=FULL_CONTAINER|LCL`;`destinationType=FBA|OVERSEAS|PRIVATE`。 - -订单详情 `data` 包含 `order,cargoes,parties,internalNotes,statusHistory`。商户详情的 `internalNotes` 固定为空;平台内部备注不得向商户展示。企业必须为 `ACTIVE` 且至少有一项 `APPROVED` 资质,才能创建和提交订单。 - -## 7. 一期相关查询接口 - -| 方法 | 路径 | 说明 | -|---|---|---| -| GET | `/api/merchant/v1/warehouses?page=1&size=20` | 商户仓库列表 | -| GET | `/api/merchant/v1/warehouses/{warehouseId}` | 启用仓库详情、地址、能力和收货截单时间 | -| GET | `/api/merchant/v1/partners/candidates` | 当前租户启用往来单位候选 | -| GET | `/api/merchant/v1/partners/{partnerId}` | 当前租户启用往来单位详情 | -| GET | `/api/platform/v1/warehouse-admin?tenantId={tenantId}&page=1&size=20` | 平台仓储列表 | -| GET | `/api/platform/v1/iam/status` | IAM 网关路由状态 | -| GET | `/api/platform/v1/orders/status` | Order 网关路由状态 | -| GET | `/api/platform/v1/first-leg/status` | Firstleg 网关路由状态 | -| GET | `/api/platform/v1/warehouses/status` | Warehouse 网关路由状态 | -| GET | `/api/platform/v1/files/status` | File 网关路由状态 | -| GET | `/api/platform/v1/print/status` | Print 网关路由状态 | - -往来单位由 `warehouse-service` 承载,表为 `baiyue_warehouse.wh_partner`,不是独立服务或独立数据库。仓库和往来单位查询强制执行租户隔离和停用过滤。 - -平台履约可通过 `PUT /api/platform/v1/orders/first-leg/{orderId}/express?tenantId={tenantId}` 写入 `expressCompany,expressTrackingNo`;商户端只能在订单详情读取,不能维护快递公司或调用平台写接口。 - -## 8. Swagger 分组 - -入口:`http://127.0.0.1:9000/swagger-ui.html`。当前可用分组:Auth 平台、IAM 平台/商户、Order 平台/商户、Firstleg 平台、Warehouse 平台/商户、File 平台/商户、Print 平台。 - -## 9. 一期明确不纳入 - -订单平台审核、受理、履约、完成、取消;尾程业务;跨区域事件通知;测试/生产部署,均不属于本地一期前端联调验收范围。 diff --git a/docs/project-structure-after-region-split.md b/docs/project-structure-after-region-split.md deleted file mode 100644 index 3ebbdbdb9..000000000 --- a/docs/project-structure-after-region-split.md +++ /dev/null @@ -1,191 +0,0 @@ -# 国内外区域拆分后的项目结构审核稿 - -更新时间:2026-07-29 -审核状态:待审核 -适用仓库:`D:\baiyue` - -## 1. 拆分结论 - -项目继续使用一个 Git 仓库和一套业务源码,通过“国内部署聚合入口、海外部署聚合入口、区域独立配置”形成两个部署单元。当前拆分没有复制或移动业务源码,也没有把国内、海外拆成两个 Git 仓库。 - -一期全部服务可以先部署在国内服务器,但源码、数据库命名、配置和事件契约从现在起按国内与海外边界约束。后期迁移时只迁移尾程及海外作业单元,新尾程聚合切到海外主写,国内旧尾程单排空;严禁同一聚合在两个区域同时写入。 - -## 2. 拆分后的目录树 - -```text -D:\baiyue -├─ pom.xml 根 Maven 聚合,一个 Git 仓库 -├─ common-core 统一返回、异常码等公共模型 -├─ common-web 统一异常处理和 Web 公共能力 -├─ common-security JWT、租户和权限上下文 -├─ common-openapi Swagger/OpenAPI 公共配置 -├─ common-audit 审计事件采集客户端 -├─ common-event-contract 国内外共享的版本化事件信封和类型 -├─ event-bridge-service 跨区事件接收幂等和投递记录基础骨架 -├─ gateway 区域网关源码,按区域独立部署 -├─ auth 统一登录与令牌服务 -├─ iam 租户、成员、角色、菜单和按钮权限 -├─ order 订单主档、状态历史和订单 Outbox -├─ firstleg 国内头程履约 -├─ lastleg 尾程履约,后期迁移到海外主写 -├─ warehouse 仓储源码,国内仓和海外仓分别部署 -├─ file 文件服务源码,按区域分别部署 -├─ print 打印服务源码,按区域分别部署 -├─ audit-service 审计落库服务,按区域分别部署 -├─ integration-service 第三方适配,按区域启用不同提供方 -├─ deployments -│ ├─ cn -│ │ ├─ pom.xml 国内部署聚合入口 -│ │ ├─ env.example 国内环境变量清单 -│ │ ├─ build.ps1 国内单元构建脚本 -│ │ └─ README.md 国内部署说明 -│ └─ overseas -│ ├─ pom.xml 海外部署聚合入口 -│ ├─ env.example 海外环境变量清单 -│ ├─ build.ps1 海外单元构建脚本 -│ └─ README.md 海外部署说明 -├─ config\nacos Nacos 配置模板 -└─ docs 架构、数据和实施审核文档 -``` - -`event-bridge-service` 已生成基础骨架,默认端口 `9300`,独立数据库 `baiyue_event_bridge`。它已实现事件信封校验、规范化摘要、内部服务令牌校验、目标区域校验和 Inbox 幂等接收。事件桥自身 8 项测试、公共事件契约 6 项测试通过;基础骨架不等于真实跨区链路已可用。 - -## 3. 模块职责与部署归属 - -| 模块 | 核心职责 | 一期物理位置 | 目标主写位置 | -| --- | --- | --- | --- | -| `gateway` | 统一入口、路由、鉴权前置校验、Swagger 聚合 | 国内 | 国内和海外各一套 | -| `auth` | 注册、登录、刷新、退出、令牌会话 | 国内 | 国内 | -| `iam` | 租户、成员、角色、菜单、按钮、数据范围和资质审核 | 国内 | 国内 | -| `order` | 商户订单主档、平台审核、状态机、订单 Outbox | 国内 | 国内 | -| `firstleg` | 国内头程任务与履约交接 | 国内 | 国内 | -| `lastleg` | 到港前、清关、提柜、海外仓、派送、签收 | 国内运行一期版本 | 海外 | -| `warehouse` | 仓库资料与仓内作业边界 | 国内 | 国内仓、海外仓各自区域主写 | -| `file` | 文件元数据、授权下载、区域对象存储适配 | 国内 | 国内和海外各一套 | -| `print` | 模板、打印任务和状态历史 | 国内 | 按区域各一套 | -| `audit-service` | 不可篡改操作审计的落库边界 | 国内 | 按区域本地记录 | -| `integration-service` | OCR、港务、海关、Amazon 和承运商适配 | 国内 | 国内和海外各一套 | -| `common-event-contract` | 事件信封、类型、区域码、校验规则 | 仅参与编译 | 两区共享同一版本 | -| `event-bridge-service` | 跨区事件接收幂等、待发送记录和投递尝试 | 国内回环验证 | 国内和海外各部署一套 | - -## 4. 国内与海外启动清单 - -### 4.1 一期国内服务器 - -一期核心闭环建议启动:`gateway`、`auth-service`、`iam-service`、`order-service`、`warehouse-service`、`firstleg-service`、`audit-service`、`integration-service`。涉及资质附件时启动 `file-service`;涉及标签和作业单时启动 `print-service`;开发尾程交接闭环后再启动 `lastleg-service`。 - -当前 `deployments/cn/pom.xml` 已聚合公共库、`common-event-contract`、`event-bridge-service`,以及 `gateway/auth/iam/order/firstleg/warehouse/file/print/audit-service/integration-service`。默认构建为 18 个 Reactor 模块,不包含 `lastleg`,用于表达长期区域职责;启用 `phase1-domestic-lastleg` Profile 后为 19 个 Reactor 模块并包含 `lastleg`,用于一期尾程临时部署在国内的兼容场景。 - -一期包含尾程的国内构建命令: - -```powershell -cd D:\baiyue\deployments\cn -.\build.ps1 -IncludePhase1Lastleg - -# 等效 Maven 命令 -mvn -f pom.xml -Pphase1-domestic-lastleg -DskipTests package -``` - -默认构建不带 `-IncludePhase1Lastleg`。长期迁移完成后停止使用该 Profile,国内单元恢复默认 18 模块,旧国内尾程单按迁移方案排空。 - -### 4.2 后期海外服务器 - -`deployments/overseas/pom.xml` 当前聚合公共库、`common-event-contract`、`event-bridge-service`,以及 `gateway/lastleg/warehouse/file/print/audit-service/integration-service`。海外不部署 `auth`、`iam`、`order`、`firstleg` 的写实例。 - -海外正式上线前必须具备:独立 Nacos、独立 MySQL、独立 Redis、独立对象存储、独立域名和证书、跨区可靠事件、海外本地鉴权、监控告警和回滚能力。当前目录聚合只代表结构已拆分,不代表这些生产资源已经创建。 - -## 5. 共享源码规则 - -1. 国内与海外共用同一 Git 仓库、同一模块源码和同一版本号,不复制 `warehouse/file/print/integration/audit` 源码。 -2. 区域差异必须通过环境变量、Spring Profile、Nacos Namespace 和适配器开关表达,禁止在业务代码中散落主机地址判断。 -3. 公共模块只提供无区域主写状态的能力,不保存业务数据,不单独启动。 -4. 事件契约必须向后兼容;新增字段优先可选,破坏性变更必须升级事件版本。 -5. 国内和海外可使用相同服务注册名,但只能注册到各自 Nacos,不能互相发现。 -6. 跨区调用只使用版本化事件或受控文件复制,不通过 OpenFeign 直接跨公网调用业务服务。 - -## 6. 构建与校验命令 - -根工程完整校验: - -```powershell -cd D:\baiyue -mvn clean test -``` - -国内部署单元: - -```powershell -cd D:\baiyue\deployments\cn -.\build.ps1 -mvn -f pom.xml -DskipTests validate -``` - -上面是默认 18 模块构建;一期需要连同国内尾程一起构建时执行 `.\build.ps1 -IncludePhase1Lastleg`,对应 19 模块。 - -海外部署单元: - -```powershell -cd D:\baiyue\deployments\overseas -.\build.ps1 -mvn -f pom.xml -DskipTests validate -``` - -事件契约: - -```powershell -cd D:\baiyue -mvn -pl common-event-contract -am test -``` - -构建成功只证明依赖、编译和自动化测试通过,不代表数据库初始化、Nacos 配置、网络连通、跨区事件或生产部署已验收。 - -## 7. 数据与基础设施边界 - -### 7.1 MySQL - -- 国内主库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_firstleg`,以及带区域归属的国内仓储、文件、打印、集成和审计数据库。 -- 一期尾程位于国内时使用独立的 `baiyue_lastleg_cn`,不与订单、头程或仓储跨库联表。 -- 海外目标库:尾程、海外仓储、海外文件、海外打印、海外集成和海外审计各自独立数据库。 -- 禁止跨区外键、跨区事务、共享数据库账号和双写;订单号等公共标识由应用生成,避免区域自增冲突。 - -现有各模块默认配置仍主要使用 `baiyue_warehouse`、`baiyue_file`、`baiyue_print`、`baiyue_lastleg` 等未带区域后缀的本地库名。区域化库名属于目标配置,必须在部署环境变量或 Nacos 中显式覆盖,不能把目标名称写成当前已经生效。 - -### 7.2 Nacos - -国内使用独立集群和 `prod-cn` Namespace,海外使用独立集群和 `prod-overseas` Namespace,现阶段 Group 保持 `BAIYUE`。两区的 `8848` 不暴露公网,海外服务不连接国内 Nacos。 - -### 7.3 Redis - -国内登录和网关当前会检查国内 Redis 中的令牌会话。海外正式运行前必须改为短期非对称签名令牌、本地 JWKS 缓存和海外本地撤销信息,不能把跨公网读取国内 Redis 当作生产方案。两区 Redis 的密码、库和键空间独立。 - -### 7.4 对象存储 - -当前文件资质上传仍使用本地磁盘目录,适合本地开发,不适合双区域。目标方案是国内文件进入国内对象存储,海外文件进入海外对象存储;跨区只复制履约所需文件,事件仅携带文件标识、版本和摘要,不携带文件正文。 - -## 8. 当前真实落地状态 - -| 能力 | 状态 | 说明 | -| --- | --- | --- | -| 单 Git 仓库、多模块 Maven 工程 | 已实现 | 根工程统一管理源码和依赖 | -| 国内、海外部署聚合目录 | 已生成,待部署验证 | 已有 POM、环境变量示例、构建脚本和说明 | -| 事件信封和类型公共契约 | 已实现基础契约 | 有源码和单元测试,尚未接入完整业务链路 | -| 事件桥服务 | 已实现基础骨架 | 内部令牌、目标区域、信封与摘要校验及 Inbox 幂等已实现,8 项测试通过;mTLS、来源白名单、RocketMQ、真实跨区发送、Outbox 扫描和业务消费者未实现 | -| 登录、租户、菜单和按钮权限模型 | 已实现一期基础能力 | 管理员与权限初始化已有验收记录;写接口权限仍需系统覆盖 | -| 订单主档和状态机 | 已实现一期主要接口 | 已有订单 Outbox 表和本地事务写入;可靠发布未闭环 | -| 头程服务 | 部分实现 | 已有任务、分配历史和消费记录,完整到仓、排柜、订舱、装柜、报关未完成 | -| 尾程服务 | 骨架 | 当前主要为健康接口,尾程业务闭环和 Inbox 尚未完成 | -| 仓储服务 | 部分实现 | 仓库资料维护和查询已具备,仓内入库、测量、拆货、打托等作业未闭环 | -| 文件服务 | 部分实现 | 资质上传下载和表结构已存在;当前仍依赖本地磁盘,对象存储未落地 | -| 打印服务 | 基础骨架 | 有表结构和健康接口,模板渲染、任务执行和打印机适配未完成 | -| 审计服务 | 基础能力已实现 | 有内部采集接口和审计表;跨区审计汇总未完成 | -| 集成服务 | 基础骨架 | 可查询提供方状态;真实 OCR、Amazon、海关、港务、承运商调用待实现 | -| 跨区 Outbox/Inbox 可靠链路 | 部分骨架,未完成 | Inbox 持久化基础已存在;mTLS、真实跨区发送、RocketMQ 发布消费、重试死信和人工补投仍待实现 | -| 海外独立基础设施与灰度 | 未完成 | 尚未进入海外生产部署和切流阶段 | - -## 9. 审核决策点 - -1. 是否确认长期归属为“订单和头程国内唯一主写,尾程海外唯一主写”。 -2. 是否确认一期尾程允许先在国内运行,但数据库和聚合从第一天保留 `execution_region`。 -3. 是否确认仓储、文件、打印、审计、集成采用同源码双区域部署,而不是复制出海外模块。 -4. 是否确认跨区只走可靠事件和受控文件复制,不开放 MySQL、Redis、Nacos 公网互通。 -5. 是否确认先在国内回环验证事件可靠性,再建设海外影子环境和灰度切流。 diff --git a/docs/repository-boundary.md b/docs/repository-boundary.md deleted file mode 100644 index e35f87346..000000000 --- a/docs/repository-boundary.md +++ /dev/null @@ -1,27 +0,0 @@ -# 头程仓库边界 - -## 业务所有权 - -头程仓库拥有统一身份入口和国内主数据,包括租户、用户、角色、菜单、按钮权限、订单、国内仓储、头程履约、文件、打印、审计和国内渠道集成。`auth` 负责认证,`iam` 负责授权与租户隔离;任何海外服务都不能直接写入这两个服务的数据库。 - -尾程仓库拥有海外尾程履约及海外本地支撑能力。头程仓库不包含尾程服务源码,尾程仓库也不复制认证、权限、角色和租户服务。 - -## 技术边界 - -- 公共代码由本仓以 `com.baiyue.shared` 坐标发布,尾程仓通过 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT` 锁定版本。 -- 两仓保留各自独立的父工程、构建流水线、配置中心命名空间和数据库实例。 -- 跨区域通信只使用版本化事件信封,通过事件桥、HTTPS 与双向证书完成。 -- 禁止跨公网共享 MySQL、Redis、Nacos,禁止跨区域直接使用 Feign 调用内部服务。 -- 文件二进制存入区域对象存储;跨区仅传递文件标识、签名地址或经批准的复制事件。 - -## 身份与权限 - -统一登录、租户、用户、角色、菜单及按钮权限全部归属头程仓。权限体系按应用端严格分为商户端 `MERCHANT` 和平台后台端 `PLATFORM` 两套,角色、菜单、页面、按钮和接口权限均按 `tenant_id + app_code` 隔离,后端写接口必须校验对应权限码,不能只依赖前端隐藏按钮。海外网关和服务只验证由头程认证中心签发的非对称令牌,通过只读密钥集合校验签名,不连接国内认证数据库。令牌需携带租户、用户、端类型、角色、权限及区域声明;海外服务仍必须执行租户隔离和本地资源授权校验。 - -## 数据与事件 - -订单在头程仓主写,头程完成后发布交接事件。尾程里程碑通过事件桥回传,头程订单服务生成国内只读轨迹投影并触发通知。发送端使用 Outbox,接收端使用 Inbox,事件必须具备全局事件编号、聚合编号、版本、源区域、目标区域、链路编号和摘要。 - -## 发布原则 - -公共制品先发布 BOM 与依赖模块,再构建尾程仓。公共事件契约只允许向后兼容扩展;破坏性变更必须提升主版本并安排双版本消费窗口。两个仓库独立发布,任何一方失败不得要求另一方同时回滚。 diff --git a/docs/two-repository-split.md b/docs/two-repository-split.md deleted file mode 100644 index f7b6320cc..000000000 --- a/docs/two-repository-split.md +++ /dev/null @@ -1,400 +0,0 @@ -# 头程与尾程双仓拆分审核方案 - -## 一、拆分结论 - -项目拆分为两个完全独立的 Git 仓库: - -- 头程仓负责国内业务、统一登录、租户、用户、角色、菜单、按钮权限、订单主数据和头程履约。 -- 尾程仓负责海外尾程履约以及海外本地仓储、文件、打印、审计、外部渠道适配。 -- 登录和权限数据只有头程仓可以写入,尾程仓不得包含 `auth`、`iam`、`order`、`firstleg` 源码。 -- 两仓不得通过源码依赖、跨库查询或跨区域 Feign 调用协作,只能通过版本化事件契约交换业务事实。 -- 公共组件由头程仓维护并发布到 Maven 私有仓库,尾程仓只引用明确版本的制品。 - -当前目录是待审核的拆分暂存稿,审核通过后再分别落到正式仓库目录并初始化独立 Git 仓库。 - -## 二、拆分后的目录树 - -```text -baiyue-firstleg -├─ pom.xml -├─ platform-bom -├─ common-core -├─ common-web -├─ common-security -├─ common-openapi -├─ common-audit -├─ common-event-contract -├─ gateway -├─ auth -├─ iam -├─ order -├─ firstleg -├─ warehouse -├─ file -├─ print -├─ audit-service -├─ integration-service -├─ event-bridge-service -├─ config -│ └─ nacos -│ └─ cn -├─ docs -├─ scripts -└─ docker-compose-infra.yml - -baiyue-lastleg -├─ pom.xml -├─ gateway -├─ lastleg -├─ warehouse -├─ file -├─ print -├─ audit-service -├─ integration-service -├─ event-bridge-service -├─ config -│ └─ nacos -├─ docs -├─ scripts -└─ env.example -``` - -## 三、模块归属和职责 - -### 3.1 头程仓 - -| 模块 | 职责 | 数据归属 | -|---|---|---| -| `gateway` | 国内统一入口、鉴权、路由和接口文档聚合 | 不保存业务数据 | -| `auth` | 统一登录、令牌签发、刷新、注销和登录会话 | 国内认证库和国内 Redis | -| `iam` | 租户、用户、成员、角色、菜单、按钮权限和数据范围 | 国内权限库 | -| `order` | 订单主数据、状态编排和跨区订单事实发布 | 国内订单库 | -| `firstleg` | 揽收、入仓、装载、发运和跨区交接 | 国内头程库 | -| `warehouse` | 国内仓库、库位和出入库能力 | 国内仓储库 | -| `file` | 国内文件元数据和对象存储适配 | 国内文件库和国内对象存储 | -| `print` | 国内标签、单据和打印任务 | 国内打印库 | -| `audit-service` | 国内审计记录 | 国内审计库 | -| `integration-service` | 国内外部渠道适配 | 配置和必要的本地状态 | -| `event-bridge-service` | 国内跨区事件发送、接收、重试和幂等 | 国内事件桥库 | -| `common-*` | 公共基础能力 | 发布为 Maven 制品 | -| `common-event-contract` | 跨区事件信封、事件类型和校验规则 | 发布为 Maven 制品 | -| `platform-bom` | 统一公共制品版本 | 发布为 Maven 物料清单 | - -### 3.2 尾程仓 - -| 模块 | 职责 | 数据归属 | -|---|---|---| -| `gateway` | 海外统一入口、本地验签、路由和访问控制 | 不保存业务数据 | -| `lastleg` | 到港、清关、拆柜、海外仓入库、派送和签收 | 海外尾程库 | -| `warehouse` | 海外仓库、库位和出入库作业 | 海外仓储库 | -| `file` | 海外业务文件、面单和签收证明 | 海外文件库和海外对象存储 | -| `print` | 海外标签、面单和打印任务 | 海外打印库 | -| `audit-service` | 海外本地操作审计 | 海外审计库 | -| `integration-service` | 海外仓、承运商、清关和平台渠道适配 | 配置和必要的本地状态 | -| `event-bridge-service` | 海外跨区事件接收、发送、重试和幂等 | 海外事件桥库 | - -尾程仓明确禁止出现以下源码目录和服务制品依赖: - -```text -auth -iam -order -firstleg -common-core -common-web -common-security -common-openapi -common-audit -common-event-contract -``` - -其中公共组件不是禁止使用,而是禁止复制源码;尾程仓必须通过 Maven 私有仓库引用。 - -## 四、登录和权限边界 - -1. 账号、密码、租户、用户、角色、菜单、按钮权限和数据范围只允许由头程仓的 `auth` 与 `iam` 写入。 -2. 用户通过国内统一入口登录,由 `auth` 使用非对称算法签发短时效令牌。 -3. 海外网关只缓存公开密钥并在本地验签,不保存签名私钥,不跨区读取国内 Redis 登录会话。 -4. 令牌携带用户、租户、应用端、权限快照版本和必要的授权声明;海外服务仍需校验租户边界。 -5. 用户停用、租户冻结、权限版本变化和高风险封禁通过事件同步为海外本地投影。 -6. 正式迁往海外前,公共安全组件必须完成公开密钥验签和本地权限投影校验改造;当前基于共享密钥和 Redis 会话的实现不能直接作为跨区域生产方案。 - -## 五、公共制品发布清单 - -头程仓发布以下公共制品,尾程仓只消费,不得再次发布同一坐标: - -| 制品坐标 | 类型 | 用途 | -|---|---|---| -| `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT` | `pom` | 统一公共组件版本 | -| `com.baiyue.shared:baiyue-common-core:1.0.0-SNAPSHOT` | `jar` | 返回结构、异常、基础实体和数据库公共配置 | -| `com.baiyue.shared:baiyue-common-web:1.0.0-SNAPSHOT` | `jar` | 统一异常、链路标识和网络层公共能力 | -| `com.baiyue.shared:baiyue-common-security:1.0.0-SNAPSHOT` | `jar` | 认证信息解析和接口权限校验 | -| `com.baiyue.shared:baiyue-common-openapi:1.0.0-SNAPSHOT` | `jar` | 接口文档公共配置 | -| `com.baiyue.shared:baiyue-common-audit:1.0.0-SNAPSHOT` | `jar` | 业务审计切面和审计发送能力 | -| `com.baiyue.shared:common-event-contract:1.0.0-SNAPSHOT` | `jar` | 跨区事件信封、类型、摘要和校验规则 | - -两个聚合父工程使用不同坐标: - -```text -com.baiyue.firstleg:baiyue-firstleg-platform:1.0.0-SNAPSHOT -com.baiyue.lastleg:baiyue-lastleg-platform:1.0.0-SNAPSHOT -``` - -正式环境不得长期引用快照版本。审核和联调阶段可以使用快照版本,上线时应发布不可覆盖的正式版本,例如 `1.0.0`,并保留制品校验值和变更记录。 - -## 六、构建和发布顺序 - -### 6.1 本地构建 - -第一次构建或公共版本发生变化时,必须先构建头程仓: - -```powershell -cd D:\baiyue-firstleg -mvn clean install -``` - -确认公共制品已经安装到本地 Maven 仓库后,再构建尾程仓: - -```powershell -cd D:\baiyue-lastleg -.\scripts\check-boundary.ps1 -mvn clean verify -``` - -### 6.2 私有仓库发布 - -用于团队开发、流水线或服务器构建时,头程仓先发布公共制品: - -```powershell -cd D:\baiyue-firstleg -mvn clean deploy -``` - -尾程流水线确认指定的 `baiyue.shared.version` 已经存在且校验通过,然后执行自身构建。尾程仓不得通过相对路径访问头程仓,也不得依赖开发人员本地残留的公共源码。 - -### 6.3 正确的拓扑顺序 - -```text -头程父工程 - → 公共核心 - → 公共网络层 - → 公共安全 - → 公共接口文档 - → 公共审计 - → 公共事件契约 - → 公共版本清单发布 - → 头程业务服务构建 - → 尾程边界检查 - → 尾程业务服务构建 -``` - -## 七、跨仓业务协作 - -### 7.1 强制规则 - -- 跨仓只传递业务事件,不共享源码模型。 -- 不通过公网 Feign 直接调用对方内部服务。 -- 不跨区域查询对方数据库、Redis、Nacos 或对象存储。 -- 事件发送采用本地事务与发件箱,接收采用收件箱幂等。 -- 同一事件重复到达只能产生一次业务效果。 -- 失败事件必须支持指数退避、死信、告警和人工补投。 - -### 7.2 推荐事件链路 - -```text -业务服务本地事务 - → 本地发件箱 - → 本区域事件桥 - → 加密的跨区传输 - → 对方区域事件桥 - → 本地收件箱去重 - → 本地业务消费者 - → 本地投影或状态机更新 -``` - -### 7.3 首批事件 - -| 方向 | 事件 | 用途 | -|---|---|---| -| 国内到海外 | 订单已创建 | 建立尾程订单投影 | -| 国内到海外 | 订单已取消 | 阻止未开始的尾程履约 | -| 国内到海外 | 头程已交接 | 建立尾程接货任务 | -| 国内到海外 | 货物已发运 | 提供预计到港和装载信息 | -| 国内到海外 | 权限版本已变化 | 刷新海外授权投影 | -| 国内到海外 | 租户已冻结 | 海外本地立即拒绝访问 | -| 海外到国内 | 尾程已接货 | 更新国内订单轨迹 | -| 海外到国内 | 已到港或已清关 | 更新节点并触发通知 | -| 海外到国内 | 已派送 | 更新订单状态和客户通知 | -| 海外到国内 | 已签收 | 完成尾程并回传签收证明引用 | -| 海外到国内 | 尾程异常 | 触发国内客服和订单异常流程 | - -文件二进制不放入事件,只传文件标识、校验值、区域和受控下载引用。 - -## 八、数据库、Nacos 和 Redis 边界 - -### 8.1 数据库 - -- 每个微服务拥有独立数据库账号和独立逻辑库。 -- 国内数据库只允许国内服务网络访问,海外数据库只允许海外服务网络访问。 -- 同名逻辑库也必须部署在不同实例或不同区域资源中,不能把同一个 MySQL 实例暴露给两区。 -- 服务之间不建立跨库外键,不使用跨库联表,不复制对方业务表。 -- 跨区所需数据建立最小化本地投影,并记录来源事件、聚合版本和最后更新时间。 -- 订单主数据归国内,尾程状态主数据归海外;国内只保存用于查询和通知的尾程轨迹投影。 - -### 8.2 Nacos - -```text -国内命名空间:prod-cn -海外命名空间:prod-overseas -``` - -- 两区使用独立 Nacos 集群、独立地址和独立凭据。 -- 服务发现仅在本区域内生效。 -- 配置项可以使用相同的数据标识,但配置内容、密钥和资源地址必须按区域隔离。 -- 网关关闭自动服务路由,只开放区域清单中明确声明的服务。 - -### 8.3 Redis - -- 国内 Redis 保存国内登录会话、限流和国内业务缓存。 -- 海外 Redis 只保存海外缓存、幂等辅助状态和本地权限投影,不保存或远程读取国内登录会话。 -- 两区 Redis 不做公网主从复制,不复用密码和数据库编号作为隔离手段。 -- 需要跨区同步的冻结或撤销事实通过事件传递,并在海外形成可过期的本地拒绝投影。 - -## 九、持续集成和发布流程 - -### 9.1 头程仓流水线 - -1. 检查仓库边界、敏感信息和禁止文件。 -2. 校验全部 POM、代码格式、单元测试和契约测试。 -3. 构建公共制品和头程业务服务。 -4. 公共组件变更时先发布候选版本到 Maven 私有仓库。 -5. 使用候选公共版本触发尾程兼容流水线。 -6. 两仓都通过后,将公共候选版本提升为不可覆盖的正式版本。 -7. 构建带提交号和版本号的国内服务镜像。 -8. 部署国内测试环境,完成数据库迁移、健康检查和一期验收。 -9. 通过审批后灰度发布国内生产环境。 - -### 9.2 尾程仓流水线 - -1. 执行仓库边界脚本,禁止头程业务源码和公共组件源码进入仓库。 -2. 从私有仓库解析锁定的公共版本,不使用动态版本范围。 -3. 在干净 Maven 缓存中执行编译、单元测试、契约测试和集成测试。 -4. 校验海外配置中不存在国内数据库、Redis、Nacos 和内部服务地址。 -5. 构建带提交号、尾程版本和公共组件版本标签的镜像。 -6. 部署海外测试环境并执行事件往返、幂等、重试和断网恢复测试。 -7. 通过审批后按租户或新订单灰度,不迁移在途订单的写入归属。 - -公共制品发布失败或尾程兼容流水线失败时,禁止头程公共版本进入正式仓库。 - -## 十、版本兼容规则 - -1. 公共制品和事件契约使用语义化版本。 -2. 补丁版本只允许缺陷修复;次版本允许向后兼容的字段和能力增加;主版本用于不兼容变更。 -3. 事件必须携带事件类型、事件标识、契约版本、生产区域、目标区域、聚合标识、聚合版本、时间和负载摘要。 -4. 新增字段必须可选或具有默认语义,消费者必须忽略不认识的附加字段。 -5. 不允许在同一主版本中删除字段、改变字段含义或复用旧事件类型表达新语义。 -6. 生产者至少兼容当前和上一版消费者;消费者至少支持当前和上一版事件契约。 -7. 公共版本升级必须同时通过头程与尾程测试,尾程仓在自己的父 POM 中明确锁定版本。 -8. 每个服务镜像记录业务版本、公共制品版本、契约版本和源代码提交号,便于回滚和审计。 - -## 十一、迁移步骤 - -1. 审核并冻结本方案中的模块归属、数据主权和首批事件清单。 -2. 在原仓库冻结结构性新增,只允许必要缺陷修复,避免拆分期间双向漂移。 -3. 完成暂存仓的中文乱码检查、父 POM、模块坐标和区域配置校验。 -4. 建立 Maven 私有仓库的正式版、候选版和快照版发布策略。 -5. 先发布头程公共物料清单、公共组件和事件契约。 -6. 在干净环境构建尾程仓,证明其不依赖原单体目录和本地源码。 -7. 分别创建 `D:\baiyue-firstleg` 与 `D:\baiyue-lastleg`,各自初始化独立 Git 仓库和远程仓库。 -8. 先在国内部署头程仓全部一期服务,尾程一期仍可在国内独立环境运行,但使用尾程仓代码和独立资源。 -9. 完成发件箱、事件桥、收件箱、业务消费者和轨迹通知闭环。 -10. 事件链路先影子运行,对比原链路结果,不立即切换业务主写。 -11. 海外基础设施准备完成后,仅将新创建、未履约的尾程聚合灰度到海外。 -12. 已在途订单保持原区域完成,不在履约过程中迁移数据库写入权。 -13. 灰度稳定后扩大流量,保留事件回放、数据核对和版本回滚能力。 -14. 双仓稳定运行并完成审计后,再归档原 `D:\baiyue`,不得提前删除。 - -## 十二、验收清单 - -### 12.1 仓库和构建 - -- [ ] 两个目录分别存在且各自只有一个独立 `.git`。 -- [ ] 尾程仓不存在 `auth`、`iam`、`order`、`firstleg` 和公共组件源码目录。 -- [ ] 两个聚合父工程及重复区域服务不存在相同可发布坐标冲突。 -- [ ] 头程仓可执行 `mvn clean verify`。 -- [ ] 头程公共制品可执行 `mvn deploy` 并从私有仓库重新下载。 -- [ ] 清空本地相关制品后,尾程仓仍可独立执行 `mvn clean verify`。 -- [ ] 两仓均未提交 `target`、密钥、密码、日志和开发工具缓存。 - -### 12.2 登录权限 - -- [ ] 登录、租户、用户、角色、菜单、按钮权限只有国内写入口。 -- [ ] 海外网关使用公开密钥本地验签,不保存国内签名私钥。 -- [ ] 海外服务不连接国内 Redis 会话。 -- [ ] 租户冻结、用户停用和权限版本变化可在规定时间内同步到海外。 -- [ ] 平台端与商户端权限、按钮权限和租户数据边界均有自动化测试。 - -### 12.3 跨区事件 - -- [ ] 订单、头程交接、尾程节点和异常事件均有明确契约版本。 -- [ ] 发件箱与业务写入属于同一本地事务。 -- [ ] 收件箱按事件标识幂等,重复事件不重复推进状态。 -- [ ] 乱序、延迟、重复、摘要冲突和非法目标区域均有测试。 -- [ ] 断网恢复、指数退避、死信告警和人工补投可用。 -- [ ] 跨区链路启用传输加密、双向认证或等效的服务身份校验。 -- [ ] 两仓不存在跨区 Feign 地址和对方内部数据库连接。 - -### 12.4 数据和基础设施 - -- [ ] 国内与海外 MySQL、Redis、Nacos、消息系统和对象存储物理隔离。 -- [ ] 每个服务使用独立数据库账号且权限最小化。 -- [ ] 没有跨库查询、跨库外键和公网数据库访问。 -- [ ] 国内与海外网关仅暴露本区域允许的路由。 -- [ ] 备份、恢复、数据保留、审计和敏感信息脱敏策略按区域验证。 - -### 12.5 发布迁移 - -- [ ] 镜像可追溯到代码提交、业务版本、公共版本和契约版本。 -- [ ] 数据库脚本支持升级校验和回滚预案。 -- [ ] 新订单灰度、在途订单保持原区域和失败回切规则已演练。 -- [ ] 国内订单轨迹与海外尾程事实完成自动核对。 -- [ ] 原仓库只在双仓稳定验收后归档,归档前保留只读备份。 - -## 十三、当前完成情况 - -### 13.1 已完成 - -- 已在可写暂存目录形成头程和尾程两个独立 Maven 聚合工程草案。 -- 已将 `auth`、`iam`、`order`、`firstleg` 和全部公共组件源码仅放入头程草案。 -- 已确认尾程草案不包含上述头程业务源码目录和编译期业务包引用。 -- 已为两个父工程使用不同的组织坐标,降低聚合制品冲突风险。 -- 已建立公共物料清单,尾程通过 `com.baiyue.shared` 公共制品引用公共能力。 -- 已按国内和海外分别整理 Gateway 与 Nacos 配置草案。 -- 已为尾程草案提供仓库边界检查脚本和构建脚本。 -- 已从尾程文件服务草案中移除同步调用国内权限服务的资格上传链路。 -- 已保留跨区事件桥和公共事件契约骨架。 - -### 13.2 待完成 - -- 待用户审核本方案后,才创建两个正式 Git 仓库;当前暂存目录不是最终交付仓库。 -- 待修复暂存文件中已有的中文编码显示问题,并统一以无字节顺序标记的 UTF-8 保存。 -- 待配置 Maven 私有仓库地址、凭据、发布权限和不可覆盖的正式版本策略。 -- 待检查并消除两个仓中重复区域服务的最终发布坐标冲突。 -- 待将公共安全组件从共享密钥与国内 Redis 会话校验改造为公开密钥验签和海外本地授权投影。 -- 待实现真实的发件箱扫描、跨区发送、收件箱消费、重试、死信和人工补投。 -- 待为订单、头程、尾程、权限冻结和轨迹通知接入真实事件生产者与消费者。 -- 待补齐尾程业务表、仓储作业、文件对象存储、打印和渠道适配的实际业务能力。 -- 待在干净 Maven 仓库中验证“头程发布公共制品后,尾程完全独立构建”。 -- 待执行两仓全部单元测试、契约测试、集成测试、安全测试和区域故障演练。 -- 待初始化独立 Git 历史、远程仓库保护规则和双仓持续集成流水线。 - -## 十四、审核决策点 - -本次审核需要确认以下内容: - -1. 登录、租户、用户、角色、菜单和按钮权限永久归头程仓唯一写入。 -2. 订单主数据归头程,尾程只维护事件投影和尾程状态主数据。 -3. 公共组件由头程仓发布,尾程不复制公共源码。 -4. 两仓跨区只使用事件,不开放跨区内部 Feign 和数据库访问。 -5. 一期先在国内完成独立双仓部署验证,后期只迁移新尾程聚合到海外。 -6. 当前 `D:\baiyue` 保留为拆分来源和回滚备份,双仓验收前不删除。 diff --git a/docs/企业资质文件迁移说明.md b/docs/企业资质文件迁移说明.md new file mode 100644 index 000000000..774598462 --- /dev/null +++ b/docs/企业资质文件迁移说明.md @@ -0,0 +1,85 @@ +# 企业资质文件迁移说明 + +## 统一接口边界 + +企业资质的唯一真实上传入口是: + +```http +POST /api/merchant/v1/files/qualifications +Content-Type: multipart/form-data +``` + +表单字段为 `file`,可选字段为 `certificateNo`。File 服务先校验并保存真实图片,再通过内部接口通知 IAM 创建资质和审核记录。IAM 的商户资质接口只负责查询,不再提供 JSON 上传入口。 + +有效的商户预览地址格式为: + +```text +/api/merchant/v1/files/qualifications/{uuid}.jpg +/api/merchant/v1/files/qualifications/{uuid}.png +``` + +数据库中的 URL 不是文件本身。禁止只生成 UUID 并修改 `document_url`,也禁止用空文件或任意图片冒充营业执照。 + +## 历史数据盘点 + +发布前先以只读方式盘点历史路径: + +```sql +SELECT id, tenant_id, qualification_type, document_url, status +FROM baiyue_iam.iam_tenant_qualification +WHERE deleted = 0 + AND (document_url IS NULL + OR document_url NOT REGEXP '^/api/merchant/v1/files/qualifications/[0-9a-fA-F-]{36}\\.(jpg|png)$'); +``` + +盘点结果中的每条记录都必须找到原始图片。找不到原始图片时,将记录标记为“待业务补传”,不得伪造文件,也不得直接改 URL。 + +## 可落地迁移步骤 + +1. 暂停对应资质的替换操作,记录资质 ID、租户 ID、旧 URL 和原文件校验和。 +2. 从可信旧存储取得原始图片,校验文件签名、扩展名和不超过 5 MB 的大小限制。 +3. 使用迁移工具按 File 服务同一规则生成 UUID 文件名,将真实文件写入共享存储的 `qualifications/{tenantId}/` 目录。 +4. 写入完成后,从 File 服务平台预览接口读取新文件并核对 SHA-256;读取失败时不得修改数据库。 +5. 在单条事务中按 `id + tenant_id + 旧 document_url` 条件更新 IAM 的 `document_url`。受影响行数不是 1 时立即回滚。 +6. 再次通过平台预览接口验收;验收完成前保留旧文件和旧 URL 清单,以便回滚。 + +容器部署前必须确认 File 实例注册名为 `file-service`,Gateway 路由目标为 `lb://file-service`,并确认 `/data/files` 是持久卷且容器用户 UID 10001 可以实际创建和删除文件。镜像内的目录权限不会覆盖宿主机 bind mount 的属主;使用宿主机目录时,须先创建目录并把所有权授予 UID 10001。旧版本已创建的卷或目录可在维护窗口使用当前 File 镜像一次性修复后重启: + +```sh +docker compose --env-file <环境文件> -f deploy/docker-compose.yml run --rm --user 0 --entrypoint sh file -c 'chown -R 10001:10001 /data/files' +``` + +`/actuator/health` 通过后,还应执行一次脱敏图片上传和平台预览,验证 Gateway、Nacos、挂载和 IAM 协作的完整链路。 + +当前 File 服务使用本地或共享文件目录时,可先运行仓库内脚本生成真实文件、前向 SQL 和回滚 SQL: + +```powershell +.\scripts\migrate-local-qualification-file.ps1 ` + -ImagePath D:\test-data\脱敏营业执照.png ` + -StorageRoot D:\baiyue-firstleg-runtime\uploads ` + -TenantId 10001 ` + -QualificationId 资质ID ` + -OldDocumentUrl /phase1-seed/demo-business-license.jpg +``` + +脚本会校验文件大小、JPG/PNG 文件签名和复制前后 SHA-256,并生成带 `id + tenant_id + 旧 URL` 乐观条件的前向 SQL及回滚 SQL。脚本不会连接数据库,也不会在生成的 SQL 中自动提交事务;执行人员确认文件已落到 File 服务实际挂载目录后,才可人工执行前向 SQL,并在 `affected_rows = 1` 且平台预览校验成功后显式 `COMMIT`。否则必须 `ROLLBACK` 并删除本次新增文件。对象存储环境不使用该脚本,应通过 File 上传接口迁移。 + +数据库更新模板如下,执行时必须替换为经过文件校验的真实值: + +```sql +START TRANSACTION; +UPDATE baiyue_iam.iam_tenant_qualification +SET document_url = :verified_new_url +WHERE id = :qualification_id + AND tenant_id = :tenant_id + AND document_url = :expected_old_url + AND deleted = 0; +-- 仅当 ROW_COUNT() = 1 且新文件已通过 File 预览校验时提交,否则回滚。 +-- 执行人员检查后显式 COMMIT 或 ROLLBACK,模板不自动提交。 +``` + +## 新环境初始化 + +初始化脚本不再创建 `/phase1-seed/...` 虚假资质。测试环境需要可下单的商户时,应使用脱敏但真实存在的 JPG/PNG 文件走 File 上传、平台审核完整流程,使租户达到 `ACTIVE` 且资质达到 `APPROVED`。初始化账号、文件和审核结果应作为一套环境数据验收,不得仅靠 SQL 绕过文件保存。 + +用于联调的样本必须在名称或图片水印中明确标注“仅供测试”,并登记租户 ID、资质 ID、文件 SHA-256 和创建时间。验收结束后按生成的回滚 SQL恢复原元数据并删除本次文件;若样本通过正常上传审核流程产生,应由测试数据清理流程按同一组标识回收,禁止直接删除仍被资质元数据引用的文件。 diff --git a/docs/入库测量与国内货柜履约业务功能测试回归报告.md b/docs/入库测量与国内货柜履约业务功能测试回归报告.md new file mode 100644 index 000000000..4ccf209e3 --- /dev/null +++ b/docs/入库测量与国内货柜履约业务功能测试回归报告.md @@ -0,0 +1,71 @@ +# 入库测量与计费、国内货柜履约业务功能回归报告 + +测试时间:2026-08-07 + +## 测试范围 + +- 入库测量与计费:企业资质、平台审核、公共主数据、客户计费规则、头程下单、入库测量、称重、计费与入库。 +- 国内货柜履约:虚拟柜、智能装柜、预装、PDA 拣货和最终装柜清单。 +- 测试环境:本机 MySQL、Redis、Nacos;隔离服务端口 `19300` 到 `19306`。未停止、未占用用户的 `192xx` 服务端口。 +- 测试结束后已停止本次启动的 Gateway 和全部 `193xx` 隔离服务;未停止 MySQL、Redis、Nacos 或用户服务。 + +## 项目索引结论 + +- 入库测量与计费主状态和测量、计费、入库编排在 `firstleg`;订单货物在 `order`;仓库、托盘、库位、往来单位在 `warehouse`;文件在 `file`。 +- 国内货柜履约智能装柜、预装、PDA 拣货、装柜清单均在 `firstleg`。 +- 当前 `tenant_id` 在业务上代表客户归属;公共主数据应使用 `tenant_id=0`,订单服务仅可读取公共仓库和公共往来单位。 + +## 入库测量与计费结果 + +结果:未通过,主链路在创建头程订单草稿前被阻断。 + +- 已通过 15 项:平台/商户登录、Firstleg 健康检查、企业注册、营业执照及法人身份证上传、企业资质提交与平台审核、承运商与客户往来单位创建、收货仓创建、客户计费规则创建、费率版本创建。 +- 阻断接口:`POST /api/merchant/v1/orders/first-leg`。 +- 返回:业务码 `A0404`,提示往来单位不存在或不属于当前企业。 + +### 阻断原因 + +1. 订单服务 `OrderMasterDataGuard` 固定以公共归属 `tenantId=0` 查询往来单位和收货仓,并要求订单客户快照往来单位具有 `SHIPPER` 类型。 +2. 当前平台往来单位 API 对 `tenantId=0` 返回 `A0422`,校验信息为“最小不能小于 1”;公共主数据无法经 API 创建或查询。 +3. 旧订单请求仍强制 `carrierId`、`carrierName`。本次不传时返回 `A0422`。根据最新业务确认,头程业务不存在“承运商”概念,这一历史字段、校验、分配接口和费率依赖应移除或改为船司领域模型,不能继续作为下单前置条件。 + +结论:在修复公共主数据 `tenantId=0` API 契约,并移除订单承运商强制校验前,入库测量与计费无法从下单继续验证测量、称重、计费和入库。 + +## 国内货柜履约结果 + +结果:通过。 + +真实 API 已复核的完成装柜清单: + +- 装柜计划:`2085554481377796097` +- 查询接口:`GET /api/platform/v1/first-leg/loading-lists/2085554481377796097?warehouseId=2084548444218646530` +- 最终状态:`COMPLETED` +- 集装箱号:`PH3-20260807103117` +- 装柜箱数:2 +- 总件数:20 +- 总毛重:20kg +- 总体积:0.24m3 +- 历史事件存在:`PDA_COMPLETE_LOADING` +- 明细已有冻结时间,符合完成后不可修改的规则。 + +定向服务层测试:29 项通过、0 失败、0 错误。 + +- `SmartLoadingServiceTest`:15 项 +- `LoadingListServiceTest`:8 项 +- `LoadingPickServiceTest`:2 项 +- `PdaPickSettlementPlanTest`:3 项 +- `SmartLoadingStatusTest`:1 项 + +## 构建与脚本观察 + +- 离线全量模块测试执行到 `firstleg`:211 项中 1 项失败。失败项是 `InboundParentChildSqlContractTest`,原因是统一 SQL 已使用最终索引命名,但测试仍断言旧索引名 `uk_inbound_parent_sequence`;属于 SQL 契约断言滞后,不是 Java 业务断言失败。 +- 入库测量与计费验收脚本需要 PowerShell 7 的 `SkipHttpErrorCheck`,而本机只有 Windows PowerShell 5;测试时使用了临时 UTF-8 BOM 兼容副本。 +- 原脚本引用的 `c7.png`、`c8.png` 示例图片未随项目提供;测试目录使用最小 PNG 仅验证上传协议。 +- 初始 File 隔离 JAR 未包含当前资质包上传端点,使用当前编译类启动后接口通过。发布前应重新打包 File 服务,避免运行包与源码不一致。 + +## 建议处理顺序 + +1. 移除头程订单及入库测量与计费计费中的承运商强制字段、校验和分配逻辑;如需要船司,改为独立船司主数据,不复用往来单位承运商。 +2. 修复平台公共主数据 API:允许且仅允许平台身份使用 `tenantId=0` 创建、查询公共仓库和往来单位。 +3. 为订单下单提供可选的公共发货单位选择,或明确订单客户快照不再依赖 `SHIPPER` 往来单位。 +4. 完成以上两项后,重新执行入库测量与计费全链路:下单、审核、入仓单、测量、OCR 称重、费用试算、费用确认、入库完成。 diff --git a/docs/公共导出与下载中心接入说明.md b/docs/公共导出与下载中心接入说明.md new file mode 100644 index 000000000..27716967e --- /dev/null +++ b/docs/公共导出与下载中心接入说明.md @@ -0,0 +1,151 @@ +# 公共导出与下载中心接入说明 + +本文描述当前已经实现的 RocketMQ + 事务 Outbox 版本。完整状态机、故障模型和容量门槛见[公共导出中心 RocketMQ 架构设计](./公共导出中心RocketMQ架构设计.md)。 + +## 1. 边界 + +- `file-service` 统一承载任务、可靠调度、通用 XLSX、存储、过期清理、下载鉴权和指标。 +- File 不跨库读取 IAM、Order、Warehouse 等业务数据;业务服务只实现标准只读 Provider。 +- MQ 只传任务标识,不传筛选条件、业务行、Token 或文件。 +- 订单与客户存储是 `FILE_STORAGE_ROOT` 目录且按单节点部署;多实例前必须使用跨主机共享卷或对象存储。 +- 当前首个类型为 `CUSTOMER_LIST`,由 IAM 提供数据。 + +## 2. 执行链路 + +```text +Admin + -> POST /api/platform/v1/files/export-tasks + -> file_export_task_guard 行锁下复核重复提交及活动上限 + -> 同一事务写 file_export_task + file_export_outbox + -> Publisher 投递 RocketMQ(失败无限退避) + -> Consumer 按 dispatch_version + 数据库租约领取(并发 4) + -> POST /internal/exports/v1/{TYPE}/batches + -> 通用 SXSSFWorkbook 流式写盘 + -> 条件提交 SUCCEEDED + -> GET /api/platform/v1/files/export-tasks/{id}/download +``` + +```text +PENDING -> RUNNING -> SUCCEEDED -> EXPIRED + -> PENDING(自动重试,dispatch_version + 1) + -> FAILED -> PENDING(人工重试,dispatch_version + 1) +``` + +消息补投保持同一版本;只有执行失败、执行租约恢复和人工重试递增版本。独立 watchdog 覆盖 Provider 拉取和最终写盘,最终成功 SQL 同时校验任务状态、版本、租约所有者与数据库 deadline。 + +Reconciler 负责补写缺失 Outbox、同版本重发长期 `SENT` 命令、恢复超时执行租约、释放超时 Outbox 租约及过期文件清理。旧 `ExportTaskWorker` 已删除,避免绕过版本和退避窗口。 + +## 3. 浏览器 API + +- `POST /api/platform/v1/files/export-tasks` +- `GET /api/platform/v1/files/export-tasks` +- `GET /api/platform/v1/files/export-tasks/{id}` +- `GET /api/platform/v1/files/export-tasks/{id}/download` +- `POST /api/platform/v1/files/export-tasks/{id}/retry` + +创建示例: + +```json +{ + "exportType": "CUSTOMER_LIST", + "format": "XLSX", + "taskName": "客户列表", + "parameters": { "keyword": "柏悦", "status": "ACTIVE" } +} +``` + +创建按注册表校验访问域、启用状态和来源权限。客户导出要求 `platform:customer:export`;列表和下载要求 `platform:download:list`。默认只能访问本人任务,`platform:download:all` 可访问同一租户与应用域的全部任务。人工重试只允许 `FAILED`,并重新校验类型启用状态和来源权限。 + +限制:单用户最多 3 个活动任务、单类型最多 5 个活动任务;同一用户 60 秒内相同类型和规范化参数返回已有活动任务。 + +## 4. Provider 契约 + +```http +POST /internal/exports/v1/{exportType}/batches +X-Internal-Service-Token: ${INTERNAL_SERVICE_TOKEN} +Content-Type: application/json +``` + +请求使用 `common-core` 的 `ExportBatchRequest`,必须包含 `taskId`、`tenantId`、`appCode`、`requestedBy`、`dispatchVersion`、`parameters`、`cursor`、`snapshotToken` 和 `limit`。Provider 必须按身份字段约束查询,不得信任 parameters 内的租户字段。 + +响应使用 `ExportBatchResponse`:首批返回 `schemaVersion`、`schemaHash`、`snapshotToken`、`columns`、`rows`、游标和估算总行数;后续批次必须保持 `schemaHash`,可省略 `columns`。File 在 Feign 解码流读取阶段限制单批 4MB,再校验游标推进、列/行/文件上限,并支持 `STRING`、`INTEGER`、`DECIMAL`、`BOOLEAN`、`DATE`、`DATETIME` 六类 Excel 单元格。 + +IAM `CUSTOMER_LIST` Provider 强制 `tenantId=0`、`appCode=PLATFORM`,采用 HMAC 签名的最大商户 ID 快照。因此不会混入开始后新增客户,但不冻结期间的修改与删除。 + +新增类型只需:业务服务实现标准 Provider、在 `baiyue.file.export.definitions` 注册受控服务/路径/权限/访问域/容量参数、补权限和测试。不得修改公共 Controller、Consumer 或 Writer。 + +## 5. MQ 与 DLQ + +- Topic:`${MQ_TOPIC_PREFIX}baiyue-file-export-command-v1`,4 个队列。 +- Consumer:`${MQ_TOPIC_PREFIX}baiyue-file-export-worker-v1`,同步并发 4。 +- Producer:`${MQ_TOPIC_PREFIX}baiyue-file-export-publisher-v1`。 +- `eventId`、`taskId`、`dispatchVersion`、`schemaVersion` 同时写入 User Properties。 +- 业务失败从不进入 DLQ;只有无法反序列化或不支持版本的消息不确认消费。 +- DLQ Handler 不解析损坏 body,只从四个属性定位,并在同一事务内把 Outbox 置 `QUARANTINED`、当前 PENDING 任务置 `FAILED`。缺少定位属性只记录 CRITICAL 和 messageId,不误伤任务。 +- DLQ Handler 的数据库处置失败持续重试,不允许因有限次数耗尽进入无人消费的二级 DLQ。 + +## 6. 配置与部署 + +关键环境变量: + +```text +ROCKETMQ_NAME_SERVER +ROCKETMQ_BROKER_IP +MQ_TOPIC_PREFIX +FILE_EXPORT_CONSUMER_CONCURRENCY +FILE_EXPORT_RECONCILER_ENABLED +FILE_EXPORT_PUBLISHER_ENABLED +FILE_EXPORT_CONSUMER_ENABLED +FILE_EXPORT_RETENTION_DAYS +FILE_STORAGE_ROOT +``` + +`MQ_TOPIC_PREFIX` 是 File 部署必填项但允许生产空值:测试环境显式填 `test-`,生产环境显式填空字符串。Compose 单服务部署会插值整份文件,因此 File 服务块本身使用空默认值,避免 Auth/IAM 等无关服务被 MQ 配置阻断;`deploy-services.sh` 只在部署 File 时按环境强校验该变量与 `ROCKETMQ_NAME_SERVER`。 + +当前 RocketMQ Compose 不映射 Broker 宿主机端口,File 与 Broker 必须同在 `infra-network`,因此 `ROCKETMQ_BROKER_IP` 填 `rocketmq-broker`。Topic 初始化会等待 `BaiyueCluster` 真正注册,任一幂等初始化命令失败后自动重试。 + +File 容器初始 JVM 为 `-Xms1g -Xmx2g -XX:+ExitOnOutOfMemoryError`。Provider 单批连接/读取超时分别为 3 秒/15 秒;执行租约 300 秒、watchdog 60 秒、deadline 30 分钟、消费超时 90 分钟、单文件 50MB。 + +开发阶段确认无保留任务后,重建并执行 `file/src/main/resources/db/file.sql`。RocketMQ 使用 `deploy/docker-compose.rocketmq.yml`;脚本会先校验并展开 `ROCKETMQ_BROKER_IP`,再显式创建 4 队列命令 Topic、Worker 消费组、DLQ Handler 消费组以及可读写的 Worker DLQ。上线顺序:Broker -> SQL -> Nacos 注册表 -> IAM Provider -> File 三开关全关 -> Reconciler -> Publisher -> Consumer -> Admin。 + +生产启用并发 4 前必须完成 4 个 20 万行任务并发压测;若 CPU、堆、磁盘、Provider 或 IAM 普通接口延迟不达设计门槛,回退并发 2。 + +## 7. 观测与验收 + +Prometheus 指标包括任务创建/完成/耗时、队列年龄、Outbox 数量/年龄/最大尝试次数、Reconciler 动作与 `export_dlq_messages_total`;日志携带任务与版本标识但不打印完整数据或令牌。 + +最低验收:任务与 Outbox 同事务;Broker 停机恢复后追平;重复消息只生成一个有效文件;watchdog 覆盖最终写盘;deadline 竞态不能提交成功;同版本补投不递增版本;毒消息只隔离一次;IAM 快照不混入新增客户;生成的 XLSX 可真实打开且日期、金额为可计算类型;普通用户不能访问他人任务。 + +### 7.1 测试环境自动验收入口 + +`scripts/export-center-test-env-acceptance.ps1` 通过 Gateway 创建真实 `CUSTOMER_LIST` 任务,轮询终态,下载文件并校验 XLSX 的 ZIP 签名。Token 只通过参数或环境变量注入,脚本不会登录、部署、停止 Broker 或改数据库。 + +单任务冒烟: + +```powershell +$env:ADMIN_ACCESS_TOKEN = '<测试环境平台用户Token>' +.\scripts\export-center-test-env-acceptance.ps1 ` + -GatewayBaseUrl 'http://8.163.30.128:9000' +``` + +并发 4 容量验收必须使用 4 个不同申请用户,避免 60 秒重复提交合并,并要求每个账号都具备 `platform:customer:export` 与 `platform:download:list`: + +```powershell +$tokens = @($env:EXPORT_TOKEN_1, $env:EXPORT_TOKEN_2, $env:EXPORT_TOKEN_3, $env:EXPORT_TOKEN_4) +.\scripts\export-center-test-env-acceptance.ps1 ` + -GatewayBaseUrl 'http://8.163.30.128:9000' ` + -AccessTokens $tokens -TaskCount 4 -MinimumRows 200000 -TimeoutSeconds 3600 +``` + +故障注入分两段执行:故障期间使用 `-CreateOnly` 创建并保存任务 ID;恢复 Broker、File 或 IAM 后,用相同申请人的 Token 和 `-TaskIds` 继续轮询与下载。这样停止/恢复基础设施仍由有权限的运维操作,脚本只验证任务是否最终追平: + +```powershell +.\scripts\export-center-test-env-acceptance.ps1 ` + -GatewayBaseUrl 'http://8.163.30.128:9000' -AccessTokens $tokens -TaskCount 4 -CreateOnly + +.\scripts\export-center-test-env-acceptance.ps1 ` + -GatewayBaseUrl 'http://8.163.30.128:9000' -AccessTokens $tokens ` + -TaskIds @('', '', '', '') -MinimumRows 200000 +``` + +脚本通过只证明 API 创建、数据库状态机、MQ 触发、Provider、XLSX 写入和下载主链路。CPU/堆/磁盘、Provider P95/P99、IAM 普通接口退化比例仍须同时从 Prometheus/Grafana 取证;DLQ 毒消息必须由 `mqadmin` 按 §19.2 投递,不能由普通验收账号伪造。 diff --git a/docs/公共导出中心RocketMQ架构设计.md b/docs/公共导出中心RocketMQ架构设计.md new file mode 100644 index 000000000..65c4b8aaf --- /dev/null +++ b/docs/公共导出中心RocketMQ架构设计.md @@ -0,0 +1,1394 @@ +# 公共导出中心 RocketMQ 架构设计(第四轮快速评审定稿) + +> 状态:设计已实施并通过本地自动化回归,待完成 §19.2/§19.3 测试环境真实验收 +> 日期:2026-08-03 +> 修订说明:第一版曾标记“定稿”,经评审发现 3 个阻断级可靠性问题(Reconciler 导致排队任务饥饿、DEAD 策略自相矛盾且可无限循环、`brokerIP1` 环境变量不会被替换)及 7 项边界缺陷,均已在本稿修正。核心修正是确立 §3 原则 5:**消息补投不产生新版本**。 +> 第四轮快速评审:确认 8C32G 环境以并发 4 作为压测起始值;补齐并发环境变量透传,并将三个调度开关改为默认关闭,避免首次部署绕过分阶段启用顺序。 +> 适用范围:`baiyue-firstleg` 内需要异步导出和下载中心能力的业务服务 +> 容量基准:平台总用户约 1000,导出为低频后台操作。本文所有定量参数按该基准取值,并在 §22 说明放大路径。 + +## 1. 结论摘要 + +将现有 File Service 的“数据库任务表 + 定时扫描并直接执行”升级为: + +```text +任务表 + 事务 Outbox + RocketMQ 触发 + 幂等 Consumer + Reconciler 兜底 + 统一数据 Provider 契约 +``` + +采用至少一次投递,不追求跨数据库与 MQ 的伪“恰好一次”。**数据库是唯一事实来源与唯一可靠性保证;RocketMQ 是加速通道,不是可靠性来源。** 这一条是本文全部设计取舍的根据: + +- 任何“此刻不该执行”的消息一律确认消费,**绝不使用 `RECONSUME_LATER` 做业务重排**。 +- 所有重试、延迟、恢复由数据库的 `next_retry_at`、`lease_until` 与 Reconciler 承担。 +- 因此 DLQ 只可能收到无法反序列化的毒消息,“DLQ 出现任意新消息即告警”这条规则才成立。 + +目标是让 IAM、Order、Warehouse、Finance 等业务服务只提供导出数据,不再分别维护任务表、MQ Consumer、Excel 生成、文件存储、重试、过期清理或下载中心。 + +### 1.1 实施前代码基线(评审时已核实) + +以下表格记录设计启动时用于制定迁移方案的历史基线,不代表当前代码状态;当前已实现能力以 [公共导出与下载中心接入说明](./公共导出与下载中心接入说明.md) 和实际源码为准。 + +| 事项 | 实际状态 | +|---|---| +| 任务执行 | `file/.../service/ExportTaskWorker.java` 定时 2 秒轮询,`processNext()` 每轮只执行一个任务 | +| 原子领取 | `ExportTaskRepository.claim()` 按 `status = PENDING` 条件更新,多实例安全 | +| 失联恢复 | `requeueStale()` 按 `updated_at` 早于阈值粗粒度回队,无租约、无心跳 | +| 自动重试 | 不存在。失败即 `FAILED`,只能人工重试 | +| 导出类型注册 | Java Bean 注册(`ExportTaskHandler` 实现类),列定义硬编码在 `CustomerExportTaskHandler.HEADERS` | +| Provider 调用 | `IamExportClient` 为 IAM 专用 Feign 接口,每增一个类型需复制一个 Client | +| RocketMQ | **仓库无任何可用基础**:`spring-cloud-starter-stream-rocketmq` 只出现在 `firstleg/pom.xml` 且 firstleg 无一行 Stream 代码(死依赖);`file/pom.xml` 无 MQ 依赖;全仓 `*.yml` 无 rocketmq 配置;`docker-compose-infra.yml` 只有 nacos/mysql/redis,无 NameServer 与 Broker | +| 既有 Outbox | 已有两套且**均无发布器**:`ord_outbox_event`(`order/src/main/resources/db/order.sql:153`,`OrderApplicationService.writeOutbox()` 只写不发)与 `bridge_outbox`(`event-bridge-service`)。全仓 `@Scheduled` 仅 `ExportTaskWorker` 一处 | +| 指标 | 只有 `micrometer-core`(`common-web/pom.xml`),**无 `micrometer-registry-prometheus`**,actuator 的 `prometheus` 端点当前不可用;无 Prometheus 服务端 | +| 测试基建 | 全仓无 Testcontainers 依赖,无容器化测试先例 | +| 存储 | `LocalExportStorage` 为具体类直接注入,对象键 `exports/{taskId}/{uuid}.xlsx`;部署已挂载 `FILE_UPLOADS_HOST_DIR:/data/files` | + +本文的实施清单以上述历史基线为迁移起点,不假设当时已有任何基础设施就绪。 + +## 2. 目标与非目标 + +### 2.1 目标 + +- 导出请求快速返回任务编号,后台异步执行。 +- 数据库提交成功的任务最终一定被调度,不因 MQ 短暂不可用而丢失。 +- MQ 重复投递、Consumer 重启、网络超时不会产生重复有效文件。 +- 失败自动重试、人工重试、租约超时恢复全部可追踪,且不产生 DLQ 噪声。 +- 下载中心继续使用统一任务表,不依赖 MQ 查询状态。 +- 新业务服务只实现公共 Provider 契约并注册导出类型,不写任务、Excel、存储、下载代码。 +- File Service 统一负责 XLSX、存储、下载鉴权、过期清理、可观测性。 +- 代码层面支持 File Service 多实例(租约、条件抢占、`lease_owner` 齐备);**部署层面订单与客户按单节点运行**,因为存储是宿主机目录(§11.1)。滚动发布期间的短暂双实例由租约保证安全。 + +### 2.2 非目标 + +- 不支持 CSV、PDF 或同步大文件下载。 +- 不允许业务服务提交任意 URL 让 File Service 回调。 +- 不允许 File Service 跨库读取业务数据。 +- 不把完整导出数据写入 MQ。 +- 不以 Redis 作为任务、重试或幂等的事实来源。 +- 不实现用户自定义列编排器。 +- 不实现导出断点续传:每次执行从零重写整个文件(见 §9.5)。 +- 订单与客户不引入 MinIO/OSS(见 §21 决策 6),但存储访问必须走接口抽象。 +- 订单与客户不引入 Testcontainers(见 §21 决策 8)。 + +## 3. 核心原则 + +1. **数据库是事实来源**:任务状态、申请人、租户、筛选条件、文件信息、尝试次数、租约保存在 MySQL。(**注意**:任务表**不**保存权限快照——权限只在创建与人工重试时校验,执行阶段不再校验,理由见 §10.4。) +2. **MQ 只传命令**:消息只含 `eventId`、`taskId`、`dispatchVersion` 等标识,不携带业务数据。 +3. **事务 Outbox 保证不丢任务**:任务与待发送消息在 File 数据库同一本地事务提交。 +4. **至少一次 + 幂等**:允许重复消息,不允许重复有效执行。 +5. **版本只代表执行尝试,不代表投递尝试**:`dispatch_version` 递增**仅**发生在三种情形——执行失败后的自动重试(§9.2)、租约超时恢复(§8.5-2)、人工重试(§9.3)。**消息补投、重发、重复投递一律不递增版本**,只产生同一版本的重复消息,由 Consumer 幂等吸收。 +6. **MQ 不承担重排与延迟**:不使用延迟消息、不使用 `RECONSUME_LATER` 做业务重排;Reconciler 是唯一重派通道。 +7. **业务服务只提供数据**:不生成文件,不维护下载状态,不消费公共导出 Topic。 +8. **注册信息必须受控**:服务名、Provider 路径、权限编码来自受控配置且经代码白名单二次校验,不接受浏览器传入。 +9. **租户维度贯穿全链路**:任务、Provider 请求、Provider 查询、下载鉴权都必须带租户与访问域(AGENTS.md 强制要求)。 +10. **时间以数据库为准**:所有租约领取、续租、过期判断使用数据库 `CURRENT_TIMESTAMP(3)`,不使用各实例的 `LocalDateTime.now()`(见 §8.6)。 + +### 3.1 原则 5 为何是本文的核心 + +第一版设计在“Reconciler 发现命令可能丢失”时递增 `dispatch_version` 并重发。这看似安全,实则制造了一个**活性错误**: + +并发为 4、单任务最长 30 分钟,则第五个任务在 Broker 队列中正常等待 30 分钟期间,任务状态一直是 `PENDING`。若 Reconciler 在第 10 分钟判定“发出太久未执行 = 消息丢失”并递增版本,原消息到达时会因版本过期被丢弃,新消息又排到队尾——**持续负载下这个任务可能永远无法开始执行**。这不是“产生一些垃圾 Outbox 行”,而是任务饥饿。 + +同一个错误还有第二个后果:Broker 长期不可用时,Outbox 发送次数耗尽 → 换新版本重建 → 新行继续耗尽 → 无限循环。因为任务的 `attempt_count` 在这个过程中从不增长,`max_attempts` 这道闸门根本拦不住。 + +根因是把**投递失败**(消息没送到)和**执行失败**(送到了但跑挂了)当成了同一件事。两者的正确处理完全相反: + +| | 语义 | 正确处理 | 是否递增版本 | +|---|---|---|---| +| 投递失败 | 命令没送到 Consumer | 重发**同一条**命令 | **否** | +| 执行失败 | 命令送到了,执行出错 | 作废旧尝试,开始新尝试 | **是** | + +确立原则 5 后,三个问题一次性消解:排队任务不再被作废(补投不动版本,原消息仍有效)、Broker 丢消息可安全补投(重发同版本,Consumer 幂等)、DEAD 无限循环消失(不再有“换版本重建”这条路径)。 + +## 4. 总体架构 + +```mermaid +flowchart LR + UI["Admin / Client"] -->|"创建任务"| API["File Export API"] + API -->|"同一事务"| TASK["file_export_task"] + API -->|"同一事务"| OUTBOX["file_export_outbox"] + PUB["Outbox Publisher"] -->|"到点领取"| OUTBOX + PUB -->|"发送 taskId"| MQ["RocketMQ export command topic"] + MQ --> CONSUMER["File Export Consumer"] + CONSUMER -->|"原子抢占 + 租约"| TASK + CONSUMER -->|"内部令牌 + 游标 + 快照"| PROVIDER["业务服务 Export Provider"] + PROVIDER --> CONSUMER + CONSUMER --> XLSX["通用 XLSX Writer"] + XLSX --> STORAGE["ExportStorage 共享目录"] + CONSUMER --> TASK + REC["Reconciler"] -->|"补发 / 租约恢复 / 过期清理"| TASK + REC --> OUTBOX + REC --> STORAGE + UI -->|"任务列表 / 下载"| API + API --> STORAGE +``` + +### 4.1 服务职责 + +| 组件 | 职责 | +|---|---| +| File Export API | 创建、查询、下载、人工重试;校验登录身份、租户、访问域与业务权限 | +| `file_export_task` | 任务状态与下载中心事实来源,承载调度字段与租约 | +| `file_export_outbox` | 可靠记录需要发送的 MQ 命令,承载发送重试与租约 | +| Outbox Publisher | 到点原子领取 Outbox、发送 MQ、记录发送结果与退避 | +| RocketMQ | 触发 Consumer、吸收突发、按 Consumer 并发天然排队 | +| File Export Consumer | 幂等抢占、watchdog 续租(§8.3)、调用 Provider、生成文件、提交结果 | +| Business Export Provider | 校验租户与筛选条件,按稳定快照游标返回列与行数据 | +| Reconciler | **唯一重派通道**:补发漏发命令、恢复超时租约、释放僵死 Outbox、清理过期文件与孤立文件 | + +### 4.2 为什么 MQ 存在(诚实说明) + +Outbox Publisher 与 Reconciler 仍然轮询数据库,MQ **不消除轮询**。MQ 在本架构中的实际价值是: + +1. 把“任务执行触发”从秒级轮询变为毫秒级推送,Reconciler 因此可以降到 30 秒一轮,数据库压力低于现状。 +2. Consumer 并发即天然的执行队列与背压,突发提交不会压垮 Provider。 +3. 与 `order` / `event-bridge-service` 已规划的事件化方向统一,`file_export_outbox` 的表结构与 Publisher 组件将成为全仓 Outbox 的公共基线(§21 决策 3)。 + +不宣称 MQ 提升了可靠性——可靠性来自数据库与 Reconciler。 + +## 5. 公共接口保持兼容 + +现有浏览器接口不变: + +- `POST /api/platform/v1/files/export-tasks` +- `GET /api/platform/v1/files/export-tasks` +- `GET /api/platform/v1/files/export-tasks/{id}` +- `GET /api/platform/v1/files/export-tasks/{id}/download` +- `POST /api/platform/v1/files/export-tasks/{id}/retry` + +创建任务请求仍为: + +```json +{ + "exportType": "CUSTOMER_LIST", + "format": "XLSX", + "taskName": "客户列表", + "parameters": { + "keyword": "柏悦", + "status": "ACTIVE" + } +} +``` + +浏览器不得提交 Provider 服务名、URL、Topic、消费组或所需权限。File Service 依据 `exportType` 查询受控注册表。响应中所有 `Long` 仍按仓库约定输出为字符串,业务成功与失败统一 HTTP 200 并以响应体 `code == "200"` 判断。 + +任务状态机(新增自动重试路径): + +```text +PENDING -> RUNNING -> SUCCEEDED -> EXPIRED + -> PENDING(自动重试,dispatch_version + 1) + -> FAILED -> PENDING(人工重试,dispatch_version + 1) +``` + +## 6. RocketMQ 设计 + +### 6.1 客户端选型 + +使用 `org.apache.rocketmq:rocketmq-spring-boot-starter:2.3.3`,**不使用** `spring-cloud-starter-stream-rocketmq`。理由: + +- 本场景需要显式控制“抢不到就确认消费”的返回语义(`ConsumeConcurrentlyStatus.CONSUME_SUCCESS`),Stream 的函数式模型表达这一点绕。 +- 仓库中那个 Stream 依赖是无人使用的死依赖,实施时**一并从 `firstleg/pom.xml` 删除**,避免两套客户端并存。 + +**版本选 2.3.3 而非 2.3.1,且 Spring Boot 3.2.9 兼容性已实证核验**(该 starter 的 parent pom 声明 `spring.boot.version` 为 2.7.18,容易误判为不兼容,故记录证据): + +| 核验项 | 结果 | +|---|---| +| 自动配置注册方式 | jar 内**同时**包含 `META-INF/spring.factories`(Boot 2)与 `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`(Boot 3),Boot 3 下 `RocketMQAutoConfiguration` 能正常加载 | +| `javax.*` 残留 | 全 jar 扫描**无任何** `javax/annotation`、`javax/validation`、`javax/servlet` 引用,不受 javax→jakarta 断裂影响 | +| 字节码版本 | major 52(Java 8),JDK 17 可运行 | +| 传递 `rocketmq-client` | 2.3.3 → **5.3.1**(2.3.1 → 5.3.0)。5.3.1 与 §16.1 的 Broker 镜像 `apache/rocketmq:5.3.1` 版本一致,也与仓库中 `spring-cloud-alibaba 2023.0.3.2` 已解析出的 `rocketmq-client 5.3.1` 相同 | + +starter 的 `spring-boot-starter` / `spring-boot-starter-validation` 依赖未锁版本,在本仓由 Spring Boot 3.2.9 的 `dependencyManagement` 接管,解析为 3.2.9。 + +协议使用 Remoting(4.x 兼容模式)直连 NameServer + Broker,不引入 RocketMQ 5.x 的 Proxy/gRPC 组件——当前量级不需要,且少一个可失效组件。 + +实施第一步仍须编译期验证:加入依赖后跑 `mvn -pl file -am test`,并在本地启动 File Service 确认 `RocketMQAutoConfiguration` 已生效(日志出现 Producer 启动记录)。 + +### 6.2 Topic 与消费组 + +| 项目 | 取值 | +|---|---| +| Topic | `${MQ_TOPIC_PREFIX}baiyue-file-export-command-v1` | +| Tag | `EXPORT_TASK` | +| Consumer Group | `${MQ_TOPIC_PREFIX}baiyue-file-export-worker-v1` | +| Producer Group | `${MQ_TOPIC_PREFIX}baiyue-file-export-publisher-v1` | +| Message Key | `taskId`(便于按任务在控制台检索) | +| 队列数 | 4 | +| 顺序要求 | 无。同一任务的串行由数据库状态与 `dispatch_version` 保证 | +| 投递语义 | At-least-once | +| 延迟消息 | **禁用**。延迟一律由 `file_export_outbox.next_retry_at` 承担(见 §6.3) | + +环境隔离用 `MQ_TOPIC_PREFIX` 前缀(测试 `test-`,生产空)而非 RocketMQ namespace,前缀在 4.x/5.x Broker 上行为一致。部署 Compose 必须显式传入该变量,并使用 `${MQ_TOPIC_PREFIX?...}` 校验“是否设置”而不是 `${MQ_TOPIC_PREFIX:-test-}`;后者会把生产显式空值错误替换为 `test-`。**禁止测试与生产共用 Topic 或消费组。** + +### 6.3 为什么禁用 MQ 延迟消息 + +RocketMQ 4.x 只支持 18 个固定延迟级别(1s/5s/10s/30s/1m/2m/3m/4m/5m/6m/7m/8m/9m/10m/20m/30m/1h/2h)。本文的退避序列(§9.2)含 15 分钟,不在级别表内;未来调整退避也会再次受限。 + +正确做法:Outbox 行自带 `next_retry_at`,Publisher 只领取 `next_retry_at <= now` 的行。延迟精度等于 Publisher 轮询周期(5 秒),任意间隔都可表达,且延迟状态在数据库里可查、可人工干预。 + +### 6.4 消息契约 + +契约类放入 `common-core` 的新包 `com.baiyue.common.core.export`(见 §21 决策 4,**不新建模块**): + +```json +{ + "schemaVersion": 1, + "eventId": "3f2a7c18-5b41-4d0e-9a6b-2c8d5e1f7a90", + "taskId": "190000000000000001", + "exportType": "CUSTOMER_LIST", + "dispatchVersion": 1, + "occurredAt": "2026-08-03T18:00:00+08:00", + "traceId": "..." +} +``` + +约束: + +- `eventId` 由导出侧直接使用 `UUID.randomUUID().toString()` 生成;不复用 `common-event-contract` 中的 `EventIdentifiers`,避免 `common-core` 反向依赖跨区域事件契约模块。 +- `dispatchVersion` 仅在自动重试、人工重试或 Reconciler 恢复超时执行租约时递增;消息补投不递增。 +- Consumer 只处理与任务当前 `dispatch_version` 相同的消息;不一致即确认消费。 +- 消息不含筛选明细、客户数据、Token、文件内容、租户敏感信息。 +- 反序列化失败或 `schemaVersion` 不支持的消息**不确认**,令其走 MQ 原生重投直至 DLQ——这是 DLQ 的唯一合法来源。落 DLQ 后必须走 §9.4.2 的隔离流程,否则 Reconciler 会重发同一条坏 payload 形成无限循环。 +- Producer 必须同时把 `eventId`、`taskId`、`dispatchVersion`、`schemaVersion` 写入 RocketMQ User Properties。DLQ Handler 从属性定位任务与 Outbox,**不得依赖已经损坏的 JSON body**;属性值必须与 Outbox 列及 payload 中的同名字段一致。 + +## 7. 事务 Outbox + +### 7.1 为什么不能直接发送 MQ + +以下写法不可接受: + +```text +INSERT task -> COMMIT -> SEND MQ +``` + +数据库提交后进程退出,消息不会发送,任务永久停留。反过来先发 MQ 再提交数据库,Consumer 可能收到不存在的任务。 + +### 7.2 表结构 + +```sql +CREATE TABLE IF NOT EXISTS file_export_outbox ( + id BIGINT UNSIGNED NOT NULL COMMENT 'Outbox 记录ID(雪花ID)', + event_id VARCHAR(64) NOT NULL COMMENT '投递意图唯一标识(UUID)', + task_id BIGINT UNSIGNED NOT NULL COMMENT '关联导出任务ID', + dispatch_version INT NOT NULL COMMENT '任务派发版本,与任务表同名字段对应', + topic VARCHAR(128) NOT NULL COMMENT '目标Topic(含环境前缀)', + tag VARCHAR(64) NOT NULL COMMENT '消息Tag', + message_key VARCHAR(64) NOT NULL COMMENT '消息Key,取taskId', + payload_json JSON NOT NULL COMMENT '待发送命令消息体', + status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/CLAIMED/SENT/QUARANTINED;无发送失败终态,QUARANTINED仅用于毒消息隔离,见7.4与9.4', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '累计发送尝试次数', + next_retry_at DATETIME(3) NOT NULL COMMENT '允许发送的最早时间,承担全部延迟语义', + lease_owner VARCHAR(128) NULL COMMENT '领取实例标识', + lease_until DATETIME(3) NULL COMMENT '领取租约截止时间,超时可被其他实例重新领取', + sent_at DATETIME(3) NULL COMMENT '发送成功时间', + last_error VARCHAR(1000) 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_file_export_outbox_event (event_id), + UNIQUE KEY uk_file_export_outbox_task_version (task_id, dispatch_version), + KEY idx_file_export_outbox_publish (status, next_retry_at, id), + KEY idx_file_export_outbox_task (task_id, dispatch_version, status, sent_at) +) ENGINE=InnoDB COMMENT='导出命令事务Outbox表:与任务变更同一本地事务写入,由Publisher可靠投递RocketMQ;不是操作日志'; +``` + +设计说明: + +- **`lease_owner` / `lease_until` 是必需的**,与既有 `bridge_outbox` 对齐。只有 `claimed_at` 时,Publisher 领取后崩溃会让该行永久停留在 `CLAIMED`,没有任何条件能重新领取它。 +- **本表不使用逻辑删除,不声明 `deleted` / `version` 字段。** 仓库 MyBatis-Plus 全局配置了 `logic-delete-field: deleted`,该配置只对声明了同名字段的实体生效,本表实体不声明即不受影响;清理走物理删除(§15)。 +- `uk_file_export_outbox_task_version` 是关键约束:它使“同一任务同一版本只会存在一条待发命令”成为数据库级事实,Reconciler 的补发因此天然幂等(重复插入报唯一键冲突并忽略)。 +- `idx_file_export_outbox_task` 专供 §8.5(1) 的 `NOT EXISTS` 子查询:该子查询按 `(task_id, dispatch_version)` 定位并读取 `status` 与 `sent_at`,走这个覆盖索引可避免回表。虽然唯一键已能定位,但补上 `status`/`sent_at` 让子查询完全走索引。 + +任务创建还需增加只有一行 `GLOBAL_CREATE` 的 `file_export_task_guard` 表,并由初始化 SQL 预置该行;File Service 启动时必须验证它存在,缺失则启动失败。创建事务只对该行执行一次 `SELECT ... FOR UPDATE`(禁止在请求内先 `INSERT IGNORE` 再升级锁,避免并发锁升级死锁),随后在同一事务内完成 60 秒重复提交检查、单用户活动任务上限、单类型活动任务上限以及 Task + Outbox 写入。当前创建量级极低(日任务 < 50),全局单行锁的等待成本可以忽略,却能避免多个实例并发请求同时通过“先 COUNT、后 INSERT”而突破 3/5 上限;该锁只串行化任务创建,不影响 Publisher、Consumer 或下载。 + +### 7.3 任务表字段调整(直接改初始化 SQL) + +**当前处于开发阶段,不产出增量迁移脚本。** 做法:直接在 `file/src/main/resources/db/file.sql` 的 `file_export_task` 建表语句中补齐新字段与索引,测试环境先执行新增 SQL 使其生效,本模块的初始化 SQL 即为唯一事实来源。 + +这样选择的前提与代价: + +- 前提:导出功能尚未有生产数据,测试环境的任务表可以直接重建,不存在必须保留的存量任务。 +- 代价:已经跑过旧版 `file.sql` 的环境不会自动升级。因此测试环境在只读确认任务数为 0 后,按依赖顺序执行 **`DROP TABLE file_export_outbox; DROP TABLE file_export_task_guard; DROP TABLE file_export_task;`,再完整执行 `file.sql`**,而不是 `ALTER TABLE`。具体核验 SQL 见 `deploy/sql/README.md`。 +- 一旦进入生产、表内有真实数据,后续任何字段变更就必须回到 AGENTS.md 要求的可重复执行增量脚本模式(`information_schema` 判断 + `ALTER TABLE`),本节的简化做法**仅适用于当前开发阶段**。 + +`file_export_task` 在现有字段基础上补入以下 7 个字段(位置放在 `failure_message` 之后、`requested_by` 之前,保持读表时调度字段聚集): + +```sql + dispatch_version INT NOT NULL DEFAULT 1 COMMENT '派发版本,仅自动重试、人工重试或超时执行租约恢复时递增;Consumer只处理版本一致的消息', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已执行尝试次数', + max_attempts INT NOT NULL DEFAULT 3 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 '执行租约截止时间,超时由Reconciler回收', + last_heartbeat_at DATETIME(3) NULL COMMENT '最后心跳时间', +``` + +并在该表的索引区补入两个索引: + +```sql + KEY idx_file_export_dispatch (status, next_retry_at, id), + KEY idx_file_export_lease (status, lease_until) +``` + +同时把 §7.2 的 `file_export_outbox` 建表语句一并加入同一个 `file.sql`,与任务表相邻放置,便于阅读时看到两者的对应关系。 + +**存量 PENDING 任务不做数据迁移**:表是重建的,没有存量。若测试环境选择保留数据,则这些任务的 `next_retry_at` 会取默认当前时间、`dispatch_version` 取 1,Reconciler 会在 30 秒内为它们补发 Outbox 命令并正常执行——无需任何一次性 SQL。 + +### 7.4 Publisher 可靠性 + +轮询周期 5 秒,每轮最多领取 50 条。领取分两步,避免 `UPDATE ... ORDER BY ... LIMIT` 的锁范围问题: + +```text +1) SELECT id FROM file_export_outbox + WHERE status = 'PENDING' AND next_retry_at <= CURRENT_TIMESTAMP(3) + ORDER BY next_retry_at, id LIMIT 50 + +2) 逐条条件更新: + UPDATE file_export_outbox + SET status = 'CLAIMED', lease_owner = :instance, + lease_until = CURRENT_TIMESTAMP(3) + INTERVAL :leaseSeconds SECOND, + attempt_count = attempt_count + 1 + WHERE id = :id AND status = 'PENDING' + -- 影响行数 0 表示已被其他实例领取,跳过 +``` + +发送与结果处理: + +- 发送成功:`status = SENT`,`sent_at = CURRENT_TIMESTAMP(3)`,清空 `lease_owner` / `lease_until`。 +- 发送失败:`status = PENDING`,`next_retry_at = CURRENT_TIMESTAMP(3) + backoff`,记录 `last_error`。退避 5s / 15s / 1m / 5m / 15m,之后**固定 15m 无限重试**。**不得无间隔死循环,也不得放弃。** +- 发送成功但更新数据库前进程退出:租约到期后另一实例重新领取并重发,产生重复消息,由 Consumer 幂等吸收。 + +**不设发送失败终态,Outbox 行永不因发送失败而放弃。** 第一版设计曾规定“`attempt_count > 20` 置 `DEAD`,仅人工恢复”,这与 §8.5 的自动补投逻辑直接冲突,且会导致:Broker 长期不可用 → 所有 Outbox 行耗尽次数变 `DEAD` → 若允许自动换版本重建则无限循环(任务 `attempt_count` 从不增长,`max_attempts` 拦不住);若不允许则**所有任务静默停滞**,违背“数据库提交成功的任务最终一定被调度”这一核心目标(§2.1)。 + +取而代之:发送失败无限退避重试,靠**告警**而非状态机来驱动人工介入。`export_outbox_oldest_age_seconds > 120` 即告警(§13),Broker 故障期间告警持续存在,运维必然介入;Broker 恢复后所有积压命令自动发出,无需任何人工操作。 + +`status` 共四个值,其中只有 `QUARANTINED` 是终态,且**只用于毒消息隔离,绝不用于发送失败**(§9.4): + +| 值 | 含义 | 谁写入 | Reconciler 是否补投 | +|---|---|---|---| +| `PENDING` | 待发送 | 创建 / 发送失败退避 / Reconciler 恢复 | — | +| `CLAIMED` | 已被某实例领取,发送中 | Publisher | 租约超时后释放回 `PENDING` | +| `SENT` | 已成功投递到 Broker | Publisher | 超时未执行则改回 `PENDING` 重发 | +| `QUARANTINED` | **消息内容损坏,永久隔离** | DLQ Handler 自动处置 | **否,永久排除** | + +`attempt_count` 仅用于计算退避档位与观测,不再作为放弃条件。 + +**`attempt_count` 的语义是“本 Outbox 行的发送尝试次数”,与任务的 `attempt_count`(执行尝试次数)是两个独立计数器,不可混用。** 发送重试不消耗任务的执行次数(§3 原则 5)。任务重试会写入**新的** Outbox 行,其 `attempt_count` 从 0 开始。 + +退避时长由 `attempt_count` 查表得出,超出表长即取末位(15m)。退避计算基于领取时已递增的值,避免首次失败就跳过 5s 档。 + +Publisher 只负责投递几十字节的小消息,不执行导出,不受导出耗时影响。 + +**Publisher 与 Consumer 必须使用不同的 Spring Bean,且 Producer 的 `sendMessageTimeout`(3 秒)远小于 Outbox 租约(60 秒)**,保证发送调用不会跨越租约边界造成重复领取。 + +## 8. Consumer 幂等、租约与背压 + +### 8.1 执行模型:消费线程同步执行 + +Consumer 采用 `MessageModel.CLUSTERING`、`consumeMessageBatchMaxSize = 1`、`consumeThreadMin = consumeThreadMax = 4`,**消费线程直接同步执行导出**,不再投递到额外线程池。 + +这样选择的原因与代价: + +- 消费线程数即单个 File Service 实例的导出并发上限(4);超出的任务在 Broker 队列中等待——这正是 MQ 的正当用途,不需要自己实现背压和“线程池满了怎么办”。 +- 并发消费模式下,一个队列可以向同一实例的多个消费线程提交不同批次,**Topic 的 4 个队列不是“最多 4 个线程”的硬上限**。队列数主要决定消费组内实例间的分配粒度;当前单实例部署使用 4 个队列足够,未来扩 File 实例时应保证队列数不少于需要获得分配的实例数,并通过压测决定是否继续扩队列。 +- 长任务会占住消费线程。因此 `consumeTimeout` 必须大于最大任务时长:**最大任务时长 30 分钟,`consumeTimeout` 设 90 分钟**。否则 Broker 判定消费超时并重投,虽有幂等兜底但会产生无意义的抢占尝试。 +- `maxReconsumeTimes = 2`。**仅对毒消息生效**:§8.2 的全部抢占失败分支都返回 `CONSUME_SUCCESS`,业务上永不触发重投;唯一不确认消费的路径是消息反序列化失败(§6.4),该路径重投 2 次后落 DLQ。此参数与抢占竞争无关——第一版文案称其"吸收抢占竞争"是错的,抢占竞争根本不会走到重投。 +- 不使用独立线程池,因此不存在“已抢占但无线程执行、任务卡到租约过期”这个失败模式。 + +导出执行不占用 Servlet 请求线程(MQ 消费线程独立),满足资源隔离要求。 + +### 8.2 原子抢占 + +Consumer 收到命令后执行条件更新(时间一律取自数据库,见 §8.6): + +```sql +UPDATE file_export_task + SET status = 'RUNNING', + attempt_count = attempt_count + 1, + started_at = CURRENT_TIMESTAMP(3), + lease_owner = :instance, + lease_until = CURRENT_TIMESTAMP(3) + INTERVAL :leaseSeconds SECOND, + last_heartbeat_at = CURRENT_TIMESTAMP(3), + processed_rows = 0, + total_rows = NULL, + failure_message = NULL + WHERE id = :taskId + AND dispatch_version = :dispatchVersion + AND status = 'PENDING' + AND next_retry_at <= CURRENT_TIMESTAMP(3) +``` + +`started_at` 每次抢占都覆盖为当前时间(不用 `COALESCE` 保留首次值),因为 §9.5 规定文件内容对应"最后一次执行开始时刻"的快照,`started_at` 必须与之一致。任务创建时间由 `requested_at` 保留。 + +`processed_rows` / `total_rows` / `failure_message` 在抢占时一并重置,保证下载中心不会显示上一轮的残留进度。 + +**影响行数为 0 时,必须按下表穷尽处理。所有分支一律 `CONSUME_SUCCESS`,无一例外:** + +| 重新读取任务后的状态 | 判定 | 处理 | +|---|---|---| +| 任务不存在 | 极旧消息或数据已清理 | 确认消费,记 WARN | +| `dispatch_version` 大于消息中的值 | 旧版本消息 | 确认消费 | +| `SUCCEEDED` / `FAILED` / `EXPIRED` | 已终结 | 确认消费 | +| `RUNNING` 且 `lease_until > now` | 另一实例正在处理 | 确认消费。**不重投**,由该实例继续;若其崩溃则 Reconciler 恢复 | +| `RUNNING` 且 `lease_until <= now` | 租约已过期 | 确认消费。**不在此处抢占**,交由 Reconciler 统一恢复(见 §8.5) | +| `PENDING` 且 `next_retry_at > now` | 处于退避窗口,命令提前到达 | 确认消费。**不做任何补救**:Publisher 会在到点后发出该版本的命令;即使那条也丢了,Reconciler 的 `SENT` 超时窗口会兜底 | +| `PENDING` 且 `next_retry_at <= now` 但更新仍为 0 | 与其他实例竞争失败 | 确认消费 | + +最后两行是原设计缺失的分支。尤其“`PENDING` 且退避未到”——自动重试会把任务置为 `PENDING` 且 `next_retry_at = CURRENT_TIMESTAMP(3) + 15min`,此时若有重复消息导致命令提前到达,就会落入这个分支。正常流程下不会发生:§9.2 保证新 Outbox 行的 `next_retry_at` 等于任务的 `next_retry_at`,Publisher 到点才发。 + +**这两个分支都只确认消费、不做补救,是刻意的。** 任何“抢不到就重投/就自己补发”的补救逻辑都会与 Reconciler 的重派职责重叠,形成两条并行的重派通道,进而产生版本竞争。§3 原则 5 的“Reconciler 是唯一重派通道”正是这个意思。 + +### 8.3 租约续期:独立 watchdog,覆盖全执行期 + +**续租必须由独立的 watchdog 线程按固定周期执行,不能挂在 Provider 批次循环里。** + +第一版设计把续租放在“每完成一个批次”之后,存在一个致命盲区:**最后一批数据拉完之后,还有 `SXSSFWorkbook.write()`、ZIP 压缩、大文件落盘这些耗时操作,期间一次批次都不会完成,续租因此完全停止。** 20 万行的 XLSX 压缩落盘在慢磁盘上完全可能超过 300 秒租约,导致任务被 Reconciler 判为失联、剥夺租约并重派——而此时它其实马上就要成功了。结果是任务被反复重跑,每次都在最后一步被杀。 + +正确做法:Consumer 抢占成功后启动一个 watchdog,**在整个执行期间**(含 Provider 拉取、XLSX 写入、压缩、落盘、结果提交前的全部阶段)每 60 秒续租一次: + +```sql +UPDATE file_export_task + SET lease_until = CURRENT_TIMESTAMP(3) + INTERVAL :leaseSeconds SECOND, + last_heartbeat_at = CURRENT_TIMESTAMP(3) + WHERE id = :taskId AND status = 'RUNNING' + AND dispatch_version = :dispatchVersion AND lease_owner = :instance +``` + +进度上报(`processed_rows`)仍在批次之间单独更新,与续租解耦——**进度是展示用的,租约是正确性用的,两者不能共用一次写入**。 + +watchdog 的生命周期与约束: + +- 抢占成功后启动,`finally` 块中确保关闭(无论成功、失败、异常)。 +- 续租周期 60 秒,租约 300 秒 → 为 watchdog 发现异常、通知执行线程并安全中止预留约 240 秒租约余量;续租异常仍按下一条立即触发中止,不能据此忽略连续失败。 +- **续租返回 0 行时立即置中止标志**,执行主线程在每个可中断点(批次边界、写入循环内)检查该标志,一旦置位就中止导出、删除半成品文件、放弃提交。 +- watchdog 自身异常不得静默吞掉,必须记 ERROR 并置中止标志——续租线程死了却继续执行,等于失去租约保护。 + +### 8.3.1 执行截止时间:watchdog 不能让任务无限跑 + +**watchdog 会无条件续租,因此它同时消除了"任务卡死自然超时"这一兜底。** §14 声明的"最大任务时长 30 分钟"若没有独立的检查机制,就只是一句无法生效的声明:一个卡在慢 Provider 上的任务会被 watchdog 一直续租下去,占着消费线程直到 `consumeTimeout`(90 分钟)才被 Broker 判超时——而那时它已白占了一个并发位一个半小时。 + +因此抢占成功时必须计算并持久化执行截止时间。在 §8.2 的抢占 SQL 中增加一列: + +```sql + execution_deadline_at = CURRENT_TIMESTAMP(3) + INTERVAL :maxDurationMinutes MINUTE, +``` + +对应 `file_export_task` 增加字段(与 §7.3 的 7 个字段一并落库,共 8 个): + +```sql + execution_deadline_at DATETIME(3) NULL COMMENT '本次执行的截止时间,超过即判超时失败;抢占时按最大任务时长计算', +``` + +**deadline 必须在三处检查**,缺一不可: + +| 检查点 | 原因 | +|---|---| +| 每个 Provider 批次开始前 | 最常见的超时来源是数据量过大、批次过多 | +| XLSX 写入循环内(每 N 行,如每 10000 行) | 单批数据量大时,写入本身可能耗时很久 | +| 结果提交前(§8.4) | 防止刚好在 deadline 边界完成却仍提交,导致时长统计失真 | + +超过 deadline 按**临时依赖错误**处理(走 §9.2 自动重试),而非永久失败——首次超时可能是 Provider 偶发慢,重试有意义。但 `failure_message` 必须写明"执行超过最大时长 30 分钟",便于区分真正的超时与其他临时错误。若重试到 `max_attempts` 仍超时,说明数据量确实超出能力,转 `FAILED`,此时应考虑调大 `max-duration-minutes` 或调小 `max-rows`。 + +**deadline 用数据库时间计算与比较**(§8.6)。检查时不要在 Java 端拿 `LocalDateTime.now()` 和它比——用 watchdog 每次续租的返回值携带"是否已超 deadline",或在续租 SQL 中顺带判断: + +```sql +UPDATE file_export_task + SET lease_until = CURRENT_TIMESTAMP(3) + INTERVAL :leaseSeconds SECOND, + last_heartbeat_at = CURRENT_TIMESTAMP(3) + WHERE id = :taskId AND status = 'RUNNING' + AND dispatch_version = :dispatchVersion AND lease_owner = :instance + AND execution_deadline_at > CURRENT_TIMESTAMP(3) -- 超时则续租失败 +``` + +这样 deadline 与租约共用一次写入:**超过 deadline 后续租自然失败 → 中止标志置位 → 执行主线程在下一个可中断点退出**。不需要额外的时间比较逻辑,也不会因实例时钟偏差而误判。 + +注意这个复用有一个后果:超 deadline 后租约不再续期,300 秒后 Reconciler 会按"租约超时"路径恢复它(§8.5-2,递增版本重试)。这与"超时按临时错误重试"的结论一致,因此**即使 Consumer 侧的中止逻辑因故没跑,Reconciler 也会兜底得到相同结果**——两条路径殊途同归,这是刻意的冗余。 + +租约、续租与超时的约束关系(实施时必须同时满足): + +```text +续租周期(60s) << 租约(300s) << consumeTimeout(90min) +Provider 读超时(15s) << 租约(300s) +最大任务时长(30min) < consumeTimeout(90min) +``` + +有了 watchdog,Provider 读超时不再是租约正确性的**唯一**保障(续租独立于批次),但仍必须按 §14 配置——否则单批可以无限挂起,任务会一直占着消费线程直到 `consumeTimeout`。 + +### 8.4 结果提交 + +文件写入完成后: + +```sql +UPDATE file_export_task + SET status = 'SUCCEEDED', file_name = ..., object_key = ..., content_type = ..., + size_bytes = ..., total_rows = ..., processed_rows = ..., + completed_at = CURRENT_TIMESTAMP(3), + expires_at = CURRENT_TIMESTAMP(3) + INTERVAL :retentionDays DAY, + lease_owner = NULL, lease_until = NULL, failure_message = NULL + WHERE id = :taskId AND status = 'RUNNING' + AND dispatch_version = :dispatchVersion AND lease_owner = :instance + AND execution_deadline_at > CURRENT_TIMESTAMP(3) +``` + +影响行数为 0 时(版本已变、租约被剥夺或提交时已越过 deadline),**必须删除本次生成的孤立文件**;若仍是本实例持有的同版本 `RUNNING` 且 deadline 已过,则走 §9.2 超时重试,否则只记 WARN 并确认消息。deadline 条件必须进入最终 SQL,不能只在 Java 中预检查,否则检查后跨越截止时间仍可能错误提交成功。 + +### 8.5 Reconciler:唯一重派通道 + +轮询周期 30 秒。四项职责,每项都是幂等的条件更新: + +**(1) 补投疑似丢失的命令** —— **恢复原 Outbox 行重发,绝不递增任务版本**(§3 原则 5)。 + +分两种情形,分别处理: + +**情形 A:当前版本没有任何 Outbox 行**(写入丢失或被误删)。补写一条**同版本**的 Outbox 行: + +```sql +SELECT t.id, t.dispatch_version + FROM file_export_task t + WHERE t.status = 'PENDING' + AND t.next_retry_at <= CURRENT_TIMESTAMP(3) - INTERVAL :redispatchGraceSeconds SECOND + AND NOT EXISTS (SELECT 1 FROM file_export_outbox o + WHERE o.task_id = t.id AND o.dispatch_version = t.dispatch_version) + LIMIT 50 +``` + +插入时 `dispatch_version` 取任务当前值,`event_id` 新生成。唯一键 `uk_file_export_outbox_task_version` 保证并发下只有一条插入成功,其余报冲突并忽略——天然幂等。 + +注意此处 `NOT EXISTS` 不带 `status` 过滤,因此 `QUARANTINED` 行也会阻止补写——这正是需要的:毒消息隔离后不允许再产生同版本命令。 + +**情形 B:Outbox 行已 `SENT` 很久,任务仍是 `PENDING`**(消息大概率已被 Broker 丢弃)。把该行**条件性恢复为 `PENDING` 重发同一版本**: + +```sql +UPDATE file_export_outbox o + JOIN file_export_task t + ON t.id = o.task_id AND t.dispatch_version = o.dispatch_version + SET o.status = 'PENDING', + o.next_retry_at = CURRENT_TIMESTAMP(3), + o.lease_owner = NULL, + o.lease_until = NULL + WHERE o.status = 'SENT' + AND o.sent_at <= CURRENT_TIMESTAMP(3) - INTERVAL :sentStaleSeconds SECOND + AND t.status = 'PENDING' + AND t.next_retry_at <= CURRENT_TIMESTAMP(3) - INTERVAL :redispatchGraceSeconds SECOND + LIMIT 20 +``` + +`WHERE o.status = 'SENT'` 天然排除了 `QUARANTINED` 行——**这是阻断毒消息循环的关键**,见 §9.4。 + +**关键:任务版本不动,Outbox 行也不换版本。** Publisher 下一轮会重发同一条命令,Consumer 收到后正常抢占执行。 + +**为什么这样就不会饿死排队任务**:假设任务因并发已满在 Broker 队列中正常等待,Reconciler 在第 10 分钟把 Outbox 行恢复为 `PENDING` 并重发。此时队列里有**两条同版本命令**: + +- 若原命令先被消费 → 正常抢占执行;随后重复命令看到 `RUNNING` → 确认消费(§8.2 第 4 行)。 +- 若重复命令先被消费 → 同样正常抢占执行;原命令看到 `RUNNING` → 确认消费。 + +**两条命令都是有效的,谁先到谁执行。** 对比第一版“递增版本”的做法:原命令会因版本过期被丢弃,新命令排到队尾,持续负载下任务永远轮不上——这正是被修掉的活性错误。 + +代价:正常排队场景下会产生一条无用的重复消息。这是刻意的取舍——重复消息由幂等吸收,成本仅是一次条件更新;而漏补一条真丢的消息会让任务永久停滞。**宁可多投,不可不投。** + +`sentStaleSeconds` 默认 600 秒。由于重复投递现在是无害的,这个值不再需要精确避开正常排队时长,可以取得比较激进;但也不宜过小,否则积压时会放大 Broker 队列长度。 + +两种情形都**不判断 `attempt_count`**:补投不是新的执行尝试,不受 `max_attempts` 约束。执行次数的闸门在 §8.2 抢占成功时递增、在 §9.2 与 §8.5(2) 判定,与补投无关。 + +**(2) 恢复超时租约**: + +```sql +SELECT id, dispatch_version, attempt_count, max_attempts, object_key + FROM file_export_task + WHERE status = 'RUNNING' AND lease_until <= CURRENT_TIMESTAMP(3) LIMIT 20 +``` + +同一事务内:若 `attempt_count < max_attempts` 则置 `PENDING`、`next_retry_at = CURRENT_TIMESTAMP(3)`、**`dispatch_version + 1`**、清空租约、写新 Outbox;否则置 `FAILED` 并写明“执行实例失联且已达最大尝试次数”。半成品文件按 §9.5 清理。 + +**这里递增版本是正确的**,与 §8.5(1) 的补投不同:租约超时意味着上一次**执行**确实失败了(实例已死或卡死),需要作废那次尝试并开始新尝试——正好符合 §3 原则 5 中“执行失败才递增”的定义。递增后旧实例若“复活”也无法再提交(§8.4 的条件带版本号)。 + +**(3) 释放僵死 Outbox**:`status = 'CLAIMED' AND lease_until <= CURRENT_TIMESTAMP(3)` → 置回 `PENDING`,`next_retry_at = CURRENT_TIMESTAMP(3)`。这是投递侧恢复,**不动任务版本**。 + +**(4) 过期与孤立文件清理**:沿用现有 `expiredArtifacts` + `markExpired` 逻辑(先删文件、删成功才置 `EXPIRED`、失败保持原状并告警),另扫描 `exports/` 下无对应 `SUCCEEDED` 任务且创建超过 24 小时的文件作为孤立文件删除。 + +Reconciler 的所有分支都只做“把任务重新变成可派发的 PENDING + 写 Outbox”或“恢复 Outbox 行重发”,**从不直接执行导出,也从不因投递问题递增任务版本**。 + +### 8.6 时间基准:一律使用数据库时钟 + +**所有与租约、退避、超时判断相关的时间,必须在 SQL 中使用 `CURRENT_TIMESTAMP(3)`,不得由 Java 端传入 `LocalDateTime.now()`。** + +这一条覆盖:抢占的 `next_retry_at <= ?`、租约的 `lease_until` 写入与比较、Outbox 的 `next_retry_at` 与 `sent_at`、Reconciler 的全部时间条件。 + +原因:多实例部署时各容器的系统时钟存在偏差(NTP 同步误差、容器时钟漂移,秒级到分钟级都可能)。若用本地时钟: + +- 时钟快的实例会**提前抢占**:它认为 `lease_until` 已过期,实际上持有者还在正常执行 → 两个实例同时写同一任务。 +- 时钟慢的实例会**延迟恢复**:真正失联的任务迟迟不被回收。 +- 退避窗口在不同实例上长短不一,`next_retry_at` 失去意义。 + +数据库是全局唯一的时间基准,所有实例读到的 `CURRENT_TIMESTAMP(3)` 一致,上述问题全部消失。 + +例外:仅用于日志输出、指标打点、文件名生成的时间可以用本地时钟——它们不参与正确性判断。`file_name` 中的时间戳即属此类。 + +现有代码中 `ExportTaskRepository` 的 `claim(id, LocalDateTime.now())`、`requeueStale(cutoff)` 等签名都是 Java 端传时间,**改造时必须一并改为 SQL 内取时间**,这是从当前实现迁移时容易遗漏的一处。 + +## 9. 重试与 DLQ + +### 9.1 错误分类 + +| 类型 | 示例 | 行为 | +|---|---|---| +| 永久业务错误 | 参数无效、导出类型已停用、列定义不合法、`schemaHash` 批次间不一致、超出 `max-rows` | 立即 `FAILED`,不自动重试 | +| 临时依赖错误 | Provider 503 / 连接超时 / 读超时、存储短暂不可用、游标未推进 | 走 §9.2 自动重试 | +| Consumer 非预期退出 | JVM 退出、实例被杀、心跳失败 | 租约超时 → Reconciler 恢复(§8.5-2) | +| MQ 投递失败 | NameServer / Broker 不可用 | Outbox 退避重发(§7.4) | +| 消息本身损坏 | 反序列化失败、`schemaVersion` 不支持 | 不确认消费 → MQ 原生重投 → DLQ → **§9.4.2 隔离(Outbox 置 `QUARANTINED` + 任务置 `FAILED`)** | + +**只有最后一类会进入 DLQ。** 这是“DLQ 出现任意新消息即告警”能作为有效告警规则的前提。 + +### 9.2 自动重试 + +`max_attempts = 3`(即最多 2 次自动重试)。退避 1 分钟、15 分钟,含 ±20% 随机抖动。退避由数据库承担,任意间隔均可表达。 + +临时失败时,Consumer 在**同一数据库事务**内: + +1. 删除本次半成品文件(见 §9.5),置 `object_key = NULL`; +2. 若 `attempt_count >= max_attempts` → `status = FAILED`,写 `failure_message`,事务结束; +3. 否则 `status = PENDING`、`next_retry_at = CURRENT_TIMESTAMP(3) + backoff`、`dispatch_version + 1`、`processed_rows = 0`、`total_rows = NULL`、清空 `lease_owner` / `lease_until`、保留 `failure_message` 供下载中心展示上次原因; +4. 写入新 Outbox 行,其 `next_retry_at` **等于任务的 `next_retry_at`**(这是 Publisher 不提前发送的保证)。 + +事务提交后确认 MQ 消息。所有重试状态在数据库中可查,不依赖客户端内存,不引入 Redis 重试队列。 + +条件更新同样要带 `dispatch_version = :v AND lease_owner = :instance`;失败则说明租约已被剥夺,放弃写入。 + +### 9.3 人工重试 + +下载中心人工重试只允许 `FAILED`(保持现有 `ExportTaskService.retry()` 的约束),在同一事务内: + +- 重新校验登录身份、租户、访问域与注册表中的 `required-permission`; +- 校验导出类型当前 `enabled = true`(停用类型不得重试); +- 清空 `failure_message`,`attempt_count = 0`,`processed_rows = 0`,`total_rows = NULL`,`started_at = NULL`,`completed_at = NULL`; +- 删除上次遗留文件并置 `object_key = NULL`; +- `status = PENDING`,`next_retry_at = CURRENT_TIMESTAMP(3)`,`dispatch_version + 1`; +- 写入新 Outbox 行。 + +### 9.4 DLQ 与毒消息隔离 + +#### 9.4.1 为什么必须隔离:否则形成无限毒消息循环 + +确立 §3 原则 5(补投重发同版本)后,出现了一个新的循环路径,**必须显式阻断**: + +```text +消息 payload 损坏(反序列化失败) + -> Consumer 不确认消费 + -> MQ 重投 2 次仍失败 -> 落 DLQ + -> 任务仍是 PENDING,Outbox 行仍是 SENT + -> 超过 sentStaleSeconds 后 Reconciler 判定"消息丢失" + -> 把同一条损坏 payload 改回 PENDING 重发 ← 循环点 + -> 再次落 DLQ ... +``` + +每 10 分钟循环一次,DLQ 持续增长,告警持续触发,且**永远不会自愈**——因为 payload 存在数据库里,重发多少次都是同样的坏数据。Reconciler 的"重发同版本"对 Broker 丢消息是正确的,对内容损坏则是灾难。 + +根因:§8.5 的补投无法区分"消息丢了"和"消息到了但读不懂"。两者在数据库侧的表征完全相同(任务 `PENDING` + Outbox `SENT`),必须由 DLQ 处置流程显式打标。 + +#### 9.4.2 隔离机制 + +新增 Outbox 终态 `QUARANTINED`,**只用于消息内容损坏,绝不用于 Broker 发送失败**(后者永不放弃,§7.4)。 + +File Service 增加独立 DLQ Handler,使用专用消费组订阅 `%DLQ%`。Handler 接收原始 `MessageExt`,从 User Properties 读取 `eventId` / `taskId` / `dispatchVersion` / `schemaVersion`,在下列事务成功提交后才确认 DLQ 消息: + +```sql +-- 1) 隔离该版本的 Outbox 行,阻断 Reconciler 补投 +UPDATE file_export_outbox + SET status = 'QUARANTINED', + last_error = CONCAT('DLQ隔离: ', :reason) + WHERE event_id = :eventId + AND task_id = :taskId AND dispatch_version = :dispatchVersion + AND status IN ('SENT', 'PENDING', 'CLAIMED'); + +-- 2) 同一事务内把对应任务置为 FAILED,让用户在下载中心看到明确失败 +UPDATE file_export_task + SET status = 'FAILED', + failure_message = '导出命令消息损坏,请重新发起导出', + completed_at = CURRENT_TIMESTAMP(3), + lease_owner = NULL, lease_until = NULL + WHERE id = :taskId AND dispatch_version = :dispatchVersion + AND status = 'PENDING'; +``` + +两步必须在**同一事务**内完成,并断言两条更新各影响 1 行。只做第 1 步会让任务永久停在 `PENDING`(Reconciler 已被阻断,无人再驱动它),对用户表现为"一直排队"——比循环更难排查。若任务版本已经变化或 Outbox `event_id` 不匹配,说明是旧消息或外部误投,记录审计后直接确认,不得隔离当前版本。若新协议消息缺少任一定位属性,DLQ Handler 记 CRITICAL、保存 Broker messageId 与原始属性供人工排查并确认消息,避免 DLQ Handler 自身形成二级死信循环。 + +隔离后的恢复路径:**修复消息契约或代码后,由用户或运维走人工重试**(§9.3)。人工重试递增 `dispatch_version` 并写入**新版本**的 Outbox 行,其 payload 由新代码生成,与被隔离的旧版本无关。旧的 `QUARANTINED` 行永久保留作为审计痕迹,因唯一键是 `(task_id, dispatch_version)` 而版本已变,不会冲突。 + +#### 9.4.3 运维要求 + +- Consumer Group 的 DLQ Topic 为 `%DLQ%`,需在部署脚本中显式创建并授予读写权限(§16.2)。 +- DLQ 出现任意消息即告警,因为按 §9.1 它只承载损坏消息。 +- DLQ Handler 必须自动执行 §9.4.2 的隔离;运维收到告警后只负责分析原因与决定是否人工重试。禁止依赖值班人员在 10 分钟补投窗口内手工运行 SQL。 +- DLQ Handler 的数据库处置失败必须持续重试(`maxReconsumeTimes = Integer.MAX_VALUE`),不能在有限次数后进入无人消费的 `%DLQ%` 二级 DLQ;缺少或非法定位属性的消息仍按 §9.4.2 记录 CRITICAL 后确认。 +- 重投前核对任务当前 `dispatch_version`;版本已变说明任务已通过新版本重试,旧消息只允许丢弃。 +- 不允许静默丢弃:每次处置需在运维记录中写明 `taskId`、`eventId`、`dispatchVersion`、损坏原因、处置方式。 +- **任务不会因为消息进 DLQ 而丢失**:要么被隔离流程显式置为 `FAILED`(用户可见、可人工重试),要么在未隔离期间由 Reconciler 补投。两种结局都不是静默停滞。 + +#### 9.4.4 毒消息的真实来源与预防 + +按 §6.4,消息只含 `eventId` / `taskId` / `exportType` / `dispatchVersion` / `occurredAt` / `traceId`,全是定长标识,**正常情况下不可能损坏**。真实来源只有两类: + +1. **消息契约不兼容变更**:如把 `taskId` 从字符串改成数字、删字段、改 `schemaVersion` 语义,而新旧版本 File Service 在滚动发布期间并存。这是最可能的来源。 +2. 手工向 Topic 投递了格式错误的消息(测试误操作)。 + +预防措施:`schemaVersion` 只增不改语义;新增字段必须可选;删除字段需跨两个发布周期(先停用后删除)。滚动发布期间新旧 Consumer 并存是常态,契约必须双向兼容。 + +### 9.5 半成品文件与快照语义 + +- 每次执行从零重写整个文件,不做断点续传。`SXSSFWorkbook` 的临时文件在 `try-with-resources` 中释放。 +- 任何失败路径(永久失败、临时失败、租约丢失、提交竞争失败)都必须删除本次 `object_key` 指向的文件。删除失败只记 WARN,由 Reconciler 的孤立文件扫描兜底。 +- **`snapshotToken` 不跨 `dispatch_version` 复用。** 每一轮执行重新向 Provider 申请首批并获得新 Token。原因:退避 15 分钟后旧 Token 很可能已失效;且新 Token 语义清晰——文件内容对应“最后一次执行开始时刻”的数据快照。 +- 因此下载中心对重试成功的任务应理解为:数据以最后一次执行时刻为准,而非任务创建时刻。任务详情返回的 `startedAt` 即该时刻。 + +## 10. 统一 Business Export Provider 契约 + +### 10.1 接入形态 + +新业务服务不接公共导出 MQ,只提供一个标准内部 HTTP Provider。契约类型放入 `common-core` 的 `com.baiyue.common.core.export` 包,避免每个服务复制 DTO。 + +```http +POST /internal/exports/v1/{exportType}/batches +X-Internal-Service-Token: ... +Content-Type: application/json +``` + +请求: + +```json +{ + "taskId": "190000000000000001", + "tenantId": "0", + "appCode": "PLATFORM", + "requestedBy": "1900000000000000009", + "dispatchVersion": 1, + "parameters": { + "keyword": "柏悦", + "status": "ACTIVE" + }, + "cursor": null, + "snapshotToken": null, + "limit": 500 +} +``` + +**`tenantId`、`appCode`、`requestedBy` 是必填的,这是原设计的严重缺口。** 现有 `file_export_task` 已保存这三项,但原契约未下传,Provider 无法按租户过滤。当前只有平台侧 `CUSTOMER_LIST` 所以未暴露,一旦出现 `app_code = MERCHANT` 的导出类型就会越权导出。AGENTS.md 明确要求“商户数据必须保留租户维度,并由服务端执行租户、访问域、数据范围和按钮权限校验”。 + +Provider 侧强制要求: + +- 必须校验 `X-Internal-Service-Token`,与 `INTERNAL_SERVICE_TOKEN` 一致才响应; +- 必须用 `tenantId` + `appCode` 约束查询,**不得信任 `parameters` 中的任何租户字段**; +- `appCode = MERCHANT` 时必须额外按 `requestedBy` 的数据范围过滤; +- 只读,不得产生副作用。 + +首批响应: + +```json +{ + "code": "200", + "message": "success", + "data": { + "schemaVersion": 1, + "schemaHash": "sha256:...", + "snapshotToken": "provider-defined-opaque-token", + "columns": [ + { "key": "tenantCode", "title": "客户编码", "type": "STRING", "width": 18 }, + { "key": "tenantName", "title": "公司名称", "type": "STRING", "width": 28 } + ], + "rows": [ + { "tenantCode": "TEN101", "tenantName": "柏悦物流" } + ], + "nextCursor": "101", + "hasMore": false, + "totalRows": 1 + } +} +``` + +响应遵循仓库统一约定:HTTP 200 + `code == "200"` 才成功,`Long` 输出为字符串。后续批次必须返回相同 `schemaHash`,可省略 `columns`;`schemaHash` 变化即视为永久业务错误并 `FAILED`(说明 Provider 在导出期间变更了列定义)。 + +`hasMore = true` 但 `nextCursor` 为空或未推进时,File Service 判定为临时依赖错误并终止本轮(沿用现有 `CustomerExportTaskHandler` 的游标推进校验)。 + +### 10.2 稳定快照 + +仅用递增 ID 游标会把导出期间新增的数据混入结果。Provider 首批必须返回不透明 `snapshotToken`,后续批次带回同一 Token。 + +Provider 可将 Token 实现为签名后的最大 ID、截止时间或数据库快照标识。File Service 不解析 Token,只限制长度(≤512 字符)并原样回传。Token 失效时 Provider 返回永久业务错误码,File Service 判为 `FAILED`——不重试,因为重试只会拿到同一个失效 Token(本轮内)。 + +**"稳定快照"的保证范围必须说准,不要过度承诺。** 不同 Token 实现的保证强度差别很大: + +| Token 实现 | 排除导出期间新增 | 冻结导出期间的修改 | 冻结导出期间的删除 | +|---|---|---|---| +| 签名后的最大 ID(最简单,推荐起步) | ✅ | ❌ 后续批次会读到改后的值 | ❌ 已删除的行不再返回,结果行数可能少于 `totalRows` | +| 截止时间戳 + 按 `updated_at` 过滤 | ✅ | ⚠️ 取决于是否有历史版本 | ❌ | +| 数据库快照(如 MySQL 一致性读快照、或导出前落临时表) | ✅ | ✅ | ✅ | + +**订单与客户 `CUSTOMER_LIST` 采用"签名后的最大 ID",即只保证不混入新增数据,不保证修改与删除的一致性。** 这对客户列表导出是可接受的——导出期间恰好有客户资料被改动的概率极低,且影响仅是个别字段取值偏新。 + +因此对外表述应为“**不会混入导出开始后新增的数据**”,而非笼统的“数据快照一致”。若某个导出类型确实需要完整快照(如财务对账),该 Provider 需自行实现临时表或一致性读方案,本契约不阻止,但也不代为实现。 + +单批响应中的 `totalRows` 为**估算值**,仅用于展示进度百分比,不得用于校验最终行数是否正确——删除会使实际行数偏少,这是上表的直接后果。 + +### 10.3 注册表 + +导出类型通过受控 Nacos 配置注册(`file-service.yml`): + +```yaml +baiyue: + file: + export: + definitions: + CUSTOMER_LIST: + provider-service: iam-service + provider-path: /internal/exports/v1/CUSTOMER_LIST/batches + required-permission: platform:customer:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 +``` + +限制与启动校验(任一不通过则**启动失败**,不允许降级运行): + +- `provider-service` 必须在**代码内置白名单**中(`iam-service`、`order-service`、`warehouse-service`、`firstleg-service`)。只靠 Nacos 校验不够——Nacos 被误改或被入侵即可指向任意服务造成 SSRF;白名单在代码里,配置只能从白名单中选。 +- `provider-path` 必须匹配 `^/internal/exports/v1/[A-Z0-9_]+/batches$`,且路径中的类型名必须等于 map 的 key。 +- `required-permission` 非空且匹配 `^[a-z]+:[a-z-]+:[a-z-]+$`。 +- `app-scope` ∈ `{PLATFORM, MERCHANT}`,用于创建任务时校验请求方访问域。 +- `max-rows` ∈ [1, 500000],`batch-size` ∈ [50, 1000],`max-attempts` ∈ [1, 5]。 +- key 不重复、不为空(YAML map 天然去重,但需校验非法字符)。 +- 注册配置由平台发布流程维护,不能由浏览器动态注册。 + +配置支持 Nacos 热刷新,但**刷新时同样执行全量校验,校验失败保留旧配置并告警**,不接受非法配置生效。 + +通过配置注册后,新业务类型不需要修改 File Controller、Consumer、Publisher 或 XLSX Writer。 + +### 10.4 权限与定义在热更新下的语义(必须明确,否则行为不确定) + +任务从创建到执行之间可能间隔数十分钟(排队 + 退避重试),期间 Nacos 注册表可能被改动,用户权限也可能被回收。**必须明确每一项按哪个时点取值**,否则同一个任务在不同时刻执行会得到不同结果。 + +本文的取值规则: + +| 项目 | 取值时点 | 理由 | +|---|---|---| +| `required-permission` 校验 | **创建时** + **人工重试时**,执行阶段不再校验 | 执行发生在后台,此时没有用户会话,无法也不应重新鉴权。任务一旦受理即视为已授权 | +| `enabled` | **创建时** + **人工重试时** | 同上。已受理的任务不因类型临时停用而失败——停用是为了阻止新任务,不是撤销在途任务 | +| `app-scope` | **创建时** | 与权限同批校验 | +| `max_attempts` | **创建时快照进任务表** | 已在 §7.3 落表,重试上限在任务生命周期内恒定,不受配置改动影响 | +| `provider-service` / `provider-path` | **执行时取当前值** | Provider 地址变更(如服务迁移)应当立即对在途任务生效,否则任务会打到已下线的地址 | +| `batch-size` / `max-rows` | **执行时取当前值** | 属于资源保护参数,调小应立即生效以保护下游 | + +因此**任务表不保存权限快照,也不保存定义版本号**——需要冻结的只有 `max_attempts`,已单独落表;其余要么只在创建时校验一次,要么刻意读取最新值。这比引入 `definition_version` 字段更简单,且语义明确。 + +两个必须接受的后果,写明以免日后被当作 bug: + +1. **权限被回收后,用户已提交的排队任务仍会执行完成**,文件可下载(下载时会再校验 `ownedTask`,但不校验导出权限)。若需要立即阻断,应由管理员显式取消任务——**本期不提供取消接口**,属已知缺口,后续需要时再加。 +2. **导出类型被 `enabled: false` 后,在途任务仍会跑完。** 若要强制停止,运维手段是关 `consumer.enabled` 或直接改任务状态为 `FAILED`。 + +如果未来出现"必须严格按执行时权限"的合规要求,则需要在任务表增加权限快照字段并在执行前比对——那是一次有意的设计变更,不是本期的疏漏。 + +## 11. 通用 XLSX Writer + +统一 Writer 替换现有 `CustomerExportTaskHandler` 中硬编码的 `HEADERS` 与 `writeRow`。至少支持: + +- 类型 `STRING`、`INTEGER`、`DECIMAL`、`BOOLEAN`、`DATE`、`DATETIME`,按类型写入对应单元格类型(当前实现把所有值写成字符串,日期与金额在 Excel 中无法排序计算,本次修正)。 +- 空值、超长单元格(截断至 32767 字符并记 WARN)、非法数字、`columns` 中未声明的字段(忽略并记 WARN,不报错)。 +- 冻结表头、列宽上限(≤255 字符宽)、稳定 Sheet 名。 +- 单 Sheet 行数上限 1048575 行;接近上限自动创建下一 Sheet 并重复表头。按 `max-rows: 200000` 的默认值,正常不会触发,但必须实现。 +- `SXSSFWorkbook(100)` 流式写入、`setCompressTempFiles(true)`、临时文件确定性清理。 +- **所有 `STRING` 值以字符串单元格写入并对首字符为 `= + - @ Tab CR` 的值加前导单引号**,防止 CSV/Excel 公式注入。 +- 上限:最大列数 100、最大行数取注册表 `max-rows`、最大任务时长 30 分钟、最大文件 50MB。任一超限即 `FAILED` 并写明具体上限。 + +**文件大小上限必须在写入过程中实时检查并提前中止,不能等文件完整落盘后再判断。** 事后检查有两个问题:磁盘已经被写满(50MB 上限形同虚设,实际可能写出几个 GB)、白白消耗了完整的导出时间才失败。 + +实现方式:在 `SXSSFWorkbook` 的输出流外包一层计数流,累计字节数超过上限立即抛出永久业务错误: + +```java +// 写入路径:SXSSFWorkbook.write(countingStream) +// countingStream 内部累加 written,超限即 throw ExportSizeExceededException +``` + +注意 `SXSSFWorkbook` 的临时文件也会占磁盘(`setCompressTempFiles(true)` 后是压缩的,但仍需空间),行数上限 `max-rows` 是这一侧的主要保护。两道闸门都要有:`max-rows` 防临时文件膨胀,计数流防最终文件膨胀。 + +同理,Provider 单批响应体 ≤4MB 的限制也应在读取时按 `Content-Length` 或计数流判断,而非完整读入后再检查。 + +业务服务不得返回样式、公式、宏或任意文件路径。 + +### 11.1 存储抽象与多实例约束 + +将现有 `LocalExportStorage` 提取为接口 `ExportStorage`(`create` / `size` / `load` / `delete`),实现类 `SharedDirectoryExportStorage` 保持当前行为与对象键格式 `exports/{taskId}/{uuid}.xlsx`。Consumer、API、Reconciler 只依赖接口。 + +订单与客户不引入对象存储(§21 决策 6),但接口就位后未来替换为 MinIO/OSS 不需要改动调用方。**对象键始终由 File Service 生成与校验,业务服务不得提供。** + +**普通宿主机目录挂载不构成"共享存储",它只在同一宿主机的容器之间共享。** 这是第一版的一处不实表述:部署已有的 `FILE_UPLOADS_HOST_DIR:/data/files` 是宿主机 bind mount,若 File Service 的两个实例被调度到**不同物理机**,各自看到的是各自宿主机上的独立目录——A 实例生成的文件,B 实例既读不到也删不掉,表现为:下载随机 404(请求打到没有该文件的实例)、过期清理留下永久孤儿文件。 + +因此订单与客户存储的明确边界,**二选一,必须在部署前确认**: + +| 方案 | 约束 | 适用 | +|---|---|---| +| **A. 单节点部署**(订单与客户默认) | File Service 只允许 1 个实例;若用编排系统需固定节点亲和,禁止自动扩容 | 1000 用户量级,导出低频,单实例足够 | +| **B. 跨主机共享卷** | 挂载 NFS / CephFS / 云盘共享卷到同一路径 | 需要多实例时 | + +选 A 时必须注意:**代码不得因此引入任何单实例假设**——租约、`lease_owner`、条件更新全部保留,因为滚动发布期间会短暂存在两个实例,且方案 B 随时可能启用。单实例是部署约束,不是设计约束。 + +选 B 时需额外验证:NFS 的 `Files.deleteIfExists` 与 `newOutputStream(CREATE_NEW)` 语义、以及多实例并发写同一目录的表现。 + +`ExportStorage` 接口的价值正在于此:从 A 迁到 B 只是换挂载,从 A/B 迁到 MinIO 只是换实现类,调用方一行不改。 + +## 12. 权限与安全 + +- 创建任务:校验登录身份、`app_code` 与注册表 `app-scope` 匹配、`required-permission` 命中。 +- 列表、详情、下载:沿用现有 `ownedTask()` 的租户 + 访问域 + 申请人 + `platform:download:all` 校验。 +- Provider 调用使用 `X-Internal-Service-Token`,**不转发用户 Bearer Token**;令牌只从配置中心密文注入。 +- File Service 不记录完整行数据、Token 或敏感筛选条件;日志中的 `parameters` 需脱敏。 +- 任务参数限制:键数 ≤20、JSON ≤4KB、单字符串 ≤256 字符、嵌套深度 ≤2。 +- Provider 响应限制:单批 ≤`batch-size` 行、列数 ≤100、单元格 ≤32767 字符、单批响应体 ≤4MB。 +- Provider 服务名与路径必须来自注册表且过代码白名单,防止 SSRF(§10.3)。 +- MQ 消息不含任何敏感数据(§6.4)。当前 Compose 仅支持 File Service 与 Broker 位于同一 `infra-network`,Broker 不映射宿主机端口。 + +## 13. 可观测性 + +**前置依赖**:必须先在 `common-web/pom.xml` 增加 `micrometer-registry-prometheus`,否则 actuator 的 `prometheus` 端点不可用,本节指标全部无处可采。同时需要落地 Prometheus 服务端(§21 决策 5)。 + +指标(1000 用户量级的实用集合): + +- `export_task_created_total{type}` +- `export_task_completed_total{type,status}` +- `export_task_duration_seconds{type}`(直方图) +- `export_task_queue_age_seconds`(最老 PENDING 任务的等待秒数,Gauge) +- `export_outbox_pending`、`export_outbox_oldest_age_seconds`、`export_outbox_max_attempt`(当前所有待发行中最大的 `attempt_count`,替代原 `export_outbox_dead`) +- `export_provider_request_seconds{service,type}`、`export_provider_failure_total{service,type}` +- `export_reconciler_action_total{action}`(`redispatch` / `lease_recover` / `outbox_release` / `expire` / `orphan_clean`) +- `export_dlq_messages_total`(File Service 的 DLQ Handler 每收到一条消息即递增;无论定位属性是否合法都计数) +- `export_storage_bytes` + +日志统一携带 `taskId`、`eventId`、`dispatchVersion`、`exportType`、`tenantId`、`requestId`、`traceId`,禁止打印完整导出数据。 + +告警: + +| 告警 | 阈值 | 含义 | +|---|---|---| +| Outbox 积压 | `export_outbox_oldest_age_seconds > 120` | Publisher 或 Broker 异常 | +| Outbox 反复发送失败 | `export_outbox_max_attempt > 10` | 单条命令连续失败超 10 次,Broker 或网络异常,需人工排查(行本身会继续重试,不会丢) | +| 任务排队过久 | `export_task_queue_age_seconds > 600` | Consumer 未消费或全部忙 | +| DLQ | 任意新消息 | 出现损坏消息 | +| 失败率 | 连续 10 分钟失败率超阈值 | Provider 或存储异常 | +| Reconciler 频繁恢复 | `export_reconciler_action_total{action="lease_recover"}` 10 分钟内 > 3 | 实例不稳定或租约过短 | +| 存储 | 不可写或剩余空间不足 | 需扩容 | + +## 14. 限流与资源隔离 + +| 项 | 取值 | 说明 | +|---|---|---| +| Consumer 并发(= 导出并发) | 4 | `consumeThreadMin = consumeThreadMax = 4`;队列数不构成并发线程硬上限 | +| 单用户同时活动任务 | 3 | 超出返回业务错误 | +| 单 `exportType` 同时活动任务 | 5 | 保护 Provider | +| 重复提交 | 同一用户 60 秒内相同 `exportType` + 相同参数 JSON → 返回已有活动任务 | 参数 JSON 需规范化后比较 | +| Provider 单批 | ≤500 行 | 注册表 `batch-size` | +| Provider 超时 | 连接 3 秒 / 读 **15 秒** | 见下方说明,**必须按导出 Client 单独配置** | +| 最大任务时长 | 30 分钟 | 超时判临时失败 | +| `consumeTimeout` | 90 分钟 | 必须 > 最大任务时长 | +| 优雅停机 | `server.shutdown: graceful`(已配置)+ Consumer `shutdown()` 先停拉取,等待当前批次到安全点,最长 60 秒 | 停机时不完成的任务由租约超时恢复 | + +默认并发 4 以测试、生产均约 8C32G 为容量基准,但这 8 核由 File、IAM、MySQL 及其他服务共享,不能按 File Service 独占 8 核估算。当前单实例即使不扩 Topic 队列也能把消费线程调到 4 以上,但不得仅凭 CPU/内存规格直接升到 6 或 8;必须复核 Provider、数据库、磁盘和普通接口延迟。若改为多实例部署,还要同时复核队列分配与共享存储。 + +上表前三项创建约束必须在 `file_export_task_guard` 行锁保护的同一数据库事务内复核并写入,Controller/Service 中事务外的预检查只能用于快速失败,不能作为并发正确性保证。 + +File Service 必须显式限制 JVM 堆,不能继续只依赖镜像的 `-XX:MaxRAMPercentage=75.0` 按整机 32GB 计算。测试与生产起步配置为 `-Xms1g -Xmx2g`;若 4 个 20 万行任务并发压测时 GC 后堆占用持续超过 65%,再依据实测调大,不能用扩大堆掩盖未释放的 Workbook、临时文件或响应对象。 + +**Provider 读超时必须按导出 Client 单独配置,不能沿用全局默认值。** 仓库 `config/nacos/*/baiyue-common.yml` 中 `spring.cloud.openfeign.client.config.default.read-timeout` 当前为 **5000ms**,这对普通接口合适,但导出单批要拉 500 行业务数据(含多表关联),5 秒极易超时,会把正常导出误判为临时依赖错误并触发无谓重试。 + +因此在 `file-service.yml` 中按 `contextId` 单独覆盖: + +```yaml +spring: + cloud: + openfeign: + client: + config: + iamExportProviderClient: &export-provider-timeout + connect-timeout: 3000 + read-timeout: 15000 + orderExportProviderClient: *export-provider-timeout + warehouseExportProviderClient: *export-provider-timeout + firstlegExportProviderClient: *export-provider-timeout +``` + +15 秒的取值依据:单批 500 行的正常耗时应在 1~3 秒,15 秒留 5 倍余量吸收抖动。**注意读超时不再是租约的保障**——§8.3 的 watchdog 独立于批次续租,即使单批阻塞 15 秒租约也不会丢;读超时在这里的作用是防止单批无限挂起把消费线程占死到 `consumeTimeout`。 + +## 15. 数据保留与存储 + +- `SUCCEEDED` 文件默认保留 7 天(沿用 `FILE_EXPORT_RETENTION_DAYS`)。 +- 文件删除成功或确认不存在后,任务才能标记 `EXPIRED`;删除失败保持原状并重试 + 告警(沿用现有实现)。 +- `file_export_outbox` 的 `SENT` 行保留 7 天后**物理删除**(每日一次,每次 ≤5000 行分批删除)。清理必须限定 `status = 'SENT'`,绝不能删除 `PENDING` / `CLAIMED` 行——它们是尚未送达的命令。 +- `file_export_task` 终态行长期保留(下载中心需要历史),当前量级无需归档。 +- 存储:订单与客户按 §11.1 方案 A(单节点)或方案 B(跨主机共享卷)二选一,部署前必须确认。宿主机 bind mount 只在同一主机内共享,**不足以支撑跨主机多实例**。 +- 对象键由 File Service 生成和校验,业务服务不得提供。 + +## 16. 基础设施落地 + +### 16.1 测试环境 RocketMQ + +实际部署入口为 `deploy/docker-compose.rocketmq.yml`,包含单 NameServer、单 Broker 和一次性 Topic 初始化容器(1000 用户量级,无主从): + +```yaml + rocketmq-namesrv: + image: apache/rocketmq:5.3.1 + command: sh mqnamesrv + restart: unless-stopped + networks: [infra-network] + + rocketmq-broker: + image: apache/rocketmq:5.3.1 + depends_on: [rocketmq-namesrv] + environment: + ROCKETMQ_BROKER_IP: ${ROCKETMQ_BROKER_IP:?ROCKETMQ_BROKER_IP is required} + volumes: + - ./rocketmq:/config:ro + - ${ROCKETMQ_DATA_DIR:-/data/firstleg/rocketmq/store}:/home/rocketmq/store + command: sh /config/render-broker-config.sh + restart: unless-stopped + networks: [infra-network] + + rocketmq-topic-init: + image: apache/rocketmq:5.3.1 + depends_on: [rocketmq-broker] + environment: + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX?MQ_TOPIC_PREFIX must be set} + # 完整的四条幂等 mqadmin 命令见实际 Compose;任一失败由 on-failure 重试 + command: sh -eu -c 'until sh mqadmin clusterList -n rocketmq-namesrv:9876 2>/dev/null | grep -q "BaiyueCluster"; do sleep 2; done; ...' + restart: on-failure + networks: [infra-network] +``` + +**`brokerIP1` 必须在启动时生成,不能在 properties 文件中直接写 `${ROCKETMQ_BROKER_IP}`。** RocketMQ 不会替换该占位符,会把字面量注册到 NameServer,客户端随后表现为发送超时。 + +因此 `deploy/rocketmq/broker.conf.template` 保存模板: + +```properties +brokerClusterName=BaiyueCluster +brokerName=baiyue-file-export-broker +brokerId=0 +brokerIP1=__BROKER_IP__ +listenPort=10911 +deleteWhen=04 +fileReservedTime=72 +brokerRole=ASYNC_MASTER +flushDiskType=ASYNC_FLUSH +autoCreateTopicEnable=false +autoCreateSubscriptionGroup=false +``` + +`deploy/rocketmq/render-broker-config.sh` 在启动前使用 `sed` 生成 `/tmp/broker.conf`,并验证地址与残留占位符: + +```bash +#!/bin/sh +set -eu + +case "${ROCKETMQ_BROKER_IP:-}" in + ""|localhost|127.0.0.1|__BROKER_IP__|*'${'*) + echo "ROCKETMQ_BROKER_IP must be an explicit client-reachable address" >&2 + exit 1 + ;; +esac +sed "s/__BROKER_IP__/${ROCKETMQ_BROKER_IP}/g" /config/broker.conf.template > /tmp/broker.conf +if grep -Eq '__BROKER_IP__|\$\{' /tmp/broker.conf; then + echo "broker.conf still contains an unresolved placeholder" >&2 + grep -nE '__BROKER_IP__|\$\{' /tmp/broker.conf >&2 + exit 1 +fi +exec sh mqbroker -n rocketmq-namesrv:9876 -c /tmp/broker.conf +``` + +脚本生成临时运行配置而不覆盖只读模板,并显式拒绝空值、`127.0.0.1`、`localhost` 和未展开占位符。 + +当前部署限定客户端与 Broker 同在 `infra-network`,因此 `ROCKETMQ_BROKER_IP` 固定填 `rocketmq-broker`(服务名)。当前 Compose 不映射 `10911/10909`,不得填写宿主机 IP;如未来确需跨主机客户端,必须先增加受防火墙限制的端口映射并重新完成网络验收,不能只改 `brokerIP1`。 + +`autoCreateTopicEnable=false` 是刻意的:Topic 必须由部署脚本显式创建,避免拼错 Topic 名时静默创建出一个无人消费的新 Topic。 + +**验收项**:Broker 起来后在 `infra-network` 内的容器中执行 `mqadmin clusterList -n `,确认输出的 Broker 地址是 `rocketmq-broker` 而非字面量或 `127.0.0.1`;再从 File Service 容器内确认 `rocketmq-broker:10911` 可达。这一步不通过,后面所有 MQ 功能都无从验证。 + +### 16.2 Topic 初始化容器 + +`deploy/docker-compose.rocketmq.yml` 的 `rocketmq-topic-init` 内联幂等执行: + +```bash +mqadmin updateTopic -n "$NAMESRV" -c BaiyueCluster -t "${PREFIX}baiyue-file-export-command-v1" -r 4 -w 4 +mqadmin updateSubGroup -n "$NAMESRV" -c BaiyueCluster -g "${PREFIX}baiyue-file-export-worker-v1" +mqadmin updateSubGroup -n "$NAMESRV" -c BaiyueCluster -g "${PREFIX}baiyue-file-export-dlq-handler-v1" +mqadmin updateTopic -n "$NAMESRV" -c BaiyueCluster -t "%DLQ%${PREFIX}baiyue-file-export-worker-v1" -r 1 -w 1 -p 6 +``` + +DLQ Topic 默认权限为只写(2),必须显式改为读写(6),否则无法消费 DLQ 排查问题。 + +初始化容器必须使用 `sh -eu`,并等待 `clusterList` 输出中明确出现 `BaiyueCluster` 后才执行上述命令;仅以 `clusterList` 退出码为 0 不能证明 Broker 已注册。任一初始化命令失败时容器按 `restart: on-failure` 重试,避免 NameServer 先就绪、Broker 后注册的首次部署竞态。 + +### 16.3 配置项 + +`file/src/main/resources/application.yml` 新增: + +```yaml +rocketmq: + name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876} + producer: + # 源码默认 test- 仅服务本地/测试;Compose 部署必须显式设置 MQ_TOPIC_PREFIX,生产传空字符串 + group: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-publisher-v1 + send-message-timeout: 3000 + retry-times-when-send-failed: 2 + retry-times-when-send-async-failed: 2 +spring: + cloud: + openfeign: + client: + config: + iamExportProviderClient: &export-provider-timeout # 覆盖全局 read-timeout:5000,见 §14 + connect-timeout: 3000 + read-timeout: 15000 + orderExportProviderClient: *export-provider-timeout + warehouseExportProviderClient: *export-provider-timeout + firstlegExportProviderClient: *export-provider-timeout +baiyue: + file: + export: + retention-days: ${FILE_EXPORT_RETENTION_DAYS:7} + instance-id: ${HOSTNAME:file-local} + consumer: + # 首次部署必须按 §17/§18 分阶段显式开启;缺少环境变量时保持关闭 + enabled: ${FILE_EXPORT_CONSUMER_ENABLED:false} + concurrency: ${FILE_EXPORT_CONSUMER_CONCURRENCY:4} + consume-timeout-minutes: ${FILE_EXPORT_CONSUME_TIMEOUT_MINUTES:90} + max-reconsume-times: 2 + publisher: + enabled: ${FILE_EXPORT_PUBLISHER_ENABLED:false} + poll-interval-ms: ${FILE_EXPORT_PUBLISHER_POLL_MS:5000} + batch-size: 50 + lease-seconds: 60 + reconciler: + enabled: ${FILE_EXPORT_RECONCILER_ENABLED:false} + poll-interval-ms: ${FILE_EXPORT_RECONCILER_POLL_MS:30000} + redispatch-grace-seconds: 60 + # 命令已发出多久仍未被执行才判定为丢失并补发,见 §8.5(1) + # 必须大于常态排队时长,且与 §13 的 export_task_queue_age_seconds 告警阈值(600s)一致 + sent-stale-seconds: ${FILE_EXPORT_SENT_STALE_SECONDS:600} + task: + lease-seconds: ${FILE_EXPORT_TASK_LEASE_SECONDS:300} + heartbeat-seconds: ${FILE_EXPORT_TASK_HEARTBEAT_SECONDS:60} + max-duration-minutes: ${FILE_EXPORT_MAX_DURATION_MINUTES:30} + max-file-size-mb: ${FILE_EXPORT_MAX_FILE_SIZE_MB:50} + rocketmq: + topic: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-command-v1 + consumer-group: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-worker-v1 + dlq-topic: "%DLQ%${MQ_TOPIC_PREFIX:test-}baiyue-file-export-worker-v1" + dlq-consumer-group: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-dlq-handler-v1 +``` + +`consumer.enabled` / `publisher.enabled` / `reconciler.enabled` 三个独立开关是**部署与回滚的关键**(§17、§18)。`instance-id` 用于 `lease_owner`,容器环境取 `HOSTNAME`。 +Publisher 的退避档位由实现固定为 5s / 15s / 1m / 5m / 15m,超过后固定 15m 持续重试;当前没有 `max-backoff-minutes` 配置项,避免文档出现不会被代码读取的伪配置。源码中的 `MQ_TOPIC_PREFIX:test-` 是本地安全默认值,部署环境仍必须由 Compose 的必填校验显式传入,见 §6.2 与 §18。 + +移除旧配置 `worker-delay-ms`、`stale-running-minutes`(由 Reconciler 参数取代)。 + +### 16.4 依赖变更 + +| 模块 | 变更 | +|---|---| +| `platform-bom` | 新增 `rocketmq-spring-boot-starter:2.3.3` 的版本管理(属性名建议 `rocketmq-spring.version`) | +| `common-core` | 新增包 `com.baiyue.common.core.export`:Provider 请求/响应 DTO、列定义与类型枚举、MQ 命令消息、注册表配置模型。均为 record,不引入新依赖(jackson 由 `spring-web` 传递已可用) | +| `file` | 新增 `rocketmq-spring-boot-starter` | +| `iam` | **无需改 pom**(已通过 `common-web` 传递依赖到 `common-core`),直接实现 Provider 接口 | +| `common-web` | 新增 `micrometer-registry-prometheus` | +| `firstleg` | **删除** 未使用的 `spring-cloud-starter-stream-rocketmq` | +| 根 `pom.xml` | 不变(不新增模块,仍为 18 个) | + +**不新建 `common-export-contract` 模块。** 关键约束:Provider 契约是 file 与 iam 双方共用的 HTTP 结构,若只放在 `file` 模块内,iam 就必须依赖 `file-service`,会把 POI、MyBatis、任务表实体全部拖入 iam 的 classpath,并形成业务服务反向依赖 file 的结构,违反 AGENTS.md 的“服务间调用先找调用方 `client/`、不共享领域 Entity”。放入 `common-core` 既满足双方共用,又不新增模块——iam 与 file 均已通过 `common-web` 传递依赖到 `common-core`,两边 pom 都不用动。 + +## 17. 实施与切换 + +### 阶段 1:基础设施与契约(无行为变更) + +1. 测试环境起 NameServer + Broker,执行 Topic 创建脚本,`mqadmin topicList` 核验。 +2. `common-core` 新增 `com.baiyue.common.core.export` 包(契约 record),`platform-bom` 登记 rocketmq starter 版本。 +3. `common-web` 加 Prometheus registry,核验 `/actuator/prometheus` 返回指标。 +4. 按 §7.3 与 §8.3.1 完善 `file/src/main/resources/db/file.sql`:补齐 `file_export_task` 的 8 个字段与 2 个索引,加入 `file_export_outbox` 与 `file_export_task_guard` 建表语句。测试环境确认任务数为 0 后删除这三张导出表并重新执行,核验方式见 `deploy/sql/README.md`。 +5. 删除 `firstleg` 的死依赖。 +6. 现有 `ExportTaskWorker` 保持运行,行为不变。 + +### 阶段 2:Provider 与 Writer(仍走旧调度) + +1. IAM 实现 `/internal/exports/v1/CUSTOMER_LIST/batches`,含租户校验与 `snapshotToken`。 +2. File 实现通用 `ExportProviderClient` 与通用 XLSX Writer、`ExportStorage` 接口、Nacos 注册表与启动校验。 +3. `CustomerExportTaskHandler` 改为走通用链路,删除硬编码列定义与 `IamExportClient`。 +4. 仍由 `ExportTaskWorker` 调度执行,**先验证数据正确性**:导出结果与旧版逐列比对一致。 + +这一步把“数据链路正确性”与“调度机制正确性”分开验证,任一环节出问题都能独立定位。 + +### 阶段 3:MQ 调度上线 + +1. 部署带 Publisher + Consumer + Reconciler 的 File Service,三个开关**全部关闭**,确认服务健康。 +2. **停用 `ExportTaskWorker.processNext()`**(保留 `expire` 逻辑迁入 Reconciler)。这里不做“新旧双轨执行”——旧 `claim()` 既不检查 `next_retry_at` 也不检查 `dispatch_version`(`ExportTaskRepository.java:56`),双轨期间它会提前执行退避窗口内的任务并绕过版本校验。双轨的复杂度换不来任何安全性。 +3. 开启 `reconciler.enabled` → 观察 `export_reconciler_action_total{action="redispatch"}`,确认存量 PENDING 被补发 Outbox。 +4. 开启 `publisher.enabled` → 观察 Outbox `SENT` 增长、Broker 消息堆积。 +5. 开启 `consumer.enabled` → 任务开始执行,观察端到端。 +6. 逐项跑完 §19 故障注入清单。 + +分步开启的价值:每一步只引入一个变量。若第 4 步后消息发不出去,问题必定在 Publisher 或 Broker,与 Consumer 无关。 + +### 阶段 4:清理 + +1. 删除 `ExportTaskWorker`、`firstPendingId`、`requeueStale` 及其测试。 +2. 更新 `docs/公共导出与下载中心接入说明.md` 与 `AGENTS.md` 的 `file/` 模块条目。 +3. 补充新业务类型接入清单(Provider 实现 + Nacos 注册两步)。 + +### 回滚 + +MQ 调度启用后的安全回退方式是**关配置开关暂停调度**,不需要回滚数据库结构;这不是恢复旧调度器: + +- 关 `consumer.enabled`:停止领取新任务;尚未领取的任务保持 `PENDING`,在途 `RUNNING` 任务按优雅停机完成,未完成的由租约超时后 Reconciler 恢复。 +- 同时关闭 `publisher.enabled` 与 `consumer.enabled`:停止发送新命令并停止执行;Outbox 与任务状态保留,重新开启后自动追平。 +- **禁止恢复旧 `ExportTaskWorker`。** 它不检查 `next_retry_at` 与 `dispatch_version`,会绕过退避和版本保护;阶段 4 删除后也不再具备该回滚路径。如需回滚应用版本,必须先确认旧版本兼容当前表结构与任务状态,并按独立发布回滚方案执行,不能仅靠开关假定回到阶段 2。 +- 数据库中的任务与状态始终完整,Outbox 行不会丢。 + +## 18. 部署顺序 + +```text +RocketMQ NameServer/Broker 就绪 +-> Topic / ConsumerGroup / DLQ 创建 +-> 测试环境执行 file.sql(任务表 + Outbox 表) +-> Nacos 发布导出类型注册表 +-> 业务 Provider(IAM)上线 +-> File Service 上线(三开关全关) +-> 开 Reconciler -> 开 Publisher -> 开 Consumer +-> Admin 验收 +``` + +环境变量需在 `deploy/docker-compose.yml` 与 Jenkins 中透传:`ROCKETMQ_NAME_SERVER`、`MQ_TOPIC_PREFIX`、`ROCKETMQ_BROKER_IP`、`FILE_EXPORT_CONSUMER_ENABLED`、`FILE_EXPORT_CONSUMER_CONCURRENCY`、`FILE_EXPORT_PUBLISHER_ENABLED`、`FILE_EXPORT_RECONCILER_ENABLED`。Compose 会在单服务部署前插值整份文件,因此不能在 File 服务块使用 `${MQ_TOPIC_PREFIX?...}` 阻断 Auth/IAM 等无关服务;`deploy/scripts/deploy-services.sh` 只在部署 File 时执行环境级强校验。`MQ_TOPIC_PREFIX` 对 File 不得省略:测试必须显式设为 `test-`,生产必须显式设为空字符串。测试与生产的 `FILE_EXPORT_CONSUMER_CONCURRENCY` 初始值均显式设为 `4`,不只依赖应用默认值;三个启用开关首次上线均显式设为 `false`,再按本节顺序逐项改为 `true`。File 容器需要 `HOSTNAME` 可用(Docker 默认提供)。 + +## 19. 测试与验收门槛 + +### 19.1 单元测试(必须全绿) + +- 任务与 Outbox 同事务创建;任务插入失败时 Outbox 不落库。 +- 并发创建:两请求同时命中重复提交或活动任务上限时,由 `file_export_task_guard` 串行复核,最终只创建允许数量的任务与 Outbox。 +- Publisher:到点领取、未到点不领取、并发领取只有一个成功、发送成功标记 `SENT`、发送失败退避、**连续失败 30 次后仍保持 `PENDING` 继续重试(无终态放弃)**、租约超时可被重新领取。 +- Consumer 抢占:**§8.2 表格的七个分支逐一覆盖**,每个分支都必须断言返回 `CONSUME_SUCCESS`。 +- Consumer:执行成功、永久失败直接 `FAILED`、临时失败递增版本并写新 Outbox、达到 `max_attempts` 转 `FAILED`。 +- **watchdog 续租覆盖写盘阶段**(§8.3 的核心):模拟 Provider 拉取结束后 `write()`/压缩耗时超过租约时长,断言 watchdog 期间持续续租、任务未被 Reconciler 剥夺、最终 `SUCCEEDED`。这是第一版按批次续租时会失败的场景。 +- watchdog:租约被剥夺时置中止标志,执行线程在下一个可中断点中止、删除半成品文件、不写任务表;watchdog 自身异常也置中止标志。 +- deadline:抢占时按数据库时钟写入;超过 deadline 后 watchdog 不再续租并触发中止;Java 预检查通过但最终提交 SQL 执行时已超时的竞态必须提交失败、删除文件并进入超时重试。 +- **时间基准**:所有租约与退避判断走数据库 `CURRENT_TIMESTAMP(3)`;构造实例本地时钟快 5 分钟的场景,断言不会提前抢占他人租约。 +- 文件大小:写入超过 50MB 时在**写入过程中**抛错中止,断言磁盘上不存在超限的完整文件。 +- 结果提交:版本已变时提交失败并删除孤立文件。 +- Reconciler:四项职责各自幂等;`redispatch` 只恢复或补写同版本 Outbox,`lease_recover` 才递增版本且达上限转 `FAILED`。 +- **Reconciler 不得跳过退避窗口**:构造一个刚自动重试、`next_retry_at` 在 15 分钟后的 `PENDING` 任务,其 Outbox 行已为 `SENT`;断言 Reconciler 连续多轮都不动它,直到 `next_retry_at` 到点后 60 秒才补投。 +- **Reconciler 补投不递增任务版本**(§3 原则 5,最关键的一条):构造一个 `PENDING` 任务、其 Outbox 行为 `SENT` 且 `sent_at` 已超窗口;断言 Reconciler 把该行改回 `PENDING` 且 `dispatch_version` **保持不变**。 +- **排队任务不被饿死**(阻断项 1 的回归测试):模拟并发已满、任务在队列中等待超过 `sentStaleSeconds`;断言补投后原命令仍能成功抢占执行(版本未变故未失效),任务最终 `SUCCEEDED`。 +- **补投两种情形**:无 Outbox 行 → 补写同版本新行;`SENT` 超窗口 → 原行改回 `PENDING`。两者都断言版本不变。 +- **补投不受 `max_attempts` 约束**:`attempt_count` 已达上限的 `PENDING` 任务,其丢失的命令仍应被补投(补投不是执行尝试)。 +- **重复命令幂等**:同版本两条命令并发消费,只有一个抢占成功执行,另一个见 `RUNNING` 后确认消费;最终只有一个有效文件。 +- **执行恢复受重试上限约束**:`RUNNING` 租约超时且 `attempt_count >= max_attempts` 时任务应置 `FAILED`;不得把这条约束误用于 `PENDING` 任务的同版本消息补投。 +- Outbox `attempt_count` 与任务 `attempt_count` 相互独立:任务重试写入的新 Outbox 行,其 `attempt_count` 从 0 开始。 +- 人工重试:只允许 `FAILED`、停用类型拒绝、递增版本、清空上次现场。 +- 注册表:白名单外服务名启动失败、路径格式非法启动失败、路径与 key 不一致启动失败、热刷新非法配置保留旧值。 +- Provider 客户端:租户字段必填、`schemaHash` 变化判永久失败、游标未推进判临时失败、超时判临时失败。 +- XLSX Writer:六种类型写入正确单元格类型、公式注入前缀转义、超长截断、多 Sheet 切换、**真实打开生成的 xlsx 并逐格校验**。 +- Producer:四个定位字段同时写入 payload 与 RocketMQ User Properties,且与 Outbox 列一致。 +- 主 Consumer 反序列化失败时不确认消费;DLQ Handler 使用原始 `MessageExt` 属性自动执行 `QUARANTINED + FAILED` 同事务更新,任一步失败则回滚且不确认;旧版本、eventId 不匹配或缺属性消息不得误伤当前任务。 +- Reconciler 永不补投 `QUARANTINED`;隔离任务人工重试生成新版本 Outbox,旧隔离行保持不变。 + +### 19.2 集成测试(连测试环境真实 RocketMQ) + +不引入 Testcontainers(§21 决策 8)。集成测试通过 profile 连接测试环境 Broker,默认在 CI 中跳过,由人工或独立 Job 触发: + +真实任务的创建、终态轮询、下载和 XLSX 签名校验统一由 `scripts/export-center-test-env-acceptance.ps1` 执行;使用 `-CreateOnly` 与 `-TaskIds` 可把故障期间创建和基础设施恢复后的追平验证拆成两段。脚本不拥有部署或停服权限,Broker/File/IAM/MySQL 的故障注入仍由有权限的运维或独立 Job 执行。 + +- 数据库提交后停 Broker,恢复后任务仍被执行(Outbox 退避生效)。 +- Publisher 发送成功但未标记 `SENT`(注入异常),重复投递只产生一个有效文件。 +- Consumer 执行中 `kill -9`,租约超时后 Reconciler 恢复并最终成功,且不留孤立文件。 +- Provider 503 两次后成功,`attempt_count` 与退避符合预期。 +- Provider 持续 503 至 `max_attempts`,任务 `FAILED`,`export_task_completed_total{status="FAILED"}` 增长,**DLQ 无消息**。 +- 手工投递一条无法反序列化但携带完整 User Properties 的消息,确认进入 DLQ 后被 DLQ Handler 自动隔离:Outbox 为 `QUARANTINED`、任务为 `FAILED`、告警触发,等待超过 `sentStaleSeconds` 后也不会再次进入 DLQ。 +- 投递缺少定位属性的畸形 DLQ 消息,确认 Handler 记录 CRITICAL 与 Broker messageId 后确认,不形成二级 DLQ;不得误伤当前任务版本。 +- 双实例并发消费同一消息,只生成一个有效文件、只有一个 `SUCCEEDED`。 + +### 19.3 测试环境验收 + +- Admin 客户列表创建真实任务,下载中心显示排队 / 执行中(进度递增)/ 重试 / 完成 / 过期。 +- 下载 XLSX 与筛选条件一致,日期与金额列在 Excel 中可排序计算。 +- 导出过程中新增一条客户数据,确认不混入本次结果(快照生效)。 +- 重启 File、停 Broker、停 IAM 各一次,恢复后任务无丢失。 +- 普通用户无法访问他人任务;无 `platform:customer:export` 权限无法创建。 +- 20 万行导出跑通,观察内存、文件大小、耗时在 §14 上限内。 +- 连续提交 10 个任务,确认按并发 4 排队执行,Provider 未被打满。 +- 同时执行 4 个 20 万行导出,确认整机 CPU 持续低于 70%、File Service GC 后堆占用低于 65%、磁盘无持续满负载、Provider P95 < 5 秒且 P99 < 10 秒,IAM 普通接口 P95 相比无导出基线恶化不超过 20%。任一项不满足则生产回退并发 2,不以机器总内存充足替代链路压测。 + +## 20. 通过标准 + +- 不采用“数据库提交后直接发送 MQ”的非可靠方案。 +- **`dispatch_version` 只因执行尝试递增,消息补投不递增版本**(§3 原则 5)——这是本轮评审的核心修正,防止排队任务饥饿与 DEAD 无限循环。 +- Outbox 无终态失败:发送永不放弃,靠告警而非状态机驱动人工介入。 +- 租约续期由独立 watchdog 覆盖全执行期,包含最终写盘与压缩阶段。 +- 所有租约与退避判断使用数据库时钟,不使用实例本地时钟。 +- 任务状态不放入 Redis,Redis 不承担重试队列。 +- 业务服务不自行实现任务、Excel、存储和下载中心。 +- File Service 不跨库读取业务数据。 +- Provider 契约包含 `tenantId` / `appCode` / `requestedBy`,且 Provider 侧强制按其过滤。 +- 权限与定义在热更新下的取值时点已逐项明确(§10.4),无未定义行为。 +- 存储的多实例边界已明确(§11.1),不把宿主机 bind mount 当作跨主机共享存储。 +- MQ 不承担延迟与业务重排;Reconciler 是唯一重派通道;DLQ 只承载损坏消息。 +- Outbox 与任务均有租约,无“领取后崩溃即永久卡死”的路径。 +- 至少一次投递、幂等、租约、Outbox、Reconciler、DLQ、指标与告警全部纳入订单与客户实现。 +- 新业务类型能通过 Provider 契约 + 受控注册接入,不复制客户导出代码。 + +## 21. 已定型决策 + +以下决策已在本文中定型,不再作为评审开放项。列出理由以便后续维护者理解取舍。 + +| # | 决策 | 取值 | 理由 | +|---|---|---|---| +| 1 | 订单与客户是否含 MQ | 含 | 业务方已定。可靠性由数据库承担,MQ 引入不降低稳定性 | +| 2 | MQ 客户端 | `rocketmq-spring-boot-starter` 2.3.3,Remoting 协议 | 需要显式控制消费返回语义;不引入 Proxy 少一个可失效组件;同时删除 `firstleg` 的 Stream 死依赖。Boot 3.2.9 兼容性与 `rocketmq-client 5.3.1` 传递版本已实证核验,见 §6.1 | +| 3 | Outbox 是否抽公共模块 | 订单与客户不抽,`file_export_outbox` 独立建表并作为全仓基线 | 订单与客户只有一个真实使用者,过早抽象会把接口定错。`order` 的 Outbox 目前**根本没有发布器**,是独立待修问题;后续收敛时以本表结构为准(含 `lease_owner`/`lease_until`/`next_retry_at`),命名统一为 `<模块>_outbox` | +| 4 | 契约放哪 | 放入 `common-core` 的 `com.baiyue.common.core.export` 包,**不新建模块**,模块总数仍为 18 | 契约是 file 与 iam 双方共用的 HTTP 结构。放 `file` 内会迫使 iam 依赖 `file-service`(拖入 POI/MyBatis/任务表实体,且业务服务反向依赖 file);新建模块则多一个模块。放 `common-core` 两全:双方已通过 `common-web` 传递依赖到它,pom 都不用改。与 `common-event-contract` 不合并——后者是跨区域事件信封(含 `sourceRegion`/`payloadHash` 等无关字段),强行复用会互相牵制 | +| 5 | Prometheus | 订单与客户补 `micrometer-registry-prometheus`;服务端由运维落地 | 无 registry 则 §13 全部指标无处可采,等于没有可观测性 | +| 6 | 文件存储 | 订单与客户共享目录挂载 + `ExportStorage` 接口抽象 | 1000 用户、7 天保留、单文件 ≤50MB,总量在 GB 级,共享目录足够。引入 MinIO 多一个有状态组件与备份负担。接口就位后替换不改调用方 | +| 7 | 导出类型注册 | Nacos 受控配置 + 代码内置服务白名单 + 启动强校验 | 纯 Nacos 有 SSRF 面;纯代码注册每加类型都要发版。两者结合:配置只能从白名单里选 | +| 8 | Testcontainers | 不引入,集成测试连测试环境 Broker | 全仓无 Testcontainers 先例,RocketMQ 容器需同时起 NameServer+Broker 并处理 `brokerIP1`,投入产出比低于直接用测试环境 | +| 9 | 新旧双轨 | 不双轨,阶段 3 直接停用旧 Worker | 旧 `claim()` 不识别 `next_retry_at` 与 `dispatch_version`,双轨会提前执行退避中的任务。回滚靠配置开关,不靠双轨 | +| 10 | Consumer 执行模型 | 消费线程同步执行,并发 4,`consumeTimeout` 90 分钟 | 8C32G 共享部署环境下以 4 作为压测起始值;避免自建线程池的“队列满怎么办”与“已抢占无线程执行”两个失败模式;MQ 队列本身就是背压 | +| 11 | 断点续传 | 不做,每次从零重写 | 20 万行 XLSX 生成在分钟级,续传的状态管理复杂度远超收益 | +| 12 | 默认参数 | `max-rows` 20 万、单文件 50MB、并发 4、租约 300 秒、`max_attempts` 3、退避 1m/15m | 见 §14、§22;生产上线前必须通过 4 个最大任务并发压测 | +| 13 | 数据库变更方式 | 开发阶段直接完善 `file/src/main/resources/db/file.sql`,测试环境 `DROP TABLE` 后重建,**不写增量迁移脚本** | 导出功能尚无生产数据,增量脚本的复杂度换不来价值。**限定条件**:一旦上生产、表内有真实数据,后续变更必须回到 AGENTS.md 的可重复执行增量模式 | +| 14 | 版本递增的唯一来源 | 仅执行失败重试、租约恢复、人工重试三处;**补投不递增** | 第二轮评审的核心修正。混淆投递失败与执行失败会造成排队任务饥饿(活性错误)与 DEAD 无限循环,见 §3.1 | +| 15 | Outbox 失败终态 | 取消 `DEAD`,发送无限退避重试 + 告警 | 设终态会让 Broker 长期故障时任务静默停滞,违背 §2.1 的“提交成功的任务最终一定被调度”。靠告警驱动人工介入,Broker 恢复后自动追平 | +| 16 | 续租方式 | 独立 watchdog 线程按 60 秒周期,覆盖全执行期 | 挂在批次循环里会漏掉最终 `write()`/压缩/落盘阶段,慢磁盘下大文件会在临成功时被误判失联并反复重跑 | +| 17 | 时间基准 | 全部走数据库 `CURRENT_TIMESTAMP(3)` | 实例时钟偏差会导致提前抢占(两实例同写一任务)或延迟恢复 | +| 18 | 多实例部署 | 代码支持,**订单与客户部署按单节点**;需多实例时先上跨主机共享卷 | 宿主机 bind mount 只在同主机内共享,跨主机会导致下载随机 404 与永久孤儿文件(§11.1) | +| 19 | 快照强度 | 订单与客户只保证“不混入导出开始后新增的数据”,不冻结修改与删除 | 最大 ID 游标的固有限制。对客户列表可接受;需要完整快照的类型由该 Provider 自行实现临时表或一致性读(§10.2) | +| 20 | 权限取值时点 | 创建与人工重试时校验,执行阶段不校验;不存权限快照 | 执行在后台无用户会话,无法重新鉴权。已受理即视为已授权,代价与已知缺口写明于 §10.4 | +| 21 | 毒消息处置 | 独立 DLQ Handler 自动将匹配的 Outbox 置 `QUARANTINED`、任务置 `FAILED` | 人工隔离无法保证在 Reconciler 10 分钟补投窗口内完成;定位字段放 User Properties,避免依赖损坏 payload | + +仍需运维/管理侧确认(不影响设计定型): + +- 测试与生产环境 Broker 的机器资源与 NameServer 地址。 +- Prometheus 服务端与告警通道的落地责任人。 +- DLQ 的值班责任人(Outbox 不再有终态失败,靠积压告警驱动,无需单独值班)。 + +## 22. 容量基准与放大路径 + +本文参数按平台总用户约 1000、导出为低频后台操作取值。估算:日导出任务 < 50,峰值并发提交 < 10,单任务行数通常 < 5 万。 + +| 维度 | 当前取值 | 何时需要调整 | 调整方式(不需改架构) | +|---|---|---|---| +| Consumer 并发 | 4 | 排队时长告警持续触发,且 §14 的 CPU、堆、磁盘、Provider 与 IAM 延迟门槛均有余量 | 单实例可直接压测更高 `concurrency`;增加 File 实例前同时复核 Topic 队列分配并启用共享存储 | +| Broker | 单节点 ASYNC_MASTER | 对消息可靠性要求提高 | 加 Slave 或 DLedger;Outbox 保证不丢任务,Broker 丢消息由 Reconciler 补发 | +| Topic 队列数 | 4 | File 实例数接近/超过队列数,或出现队列分配不均、单队列热点 | 用 `mqadmin updateTopic -r/-w` 扩容;并发消费下队列数不需要等于线程总数 | +| 存储 | 共享目录 | 总量超过单机磁盘或需异地备份 | 实现 `ExportStorage` 的 MinIO/OSS 版本,替换 Bean | +| `max-rows` | 20 万 | 出现更大导出需求 | 调注册表;超过 100 万需考虑分文件打包(新需求,非本文范围) | +| Reconciler 周期 | 30 秒 | 任务量增长使单轮扫描变慢 | 缩小 `LIMIT` 并提高频率,或加分片条件 | + +**关键点:以上任何调整都只涉及配置或单个实现类,不触及 Outbox、幂等、租约、Reconciler 这套核心机制。** 这是本文追求“不需要改第二次”的具体含义——不是参数永不变,而是机制不用重做。 + +## 23. 关联文档 + +- 当前已实现版本的接入说明:[公共导出与下载中心接入说明](./公共导出与下载中心接入说明.md)(阶段 4 完成后需同步更新) +- 统一响应与 HTTP 200 约定:[统一响应约定改造方案-HTTP200](./统一响应约定改造方案-HTTP200.md) +- 目录与服务边界:[头程项目目录结构与服务说明](./头程项目目录结构与服务说明.md) +- 本模块表结构事实来源:`file/src/main/resources/db/file.sql`(开发阶段直接维护建表语句,不走增量脚本,见 §7.3) +- 生产化后的增量执行边界:`deploy/sql/README.md` diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md new file mode 100644 index 000000000..9c003b138 --- /dev/null +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -0,0 +1,151 @@ +# 同步调用依赖与失败传播治理基线 + +## 目的与口径 + +本基线覆盖当前官方 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 秒 | +| `HttpCustomerVisitFileGateway` | `CustomerVisitService → CustomerVisitFileGateway → /internal/file/v1/business-files/**` | 附件上传没有幂等键,禁止自动重试;元数据读取可重复 | 连接 3 秒、读取 30 秒 | +| `HttpProcurementFileGateway` | `OverseasProcurementService → ProcurementFileGateway → /internal/file/v1/business-files/**` | 采购凭证和签收文件上传没有幂等键,禁止自动重试 | 连接 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 498508392..522ab5e45 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -1,8 +1,16 @@ # 头程项目目录结构与服务说明 +## 文档定位与读取时机 + +本文是面向架构理解和运维协作的服务总览,主要说明技术基线、目录结构、服务端口、数据库归属、调用关系和启动部署拓扑。 + +- 普通单模块开发不需要预先通读本文;先读仓库根 `AGENTS.md` 和目标模块 `AGENTS.md`。 +- 涉及跨服务设计、模块拆分、端口或数据库归属、根目录调整、本地启动和部署拓扑时再读本文。 +- Controller、Service、SQL、测试和当前实现入口以各级 `AGENTS.md` 与代码为准;本文不维护文件级快速索引,避免与执行指引重复和漂移。 + ## 1. 项目定位 -`baiyue-firstleg` 是国内头程业务后端仓库,采用 Maven 多模块单仓结构。仓库内包含 7 个公共模块和 11 个可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -16,7 +24,7 @@ - SpringDoc OpenAPI / Swagger - GitLab、Jenkins、Docker Compose -本仓库只保存头程代码。尾程业务位于独立 Git 仓库,不允许把尾程源码、数据库或配置重新放回本仓库。 +国内与海外服务共享源码仓库和公共制品,但保持独立数据库、Nacos 命名空间与部署边界;跨区域业务协作使用版本化 Outbox/Inbox 事件,不共享业务数据源。 ## 2. 根目录结构 @@ -31,12 +39,16 @@ baiyue-firstleg/ ├─ common-event-contract/ # 国内外跨区域事件统一契约 │ ├─ gateway/ # 统一 API 网关 +├─ gateway-overseas/ # 海外区域 API 网关 ├─ auth/ # 统一登录与账号凭据 ├─ iam/ # 租户、成员、角色、权限、企业审核 ├─ order/ # 头程订单中心 -├─ firstleg/ # 头程履约任务与分配 -├─ warehouse/ # 仓库与往来单位主数据 -├─ file/ # 文件中心 +├─ firstleg/ # 履约任务、入库、测量与计费 +├─ warehouse/ # 仓库主数据、托盘与库位资源 +├─ finance/ # 财务账期、账单、收付款与核销 +├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务 +├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁 +├─ file/ # 资质、导出与测量图片文件中心 ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 ├─ integration-service/ # 第三方平台集成适配 @@ -78,134 +90,79 @@ 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` | 履约任务、操作分配和分配历史;不是订单主数据服务 | +| `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务与分配;入库单、测量审核、FBA 计费、费用发布和完成入库编排;不是订单主数据服务 | | `order` | `order-service` | 9204 | `baiyue_order` | 订单草稿、编辑、提交、详情、状态历史、货物、往来单位快照、内部备注和 Outbox | -| `warehouse` | `warehouse-service` | 9205 | `baiyue_warehouse` | 仓库、仓库地址、服务能力、启停校验,以及往来单位查询;往来单位不单独拆服务 | -| `file` | `file-service` | 9206 | `baiyue_file` | 文件存储与鉴权;当前主要实现企业资质、营业执照上传和预览 | -| `print` | `print-service` | 9207 | `baiyue_print` | 打印中心边界已经建立;当前主要是服务骨架、健康检查和数据库结构,打印模板与任务仍需继续完善 | +| `warehouse` | `warehouse-service` | 9205 | `baiyue_warehouse` | 仓库、地址、服务能力、往来单位、快递公司,以及入库测量与计费托盘、库位、预占和占用;往来单位不单独拆服务 | +| `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 和接收结果 | -| `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;当前配置默认关闭业务审计发送,可按环境开启 | +| `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. 各服务文件夹说明 ### 5.1 `gateway/`:统一入口 -主要内容: - -- `config/`:网关安全、CORS、访问域规则和 Redis 会话过滤 -- `filter/`:可信身份请求头处理 -- `application.yml`:本地默认路由与安全配置 -- `application-cn.yml`:国内环境路由和 Swagger 聚合配置 - -前端只应访问 Gateway,不应直接暴露各业务服务端口。网关负责入口认证和路由,业务服务仍需执行租户、数据范围和按钮权限检查。 +Gateway 负责统一路由、CORS、入口认证、可信身份头和 Swagger 聚合。前端只访问 Gateway,不直接暴露业务服务端口;业务服务仍需执行租户、访问域、数据范围和按钮权限校验,不能把网关认证视为最终授权。 ### 5.2 `auth/`:统一登录与账号凭据 -主要内容: - -- `controller/AuthController`:登录、刷新 Token、退出、注册入口 -- `controller/InternalAccountController`:IAM 调用的账号管理接口 -- `service/AuthService`:认证和 Token 生命周期 -- `service/AccountManagementService`:密码修改、密码重置和账号状态处理 -- `service/MerchantRegistrationService`:商户注册协作 -- `client/`:向 IAM 获取授权快照和企业注册结果 -- `resources/db/`:账号和刷新 Token 表结构、一期初始化账号 - -密码散列、登录失败锁定和刷新 Token 属于 Auth。成员资料、角色和权限属于 IAM,不能放入 Auth。 +Auth 负责登录、Token 生命周期、商户注册协作、账号密码和账号状态。密码散列、登录失败锁定和刷新 Token 属于 Auth;成员资料、角色、权限和企业审核属于 IAM,两者通过内部接口协作,不共享数据库。 ### 5.3 `iam/`:租户、成员和权限中心 -主要内容: - -- `MerchantTenantController`:商户端企业、成员相关接口 -- `PlatformTenantController`:平台端企业审核和管理接口 -- `ProfileResourceController`:个人资料、企业资料和资质资源 -- `RbacController`:角色、权限资源、启停和角色授权 -- `InternalAuthorizationController`:向 Auth/其他服务返回权限快照 -- `entity/`:租户、用户、成员关系、角色、权限、数据范围、企业地址和资质审核 -- `resources/db/`:IAM 主结构及一期成员、角色、权限、企业入驻扩展脚本 - -IAM 以 `tenant_id` 作为核心隔离维度,同时维护两套访问域:商户端和平台后台端。按钮权限通过 permission code / permissionIds 管理,不能只依赖前端隐藏按钮。 +IAM 负责租户、企业资料与审核、成员、平台用户、角色、菜单、按钮权限、数据范围和授权快照。它以 `tenant_id` 为核心隔离维度,并区分商户端与平台后台访问域;权限必须由后端校验,不能只依赖前端隐藏按钮。 ### 5.4 `order/`:订单中心 -主要内容: +Order 负责订单草稿、提交、平台处理状态、货物、参与方和主数据快照、状态历史、内部备注、幂等记录与 Outbox。订单保存关键业务快照,Warehouse 或往来单位后续变化不能改写历史订单。 -- `MerchantFirstLegOrderController`:商户端订单草稿、编辑、提交和查询 -- `PlatformFirstLegOrderController`:平台端订单查询和内部处理入口 -- `OrderApplicationService`:订单业务编排和状态转换 -- `OrderEligibilityGuard`:企业审核资格校验 -- `OrderMasterDataGuard`:仓库、往来单位和主数据有效性校验 -- `OrderScopeGuard`:租户与访问范围校验 -- `entity/`:订单、货物、订单参与方快照、状态历史、内部备注和 Outbox 事件 -- `client/`:调用 IAM、Warehouse 获取资格和主数据 - -订单保存的是仓库、往来单位等关键字段的业务快照,主数据后续修改不能改变历史订单。跨服务通知通过 Outbox/事件方式演进,不应让数据库跨库直连。 +企业资格和仓库主数据通过 IAM、Warehouse 内部接口校验;Firstleg 查询订单货物明细也通过受保护的内部接口完成。服务之间不建立跨库外键或跨库查询,通知优先通过 Outbox 和事件演进。 ### 5.5 `firstleg/`:头程履约中心 -主要内容: +Firstleg 承担订单提交后的履约任务、操作分配与历史,以及入库单查询、测量回传、人工测量、审核、异常放行、FBA 计费、费用发布和完成入库状态编排。 -- `PlatformFulfillmentAssignmentController`:平台端履约分配入口 -- `FulfillmentTaskService`:履约任务和分配业务 -- `FulfillmentTaskEntity`:履约任务 -- `AssignmentHistoryEntity`:分配历史 +订单主单和货物主数据仍属于 Order;托盘和库位资源属于 Warehouse;图片对象属于 File;OCR 供应商协议属于 Integration。Firstleg 只保存自身业务状态、快照和逻辑引用,通过内部 DTO 调用其他服务,不复制对方领域模型。 -该服务承担订单提交后的执行过程。订单主单及商户草稿仍属于 `order`,避免订单交易状态与履约作业状态混在同一张表。 +### 5.6 `warehouse/`:仓库与仓储资源 -### 5.6 `warehouse/`:仓库与往来单位 +Warehouse 负责仓库、地址、服务能力、往来单位、快递公司,以及入库测量与计费托盘、库位、预占和占用资源。往来单位只是 Warehouse 下的主数据配置,保存在 `baiyue_warehouse.wh_partner`,不创建独立服务和数据库。 -主要内容: - -- 商户端、平台端和内部仓库 Controller -- `WarehouseApplicationService`:仓库查询、地址、能力和状态校验 -- `PartnerQueryService`:往来单位只读查询和停用过滤 -- `WarehouseEntity`、`WarehouseAddressEntity`、`ServiceCapabilityEntity` -- `PartnerEntity`:往来单位 -- `warehouse-seed.sql`:测试环境仓库初始化数据 - -往来单位只是 Warehouse 服务下的主数据配置,数据库表为 `baiyue_warehouse.wh_partner`,不创建独立微服务和数据库。 +Firstleg 通过内部接口发起托盘库位预占、确认和释放,只保存逻辑引用与入库快照。容量、共享溢出、预占和占用一致性由 Warehouse 的数据库事务和条件更新最终保证。 ### 5.7 `file/`:文件中心 -主要内容: +File 负责企业资质文件、公共异步导出与下载、入库称重图片和第三方测量证据。企业资质审核及其业务元数据仍归 IAM;测量图片通过 `file_object` 保存文件元数据,并调用 Integration 执行受控重量 OCR。 -- `QualificationFileController`:商户端企业资质文件 -- `PlatformQualificationFileController`:平台端鉴权查看 -- `QualificationFileStorage`:存储抽象 -- `LocalQualificationStorage`:当前本地磁盘实现 -- `IamQualificationClient`:校验企业资质访问权限 - -生产环境应把存储实现替换为对象存储或共享文件存储。数据库保存元数据,文件内容不写入 Git,也不依赖应用容器临时目录。 +当前文件内容使用本地或共享磁盘实现,生产环境应演进为对象存储或稳定共享存储。文件内容不写入 Git,也不能依赖应用容器临时目录;入库和测量状态仍归 Firstleg。 ### 5.8 `print/`:打印中心 -当前目录已有独立应用、端口、数据库脚本、公共安全和审计依赖,但业务 Controller 目前只有健康检查。后续应在此目录增加: +Print 是无数据库的无状态 PDF 渲染服务。业务归属服务完成权限和数据真实性校验,组装完整 `labels[]` 后通过内部令牌调用 Print;Print 只在内存中生成一次性多页 PDF 并同步返回。本服务不保存模板、任务、历史或文件,不调用 File,不连接打印机,也不提供二次下载地址。 -- 打印模板版本管理 -- 打印任务和状态机 -- PDF/标签生成 -- 失败重试和幂等键 -- 打印文件与 File 服务的引用关系 - -订单服务只提交打印请求,不应自己生成面单或直接操作打印机。 +业务服务接入时先读 `print/README.md`,机器可读契约以 `print/src/main/resources/openapi/print-internal-v1.yaml` 为准。 ### 5.9 `audit-service/`:审计日志中心 -通过 `AuditIngestController` 接收审计事件,`AuditIngestService` 处理后写入 `baiyue_audit`。公共模块负责采集和脱敏,本服务负责集中持久化、查询扩展和留存策略。 +公共审计模块负责采集、脱敏和发送,Audit Service 负责集中接收并写入 `baiyue_audit`。平台端使用 `GET /api/platform/v1/audit/logs` 和 `GET /api/platform/v1/audit/logs/{id}` 查询,需具备 `platform:audit:list` 权限;列表不返回变更快照,详情仅返回已脱敏快照。审计链路不得记录密码、Token、第三方密钥或未经脱敏的客户敏感数据。 ### 5.10 `integration-service/`:第三方集成中心 -当前包含百度 OCR 和 Amazon Logistics/SP-API 适配器,以及第三方能力状态查询。服务不使用业务数据库,密钥必须通过 Jenkins Credentials、环境变量或 Nacos 密文注入。 +Integration 负责百度 OCR、重量识别和 Amazon Logistics/SP-API 等第三方协议适配及能力状态。入库测量与计费重量 OCR 由 File 调用,业务图片和入库状态不归 Integration;服务不使用业务数据库,真实外部调用默认关闭,密钥必须通过 Jenkins Credentials、环境变量或 Nacos 密文注入。 ### 5.11 `event-bridge-service/`:跨区域事件桥 -负责接收国内外服务器之间的业务事件,并通过 Inbox 表实现事件去重。它只传输必要业务事件,不共享头程和尾程数据库。事件载荷遵循 `common-event-contract`,应包含事件 ID、来源区域、目标区域、业务键、版本、时间和 Payload 哈希。 +Event Bridge 负责接收国内外服务器之间的必要业务事件,并通过 Inbox 实现幂等。事件载荷遵循 `common-event-contract`,包含事件 ID、来源和目标区域、业务键、版本、时间及 Payload 哈希;头程与尾程不共享 MySQL、Redis、Nacos 或内部接口。 + +头程装柜完成事件以实际集装箱为顶层批次,`orders[]` 中每项是一张实际发运的来源入仓单;一个柜可包含多张入仓单。每个货物明细携带来源明细 ID、实际装运箱数和完整的国内箱唛池。国内不要求在装柜时逐箱扫描冻结,完整箱唛池可以大于本柜实际装运箱数;海外按实际首次扫描的箱唛确认归属和实收,未扫描箱唛保留给后续子订单。海外将同柜各来源入仓单分别生成入库计划,但以同一集装箱号关联,并且只允许同一目的仓的箱子合托。 ## 6. 配置、部署和辅助目录 @@ -217,7 +174,7 @@ config/nacos/ └─ cn/ # 国内服务器配置模板 ``` -每套目录包含公共配置 `baiyue-common.yml` 和 11 个服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 +`local/`、`cn/`、`overseas/` 按部署区域保存公共配置和该区域需要的服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 ### `deploy/` @@ -226,7 +183,10 @@ 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、端口映射 ``` @@ -234,10 +194,11 @@ deploy/ - `local-env.ps1`:本地服务环境变量 - `publish-local-nacos-config.ps1`:发布本地 Nacos 配置 -- `phase1-acceptance.ps1`:一期验收入口 +- `phase1-acceptance.ps1`:订单与客户验收入口 - `phase1-business-maturity-regression.ps1`:业务成熟度回归 - `phase1-onboarding-order-e2e.ps1`:企业入驻到订单端到端验证 - `order-warehouse-real-api-regression.ps1`:订单、仓库、往来单位真实 API 回归 +- `export-center-test-env-acceptance.ps1`:公共导出中心测试环境验收 ### `docs/` 与 `reports/` @@ -252,12 +213,16 @@ deploy/ ├─ IAM ── Auth(内部账号管理) ├─ Order ── IAM(企业资格) │ └─ Warehouse(仓库、往来单位) - ├─ Firstleg(履约任务与分配) - ├─ File ── IAM(资质文件鉴权) + ├─ Firstleg(履约、入库、测量审核与计费) + │ ├─ Order(订单货物明细) + │ ├─ Warehouse(托盘、库位预占与占用) + │ └─ File(第三方测量证据图片) + ├─ File + │ ├─ IAM(资质文件鉴权与登记) + │ └─ Integration Service(重量 OCR) ├─ Print └─ 平台内部能力 ├─ Audit Service - ├─ Integration Service └─ Event Bridge Service ── 国外尾程事件入口 ``` @@ -267,15 +232,48 @@ deploy/ 推荐顺序: -1. 外部基础设施:MySQL、Redis、Nacos。 -2. 核心认证:IAM、Auth。 -3. 业务主数据:Warehouse、File。 -4. 核心业务:Order、Firstleg、Print。 -5. 支撑能力:Audit、Integration、Event Bridge。 +1. 外部基础设施:MySQL、Redis、Nacos;启用公共导出时再准备 RocketMQ。 +2. 核心认证:Auth、IAM。 +3. 主数据与适配:Warehouse、Integration。 +4. 业务依赖:Order、File。 +5. 核心编排与支撑:Firstleg、Print、Audit、Event Bridge。 6. 最后启动 Gateway。 Nacos 服务发现允许部分服务交换顺序,但联调时按上述顺序更容易判断故障来源。 +### Auth/IAM RBAC 会话撤销发布验证 + +角色启停或角色权限全量变更会由 IAM 调用 Auth 的 +`POST /internal/auth/accounts/sessions/revoke`,批量撤销受影响成员的 Access/Refresh 会话。 +该契约发布时必须先部署 Auth,再部署 IAM,不得只更新 IAM。 + +发布后至少完成以下验证: + +1. 确认 Auth、IAM 使用同一提交对应的镜像版本,且 IAM 能通过服务发现和内部服务令牌访问上述 Auth 内部接口;404、连接失败、内部令牌不一致或非成功业务码均不得放行 IAM 角色变更。 +2. 为测试成员分配带订单查看权限的自定义角色,成员登录并确认订单列表可访问。 +3. 停用该角色必须返回业务成功;继续使用停用前的 Access Token 访问订单列表必须被拒绝,旧 Refresh Token 也必须失效。 +4. 成员重新登录后权限集合应为空;重新启用角色并再次登录后,原角色权限应恢复。 +5. 修改角色权限集合后重复验证旧 Access/Refresh Token 失效,避免 `grantPermissions` 路径遗留旧权限。 + +2026-08-08 测试环境曾出现版本不一致:停用已分配角色返回 HTTP 200、业务码 `S0503`, +提示“认证会话撤销失败,角色变更未生效”。证据报告为 +`reports/client-test-env-20260808021636-b5090589.json`,`requestId=client-e2e-d9e860c8-b061-43fd-b67b-196f76d95b3d`, +`traceId=f2812578869349b1b4393421498d05df`。该结果说明 IAM 已包含会话撤销调用,但 Auth +内部接口尚未部署或不可达;必须在 Auth 先行部署并通过连通性检查后重新执行上述验收。 + +本轮 Client 扩展测试的发布前最终基准为 +`reports/client-test-env-20260808022313-1798dc6c.json`:共 150 项,143 通过、5 失败、2 跳过。 +5 个阻断项分别为角色停用和角色权限收缩时 Auth/IAM 版本不一致导致 `S0503`、清空成员角色未撤销旧 JWT、 +缺少 `Idempotency-Key` 返回 `S0500`、跨客户资质文件读取返回 `B0001`。订单条件必填 `A0422`、 +已提交订单编辑 `A0409`、密码恢复和报告脱敏均已通过。后续发布验证应以该报告为对照,确认上述 5 项全部关闭。 + +部署完成后的最终验收报告为 `reports/client-test-env-20260808032107-59bca758.json`: +163 项全部通过,0 失败、0 跳过,`success=true`。发布前 5 个失败项和 2 个依赖跳过项均已关闭, +包括角色停用、角色权限收缩和成员角色清空后的旧会话撤销,缺少 `Idempotency-Key` 返回 `A0400`, +以及跨客户资质文件读取返回 `A0404`。角色停用后,旧 Token 查询实时权限也必须返回 +HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新会话查询停用角色的有效权限时才返回空集合。 +账号原密码恢复和报告脱敏检查同时通过。本报告作为本轮 Client 全量 HTTP 验收的最终通过基线。 + ## 9. 开发和拆分约束 - 每个业务服务只访问自己的数据库;禁止跨库 Join 和直接读取其他服务表。 @@ -283,6 +281,6 @@ Nacos 服务发现允许部分服务交换顺序,但联调时按上述顺序 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会自动重建全部 11 个服务。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 -- 头程与尾程保持两个 Git 仓库、两套数据库和两套运行环境。 +- 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 diff --git a/docs/数据库环境切换说明.md b/docs/数据库环境切换说明.md new file mode 100644 index 000000000..7828dadcf --- /dev/null +++ b/docs/数据库环境切换说明.md @@ -0,0 +1,62 @@ +# Nacos 环境切换说明 + +数据库、Redis 和服务地址均由所选环境的 Nacos 配置决定。本地启动时只切换 +Nacos 地址与 namespace,不再通过 `application-test.yml` 单独切换数据库。 + +## 环境映射 + +| Maven Profile | Nacos 地址 | Namespace | +| --- | --- | --- | +| `local` | `127.0.0.1:8848` | `public` | +| `dev` | `dev-nacos.buggoglobal.com:8848` | `DEV_NACOS_NAMESPACE` | +| `test` | `8.163.30.128:8848` | `d99d7998-5bb0-4ffe-a2a0-1a564ce14463` | + +本地 Nacos 使用 `nacos/nacos`。开发和测试密码不写入 Git,需在启动 IDEA +之前设置一次用户环境变量: + +```powershell +[Environment]::SetEnvironmentVariable('DEV_NACOS_PASSWORD', '<开发 Nacos 密码>', 'User') +[Environment]::SetEnvironmentVariable('TEST_NACOS_PASSWORD', '<测试 Nacos 密码>', 'User') +[Environment]::SetEnvironmentVariable('DEV_NACOS_NAMESPACE', '<开发海外 namespace>', 'User') +``` + +设置后完全退出并重新打开 IDEA,使 Maven 能读取新环境变量。 + +## IDEA 中切换 + +1. 在 Maven 面板的 `Profiles` 中,`local`、`dev`、`test` 只勾选一个。 +2. 重新加载 Maven 项目。 +3. 在目标服务的 Maven `Plugins > spring-boot > spring-boot:run` 启动。 + +例如海外查询链路需要分别启动 `gateway-overseas`、`lastleg-service` 和 +`warehouse-overseas-service`。已经运行的进程不会自动切换环境,切换 Profile +后必须停止并重新启动。 + +IDEA 普通绿色 Run 按钮不执行 Maven,因此不会读取 Maven Profile。需要继续使用 +绿色 Run 时,请在 Run Configuration 的 Environment variables 中设置 +`NACOS_SERVER_ADDR`、`NACOS_NAMESPACE`、`NACOS_USERNAME` 和 `NACOS_PASSWORD`。 +`gateway-overseas` 已默认启用 `overseas` Spring Profile。 + +## PowerShell 中切换 + +也可以在当前终端加载同一套变量,然后从同一终端启动服务: + +```powershell +. .\scripts\use-nacos-environment.ps1 -Environment local -Region overseas +. .\scripts\use-nacos-environment.ps1 -Environment dev -Region overseas +. .\scripts\use-nacos-environment.ps1 -Environment test -Region overseas +``` + +脚本读取 `DEV_NACOS_PASSWORD`、`TEST_NACOS_PASSWORD`,namespace 优先读取 +`*_OVERSEAS_NACOS_NAMESPACE`,未设置时读取 `*_NACOS_NAMESPACE`。命令前面的点和 +空格不能省略,否则变量只存在于脚本子进程中。 + +## 当前限制 + +- 测试 Nacos 的 `8848` 客户端端口已确认可连接。 +- 开发 Nacos 网页控制台可访问,但 `dev-nacos.buggoglobal.com:8848` 当前从本机 + 无法连接。需要开放 Nacos 客户端端口以及 Nacos 2.x/3.x 所需的 gRPC 端口, + 或提供一个客户端可访问的内部地址。 +- 国内和海外存在同名 Data ID,远程环境必须使用各自独立 namespace。开发海外 + namespace 尚未确认,因此通过 `DEV_NACOS_NAMESPACE` 在本机配置。 +- 不要同时勾选多个环境 Profile;Maven 对同名属性的最终取值不直观,容易连错环境。 diff --git a/docs/系统状态枚举说明.md b/docs/系统状态枚举说明.md new file mode 100644 index 000000000..08ede00e1 --- /dev/null +++ b/docs/系统状态枚举说明.md @@ -0,0 +1,186 @@ +# 系统状态枚举说明 + +## 使用范围 + +本文档说明当前后端实际使用的状态值。状态值是接口、数据库和前端联调契约,不能为了展示中文而修改其英文编码;前端展示应使用本说明中的中文含义。 + +代码中的 Java 枚举、状态常量和数据库状态字段均已补充中文注释。本说明用于集中查询,不替代各业务状态机的校验。 + +## 通用响应码 + +| 状态值 | 中文含义 | +| --- | --- | +| `200` | 请求成功。 | +| `A0400` | 请求参数不符合接口要求。 | +| `A0401` | 未登录、令牌无效或会话过期。 | +| `A0403` | 已登录但没有权限。 | +| `A0404` | 资源不存在或不在可访问范围。 | +| `A0409` | 状态、版本或幂等键冲突。 | +| `A0422` | 字段或业务前置校验失败。 | +| `A0429` | 请求频率或并发数量超限。 | +| `B0001` | 通用业务失败。 | +| `S0500` | 服务内部异常。 | +| `S0503` | 下游服务、数据库或基础设施暂不可用。 | + +## 账号和权限 + +| 字段 | 状态值 | 中文含义 | +| --- | --- | --- | +| 账号状态 | `ACTIVE` | 账号正常,可登录。 | +| 账号状态 | `DISABLED` | 账号已停用,不允许登录。 | +| 账号状态 | `LOCKED` | 账号已锁定,需要管理员解除。 | +| 客户资料状态 | `PROFILE_REQUIRED` | 客户资料尚未补全。 | +| 客户资料状态 | `PENDING` | 客户资料已提交,等待审核。 | +| 客户资料状态 | `ACTIVE` | 客户审核通过,可正常开展业务。 | +| 客户资料状态 | `REJECTED` | 客户资料审核驳回。 | +| 客户资料状态 | `DISABLED` | 客户已停用。 | +| 客户、成员、角色、权限状态 | `ACTIVE` | 已启用,可参与登录、授权或业务使用。 | +| 客户、成员、角色、权限状态 | `DISABLED` | 已停用,不可继续使用。 | +| 审核记录状态 | `PENDING` | 等待审核。 | +| 审核记录状态 | `APPROVED` | 审核通过。 | +| 审核记录状态 | `REJECTED` | 审核驳回。 | +| 审核记录状态 | `SUPERSEDED` | 已被更新后的审核记录替代。 | + +## 订单状态 + +| 状态值 | 中文含义 | +| --- | --- | +| `DRAFT` | 草稿,可继续编辑。 | +| `SUBMITTED` | 已提交,等待平台审核。 | +| `APPROVED` | 审核通过,可接单或进入履约。 | +| `ACCEPTED` | 平台已接单。 | +| `REJECTED` | 审核驳回,不允许进入履约。 | +| `CANCELLED` | 已取消,订单结束。 | +| `PROCESSING` | 履约处理中。 | +| `COMPLETED` | 订单完成。 | + +统一订单列表的主状态是展示分组,不等于订单表的内部状态:`PENDING_REVIEW`、`WAIT_MEASURE`、`WAIT_SPLIT`、`LOADING`、`LOADED`、`DELIVERED`。 + +## 仓库与库位 + +| 字段 | 状态值 | 中文含义 | +| --- | --- | --- | +| 仓库、往来单位、快递公司 | `ACTIVE` | 启用,可被下单和作业引用。 | +| 仓库、往来单位、快递公司 | `INACTIVE` | 停用,不允许新增业务引用。 | +| 托盘 | `AVAILABLE` | 可分配。 | +| 托盘 | `RESERVED` | 已预占,尚未确认占用。 | +| 托盘 | `OCCUPIED` | 已确认占用。 | +| 库位 | `AVAILABLE` | 可用。 | +| 库位 | `RESERVED` | 已预占。 | +| 库位 | `OCCUPIED` | 已占用。 | +| 库位 | `DISABLED` | 已停用,不允许分配。 | +| 库位预占记录 | `RESERVED` | 预占生效中。 | +| 库位预占记录 | `CONFIRMED` | 预占已转为正式占用。 | +| 库位预占记录 | `RELEASED` | 已释放。 | +| 库位预占记录 | `EXPIRED` | 预占超时失效。 | +| 库位占用记录 | `OCCUPIED` | 正在占用。 | +| 库位占用记录 | `RELEASED` | 已释放。 | + +## 入库 + +### 入库单主流程状态 + +| 状态值 | 中文含义 | +| --- | --- | +| `WAIT_MEASURE` | 等待录入或接收测量数据。 | +| `MEASURING` | 正在测量、识别重量或人工修正。 | +| `MEASURE_PENDING` | 测量已提交,等待审核。 | +| `MEASURE_REJECTED` | 测量审核被驳回,等待修正或异常放行。 | +| `WAIT_PRICING` | 测量通过或异常放行,等待计费。 | +| `FEE_CONFIRMING` | 已生成费用,等待费用确认和发送。 | +| `INBOUNDING` | 费用已确认,正在占用托盘、库位并完成入库。 | +| `WAIT_SPLIT` | 入库完成,可进入智能装柜或预装确认;费用状态独立维护。 | +| `LOADING` | 已进入装柜作业,PDA 可执行拣货。 | +| `LOADED` | 装柜完成,等待派送完成。 | +| `DELIVERED` | 已完成派送。 | +| `VOIDED` | 已作废,不允许继续处理。 | + +主链路为:`WAIT_MEASURE` -> `MEASURING` -> `MEASURE_PENDING` -> `WAIT_PRICING` -> `FEE_CONFIRMING` -> `INBOUNDING` -> `WAIT_SPLIT` -> `LOADING` -> `LOADED` -> `DELIVERED`。测量驳回走 `MEASURE_PENDING` -> `MEASURE_REJECTED`;异常放行后回到 `WAIT_PRICING`。 + +### 测量、重量和费用状态 + +| 字段 | 状态值 | 中文含义 | +| --- | --- | --- | +| 测量批次 | `DRAFT` | 后台人工测量草稿,可编辑。 | +| 测量批次 | `RECEIVED` | 已接收第三方测量回传,待平台提交审核。 | +| 测量批次 | `SUBMITTED` | 已提交测量审核。 | +| 测量批次 | `APPROVED` | 测量审核通过。 | +| 测量批次 | `REJECTED` | 测量审核驳回。 | +| 重量识别 | `PENDING_CONFIRM` | OCR 已识别有效数值,等待人工确认。 | +| 重量识别 | `NEEDS_MANUAL` | OCR 无效或不完整,必须人工录入或修正。 | +| 重量识别 | `CONFIRMED` | 重量已确认,可参与计费。 | +| 测量审核、费用审核 | `NOT_SUBMITTED` | 尚未提交审核。 | +| 测量审核、费用审核 | `PENDING` | 等待审核。 | +| 测量审核、费用审核 | `APPROVED` | 审核通过。 | +| 测量审核、费用审核 | `REJECTED` | 审核驳回。 | +| 费用状态 | `UNPRICED` | 尚未计费。 | +| 费用状态 | `CALCULATED` | 已计算费用,等待确认。 | +| 费用状态 | `CONFIRMED` | 费用已确认,可发送给客户。 | +| 费用状态 | `SENT` | 费用明细已发送给客户。 | +| 客户通知状态 | `NOT_CREATED` | 尚未创建通知任务。 | +| 客户通知状态 | `PENDING` | 已创建待处理通知。当前代码未发现发送完成后的独立状态更新。 | + +### 第三方回传和幂等记录 + +| 字段 | 状态值 | 中文含义 | +| --- | --- | --- | +| 第三方测量调用 | `PROCESSING` | 请求正在处理。 | +| 第三方测量调用 | `ACCEPTED` | 回传被接受,包含幂等重放成功。 | +| 第三方测量调用 | `REJECTED` | 回传校验或处理失败。 | +| 入库与计费幂等记录 | `PROCESSING` | 请求正在执行。 | +| 入库与计费幂等记录 | `COMPLETED` | 请求已成功完成,可返回幂等结果。 | + +## 国内货柜履约智能装柜和 PDA + +| 字段 | 状态值 | 中文含义 | +| --- | --- | --- | +| 货柜聚合 | `WAIT_LOADING` | 待装柜。货柜已创建但尚未创建装柜计划的派生状态,只在查询结果中返回,不写入装柜计划表。 | +| 装柜计划 | `EDITING` | 预装柜中,可维护货柜信息,并可增删未拣货明细。 | +| 装柜计划 | `LOADING` | 拣货中,已生成拣货任务;未拣货明细仍可追加、修改或删除。 | +| 装柜计划 | `COMPLETED` | 装柜完成。拣货完成后自动完成,计划、明细和货柜信息只读。 | +| PDA 拣货任务 | `LOADING` | 拣货作业进行中。 | +| PDA 拣货任务 | `COMPLETED` | 拣货已完成。 | +| PDA 拣货明细 | `PENDING` | 尚未开始拣货。 | +| PDA 拣货明细 | `PICKING` | 已拣部分数量,仍可继续拣货。 | +| PDA 拣货明细 | `PICKED` | 计划箱数已全部拣完。 | +| PDA 拣货明细 | `PARTIAL` | 只拣到部分箱数,剩余数量需要生成关联子入库单。 | +| PDA 拣货明细 | `SKIPPED` | 本次跳过,入库单回退为已入库并可重新参与智能装柜。 | +| 预装清单 | `PENDING` | 预装草稿,尚未开始确认。 | +| 预装清单 | `CONFIRMING` | 正在确认预装明细。 | +| 预装清单 | `CONFIRMED` | 预装已确认,可进入装柜作业。 | +| 新版拆单批次 | `PREPARED` | 已准备待应用。 | +| 新版拆单批次 | `APPLIED` | 已应用并生成关联子入库单。 | +| 新版拆单批次 | `CANCELLED` | 已取消,不能再次应用。 | +| 旧版拆单记录 | `LOCKED` | 已锁定待完成。 | +| 旧版拆单记录 | `COMPLETED` | 已完成并关联子入库单。 | + +货柜列表状态为:`WAIT_LOADING`(待装柜)-> `EDITING`(预装柜中)-> `LOADING`(拣货中)-> `COMPLETED`(装柜完成)。其中 `WAIT_LOADING` 是货柜没有装柜计划时的派生状态;装柜计划持久化状态只能单向推进:`EDITING` -> `LOADING` -> `COMPLETED`,没有撤回状态。`COMPLETED` 后不允许编辑或删除装柜明细。 + +## 文件、导出和审计 + +| 字段 | 状态值 | 中文含义 | +| --- | --- | --- | +| 导出任务 | `PENDING` | 等待执行。 | +| 导出任务 | `RUNNING` | 正在生成文件。 | +| 导出任务 | `SUCCEEDED` | 文件已生成,可下载。 | +| 导出任务 | `FAILED` | 生成失败,可按规则重试。 | +| 导出任务 | `EXPIRED` | 文件已过期。 | +| 导出消息 | `PENDING` | 等待投递。 | +| 导出消息 | `CLAIMED` | 已被工作线程领取。 | +| 导出消息 | `SENT` | 已成功投递。 | +| 导出消息 | `QUARANTINED` | 异常隔离,等待人工或补偿处理。 | +| 审计消息 | `PENDING` | 等待发送。 | +| 审计消息 | `SENDING` | 正在发送。 | +| 审计消息 | `SENT` | 已发送成功。 | +| 审计消息 | `DEAD` | 多次失败,进入死信补偿。 | + +## 第三方能力和 OCR + +| 字段 | 状态值 | 中文含义 | +| --- | --- | --- | +| 第三方能力 | `DISABLED` | 能力未启用。 | +| 第三方能力 | `NOT_CONFIGURED` | 已启用但密钥或配置不完整。 | +| 第三方能力 | `READY` | 已启用且配置完整。 | +| 单图 OCR | `RECOGNIZED` | 已识别到有效重量。 | +| 单图 OCR | `NO_WEIGHT_FOUND` | 已处理图片但未识别到可用重量。 | +| 单图 OCR | `FAILED` | 图片处理、供应商调用或解析失败。 | diff --git a/docs/统一响应约定改造方案-HTTP200.md b/docs/统一响应约定改造方案-HTTP200.md new file mode 100644 index 000000000..1a9b51e70 --- /dev/null +++ b/docs/统一响应约定改造方案-HTTP200.md @@ -0,0 +1,267 @@ +# 统一响应约定改造方案:HTTP 200 + body code + +> 状态:代码改造与后端全量测试已完成,待测试环境发布验证 +> 日期:2026-08-01 + +## 1. 最终约定 + +业务接口统一返回 HTTP `200`,调用方只按响应体判断业务结果: + +| 判断条件 | 含义 | +|---|---| +| HTTP `200` 且 body `code == "200"` | 成功 | +| HTTP `200` 且 body `code != "200"` | 业务失败,展示或记录 `message` | +| HTTP 非 `200` | 鉴权、协议、网关或基础设施异常,不属于普通业务失败 | + +`code` 是字符串。前端不得再把旧成功码 `"0"` 视为成功。 + +```json +{ + "code": "200", + "message": "success", + "data": {}, + "requestId": "...", + "traceId": "...", + "timestamp": "2026-08-01T00:00:00Z" +} +``` + +业务失败示例: + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{"code":"A0409","message":"Review already completed / 审核已完成","data":null} +``` + +## 2. 保留真实 HTTP 状态的边界 + +以下响应不强制改成 HTTP 200: + +- 网关鉴权和 Servlet Security 入口的 `401/403`,前端 token 刷新依赖真实 `401`。 +- Event Bridge 内部令牌保护的 `401/503`。 +- Warehouse readiness 探针的 `503`。 +- Audit 异步接收成功的 `202`。 +- 框架、协议和基础设施错误,例如路由不存在 `404`、`405`、`413`、`415`、代理或网关 `502/504`。 + +控制器或业务服务抛出的 `BizException`、参数校验失败和统一异常处理器捕获的系统异常,均返回 HTTP 200 + 非 200 body code。 + +## 3. 后端实现 + +### 3.1 统一响应 + +- `ResultCode.SUCCESS` 已从 `"0"` 改为 `"200"`。 +- `ApiResponse.successful()` 只认 `ResultCode.SUCCESS.code()`。 +- `GlobalExceptionHandler` 的业务、校验和兜底异常统一使用 `ResponseEntity.ok(...)`。 +- Warehouse 数据访问异常处理器同步使用 HTTP 200。 +- `common-web` 增加自动配置,确保只扫描自身包的 audit-service、event-bridge-service 也能注册统一异常处理器和关联 ID 过滤器。 + +### 3.2 服务间调用 + +Feign 调用统一按新契约处理: + +1. 提供方返回 HTTP 200:检查 `response.successful()`,失败时读取 body `code/message`。 +2. 提供方返回 HTTP 非 200:视为鉴权、协议、网关或基础设施异常,统一映射为 `SERVICE_UNAVAILABLE`,不得按 HTTP 404/409 等状态推断业务结果。 +3. 空响应、解码失败、契约漂移或传输异常同样统一映射为 `SERVICE_UNAVAILABLE`。 + +`ResultCode.of(rawCode)` 只还原本地已知枚举;未知或空 code 会降级为 `B0001`,不会原样透传未知 code,也不会误判成功。 + +服务间调用统一收敛为 `OrderEligibilityGuard` 的三段式写法: + +```java +} catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "...服务暂时不可用"); +} catch (BizException exception) { + throw exception; +} catch (RuntimeException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "...响应无效"); +} +``` + +> `catch (BizException)` 必须显式置于 `RuntimeException` 之前,否则主路径抛出的业务异常会被兜底分支吞掉、降级成 `S0503`。 +> +> Spring MVC 对**未匹配路由**返回的 404 不经过 `GlobalExceptionHandler`,仍是真 404;服务间调用方不得把这个协议错误伪装成业务资源不存在。 + +## 4. 企业审核代码与修复 + +当前已有企业审核流程: + +- 接口:`PlatformTenantController` 的 `/reviews/{id}/approve` 与 `/reviews/{id}/reject`。 +- 服务:`TenantService.review()`。 +- 表:`iam_tenant_review`,审核意见字段为 `VARCHAR(500)`。 + +本次补齐: + +- `ReviewRequest.comment` 增加 `@Size(max = 500)`(`TenantDtos.java:52`),审批和驳回请求增加 `@Valid`(`PlatformTenantController.java:122`、`:137`)。 +- 审核状态使用 `WHERE id = ? AND status = 'PENDING'` 条件更新(`TenantRepository.completeReview()`)。 +- 只有影响行数为 1 的请求获得审核权;重复或并发请求返回 body code `A0409`。 +- 获得审核权后才更新租户和待审资质,整个流程仍在同一事务内;后续步骤失败时审核更新一并回滚。 + +这样可避免两个操作员同时通过/驳回时相互覆盖审核结果。 + +> 实现说明:`TenantService.review()` 中 `if (!"PENDING".equals(review.getStatus()))`(`:98`)只是读后快速失败,用于给出即时提示;真正的并发防护在 `completeReview()` 的条件更新 —— 两个并发请求都通过前置检查时,只有一个能拿到 `affected == 1`,另一个落到 `:105` 抛 `A0409`。两层都保留是合理的,不要因为「看起来重复」而删掉任一层。 + +## 5. 前端约定 + +admin-web 与 client-web 都只在 `code === "200"` 时解包数据;其他 body code 转为业务错误。真实 HTTP `401` 的刷新重试逻辑保持不变,真实 5xx 的防御性错误解析也继续保留。 + +### 5.1 改造落点(2026-08-01 实测确认已完成) + +| 仓库 | 位置 | 现状 | +|---|---|---| +| admin-web | `src/api/http.ts:101` | `if (response?.code !== '200')` ✅ | +| client-web | `src/api/http.ts:17` | `if (envelope.code !== '200')` ✅ | +| admin-web | `src/stores/auth.test.ts:13-16` | 测试内自建 unwrap 同步改为 `!== '200'` ✅ | +| admin-web | 4 个 api/utils 测试文件 | mock 数据已全部改为 `code: '200'`,共 24 处 ✅ | + +`code` 为字符串,前端使用严格比较 `!==`,与后端 `ApiResponse.code`(`String` 类型,Jackson 不做数字推断)对齐。若后端误改为数字类型,`'200' !== 200` 恒为真会导致全站失败 —— 见 3.1。 + +### 5.2 防回归断言 + +`admin-web/src/api/http.test.ts:21-26` 新增用例 `treats only body code 200 as success`,显式断言旧成功码 `'0'` 现在必须抛 `ApiResponseError`: + +```ts +expect(unwrapApiResponse({ code: '200', message: 'success', data: { id: '1' } })).toEqual({ id: '1' }) +expect(() => unwrapApiResponse({ code: '0', message: 'legacy success code', data: null })) + .toThrow(ApiResponseError) +``` + +这是文件里唯一保留的 `code: '0'`,属于有意为之,核查脚本扫到时不应误判为漏改。 + +client-web 通过 `src/views/AuthViews.test.mjs` 的源码契约测试覆盖 `unwrap()`:明确要求 `code !== '200'`,并拒绝旧的 `code !== '0'` 判断。 + +### 5.3 不受影响的应用 + +`apps/` 下另外 4 个应用经核查无成功码判断逻辑: + +| 应用 | 情况 | +|---|---| +| `mobile-app`、`website`、`wechat-miniprogram` | 仅有 `AGENTS.md` / `README.md`,尚无实现代码 | +| `pda-uniapp` | 有页面代码但无 `ApiResponse` 判定(`.code` 均为任务编号或构建产物) | + +另注:`admin-web/shared/src/index.ts:30` 的 `'0'` 是数字字符范围判断(`>= '0' && <= '9'`),与成功码无关,勿改。 + +## 6. OpenAPI 与可观测性 + +- 控制器注解以及 IAM、Order、Firstleg 静态 OpenAPI 已移除普通业务 `400/404/409/422/503` 响应声明。 + 实测确认:三份静态 OpenAPI 中此类声明为 0 处;控制器注解仅剩 `EventBridgeController.java:41-42` 的 `401/503`,属第 2 节允许的内部令牌保护边界,**不是漏改**。 +- HTTP 200 的描述明确要求继续检查 body code;真实鉴权状态继续保留。 +- 统一异常处理器增加 `baiyue.api.business.failures` 计数器,仅使用有限的结果码作为 `code` 标签,避免高基数。 +- Prometheus 中对应的计数指标通常导出为 `baiyue_api_business_failures_total{code="A0409"}`。 + +发布后,业务错误率告警应基于该指标,不再依赖 HTTP 4xx/5xx 曲线。 + +## 7. 发布顺序 + +1. 同批发布 `common-core`、`common-web` 和所有包含它们的服务构建产物。 +2. 同批发布已改造的 Feign 调用方,避免新提供方与旧调用方形成契约窗口。 +3. 发布 admin-web 与 client-web;前后端不可长时间跨版本运行,因为旧前端不识别成功码 `"200"`,新前端也不再接受 `"0"`。 +4. 验证成功、业务失败、真实 401、readiness 503、audit 202。 +5. 确认业务失败指标和告警正常后再完成发布。 + +不建议在后端成功码切换和前端切换之间做长时间灰度。如必须灰度,应由网关或独立兼容层按客户端版本转换成功码,不能让客户端同时猜测 `"0"` 和 `"200"`。 + +## 8. 核查与验收 + +### 8.1 静态核查脚本 + +```bash +# —— 后端(在 baiyue-firstleg 下执行)—— + +# 成功码应为字符串 "200" +grep -n 'SUCCESS(' common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java +# 期望:SUCCESS("200", "success", HttpStatus.OK) + +# code 字段必须是 String,不能是 int/Integer +grep -n 'code' common-core/src/main/java/com/baiyue/common/core/api/ApiResponse.java | head -3 +# 期望:String code, + +# 除枚举定义外不应再有 "0" 成功码硬编码 +grep -rn '"0"' --include="*.java" */src/main | grep -v 'ResultCode.java' | grep -v 'logic-delete' + +# 生产代码不应使用 Feign HTTP 状态码推断业务结果;此命令应无输出 +grep -rn 'exception\.status()\|feign\.status()\|FeignException\.NotFound' --include="*.java" */src/main + +# —— 前端(在 apps 下执行)—— + +# 两个前端的成功码判断 +grep -rn "code !== '200'" admin-web/src client-web/src --include="*.ts" --exclude-dir=node_modules +# 期望:admin-web/src/api/http.ts、client-web/src/api/http.ts 各一处 + +# 残留旧成功码:期望仅 http.test.ts 那一条反向断言(见 5.2) +grep -rn "code: '0'\|code !== '0'\|code === '0'" admin-web/src client-web/src \ + --include="*.ts" --include="*.tsx" --exclude-dir=node_modules +``` + +### 8.2 构建与测试 + +```bash +# 后端:common-web 是共享依赖,必须带 -am +cd baiyue-firstleg +mvn -pl common-core,common-web,auth,iam,file,order,warehouse,audit-service,event-bridge-service -am test + +# 前端 +cd ../apps/admin-web && pnpm test && pnpm typecheck && pnpm build +cd ../client-web && pnpm test && pnpm typecheck && pnpm build +``` + +### 8.3 部署后冒烟 + +```bash +# ① 业务成功:HTTP 200 + "code":"200"(不再是 "0") +curl -isS "http://8.163.30.128:9000/api/merchant/v1/iam/tenant/qualifications" \ + -H "Authorization: Bearer " | sed -n '1p;/^{/p' + +# ② 业务失败:HTTP 200 + "code":"A0409" +curl -isS -X POST "http://8.163.30.128:9000/api/merchant/v1/files/qualifications" \ + -H "Authorization: Bearer " \ + -F "file=@/path/to/license.png" -F "certificateNo=91310000PHASE10001" \ + | sed -n '1p;/^{/p' + +# ③ 真实 401 保持(前端 token 刷新依赖) +curl -isS -X POST "http://8.163.30.128:9000/api/merchant/v1/files/qualifications" | sed -n '1p' + +# ④ readiness 真实 503 +curl -isS "http://8.163.30.128:9205/actuator/health/readiness" | sed -n '1p' + +# ⑤ 业务失败指标 +curl -sS "http://8.163.30.128:9201/actuator/prometheus" | grep baiyue_api_business_failures_total +``` + +> ① 是本次成功码改造的关键验证点:若仍返回 `"code":"0"` 说明该服务未重新构建(`common-core` 打包在各服务 jar 内)。 + +### 8.4 回滚 + +无数据库变更、无 Nacos 变更,回滚即回退代码重新构建。注意**前后端必须一起回滚** —— 只回滚一侧会立即造成全站失败,原因见第 7 节。 + +## 9. 验收清单 + +**静态核查(2026-08-01 已实测)** + +- [x] 后端成功码为字符串 `"200"` — `ResultCode.java:11`,字段类型 `String` +- [x] `ApiResponse.code` 为 `String`,Jackson 不做数字推断,序列化为 `"200"` +- [x] admin-web `src/api/http.ts:101` 判 `!== '200'` +- [x] client-web `src/api/http.ts:17` 判 `!== '200'` +- [x] 前端 mock 与自建 unwrap 全部改为 `'200'`(24 处) +- [x] 唯一残留的 `code: '0'` 是 `http.test.ts:24` 的防回归反向断言,属有意保留 + +**运行时验收(部署后按 8.3 执行)** + +- [ ] 成功响应:HTTP 200 + `code: "200"`。 +- [ ] 参数错误、资源不存在、冲突、下游业务失败:HTTP 200 + 非 200 code。 +- [ ] 重复或并发审核:仅一个请求成功,其余返回 `A0409`。 +- [ ] 超过 500 字的审核意见:HTTP 200 + `A0422`。 +- [ ] 未登录请求:真实 HTTP 401,前端刷新逻辑正常。 +- [ ] Warehouse readiness 不可用:真实 HTTP 503。 +- [ ] Audit 接收成功:真实 HTTP 202,body `code: "200"`。 +- [ ] Prometheus 能查询 `baiyue_api_business_failures_total`。 +- [ ] 三个仓库测试、类型检查和构建全部通过(8.2)。 + +**遗留项** + +- [x] 已删除 Feign 旧 HTTP 状态码业务映射;服务间调用统一使用 HTTP 200 body code + +## 10. 与本改造无关的现有工作区改动 + +`file` 模块的 `LocalQualificationStorage` 及其测试是已有的存储目录写入探针改动,不属于响应约定。本次不覆盖、不回退,提交时建议与响应改造拆分。 diff --git a/docs/重量OCR接入说明.md b/docs/重量OCR接入说明.md new file mode 100644 index 000000000..ba1e23dbd --- /dev/null +++ b/docs/重量OCR接入说明.md @@ -0,0 +1,91 @@ +# 重量 OCR 接入说明 + +## 功能位置 + +重量 OCR 已作为功能模块并入 `integration-service`,不再作为独立微服务部署,不新增端口、数据库或注册服务名。 + +主要代码: + +- `controller/WeightOcrController.java`:内部批量识别接口。 +- `service/ocr/WeightOcrBatchService.java`:批次编排、结果汇总和文件落盘。 +- `service/ocr/ImageCropper.java`:图片下方中央区域裁剪。 +- `service/ocr/WeightCandidateSelector.java`:重量候选筛选。 +- `service/ocr/WeightOcrStoragePolicy.java`:输入、输出目录边界保护。 +- `provider/ocr/BaiduMeterClient.java`:百度令牌缓存、识别、限速和重试。 + +## 接口 + +```text +POST /internal/integrations/weight-ocr/batches +X-Internal-Service-Token: 内部服务令牌 +Content-Type: application/json +``` + +请求示例: + +```json +{ + "inputDirectory": "batch-001", + "outputDirectory": null, + "cropStartRatio": 0.5, + "horizontalTrimRatio": 0.25, + "recursive": false +} +``` + +`inputDirectory` 默认相对于 `WEIGHT_OCR_INPUT_ROOT`,`outputDirectory` 默认在 `WEIGHT_OCR_OUTPUT_ROOT` 下按批次 ID 自动创建。接口拒绝访问两个受控根目录之外的路径。 + +## 必需配置 + +```text +BAIDU_OCR_ENABLED=true +BAIDU_OCR_API_KEY=百度应用的API Key +BAIDU_OCR_SECRET_KEY=百度应用的Secret Key +INTERNAL_SERVICE_TOKEN=至少32字节的内部令牌 +``` + +本地默认目录: + +```text +${java.io.tmpdir}/baiyue-weight-ocr/input +${java.io.tmpdir}/baiyue-weight-ocr/output +``` + +国内服务器默认目录: + +```text +/data/baiyue/weight-ocr/input +/data/baiyue/weight-ocr/output +``` + +可选配置: + +| 环境变量 | 默认值 | 说明 | +| --- | ---: | --- | +| `WEIGHT_OCR_MAX_IMAGE_SIZE` | `3MB` | 单张原图及裁剪图上限 | +| `WEIGHT_OCR_MAX_IMAGE_PIXELS` | `40000000` | 解码后最大像素数 | +| `WEIGHT_OCR_MAX_BATCH_SIZE` | `50` | 单批图片上限 | +| `BAIDU_OCR_CONNECT_TIMEOUT` | `10s` | 建连超时 | +| `BAIDU_OCR_REQUEST_TIMEOUT` | `60s` | 单次请求超时 | +| `BAIDU_OCR_MIN_REQUEST_INTERVAL` | `600ms` | 百度请求最小间隔 | +| `BAIDU_OCR_MAX_QPS_RETRIES` | `3` | 百度 QPS 限制重试次数 | + +## 输出文件 + +```text +批次输出目录/ +├─ batch-results.json +├─ cropped/ +└─ raw/ +``` + +接口和结果文件仅返回相对于受控根目录的路径,不返回服务器绝对路径。真实百度响应不写入日志,只保存在批次的 `raw` 目录。 + +## 安全边界 + +- 接口仅供内部服务调用,必须校验 `X-Internal-Service-Token`。 +- 百度能力默认关闭,密钥只从环境变量或配置中心密文注入。 +- 同一实例一次只执行一个 OCR 批次,避免并发压垮第三方接口。 +- 单批顺序调用百度接口,并对 QPS 错误执行有上限的退避重试。 +- 支持 `jpg`、`jpeg`、`png`、`bmp`,同时限制文件大小和解码像素数。 +- 当前不访问业务数据库,识别结果由调用方按自己的业务边界处理。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md new file mode 100644 index 000000000..890bd77e2 --- /dev/null +++ b/event-bridge-service/AGENTS.md @@ -0,0 +1,40 @@ +# Event Bridge Service 模块指引 + +## 职责边界 + +- Mapper 只由 `EventBridgeApplication.java` 对 `com.baiyue.eventbridge` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 +- 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。 +- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。 +- 事件结构和 Payload 哈希以 `common-event-contract` 为准。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | +| Inbox 接收与幂等 | `service/EventInboxService.java` | +| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 的 `BUSINESS_FEE_SOURCE_CHANGED` 与 `WALLET_COMMAND_REQUESTED` 定向投 Finance,其余国内回投 Firstleg;海外业务事件及 `WALLET_COMMAND_COMPLETED` 投 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` | +| 国内 Finance 事件客户端 | `service/FinanceEventClient.java`、`HttpFinanceEventClient.java`;费用入口 `/internal/finance/v1/business-fee-source-events`,资金指令入口 `/internal/finance/v1/wallet-command-events`,分别配置 `finance-consumer-url`、`finance-wallet-consumer-url`,不得误投 Firstleg | +| 人工补投 | `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` | +| 数据库 | `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 字段。 + +## 修改与验证 + +- 定向验证:`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..de2dd96ba 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,20 @@ package com.baiyue.eventbridge; +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import org.apache.ibatis.annotations.Mapper; 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") +@MapperScan(basePackages = "com.baiyue.eventbridge", annotationClass = Mapper.class) @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 4b4f4fec9..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,13 +4,15 @@ 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; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -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 与来源白名单。 @@ -25,15 +28,15 @@ import org.springframework.web.bind.annotation.RestController; @Tag(name = "事件桥内部接口", description = "跨区域事件的幂等接收与能力状态") @RestController @RequestMapping("/internal/event-bridge") -@RequiredArgsConstructor public class EventBridgeController { - private final EventInboxService eventInboxService; - private final InternalServiceTokenGuard internalServiceTokenGuard; + @Resource private EventInboxService eventInboxService; + @Resource private EventReplayService eventReplayService; + @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 = "内部服务令牌无效"), @@ -48,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..1b97d2bdf --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java @@ -0,0 +1,128 @@ +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.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, + EventTypes.OVERSEAS_DELIVERY_COMPLETED, + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, + EventTypes.WALLET_COMMAND_REQUESTED, + EventTypes.WALLET_COMMAND_COMPLETED, + 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/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index 8803d0a1a..1a0538a19 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 @@ -5,73 +5,121 @@ 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; /** * 入站事件校验与幂等落库服务。 */ @Service public class EventInboxService { - private final InboxStore inboxStore; - private final ObjectMapper objectMapper; - private final String localRegion; + @Resource private InboxStore inboxStore; + @Resource private ObjectMapper objectMapper; + @Resource private EventBridgeMetrics metrics; - public EventInboxService(InboxStore inboxStore, ObjectMapper objectMapper, - @Value("${baiyue.region:CN}") String localRegion) { - this.inboxStore = inboxStore; - this.objectMapper = objectMapper; - this.localRegion = localRegion; - } + @Value("${baiyue.region:CN}") + private String localRegion; + + /** Local single-bridge mode may carry both regional envelopes; production keeps strict routing. */ + @Value("${baiyue.event-bridge.accept-any-region:false}") + private boolean acceptAnyRegion; @Transactional public EventReceiveResult receive(EventEnvelope envelope) { envelope.requireValid(); - if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) { + if (!acceptAnyRegion && !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()); if (existing != null) { return duplicateOrConflict(existing, envelope); } + String idempotencyKey = idempotencyKey(envelope); + BridgeInbox sameBusinessEvent = inboxStore.findByIdempotencyKey(idempotencyKey); + if (sameBusinessEvent != null) { + return duplicateBusinessEventOrConflict(sameBusinessEvent, envelope); + } 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()); + if (concurrent == null) concurrent = inboxStore.findByIdempotencyKey(idempotencyKey); if (concurrent == null) { throw exception; } - return duplicateOrConflict(concurrent, envelope); + return Objects.equals(concurrent.getEventId(), envelope.eventId()) + ? duplicateOrConflict(concurrent, envelope) + : duplicateBusinessEventOrConflict(concurrent, envelope); } } - private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) { - if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { - throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件"); + private EventReceiveResult duplicateBusinessEventOrConflict(BridgeInbox existing, EventEnvelope envelope) { + if (!sameBusinessEvent(existing, envelope)) { + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT); + throw new IllegalArgumentException("相同eventKey对应的事件负载不一致,拒绝柜号或事实漂移"); } + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE); + return new EventReceiveResult(existing.getEventId(), "重复", existing.getReceivedAt()); + } + + private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) { + 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 sameEnvelope(BridgeInbox existing, EventEnvelope envelope) { + return sameBusinessEvent(existing, envelope) + && Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt())) + && Objects.equals(existing.getTraceId(), envelope.traceId()) + && Objects.equals(existing.getCausationId(), envelope.causationId()); + } + + private boolean sameBusinessEvent(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(existing.getCorrelationId(), envelope.correlationId()); + } + private BridgeInbox toInbox(EventEnvelope envelope) { BridgeInbox inbox = new BridgeInbox(); inbox.setEventId(envelope.eventId()); - inbox.setIdempotencyKey(envelope.sourceRegion() + ":" + envelope.eventId()); + inbox.setIdempotencyKey(idempotencyKey(envelope)); inbox.setEventType(envelope.eventType()); 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()); @@ -79,7 +127,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())); @@ -90,4 +138,12 @@ public class EventInboxService { inbox.setReceivedAt(LocalDateTime.now()); return inbox; } + + private static Instant normalizeOccurredAt(Instant value) { + return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS); + } + + private static String idempotencyKey(EventEnvelope envelope) { + return envelope.sourceRegion() + ":" + envelope.correlationId(); + } } 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/FinanceEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FinanceEventClient.java new file mode 100644 index 000000000..a8d4e7107 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FinanceEventClient.java @@ -0,0 +1,7 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +public interface FinanceEventClient { + String deliver(BridgeInbox event) throws Exception; +} 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/HttpFinanceEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFinanceEventClient.java new file mode 100644 index 000000000..e86b022f9 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFinanceEventClient.java @@ -0,0 +1,61 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +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 HttpFinanceEventClient implements FinanceEventClient { + @Resource private ObjectMapper objectMapper; + @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; + @Value("${baiyue.event-dispatcher.finance-consumer-url:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events}") + private URI consumerUrl; + @Value("${baiyue.event-dispatcher.finance-wallet-consumer-url:http://127.0.0.1:9203/internal/finance/v1/wallet-command-events}") + private URI walletConsumerUrl; + @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()); + URI target = EventTypes.WALLET_COMMAND_REQUESTED.equals(row.getEventType()) + ? walletConsumerUrl : consumerUrl; + HttpRequest request = tracePropagation.apply(HttpRequest.newBuilder(target) + .timeout(properties.requestTimeout()).header("Content-Type", "application/json") + .header("X-Internal-Service-Token", serviceToken)) + .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 ignored) { body = null; } + if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText())) { + throw new IllegalStateException("Finance消费失败: HTTP " + response.statusCode() + + ", code=" + (body == null ? "missing" : body.path("code").asText("missing"))); + } + return String.valueOf(response.statusCode()); + } +} 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/InboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java index e96519c13..b542caebb 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java @@ -7,5 +7,6 @@ import com.baiyue.eventbridge.domain.BridgeInbox; */ public interface InboxStore { BridgeInbox findByEventId(String eventId); + BridgeInbox findByIdempotencyKey(String idempotencyKey); void insert(BridgeInbox inbox); } 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..fedcdf41c 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,22 +2,28 @@ 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; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; /** * 基于 MyBatis-Plus 的入站幂等存储。 */ @Repository -@RequiredArgsConstructor public class MybatisInboxStore implements InboxStore { - private final BridgeInboxMapper mapper; + @Resource private BridgeInboxMapper mapper; @Override public BridgeInbox findByEventId(String eventId) { return mapper.selectById(eventId); } + @Override + public BridgeInbox findByIdempotencyKey(String idempotencyKey) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(BridgeInbox::getIdempotencyKey, idempotencyKey)); + } + @Override public void insert(BridgeInbox inbox) { mapper.insert(inbox); 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..17ff106b7 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -0,0 +1,141 @@ +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 FinanceEventClient financeEventClient; + @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" -> FINANCE_EVENT_TYPES.contains(eventType) + ? financeEventClient.deliver(event) : 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 static final Set FINANCE_EVENT_TYPES = Set.of( + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, + EventTypes.WALLET_COMMAND_REQUESTED); + + 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.yml b/event-bridge-service/src/main/resources/application.yml index 03dd17a36..c71fb2f2b 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -1,7 +1,11 @@ server: - port: ${EVENT_BRIDGE_PORT:9300} + port: ${EVENT_BRIDGE_PORT:9312} spring: + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} application: name: event-bridge-service config: @@ -26,6 +30,20 @@ spring: baiyue: region: ${REGION_CODE:CN} + 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} + finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events} + finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:http://127.0.0.1:9203/internal/finance/v1/wallet-command-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:} 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 3fd4368b7..300f674ec 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 '聚合版本', @@ -34,12 +34,12 @@ CREATE TABLE IF NOT EXISTS bridge_outbox ( CREATE TABLE IF NOT EXISTS bridge_inbox ( event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', - idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键', + idempotency_key VARCHAR(191) NOT NULL COMMENT '来源区域与业务eventKey组成的接收幂等键', 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 '租户标识', + 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/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 97fc88360..3639dd0e5 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,9 +5,12 @@ 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 io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; import java.time.LocalDateTime; @@ -23,7 +26,9 @@ import static org.mockito.Mockito.when; class EventInboxServiceTest { private final ObjectMapper objectMapper = new ObjectMapper(); private final InboxStore store = mock(InboxStore.class); - private final EventInboxService service = new EventInboxService(store, objectMapper, "OVERSEAS"); + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry); + private final EventInboxService service = service("OVERSEAS"); @Test void 首次事件落库并返回已接收() { @@ -32,12 +37,45 @@ class EventInboxServiceTest { assertThat(service.receive(envelope).result()).isEqualTo("已接收"); verify(store).insert(any(BridgeInbox.class)); + assertThat(receiveCount("RECEIVED")).isEqualTo(1.0); + } + + @Test + 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.payloadHash()); + 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("重复"); @@ -46,17 +84,58 @@ class EventInboxServiceTest { @Test void 相同事件但摘要不同时拒绝覆盖() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())).thenReturn(existing("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 相同事件和摘要但聚合标识不同时拒绝覆盖() { + EventEnvelope envelope = withHash(envelope("sha256:placeholder")); + 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 相同业务eventKey的新eventId只允许同负载重放并拒绝柜号漂移() { + EventEnvelope envelope = withHash(envelope("sha256:placeholder")); + BridgeInbox existing = existing(envelope, envelope.payloadHash()); + when(store.findByEventId(envelope.eventId())).thenReturn(null); + when(store.findByIdempotencyKey("CN:flow-1")).thenReturn(existing); + EventEnvelope replay = new EventEnvelope("550e8400-e29b-41d4-a716-446655440001", + envelope.eventType(), envelope.eventVersion(), Instant.parse("2026-07-29T09:00:00Z"), + envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(), + envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), "trace-2", + envelope.correlationId(), null, envelope.payload(), envelope.payloadHash()); + assertThat(service.receive(replay).result()).isEqualTo("重复"); + + com.fasterxml.jackson.databind.node.ObjectNode driftPayload = + (com.fasterxml.jackson.databind.node.ObjectNode) envelope.payload().deepCopy(); + driftPayload.put("containerNo", "DIFFERENT"); + String driftHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(driftPayload)); + EventEnvelope drift = new EventEnvelope("550e8400-e29b-41d4-a716-446655440002", + envelope.eventType(), envelope.eventVersion(), envelope.occurredAt(), envelope.sourceRegion(), + envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(), envelope.aggregateId(), + envelope.aggregateVersion(), envelope.handoverVersion(), "trace-3", envelope.correlationId(), + null, driftPayload, driftHash); + assertThatThrownBy(() -> service.receive(drift)) + .hasMessageContaining("eventKey").hasMessageContaining("漂移"); } @Test void 并发插入冲突后按既有记录判定为重复() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope.payloadHash())); + when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope, envelope.payloadHash())); doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any()); assertThat(service.receive(envelope).result()).isEqualTo("重复"); @@ -64,7 +143,7 @@ class EventInboxServiceTest { @Test void 目标区域与当前部署区域不一致时拒绝接收() { - EventInboxService cnService = new EventInboxService(store, objectMapper, "CN"); + EventInboxService cnService = service("CN"); EventEnvelope envelope = withHash(envelope("sha256:placeholder")); assertThatThrownBy(() -> cnService.receive(envelope)) @@ -72,6 +151,15 @@ class EventInboxServiceTest { .hasMessageContaining("目标区域"); } + @Test + void 本地单实例模式允许接收不同目标区域事件() { + EventInboxService localService = service("CN", true); + EventEnvelope envelope = withHash(envelope("sha256:placeholder")); + + assertThat(localService.receive(envelope).result()).isEqualTo("已接收"); + verify(store).insert(any(BridgeInbox.class)); + } + private EventEnvelope envelope(String hash) { return new EventEnvelope("550e8400-e29b-41d4-a716-446655440000", "OrderApproved", 1, Instant.parse("2026-07-29T08:30:00Z"), RegionCode.CN, RegionCode.OVERSEAS, @@ -79,6 +167,26 @@ class EventInboxServiceTest { objectMapper.createObjectNode().put("orderId", "90001"), hash); } + private EventInboxService service(String region) { + return service(region, false); + } + + private EventInboxService service(String region, boolean acceptAnyRegion) { + EventInboxService service = new EventInboxService(); + ReflectionTestUtils.setField(service, "inboxStore", store); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + ReflectionTestUtils.setField(service, "metrics", metrics); + ReflectionTestUtils.setField(service, "localRegion", region); + ReflectionTestUtils.setField(service, "acceptAnyRegion", acceptAnyRegion); + 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(), @@ -87,9 +195,22 @@ class EventInboxServiceTest { source.correlationId(), source.causationId(), source.payload(), hash); } - private BridgeInbox existing(String hash) { + private BridgeInbox existing(EventEnvelope envelope, String hash) { BridgeInbox inbox = new BridgeInbox(); - inbox.setEventId("550e8400-e29b-41d4-a716-446655440000"); + inbox.setEventId(envelope.eventId()); + 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/HttpFinanceEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFinanceEventClientTest.java new file mode 100644 index 000000000..cac61af80 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFinanceEventClientTest.java @@ -0,0 +1,19 @@ +package com.baiyue.eventbridge.service; + +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 HttpFinanceEventClientTest { + @Test + void walletConsumerUrlCanBeConfiguredForContainerDeployment() throws Exception { + String application = Files.readString(Path.of("src/main/resources/application.yml")); + + assertThat(application).contains( + "finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:" + + "http://127.0.0.1:9203/internal/finance/v1/wallet-command-events}"); + } +} 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..4262a7bc5 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -0,0 +1,313 @@ +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 FinanceEventClient financeEventClient = mock(FinanceEventClient.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, "financeEventClient", financeEventClient); + 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 overseasFeeSourceIsDeliveredToFinanceAndNeverFirstleg() throws Exception { + event.setEventType(EventTypes.BUSINESS_FEE_SOURCE_CHANGED); + event.setSourceRegion("OVERSEAS"); + event.setTargetRegion("CN"); + when(financeEventClient.deliver(event)).thenReturn("200"); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(financeEventClient).deliver(event); + verifyNoInteractions(firstlegEventClient); + verifyNoInteractions(eventClient); + 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 new file mode 100644 index 000000000..fe8603673 --- /dev/null +++ b/file/AGENTS.md @@ -0,0 +1,44 @@ +# File Service 模块指引 + +## 职责与边界 + +- Mapper 只由 `FileServiceApplication.java` 对 `com.baiyue.file` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 +- 负责企业资质文件、公共导出、入库测量图片的上传、存储、读取和鉴权。 +- 资质审核元数据归 `iam/`,入库状态归 `firstleg/`,OCR 适配归 `integration-service/`;不跨库读取业务表。 + +## 模块入口 + +| 目标 | 入口 | +|---|---| +| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java`、`controller/QualificationTypes.java`;契约 `src/main/resources/openapi/qualification-required-package-v1.yaml`。营业执照必须与 18 位统一社会信用代码走 `required-package`;法人身份证选填,客户可在已有营业执照后通过 `/api/merchant/v1/files/qualifications` 单独新增或替换 | +| 导出中心与退避重试 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.java`、`mq/`;响应公开 `attemptCount/maxAttempts/nextRetryAt` | +| 测量图片/证据 | `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`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | +| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 | +| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | +| 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 | +| 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 | +| 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | +| 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=0`(平台专属,客户可选快照不改变文件归属,拒绝客户作用域)、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 | +| 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 | +| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | +| 派送单三类资料 | `src/main/resources/openapi/delivery-files-v1.yaml`;依次为装车照片 `DELIVERY_LOADING_PHOTO`、BOL文件(含签名)`DELIVERY_BOL`、签收文件(POD签收/快递签收)`DELIVERY_RECEIPT`,按客户、派送内部ID、类别隔离,任意格式单文件50MiB;Lastleg持有分类绑定、保存、替换删除和历史可见性,按非空类别计数x/3,不强制必传或识别签名;File保留不可变对象与真实createdAt | +| 下游 OCR | `client/WeightOcrClient.java` | +| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` | +| 测试/验证 | `src/test/java/com/baiyue/file/`;`mvn -pl file -am test` | + +## 修改约束 + +- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。 +- 客户单文件资质入口只接受 `LEGAL_PERSON_ID/FREIGHT_FORWARDER_CERTIFICATE/BANK_ACCOUNT_LICENSE`,客户范围只取登录 Token;营业执照不得绕过 `required-package`。同类当前资质已存在时必须显式 `replace=true`,成功后旧元数据标记为已替代;上传不会自动提交审核。 +- 客户业务文件按客户、业务 ID 和文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。装柜四步照片与铅封是跨客户物理柜证据,所有者固定为平台(`tenantId=0`),业务 ID 固定为 `containerBookingId`,File 按柜与五类 `LOADING_*` 精确校验且不得跨柜复用;PDA `taskId` 只由 Firstleg 保存为来源/审计,不进入 File 所有权。国内 PDA 禁止直连 File 平台接口,必须经 Firstleg 校验任务、仓库和操作者后,由 Firstleg 使用内部服务令牌访问 File。 +- 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。 +- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。 +- 人工重试只允许 `FAILED`,或 `PENDING` 且已有失败尝试、正在等待自动退避的任务;后者只把当前任务与现有 Outbox 投递立即到期,不增加新任务或并发消息。`RUNNING`、初次等待消费、已成功或已过期任务均拒绝重试。 +- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。 +- 报关源文件和完成文件的 `businessId` 固定为 loadingPlanId,File DTO 仍为 `Metadata(fileId, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes)`,其中 `businessType` 即 fileCategory;Firstleg 保存业务文件版本并决定用户入口。权限固定为 Platform 上传结果 `platform:firstleg:customs:manage`、下载 `platform:firstleg:loading-list:list`,Client 上传源文件 `merchant:order:edit`、下载自身源文件或获准结果 `merchant:order:view`。结果还必须满足整柜完成和可见性,买单客户是否存在结果入口同样由 Firstleg 判定,File 不复制这些状态。 + +## 全量验收导航 + +订单与客户、入库测量与计费、国内货柜履约及财务全量验收均复用本模块文件能力;按本次链路读取对应阶段/专题文档,统一核对资质、测量图片、装柜证据、报关回执的归属、鉴权、审计和生命周期。照片允许追加、错误记录仅作废不硬删除,客户不得读取内部照片或锁码。 diff --git a/file/README.md b/file/README.md new file mode 100644 index 000000000..ce4ae894d --- /dev/null +++ b/file/README.md @@ -0,0 +1,125 @@ +# File Service 使用与运维说明 + +File Service 同时承载企业资质文件和公共导出下载中心。公共导出采用数据库任务表、事务 Outbox、RocketMQ 和业务 Provider;完整架构与接口契约见 [`../docs/公共导出与下载中心接入说明.md`](../docs/公共导出与下载中心接入说明.md)。 + +## 公共导出运行条件 + +创建导出任务只会写入 `file_export_task` 和 `file_export_outbox`。以下三个组件全部启用后,任务才会从 `PENDING` 继续执行: + +```dotenv +FILE_EXPORT_RECONCILER_ENABLED=true +FILE_EXPORT_PUBLISHER_ENABLED=true +FILE_EXPORT_CONSUMER_ENABLED=true +FILE_EXPORT_CONSUMER_CONCURRENCY=4 +``` + +三个开关默认均为 `false`,避免 RocketMQ 尚未就绪时 File Service 启动失败。Docker 容器只在创建时读取环境变量;修改 Jenkins 环境文件后必须重新部署 File Service,`docker restart` 不会更新配置。 + +当前测试环境由 Jenkins 文件凭据 `firstleg-test-env` 注入这些变量;凭据内容变更后重新执行 `file` 服务的部署阶段。 + +测试环境还必须配置: + +```dotenv +INFRA_NETWORK=firstleg-test-infra-network +ROCKETMQ_NAME_SERVER=rocketmq-namesrv:9876 +ROCKETMQ_BROKER_IP=rocketmq-broker +MQ_TOPIC_PREFIX=test- +ROCKETMQ_DATA_DIR=/data/firstleg/rocketmq/store +``` + +File 与 RocketMQ 不暴露 MQ 宿主机端口,二者通过 `infra-network` 和 Docker 服务名通信。`ROCKETMQ_BROKER_IP` 不得填写 `localhost`、`127.0.0.1` 或宿主机公网地址。 + +## 测试环境首次部署 RocketMQ + +在服务器的稳定仓库目录执行,不要依赖会被 Jenkins 清理的临时工作区: + +```bash +cd /www/deploy/baiyue-firstleg +mkdir -p /data/firstleg/rocketmq/store +chown -R 3000:3000 /data/firstleg/rocketmq/store + +DEPLOY_ENV=test \ +INFRA_NETWORK=firstleg-test-infra-network \ +ROCKETMQ_BROKER_IP=rocketmq-broker \ +ROCKETMQ_DATA_DIR=/data/firstleg/rocketmq/store \ +MQ_TOPIC_PREFIX=test- \ +docker compose -f deploy/docker-compose.rocketmq.yml up -d +``` + +官方 `apache/rocketmq:5.3.1` 镜像使用 UID/GID `3000` 的 `rocketmq` 用户。宿主机存储目录若属于 `root:root` 且不可写,Broker 会反复重启;不要把 Broker 改成 root 用户,应修正挂载目录所有权。 + +部署后的正常状态: + +```text +rocketmq-namesrv Up +rocketmq-broker Up +rocketmq-topic-init Exited (0) +``` + +`rocketmq-topic-init` 是一次性初始化容器,`Exited (0)` 表示 Topic、消费组和 DLQ 初始化成功。 + +验证 Broker 注册结果: + +```bash +docker run --rm \ + --network firstleg-test-infra-network \ + apache/rocketmq:5.3.1 \ + sh mqadmin clusterList -n rocketmq-namesrv:9876 +``` + +输出必须包含 `BaiyueCluster`、`baiyue-file-export-broker` 和可访问地址 `rocketmq-broker:10911`。 + +## 分阶段启用导出 + +首次上线按以下顺序逐步修改环境文件,每一步都重新部署 File Service 并确认容器 `healthy`、`restartCount=0`: + +1. Reconciler:`true / false / false` +2. Reconciler + Publisher:`true / true / false` +3. Reconciler + Publisher + Consumer:`true / true / true` + +顺序均为 `RECONCILER / PUBLISHER / CONSUMER`。第二阶段应观察到 Outbox 从 `PENDING` 变为 `SENT`;第三阶段任务应从 `PENDING` 进入 `RUNNING`,最终进入 `SUCCEEDED` 或带明确原因的 `FAILED`。已有的 `PENDING` 任务无需重建或手工改状态。 + +## 快速排查 + +任务状态查询: + +```sql +SELECT id, task_no, status, dispatch_version, attempt_count, + processed_rows, next_retry_at, lease_owner, lease_until, + requested_at, started_at, completed_at, failure_message +FROM file_export_task +WHERE id = ?; +``` + +Outbox 查询: + +```sql +SELECT id, task_id, dispatch_version, topic, status, attempt_count, + next_retry_at, lease_owner, lease_until, sent_at, last_error +FROM file_export_outbox +WHERE task_id = ? +ORDER BY id; +``` + +| 现象 | 判断与处理 | +|---|---| +| Task `PENDING`、`attempt_count=0`,Outbox `PENDING`、`attempt_count=0` | Publisher 未启用或未运行 | +| Outbox `PENDING` 且有 `last_error` | Publisher 已尝试,检查 NameServer、Broker、Topic 和网络 | +| Outbox `CLAIMED` 且租约过期 | Publisher 中断,确认 Reconciler 已启用 | +| Outbox `SENT`,Task 仍为 `PENDING` | Consumer 未启用、未连接或存在消费积压 | +| File 启动时报 `connect to null failed` | NameServer/Broker 未运行,或未连接同一 `infra-network` | +| Broker 反复重启,数据目录为 `root:root` | 将挂载目录递归调整为 `3000:3000`,不要使用 root 运行 Broker | + +不要手工把 Task 改为 `RUNNING` 或把 Outbox 改为 `SENT`,否则会绕过租约、幂等和消息投递链路。 + +## 验收 + +测试环境主链路验收使用: + +```powershell +$env:ADMIN_ACCESS_TOKEN = '<测试环境平台用户Token>' +.\scripts\export-center-test-env-acceptance.ps1 ` + -GatewayBaseUrl 'http://8.163.30.128:9000' +``` + +脚本会创建真实 `CUSTOMER_LIST` 任务、轮询终态、下载文件并检查 XLSX ZIP 签名。Token 只通过当前进程环境变量注入,不写入仓库文件。 diff --git a/file/delivery-documents-handoff-20260909.md b/file/delivery-documents-handoff-20260909.md new file mode 100644 index 000000000..efbcfc9b3 --- /dev/null +++ b/file/delivery-documents-handoff-20260909.md @@ -0,0 +1,32 @@ +# 派送资料 File 交付及打印 BOL 核查 + +范围:测试工作区当前 test 分支;File/Print。未执行部署、真实 HTTP 或数据库操作。 + +## 能力矩阵 + +| 能力 | 状态与证据 | +|---|---| +| 三类上传 | 按序 LOADING_PHOTO→DELIVERY_LOADING_PHOTO(装车照片)、BOL→DELIVERY_BOL(BOL文件(含签名))、RECEIPT→DELIVERY_RECEIPT(签收文件(POD签收/快递签收));任意格式,非空且每文件≤52428800字节 | +| 所有权与元数据 | 复用 InternalBusinessFileService:tenantId=真实客户>0、businessId=deliveryOrderId内部Long十进制字符串、businessType和fileId精确过滤;同时只读ACTIVE且未删除对象 | +| 上传时间 | Metadata增加真实持久化createdAt,LocalDateTime无偏移;已有其他通用业务文件OpenAPI同步字段 | +| 下载 | 内部服务令牌必需,复合归属查询成功后读取真实对象;attachment、UTF-8文件名、no-store | +| 替换与删除 | 新上传产生独立对象并保留旧内容;当前/历史引用、原子保存、版本和可见性归Lastleg。File未新增物理删除入口 | +| 类别计数 | Lastleg每类0至20文件,totalCategoryCount=3,计数按已绑定非空类别x/3,不按文件数计数、不要求全部必传;无新增完成门禁、签名识别或电子签名;File不复制绑定或状态 | +| 数据库 | 现有business_type VARCHAR(32)、business_id VARCHAR(64)、created_at足够,无结构变更或SQL执行 | + +## 打印 BOL 现状 + +判定:Print通用渲染能力已实现,派送BOL业务打印链路未实现/待业务契约确认,未作环境验收。 + +- `print/src/main/java/com/baiyue/print/controller/InternalPdfController.java` 提供内部 inbound-marks、freight-a4-documents 两个入口,校验内部令牌,返回真实PDF、页数、Content-Disposition和no-store。 +- `print/src/main/java/com/baiyue/print/dto/FreightA4PdfDtos.java` 仅定义海运托运书和提单确认件A4模板;没有派送BOL专用模板。 +- `print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java` 实际生成字节并用PDFBox解析页数、尺寸与业务字段。 +- 本轮定向检索 `lastleg-service/src/main/java/com/baiyue/lastleg/delivery/{controller,service}` 未发现调用Print的派送BOL生成链;当前BOL号和三类资料绑定不能视为生成打印能力。已上传BOL的鉴权下载也不等于生成派送BOL。 +- 派送专属打印权限、状态门禁、字段映射、模板是否复用和输出入口尚不能由现有File/Print证明,本次未新增打印方案。其余五个按钮由Admin/后端16核查。 + +## 验证 + +- 2026-09-09 11:21(UTC+8),`mvn -pl file -am test`:BUILD SUCCESS,File 195项,0失败/错误/跳过。DeliveryBusinessFileTest 14项覆盖三类各自不可变上传、真实时间、50MiB边界和完整查询隔离,以及五个已移除类型的拒绝;DeliveryFileContractTest 1项锁定顺序、名称、映射及运行枚举一致性。仅本轮三类结果作为当前验收证据。 +- `mvn -pl print -am -Dtest=FreightA4PdfServiceGenerationTest,InternalPdfControllerTest -Dsurefire.failIfNoSpecifiedTests=false test`:10项通过,含PDFBox真实解析。没有把它作为派送业务打印已实现的证据。 +- AGENTS索引合同未通过:现有柜费分摊金额守恒、整组操作契约两项索引缺失;不属于本次File变更。 +- Lastleg绑定/替换删除后的用户可见性需由后端16专项测试及后续获准环境验收确认。 diff --git a/file/pom.xml b/file/pom.xml index e9b118423..7c0efeae4 100644 --- a/file/pom.xml +++ b/file/pom.xml @@ -5,6 +5,7 @@ com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml file-service + org.apache.poipoi-ooxml5.3.0 com.baiyue.sharedbaiyue-common-audit${project.version} com.baiyue.sharedbaiyue-common-openapi${project.version} com.baomidoumybatis-plus-spring-boot3-starter @@ -16,6 +17,7 @@ org.springframework.cloudspring-cloud-starter-openfeign org.springframework.cloudspring-cloud-starter-loadbalancer org.springframework.bootspring-boot-starter-actuator + org.apache.rocketmqrocketmq-spring-boot-starter com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest diff --git a/file/src/main/java/com/baiyue/file/FileServiceApplication.java b/file/src/main/java/com/baiyue/file/FileServiceApplication.java index d4de34b4b..9bdb9a83d 100644 --- a/file/src/main/java/com/baiyue/file/FileServiceApplication.java +++ b/file/src/main/java/com/baiyue/file/FileServiceApplication.java @@ -1,14 +1,19 @@ package com.baiyue.file; +import org.apache.ibatis.annotations.Mapper; +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; /** * 文件中心服务启动入口。 */ @SpringBootApplication(scanBasePackages = "com.baiyue") +@MapperScan(basePackages = "com.baiyue.file", annotationClass = Mapper.class) @EnableFeignClients(basePackages = "com.baiyue.file.client") +@EnableScheduling public class FileServiceApplication { public static void main(String[] args) { SpringApplication.run(FileServiceApplication.class, args); diff --git a/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java new file mode 100644 index 000000000..4fcb7d5df --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java @@ -0,0 +1,54 @@ +package com.baiyue.file.client; + +import jakarta.annotation.Resource; +import jakarta.annotation.PostConstruct; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.file.config.ExportDefinitionRegistry.Definition; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@Component +public class ExportProviderClient { + private Map clients; + @Resource private IamExportProviderClient iam; + @Resource private OrderExportProviderClient order; + @Resource private WarehouseExportProviderClient warehouse; + @Resource private FirstlegExportProviderClient firstleg; + @Resource private FinanceExportProviderClient finance; + @Resource + private MeterRegistry meters; + @PostConstruct + void initializeClients() { + this.clients = Map.of("iam-service", iam, "order-service", order, + "warehouse-service", warehouse, "firstleg-service", firstleg, + "finance-service", finance); + } + + public ApiResponse batch(String internalToken, String exportType, + Definition definition, ExportBatchRequest request) { + ExportProviderFeignClient client = clients.get(definition.getProviderService()); + if (client == null) throw new IllegalStateException("Export provider is not allowlisted: " + definition.getProviderService()); + long started = System.nanoTime(); + try { + ApiResponse response = client.batch(internalToken, exportType, request); + if (response == null || !response.successful()) failure(definition.getProviderService(), exportType); + return response; + } catch (RuntimeException exception) { + failure(definition.getProviderService(), exportType); + throw exception; + } finally { + meters.timer("export_provider_request_seconds", "service", definition.getProviderService(), "type", exportType) + .record(System.nanoTime() - started, TimeUnit.NANOSECONDS); + } + } + + private void failure(String service, String type) { + meters.counter("export_provider_failure_total", "service", service, "type", type).increment(); + } +} diff --git a/file/src/main/java/com/baiyue/file/client/ExportProviderFeignClient.java b/file/src/main/java/com/baiyue/file/client/ExportProviderFeignClient.java new file mode 100644 index 000000000..d069633f6 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/ExportProviderFeignClient.java @@ -0,0 +1,17 @@ +package com.baiyue.file.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +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; + +interface ExportProviderFeignClient { + @PostMapping("/internal/exports/v1/{exportType}/batches") + ApiResponse batch( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @PathVariable String exportType, + @RequestBody ExportBatchRequest request); +} diff --git a/file/src/main/java/com/baiyue/file/client/ExportProviderFeignConfiguration.java b/file/src/main/java/com/baiyue/file/client/ExportProviderFeignConfiguration.java new file mode 100644 index 000000000..b6df5f054 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/ExportProviderFeignConfiguration.java @@ -0,0 +1,31 @@ +package com.baiyue.file.client; + +import feign.Response; +import feign.codec.Decoder; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.cloud.openfeign.support.SpringDecoder; +import org.springframework.context.annotation.Bean; + +import java.io.IOException; +import java.io.InputStream; + +public class ExportProviderFeignConfiguration { + static final int MAX_RESPONSE_BYTES = 4 * 1024 * 1024; + + @Bean + Decoder exportProviderDecoder(ObjectFactory converters) { + Decoder delegate = new SpringDecoder(converters); + return (response, type) -> delegate.decode(withLimitedBody(response), type); + } + + private Response withLimitedBody(Response response) throws IOException { + if (response.body() == null) return response; + byte[] bytes; + try (InputStream input = response.body().asInputStream()) { + bytes = input.readNBytes(MAX_RESPONSE_BYTES + 1); + } + if (bytes.length > MAX_RESPONSE_BYTES) throw new ExportProviderResponseTooLargeException(MAX_RESPONSE_BYTES); + return response.toBuilder().body(bytes).build(); + } +} diff --git a/file/src/main/java/com/baiyue/file/client/ExportProviderResponseTooLargeException.java b/file/src/main/java/com/baiyue/file/client/ExportProviderResponseTooLargeException.java new file mode 100644 index 000000000..18b1a5a91 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/ExportProviderResponseTooLargeException.java @@ -0,0 +1,7 @@ +package com.baiyue.file.client; + +public class ExportProviderResponseTooLargeException extends RuntimeException { + public ExportProviderResponseTooLargeException(long limit) { + super("Provider 单批响应超过 " + limit + " 字节"); + } +} diff --git a/file/src/main/java/com/baiyue/file/client/FinanceExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/FinanceExportProviderClient.java new file mode 100644 index 000000000..63f2e8942 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/FinanceExportProviderClient.java @@ -0,0 +1,7 @@ +package com.baiyue.file.client; + +import org.springframework.cloud.openfeign.FeignClient; + +@FeignClient(name="finance-service", contextId="financeExportProviderClient", + url="${baiyue.clients.finance.url:}", configuration=ExportProviderFeignConfiguration.class) +interface FinanceExportProviderClient extends ExportProviderFeignClient { } diff --git a/file/src/main/java/com/baiyue/file/client/FirstlegExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/FirstlegExportProviderClient.java new file mode 100644 index 000000000..e17e61489 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/FirstlegExportProviderClient.java @@ -0,0 +1,6 @@ +package com.baiyue.file.client; + +import org.springframework.cloud.openfeign.FeignClient; + +@FeignClient(name="firstleg-service", contextId="firstlegExportProviderClient", url="${baiyue.clients.firstleg.url:}", configuration=ExportProviderFeignConfiguration.class) +interface FirstlegExportProviderClient extends ExportProviderFeignClient { } diff --git a/file/src/main/java/com/baiyue/file/client/IamExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/IamExportProviderClient.java new file mode 100644 index 000000000..e1af7b346 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/IamExportProviderClient.java @@ -0,0 +1,6 @@ +package com.baiyue.file.client; + +import org.springframework.cloud.openfeign.FeignClient; + +@FeignClient(name="iam-service", contextId="iamExportProviderClient", url="${baiyue.clients.iam.url:}", configuration=ExportProviderFeignConfiguration.class) +interface IamExportProviderClient extends ExportProviderFeignClient { } diff --git a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java index def2eeef7..181269c14 100644 --- a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java +++ b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java @@ -14,7 +14,45 @@ public interface IamQualificationClient { @RequestHeader("X-Internal-Service-Token") String internalToken, @RequestBody QualificationRequest request); + @PostMapping("/platform-qualifications") + ApiResponse submitPlatform( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @RequestBody PlatformQualificationRequest request); + + @PostMapping("/required-qualification-packages") + ApiResponse submitRequiredPackage( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @RequestBody RequiredPackageRequest request); + + @PostMapping("/platform-required-qualification-packages") + ApiResponse submitPlatformRequiredPackage( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @RequestBody RequiredPackageRequest request); + record QualificationRequest(Long tenantId, String qualificationType, - String certificateNo, String documentUrl) { } - record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus) { } + String certificateNo, String documentUrl, + String originalFileName, + java.time.LocalDate validTo, boolean replace) { } + record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus, + String replacedDocumentUrl) { } + record PlatformQualificationResponse(Long qualificationId, Long reviewId, String tenantStatus, + String replacedDocumentUrl) { } + record PlatformQualificationRequest(Long tenantId, String qualificationType, + String certificateNo, String documentUrl, + String originalFileName, + java.time.LocalDate validTo, boolean replace) { } + record RequiredPackageRequest(Long tenantId, String unifiedSocialCreditCode, + String businessLicenseDocumentUrl, + String businessLicenseOriginalFileName, + java.time.LocalDate businessLicenseValidTo, + boolean replaceBusinessLicense, + String legalPersonIdDocumentUrl, + String legalPersonIdOriginalFileName, + java.time.LocalDate legalPersonIdValidTo, + boolean replaceLegalPersonId) { } + record RequiredPackageResponse(Long businessLicenseQualificationId, + Long legalPersonIdQualificationId, + String tenantStatus, + String replacedBusinessLicenseDocumentUrl, + String replacedLegalPersonIdDocumentUrl) { } } diff --git a/file/src/main/java/com/baiyue/file/client/OrderExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/OrderExportProviderClient.java new file mode 100644 index 000000000..422d96da9 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/OrderExportProviderClient.java @@ -0,0 +1,6 @@ +package com.baiyue.file.client; + +import org.springframework.cloud.openfeign.FeignClient; + +@FeignClient(name="order-service", contextId="orderExportProviderClient", url="${baiyue.clients.order.url:}", configuration=ExportProviderFeignConfiguration.class) +interface OrderExportProviderClient extends ExportProviderFeignClient { } diff --git a/file/src/main/java/com/baiyue/file/client/WarehouseExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/WarehouseExportProviderClient.java new file mode 100644 index 000000000..23dde5644 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/WarehouseExportProviderClient.java @@ -0,0 +1,6 @@ +package com.baiyue.file.client; + +import org.springframework.cloud.openfeign.FeignClient; + +@FeignClient(name="warehouse-service", contextId="warehouseExportProviderClient", url="${baiyue.clients.warehouse.url:}", configuration=ExportProviderFeignConfiguration.class) +interface WarehouseExportProviderClient extends ExportProviderFeignClient { } diff --git a/file/src/main/java/com/baiyue/file/client/WeightOcrClient.java b/file/src/main/java/com/baiyue/file/client/WeightOcrClient.java new file mode 100644 index 000000000..c29a9d72d --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/WeightOcrClient.java @@ -0,0 +1,22 @@ +package com.baiyue.file.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; + +@FeignClient(name = "integration-service", contextId = "measurementWeightOcrClient", + url = "${baiyue.clients.integration.url:}", path = "/internal/integrations/weight-ocr") +public interface WeightOcrClient { + @PostMapping(value = "/single", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + ApiResponse recognize(@RequestHeader("X-Internal-Service-Token") String token, + @RequestPart("file") MultipartFile file); + + record Result(String status, BigDecimal weight, BigDecimal confidence, + String ocrRawText, String error) {} +} diff --git a/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java b/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java new file mode 100644 index 000000000..bec3b2a44 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java @@ -0,0 +1,78 @@ +package com.baiyue.file.config; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +@Component +@ConfigurationProperties(prefix = "baiyue.file.export") +public class ExportDefinitionRegistry { + private static final Set SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service", "finance-service"); + private static final Pattern TYPE = Pattern.compile("[A-Z0-9_]+"); + private static final Pattern PATH = Pattern.compile("/internal/exports/v1/([A-Z0-9_]+)/batches"); + private static final Pattern PERMISSION = Pattern.compile("[a-z]+(?::[a-z-]+){2,}"); + private Map definitions = Map.of(); + + @PostConstruct + public void validate() { + if (definitions.isEmpty()) throw new IllegalStateException("At least one export definition is required"); + definitions.forEach(this::validateDefinition); + } + + public Definition require(String exportType) { + Definition definition = definitions.get(exportType); + if (definition == null) throw new IllegalArgumentException("Unsupported export type: " + exportType); + return definition; + } + + private void validateDefinition(String type, Definition d) { + if (!TYPE.matcher(type).matches() || d == null) throw new IllegalStateException("Invalid export type: " + type); + if (!SERVICES.contains(d.providerService)) throw new IllegalStateException("Export provider is not allowlisted: " + d.providerService); + var path = PATH.matcher(d.providerPath == null ? "" : d.providerPath); + if (!path.matches() || !type.equals(path.group(1))) throw new IllegalStateException("Invalid provider path for " + type); + if (d.requiredPermission == null || !PERMISSION.matcher(d.requiredPermission).matches()) throw new IllegalStateException("Invalid export permission for " + type); + if (!Set.of("PLATFORM", "MERCHANT").contains(d.appScope)) throw new IllegalStateException("Invalid app scope for " + type); + if (d.maxRows < 1 || d.maxRows > 500_000 || d.batchSize < 50 || d.batchSize > 1_000 || d.maxAttempts < 1 || d.maxAttempts > 5) { + throw new IllegalStateException("Invalid export limits for " + type); + } + } + + public Map getDefinitions() { return definitions; } + public void setDefinitions(Map definitions) { + Map candidate = definitions == null ? Map.of() : Map.copyOf(definitions); + if (candidate.isEmpty()) throw new IllegalStateException("At least one export definition is required"); + candidate.forEach(this::validateDefinition); + this.definitions = candidate; + } + + public static class Definition { + private String providerService; + private String providerPath; + private String requiredPermission; + private String appScope; + private boolean enabled = true; + private int maxRows = 200_000; + private int maxAttempts = 3; + private int batchSize = 500; + public String getProviderService() { return providerService; } + public void setProviderService(String value) { providerService = value; } + public String getProviderPath() { return providerPath; } + public void setProviderPath(String value) { providerPath = value; } + public String getRequiredPermission() { return requiredPermission; } + public void setRequiredPermission(String value) { requiredPermission = value; } + public String getAppScope() { return appScope; } + public void setAppScope(String value) { appScope = value; } + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean value) { enabled = value; } + public int getMaxRows() { return maxRows; } + public void setMaxRows(int value) { maxRows = value; } + public int getMaxAttempts() { return maxAttempts; } + public void setMaxAttempts(int value) { maxAttempts = value; } + public int getBatchSize() { return batchSize; } + public void setBatchSize(int value) { batchSize = value; } + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java new file mode 100644 index 000000000..64cac7d87 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java @@ -0,0 +1,94 @@ +package com.baiyue.file.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.file.service.InternalBusinessFileService; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.RequestHeader; +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 java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/file/v1/business-files") +public class InternalBusinessFileController { + @Resource private InternalBusinessFileService service; + @Value("${baiyue.security.internal-service-token}") private String expected; + + @PostMapping(value = "/{tenantId}/{businessType}/{businessId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse upload( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, + @PathVariable @NotBlank String businessType, + @PathVariable @NotBlank String businessId, + @RequestPart("file") MultipartFile file) { + check(token); + checkTenantId(tenantId); + return ApiResponse.success(service.upload(tenantId, businessType, businessId, operatorId, file)); + } + + @GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") + public ApiResponse metadata( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, + @PathVariable @NotBlank String businessType, + @PathVariable @NotBlank String businessId, + @PathVariable @Positive long fileId) { + check(token); + checkTenantId(tenantId); + return ApiResponse.success(service.metadata(tenantId, businessType, businessId, fileId)); + } + + @GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}/content") + public ResponseEntity download( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, + @PathVariable @NotBlank String businessType, + @PathVariable @NotBlank String businessId, + @PathVariable @Positive long fileId) { + check(token); + checkTenantId(tenantId); + var value = service.download(tenantId, businessType, businessId, fileId); + MediaType contentType; + try { + contentType = MediaType.parseMediaType(value.contentType()); + } catch (IllegalArgumentException exception) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .contentType(contentType) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename(value.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentLength(value.content().length) + .body(value.content()); + } + + private void check(String token) { + if (token == null || !MessageDigest.isEqual( + token.getBytes(StandardCharsets.UTF_8), expected.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } + } + + private static void checkTenantId(long tenantId) { + if (tenantId < 0) throw new BizException(ResultCode.BAD_REQUEST, "客户作用域不能为负数"); + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java b/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java new file mode 100644 index 000000000..252f8cf20 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java @@ -0,0 +1,75 @@ +package com.baiyue.file.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.file.service.LoadingEvidenceService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.*; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** Firstleg 使用的装柜证据文件内部接口。 */ +@RestController +@RequestMapping("/internal/file/v1/loading-evidence") +public class InternalLoadingEvidenceController { + @Resource private LoadingEvidenceService service; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + private byte[] expectedToken; + + @PostConstruct void initializeToken() { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); } + + @PostMapping(value = "/{containerBookingId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse upload( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, @PathVariable long containerBookingId, + @RequestParam String evidenceType, @RequestPart("file") MultipartFile file) { + requireToken(token); + if (containerBookingId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和操作主体ID必须大于0"); + return ApiResponse.success(service.upload(containerBookingId, operatorId, evidenceType, file)); + } + + @GetMapping("/{containerBookingId}/{fileId}") + public ApiResponse validate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long containerBookingId, @PathVariable long fileId) { + requireToken(token); + if (containerBookingId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和文件ID必须大于0"); + return ApiResponse.success(service.validate(containerBookingId, fileId)); + } + + @GetMapping("/{containerBookingId}/{fileId}/content") + public ResponseEntity download( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long containerBookingId, @PathVariable long fileId) { + requireToken(token); + if (containerBookingId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和文件ID必须大于0"); + var value = service.download(containerBookingId, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content()); + } + + private void requireToken(String token) { + if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); + } + } + + private static MediaType safeImageMediaType(String value) { + if (value == null || value.isBlank()) return MediaType.APPLICATION_OCTET_STREAM; + try { + MediaType mediaType = MediaType.parseMediaType(value); + return "image".equalsIgnoreCase(mediaType.getType()) ? mediaType : MediaType.APPLICATION_OCTET_STREAM; + } catch (org.springframework.http.InvalidMediaTypeException exception) { + return MediaType.APPLICATION_OCTET_STREAM; + } + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/InternalMeasurementImageController.java b/file/src/main/java/com/baiyue/file/controller/InternalMeasurementImageController.java new file mode 100644 index 000000000..7e76c25c9 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/InternalMeasurementImageController.java @@ -0,0 +1,129 @@ +package com.baiyue.file.controller; + +import jakarta.annotation.Resource; +import jakarta.annotation.PostConstruct; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.service.MeasurementImageService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +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.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; + +/** Firstleg 保存第三方尺寸测量证据图片的内部入口。 */ +@RestController +@RequestMapping("/internal/file/v1/measurement-images") +public class InternalMeasurementImageController { + @Resource + private MeasurementImageService images; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + private byte[] expectedToken; + + @PostConstruct + void initializeToken() { + expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); + } + + @PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse upload( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, + @PathVariable long inboundId, + @RequestPart("file") MultipartFile file) { + requireToken(token); + if (tenantId <= 0 || inboundId <= 0 || operatorId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和操作主体ID必须大于0"); + } + return ApiResponse.success( + images.uploadMeasurementEvidence(tenantId, inboundId, operatorId, file)); + } + + @PostMapping(value = "/receipts/{tenantId}/{receiptId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadReceipt( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, + @PathVariable long receiptId, + @RequestPart("file") MultipartFile file) { + requireToken(token); + if (tenantId <= 0 || receiptId <= 0 || operatorId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "客户、收件记录和操作主体ID必须大于0"); + } + return ApiResponse.success(images.uploadMojiaReceiptEvidence( + tenantId, receiptId, operatorId, file)); + } + + @DeleteMapping("/{tenantId}/{inboundId}/{fileId}") + public ApiResponse delete( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, + @PathVariable long inboundId, + @PathVariable long fileId) { + requireToken(token); + if (tenantId <= 0 || inboundId <= 0 || fileId <= 0 || operatorId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单、文件和操作主体ID必须大于0"); + } + images.deleteMeasurementEvidence(tenantId, inboundId, fileId, operatorId); + return ApiResponse.success(null); + } + + @DeleteMapping("/receipts/{tenantId}/{receiptId}/{fileId}") + public ApiResponse deleteReceipt( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, + @PathVariable long receiptId, + @PathVariable long fileId) { + requireToken(token); + if (tenantId <= 0 || receiptId <= 0 || fileId <= 0 || operatorId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "客户、收件记录、文件和操作主体ID必须大于0"); + } + images.deleteMojiaReceiptEvidence(tenantId, receiptId, fileId, operatorId); + return ApiResponse.success(null); + } + + @GetMapping("/{tenantId}/{inboundId}/{fileId}") + public ResponseEntity download( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, + @PathVariable long inboundId, + @PathVariable long fileId) { + requireToken(token); + if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0"); + } + var image = images.download(tenantId, inboundId, fileId); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(image.contentType())) + .contentLength(image.content().length) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() + .filename(image.fileName(), StandardCharsets.UTF_8).build().toString()) + .body(image.content()); + } + + private void requireToken(String token) { + if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); + } + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/InternalPriceBookFileController.java b/file/src/main/java/com/baiyue/file/controller/InternalPriceBookFileController.java new file mode 100644 index 000000000..1952954f4 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/InternalPriceBookFileController.java @@ -0,0 +1,89 @@ +package com.baiyue.file.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.file.service.PriceBookFileService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.RequestHeader; +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 java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** Internal source-file API used only by Firstleg price-book workflows. */ +@RestController +@RequestMapping("/internal/file/v1/price-books") +public class InternalPriceBookFileController { + @Resource + private PriceBookFileService service; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + private byte[] expectedToken; + + @PostConstruct + void initializeToken() { + expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); + } + + @PostMapping(value = "/{businessId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse upload( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long businessId, + @RequestPart("file") MultipartFile file) { + requireAccess(token, operatorId, businessId, null); + return ApiResponse.success(service.upload(businessId, operatorId, file)); + } + + @GetMapping("/{businessId}/{fileId}/metadata") + public ApiResponse metadata( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long businessId, + @PathVariable long fileId) { + requireAccess(token, operatorId, businessId, fileId); + return ApiResponse.success(service.metadata(businessId, fileId)); + } + + @GetMapping("/{businessId}/{fileId}/content") + public ResponseEntity download( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long businessId, + @PathVariable long fileId) { + requireAccess(token, operatorId, businessId, fileId); + PriceBookFileService.Download download = service.download(businessId, fileId); + PriceBookFileService.FileMetadata metadata = download.metadata(); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(metadata.contentType())) + .contentLength(download.content().length) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(metadata.fileName(), StandardCharsets.UTF_8).build().toString()) + .header("X-Content-Type-Options", "nosniff") + .body(download.content()); + } + + private void requireAccess(String token, long operatorId, long businessId, Long fileId) { + if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); + } + if (operatorId <= 0 || businessId <= 0 || (fileId != null && fileId <= 0)) { + throw new BizException(ResultCode.BAD_REQUEST, "操作主体、价格版本业务和文件ID必须大于0"); + } + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/InternalProductImageController.java b/file/src/main/java/com/baiyue/file/controller/InternalProductImageController.java new file mode 100644 index 000000000..51a135d08 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/InternalProductImageController.java @@ -0,0 +1,48 @@ +package com.baiyue.file.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.file.service.ProductImageService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/file/v1/product-images") +public class InternalProductImageController { + @Resource private ProductImageService images; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + private byte[] expectedToken; + + @PostConstruct + void initializeToken() { + expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); + } + + @GetMapping("/{tenantId}/{fileId}") + public ApiResponse validate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, + @PathVariable long fileId) { + requireToken(token); + if (tenantId <= 0 || fileId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "客户和文件ID必须大于0"); + } + return ApiResponse.success(images.validate(tenantId, fileId)); + } + + private void requireToken(String token) { + if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); + } + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/MerchantOrderBatchImportFileController.java b/file/src/main/java/com/baiyue/file/controller/MerchantOrderBatchImportFileController.java new file mode 100644 index 000000000..beab17f86 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/MerchantOrderBatchImportFileController.java @@ -0,0 +1,59 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.InternalBusinessFileService; +import jakarta.annotation.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +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.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; + +@RestController +@RequestMapping("/api/merchant/v1/files/order-batch-imports") +public class MerchantOrderBatchImportFileController { + private static final String ERROR_REPORT = "ORDER_BATCH_IMPORT_ERROR_REPORT"; + + @Resource private InternalBusinessFileService files; + + @GetMapping("/{batchNo}/error-reports/{fileId}") + @PreAuthorize("hasAuthority('merchant:order:create')") + public ResponseEntity downloadErrorReport( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String batchNo, + @PathVariable long fileId) { + var value = files.download(merchantTenant(user), ERROR_REPORT, batchNo, fileId); + MediaType contentType; + try { + contentType = MediaType.parseMediaType(value.contentType()); + } catch (IllegalArgumentException exception) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .contentType(contentType) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename(value.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentLength(value.content().length) + .body(value.content()); + } + + private static long merchantTenant(AuthenticatedUser user) { + if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() <= 0 + || user.userId() == null || user.userId() <= 0) { + throw new BizException(ResultCode.FORBIDDEN, "Merchant identity is required"); + } + return user.tenantId(); + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/MerchantProductImageController.java b/file/src/main/java/com/baiyue/file/controller/MerchantProductImageController.java new file mode 100644 index 000000000..060fea945 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/MerchantProductImageController.java @@ -0,0 +1,62 @@ +package com.baiyue.file.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.security.AuthenticatedUser; +import com.baiyue.file.service.ProductImageService; +import jakarta.annotation.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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 java.nio.charset.StandardCharsets; + +@RestController +@RequestMapping("/api/merchant/v1/files/product-images") +public class MerchantProductImageController { + @Resource private ProductImageService images; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit')") + public ApiResponse upload( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestPart("file") MultipartFile file) { + long tenantId = merchantTenant(user); + return ApiResponse.success(images.upload(tenantId, user.userId(), file)); + } + + @GetMapping("/{fileId}") + @PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit','merchant:order:view')") + public ResponseEntity download( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long fileId) { + var image = images.download(merchantTenant(user), fileId); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(image.contentType())) + .contentLength(image.content().length) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() + .filename(image.fileName(), StandardCharsets.UTF_8).build().toString()) + .body(image.content()); + } + + private static long merchantTenant(AuthenticatedUser user) { + if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() <= 0 || user.userId() == null || user.userId() <= 0) { + throw new BizException(ResultCode.FORBIDDEN, "Merchant identity is required"); + } + return user.tenantId(); + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java b/file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java new file mode 100644 index 000000000..fd0c60fea --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java @@ -0,0 +1,82 @@ +package com.baiyue.file.controller; + +import jakarta.annotation.Resource; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest; +import com.baiyue.file.dto.ExportTaskDtos.ExportTaskResponse; +import com.baiyue.file.service.ExportTaskService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +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.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; + +@RestController +@RequestMapping("/api/platform/v1/files/export-tasks") +@Validated +public class PlatformExportTaskController { + @Resource + private ExportTaskService service; + + @PostMapping + @PreAuthorize("isAuthenticated()") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody CreateExportTaskRequest request) { + return ApiResponse.success(service.create(user, request)); + } + + @GetMapping + @PreAuthorize("hasAuthority('platform:download:list')") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue = "1") @Min(1) long page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) long size, + @RequestParam(required = false) String exportType, + @RequestParam(required = false) @Pattern(regexp = "PENDING|RUNNING|SUCCEEDED|FAILED|EXPIRED") String status, + @RequestParam(required = false) LocalDate requestedFrom, + @RequestParam(required = false) LocalDate requestedTo) { + return ApiResponse.success(service.page(user, page, size, exportType, status, requestedFrom, requestedTo)); + } + + @GetMapping("/{id:\\d+}") + @PreAuthorize("hasAuthority('platform:download:list')") + public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id) { + return ApiResponse.success(service.get(user, id)); + } + + @GetMapping("/{id:\\d+}/download") + @PreAuthorize("hasAuthority('platform:download:list')") + public ResponseEntity download( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id) { + var download = service.download(user, id); + ContentDisposition disposition = ContentDisposition.attachment() + .filename(download.fileName(), StandardCharsets.UTF_8).build(); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .contentType(MediaType.parseMediaType(download.contentType())) + .body(download.resource()); + } + + @PostMapping("/{id:\\d+}/retry") + @PreAuthorize("hasAuthority('platform:download:list')") + public ApiResponse retry(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id) { + return ApiResponse.success(service.retry(user, id)); + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java b/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java new file mode 100644 index 000000000..2bcffa826 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java @@ -0,0 +1,63 @@ +package com.baiyue.file.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.security.AuthenticatedUser; +import com.baiyue.file.service.LoadingEvidenceService; +import jakarta.annotation.Resource; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; + +/** 平台及作业端装柜证据上传和读取。 */ +@RestController +@RequestMapping("/api/platform/v1/files/loading-evidence") +public class PlatformLoadingEvidenceController { + @Resource private LoadingEvidenceService service; + + @PostMapping(value = "/{containerBookingId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')") + public ApiResponse upload( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long containerBookingId, + @RequestParam String evidenceType, @RequestPart("file") MultipartFile file) { + long operatorId = requirePlatform(user); requirePositive(containerBookingId); + return ApiResponse.success(service.upload(containerBookingId, operatorId, evidenceType, file)); + } + + @GetMapping("/{containerBookingId}/{fileId}") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')") + public ResponseEntity download(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long containerBookingId, @PathVariable long fileId) { + requirePlatform(user); requirePositive(containerBookingId, fileId); + var value = service.download(containerBookingId, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() + .filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content()); + } + + private long requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) || user.tenantId() == null + || user.tenantId() != 0L || user.userId() == null || user.userId() <= 0) { + throw new BizException(ResultCode.FORBIDDEN, "需要平台端身份"); + } + return user.userId(); + } + private static void requirePositive(long... ids) { + for (long id : ids) if (id <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须大于0"); + } + private static MediaType safeImageMediaType(String value) { + if (value == null || value.isBlank()) return MediaType.APPLICATION_OCTET_STREAM; + try { + MediaType mediaType = MediaType.parseMediaType(value); + return "image".equalsIgnoreCase(mediaType.getType()) ? mediaType : MediaType.APPLICATION_OCTET_STREAM; + } catch (InvalidMediaTypeException exception) { + return MediaType.APPLICATION_OCTET_STREAM; + } + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java b/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java new file mode 100644 index 000000000..fd52b336a --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java @@ -0,0 +1,116 @@ +package com.baiyue.file.controller; + +import jakarta.annotation.Resource; + +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.security.AuthenticatedUser; +import com.baiyue.file.service.MeasurementImageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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 java.nio.charset.StandardCharsets; + +/** 平台入库测量图片上传、OCR 和受控读取入口。 */ +@RestController +@RequestMapping("/api/platform/v1/files/measurements") +@Tag(name = "平台入库测量图片") +public class PlatformMeasurementImageController { + @Resource + private MeasurementImageService images; + + @PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')") + @Operation(summary = "上传称重图片并执行 OCR", + description = "图片先持久化再识别;OCR 失败时保留文件,允许重试或人工录入。") + public ApiResponse upload( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long tenantId, + @PathVariable long inboundId, + @RequestPart("file") MultipartFile file) { + long operatorId = requirePlatform(user); + requirePositive(tenantId, inboundId); + return ApiResponse.success(images.upload(tenantId, inboundId, operatorId, file)); + } + + @PostMapping("/{tenantId}/{inboundId}/{fileId}/recognize") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')") + @Operation(summary = "重试称重图片 OCR") + public ApiResponse retryRecognition( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long tenantId, + @PathVariable long inboundId, + @PathVariable long fileId) { + requirePlatform(user); + requirePositive(tenantId, inboundId, fileId); + return ApiResponse.success(images.retryRecognition(tenantId, inboundId, fileId)); + } + + @GetMapping("/{tenantId}/{inboundId}/{fileId}") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')") + @Operation(summary = "查看入库单称重图片") + public ResponseEntity download( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long tenantId, + @PathVariable long inboundId, + @PathVariable long fileId) { + requirePlatform(user); + requirePositive(tenantId, inboundId, fileId); + var image = images.download(tenantId, inboundId, fileId); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(image.contentType())) + .contentLength(image.content().length) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() + .filename(image.fileName(), StandardCharsets.UTF_8).build().toString()) + .body(image.content()); + } + + @GetMapping("/{tenantId}/{inboundId}/{fileId}/thumbnail") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')") + @Operation(summary = "查看入库单称重图片缩略图") + public ResponseEntity thumbnail( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long tenantId, + @PathVariable long inboundId, + @PathVariable long fileId) { + requirePlatform(user); + requirePositive(tenantId, inboundId, fileId); + var image = images.thumbnail(tenantId, inboundId, fileId); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .contentType(MediaType.IMAGE_JPEG) + .contentLength(image.content().length) + .body(image.content()); + } + + private long requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L + || user.userId() == null || user.userId() <= 0) { + throw new BizException(ResultCode.FORBIDDEN, "需要平台端身份"); + } + return user.userId(); + } + + private void requirePositive(long... ids) { + for (long id : ids) { + if (id <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须大于0"); + } + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformProductImageController.java b/file/src/main/java/com/baiyue/file/controller/PlatformProductImageController.java new file mode 100644 index 000000000..4ffcf47d0 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/PlatformProductImageController.java @@ -0,0 +1,55 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.ProductImageService; +import jakarta.annotation.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +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.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; + +/** 鐢ㄤ簬骞冲彴绔鍗曡鎯呮煡鐪嬪晢鎴峰晢鍝佸浘鐗囥€? */ +@RestController +@RequestMapping("/api/platform/v1/files/product-images") +public class PlatformProductImageController { + @Resource private ProductImageService images; + + @GetMapping("/{tenantId}/{fileId}") + @PreAuthorize("hasAuthority('platform:order:list')") + public ResponseEntity download( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long tenantId, + @PathVariable long fileId) { + requirePlatform(user); + if (tenantId <= 0 || fileId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "瀹㈡埛鍜屾枃浠禝D蹇呴』澶т簬0"); + } + var image = images.download(tenantId, fileId); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(image.contentType())) + .contentLength(image.content().length) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() + .filename(image.fileName(), StandardCharsets.UTF_8).build().toString()) + .body(image.content()); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L + || user.userId() == null || user.userId() <= 0) { + throw new BizException(ResultCode.FORBIDDEN, "闇€瑕佸钩鍙扮韬唤"); + } + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java index 9792adab9..e4694bede 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java @@ -1,10 +1,15 @@ package com.baiyue.file.controller; import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.client.IamQualificationClient; import com.baiyue.file.service.LocalQualificationStorage; +import com.baiyue.file.service.QualificationPackageService; import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.CacheControl; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -14,23 +19,113 @@ 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.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.beans.factory.annotation.Value; + +import java.time.LocalDate; @RestController @RequestMapping("/api/platform/v1/files/qualifications") public class PlatformQualificationFileController { + private static final Logger LOGGER = LoggerFactory.getLogger(PlatformQualificationFileController.class); @Resource private LocalQualificationStorage storage; + @Resource private IamQualificationClient iamClient; + @Resource private QualificationPackageService packageService; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + @PostMapping(value = "/{tenantId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')") + public ApiResponse upload( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long tenantId, + @RequestPart("file") MultipartFile file, + @RequestParam String qualificationType, + @RequestParam(required = false) String certificateNo, + @RequestParam(required = false) LocalDate validTo, + @RequestParam(defaultValue = "false") boolean replace) { + requirePlatform(user); + requireCustomer(tenantId); + qualificationType = QualificationTypes.requireOptional(qualificationType); + var stored = storage.store(tenantId, file); + try { + var response = iamClient.submitPlatform(internalToken, + new IamQualificationClient.PlatformQualificationRequest( + tenantId, qualificationType, certificateNo, stored.documentUrl(), + file.getOriginalFilename(), validTo, replace)); + if (response == null) { + throw new IllegalStateException("IAM qualification response is empty"); + } + if (!response.successful()) { + ResultCode code = ResultCode.of(response.code()); + String message = response.message() == null || response.message().isBlank() + ? code.message() : response.message(); + storage.delete(stored); + throw new BizException(code, message); + } + if (response.data() == null) { + throw new IllegalStateException("IAM qualification response data is empty"); + } + var data = response.data(); + if (!stored.documentUrl().equals(data.replacedDocumentUrl())) { + storage.deleteByDocumentUrl(tenantId, data.replacedDocumentUrl()); + } + return ApiResponse.success(new QualificationFileController.UploadResponse( + data.qualificationId(), data.reviewId(), data.tenantStatus(), stored.documentUrl())); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + LOGGER.error("IAM 平台资质登记结果未知,保留已写入文件等待重试或对账,tenantId={},documentUrl={}", + tenantId, stored.documentUrl(), exception); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable"); + } + } + + @PostMapping(value = "/{tenantId}/required-package", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')") + public ApiResponse uploadRequiredPackage( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long tenantId, + @RequestPart(value = "businessLicense", required = false) MultipartFile businessLicense, + @RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId, + @RequestParam String unifiedSocialCreditCode, + @RequestParam(required = false) LocalDate businessLicenseValidTo, + @RequestParam(required = false) LocalDate legalPersonIdValidTo, + @RequestParam(defaultValue = "false") boolean replaceBusinessLicense, + @RequestParam(defaultValue = "false") boolean replaceLegalPersonId) { + requirePlatform(user); + requireCustomer(tenantId); + return ApiResponse.success(packageService.upload(tenantId, businessLicense, legalPersonId, + unifiedSocialCreditCode, businessLicenseValidTo, legalPersonIdValidTo, + replaceBusinessLicense, replaceLegalPersonId, true)); + } @GetMapping("/{tenantId}/{fileName}") - @PreAuthorize("hasAuthority('platform:customer:review')") + @PreAuthorize("hasAuthority('platform:customer:list')") public ResponseEntity download( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable String fileName) { - if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())) { - throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required"); - } + requirePlatform(user); + requireCustomer(tenantId); var resource = storage.load(tenantId, fileName); - String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE : MediaType.IMAGE_JPEG_VALUE; + String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE + : (fileName.endsWith(".pdf") ? MediaType.APPLICATION_PDF_VALUE : MediaType.IMAGE_JPEG_VALUE); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .contentType(MediaType.parseMediaType(mediaType)).body(resource); } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L) { + throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required"); + } + } + + private void requireCustomer(long tenantId) { + if (tenantId <= 0L) { + throw new BizException(ResultCode.BAD_REQUEST, "Customer identity must be positive"); + } + } } diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java index 3dea234c6..fd8cf524b 100644 --- a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java @@ -6,8 +6,10 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.file.client.IamQualificationClient; import com.baiyue.file.service.LocalQualificationStorage; -import feign.FeignException; +import com.baiyue.file.service.QualificationPackageService; import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.CacheControl; import org.springframework.http.MediaType; @@ -17,11 +19,15 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDate; + @RestController @RequestMapping("/api/merchant/v1/files/qualifications") public class QualificationFileController { + private static final Logger LOGGER = LoggerFactory.getLogger(QualificationFileController.class); @Resource private LocalQualificationStorage storage; @Resource private IamQualificationClient iamClient; + @Resource private QualificationPackageService packageService; @Value("${baiyue.security.internal-service-token}") private String internalToken; @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -29,34 +35,67 @@ public class QualificationFileController { public ApiResponse upload( @AuthenticationPrincipal AuthenticatedUser user, @RequestPart("file") MultipartFile file, - @RequestParam(value = "certificateNo", required = false) String certificateNo) { + @RequestParam String qualificationType, + @RequestParam(value = "certificateNo", required = false) String certificateNo, + @RequestParam(required = false) LocalDate validTo, + @RequestParam(defaultValue = "false") boolean replace) { long tenantId = merchantTenant(user); + qualificationType = QualificationTypes.requireSingleUpload(qualificationType); var stored = storage.store(tenantId, file); try { var response = iamClient.submit(internalToken, - new IamQualificationClient.QualificationRequest(tenantId, "BUSINESS_LICENSE", - certificateNo, stored.documentUrl())); - if (response == null || !response.successful() || response.data() == null) { - throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Qualification workflow failed"); + new IamQualificationClient.QualificationRequest(tenantId, qualificationType, + certificateNo, stored.documentUrl(), file.getOriginalFilename(), validTo, replace)); + if (response == null) { + throw new IllegalStateException("IAM qualification response is empty"); + } + if (!response.successful()) { + ResultCode code = ResultCode.of(response.code()); + String message = response.message() == null || response.message().isBlank() + ? code.message() : response.message(); + storage.delete(stored); + throw new BizException(code, message); + } + if (response.data() == null) { + throw new IllegalStateException("IAM qualification response data is empty"); } var data = response.data(); + if (!stored.documentUrl().equals(data.replacedDocumentUrl())) { + storage.deleteByDocumentUrl(tenantId, data.replacedDocumentUrl()); + } return ApiResponse.success(new UploadResponse(data.qualificationId(), data.reviewId(), data.tenantStatus(), stored.documentUrl())); + } catch (BizException exception) { + throw exception; } catch (RuntimeException exception) { - storage.delete(stored); - if (exception instanceof FeignException feign && feign.status() == 409) { - throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review"); - } - if (exception instanceof BizException business) throw business; + LOGGER.error("IAM 资质登记结果未知,保留已写入文件等待重试或对账,tenantId={},documentUrl={}", + tenantId, stored.documentUrl(), exception); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable"); } } + @PostMapping(value = "/required-package", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('merchant:qualification:upload')") + public ApiResponse uploadRequiredPackage( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestPart(value = "businessLicense", required = false) MultipartFile businessLicense, + @RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId, + @RequestParam String unifiedSocialCreditCode, + @RequestParam(required = false) LocalDate businessLicenseValidTo, + @RequestParam(required = false) LocalDate legalPersonIdValidTo, + @RequestParam(defaultValue = "false") boolean replaceBusinessLicense, + @RequestParam(defaultValue = "false") boolean replaceLegalPersonId) { + return ApiResponse.success(packageService.upload(merchantTenant(user), businessLicense, legalPersonId, + unifiedSocialCreditCode, businessLicenseValidTo, legalPersonIdValidTo, + replaceBusinessLicense, replaceLegalPersonId, false)); + } + @GetMapping("/{fileName}") public ResponseEntity download( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String fileName) { var resource = storage.load(merchantTenant(user), fileName); - String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE : MediaType.IMAGE_JPEG_VALUE; + String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE + : (fileName.endsWith(".pdf") ? MediaType.APPLICATION_PDF_VALUE : MediaType.IMAGE_JPEG_VALUE); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .contentType(MediaType.parseMediaType(mediaType)).body(resource); } diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java b/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java new file mode 100644 index 000000000..ce5363724 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java @@ -0,0 +1,43 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; + +import java.util.Set; + +final class QualificationTypes { + private static final Set SUPPORTED = Set.of( + "BUSINESS_LICENSE", + "LEGAL_PERSON_ID", + "FREIGHT_FORWARDER_CERTIFICATE", + "BANK_ACCOUNT_LICENSE"); + + private QualificationTypes() { } + + static String requireSupported(String qualificationType) { + if (qualificationType == null || !SUPPORTED.contains(qualificationType)) { + throw new BizException("Unsupported qualification type / 不支持的资质类型"); + } + return qualificationType; + } + + static String requireSingleUpload(String qualificationType) { + String type = requireSupported(qualificationType); + if ("BUSINESS_LICENSE".equals(type)) { + throw new BizException(ResultCode.BAD_REQUEST, + "The business license must be uploaded with its unified social credit code" + + " / 营业执照必须与统一社会信用代码一并上传"); + } + return type; + } + + static String requireOptional(String qualificationType) { + String type = requireSupported(qualificationType); + if ("BUSINESS_LICENSE".equals(type) || "LEGAL_PERSON_ID".equals(type)) { + throw new BizException(ResultCode.BAD_REQUEST, + "Business license and legal-person ID use the platform qualification package" + + " / 平台端营业执照与法人身份证通过资质包上传"); + } + return type; + } +} diff --git a/file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java b/file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java new file mode 100644 index 000000000..beb48161b --- /dev/null +++ b/file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java @@ -0,0 +1,25 @@ +package com.baiyue.file.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.Map; + +public final class ExportTaskDtos { + private ExportTaskDtos() { } + + public record CreateExportTaskRequest( + @NotBlank @Size(max = 64) String exportType, + @NotBlank @Size(max = 16) String format, + @NotBlank @Size(max = 128) String taskName, + Map parameters) { } + + public record ExportTaskResponse( + String id, String taskNo, String exportType, String format, String taskName, + String status, Long totalRows, long processedRows, String fileName, + String contentType, Long sizeBytes, String failureMessage, + int attemptCount, Integer maxAttempts, LocalDateTime nextRetryAt, + String requestedBy, String requestedUsername, LocalDateTime requestedAt, + LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime expiresAt) { } +} diff --git a/file/src/main/java/com/baiyue/file/entity/FileExportOutbox.java b/file/src/main/java/com/baiyue/file/entity/FileExportOutbox.java new file mode 100644 index 000000000..9020089a8 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/entity/FileExportOutbox.java @@ -0,0 +1,31 @@ +package com.baiyue.file.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("file_export_outbox") +public class FileExportOutbox { + @TableId private Long id; + private String eventId; + private Long taskId; + private Integer dispatchVersion; + private String topic; + private String tag; + private String messageKey; + private String payloadJson; + private String status; + private Integer attemptCount; + private LocalDateTime nextRetryAt; + private String leaseOwner; + private LocalDateTime leaseUntil; + private LocalDateTime sentAt; + private String lastError; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/file/src/main/java/com/baiyue/file/entity/FileExportTask.java b/file/src/main/java/com/baiyue/file/entity/FileExportTask.java new file mode 100644 index 000000000..d2752731f --- /dev/null +++ b/file/src/main/java/com/baiyue/file/entity/FileExportTask.java @@ -0,0 +1,45 @@ +package com.baiyue.file.entity; + +import com.baiyue.common.core.model.BaseEntity; +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("file_export_task") +public class FileExportTask extends BaseEntity { + @TableId private Long id; + private Long tenantId; + private String appCode; + private String taskNo; + private String exportType; + private String format; + private String taskName; + private String parametersJson; + private String status; + private Long totalRows; + private Long processedRows; + private String fileName; + private String objectKey; + private String contentType; + private Long sizeBytes; + private String failureMessage; + private Integer dispatchVersion; + private Integer attemptCount; + private Integer maxAttempts; + private LocalDateTime nextRetryAt; + private String leaseOwner; + private LocalDateTime leaseUntil; + private LocalDateTime lastHeartbeatAt; + private LocalDateTime executionDeadlineAt; + private Long requestedBy; + private String requestedUsername; + private LocalDateTime requestedAt; + private LocalDateTime startedAt; + private LocalDateTime completedAt; + private LocalDateTime expiresAt; +} diff --git a/file/src/main/java/com/baiyue/file/entity/FileObjectEntity.java b/file/src/main/java/com/baiyue/file/entity/FileObjectEntity.java new file mode 100644 index 000000000..c65be7d56 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/entity/FileObjectEntity.java @@ -0,0 +1,34 @@ +package com.baiyue.file.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("file_object") +public class FileObjectEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private String businessType; + private String businessId; + private String originalName; + private String objectKey; + private String storageProvider; + private String bucketName; + private String contentType; + private Long sizeBytes; + private String sha256; + private String status; + private Long createdBy; + private LocalDateTime createdAt; + private Long updatedBy; + private LocalDateTime updatedAt; + private Boolean deleted; + private Integer version; +} diff --git a/file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java b/file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java new file mode 100644 index 000000000..a8104f66e --- /dev/null +++ b/file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java @@ -0,0 +1,63 @@ +package com.baiyue.file.mapper; + +import com.baiyue.file.entity.FileExportOutbox; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Delete; +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 FileExportOutboxMapper extends BaseMapper { + @Insert(""" + INSERT INTO file_export_outbox(id,event_id,task_id,dispatch_version,topic,tag,message_key,payload_json, + status,attempt_count,next_retry_at,created_at,updated_at) + VALUES(#{id},#{eventId},#{taskId},#{dispatchVersion},#{topic},#{tag},#{messageKey},CAST(#{payloadJson} AS JSON), + 'PENDING',0,(SELECT next_retry_at FROM file_export_task WHERE id=#{taskId}),CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertCommand(FileExportOutbox row); + @Select("SELECT id FROM file_export_outbox WHERE status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3) ORDER BY next_retry_at,id LIMIT #{limit}") + List publishableIds(@Param("limit") int limit); + + @Update("UPDATE file_export_outbox SET status='CLAIMED',lease_owner=#{owner},lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND),attempt_count=attempt_count+1 WHERE id=#{id} AND status='PENDING'") + int claim(@Param("id") long id, @Param("owner") String owner, @Param("leaseSeconds") int leaseSeconds); + + @Update("UPDATE file_export_outbox SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL,last_error=NULL WHERE id=#{id} AND status='CLAIMED' AND lease_owner=#{owner}") + int sent(@Param("id") long id, @Param("owner") String owner); + + @Update("UPDATE file_export_outbox SET status='PENDING',next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND),lease_owner=NULL,lease_until=NULL,last_error=#{error} WHERE id=#{id} AND status='CLAIMED' AND lease_owner=#{owner}") + int sendFailed(@Param("id") long id, @Param("owner") String owner, + @Param("backoffSeconds") int backoffSeconds, @Param("error") String error); + + @Update(""" + UPDATE file_export_outbox SET next_retry_at=CURRENT_TIMESTAMP(3) + WHERE task_id=#{taskId} AND dispatch_version=#{dispatchVersion} + AND status='PENDING' AND next_retry_at>CURRENT_TIMESTAMP(3) + """) + int expeditePendingRetry(@Param("taskId") long taskId, @Param("dispatchVersion") int dispatchVersion); + + @Update("UPDATE file_export_outbox SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL WHERE status='CLAIMED' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 50") + int releaseExpiredClaims(); + + @Update(""" + UPDATE file_export_outbox + SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL + WHERE id IN (SELECT id FROM (SELECT o.id FROM file_export_outbox o JOIN file_export_task t + ON t.id=o.task_id AND t.dispatch_version=o.dispatch_version + WHERE o.status='SENT' AND o.sent_at<=DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{staleSeconds} SECOND) + AND t.status='PENDING' AND t.next_retry_at<=DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{graceSeconds} SECOND) + ORDER BY o.sent_at,o.id LIMIT 20) stale) + """) + int redispatchSent(@Param("staleSeconds") int staleSeconds, @Param("graceSeconds") int graceSeconds); + + @Update("UPDATE file_export_outbox SET status='QUARANTINED',last_error=#{reason},lease_owner=NULL,lease_until=NULL WHERE event_id=#{eventId} AND task_id=#{taskId} AND dispatch_version=#{dispatchVersion} AND status<>'QUARANTINED'") + int quarantine(@Param("eventId") String eventId, @Param("taskId") long taskId, + @Param("dispatchVersion") int dispatchVersion, @Param("reason") String reason); + + @Delete("DELETE FROM file_export_outbox WHERE status='SENT' AND sent_at { + @Select("SELECT guard_key FROM file_export_task_guard WHERE guard_key='GLOBAL_CREATE' FOR UPDATE") + String lockCreationGuard(); + + @Select("SELECT guard_key FROM file_export_task_guard WHERE guard_key='GLOBAL_CREATE'") + String findCreationGuard(); + + @Update("UPDATE file_export_task SET next_retry_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status='PENDING' AND dispatch_version=1") + int initializeDispatchTime(@Param("id") long id); + @Update(""" + UPDATE file_export_task SET status='RUNNING', attempt_count=attempt_count+1, + started_at=CURRENT_TIMESTAMP(3), lease_owner=#{owner}, + lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND), + last_heartbeat_at=CURRENT_TIMESTAMP(3), + execution_deadline_at=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{durationMinutes} MINUTE), + processed_rows=0,total_rows=NULL,failure_message=NULL + WHERE id=#{id} AND dispatch_version=#{dispatchVersion} AND status='PENDING' + AND next_retry_at<=CURRENT_TIMESTAMP(3) + """) + int claim(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion, + @Param("owner") String owner, @Param("leaseSeconds") int leaseSeconds, + @Param("durationMinutes") int durationMinutes); + + @Update(""" + UPDATE file_export_task SET lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND), + last_heartbeat_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status='RUNNING' + AND dispatch_version=#{dispatchVersion} AND lease_owner=#{owner} + AND execution_deadline_at>CURRENT_TIMESTAMP(3) + """) + int heartbeat(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion, + @Param("owner") String owner, @Param("leaseSeconds") int leaseSeconds); + + @Update("UPDATE file_export_task SET processed_rows=#{rows} WHERE id=#{id} AND status='RUNNING' AND dispatch_version=#{dispatchVersion} AND lease_owner=#{owner}") + int progress(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion, + @Param("owner") String owner, @Param("rows") long rows); + + @Update(""" + UPDATE file_export_task SET status='SUCCEEDED',file_name=#{fileName},object_key=#{objectKey}, + content_type=#{contentType},size_bytes=#{sizeBytes},total_rows=#{rows},processed_rows=#{rows}, + completed_at=CURRENT_TIMESTAMP(3),expires_at=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{retentionDays} DAY), + lease_owner=NULL,lease_until=NULL,failure_message=NULL + WHERE id=#{id} AND status='RUNNING' AND dispatch_version=#{dispatchVersion} + AND lease_owner=#{owner} AND execution_deadline_at>CURRENT_TIMESTAMP(3) + """) + int succeed(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion, + @Param("owner") String owner, @Param("fileName") String fileName, + @Param("objectKey") String objectKey, @Param("contentType") String contentType, + @Param("sizeBytes") long sizeBytes, @Param("rows") long rows, + @Param("retentionDays") int retentionDays); + + @Update(""" + UPDATE file_export_task SET status='PENDING',dispatch_version=dispatch_version+1, + next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND), + lease_owner=NULL,lease_until=NULL,last_heartbeat_at=NULL,execution_deadline_at=NULL, + failure_message=#{message} WHERE id=#{id} AND status='RUNNING' + AND dispatch_version=#{dispatchVersion} AND lease_owner=#{owner} AND attempt_count0 AND next_retry_at>CURRENT_TIMESTAMP(3) + """) + int expeditePendingRetry(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion); + + @Update("UPDATE file_export_task SET status='FAILED',completed_at=CURRENT_TIMESTAMP(3),failure_message=#{reason},lease_owner=NULL,lease_until=NULL WHERE id=#{id} AND dispatch_version=#{dispatchVersion} AND status='PENDING'") + int failQuarantined(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion, + @Param("reason") String reason); + + @Update(""" + UPDATE file_export_task SET status='PENDING',dispatch_version=dispatch_version+1, + next_retry_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL,last_heartbeat_at=NULL, + execution_deadline_at=NULL,failure_message='执行实例失联,任务已重新派发' + WHERE id=#{id} AND status='RUNNING' AND dispatch_version=#{dispatchVersion} + AND lease_until<=CURRENT_TIMESTAMP(3) AND attempt_count=max_attempts + """) + int failStale(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion); + + @Select("SELECT * FROM file_export_task WHERE status='SUCCEEDED' AND expires_at<=CURRENT_TIMESTAMP(3) AND object_key IS NOT NULL ORDER BY expires_at LIMIT 50") + List expiredArtifactsDb(); + + @Select("SELECT COUNT(*) FROM file_export_task WHERE tenant_id=#{tenantId} AND app_code=#{appCode} AND requested_by=#{requestedBy} AND status IN ('PENDING','RUNNING')") + long activeForUser(@Param("tenantId") long tenantId,@Param("appCode") String appCode,@Param("requestedBy") long requestedBy); + + @Select("SELECT COUNT(*) FROM file_export_task WHERE export_type=#{exportType} AND status IN ('PENDING','RUNNING')") + long activeForType(@Param("exportType") String exportType); + + @Select("SELECT * FROM file_export_task WHERE tenant_id=#{tenantId} AND app_code=#{appCode} AND requested_by=#{requestedBy} AND export_type=#{exportType} AND parameters_json=CAST(#{parametersJson} AS JSON) AND status IN ('PENDING','RUNNING') AND requested_at>=DATE_SUB(CURRENT_TIMESTAMP(3),INTERVAL 60 SECOND) ORDER BY requested_at DESC LIMIT 1") + FileExportTask recentDuplicate(@Param("tenantId") long tenantId,@Param("appCode") String appCode,@Param("requestedBy") long requestedBy,@Param("exportType") String exportType,@Param("parametersJson") String parametersJson); + + @Select("SELECT COUNT(*) FROM file_export_task WHERE id=#{id} AND status='SUCCEEDED' AND expires_at>CURRENT_TIMESTAMP(3)") + int downloadable(@Param("id") long id); + + @Select("SELECT COUNT(*) FROM file_export_task WHERE status='SUCCEEDED' AND object_key=#{objectKey}") + int succeededObjectExists(@Param("objectKey") String objectKey); + + @Select("SELECT * FROM file_export_task WHERE status='RUNNING' AND lease_until<=CURRENT_TIMESTAMP(3) ORDER BY lease_until LIMIT 20") + List staleRunning(); + + @Select("SELECT * FROM file_export_task WHERE status='PENDING' AND next_retry_at<=DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{graceSeconds} SECOND) AND NOT EXISTS (SELECT 1 FROM file_export_outbox o WHERE o.task_id=file_export_task.id AND o.dispatch_version=file_export_task.dispatch_version) ORDER BY id LIMIT 50") + List missingOutbox(@Param("graceSeconds") int graceSeconds); + + @Update("UPDATE file_export_task SET status='EXPIRED' WHERE id=#{id} AND status='SUCCEEDED'") + int markExpiredDb(@Param("id") long id); +} diff --git a/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java b/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java new file mode 100644 index 000000000..729476614 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.file.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.file.entity.FileObjectEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface FileObjectMapper extends BaseMapper { +} diff --git a/file/src/main/java/com/baiyue/file/mq/ExportCommandConsumer.java b/file/src/main/java/com/baiyue/file/mq/ExportCommandConsumer.java new file mode 100644 index 000000000..120f0621e --- /dev/null +++ b/file/src/main/java/com/baiyue/file/mq/ExportCommandConsumer.java @@ -0,0 +1,53 @@ +package com.baiyue.file.mq; + +import jakarta.annotation.Resource; +import jakarta.annotation.PostConstruct; + +import com.baiyue.common.core.export.ExportContracts.ExportCommand; +import com.baiyue.file.service.ExportTaskExecutor; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.rocketmq.common.message.MessageExt; +import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer; +import org.apache.rocketmq.spring.annotation.MessageModel; +import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; +import org.apache.rocketmq.spring.core.RocketMQListener; +import org.apache.rocketmq.spring.core.RocketMQPushConsumerLifecycleListener; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; + +@Service +@ConditionalOnProperty(prefix="baiyue.file.export.consumer", name="enabled", havingValue="true") +@RocketMQMessageListener(topic="${baiyue.file.export.rocketmq.topic}", consumerGroup="${baiyue.file.export.rocketmq.consumer-group}", + selectorExpression="EXPORT_TASK", messageModel= MessageModel.CLUSTERING, consumeThreadNumber=4, + consumeThreadMax=4, consumeTimeout=90, maxReconsumeTimes=2) +public class ExportCommandConsumer implements RocketMQListener, RocketMQPushConsumerLifecycleListener { + @Resource + private ObjectMapper objectMapper; + @Resource + private ExportTaskExecutor executor; + @Value("${baiyue.file.export.instance-id:${HOSTNAME:${random.uuid}}}") private String instance; + @Value("${baiyue.file.export.consumer.concurrency:4}") private int concurrency; + @Value("${baiyue.file.export.consumer.consume-timeout-minutes:90}") private long consumeTimeoutMinutes; + @Value("${baiyue.file.export.consumer.max-reconsume-times:2}") private int maxReconsumeTimes; + + @PostConstruct + void validateConfiguration() { + if (concurrency < 1 || concurrency > 64) throw new IllegalArgumentException("Export consumer concurrency must be between 1 and 64"); + } + @Override public void prepareStart(DefaultMQPushConsumer consumer) { + consumer.setConsumeThreadMin(concurrency); + consumer.setConsumeThreadMax(concurrency); + consumer.setConsumeTimeout(consumeTimeoutMinutes); + consumer.setMaxReconsumeTimes(maxReconsumeTimes); + } + @Override public void onMessage(MessageExt message) { + final ExportCommand command; + try { command = objectMapper.readValue(new String(message.getBody(), StandardCharsets.UTF_8), ExportCommand.class); } + catch (Exception exception) { throw new IllegalArgumentException("Malformed export command", exception); } + if (command.schemaVersion()!=1 || command.taskId()==null || command.eventId()==null) throw new IllegalArgumentException("Unsupported export command"); + executor.execute(Long.parseLong(command.taskId()), command.dispatchVersion(), instance); + } +} diff --git a/file/src/main/java/com/baiyue/file/mq/ExportDlqHandler.java b/file/src/main/java/com/baiyue/file/mq/ExportDlqHandler.java new file mode 100644 index 000000000..6d6bf06a7 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/mq/ExportDlqHandler.java @@ -0,0 +1,39 @@ +package com.baiyue.file.mq; + +import jakarta.annotation.Resource; + +import com.baiyue.file.service.ExportStateService; +import io.micrometer.core.instrument.MeterRegistry; +import org.apache.rocketmq.common.message.MessageExt; +import org.apache.rocketmq.spring.annotation.MessageModel; +import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; +import org.apache.rocketmq.spring.core.RocketMQListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +@Service +@ConditionalOnProperty(prefix="baiyue.file.export.consumer", name="enabled", havingValue="true") +@RocketMQMessageListener(topic="${baiyue.file.export.rocketmq.dlq-topic}", + consumerGroup="${baiyue.file.export.rocketmq.dlq-consumer-group}", messageModel=MessageModel.CLUSTERING, + consumeThreadNumber=1, consumeThreadMax=1, maxReconsumeTimes=Integer.MAX_VALUE) +public class ExportDlqHandler implements RocketMQListener { + private static final Logger LOGGER = LoggerFactory.getLogger(ExportDlqHandler.class); + @Resource + private ExportStateService states; + @Resource + private MeterRegistry meters; + @Override public void onMessage(MessageExt message) { + meters.counter("export_dlq_messages").increment(); + String eventId=message.getUserProperty("eventId"), taskId=message.getUserProperty("taskId"); + String version=message.getUserProperty("dispatchVersion"), schema=message.getUserProperty("schemaVersion"); + if (blank(eventId)||blank(taskId)||blank(version)||blank(schema)) { + LOGGER.error("CRITICAL: 畸形 DLQ 消息缺少定位属性 messageId={}", message.getMsgId()); + return; + } + try { states.quarantine(eventId, Long.parseLong(taskId), Integer.parseInt(version), "消息契约损坏,已自动隔离(schemaVersion="+schema+")"); } + catch (NumberFormatException exception) { LOGGER.error("CRITICAL: 畸形 DLQ 消息定位属性非法 messageId={}", message.getMsgId(), exception); } + } + private boolean blank(String value) { return value==null||value.isBlank(); } +} diff --git a/file/src/main/java/com/baiyue/file/mq/ExportOutboxPublisher.java b/file/src/main/java/com/baiyue/file/mq/ExportOutboxPublisher.java new file mode 100644 index 000000000..f0aaf0163 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/mq/ExportOutboxPublisher.java @@ -0,0 +1,52 @@ +package com.baiyue.file.mq; + +import jakarta.annotation.Resource; + +import com.baiyue.file.entity.FileExportOutbox; +import com.baiyue.file.repository.ExportOutboxRepository; +import org.apache.rocketmq.spring.core.RocketMQTemplate; +import org.apache.rocketmq.spring.support.RocketMQHeaders; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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.Service; + +@Service +@ConditionalOnProperty(prefix="baiyue.file.export.publisher", name="enabled", havingValue="true") +public class ExportOutboxPublisher { + private static final Logger LOGGER = LoggerFactory.getLogger(ExportOutboxPublisher.class); + private static final int[] BACKOFF = {5, 15, 60, 300, 900}; + @Resource + private ExportOutboxRepository outbox; + @Resource + private RocketMQTemplate rocketMQ; + @Value("${baiyue.file.export.instance-id:${HOSTNAME:${random.uuid}}}") private String owner; + @Value("${baiyue.file.export.publisher.batch-size:50}") private int batchSize; + @Value("${baiyue.file.export.publisher.lease-seconds:60}") private int leaseSeconds; + @Scheduled(fixedDelayString="${baiyue.file.export.publisher.poll-interval-ms:5000}") + public void publish() { + for (Long id : outbox.publishableIds(batchSize)) { + if (!outbox.claim(id, owner, leaseSeconds)) continue; + FileExportOutbox row = outbox.find(id); + try { + var message = MessageBuilder.withPayload(row.getPayloadJson()) + .setHeader(RocketMQHeaders.KEYS, row.getMessageKey()) + .setHeader("eventId", row.getEventId()) + .setHeader("taskId", String.valueOf(row.getTaskId())) + .setHeader("dispatchVersion", String.valueOf(row.getDispatchVersion())) + .setHeader("schemaVersion", "1").build(); + rocketMQ.syncSend(row.getTopic() + ":" + row.getTag(), message, 3000); + outbox.sent(id, owner); + } catch (RuntimeException exception) { + int attempt = Math.max(1, row.getAttemptCount()); + int seconds = BACKOFF[Math.min(attempt - 1, BACKOFF.length - 1)]; + outbox.sendFailed(id, owner, seconds, safe(exception.getMessage())); + LOGGER.error("导出命令发送失败 eventId={} taskId={}", row.getEventId(), row.getTaskId(), exception); + } + } + } + private String safe(String value) { String message=value==null?"RocketMQ send failed":value; return message.length()>1000?message.substring(0,1000):message; } +} diff --git a/file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java b/file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java new file mode 100644 index 000000000..81fa1cc52 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java @@ -0,0 +1,32 @@ +package com.baiyue.file.repository; + +import jakarta.annotation.Resource; + +import com.baiyue.file.entity.FileExportOutbox; +import com.baiyue.file.mapper.FileExportOutboxMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public class ExportOutboxRepository { + @Resource + private FileExportOutboxMapper mapper; + public boolean insert(FileExportOutbox row) { + try { return mapper.insertCommand(row) == 1; } + catch (DuplicateKeyException ignored) { return false; } + } + public FileExportOutbox find(long id) { return mapper.selectById(id); } + public List publishableIds(int limit) { return mapper.publishableIds(limit); } + public boolean claim(long id, String owner, int leaseSeconds) { return mapper.claim(id, owner, leaseSeconds) == 1; } + public boolean sent(long id, String owner) { return mapper.sent(id, owner) == 1; } + public boolean sendFailed(long id, String owner, int seconds, String error) { return mapper.sendFailed(id, owner, seconds, error) == 1; } + public boolean expeditePendingRetry(long taskId, int dispatchVersion) { + return mapper.expeditePendingRetry(taskId, dispatchVersion) == 1; + } + public int releaseExpiredClaims() { return mapper.releaseExpiredClaims(); } + public int redispatchSent(int staleSeconds, int graceSeconds) { return mapper.redispatchSent(staleSeconds, graceSeconds); } + public boolean quarantine(String eventId, long taskId, int version, String reason) { return mapper.quarantine(eventId, taskId, version, reason) == 1; } + public int deleteRetainedSent(int retentionDays, int limit) { return mapper.deleteRetainedSent(retentionDays, limit); } +} diff --git a/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java b/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java new file mode 100644 index 000000000..7fe987e8f --- /dev/null +++ b/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java @@ -0,0 +1,108 @@ +package com.baiyue.file.repository; + +import jakarta.annotation.Resource; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.file.entity.FileExportTask; +import com.baiyue.file.mapper.FileExportTaskMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class ExportTaskRepository { + @Resource + private FileExportTaskMapper mapper; + + @PostConstruct + void validateCreationGuard() { + if (!"GLOBAL_CREATE".equals(mapper.findCreationGuard())) { + throw new IllegalStateException("Missing GLOBAL_CREATE export task guard; apply file.sql before starting File Service"); + } + } + + public void lockCreationGuard() { + if (!"GLOBAL_CREATE".equals(mapper.lockCreationGuard())) { + throw new IllegalStateException("Unable to acquire export task creation guard"); + } + } + + public void insert(FileExportTask task) { mapper.insert(task); } + public void initializeDispatchTime(long id) { mapper.initializeDispatchTime(id); } + + public boolean claim(long id, int dispatchVersion, String owner, int leaseSeconds, int durationMinutes) { + return mapper.claim(id, dispatchVersion, owner, leaseSeconds, durationMinutes) == 1; + } + + public boolean heartbeat(long id, int dispatchVersion, String owner, int leaseSeconds) { + return mapper.heartbeat(id, dispatchVersion, owner, leaseSeconds) == 1; + } + + public boolean progress(long id, int dispatchVersion, String owner, long rows) { + return mapper.progress(id, dispatchVersion, owner, rows) == 1; + } + + public boolean succeed(long id, int dispatchVersion, String owner, String fileName, String objectKey, + String contentType, long sizeBytes, long rows, int retentionDays) { + return mapper.succeed(id, dispatchVersion, owner, fileName, objectKey, contentType, + sizeBytes, rows, retentionDays) == 1; + } + + public boolean scheduleRetry(long id, int dispatchVersion, String owner, int backoffSeconds, String message) { + return mapper.scheduleRetry(id, dispatchVersion, owner, backoffSeconds, message) == 1; + } + + public boolean fail(long id, int dispatchVersion, String owner, String message) { + return mapper.fail(id, dispatchVersion, owner, message) == 1; + } + public boolean manualRetry(long id) { return mapper.manualRetry(id) == 1; } + public boolean expeditePendingRetry(long id, int dispatchVersion) { + return mapper.expeditePendingRetry(id, dispatchVersion) == 1; + } + public boolean failQuarantined(long id, int dispatchVersion, String reason) { return mapper.failQuarantined(id, dispatchVersion, reason) == 1; } + public boolean recoverStale(long id, int dispatchVersion) { return mapper.recoverStale(id, dispatchVersion) == 1; } + public boolean failStale(long id, int dispatchVersion) { return mapper.failStale(id, dispatchVersion) == 1; } + public List expiredArtifactsDb() { return mapper.expiredArtifactsDb(); } + public long activeForUser(long tenantId,String appCode,long requestedBy){return mapper.activeForUser(tenantId,appCode,requestedBy);} + public long activeForType(String exportType){return mapper.activeForType(exportType);} + public FileExportTask recentDuplicate(long tenantId,String appCode,long requestedBy,String exportType,String json){return mapper.recentDuplicate(tenantId,appCode,requestedBy,exportType,json);} + public boolean downloadable(long id){return mapper.downloadable(id)>0;} + public boolean succeededObjectExists(String objectKey){return mapper.succeededObjectExists(objectKey)>0;} + + public List staleRunning() { return mapper.staleRunning(); } + public List missingOutbox(int graceSeconds) { return mapper.missingOutbox(graceSeconds); } + + public FileExportTask find(long id) { return mapper.selectById(id); } + + public PageResult page(long page, long size, long tenantId, String appCode, + Long requestedBy, String exportType, String status, + LocalDate requestedFrom, LocalDate requestedTo) { + var query = new LambdaQueryWrapper() + .eq(FileExportTask::getTenantId, tenantId) + .eq(FileExportTask::getAppCode, appCode) + .eq(requestedBy != null, FileExportTask::getRequestedBy, requestedBy) + .eq(StringUtils.hasText(exportType), FileExportTask::getExportType, exportType) + .eq(StringUtils.hasText(status), FileExportTask::getStatus, status) + .ge(requestedFrom != null, FileExportTask::getRequestedAt, + requestedFrom == null ? null : requestedFrom.atStartOfDay()) + .lt(requestedTo != null, FileExportTask::getRequestedAt, + requestedTo == null ? null : requestedTo.plusDays(1).atStartOfDay()) + .orderByDesc(FileExportTask::getRequestedAt) + .orderByDesc(FileExportTask::getId); + return PageResult.from(mapper.selectPage(new Page<>(page, size), query)); + } + + public void markExpired(long id) { + mapper.update(null, new LambdaUpdateWrapper() + .eq(FileExportTask::getId, id) + .eq(FileExportTask::getStatus, "SUCCEEDED") + .set(FileExportTask::getStatus, "EXPIRED")); + } +} diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java new file mode 100644 index 000000000..f2603ec75 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -0,0 +1,99 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.util.Arrays; + +/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */ +enum BusinessFileCategory { + DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), + DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), + DELIVERY_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), + TRUCK_APPOINTMENT_ATTACHMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OCEAN_BOOKING_DOCUMENT(false, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), + CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + CUSTOMS_RECEIPT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + FINANCE_VOUCHER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + INBOUND_HOLD_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_PROCUREMENT_RECEIPT(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_PROCUREMENT_VOUCHER(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + CONTAINER_CLEARANCE_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + LOADING_CUSTOMS_DECLARATION(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), + LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), + ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO), + ORDER_BATCH_IMPORT_ERROR_REPORT(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO); + + static final long STANDARD_MAX_SIZE = 10L * 1024 * 1024; + static final long CUSTOMS_MAX_SIZE = 50L * 1024 * 1024; + static final long ORDER_IMPORT_MAX_SIZE = 5L * 1024 * 1024; + + private final boolean customerOwned; + private final FormatPolicy formatPolicy; + private final long maxSizeBytes; + private final BusinessIdPolicy businessIdPolicy; + + BusinessFileCategory(boolean customerOwned, FormatPolicy formatPolicy, long maxSizeBytes, + BusinessIdPolicy businessIdPolicy) { + this.customerOwned = customerOwned; + this.formatPolicy = formatPolicy; + this.maxSizeBytes = maxSizeBytes; + this.businessIdPolicy = businessIdPolicy; + } + + static BusinessFileCategory require(String value) { + return Arrays.stream(values()).filter(item -> item.name().equals(value)).findFirst() + .orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型")); + } + + void validateOwner(long tenantId) { + if ((this == OCEAN_BOOKING_DOCUMENT || this == OVERSEAS_UNLOAD_PHOTO) && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, + this == OCEAN_BOOKING_DOCUMENT ? "订舱附件必须归属平台" : "海外卸柜照片必须归属平台"); + } + if (customerOwned && tenantId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户"); + } + } + + long maxSizeBytes() { + return maxSizeBytes; + } + + FormatPolicy formatPolicy() { + return formatPolicy; + } + + String storageBusinessId(String businessId) { + if (businessIdPolicy == BusinessIdPolicy.ORDER_BATCH_NO) { + if (businessId == null || !businessId.matches("OBI-[0-9A-F]{20}")) { + throw new BizException(ResultCode.BAD_REQUEST, "批量导入业务编号格式无效"); + } + return businessId; + } + final long value; + try { + value = Long.parseLong(businessId); + } catch (NumberFormatException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "业务ID格式无效"); + } + if (value <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须有效"); + return Long.toString(value); + } + + enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF } + private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO } + private static final class Limits { + private static final long STANDARD = 10L * 1024 * 1024; + private static final long CUSTOMS = 50L * 1024 * 1024; + private static final long ORDER_IMPORT = 5L * 1024 * 1024; + } +} diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java new file mode 100644 index 000000000..764b21e53 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -0,0 +1,169 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +@Component +public class BusinessFileStorage { + private static final String XLSX_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + private static final Map TYPES = Map.of( + "text/plain", ".txt", "application/pdf", ".pdf", + "image/jpeg", ".jpg", "image/png", ".png"); + + @Value("${baiyue.file.storage-root}") + private Path configuredRoot; + private Path root; + + @PostConstruct + void initializeRoot() { + root = configuredRoot.toAbsolutePath().normalize(); + } + + Stored store(long tenantId, long businessId, MultipartFile file, long maxSize, boolean arbitraryFormat) { + return store(tenantId, Long.toString(businessId), file, maxSize, + arbitraryFormat ? BusinessFileCategory.FormatPolicy.ARBITRARY : BusinessFileCategory.FormatPolicy.STANDARD); + } + + Stored store(long tenantId, String businessId, MultipartFile file, BusinessFileCategory category) { + return store(tenantId, businessId, file, category.maxSizeBytes(), category.formatPolicy()); + } + + private Stored store(long tenantId, String businessId, MultipartFile file, long maxSize, + BusinessFileCategory.FormatPolicy formatPolicy) { + if (file == null || file.isEmpty()) throw bad("附件不能为空"); + if (file.getSize() > maxSize) throw bad("附件不能超过" + maxSize / 1024 / 1024 + "MB"); + if (businessId == null || !businessId.matches("[A-Za-z0-9-]+")) throw bad("业务ID格式无效"); + String type = safeContentType(file.getContentType()); + String extension; + if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) { + extension = ""; + } else if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF) { + if (!"application/pdf".equals(type)) throw bad("附件仅支持PDF"); + verifyContent(file, type); + extension = ".pdf"; + } else if (formatPolicy == BusinessFileCategory.FormatPolicy.XLSX) { + if (!XLSX_TYPE.equals(type)) throw bad("附件仅支持XLSX"); + verifyXlsx(file); + extension = ".xlsx"; + } else { + extension = TYPES.get(type); + if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG"); + verifyContent(file, type); + } + String name = safeName(file); + String objectKey = "business-files/" + tenantId + "/" + businessId + "/" + UUID.randomUUID() + extension; + Path target = root.resolve(objectKey).normalize(); + if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); + try { + Files.createDirectories(target.getParent()); + file.transferTo(target); + return new Stored(objectKey, type, file.getSize(), sha256(target), name); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "附件保存失败"); + } + } + + private static void verifyXlsx(MultipartFile file) { + try (InputStream input = file.getInputStream()) { + byte[] prefix = input.readNBytes(4); + if (prefix.length < 4 || prefix[0] != 0x50 || prefix[1] != 0x4b + || prefix[2] != 0x03 || prefix[3] != 0x04) { + throw bad("上传内容不是有效XLSX"); + } + } catch (IOException exception) { + throw bad("附件内容读取失败"); + } + } + + private static String safeContentType(String contentType) { + if (contentType == null || contentType.isBlank()) return MediaType.APPLICATION_OCTET_STREAM_VALUE; + try { + return MediaType.parseMediaType(contentType).toString().toLowerCase(Locale.ROOT); + } catch (IllegalArgumentException exception) { + return MediaType.APPLICATION_OCTET_STREAM_VALUE; + } + } + + byte[] read(String objectKey) { + Path target = root.resolve(objectKey).normalize(); + if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); + try { + return Files.readAllBytes(target); + } catch (IOException exception) { + throw new BizException(ResultCode.NOT_FOUND, "附件内容不存在"); + } + } + + private void verifyContent(MultipartFile file, String type) { + try (InputStream input = file.getInputStream()) { + byte[] prefix = input.readNBytes(4096); + if ("application/pdf".equals(type)) { + if (prefix.length < 5 || !new String(prefix, 0, 5, StandardCharsets.US_ASCII).equals("%PDF-")) { + throw bad("上传内容不是有效PDF"); + } + } else if ("text/plain".equals(type)) { + for (byte value : prefix) if (value == 0) throw bad("文本附件包含非法二进制内容"); + } else if (prefix.length < 8 || !validImageSignature(prefix, type)) { + throw bad("上传内容不是有效图片"); + } + } catch (IOException exception) { + throw bad("附件内容读取失败"); + } + } + + private static boolean validImageSignature(byte[] bytes, String type) { + if ("image/png".equals(type)) { + return (bytes[0] & 0xff) == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47; + } + return (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0xd8; + } + + private static String safeName(MultipartFile file) { + String name = file.getOriginalFilename(); + if (name == null || name.isBlank()) return "attachment"; + String normalized = name.replace('\\', '/'); + normalized = normalized.substring(normalized.lastIndexOf('/') + 1) + .replaceAll("[\\p{Cntrl}]", "").trim(); + if (normalized.isBlank()) return "attachment"; + int codePoints = normalized.codePointCount(0, normalized.length()); + if (codePoints <= 255) return normalized; + return normalized.substring(normalized.offsetByCodePoints(0, codePoints - 255)); + } + + private static String sha256(Path path) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(path)) { + byte[] buffer = new byte[8192]; + for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private static BizException bad(String message) { + return new BizException(ResultCode.BAD_REQUEST, message); + } + + record Stored(String objectKey, String contentType, long size, String sha256, String originalName) {} +} diff --git a/file/src/main/java/com/baiyue/file/service/ExportCommandFactory.java b/file/src/main/java/com/baiyue/file/service/ExportCommandFactory.java new file mode 100644 index 000000000..c3577a034 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ExportCommandFactory.java @@ -0,0 +1,39 @@ +package com.baiyue.file.service; + +import jakarta.annotation.Resource; + +import com.baiyue.common.core.export.ExportContracts.ExportCommand; +import com.baiyue.file.entity.FileExportOutbox; +import com.baiyue.file.entity.FileExportTask; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Component +public class ExportCommandFactory { + @Resource + private ObjectMapper objectMapper; + @Value("${baiyue.file.export.rocketmq.topic:test-baiyue-file-export-command-v1}") + private String topic; + public FileExportOutbox create(FileExportTask task, int dispatchVersion) { + String eventId = UUID.randomUUID().toString(); + ExportCommand command = new ExportCommand(1, eventId, String.valueOf(task.getId()), task.getExportType(), + dispatchVersion, OffsetDateTime.now(), MDC.get("traceId")); + FileExportOutbox row = new FileExportOutbox(); + row.setId(IdWorker.getId()); row.setEventId(eventId); row.setTaskId(task.getId()); + row.setDispatchVersion(dispatchVersion); row.setTopic(topic); row.setTag("EXPORT_TASK"); + row.setMessageKey(String.valueOf(task.getId())); row.setPayloadJson(json(command)); + row.setStatus("PENDING"); row.setAttemptCount(0); + return row; + } + private String json(ExportCommand command) { + try { return objectMapper.writeValueAsString(command); } + catch (JsonProcessingException exception) { throw new IllegalStateException("Unable to serialize export command", exception); } + } +} diff --git a/file/src/main/java/com/baiyue/file/service/ExportFailure.java b/file/src/main/java/com/baiyue/file/service/ExportFailure.java new file mode 100644 index 000000000..e49b5416b --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ExportFailure.java @@ -0,0 +1,10 @@ +package com.baiyue.file.service; + +public class ExportFailure extends RuntimeException { + private final boolean retryable; + public ExportFailure(String message, boolean retryable) { super(message); this.retryable = retryable; } + public ExportFailure(String message, boolean retryable, Throwable cause) { super(message, cause); this.retryable = retryable; } + public boolean retryable() { return retryable; } + public static ExportFailure temporary(String message, Throwable cause) { return new ExportFailure(message, true, cause); } + public static ExportFailure permanent(String message) { return new ExportFailure(message, false); } +} diff --git a/file/src/main/java/com/baiyue/file/service/ExportMetrics.java b/file/src/main/java/com/baiyue/file/service/ExportMetrics.java new file mode 100644 index 000000000..740e69bf3 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ExportMetrics.java @@ -0,0 +1,27 @@ +package com.baiyue.file.service; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Component +public class ExportMetrics { + @Resource private MeterRegistry registry; + @Resource private JdbcTemplate jdbc; + + @PostConstruct + void registerGauges() { + Gauge.builder("export_task_queue_age_seconds",jdbc,j->number(j,"SELECT COALESCE(TIMESTAMPDIFF(SECOND,MIN(requested_at),CURRENT_TIMESTAMP(3)),0) FROM file_export_task WHERE status='PENDING'")).register(registry); + Gauge.builder("export_outbox_pending",jdbc,j->number(j,"SELECT COUNT(*) FROM file_export_outbox WHERE status IN ('PENDING','CLAIMED')")).register(registry); + Gauge.builder("export_outbox_oldest_age_seconds",jdbc,j->number(j,"SELECT COALESCE(TIMESTAMPDIFF(SECOND,MIN(created_at),CURRENT_TIMESTAMP(3)),0) FROM file_export_outbox WHERE status IN ('PENDING','CLAIMED')")).register(registry); + Gauge.builder("export_outbox_max_attempt",jdbc,j->number(j,"SELECT COALESCE(MAX(attempt_count),0) FROM file_export_outbox WHERE status IN ('PENDING','CLAIMED')")).register(registry); + Gauge.builder("export_storage_bytes",jdbc,j->number(j,"SELECT COALESCE(SUM(size_bytes),0) FROM file_export_task WHERE status='SUCCEEDED' AND expires_at>CURRENT_TIMESTAMP(3)")).register(registry); + } + public void created(String type){registry.counter("export_task_created_total","type",type).increment();} + public void completed(String type,String status){registry.counter("export_task_completed_total","type",type,"status",status).increment();} + private static double number(JdbcTemplate jdbc,String sql){try{Number value=jdbc.queryForObject(sql,Number.class);return value==null?0:value.doubleValue();}catch(RuntimeException ignored){return 0;}} +} diff --git a/file/src/main/java/com/baiyue/file/service/ExportReconciler.java b/file/src/main/java/com/baiyue/file/service/ExportReconciler.java new file mode 100644 index 000000000..f072ac66a --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ExportReconciler.java @@ -0,0 +1,49 @@ +package com.baiyue.file.service; + +import jakarta.annotation.Resource; + +import com.baiyue.file.entity.FileExportTask; +import com.baiyue.file.repository.ExportOutboxRepository; +import com.baiyue.file.repository.ExportTaskRepository; +import io.micrometer.core.instrument.MeterRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import java.time.Duration; + +@Service +@ConditionalOnProperty(prefix="baiyue.file.export.reconciler", name="enabled", havingValue="true") +public class ExportReconciler { + private static final Logger LOGGER= LoggerFactory.getLogger(ExportReconciler.class); + @Resource private ExportTaskRepository tasks; + @Resource private ExportOutboxRepository outbox; + @Resource private ExportStateService states; + @Resource private ExportStorage storage; + @Resource private MeterRegistry meters; + @Value("${baiyue.file.export.reconciler.redispatch-grace-seconds:60}") private int graceSeconds; + @Value("${baiyue.file.export.reconciler.sent-stale-seconds:600}") private int sentStaleSeconds; + @Value("${baiyue.file.export.retention-days:7}") private int retentionDays; + @Scheduled(cron="${baiyue.file.export.reconciler.outbox-cleanup-cron:0 15 3 * * *}") + public void cleanupRetainedOutbox() { + count("outbox_clean",outbox.deleteRetainedSent(retentionDays,5000)); + } + @Scheduled(fixedDelayString="${baiyue.file.export.reconciler.poll-interval-ms:30000}") + public void reconcile() { + int released=outbox.releaseExpiredClaims(); count("outbox_release",released); + int redispatched=outbox.redispatchSent(sentStaleSeconds,graceSeconds); count("redispatch",redispatched); + int missing=0; for(FileExportTask task:tasks.missingOutbox(graceSeconds)) if(states.ensureOutbox(task)) missing++; + count("redispatch",missing); + int recovered=0; for(FileExportTask task:tasks.staleRunning()) if(states.recoverStale(task)) recovered++; + count("lease_recover",recovered); + int expired=0; for(FileExportTask task:tasks.expiredArtifactsDb()) { + if(storage.delete(task.getObjectKey())) { tasks.markExpired(task.getId()); expired++; } + else LOGGER.warn("导出文件过期清理失败 taskId={}",task.getId()); + } + count("expire",expired); + count("orphan_clean", storage.cleanupOrphans(Duration.ofHours(24), tasks::succeededObjectExists)); + } + private void count(String action,int value){ if(value>0)meters.counter("export_reconciler_action_total","action",action).increment(value); } +} diff --git a/file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java b/file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java new file mode 100644 index 000000000..d3e76f9ea --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java @@ -0,0 +1,82 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.export.ExportContracts.ExportColumn; +import com.baiyue.common.core.export.ExportContracts.ExportColumnType; + +import java.util.List; + +final class ExportSchemaContracts { + static final String BUY_DECLARATION_ITEMS = "LOADING_LIST_BUY_DECLARATION_ITEMS"; + static final String DOMESTIC_WAREHOUSE_ORDER_LIST = "DOMESTIC_WAREHOUSE_ORDER_LIST"; + static final List BUY_DECLARATION_COLUMNS = List.of( + column("customerName", "客户名称", ExportColumnType.STRING), + column("orderNo", "订单号", ExportColumnType.STRING), + column("inboundCode", "入仓码", ExportColumnType.STRING), + column("sku", "SKU", ExportColumnType.STRING), + column("chineseName", "中文品名", ExportColumnType.STRING), + column("englishName", "英文品名", ExportColumnType.STRING), + column("cartonCount", "箱数", ExportColumnType.INTEGER), + column("productQuantity", "产品数量", ExportColumnType.INTEGER), + column("grossWeight", "毛重", ExportColumnType.DECIMAL), + column("netWeight", "净重", ExportColumnType.DECIMAL), + column("volume", "体积", ExportColumnType.DECIMAL), + column("mark", "唛头", ExportColumnType.STRING), + column("customerRemark", "客户备注", ExportColumnType.STRING)); + static final List DOMESTIC_WAREHOUSE_ORDER_COLUMNS = List.of( + column("inboundCode", "入仓码", ExportColumnType.STRING), + column("orderNo", "订单号", ExportColumnType.STRING), + column("mainStatus", "订单主状态", ExportColumnType.STRING), + column("customerName", "客户", ExportColumnType.STRING), + column("channelName", "渠道", ExportColumnType.STRING), + column("channelChanged", "曾变更渠道", ExportColumnType.BOOLEAN), + column("receivingWarehouseName", "定位仓库", ExportColumnType.STRING), + column("destinationWarehouseName", "交货仓", ExportColumnType.STRING), + column("expectedShipDate", "预计出运日期", ExportColumnType.DATE), + column("expectedArrivalAt", "预计到货时间", ExportColumnType.DATETIME), + column("expectedVolumeCbm", "预计体积(m³)", ExportColumnType.DECIMAL), + column("totalPackageCount", "箱数", ExportColumnType.INTEGER), + column("totalQuantity", "产品数量", ExportColumnType.INTEGER), + column("actualGrossWeightKg", "实测毛重(kg)", ExportColumnType.DECIMAL), + column("actualVolumeCbm", "实测体积(m³)", ExportColumnType.DECIMAL), + column("chargeableWeightKg", "计费重量(kg)", ExportColumnType.DECIMAL), + column("chargeableVolumeCbm", "计费体积(m³)", ExportColumnType.DECIMAL), + column("feeReviewStatus", "费用审核状态", ExportColumnType.STRING), + column("skuNos", "SKU", ExportColumnType.STRING), + column("taxMode", "报关方式", ExportColumnType.STRING), + column("containerMode", "装柜方式", ExportColumnType.STRING), + column("customsInspection", "是否商检", ExportColumnType.BOOLEAN), + column("containerNo", "集装箱号", ExportColumnType.STRING), + column("cabinetNo", "柜次号", ExportColumnType.STRING), + column("vesselName", "船名", ExportColumnType.STRING), + column("voyageNo", "航次", ExportColumnType.STRING), + column("createdAt", "创建时间", ExportColumnType.DATETIME)); + + private ExportSchemaContracts() { } + + static void validate(String exportType, List columns) { + if (BUY_DECLARATION_ITEMS.equals(exportType)) { + validate(columns, BUY_DECLARATION_COLUMNS, "买单报关导出列定义不匹配"); + } else if (DOMESTIC_WAREHOUSE_ORDER_LIST.equals(exportType)) { + validate(columns, DOMESTIC_WAREHOUSE_ORDER_COLUMNS, "国内仓订单导出列定义不匹配"); + } + } + + private static void validate(List columns, List expectedColumns, + String failureMessage) { + if (columns.size() != expectedColumns.size()) throw ExportFailure.permanent(failureMessage); + for (int index = 0; index < columns.size(); index++) { + ExportColumn actual = columns.get(index); + ExpectedColumn expected = expectedColumns.get(index); + if (!expected.key.equals(actual.key()) || !expected.title.equals(actual.title()) + || expected.type != actual.type()) { + throw ExportFailure.permanent(failureMessage); + } + } + } + + private static ExpectedColumn column(String key, String title, ExportColumnType type) { + return new ExpectedColumn(key, title, type); + } + + record ExpectedColumn(String key, String title, ExportColumnType type) { } +} diff --git a/file/src/main/java/com/baiyue/file/service/ExportStateService.java b/file/src/main/java/com/baiyue/file/service/ExportStateService.java new file mode 100644 index 000000000..62bfb5ae5 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ExportStateService.java @@ -0,0 +1,106 @@ +package com.baiyue.file.service; + +import jakarta.annotation.Resource; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileExportTask; +import com.baiyue.file.repository.ExportOutboxRepository; +import com.baiyue.file.repository.ExportTaskRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Service +public class ExportStateService { + @Resource + private ExportTaskRepository tasks; + @Resource + private ExportOutboxRepository outbox; + @Resource private ExportCommandFactory commands; + @Resource private ExportMetrics metrics; + @Transactional + public CreationResult createConstrained(FileExportTask task) { + tasks.lockCreationGuard(); + FileExportTask duplicate = tasks.recentDuplicate(task.getTenantId(), task.getAppCode(), task.getRequestedBy(), + task.getExportType(), task.getParametersJson()); + if (duplicate != null) return new CreationResult(duplicate, false); + if (tasks.activeForUser(task.getTenantId(), task.getAppCode(), task.getRequestedBy()) >= 3) { + throw new BizException(ResultCode.TOO_MANY_REQUESTS, "同一用户最多同时执行 3 个导出任务"); + } + if (tasks.activeForType(task.getExportType()) >= 5) { + throw new BizException(ResultCode.TOO_MANY_REQUESTS, "该导出类型当前任务较多,请稍后重试"); + } + tasks.insert(task); + tasks.initializeDispatchTime(task.getId()); + if (!outbox.insert(commands.create(task, task.getDispatchVersion()))) { + throw new IllegalStateException("Duplicate export command while creating task"); + } + return new CreationResult(task, true); + } + @Transactional + public boolean retryAfterFailure(FileExportTask current, String owner, int seconds, String message) { + if (!tasks.scheduleRetry(current.getId(), current.getDispatchVersion(), owner, seconds, safe(message))) return false; + FileExportTask refreshed = tasks.find(current.getId()); + if (!outbox.insert(commands.create(refreshed, refreshed.getDispatchVersion()))) { + throw new IllegalStateException("Unable to create retry command"); + } + return true; + } + @Transactional + public boolean fail(FileExportTask task, String owner, String message) { + return tasks.fail(task.getId(), task.getDispatchVersion(), owner, safe(message)); + } + @Transactional + public boolean ensureOutbox(FileExportTask task) { + return outbox.insert(commands.create(task, task.getDispatchVersion())); + } + @Transactional + public boolean manualRetry(FileExportTask task) { + if (!tasks.manualRetry(task.getId())) return false; + FileExportTask refreshed = tasks.find(task.getId()); + if (!outbox.insert(commands.create(refreshed, refreshed.getDispatchVersion()))) { + throw new IllegalStateException("Unable to create manual retry command"); + } + return true; + } + @Transactional + public boolean expeditePendingRetry(FileExportTask task) { + if (!outbox.expeditePendingRetry(task.getId(), task.getDispatchVersion())) return false; + if (!tasks.expeditePendingRetry(task.getId(), task.getDispatchVersion())) { + throw new IllegalStateException("Unable to expedite pending export task"); + } + return true; + } + @Transactional + public void quarantine(String eventId, long taskId, int version, String reason) { + FileExportTask current = tasks.find(taskId); + if (current == null || current.getDispatchVersion() != version || !"PENDING".equals(current.getStatus())) return; + boolean changed = outbox.quarantine(eventId, taskId, version, safe(reason)); + if (changed) { + if (!tasks.failQuarantined(taskId, version, safe(reason))) { + throw new IllegalStateException("Unable to fail quarantined export task"); + } + metrics.completed(current.getExportType(), "FAILED"); + } + } + @Transactional + public boolean recoverStale(FileExportTask task) { + if (task.getAttemptCount() >= task.getMaxAttempts()) { + boolean failed = tasks.failStale(task.getId(), task.getDispatchVersion()); + if (failed) metrics.completed(task.getExportType(), "FAILED"); + return failed; + } + if (!tasks.recoverStale(task.getId(), task.getDispatchVersion())) return false; + FileExportTask refreshed = tasks.find(task.getId()); + if (!outbox.insert(commands.create(refreshed, refreshed.getDispatchVersion()))) { + throw new IllegalStateException("Unable to create lease recovery command"); + } + return true; + } + private String safe(String message) { + String value = message == null || message.isBlank() ? "导出任务执行失败" : message; + return value.length() > 1000 ? value.substring(0, 1000) : value; + } + public record CreationResult(FileExportTask task, boolean created) { } +} diff --git a/file/src/main/java/com/baiyue/file/service/ExportStorage.java b/file/src/main/java/com/baiyue/file/service/ExportStorage.java new file mode 100644 index 000000000..ddcf35fa2 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ExportStorage.java @@ -0,0 +1,21 @@ +package com.baiyue.file.service; + +import org.springframework.core.io.Resource; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Path; +import java.time.Duration; +import java.util.function.Predicate; + +public interface ExportStorage { + PendingFile create(long taskId); + long size(String objectKey); + Resource load(String objectKey); + boolean delete(String objectKey); + int cleanupOrphans(Duration minimumAge, Predicate referenced); + + record PendingFile(String objectKey, Path path, OutputStream output) implements AutoCloseable { + @Override public void close() throws IOException { output.close(); } + } +} diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskExecutor.java b/file/src/main/java/com/baiyue/file/service/ExportTaskExecutor.java new file mode 100644 index 000000000..78baac807 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ExportTaskExecutor.java @@ -0,0 +1,108 @@ +package com.baiyue.file.service; + +import jakarta.annotation.Resource; + +import com.baiyue.file.config.ExportDefinitionRegistry; +import com.baiyue.file.entity.FileExportTask; +import com.baiyue.file.repository.ExportTaskRepository; +import io.micrometer.core.instrument.MeterRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import jakarta.annotation.PreDestroy; + +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +@Service +public class ExportTaskExecutor { + private static final Logger LOGGER = LoggerFactory.getLogger(ExportTaskExecutor.class); + @Resource + private ExportTaskRepository tasks; + @Resource + private ExportDefinitionRegistry definitions; + @Resource private GenericXlsxExportRunner runner; + @Resource private ExportStateService states; + @Resource private ExportStorage storage; + @Resource private MeterRegistry meters; + @Value("${baiyue.file.export.task.lease-seconds:300}") private int leaseSeconds; + @Value("${baiyue.file.export.task.heartbeat-seconds:60}") private int heartbeatSeconds; + @Value("${baiyue.file.export.task.max-duration-minutes:30}") private int maxDurationMinutes; + @Value("${baiyue.file.export.retention-days:7}") private int retentionDays; + private final ScheduledExecutorService watchdogs = Executors.newScheduledThreadPool(4, runnable -> { + Thread thread = new Thread(runnable, "file-export-watchdog"); thread.setDaemon(true); return thread; + }); + + public void execute(long taskId, int dispatchVersion, String owner) { + if (!tasks.claim(taskId, dispatchVersion, owner, leaseSeconds, maxDurationMinutes)) return; + FileExportTask task = tasks.find(taskId); + AtomicBoolean abort = new AtomicBoolean(); + ScheduledFuture watchdog = watchdogs.scheduleAtFixedRate(() -> heartbeat(taskId, dispatchVersion, owner, abort), + heartbeatSeconds, heartbeatSeconds, TimeUnit.SECONDS); + long started = System.nanoTime(); + GenericXlsxExportRunner.Result result = null; + try { + result = runner.run(task, definitions.require(task.getExportType()), abort, + rows -> { if (!tasks.progress(taskId, dispatchVersion, owner, rows)) abort.set(true); }); + if (!tasks.succeed(taskId, dispatchVersion, owner, result.fileName(), result.objectKey(), + result.contentType(), result.sizeBytes(), result.rows(), retentionDays)) { + deleteOrWarn(result.objectKey(), taskId, "最终状态提交未生效"); + FileExportTask latest = tasks.find(taskId); + if (latest != null && "RUNNING".equals(latest.getStatus()) + && latest.getDispatchVersion() == dispatchVersion && owner.equals(latest.getLeaseOwner())) { + retryOrFail(latest, dispatchVersion, owner, ExportFailure.temporary("导出执行超过最大时长 " + maxDurationMinutes + " 分钟", null)); + } + } else { + meters.counter("export_task_completed_total", "type", task.getExportType(), "status", "SUCCEEDED").increment(); + } + } catch (RuntimeException exception) { + if (result != null) deleteOrWarn(result.objectKey(), taskId, "执行异常"); + retryOrFail(tasks.find(taskId), dispatchVersion, owner, exception); + LOGGER.error("导出任务执行失败 taskId={} dispatchVersion={}", taskId, dispatchVersion, exception); + } finally { + watchdog.cancel(true); + meters.timer("export_task_duration_seconds", "type", task.getExportType()) + .record(System.nanoTime() - started, TimeUnit.NANOSECONDS); + } + } + + private void heartbeat(long id, int version, String owner, AtomicBoolean abort) { + try { if (!tasks.heartbeat(id, version, owner, leaseSeconds)) abort.set(true); } + catch (RuntimeException exception) { abort.set(true); LOGGER.error("导出 watchdog 续租失败 taskId={}", id, exception); } + } + + private void deleteOrWarn(String objectKey, long taskId, String reason) { + try { + if (!storage.delete(objectKey)) { + LOGGER.warn("导出结果文件清理失败 taskId={} objectKey={} reason={}", taskId, objectKey, reason); + } + } catch (RuntimeException exception) { + LOGGER.warn("导出结果文件清理异常 taskId={} objectKey={} reason={}", taskId, objectKey, reason, exception); + } + } + + private void retryOrFail(FileExportTask task, int expectedVersion, String owner, RuntimeException exception) { + if (task == null || !"RUNNING".equals(task.getStatus()) || task.getDispatchVersion() != expectedVersion + || !owner.equals(task.getLeaseOwner())) return; + boolean retryable = !(exception instanceof ExportFailure failure) || failure.retryable(); + String message = exception.getMessage() == null ? "导出任务执行失败" : exception.getMessage(); + if (retryable && task.getAttemptCount() < task.getMaxAttempts()) { + int backoff = jitteredBackoffSeconds(task.getAttemptCount() <= 1 ? 60 : 900); + if (states.retryAfterFailure(task, owner, backoff, message)) return; + } + if (states.fail(task, owner, message)) { + meters.counter("export_task_completed_total", "type", task.getExportType(), "status", "FAILED").increment(); + } + } + + static int jitteredBackoffSeconds(int baseSeconds) { + int spread = Math.max(1, baseSeconds / 5); + return ThreadLocalRandom.current().nextInt(baseSeconds - spread, baseSeconds + spread + 1); + } + + @PreDestroy + void shutdownWatchdogs() { + watchdogs.shutdownNow(); + } +} diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java new file mode 100644 index 000000000..a25401d95 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java @@ -0,0 +1,229 @@ +package com.baiyue.file.service; + +import jakarta.annotation.Resource; + +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.file.dto.ExportTaskDtos.CreateExportTaskRequest; +import com.baiyue.file.dto.ExportTaskDtos.ExportTaskResponse; +import com.baiyue.file.entity.FileExportTask; +import com.baiyue.file.repository.ExportTaskRepository; +import com.baiyue.file.config.ExportDefinitionRegistry; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; + +@Service +public class ExportTaskService { + @Resource private ExportTaskRepository repository; + @Resource private ExportStorage storage; + @Resource private ObjectMapper objectMapper; + @Resource private ExportDefinitionRegistry registry; + @Resource private ExportStateService stateService; + @Resource private ExportMetrics metrics; + + public ExportTaskResponse create(AuthenticatedUser user, CreateExportTaskRequest request) { + requirePlatform(user); + String type = request.exportType().trim().toUpperCase(Locale.ROOT); + String format = request.format().trim().toUpperCase(Locale.ROOT); + var definition = registry.require(type); + if (!"XLSX".equals(format)) throw new BizException(ResultCode.VALIDATION_FAILED, "当前仅支持 XLSX 格式"); + String permission = definition.getRequiredPermission(); + if (!definition.isEnabled() || !definition.getAppScope().equalsIgnoreCase(user.appCode())) { + throw new BizException(ResultCode.VALIDATION_FAILED, "该导出类型当前不可用"); + } + if (!user.permissions().contains(permission)) { + throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限"); + } + validateParameters(type, request.parameters()); + validateTypeParameters(type, request.parameters()); + String parametersJson = parametersJson(request.parameters()); + long id = IdWorker.getId(); + LocalDateTime now = LocalDateTime.now(); + FileExportTask task = new FileExportTask(); + task.setId(id); + task.setTenantId(user.tenantId()); + task.setAppCode(user.appCode().toUpperCase(Locale.ROOT)); + task.setTaskNo("EXP" + id); + task.setExportType(type); + task.setFormat(format); + task.setTaskName(request.taskName().trim()); + task.setParametersJson(parametersJson); + task.setStatus("PENDING"); + task.setProcessedRows(0L); + task.setDispatchVersion(1); + task.setAttemptCount(0); + task.setMaxAttempts(definition.getMaxAttempts()); + task.setNextRetryAt(now); + task.setRequestedBy(user.userId()); + task.setRequestedUsername(user.username()); + task.setRequestedAt(now); + ExportStateService.CreationResult result = stateService.createConstrained(task); + if (result.created()) metrics.created(type); + return response(result.task()); + } + + public PageResult page(AuthenticatedUser user, long page, long size, + String exportType, String status, + LocalDate requestedFrom, LocalDate requestedTo) { + requirePlatform(user); + if (requestedFrom != null && requestedTo != null && requestedFrom.isAfter(requestedTo)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "开始日期不能晚于结束日期"); + } + Long owner = user.permissions().contains("platform:download:all") ? null : user.userId(); + PageResult source = repository.page(page, size, user.tenantId(), user.appCode(), owner, + normalize(exportType), normalize(status), requestedFrom, requestedTo); + return new PageResult<>(source.records().stream().map(this::response).toList(), + source.page(), source.size(), source.total(), source.pages()); + } + + public ExportTaskResponse get(AuthenticatedUser user, long id) { + return response(ownedTask(user, id)); + } + + public DownloadResource download(AuthenticatedUser user, long id) { + FileExportTask task = ownedTask(user, id); + if (!"SUCCEEDED".equals(task.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "导出任务尚未完成或文件已过期"); + } + if (!repository.downloadable(id)) { + throw new BizException(ResultCode.CONFLICT, "导出文件已过期"); + } + return new DownloadResource(storage.load(task.getObjectKey()), task.getFileName(), task.getContentType()); + } + + public ExportTaskResponse retry(AuthenticatedUser user, long id) { + FileExportTask task = ownedTask(user, id); + var definition = registry.require(task.getExportType()); + if (!definition.isEnabled()) throw new BizException(ResultCode.CONFLICT, "该导出类型已停用"); + String permission = definition.getRequiredPermission(); + if (!user.permissions().contains(permission)) { + throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限"); + } + if ("PENDING".equals(task.getStatus()) && task.getAttemptCount() != null && task.getAttemptCount() > 0) { + if (!stateService.expeditePendingRetry(task)) { + throw new BizException(ResultCode.CONFLICT, "任务已进入执行队列,无需重复重试"); + } + return response(repository.find(id)); + } + if (!"FAILED".equals(task.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "只有等待重试或失败的导出任务可以重试"); + } + if (!storage.delete(task.getObjectKey())) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "上次导出文件清理失败,请稍后重试"); + } + boolean retried = stateService.manualRetry(task); + if (!retried) { + throw new BizException(ResultCode.CONFLICT, "只有等待重试或失败的导出任务可以重试"); + } + return response(repository.find(id)); + } + + private FileExportTask ownedTask(AuthenticatedUser user, long id) { + requirePlatform(user); + FileExportTask task = repository.find(id); + if (task == null || !task.getTenantId().equals(user.tenantId()) + || !task.getAppCode().equalsIgnoreCase(user.appCode())) { + throw new BizException(ResultCode.NOT_FOUND, "导出任务不存在"); + } + if (!task.getRequestedBy().equals(user.userId()) && !user.permissions().contains("platform:download:all")) { + throw new BizException(ResultCode.FORBIDDEN, "不能访问其他用户的导出任务"); + } + return task; + } + + private String parametersJson(Map parameters) { + try { return objectMapper.writeValueAsString(parameters == null ? Map.of() : new TreeMap<>(parameters)); } + catch (JsonProcessingException exception) { throw new BizException(ResultCode.BAD_REQUEST, "导出筛选条件无法序列化"); } + } + + private void validateParameters(String type, Map parameters) { + Map values = parameters == null ? Map.of() : parameters; + int maxItems = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST.equals(type) ? 32 : 20; + if (values.size() > maxItems) { + throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选条件最多 " + maxItems + " 项"); + } + values.forEach((key, value) -> { + if (key == null || key.isBlank() || key.length() > 64) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选键无效"); + validateParameterValue(value, 1); + }); + try { + int maxBytes = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST.equals(type) + ? 512 * 1024 : 4096; + if (objectMapper.writeValueAsBytes(values).length > maxBytes) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "导出筛选条件不能超过 " + (maxBytes / 1024) + "KB"); + } + } catch (JsonProcessingException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "导出筛选条件无法序列化"); + } + } + + private void validateParameterValue(Object value, int depth) { + if (depth > 2) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选条件嵌套不能超过 2 层"); + if (value == null || value instanceof Number || value instanceof Boolean) return; + if (value instanceof String text) { + if (text.length() > 256) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选值最多 256 字符"); + return; + } + if (value instanceof Map map) { + map.forEach((key, nested) -> { + if (!(key instanceof String text) || text.isBlank() || text.length() > 64) { + throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选键无效"); + } + validateParameterValue(nested, depth + 1); + }); + return; + } + if (value instanceof Iterable values) { + values.forEach(nested -> validateParameterValue(nested, depth + 1)); + return; + } + throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选值类型无效"); + } + + private void validateTypeParameters(String type, Map parameters) { + if (!ExportSchemaContracts.BUY_DECLARATION_ITEMS.equals(type)) return; + Map values = parameters == null ? Map.of() : parameters; + if (!values.keySet().equals(java.util.Set.of("loadingPlanId"))) { + throw new BizException(ResultCode.VALIDATION_FAILED, "买单报关导出仅接受 loadingPlanId"); + } + Object raw = values.get("loadingPlanId"); + try { + if (!(raw instanceof String text) || !text.matches("[1-9][0-9]*") + || Long.parseLong(text) <= 0) throw new NumberFormatException(); + } catch (NumberFormatException exception) { + throw new BizException(ResultCode.VALIDATION_FAILED, "loadingPlanId 必须为正整数字符串"); + } + } + + private ExportTaskResponse response(FileExportTask task) { + return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(), + task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(), + task.getProcessedRows() == null ? 0 : task.getProcessedRows(), task.getFileName(), + task.getContentType(), task.getSizeBytes(), task.getFailureMessage(), + task.getAttemptCount() == null ? 0 : task.getAttemptCount(), task.getMaxAttempts(), task.getNextRetryAt(), + String.valueOf(task.getRequestedBy()), task.getRequestedUsername(), task.getRequestedAt(), + task.getStartedAt(), task.getCompletedAt(), task.getExpiresAt()); + } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.userId() == null || user.tenantId() == null + || user.tenantId() != 0L || !"PLATFORM".equalsIgnoreCase(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required"); + } + } + + private String normalize(String value) { return value == null || value.isBlank() ? null : value.trim().toUpperCase(Locale.ROOT); } + + public record DownloadResource(org.springframework.core.io.Resource resource, String fileName, String contentType) { } +} diff --git a/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java b/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java new file mode 100644 index 000000000..8be548608 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java @@ -0,0 +1,212 @@ +package com.baiyue.file.service; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.common.core.export.ExportContracts.ExportColumn; +import com.baiyue.file.client.ExportProviderClient; +import com.baiyue.file.client.ExportProviderResponseTooLargeException; +import com.baiyue.file.config.ExportDefinitionRegistry.Definition; +import com.baiyue.file.entity.FileExportTask; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.LongConsumer; + +@Service +public class GenericXlsxExportRunner { + private static final Logger LOGGER = LoggerFactory.getLogger(GenericXlsxExportRunner.class); + public static final String CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + private static final int EXCEL_SHEET_ROWS = 1_048_575; + @Resource private ExportProviderClient provider; + @Resource private ExportStorage storage; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.file.export.task.max-file-size-mb:50}") private long maxFileSizeMb; + private long maxFileBytes; + + @PostConstruct + void initializeLimits() { + maxFileBytes = Math.max(1, maxFileSizeMb) * 1024L * 1024L; + } + + public Result run(FileExportTask task, Definition definition, AtomicBoolean abort, LongConsumer progress) { + Map parameters = parameters(task.getParametersJson()); + ExportStorage.PendingFile target = storage.create(task.getId()); + long rows = 0; + String cursor = null, snapshot = null, schemaHash = null; + List columns = null; + Set columnKeys = Set.of(); + Set warnedUnknownColumns = new HashSet<>(); + Set warnedTruncatedColumns = new HashSet<>(); + try (target; SXSSFWorkbook workbook = new SXSSFWorkbook(100)) { + workbook.setCompressTempFiles(true); + WorkbookStyles styles = new WorkbookStyles(workbook); + SheetState sheet = null; + while (true) { + checkAbort(abort); + ExportBatchRequest request = new ExportBatchRequest(String.valueOf(task.getId()), + String.valueOf(task.getTenantId()), task.getAppCode(), String.valueOf(task.getRequestedBy()), + task.getDispatchVersion(), parameters, cursor, snapshot, definition.getBatchSize()); + ApiResponse response; + try { response = provider.batch(internalToken, task.getExportType(), definition, request); } + catch (RuntimeException exception) { + if (hasCause(exception, ExportProviderResponseTooLargeException.class)) { + throw ExportFailure.permanent("Provider 单批响应超过 4MB"); + } + throw ExportFailure.temporary("导出数据服务暂不可用", exception); + } + if (response == null) throw ExportFailure.temporary("导出数据服务无响应", null); + if (!response.successful()) { + String message = response.message() == null ? "导出数据服务返回失败" : response.message(); + if (response.code() != null && response.code().startsWith("S")) throw ExportFailure.temporary(message, null); + throw ExportFailure.permanent(message); + } + if (response.data() == null) throw ExportFailure.permanent("Provider 成功响应缺少 data"); + ExportBatchResponse batch = response.data(); + try { if (objectMapper.writeValueAsBytes(batch).length > 4 * 1024 * 1024) throw ExportFailure.permanent("Provider 单批响应超过 4MB"); } + catch (IOException exception) { throw ExportFailure.permanent("Provider 响应无法序列化"); } + if (batch.rows() == null || batch.rows().size() > definition.getBatchSize()) throw ExportFailure.permanent("Provider 单批数据超过限制"); + if (batch.totalRows() != null && batch.totalRows() > definition.getMaxRows()) throw ExportFailure.permanent("导出预计行数超过最大限制 " + definition.getMaxRows()); + if (schemaHash == null) { + if (batch.schemaVersion() != 1 || blank(batch.schemaHash()) || blank(batch.snapshotToken()) || batch.snapshotToken().length() > 512) { + throw ExportFailure.permanent("Provider 首批契约无效"); + } + columns = validateColumns(batch.columns()); + ExportSchemaContracts.validate(task.getExportType(), columns); + columnKeys = columns.stream().map(ExportColumn::key).collect(java.util.stream.Collectors.toUnmodifiableSet()); schemaHash = batch.schemaHash(); snapshot = batch.snapshotToken(); + sheet = newSheet(workbook, 1, columns); + } else if (!schemaHash.equals(batch.schemaHash())) { + throw ExportFailure.permanent("Provider 导出列定义在执行期间发生变化"); + } + for (Map values : batch.rows()) { + if (abort.get()) throw ExportFailure.temporary("导出执行租约已失效", null); + if (++rows > definition.getMaxRows()) throw ExportFailure.permanent("导出行数超过最大限制 " + definition.getMaxRows()); + if (sheet.rowIndex > EXCEL_SHEET_ROWS) sheet = newSheet(workbook, sheet.index + 1, columns); + writeRow(sheet.sheet.createRow(sheet.rowIndex++), columns, columnKeys, values, styles, warnedUnknownColumns, warnedTruncatedColumns); + if (rows % 10_000 == 0) checkAbort(abort); + } + progress.accept(rows); + if (!batch.hasMore()) break; + if (blank(batch.nextCursor()) || batch.nextCursor().equals(cursor)) throw ExportFailure.temporary("Provider 游标未推进", null); + cursor = batch.nextCursor(); + } + checkAbort(abort); + workbook.write(new SizeLimitedOutputStream(target.output(), maxFileBytes)); + } catch (SizeLimitedOutputStream.ExportSizeExceededException exception) { + deleteOrWarn(target.objectKey(), task.getId(), "文件超过大小上限"); throw ExportFailure.permanent(exception.getMessage()); + } catch (IOException exception) { + deleteOrWarn(target.objectKey(), task.getId(), "文件写入失败"); throw ExportFailure.temporary("导出文件写入失败", exception); + } catch (RuntimeException exception) { + deleteOrWarn(target.objectKey(), task.getId(), "导出生成失败"); throw exception; + } + String name = task.getTaskName() + "-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + ".xlsx"; + try { + return new Result(name, target.objectKey(), CONTENT_TYPE, storage.size(target.objectKey()), rows); + } catch (RuntimeException exception) { + deleteOrWarn(target.objectKey(), task.getId(), "读取最终文件大小失败"); + throw ExportFailure.temporary("导出文件读取失败", exception); + } + } + + private void deleteOrWarn(String objectKey, long taskId, String reason) { + try { + if (!storage.delete(objectKey)) { + LOGGER.warn("导出临时文件清理失败 taskId={} objectKey={} reason={}", taskId, objectKey, reason); + } + } catch (RuntimeException exception) { + LOGGER.warn("导出临时文件清理异常 taskId={} objectKey={} reason={}", taskId, objectKey, reason, exception); + } + } + + private List validateColumns(List columns) { + if (columns == null || columns.isEmpty() || columns.size() > 100) throw ExportFailure.permanent("Provider 导出列定义无效"); + Set keys = new HashSet<>(); + for (ExportColumn column : columns) { + if (column == null || blank(column.key()) || blank(column.title()) || column.type() == null || !keys.add(column.key())) { + throw ExportFailure.permanent("Provider 导出列定义无效"); + } + } + return List.copyOf(columns); + } + + private SheetState newSheet(SXSSFWorkbook workbook, int index, List columns) { + Sheet sheet = workbook.createSheet(index == 1 ? "导出数据" : "导出数据-" + index); + CellStyle header = workbook.createCellStyle(); header.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); header.setFillPattern(FillPatternType.SOLID_FOREGROUND); + Font font = workbook.createFont(); font.setBold(true); header.setFont(font); + Row row = sheet.createRow(0); + for (int i = 0; i < columns.size(); i++) { + Cell cell = row.createCell(i); cell.setCellValue(columns.get(i).title()); cell.setCellStyle(header); + sheet.setColumnWidth(i, Math.min(255, Math.max(8, columns.get(i).width() == null ? 18 : columns.get(i).width())) * 256); + } + sheet.createFreezePane(0, 1); + return new SheetState(index, sheet, 1); + } + + private void writeRow(Row row, List columns, Set columnKeys, Map values, + WorkbookStyles styles, Set warnedUnknownColumns, Set warnedTruncatedColumns) { + Map source = values == null ? Map.of() : values; + source.keySet().stream().filter(key -> !columnKeys.contains(key) && warnedUnknownColumns.add(key)) + .forEach(key -> LOGGER.warn("Provider 返回未声明字段,已忽略 column={}", key)); + for (int i = 0; i < columns.size(); i++) writeCell(row.createCell(i), columns.get(i), source.get(columns.get(i).key()), styles, warnedTruncatedColumns); + } + + private void writeCell(Cell cell, ExportColumn column, Object value, WorkbookStyles styles, Set warnedTruncatedColumns) { + if (value == null) return; + try { + switch (column.type()) { + case STRING -> cell.setCellValue(safeString(String.valueOf(value), column.key(), warnedTruncatedColumns)); + case INTEGER -> cell.setCellValue(new BigDecimal(String.valueOf(value)).longValueExact()); + case DECIMAL -> cell.setCellValue(new BigDecimal(String.valueOf(value)).doubleValue()); + case BOOLEAN -> cell.setCellValue(value instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(value))); + case DATE -> { cell.setCellValue(LocalDate.parse(String.valueOf(value))); cell.setCellStyle(styles.date); } + case DATETIME -> { cell.setCellValue(LocalDateTime.parse(String.valueOf(value))); cell.setCellStyle(styles.dateTime); } + } + } catch (RuntimeException exception) { throw ExportFailure.permanent("Provider 字段类型与列定义不匹配: " + column.key()); } + } + + private String safeString(String value, String column, Set warnedTruncatedColumns) { + if (value.length() > 32767 && warnedTruncatedColumns.add(column)) { + LOGGER.warn("Provider 字符串超过 Excel 单元格上限,已截断 column={}", column); + } + String text = value.length() > 32767 ? value.substring(0, 32767) : value; + return !text.isEmpty() && "=+-@\t\r\n".indexOf(text.charAt(0)) >= 0 ? "'" + text : text; + } + private Map parameters(String json) { + try { return objectMapper.readValue(json, new TypeReference<>() { }); } + catch (Exception exception) { throw ExportFailure.permanent("导出筛选条件无效"); } + } + private void checkAbort(AtomicBoolean abort) { if (abort.get()) throw ExportFailure.temporary("导出执行超过最大时长或租约已失效", null); } + private boolean blank(String value) { return value == null || value.isBlank(); } + private boolean hasCause(Throwable error, Class type) { + for (Throwable current=error; current!=null; current=current.getCause()) if (type.isInstance(current)) return true; + return false; + } + private static final class WorkbookStyles { + final CellStyle date; + final CellStyle dateTime; + WorkbookStyles(Workbook workbook) { + DataFormat formats = workbook.getCreationHelper().createDataFormat(); + date = workbook.createCellStyle(); date.setDataFormat(formats.getFormat("yyyy-mm-dd")); + dateTime = workbook.createCellStyle(); dateTime.setDataFormat(formats.getFormat("yyyy-mm-dd hh:mm:ss")); + } + } + private static final class SheetState { final int index; final Sheet sheet; int rowIndex; SheetState(int index, Sheet sheet, int rowIndex) { this.index=index;this.sheet=sheet;this.rowIndex=rowIndex; } } + public record Result(String fileName, String objectKey, String contentType, long sizeBytes, long rows) { } +} diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java new file mode 100644 index 000000000..b82ed4fd8 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -0,0 +1,94 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +@Service +public class InternalBusinessFileService { + @Resource private FileObjectMapper files; + @Resource private BusinessFileStorage storage; + + public Metadata upload(long tenantId, String businessType, String businessId, long operatorId, + MultipartFile file) { + if (tenantId < 0 || operatorId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "归属和操作人必须有效"); + } + BusinessFileCategory category = BusinessFileCategory.require(businessType); + category.validateOwner(tenantId); + String storageBusinessId = category.storageBusinessId(businessId); + var stored = storage.store(tenantId, storageBusinessId, file, category); + FileObjectEntity value = new FileObjectEntity(); + value.setTenantId(tenantId); + value.setBusinessType(businessType); + value.setBusinessId(businessId); + value.setOriginalName(stored.originalName()); + value.setObjectKey(stored.objectKey()); + value.setStorageProvider("LOCAL"); + value.setBucketName("business-files"); + value.setContentType(stored.contentType()); + value.setSizeBytes(stored.size()); + value.setSha256(stored.sha256()); + value.setStatus("ACTIVE"); + value.setCreatedBy(operatorId); + value.setUpdatedBy(operatorId); + value.setCreatedAt(LocalDateTime.now()); + value.setUpdatedAt(value.getCreatedAt()); + value.setDeleted(false); + value.setVersion(0); + if (files.insert(value) != 1) { + throw new BizException(ResultCode.INTERNAL_ERROR, "附件元数据保存失败"); + } + return metadata(value); + } + + public Metadata metadata(long tenantId, String businessType, String businessId, long fileId) { + return metadata(required(tenantId, businessType, businessId, fileId)); + } + + public Download download(long tenantId, String businessType, String businessId, long fileId) { + FileObjectEntity value = required(tenantId, businessType, businessId, fileId); + return new Download(value.getOriginalName(), value.getContentType(), storage.read(value.getObjectKey())); + } + + private FileObjectEntity required(long tenantId, String businessType, String businessId, long fileId) { + validateScope(tenantId, businessType, businessId, fileId); + FileObjectEntity value = files.selectOne(new LambdaQueryWrapper() + .eq(FileObjectEntity::getTenantId, tenantId) + .eq(FileObjectEntity::getBusinessType, businessType) + .eq(FileObjectEntity::getBusinessId, businessId) + .eq(FileObjectEntity::getId, fileId) + .eq(FileObjectEntity::getStatus, "ACTIVE") + .eq(FileObjectEntity::getDeleted, false)); + if (value == null) { + throw new BizException(ResultCode.NOT_FOUND, "业务附件不存在或归属不匹配"); + } + return value; + } + + private static void validateScope(long tenantId, String businessType, String businessId, long fileId) { + if (tenantId < 0 || fileId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "归属和文件ID必须有效"); + } + BusinessFileCategory category = BusinessFileCategory.require(businessType); + category.validateOwner(tenantId); + category.storageBusinessId(businessId); + } + + private static Metadata metadata(FileObjectEntity value) { + return new Metadata(value.getId(), value.getTenantId() == 0 ? "PLATFORM" : "CUSTOMER", + value.getTenantId(), value.getBusinessType(), value.getBusinessId(), + value.getOriginalName(), value.getSha256(), value.getContentType(), value.getSizeBytes(), value.getCreatedAt()); + } + + public record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId, + String fileName, String sha256, String contentType, Long sizeBytes, + LocalDateTime createdAt) {} + public record Download(String fileName, String contentType, byte[] content) {} +} diff --git a/file/src/main/java/com/baiyue/file/service/LoadingEvidenceService.java b/file/src/main/java/com/baiyue/file/service/LoadingEvidenceService.java new file mode 100644 index 000000000..602ed5a26 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/LoadingEvidenceService.java @@ -0,0 +1,86 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.Set; + +/** 装柜四步照片和铅封证据的受控文件入口。 */ +@Service +public class LoadingEvidenceService { + private static final long PLATFORM_TENANT_ID = 0L; + private static final String PLATFORM_OWNER_SCOPE = "PLATFORM"; + private static final Set BUSINESS_TYPES = Set.of( + "LOADING_PHOTO_STEP_1", "LOADING_PHOTO_STEP_2", "LOADING_PHOTO_STEP_3", + "LOADING_PHOTO_STEP_4", "LOADING_SEAL"); + @Resource private MeasurementImageStorage storage; + @Resource private FileObjectMapper files; + + @Transactional + public UploadResult upload(long containerBookingId, long operatorId, String evidenceType, + MultipartFile file) { + validateType(evidenceType); + var stored = storage.storeLoading(PLATFORM_TENANT_ID, containerBookingId, file); + FileObjectEntity object = new FileObjectEntity(); + object.setTenantId(PLATFORM_TENANT_ID); + object.setBusinessType("LOADING_" + evidenceType); + object.setBusinessId(Long.toString(containerBookingId)); + object.setOriginalName(stored.originalName()); + object.setObjectKey(stored.objectKey()); + object.setStorageProvider("LOCAL"); + object.setBucketName("loading-evidence"); + object.setContentType(stored.contentType()); + object.setSizeBytes(stored.size()); + object.setSha256(stored.sha256()); + object.setStatus("ACTIVE"); + object.setCreatedBy(operatorId); object.setUpdatedBy(operatorId); + object.setCreatedAt(LocalDateTime.now()); object.setUpdatedAt(LocalDateTime.now()); + object.setDeleted(false); object.setVersion(0); + files.insert(object); + return new UploadResult(object.getId(), PLATFORM_OWNER_SCOPE, object.getTenantId(), + object.getBusinessType(), object.getBusinessId(), object.getOriginalName(), + object.getContentType(), object.getSizeBytes()); + } + + public FileMetadata validate(long containerBookingId, long fileId) { + FileObjectEntity object = requireObject(containerBookingId, fileId); + return new FileMetadata(object.getId(), PLATFORM_OWNER_SCOPE, object.getTenantId(), + object.getBusinessType(), object.getBusinessId(), object.getOriginalName(), object.getContentType()); + } + + public Download download(long containerBookingId, long fileId) { + FileObjectEntity object = requireObject(containerBookingId, fileId); + return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey())); + } + + private FileObjectEntity requireObject(long containerBookingId, long fileId) { + FileObjectEntity object = files.selectOne(new LambdaQueryWrapper() + .eq(FileObjectEntity::getTenantId, PLATFORM_TENANT_ID).eq(FileObjectEntity::getId, fileId) + .eq(FileObjectEntity::getBusinessId, Long.toString(containerBookingId)) + .in(FileObjectEntity::getBusinessType, BUSINESS_TYPES) + .eq(FileObjectEntity::getStatus, "ACTIVE") + .eq(FileObjectEntity::getDeleted, false)); + if (object == null) throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在"); + return object; + } + + private static void validateType(String type) { + if (type == null || !(type.matches("PHOTO_STEP_[1-4]") || "SEAL".equals(type))) { + throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型"); + } + } + + public record UploadResult(Long fileId, String ownerScope, Long tenantId, String businessType, + String businessId, String fileName, String contentType, Long sizeBytes) {} + public record FileMetadata(Long fileId, String ownerScope, Long tenantId, String businessType, + String businessId, String fileName, String contentType) {} + public record Download(String fileName, String contentType, byte[] content) {} +} diff --git a/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java b/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java new file mode 100644 index 000000000..b9bd80214 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java @@ -0,0 +1,106 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.time.Instant; +import java.util.function.Predicate; +import java.util.stream.Stream; +import java.util.UUID; + +@Service +public class LocalExportStorage implements ExportStorage { + @Value("${baiyue.file.storage-root}") private String configuredRoot; + private Path root; + private Path exportRoot; + + @PostConstruct + void initialize() { + root = Path.of(configuredRoot).toAbsolutePath().normalize(); + exportRoot = root.resolve("exports").normalize(); + try { + Files.createDirectories(exportRoot); + if (!exportRoot.startsWith(root) || !Files.isDirectory(exportRoot) || !Files.isWritable(exportRoot)) { + throw new IOException("Export storage is not writable"); + } + } catch (IOException exception) { + throw new IllegalStateException("导出文件存储目录不可用", exception); + } + } + + public PendingFile create(long taskId) { + String objectKey = "exports/" + taskId + "/" + UUID.randomUUID() + ".xlsx"; + Path target = resolve(objectKey); + try { + Files.createDirectories(target.getParent()); + OutputStream output = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + return new PendingFile(objectKey, target, output); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "导出文件存储暂不可用"); + } + } + + public long size(String objectKey) { + try { return Files.size(resolve(objectKey)); } + catch (IOException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "导出文件读取失败"); } + } + + public Resource load(String objectKey) { + Path target = resolve(objectKey); + if (!Files.isRegularFile(target)) throw new BizException(ResultCode.NOT_FOUND, "导出文件不存在或已清理"); + try { return new UrlResource(target.toUri()); } + catch (Exception exception) { throw new BizException(ResultCode.NOT_FOUND, "导出文件不存在或已清理"); } + } + + public boolean delete(String objectKey) { + if (objectKey == null || objectKey.isBlank()) return true; + Path target = resolve(objectKey); + try { + Files.deleteIfExists(target); + } catch (IOException ignored) { + return false; + } + Path parent = target.getParent(); + if (parent != null && parent.startsWith(exportRoot) && !parent.equals(exportRoot)) { + try { Files.deleteIfExists(parent); } + catch (IOException ignored) { /* 文件已经删除,空目录清理失败不影响任务过期。 */ } + } + return true; + } + + @Override + public int cleanupOrphans(Duration minimumAge, Predicate referenced) { + int removed = 0; + Instant cutoff = Instant.now().minus(minimumAge); + try (Stream paths = Files.walk(exportRoot)) { + for (Path path : paths.filter(Files::isRegularFile).toList()) { + String objectKey = root.relativize(path).toString().replace('\\', '/'); + if (!objectKey.matches("exports/\\d+/[0-9a-fA-F-]{36}\\.xlsx")) continue; + if (Files.getLastModifiedTime(path).toInstant().isAfter(cutoff) || referenced.test(objectKey)) continue; + if (delete(objectKey)) removed++; + } + } catch (IOException ignored) { /* 下一轮继续清理;存储指标和日志负责暴露持续故障。 */ } + return removed; + } + + private Path resolve(String objectKey) { + if (objectKey == null || !objectKey.matches("exports/\\d+/[0-9a-fA-F-]{36}\\.xlsx")) { + throw new BizException(ResultCode.BAD_REQUEST, "Invalid export object key"); + } + Path target = root.resolve(objectKey).normalize(); + if (!target.startsWith(exportRoot)) throw new BizException(ResultCode.BAD_REQUEST, "Invalid export object key"); + return target; + } + +} diff --git a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java index 830394a15..8885e0be6 100644 --- a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java +++ b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java @@ -1,6 +1,10 @@ package com.baiyue.file.service; import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; @@ -11,44 +15,78 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.StandardOpenOption; import java.util.Locale; import java.util.Set; import java.util.UUID; @Service public class LocalQualificationStorage { + private static final Logger LOGGER = LoggerFactory.getLogger(LocalQualificationStorage.class); private static final long MAX_SIZE = 5L * 1024 * 1024; - private static final Set TYPES = Set.of("image/jpeg", "image/png"); - private final Path root; + private static final Set TYPES = Set.of( + "image/jpeg", "image/png", "image/bmp", "image/x-ms-bmp", "application/pdf"); + @Value("${baiyue.file.storage-root}") private String configuredRoot; + private Path root; + private Path qualificationRoot; - public LocalQualificationStorage(@Value("${baiyue.file.storage-root}") String root) { - this.root = Path.of(root).toAbsolutePath().normalize(); + /** 服务启动时校验存储根目录,避免健康检查正常但首次上传才暴露挂载或权限问题。 */ + @PostConstruct + public void initialize() { + root = Path.of(configuredRoot).toAbsolutePath().normalize(); + qualificationRoot = root.resolve("qualifications").normalize(); + try { + Files.createDirectories(root); + Files.createDirectories(qualificationRoot); + if (!qualificationRoot.startsWith(root) + || !Files.isDirectory(qualificationRoot) || !Files.isWritable(qualificationRoot)) { + throw new IOException("存储根目录不可写"); + } + verifyWriteAccess(); + } catch (IOException exception) { + LOGGER.error("企业资质存储根目录不可用:{}", root, exception); + throw new IllegalStateException("企业资质存储根目录不可用", exception); + } } public StoredFile store(long tenantId, MultipartFile file) { - if (file == null || file.isEmpty()) throw new BizException("One business-license image is required"); - if (file.getSize() > MAX_SIZE) throw new BizException("Business-license image must not exceed 5 MB"); + if (file == null || file.isEmpty()) throw new BizException("One qualification file is required / 请选择资质文件"); + if (file.getSize() > MAX_SIZE) throw new BizException("Qualification file must not exceed 5 MB / 资质文件不能超过 5MB"); String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); - if (!TYPES.contains(contentType)) throw new BizException("Only JPG and PNG images are supported"); - if (!matchesImageSignature(file, contentType)) { - throw new BizException("The uploaded file content is not a valid JPG or PNG image"); + if (!TYPES.contains(contentType)) throw new BizException("Only PDF, JPG, PNG and BMP files are supported / 仅支持 PDF、JPG、PNG、BMP 文件"); + if (!matchesSignature(file, contentType)) { + throw new BizException("The uploaded file content does not match its type / 文件内容与格式不匹配"); } - String extension = "image/png".equals(contentType) ? ".png" : ".jpg"; + String extension = switch (contentType) { + case "image/png" -> ".png"; + case "image/bmp", "image/x-ms-bmp" -> ".bmp"; + case "application/pdf" -> ".pdf"; + default -> ".jpg"; + }; String fileName = UUID.randomUUID() + extension; - Path directory = root.resolve("qualifications").resolve(Long.toString(tenantId)).normalize(); + Path directory = tenantDirectory(tenantId); Path target = directory.resolve(fileName).normalize(); if (!target.startsWith(directory)) throw new BizException("Invalid upload path"); try { Files.createDirectories(directory); - file.transferTo(target); + try (InputStream input = file.getInputStream(); + OutputStream output = Files.newOutputStream(target, + StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + input.transferTo(output); + } return new StoredFile(target, fileName, "/api/merchant/v1/files/qualifications/" + fileName); } catch (IOException exception) { - throw new BizException("Unable to store enterprise qualification"); + try { Files.deleteIfExists(target); } catch (IOException cleanupException) { + exception.addSuppressed(cleanupException); + } + LOGGER.error("企业资质文件写入失败,tenantId={},目录={}", tenantId, directory, exception); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "企业资质文件存储暂不可用"); } } - private boolean matchesImageSignature(MultipartFile file, String contentType) { + private boolean matchesSignature(MultipartFile file, String contentType) { byte[] header = new byte[8]; try (InputStream input = file.getInputStream()) { int read = input.read(header); @@ -56,6 +94,13 @@ public class LocalQualificationStorage { return read >= 3 && (header[0] & 0xff) == 0xff && (header[1] & 0xff) == 0xd8 && (header[2] & 0xff) == 0xff; } + if ("application/pdf".equals(contentType)) { + return read >= 5 && header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44 + && header[3] == 0x46 && header[4] == 0x2d; + } + if ("image/bmp".equals(contentType) || "image/x-ms-bmp".equals(contentType)) { + return read >= 2 && header[0] == 0x42 && header[1] == 0x4d; + } return read >= 8 && (header[0] & 0xff) == 0x89 && header[1] == 0x50 && header[2] == 0x4e && header[3] == 0x47 && header[4] == 0x0d && header[5] == 0x0a && header[6] == 0x1a && header[7] == 0x0a; @@ -65,17 +110,58 @@ public class LocalQualificationStorage { } public Resource load(long tenantId, String fileName) { - if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png)")) throw new BizException("Invalid file name"); - Path directory = root.resolve("qualifications").resolve(Long.toString(tenantId)).normalize(); + if (fileName == null || !fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|bmp|pdf)")) { + throw fileNotFound(); + } + Path directory = tenantDirectory(tenantId); Path target = directory.resolve(fileName).normalize(); - if (!target.startsWith(directory) || !Files.isRegularFile(target)) throw new BizException("File not found"); + if (!target.startsWith(directory) || !Files.isRegularFile(target)) throw fileNotFound(); try { return new UrlResource(target.toUri()); } - catch (Exception exception) { throw new BizException("File not found"); } + catch (Exception exception) { throw fileNotFound(); } + } + + private BizException fileNotFound() { + return new BizException(ResultCode.NOT_FOUND, "File not found"); } public void delete(StoredFile file) { try { Files.deleteIfExists(file.path()); } catch (IOException ignored) { } } + public void deleteByDocumentUrl(long tenantId, String documentUrl) { + if (documentUrl == null || documentUrl.isBlank()) return; + String prefix = "/api/merchant/v1/files/qualifications/"; + if (!documentUrl.startsWith(prefix)) return; + String fileName = documentUrl.substring(prefix.length()); + if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|bmp|pdf)")) return; + Path directory = tenantDirectory(tenantId); + Path target = directory.resolve(fileName).normalize(); + if (!target.startsWith(directory)) return; + try { Files.deleteIfExists(target); } catch (IOException exception) { + LOGGER.warn("旧资质文件清理失败,tenantId={},fileName={}", tenantId, fileName, exception); + } + } + + private Path tenantDirectory(long tenantId) { + Path directory = qualificationRoot.resolve(Long.toString(tenantId)).normalize(); + if (!directory.startsWith(qualificationRoot)) { + throw new BizException("Invalid upload path"); + } + return directory; + } + + /** 验证实际挂载目录可创建和删除文件,避免仅凭 isWritable 掩盖 Docker bind mount 权限或只读问题。 */ + private void verifyWriteAccess() throws IOException { + Path probe = Files.createTempFile(qualificationRoot, ".file-service-probe-", ".tmp"); + try { + Files.delete(probe); + } catch (IOException exception) { + try { Files.deleteIfExists(probe); } catch (IOException cleanupException) { + exception.addSuppressed(cleanupException); + } + throw exception; + } + } + public record StoredFile(Path path, String fileName, String documentUrl) { } } diff --git a/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java b/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java new file mode 100644 index 000000000..6bedb1925 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java @@ -0,0 +1,225 @@ +package com.baiyue.file.service; + +import jakarta.annotation.Resource; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.client.WeightOcrClient; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import feign.FeignException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.Set; + +import javax.imageio.ImageIO; + +@Service +public class MeasurementImageService { + @Resource private MeasurementImageStorage storage; + @Resource private FileObjectMapper files; + @Resource private WeightOcrClient ocr; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + @Transactional + public UploadResult upload(long tenantId, long inboundId, long operatorId, MultipartFile file) { + return recognize(storeObject(tenantId, inboundId, operatorId, file, "INBOUND_WEIGHT_IMAGE")); + } + + /** 保存第三方尺寸测量证据图片,不触发重量 OCR。 */ + @Transactional + public UploadResult uploadMeasurementEvidence(long tenantId, long inboundId, long operatorId, + MultipartFile file) { + FileObjectEntity object = storeObject( + tenantId, inboundId, operatorId, file, "INBOUND_MEASUREMENT_IMAGE"); + return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), + object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null, null); + } + + /** 保存尚未匹配正式入库单的墨家联调图片。 */ + @Transactional + public UploadResult uploadMojiaReceiptEvidence(long tenantId, long receiptId, long operatorId, + MultipartFile file) { + FileObjectEntity object = storeObject( + tenantId, receiptId, operatorId, file, "MOJIA_MEASUREMENT_RECEIPT"); + return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), + object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null, null); + } + + private FileObjectEntity storeObject(long tenantId, long inboundId, long operatorId, + MultipartFile file, String businessType) { + var stored = storage.store(tenantId, inboundId, file); + FileObjectEntity object = new FileObjectEntity(); + object.setTenantId(tenantId); + object.setBusinessType(businessType); + object.setBusinessId(Long.toString(inboundId)); + object.setOriginalName(stored.originalName()); + object.setObjectKey(stored.objectKey()); + object.setStorageProvider("LOCAL"); + object.setBucketName("measurement-images"); + object.setContentType(stored.contentType()); + object.setSizeBytes(stored.size()); + object.setSha256(stored.sha256()); + object.setStatus("ACTIVE"); + object.setCreatedBy(operatorId); + object.setUpdatedBy(operatorId); + object.setCreatedAt(LocalDateTime.now()); + object.setUpdatedAt(LocalDateTime.now()); + object.setDeleted(false); + object.setVersion(0); + files.insert(object); + return object; + } + + public UploadResult retryRecognition(long tenantId, long inboundId, long fileId) { + FileObjectEntity object = requireObject(tenantId, inboundId, fileId); + if (!"INBOUND_WEIGHT_IMAGE".equals(object.getBusinessType())) { + throw new BizException(ResultCode.CONFLICT, "尺寸测量证据图片不执行重量OCR"); + } + return recognize(object); + } + + public Download download(long tenantId, long inboundId, long fileId) { + FileObjectEntity object = requireObject(tenantId, inboundId, fileId); + return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey())); + } + + public Download thumbnail(long tenantId, long inboundId, long fileId) { + Download original = download(tenantId, inboundId, fileId); + try { + BufferedImage source = ImageIO.read(new ByteArrayInputStream(original.content())); + if (source == null) { + throw new BizException(ResultCode.VALIDATION_FAILED, "称重图片无法生成缩略图"); + } + double scale = Math.min(1D, Math.min(480D / source.getWidth(), 320D / source.getHeight())); + int width = Math.max(1, (int) Math.round(source.getWidth() * scale)); + int height = Math.max(1, (int) Math.round(source.getHeight() * scale)); + BufferedImage target = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = target.createGraphics(); + try { + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, width, height); + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR); + graphics.setRenderingHint(RenderingHints.KEY_RENDERING, + RenderingHints.VALUE_RENDER_QUALITY); + graphics.drawImage(source, 0, 0, width, height, null); + } finally { + graphics.dispose(); + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + if (!ImageIO.write(target, "jpg", output)) { + throw new BizException(ResultCode.INTERNAL_ERROR, "称重图片缩略图编码失败"); + } + return new Download("thumbnail.jpg", "image/jpeg", output.toByteArray()); + } catch (IOException exception) { + throw new BizException(ResultCode.INTERNAL_ERROR, "称重图片缩略图生成失败"); + } + } + + @Transactional + public void deleteMeasurementEvidence(long tenantId, long inboundId, long fileId, long operatorId) { + FileObjectEntity object = requireObject(tenantId, inboundId, fileId, + Set.of("INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE")); + deleteObject(object, operatorId); + } + + @Transactional + public void deleteMojiaReceiptEvidence(long tenantId, long receiptId, long fileId, long operatorId) { + FileObjectEntity object = requireObject(tenantId, receiptId, fileId, + Set.of("MOJIA_MEASUREMENT_RECEIPT")); + deleteObject(object, operatorId); + } + + private void deleteObject(FileObjectEntity object, long operatorId) { + object.setStatus("DELETED"); + object.setUpdatedBy(operatorId); + object.setUpdatedAt(LocalDateTime.now()); + if (files.updateById(object) != 1 || files.deleteById(object.getId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "测量图片已被删除,请刷新后重试"); + } + storage.delete(object.getObjectKey()); + } + + private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) { + return requireObject(tenantId, inboundId, fileId, + Set.of("INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE")); + } + + private FileObjectEntity requireObject(long tenantId, long businessId, long fileId, + Set businessTypes) { + FileObjectEntity object = files.selectOne(new LambdaQueryWrapper() + .eq(FileObjectEntity::getTenantId, tenantId) + .eq(FileObjectEntity::getId, fileId) + .in(FileObjectEntity::getBusinessType, businessTypes) + .eq(FileObjectEntity::getBusinessId, Long.toString(businessId)) + .eq(FileObjectEntity::getStatus, "ACTIVE") + .eq(FileObjectEntity::getDeleted, false)); + if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在"); + return object; + } + + private UploadResult recognize(FileObjectEntity object) { + byte[] content = storage.read(object.getObjectKey()); + MultipartFile image = new StoredImageMultipartFile( + object.getOriginalName(), object.getContentType(), content); + try { + var response = ocr.recognize(internalToken, image); + if (response == null || !response.successful() || response.data() == null) { + return failed(object, response == null ? "OCR服务无响应" : response.message()); + } + var result = response.data(); + return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), + object.getSizeBytes(), result.status(), result.weight(), "KG", + normalizeConfidence(result.confidence()), result.ocrRawText(), result.error()); + } catch (FeignException exception) { + return failed(object, "OCR服务不可用,可稍后重试或人工录入"); + } + } + + private UploadResult failed(FileObjectEntity object, String message) { + return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), + object.getSizeBytes(), "FAILED", null, null, null, null, message); + } + + private BigDecimal normalizeConfidence(BigDecimal value) { + if (value == null) return null; + if (value.compareTo(BigDecimal.ZERO) < 0) value = BigDecimal.ZERO; + if (value.compareTo(BigDecimal.ONE) > 0) value = BigDecimal.ONE; + return value.setScale(5, RoundingMode.HALF_UP); + } + + public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes, + String recognitionStatus, BigDecimal weightValue, String weightUnit, + BigDecimal confidence, String ocrRawText, String error) {} + public record Download(String fileName, String contentType, byte[] content) {} + + private record StoredImageMultipartFile(String originalFilename, String contentType, byte[] bytes) + implements MultipartFile { + @Override public String getName() { return "file"; } + @Override public String getOriginalFilename() { return originalFilename; } + @Override public String getContentType() { return contentType; } + @Override public boolean isEmpty() { return bytes.length == 0; } + @Override public long getSize() { return bytes.length; } + @Override public byte[] getBytes() { return bytes.clone(); } + @Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); } + @Override public void transferTo(java.io.File destination) throws IOException { + java.nio.file.Files.write(destination.toPath(), bytes); + } + } +} diff --git a/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java b/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java new file mode 100644 index 000000000..df40cb857 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java @@ -0,0 +1,108 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +@Component +public class MeasurementImageStorage { + private static final long MAX_SIZE = 5L * 1024 * 1024; + private static final Set TYPES = Set.of("image/jpeg", "image/png", "image/bmp"); + @Value("${baiyue.file.storage-root}") private Path configuredRoot; + private Path root; + + @PostConstruct + void initializeRoot() { root = configuredRoot.toAbsolutePath().normalize(); } + + public Stored store(long tenantId, long inboundId, MultipartFile file) { + return store(tenantId, inboundId, file, "measurements"); + } + + public Stored storeLoading(long tenantId, long inboundId, MultipartFile file) { + return store(tenantId, inboundId, file, "loading-evidence"); + } + + private Stored store(long tenantId, long inboundId, MultipartFile file, String directory) { + if (file == null || file.isEmpty()) throw bad("称重图片不能为空"); + if (file.getSize() > MAX_SIZE) throw bad("称重图片不能超过5MB"); + String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); + if (!TYPES.contains(type)) throw bad("称重图片仅支持JPG、PNG、BMP"); + verifyImage(file); + String extension = "image/png".equals(type) ? ".png" : ("image/bmp".equals(type) ? ".bmp" : ".jpg"); + String objectKey = directory + "/" + tenantId + "/" + inboundId + "/" + UUID.randomUUID() + extension; + Path target = root.resolve(objectKey).normalize(); + if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); + try { + Files.createDirectories(target.getParent()); + file.transferTo(target); + return new Stored(objectKey, target, type, file.getSize(), sha256(target), safeName(file)); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "称重图片保存失败"); + } + } + + public byte[] read(String objectKey) { + Path path = root.resolve(objectKey).normalize(); + if (!path.startsWith(root) || !Files.isRegularFile(path)) { + throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在"); + } + try { return Files.readAllBytes(path); } + catch (IOException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "称重图片读取失败"); } + } + + public void delete(String objectKey) { + Path path = root.resolve(objectKey).normalize(); + if (!path.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); + try { + Files.deleteIfExists(path); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "测量图片清理失败"); + } + } + + private void verifyImage(MultipartFile file) { + try (InputStream input = file.getInputStream()) { + if (ImageIO.read(input) == null) throw bad("上传内容不是有效图片"); + } catch (IOException exception) { + throw bad("上传内容不是有效图片"); + } + } + + private static String sha256(Path path) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(path)) { + byte[] buffer = new byte[8192]; + for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private static String safeName(MultipartFile file) { + String name = file.getOriginalFilename(); + if (name == null || name.isBlank()) return "weight-image"; + return Path.of(name).getFileName().toString(); + } + + private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } + public record Stored(String objectKey, Path path, String contentType, long size, String sha256, + String originalName) {} +} diff --git a/file/src/main/java/com/baiyue/file/service/PriceBookFileService.java b/file/src/main/java/com/baiyue/file/service/PriceBookFileService.java new file mode 100644 index 000000000..214c38c64 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/PriceBookFileService.java @@ -0,0 +1,88 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; + +/** Persists platform price-book source files and enforces exact business ownership. */ +@Service +public class PriceBookFileService { + public static final long PLATFORM_SCOPE = 0L; + public static final String BUSINESS_TYPE = "FBA_PRICE_BOOK_SOURCE"; + + @Resource + private PriceBookFileStorage storage; + @Resource + private FileObjectMapper files; + + @Transactional + public FileMetadata upload(long businessId, long operatorId, MultipartFile file) { + PriceBookFileStorage.Stored stored = storage.store(businessId, file); + FileObjectEntity object = new FileObjectEntity(); + object.setTenantId(PLATFORM_SCOPE); + object.setBusinessType(BUSINESS_TYPE); + object.setBusinessId(Long.toString(businessId)); + object.setOriginalName(stored.originalName()); + object.setObjectKey(stored.objectKey()); + object.setStorageProvider("LOCAL"); + object.setBucketName("price-books"); + object.setContentType(stored.contentType()); + object.setSizeBytes(stored.sizeBytes()); + object.setSha256(stored.sha256()); + object.setStatus("ACTIVE"); + object.setCreatedBy(operatorId); + object.setUpdatedBy(operatorId); + object.setCreatedAt(LocalDateTime.now()); + object.setUpdatedAt(LocalDateTime.now()); + object.setDeleted(false); + object.setVersion(0); + try { + if (files.insert(object) != 1) { + throw new BizException(ResultCode.INTERNAL_ERROR, "价格表文件元数据保存失败"); + } + } catch (RuntimeException exception) { + storage.delete(stored.objectKey()); + throw exception; + } + return metadata(object); + } + + public FileMetadata metadata(long businessId, long fileId) { + return metadata(requireObject(businessId, fileId)); + } + + public Download download(long businessId, long fileId) { + FileObjectEntity object = requireObject(businessId, fileId); + return new Download(metadata(object), storage.read(object.getObjectKey())); + } + + private FileObjectEntity requireObject(long businessId, long fileId) { + FileObjectEntity object = files.selectOne(new LambdaQueryWrapper() + .eq(FileObjectEntity::getTenantId, PLATFORM_SCOPE) + .eq(FileObjectEntity::getBusinessType, BUSINESS_TYPE) + .eq(FileObjectEntity::getBusinessId, Long.toString(businessId)) + .eq(FileObjectEntity::getId, fileId) + .eq(FileObjectEntity::getStatus, "ACTIVE") + .eq(FileObjectEntity::getDeleted, false)); + if (object == null) throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在"); + return object; + } + + private static FileMetadata metadata(FileObjectEntity object) { + return new FileMetadata(object.getId(), Long.parseLong(object.getBusinessId()), + object.getOriginalName(), object.getContentType(), object.getSizeBytes(), object.getSha256(), + object.getBusinessType()); + } + + public record FileMetadata(Long fileId, Long businessId, String fileName, String contentType, + Long sizeBytes, String sha256, String businessType) {} + public record Download(FileMetadata metadata, byte[] content) {} +} diff --git a/file/src/main/java/com/baiyue/file/service/PriceBookFileStorage.java b/file/src/main/java/com/baiyue/file/service/PriceBookFileStorage.java new file mode 100644 index 000000000..df92dc5b6 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/PriceBookFileStorage.java @@ -0,0 +1,173 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** Stores original price-book XLSX files without interpreting workbook table metadata. */ +@Component +public class PriceBookFileStorage { + static final String XLSX_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + private static final Set ACCEPTED_CONTENT_TYPES = Set.of( + XLSX_CONTENT_TYPE, "application/octet-stream", "application/zip"); + private static final Set REQUIRED_ENTRIES = Set.of( + "[Content_Types].xml", "xl/workbook.xml", "xl/_rels/workbook.xml.rels"); + private static final int MAX_ZIP_ENTRIES = 20_000; + private static final long MAX_EXPANDED_BYTES = 256L * 1024 * 1024; + + @Value("${baiyue.file.storage-root}") + private Path configuredRoot; + @Value("${baiyue.file.price-book.max-size-bytes:10485760}") + private long maxSizeBytes; + private Path root; + + @PostConstruct + void initializeRoot() { + root = configuredRoot.toAbsolutePath().normalize(); + } + + public Stored store(long businessId, MultipartFile file) { + validateFile(file); + String originalName = safeName(file); + verifyXlsxPackage(file); + String objectKey = "price-books/0/" + businessId + "/" + UUID.randomUUID() + ".xlsx"; + Path target = resolve(objectKey); + try { + Files.createDirectories(target.getParent()); + file.transferTo(target); + return new Stored(objectKey, originalName, XLSX_CONTENT_TYPE, Files.size(target), sha256(target)); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件保存失败"); + } + } + + public byte[] read(String objectKey) { + Path target = resolve(objectKey); + if (!Files.isRegularFile(target)) { + throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在"); + } + try { + return Files.readAllBytes(target); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件读取失败"); + } + } + + public void delete(String objectKey) { + Path target = resolve(objectKey); + try { + Files.deleteIfExists(target); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件清理失败"); + } + } + + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) throw bad("价格表文件不能为空"); + if (maxSizeBytes <= 0 || file.getSize() > maxSizeBytes) { + throw bad("价格表文件超过允许大小"); + } + String name = file.getOriginalFilename(); + if (name == null || name.isBlank() || name.length() > 200 + || name.indexOf('\0') >= 0 || name.contains("/") || name.contains("\\")) { + throw bad("价格表文件名不安全"); + } + if (!name.toLowerCase(Locale.ROOT).endsWith(".xlsx")) { + throw bad("价格表仅支持XLSX文件"); + } + String contentType = file.getContentType() == null + ? "" : file.getContentType().toLowerCase(Locale.ROOT); + if (!ACCEPTED_CONTENT_TYPES.contains(contentType)) { + throw bad("价格表文件类型不正确"); + } + } + + private void verifyXlsxPackage(MultipartFile file) { + Set found = new java.util.HashSet<>(); + int entries = 0; + long expandedBytes = 0; + try (InputStream raw = new BufferedInputStream(file.getInputStream())) { + raw.mark(4); + if (raw.read() != 'P' || raw.read() != 'K' || raw.read() != 3 || raw.read() != 4) { + throw bad("上传内容不是有效XLSX文件"); + } + raw.reset(); + try (ZipInputStream zip = new ZipInputStream(raw)) { + byte[] buffer = new byte[8192]; + for (ZipEntry entry; (entry = zip.getNextEntry()) != null;) { + if (++entries > MAX_ZIP_ENTRIES || unsafeEntry(entry.getName())) { + throw bad("XLSX压缩包结构不安全"); + } + if (REQUIRED_ENTRIES.contains(entry.getName())) found.add(entry.getName()); + for (int count; (count = zip.read(buffer)) >= 0;) { + expandedBytes += count; + if (expandedBytes > MAX_EXPANDED_BYTES) { + throw bad("XLSX解压内容超过安全限制"); + } + } + } + } + } catch (BizException exception) { + throw exception; + } catch (IOException exception) { + throw bad("上传内容不是有效XLSX文件"); + } + if (!found.containsAll(REQUIRED_ENTRIES)) throw bad("上传内容缺少XLSX必要结构"); + } + + private Path resolve(String objectKey) { + if (objectKey == null || !objectKey.matches("price-books/0/\\d+/[0-9a-fA-F-]{36}\\.xlsx")) { + throw new BizException(ResultCode.FORBIDDEN, "价格表文件路径无效"); + } + Path target = root.resolve(objectKey).normalize(); + if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); + return target; + } + + private static boolean unsafeEntry(String name) { + return name == null || name.startsWith("/") || name.startsWith("\\") + || name.contains("../") || name.contains("..\\") || name.indexOf('\0') >= 0; + } + + private static String safeName(MultipartFile file) { + return file.getOriginalFilename().trim(); + } + + private static String sha256(Path path) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(path)) { + byte[] buffer = new byte[8192]; + for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private static BizException bad(String message) { + return new BizException(ResultCode.BAD_REQUEST, message); + } + + public record Stored(String objectKey, String originalName, String contentType, + long sizeBytes, String sha256) {} +} diff --git a/file/src/main/java/com/baiyue/file/service/ProductImageService.java b/file/src/main/java/com/baiyue/file/service/ProductImageService.java new file mode 100644 index 000000000..083376fe5 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ProductImageService.java @@ -0,0 +1,76 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; + +@Service +public class ProductImageService { + public static final String BUSINESS_TYPE = "ORDER_PRODUCT_IMAGE"; + @Resource private ProductImageStorage storage; + @Resource private FileObjectMapper files; + + @Transactional + public UploadResult upload(long tenantId, long operatorId, MultipartFile file) { + var stored = storage.store(tenantId, file); + FileObjectEntity object = new FileObjectEntity(); + object.setTenantId(tenantId); + object.setBusinessType(BUSINESS_TYPE); + object.setOriginalName(stored.originalName()); + object.setObjectKey(stored.objectKey()); + object.setStorageProvider("LOCAL"); + object.setBucketName("product-images"); + object.setContentType(stored.contentType()); + object.setSizeBytes(stored.size()); + object.setSha256(stored.sha256()); + object.setStatus("ACTIVE"); + object.setCreatedBy(operatorId); + object.setUpdatedBy(operatorId); + object.setCreatedAt(LocalDateTime.now()); + object.setUpdatedAt(LocalDateTime.now()); + object.setDeleted(false); + object.setVersion(0); + try { + files.insert(object); + } catch (RuntimeException exception) { + storage.delete(stored.objectKey()); + throw exception; + } + return metadata(object); + } + + public UploadResult validate(long tenantId, long fileId) { + return metadata(requireObject(tenantId, fileId)); + } + + public Download download(long tenantId, long fileId) { + FileObjectEntity object = requireObject(tenantId, fileId); + return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey())); + } + + private FileObjectEntity requireObject(long tenantId, long fileId) { + FileObjectEntity object = files.selectOne(new LambdaQueryWrapper() + .eq(FileObjectEntity::getTenantId, tenantId) + .eq(FileObjectEntity::getId, fileId) + .eq(FileObjectEntity::getBusinessType, BUSINESS_TYPE) + .eq(FileObjectEntity::getStatus, "ACTIVE") + .eq(FileObjectEntity::getDeleted, false)); + if (object == null) throw new BizException(ResultCode.NOT_FOUND, "产品图片不存在"); + return object; + } + + private static UploadResult metadata(FileObjectEntity object) { + return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), object.getSizeBytes()); + } + + public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes) {} + public record Download(String fileName, String contentType, byte[] content) {} +} diff --git a/file/src/main/java/com/baiyue/file/service/ProductImageStorage.java b/file/src/main/java/com/baiyue/file/service/ProductImageStorage.java new file mode 100644 index 000000000..b6aef99dc --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ProductImageStorage.java @@ -0,0 +1,128 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Iterator; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +@Component +public class ProductImageStorage { + private static final long MAX_SIZE = 5L * 1024 * 1024; + private static final Set TYPES = Set.of("image/jpeg", "image/png"); + @Value("${baiyue.file.storage-root}") private Path configuredRoot; + private Path root; + + @PostConstruct + void initializeRoot() { + root = configuredRoot.toAbsolutePath().normalize(); + } + + public Stored store(long tenantId, MultipartFile file) { + if (file == null || file.isEmpty()) throw bad("产品图片不能为空"); + if (file.getSize() > MAX_SIZE) throw bad("产品图片不能超过5MB"); + String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); + if (!TYPES.contains(type)) throw bad("产品图片仅支持JPG、PNG"); + verifyImage(file, type); + String extension = "image/png".equals(type) ? ".png" : ".jpg"; + String objectKey = "product-images/" + tenantId + "/" + UUID.randomUUID() + extension; + Path target = root.resolve(objectKey).normalize(); + if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); + try { + Files.createDirectories(target.getParent()); + file.transferTo(target); + return new Stored(objectKey, type, file.getSize(), sha256(target), safeName(file)); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "产品图片保存失败"); + } + } + + public byte[] read(String objectKey) { + Path path = root.resolve(objectKey).normalize(); + if (!path.startsWith(root) || !Files.isRegularFile(path)) { + throw new BizException(ResultCode.NOT_FOUND, "产品图片不存在"); + } + try { + return Files.readAllBytes(path); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "产品图片读取失败"); + } + } + + public void delete(String objectKey) { + Path path = root.resolve(objectKey).normalize(); + if (!path.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); + try { + Files.deleteIfExists(path); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "产品图片清理失败"); + } + } + + private void verifyImage(MultipartFile file, String declaredType) { + try (InputStream input = file.getInputStream(); + ImageInputStream imageInput = ImageIO.createImageInputStream(input)) { + if (imageInput == null) throw bad("上传内容不是有效图片"); + Iterator readers = ImageIO.getImageReaders(imageInput); + if (!readers.hasNext()) throw bad("上传内容不是有效图片"); + ImageReader reader = readers.next(); + try { + String format = reader.getFormatName().toLowerCase(Locale.ROOT); + boolean matches = "image/png".equals(declaredType) ? "png".equals(format) + : "jpeg".equals(format) || "jpg".equals(format); + if (!matches) throw bad("图片内容与文件类型不一致"); + reader.setInput(imageInput, true, true); + reader.read(0); + } finally { + reader.dispose(); + } + } catch (IOException exception) { + throw bad("上传内容不是有效图片"); + } + } + + private static String sha256(Path path) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(path)) { + byte[] buffer = new byte[8192]; + for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private static String safeName(MultipartFile file) { + String name = file.getOriginalFilename(); + if (name == null || name.isBlank()) return "product-image"; + String normalized = name.replace('\\', '/'); + String leaf = normalized.substring(normalized.lastIndexOf('/') + 1) + .replaceAll("[\\p{Cntrl}]", "").trim(); + if (leaf.isEmpty()) return "product-image"; + return leaf.length() <= 255 ? leaf : leaf.substring(leaf.length() - 255); + } + + private static BizException bad(String message) { + return new BizException(ResultCode.BAD_REQUEST, message); + } + + public record Stored(String objectKey, String contentType, long size, String sha256, String originalName) {} +} diff --git a/file/src/main/java/com/baiyue/file/service/QualificationFileStorage.java b/file/src/main/java/com/baiyue/file/service/QualificationFileStorage.java index 0b07bf0e3..76b96daed 100644 --- a/file/src/main/java/com/baiyue/file/service/QualificationFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/QualificationFileStorage.java @@ -1,6 +1,7 @@ package com.baiyue.file.service; import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; @@ -18,11 +19,11 @@ import java.util.UUID; @Service public class QualificationFileStorage { private static final Set CONTENT_TYPES = Set.of("image/jpeg", "image/png"); - private final Path root; + @Value("${baiyue.file.storage-root}") private String configuredRoot; + private Path root; - public QualificationFileStorage(@Value("${baiyue.file.storage-root}") String root) { - this.root = Path.of(root).toAbsolutePath().normalize().resolve("qualifications"); - } + @PostConstruct + void initializeRoot() { root = Path.of(configuredRoot).toAbsolutePath().normalize().resolve("qualifications"); } public StoredFile store(long tenantId, MultipartFile file) { if (file == null || file.isEmpty()) throw new BizException("One qualification image is required"); diff --git a/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java b/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java new file mode 100644 index 000000000..5519973f0 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java @@ -0,0 +1,95 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.client.IamQualificationClient; +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.web.multipart.MultipartFile; + +import java.time.LocalDate; + +@Service +public class QualificationPackageService { + private static final Logger LOGGER = LoggerFactory.getLogger(QualificationPackageService.class); + @Resource private LocalQualificationStorage storage; + @Resource private IamQualificationClient iamClient; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public PackageUploadResult upload(long tenantId, MultipartFile businessLicense, + MultipartFile legalPersonId, String creditCode, + LocalDate businessLicenseValidTo, LocalDate legalPersonIdValidTo, + boolean replaceBusinessLicense, boolean replaceLegalPersonId, + boolean platform) { + String normalizedCreditCode = creditCode == null ? "" : creditCode.trim().toUpperCase(); + if (!normalizedCreditCode.matches("[0-9A-Z]{18}")) { + throw new BizException(ResultCode.BAD_REQUEST, "请输入有效的18位统一社会信用代码"); + } + if (businessLicense == null || businessLicense.isEmpty()) { + throw new BizException(ResultCode.BAD_REQUEST, "营业执照文件不能为空"); + } + if (legalPersonId == null && (legalPersonIdValidTo != null || replaceLegalPersonId)) { + throw new BizException(ResultCode.BAD_REQUEST, "未上传法人身份证时不能填写其有效期或替换标记"); + } + LocalQualificationStorage.StoredFile storedBusinessLicense = storage.store(tenantId, businessLicense); + LocalQualificationStorage.StoredFile storedLegalPersonId = null; + try { + if (legalPersonId != null) storedLegalPersonId = storage.store(tenantId, legalPersonId); + } catch (RuntimeException exception) { + storage.delete(storedBusinessLicense); + throw exception; + } + String legalPersonIdDocumentUrl = storedLegalPersonId == null ? null : storedLegalPersonId.documentUrl(); + + var request = new IamQualificationClient.RequiredPackageRequest( + tenantId, normalizedCreditCode, storedBusinessLicense.documentUrl(), businessLicense.getOriginalFilename(), + businessLicenseValidTo, replaceBusinessLicense, legalPersonIdDocumentUrl, + legalPersonId == null ? null : legalPersonId.getOriginalFilename(), + legalPersonIdValidTo, replaceLegalPersonId); + try { + var response = platform + ? iamClient.submitPlatformRequiredPackage(internalToken, request) + : iamClient.submitRequiredPackage(internalToken, request); + if (response == null) throw new IllegalStateException("IAM required qualification package response is empty"); + if (!response.successful()) { + ResultCode code = ResultCode.of(response.code()); + String message = response.message() == null || response.message().isBlank() + ? code.message() : response.message(); + storage.delete(storedBusinessLicense); + deleteIfStored(storedLegalPersonId); + throw new BizException(code, message); + } + if (response.data() == null) { + throw new IllegalStateException("IAM required qualification package response data is empty"); + } + var data = response.data(); + deleteReplacedFile(tenantId, data.replacedBusinessLicenseDocumentUrl(), storedBusinessLicense.documentUrl()); + deleteReplacedFile(tenantId, data.replacedLegalPersonIdDocumentUrl(), legalPersonIdDocumentUrl); + return new PackageUploadResult(data.businessLicenseQualificationId(), data.legalPersonIdQualificationId(), + data.tenantStatus(), storedBusinessLicense.documentUrl(), legalPersonIdDocumentUrl); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + LOGGER.error("IAM 必填资质包登记结果未知,保留已写入文件等待重试或对账,tenantId={}, businessLicenseUrl={}, legalPersonIdUrl={}", + tenantId, storedBusinessLicense.documentUrl(), legalPersonIdDocumentUrl, exception); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable"); + } + } + + private void deleteReplacedFile(long tenantId, String replacedUrl, String currentUrl) { + if (currentUrl != null && !currentUrl.equals(replacedUrl)) storage.deleteByDocumentUrl(tenantId, replacedUrl); + } + + private void deleteIfStored(LocalQualificationStorage.StoredFile storedFile) { + if (storedFile != null) storage.delete(storedFile); + } + + public record PackageUploadResult(Long businessLicenseQualificationId, + Long legalPersonIdQualificationId, + String tenantStatus, + String businessLicenseDocumentUrl, + String legalPersonIdDocumentUrl) { } +} diff --git a/file/src/main/java/com/baiyue/file/service/SizeLimitedOutputStream.java b/file/src/main/java/com/baiyue/file/service/SizeLimitedOutputStream.java new file mode 100644 index 000000000..e2049c3ba --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/SizeLimitedOutputStream.java @@ -0,0 +1,19 @@ +package com.baiyue.file.service; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +final class SizeLimitedOutputStream extends FilterOutputStream { + private final long limit; + private long count; + SizeLimitedOutputStream(OutputStream out, long limit) { super(out); this.limit = limit; } + @Override public void write(int value) throws IOException { ensure(1); out.write(value); count++; } + @Override public void write(byte[] bytes, int offset, int length) throws IOException { ensure(length); out.write(bytes, offset, length); count += length; } + private void ensure(long added) throws IOException { + if (count + added > limit) throw new ExportSizeExceededException("导出文件超过最大限制 " + limit + " 字节"); + } + static final class ExportSizeExceededException extends IOException { + ExportSizeExceededException(String message) { super(message); } + } +} diff --git a/file/src/main/resources/application-dev.yml b/file/src/main/resources/application-dev.yml new file mode 100644 index 000000000..334396130 --- /dev/null +++ b/file/src/main/resources/application-dev.yml @@ -0,0 +1,6 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${FILE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${FILE_DB_USERNAME:root} + password: ${FILE_DB_PASSWORD:123456} diff --git a/file/src/main/resources/application-test.yml b/file/src/main/resources/application-test.yml new file mode 100644 index 000000000..fec0b48d0 --- /dev/null +++ b/file/src/main/resources/application-test.yml @@ -0,0 +1,6 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${FILE_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} + username: ${FILE_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} + password: ${FILE_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 834f1fb1d..ef4179c70 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -1,6 +1,8 @@ server: port: ${FILE_PORT:9206} spring: + profiles: + active: ${SPRING_PROFILES_ACTIVE:dev} application: name: file-service config: @@ -18,15 +20,33 @@ spring: group: ${NACOS_GROUP:BAIYUE} namespace: ${NACOS_NAMESPACE:public} config.namespace: ${NACOS_NAMESPACE:public} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FILE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FILE_DB_USERNAME:root} - password: ${FILE_DB_PASSWORD:123456} + openfeign: + client: + config: + iamExportProviderClient: + connect-timeout: 3000 + read-timeout: 15000 + orderExportProviderClient: + connect-timeout: 3000 + read-timeout: 15000 + warehouseExportProviderClient: + connect-timeout: 3000 + read-timeout: 15000 + firstlegExportProviderClient: + connect-timeout: 3000 + read-timeout: 15000 + financeExportProviderClient: + connect-timeout: 3000 + read-timeout: 15000 servlet: multipart: - max-file-size: 5MB - max-request-size: 6MB + max-file-size: 50MB + max-request-size: 51MB +rocketmq: + name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876} + producer: + group: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-publisher-v1 + send-message-timeout: 3000 baiyue: security: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} @@ -34,6 +54,150 @@ baiyue: clients: iam: url: ${IAM_SERVICE_URL:} + integration: + url: ${INTEGRATION_SERVICE_URL:} + finance: + url: ${FINANCE_SERVICE_URL:} file: - storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-firstleg-runtime/uploads} + storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads} + price-book: + max-size-bytes: ${FILE_PRICE_BOOK_MAX_SIZE_BYTES:10485760} + export: + retention-days: ${FILE_EXPORT_RETENTION_DAYS:7} + legacy-worker-enabled: false + instance-id: ${HOSTNAME:file-local} + task: + lease-seconds: ${FILE_EXPORT_TASK_LEASE_SECONDS:300} + heartbeat-seconds: ${FILE_EXPORT_TASK_HEARTBEAT_SECONDS:60} + max-duration-minutes: ${FILE_EXPORT_MAX_DURATION_MINUTES:30} + max-file-size-mb: ${FILE_EXPORT_MAX_FILE_SIZE_MB:50} + publisher: + enabled: ${FILE_EXPORT_PUBLISHER_ENABLED:false} + poll-interval-ms: ${FILE_EXPORT_PUBLISHER_POLL_MS:5000} + batch-size: 50 + lease-seconds: 60 + consumer: + enabled: ${FILE_EXPORT_CONSUMER_ENABLED:false} + concurrency: ${FILE_EXPORT_CONSUMER_CONCURRENCY:4} + consume-timeout-minutes: ${FILE_EXPORT_CONSUME_TIMEOUT_MINUTES:90} + max-reconsume-times: 2 + reconciler: + enabled: ${FILE_EXPORT_RECONCILER_ENABLED:false} + poll-interval-ms: ${FILE_EXPORT_RECONCILER_POLL_MS:30000} + redispatch-grace-seconds: 60 + sent-stale-seconds: 600 + rocketmq: + topic: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-command-v1 + consumer-group: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-worker-v1 + dlq-topic: "%DLQ%${MQ_TOPIC_PREFIX:test-}baiyue-file-export-worker-v1" + dlq-consumer-group: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-dlq-handler-v1 + definitions: + CUSTOMER_LIST: + provider-service: iam-service + provider-path: /internal/exports/v1/CUSTOMER_LIST/batches + required-permission: platform:customer:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + DOMESTIC_WAREHOUSE_ORDER_LIST: + provider-service: order-service + provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches + required-permission: platform:order:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 200 + LOADING_LIST_BUY_DECLARATION_ITEMS: + provider-service: firstleg-service + provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches + required-permission: platform:firstleg:customs:manage + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_CUSTOMER_ACCOUNT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches + required-permission: platform:finance:customer-account:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_CUSTOMER_ACCOUNT_LEDGER: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches + required-permission: platform:finance:customer-account:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_FEE_SOURCE_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches + required-permission: platform:finance:customer-arap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECONCILIATION_STATEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches + required-permission: platform:finance:customer-statement:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_SETTLEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches + required-permission: platform:finance:settlement-ar:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches + required-permission: platform:finance:settlement-ar:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_PAYABLE_SETTLEMENT_LIST: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches + required-permission: platform:finance:settlement-ap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_PAYABLE_SETTLEMENT_DETAIL: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches + required-permission: platform:finance:settlement-ap:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_RECEIVABLE_STATISTICS: + provider-service: finance-service + provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches + required-permission: platform:finance:receivable-statistics:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 management.endpoints.web.exposure.include: health,info,prometheus diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql index 7e35cf92d..2a4a61cb0 100644 --- a/file/src/main/resources/db/file.sql +++ b/file/src/main/resources/db/file.sql @@ -5,9 +5,9 @@ USE baiyue_file; CREATE TABLE IF NOT EXISTS file_object ( id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - business_type VARCHAR(32) NULL COMMENT '关联业务类型', - business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户ID', + business_type VARCHAR(32) NULL COMMENT '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE', + business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId', original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', object_key VARCHAR(512) NOT NULL COMMENT '对象存储键', storage_provider VARCHAR(32) NOT NULL COMMENT '存储提供方', @@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS file_object ( CREATE TABLE IF NOT EXISTS file_download_token ( id BIGINT UNSIGNED NOT NULL COMMENT '下载令牌ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID', token_hash CHAR(64) NOT NULL COMMENT '下载令牌SHA-256摘要', expires_at DATETIME(3) NOT NULL COMMENT '过期时间', @@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS file_download_token ( CREATE TABLE IF NOT EXISTS file_download_log ( id BIGINT UNSIGNED NOT NULL COMMENT '下载日志ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID', user_id BIGINT UNSIGNED NULL COMMENT '下载用户ID', request_id VARCHAR(64) NULL COMMENT '请求ID', @@ -59,3 +59,82 @@ CREATE TABLE IF NOT EXISTS file_download_log ( KEY idx_download_log_file (tenant_id, file_id, downloaded_at), KEY idx_download_log_user (tenant_id, user_id, downloaded_at) ) ENGINE=InnoDB COMMENT='文件下载审计日志表'; + +CREATE TABLE IF NOT EXISTS file_export_task ( + id BIGINT UNSIGNED NOT NULL COMMENT '导出任务ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键;平台任务固定为0', + app_code VARCHAR(24) NOT NULL COMMENT '申请端:PLATFORM或MERCHANT', + task_no VARCHAR(40) NOT NULL COMMENT '导出任务编号', + export_type VARCHAR(64) NOT NULL COMMENT '导出类型', + format VARCHAR(16) NOT NULL DEFAULT 'XLSX' COMMENT '导出格式', + task_name VARCHAR(128) NOT NULL COMMENT '任务名称', + parameters_json JSON NOT NULL COMMENT '筛选条件快照', + status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待处理、RUNNING执行中、SUCCEEDED成功、FAILED失败、EXPIRED已过期', + total_rows BIGINT UNSIGNED NULL COMMENT '预计总行数', + processed_rows BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已处理行数', + file_name VARCHAR(255) NULL COMMENT '下载文件名', + object_key VARCHAR(512) NULL COMMENT '存储对象键', + content_type VARCHAR(128) NULL COMMENT '文件类型', + size_bytes BIGINT UNSIGNED NULL COMMENT '文件字节数', + failure_message VARCHAR(1000) NULL COMMENT '失败原因', + dispatch_version INT NOT NULL DEFAULT 1 COMMENT '派发版本,仅执行重试时递增', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已执行尝试次数', + max_attempts INT NOT NULL DEFAULT 3 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_heartbeat_at DATETIME(3) NULL COMMENT '最后心跳时间', + execution_deadline_at DATETIME(3) NULL COMMENT '本次执行截止时间', + requested_by BIGINT UNSIGNED NOT NULL COMMENT '申请用户ID', + requested_username VARCHAR(128) NULL COMMENT '申请账号快照', + requested_at DATETIME(3) NOT NULL COMMENT '申请时间', + started_at DATETIME(3) NULL COMMENT '开始时间', + completed_at DATETIME(3) NULL COMMENT '完成时间', + expires_at DATETIME(3) 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_file_export_task_no (task_no), + KEY idx_file_export_owner (tenant_id, app_code, requested_by, requested_at), + KEY idx_file_export_status (status, requested_at), + KEY idx_file_export_dispatch (status, next_retry_at, id), + KEY idx_file_export_lease (status, lease_until), + KEY idx_file_export_expiry (status, expires_at) +) ENGINE=InnoDB COMMENT='公共异步导出任务表'; + +CREATE TABLE IF NOT EXISTS file_export_task_guard ( + guard_key VARCHAR(64) NOT NULL COMMENT '任务创建串行化锁键', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '锁记录最后更新时间', + PRIMARY KEY (guard_key) +) ENGINE=InnoDB COMMENT='导出任务创建并发保护表:低频创建入口使用单行锁保证重复提交及活动上限'; + +INSERT IGNORE INTO file_export_task_guard (guard_key) VALUES ('GLOBAL_CREATE'); + +CREATE TABLE IF NOT EXISTS file_export_outbox ( + id BIGINT UNSIGNED NOT NULL COMMENT 'Outbox记录ID', + event_id VARCHAR(64) NOT NULL COMMENT '投递意图唯一标识', + task_id BIGINT UNSIGNED NOT NULL COMMENT '关联导出任务ID', + dispatch_version INT NOT NULL COMMENT '任务派发版本', + topic VARCHAR(128) NOT NULL COMMENT '目标Topic', + tag VARCHAR(64) NOT NULL COMMENT '消息Tag', + message_key VARCHAR(64) NOT NULL COMMENT '消息Key', + payload_json JSON NOT NULL COMMENT '命令消息体', + status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待发送、CLAIMED已领取、SENT已发送、QUARANTINED隔离', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '累计发送尝试次数', + next_retry_at DATETIME(3) NOT NULL COMMENT '允许发送的最早时间', + lease_owner VARCHAR(128) NULL COMMENT '领取实例标识', + lease_until DATETIME(3) NULL COMMENT '领取租约截止时间', + sent_at DATETIME(3) NULL COMMENT '发送成功时间', + last_error VARCHAR(1000) NULL COMMENT '最后发送错误', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'Outbox记录创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'Outbox记录更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_file_export_outbox_event (event_id), + UNIQUE KEY uk_file_export_outbox_task_version (task_id, dispatch_version), + KEY idx_file_export_outbox_publish (status, next_retry_at, id), + KEY idx_file_export_outbox_task (task_id, dispatch_version, status, sent_at) +) ENGINE=InnoDB COMMENT='导出命令事务Outbox表'; diff --git a/file/src/main/resources/openapi/customs-file-and-export-v1.yaml b/file/src/main/resources/openapi/customs-file-and-export-v1.yaml new file mode 100644 index 000000000..f7fa002a8 --- /dev/null +++ b/file/src/main/resources/openapi/customs-file-and-export-v1.yaml @@ -0,0 +1,156 @@ +openapi: 3.0.3 +info: + title: File Customs Business File and Export Contract + version: 1.0.0 + description: | + File 只保存不可变文件对象并生成异步 XLSX,不保存报关状态或业务版本。 + Firstleg 持有 loadingPlanId、文件版本、完成状态和可见性,并在调用本内部接口前完成 Platform/Client 鉴权。 + LOADING_LIST_CUSTOMS_SOURCE 与 LOADING_LIST_CUSTOMS_RESULT 均为 CUSTOMER owner scope,tenantId 必须大于 0; + 客户源文件上传/下载由 Firstleg 校验当前客户,结果文件下载还须由 Firstleg 校验整柜完成和结果可见性。 + 用户侧权限冻结为:Platform 上传结果 platform:firstleg:customs:manage,Platform 下载 platform:firstleg:customs:list; + Client 上传源文件 merchant:order:edit,Client 下载自身源文件或获准结果 merchant:order:view。 + 买单客户是否有结果入口由 Firstleg 判定,File 不据此增加结果文件或下载入口。 +paths: + /internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}: + post: + summary: 保存报关业务文件对象 + description: | + 仅服务令牌可调用。fileCategory 取 LOADING_LIST_CUSTOMS_SOURCE 或 LOADING_LIST_CUSTOMS_RESULT。 + 支持任意文件格式,单文件最多 50MB。operatorId 为已通过 Firstleg 鉴权的实际操作人。 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/OperatorId' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/FileCategory' + - $ref: '#/components/parameters/LoadingPlanId' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + responses: + '200': + description: ApiResponse + content: + application/json: + schema: {$ref: '#/components/schemas/BusinessFileResponse'} + /internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}/{fileId}: + get: + summary: 校验报关文件对象的精确复合归属 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/FileCategory' + - $ref: '#/components/parameters/LoadingPlanId' + - $ref: '#/components/parameters/FileId' + responses: + '200': + description: ApiResponse + content: + application/json: + schema: {$ref: '#/components/schemas/BusinessFileResponse'} + /internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}/{fileId}/content: + get: + summary: 下载精确归属的报关文件对象 + description: 响应固定为 attachment 且 Cache-Control 包含 no-store;用户可见性由 Firstleg 在调用前判定。 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/FileCategory' + - $ref: '#/components/parameters/LoadingPlanId' + - $ref: '#/components/parameters/FileId' + responses: + '200': + description: 文件内容 + headers: + Cache-Control: {schema: {type: string, example: no-store}} + Content-Disposition: {schema: {type: string, example: attachment}} + content: + application/octet-stream: + schema: {type: string, format: binary} + /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches: + post: + summary: Firstleg 买单报关明细导出 Provider 契约 + description: | + 此路径由 Firstleg 提供、File 调用。一柜一份;parameters 只能包含正整数 loadingPlanId。 + File 创建该导出任务的唯一 requiredPermission 为 platform:firstleg:customs:manage。 + Provider 必须按 schemaVersion=1 返回下列固定顺序列,File 校验后生成 XLSX。 + parameters: + - $ref: '#/components/parameters/InternalToken' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ExportBatchRequest'} + responses: + '200': + description: ApiResponse +components: + parameters: + InternalToken: {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}} + OperatorId: {name: X-Operator-Id, in: header, required: true, schema: {type: integer, format: int64, minimum: 1}} + TenantId: {name: tenantId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}, description: 客户ID;不得使用平台0作用域} + FileCategory: {name: fileCategory, in: path, required: true, schema: {type: string, enum: [LOADING_LIST_CUSTOMS_SOURCE, LOADING_LIST_CUSTOMS_RESULT]}} + LoadingPlanId: {name: loadingPlanId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + FileId: {name: fileId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + schemas: + BusinessFileResponse: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: {$ref: '#/components/schemas/BusinessFileMetadata'} + BusinessFileMetadata: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes] + properties: + fileId: {type: string, description: File不可变对象ID,跨前端按字符串传输} + ownerScope: {type: string, enum: [CUSTOMER]} + tenantId: {type: string, description: CUSTOMER owner scope客户ID} + businessType: {type: string, description: fileCategory} + businessId: {type: string, description: loadingPlanId} + fileName: {type: string} + sha256: {type: string} + contentType: {type: string} + sizeBytes: {type: integer, format: int64, maximum: 52428800} + createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移} + ExportBatchRequest: + type: object + required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit] + properties: + taskId: {type: string} + tenantId: {type: string, enum: ['0']} + appCode: {type: string, enum: [PLATFORM]} + requestedBy: {type: string} + dispatchVersion: {type: integer} + parameters: + type: object + required: [loadingPlanId] + additionalProperties: false + properties: + loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$'} + cursor: {type: string, nullable: true} + snapshotToken: {type: string, nullable: true} + limit: {type: integer, minimum: 50, maximum: 1000} + BuyDeclarationColumns: + type: object + description: 固定列顺序 + properties: + customerName: {type: string, title: 客户名称} + orderNo: {type: string, title: 订单号} + inboundCode: {type: string, title: 入仓码} + sku: {type: string, title: SKU} + chineseName: {type: string, title: 中文品名} + englishName: {type: string, title: 英文品名} + cartonCount: {type: integer, title: 箱数} + productQuantity: {type: integer, title: 产品数量} + grossWeight: {type: number, title: 毛重} + netWeight: {type: number, title: 净重} + volume: {type: number, title: 体积} + mark: {type: string, title: 唛头} + customerRemark: {type: string, title: 客户备注} diff --git a/file/src/main/resources/openapi/delivery-files-v1.yaml b/file/src/main/resources/openapi/delivery-files-v1.yaml new file mode 100644 index 000000000..a28ea6390 --- /dev/null +++ b/file/src/main/resources/openapi/delivery-files-v1.yaml @@ -0,0 +1,102 @@ +openapi: 3.0.3 +info: + title: 派送单三类资料 File 内部契约 + version: 1.0.0 + description: >- + tenantId为派送单所属客户,businessId为deliveryOrderId内部Long的十进制字符串,不是派送单号或BOL号。 + 任意格式,非空单文件最多50MiB(52428800字节)。每类0至20文件由Lastleg控制,三类不强制必传。 + totalCategoryCount固定3,计数为非空类别x/3,不是文件数;不增加完成门禁或签名识别。 + 上传只创建不可变对象,不自动绑定。Lastleg原子保存分类绑定与版本,替换/删除仅解绑并保留历史证据, + 用户下载前由Lastleg检查当前引用或获准历史引用和权限,再调用File精确归属下载。不得按BOL号跨客户复用。 + x-category-mapping: + LOADING_PHOTO: DELIVERY_LOADING_PHOTO + BOL: DELIVERY_BOL + RECEIPT: DELIVERY_RECEIPT + x-category-titles: + LOADING_PHOTO: 装车照片 + BOL: BOL文件(含签名) + RECEIPT: 签收文件(POD签收/快递签收) +paths: + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}: + parameters: + - {$ref: '#/components/parameters/Token'} + - {$ref: '#/components/parameters/Tenant'} + - {$ref: '#/components/parameters/Type'} + - {$ref: '#/components/parameters/Business'} + post: + summary: 上传派送资料不可变对象 + parameters: + - {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + responses: + '200': {$ref: '#/components/responses/Metadata'} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}: + parameters: + - {$ref: '#/components/parameters/Token'} + - {$ref: '#/components/parameters/Tenant'} + - {$ref: '#/components/parameters/Type'} + - {$ref: '#/components/parameters/Business'} + - {$ref: '#/components/parameters/File'} + get: + summary: 校验客户、派送单、类别和文件ID,读取有效未删除文件元数据 + responses: + '200': {$ref: '#/components/responses/Metadata'} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content: + parameters: + - {$ref: '#/components/parameters/Token'} + - {$ref: '#/components/parameters/Tenant'} + - {$ref: '#/components/parameters/Type'} + - {$ref: '#/components/parameters/Business'} + - {$ref: '#/components/parameters/File'} + get: + summary: 按完整归属下载;内部令牌必需,不提供匿名下载 + responses: + '200': + description: 二进制成功响应;失败按统一JSON code判断 + headers: + Cache-Control: {schema: {type: string, enum: [no-store]}} + Content-Disposition: {schema: {type: string}, description: attachment及UTF-8原文件名} +components: + parameters: + Token: {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}} + Tenant: {name: tenantId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + Business: {name: businessId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + File: {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + Type: + name: businessType + in: path + required: true + schema: + type: string + enum: [DELIVERY_LOADING_PHOTO, DELIVERY_BOL, DELIVERY_RECEIPT] + responses: + Metadata: + description: 业务成功/失败使用HTTP200,成功code为字符串200;无效内部令牌拒绝 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: + type: object + properties: + fileId: {type: string} + ownerScope: {type: string, enum: [CUSTOMER]} + tenantId: {type: string} + businessType: {type: string} + businessId: {type: string} + fileName: {type: string} + sha256: {type: string} + contentType: {type: string} + sizeBytes: {type: integer, format: int64, maximum: 52428800} + createdAt: {type: string, description: 持久化created_at原值,LocalDateTime格式yyyy-MM-ddTHH:mm:ss,无时区偏移} diff --git a/file/src/main/resources/openapi/domestic-order-export-v1.yaml b/file/src/main/resources/openapi/domestic-order-export-v1.yaml new file mode 100644 index 000000000..e2b788aad --- /dev/null +++ b/file/src/main/resources/openapi/domestic-order-export-v1.yaml @@ -0,0 +1,138 @@ +openapi: 3.0.3 +info: + title: File Domestic Warehouse Order Export Contract + version: 1.0.0 + description: >- + File 只持有异步任务、XLSX 文件、任务归属和下载权限。订单筛选、数据权限、分页游标及行数据由 + Order 的 DOMESTIC_WAREHOUSE_ORDER_LIST Provider 持有,File 不复制订单业务查询。 +paths: + /api/platform/v1/files/export-tasks: + post: + summary: 创建国内仓订单 XLSX 导出任务 + description: >- + exportType 固定 DOMESTIC_WAREHOUSE_ORDER_LIST,format 固定 XLSX,调用人必须具有 + platform:order:export。selectedOrderNos 非空时导出勾选业务订单号;为空或省略时由 Order + 按 parameters 中当前列表筛选导出全部匹配记录,parameters 不接受 page、size 或内部数据库 ID。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CreateDomesticOrderExportTask'} + responses: + '200': + description: 返回异步任务,初始状态 PENDING + content: + application/json: + schema: {$ref: '#/components/schemas/ExportTaskRetryState'} + '403': {description: 缺少 platform:order:export} + /api/platform/v1/files/export-tasks/{taskId}/retry: + post: + summary: 人工重试订单导出任务 + description: >- + FAILED 任务重新开始一次执行;PENDING 且已自动失败过的退避任务立即到期并复用当前投递消息, + 不新增并发任务。RUNNING、初次等待消费及已完成任务不允许重复重试。 + parameters: + - {name: taskId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: 返回已重新进入立即执行队列的任务 + content: + application/json: + schema: {$ref: '#/components/schemas/ExportTaskRetryState'} + '409': {description: 任务当前状态不允许重试或已进入执行队列} + /api/platform/v1/files/export-tasks/{taskId}/download: + get: + summary: 下载本人已完成的订单导出文件 + parameters: + - {name: taskId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: XLSX 文件;他人任务仅 platform:download:all 可下载 + headers: + Cache-Control: {schema: {type: string, example: no-store}} + Content-Disposition: {schema: {type: string, example: attachment}} + content: + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: + schema: {type: string, format: binary} +components: + schemas: + ExportTaskRetryState: + type: object + required: [id, status, attemptCount, maxAttempts] + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + status: {type: string, enum: [PENDING, RUNNING, SUCCEEDED, FAILED, EXPIRED]} + failureMessage: {type: string, nullable: true} + attemptCount: {type: integer, minimum: 0} + maxAttempts: {type: integer, minimum: 1} + nextRetryAt: {type: string, format: date-time, nullable: true} + CreateDomesticOrderExportTask: + type: object + required: [exportType, format, taskName, parameters] + properties: + exportType: {type: string, enum: [DOMESTIC_WAREHOUSE_ORDER_LIST]} + format: {type: string, enum: [XLSX]} + taskName: {type: string, minLength: 1, maxLength: 128} + parameters: {$ref: '#/components/schemas/DomesticOrderExportParameters'} + DomesticOrderExportParameters: + type: object + description: >- + 透传给 Order Provider;selectedOrderNos 非空时优先,其余字段与平台订单列表当前筛选一致。 + properties: + selectedOrderNos: + type: array + maxItems: 5000 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 64, description: 订单业务编号} + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + keyword: {type: string, maxLength: 64} + orderNo: {type: string, maxLength: 64} + mainStatus: {type: string} + customerId: {type: string, pattern: '^[1-9][0-9]*$'} + receivingWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + inboundCode: {type: string, maxLength: 64} + internalOrderStatus: {type: string, maxLength: 32} + expectedArrivalDate: {type: string, format: date} + feeReviewStatus: {type: string} + channelName: {type: string, maxLength: 128} + containerNo: {type: string, maxLength: 64} + cabinetNo: {type: string, maxLength: 64} + sailingDate: {type: string, format: date} + loaded: {type: boolean} + fullContainer: {type: boolean} + measurementReviewStatus: {type: string} + fbaCode: {type: string, maxLength: 64} + createdFrom: {type: string, format: date-time} + createdTo: {type: string, format: date-time} + DomesticOrderExportColumns: + type: object + description: Provider schemaVersion=1 固定列顺序;不包含任何内部数据库 ID。 + properties: + inboundCode: {type: string, title: 入仓码} + orderNo: {type: string, title: 订单号} + mainStatus: {type: string, title: 订单主状态} + customerName: {type: string, title: 客户} + channelName: {type: string, title: 渠道} + channelChanged: {type: boolean, title: 曾变更渠道} + receivingWarehouseName: {type: string, title: 定位仓库} + destinationWarehouseName: {type: string, title: 交货仓} + expectedShipDate: {type: string, format: date, title: 预计出运日期} + expectedArrivalAt: {type: string, format: date-time, title: 预计到货时间} + expectedVolumeCbm: {type: number, title: 预计体积(m³)} + totalPackageCount: {type: integer, title: 箱数} + totalQuantity: {type: integer, title: 产品数量} + actualGrossWeightKg: {type: number, title: 实测毛重(kg)} + actualVolumeCbm: {type: number, title: 实测体积(m³)} + chargeableWeightKg: {type: number, title: 计费重量(kg)} + chargeableVolumeCbm: {type: number, title: 计费体积(m³)} + feeReviewStatus: {type: string, title: 费用审核状态} + skuNos: {type: string, title: SKU} + taxMode: {type: string, title: 报关方式} + containerMode: {type: string, title: 装柜方式} + customsInspection: {type: boolean, title: 是否商检} + containerNo: {type: string, title: 集装箱号} + cabinetNo: {type: string, title: 柜次号} + vesselName: {type: string, title: 船名} + voyageNo: {type: string, title: 航次} + createdAt: {type: string, format: date-time, title: 创建时间} diff --git a/file/src/main/resources/openapi/loading-evidence-v1.yaml b/file/src/main/resources/openapi/loading-evidence-v1.yaml new file mode 100644 index 000000000..f6f73f5ff --- /dev/null +++ b/file/src/main/resources/openapi/loading-evidence-v1.yaml @@ -0,0 +1,196 @@ +openapi: 3.0.3 +info: + title: Loading Evidence File API + version: 1.0.0 + description: >- + 装柜四步照片与铅封文件只归属物理柜。所有文件固定 ownerScope=PLATFORM、tenantId=0、 + businessId=containerBookingId;PDA taskId 仅由 Firstleg 保存为来源和审计,不参与 File 归属。 +servers: + - url: / +tags: + - name: LoadingEvidence +paths: + /api/platform/v1/files/loading-evidence/{containerBookingId}: + post: + tags: [LoadingEvidence] + summary: Admin上传柜级装柜证据 + description: >- + 仅Admin装柜证据维护权限可直连;国内PDA禁止直连File,必须先调用Firstleg任务证据接口。 + 不接受客户ID或入仓单ID;同一文件不得跨containerBookingId复用。 + x-required-permission: platform:firstleg:inbound:loading:edit + parameters: + - $ref: '#/components/parameters/ContainerBookingId' + - $ref: '#/components/parameters/EvidenceType' + 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/UploadResponse'} + /api/platform/v1/files/loading-evidence/{containerBookingId}/{fileId}: + get: + tags: [LoadingEvidence] + summary: Admin下载柜级装柜证据 + description: >- + 仅Admin装柜证据读取权限可直连;国内PDA必须经Firstleg任务、仓库和操作者校验后,由Firstleg使用内部服务令牌下载。 + 文件必须同时命中平台所有权、containerBookingId、fileId、五类LOADING业务类型和ACTIVE状态;响应禁止缓存。 + x-required-permission: platform:firstleg:inbound:loading:view + parameters: + - $ref: '#/components/parameters/ContainerBookingId' + - $ref: '#/components/parameters/FileId' + responses: + '200': + description: 文件内容 + headers: + Cache-Control: + schema: {type: string, enum: [no-store]} + content: + image/jpeg: {schema: {type: string, format: binary}} + image/png: {schema: {type: string, format: binary}} + image/bmp: {schema: {type: string, format: binary}} + application/octet-stream: {schema: {type: string, format: binary}} + /internal/file/v1/loading-evidence/{containerBookingId}: + post: + tags: [LoadingEvidence] + summary: Firstleg内部上传柜级装柜证据 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/OperatorId' + - $ref: '#/components/parameters/ContainerBookingId' + - $ref: '#/components/parameters/EvidenceType' + 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/UploadResponse'} + /internal/file/v1/loading-evidence/{containerBookingId}/{fileId}: + get: + tags: [LoadingEvidence] + summary: Firstleg内部校验柜级文件元数据 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/ContainerBookingId' + - $ref: '#/components/parameters/FileId' + responses: + '200': + description: 元数据 + content: + application/json: + schema: {$ref: '#/components/schemas/MetadataResponse'} + /internal/file/v1/loading-evidence/{containerBookingId}/{fileId}/content: + get: + tags: [LoadingEvidence] + summary: Firstleg内部下载柜级文件 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/ContainerBookingId' + - $ref: '#/components/parameters/FileId' + responses: + '200': + description: 文件内容,Cache-Control固定no-store + headers: + Cache-Control: + schema: {type: string, enum: [no-store]} + content: + application/octet-stream: {schema: {type: string, format: binary}} +components: + parameters: + InternalToken: + in: header + name: X-Internal-Service-Token + required: true + schema: {type: string} + OperatorId: + in: header + name: X-Operator-Id + required: true + schema: {$ref: '#/components/schemas/Id'} + ContainerBookingId: + in: path + name: containerBookingId + required: true + schema: {$ref: '#/components/schemas/Id'} + FileId: + in: path + name: fileId + required: true + schema: {$ref: '#/components/schemas/Id'} + EvidenceType: + in: query + name: evidenceType + required: true + schema: + type: string + enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL] + schemas: + Id: + type: string + pattern: '^[1-9][0-9]*$' + ApiEnvelope: + type: object + required: [code, message, requestId, traceId, timestamp] + properties: + code: {type: string} + message: {type: string} + requestId: {type: string} + traceId: {type: string} + timestamp: {type: string, format: date-time} + UploadResponse: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + required: [data] + properties: + data: {$ref: '#/components/schemas/UploadResult'} + MetadataResponse: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + required: [data] + properties: + data: {$ref: '#/components/schemas/FileMetadata'} + FileIdentity: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, contentType] + properties: + fileId: {$ref: '#/components/schemas/Id'} + ownerScope: {type: string, enum: [PLATFORM]} + tenantId: {type: string, enum: ['0']} + businessType: + type: string + enum: [LOADING_PHOTO_STEP_1, LOADING_PHOTO_STEP_2, LOADING_PHOTO_STEP_3, LOADING_PHOTO_STEP_4, LOADING_SEAL] + businessId: + allOf: + - $ref: '#/components/schemas/Id' + description: 物理containerBookingId + fileName: {type: string} + contentType: {type: string} + UploadResult: + allOf: + - $ref: '#/components/schemas/FileIdentity' + - type: object + required: [sizeBytes] + properties: + sizeBytes: {type: integer, format: int64, minimum: 1} + FileMetadata: + allOf: + - $ref: '#/components/schemas/FileIdentity' diff --git a/file/src/main/resources/openapi/ocean-booking-file-v1.yaml b/file/src/main/resources/openapi/ocean-booking-file-v1.yaml new file mode 100644 index 000000000..dc3843701 --- /dev/null +++ b/file/src/main/resources/openapi/ocean-booking-file-v1.yaml @@ -0,0 +1,116 @@ +openapi: 3.0.3 +info: + title: Ocean Booking Document Internal API + version: 1.0.0 + description: >- + 订舱文件归属订舱聚合,不归属拖车。固定ownerScope=PLATFORM、tenantId=0、 + businessType=OCEAN_BOOKING_DOCUMENT、businessId=oceanBookingId。BOOKING_CONFIRMATION与 + BILL_OF_LADING文档类型、版本和有效状态归Firstleg;File不判断订舱业务状态。客户为可选快照,有值、为空或清空均不改变文件归属,客户作用域上传读取一律拒绝。 + x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING] + x-lifecycle-owner: FIRSTLEG + x-file-object-semantics: IMMUTABLE_CREATE_ONLY +paths: + /internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}: + post: + summary: Firstleg上传订舱附件 + security: [{internalServiceToken: []}] + parameters: + - {$ref: '#/components/parameters/OperatorId'} + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/OceanBookingId'} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: 任意格式,最大50MiB;File保留规范化Content-Type与原始文件名 + responses: + '200': {$ref: '#/components/responses/Metadata'} + /internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}/{fileId}: + get: + summary: Firstleg校验订舱附件元数据 + security: [{internalServiceToken: []}] + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/OceanBookingId'} + - {$ref: '#/components/parameters/FileId'} + responses: + '200': {$ref: '#/components/responses/Metadata'} + /internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}/{fileId}/content: + get: + summary: Firstleg下载订舱附件 + security: [{internalServiceToken: []}] + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/OceanBookingId'} + - {$ref: '#/components/parameters/FileId'} + responses: + '200': + description: 附件内容;错误订舱ID、拖车业务类型或文件ID不得命中 + headers: + Cache-Control: {schema: {type: string, enum: [no-store]}} + content: + application/octet-stream: {schema: {type: string, format: binary}} +components: + securitySchemes: + internalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} + parameters: + OperatorId: + name: X-Operator-Id + in: header + required: true + schema: {$ref: '#/components/schemas/Id'} + TenantId: + name: tenantId + in: path + required: true + description: 平台固定0,与订舱客户快照无关 + schema: {type: string, enum: ['0']} + OceanBookingId: + name: oceanBookingId + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + FileId: + name: fileId + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + responses: + Metadata: + description: 精确订舱归属元数据 + content: + application/json: + schema: + type: object + required: [code, data] + properties: + code: {type: string, enum: ['200']} + data: {$ref: '#/components/schemas/Metadata'} + schemas: + Id: {type: string, pattern: '^[1-9][0-9]*$'} + Metadata: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes] + properties: + fileId: {$ref: '#/components/schemas/Id'} + ownerScope: {type: string, enum: [PLATFORM]} + tenantId: + type: string + enum: ['0'] + description: 平台固定0,与订舱客户快照无关 + businessType: {type: string, enum: [OCEAN_BOOKING_DOCUMENT]} + businessId: + allOf: [{$ref: '#/components/schemas/Id'}] + description: oceanBookingId + fileName: {type: string} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string} + sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 52428800} + createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移} diff --git a/file/src/main/resources/openapi/order-batch-import-file-v1.yaml b/file/src/main/resources/openapi/order-batch-import-file-v1.yaml new file mode 100644 index 000000000..ff85f248a --- /dev/null +++ b/file/src/main/resources/openapi/order-batch-import-file-v1.yaml @@ -0,0 +1,137 @@ +openapi: 3.0.3 +info: + title: Order Batch Import File Internal API + version: 1.0.0 + description: >- + Order批量导入源文件与错误报告均为客户所有的不可变XLSX对象;businessId固定为Order生成的batchNo。 + File只保存并校验对象归属,不复制导入任务状态。 + x-lifecycle-owner: ORDER + x-file-object-semantics: IMMUTABLE_CREATE_ONLY +paths: + /api/merchant/v1/files/order-batch-imports/{batchNo}/error-reports/{fileId}: + get: + summary: 客户下载本客户批量导入错误报告 + description: 需要merchant:order:create;客户作用域只取登录态,文件类别固定ORDER_BATCH_IMPORT_ERROR_REPORT。 + security: [{bearerAuth: []}] + parameters: + - {$ref: '#/components/parameters/BatchNo'} + - {$ref: '#/components/parameters/FileId'} + responses: + '200': + description: 精确归属的XLSX错误报告 + headers: + Cache-Control: {schema: {type: string, enum: [no-store]}} + Content-Disposition: {schema: {type: string}} + content: + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: + schema: {type: string, format: binary} + /internal/file/v1/business-files/{tenantId}/{businessType}/{batchNo}: + post: + summary: Order上传批量导入源文件或错误报告 + security: [{internalServiceToken: []}] + parameters: + - {$ref: '#/components/parameters/OperatorId'} + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BatchNo'} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: 有效XLSX文件,最大5MiB + responses: + '200': {$ref: '#/components/responses/Metadata'} + /internal/file/v1/business-files/{tenantId}/{businessType}/{batchNo}/{fileId}: + get: + summary: Order精确校验批量导入文件元数据 + security: [{internalServiceToken: []}] + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BatchNo'} + - {$ref: '#/components/parameters/FileId'} + responses: + '200': {$ref: '#/components/responses/Metadata'} + /internal/file/v1/business-files/{tenantId}/{businessType}/{batchNo}/{fileId}/content: + get: + summary: Order下载批量导入源文件或错误报告 + security: [{internalServiceToken: []}] + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BatchNo'} + - {$ref: '#/components/parameters/FileId'} + responses: + '200': + description: 精确归属的XLSX附件;错误客户、类别、batchNo或fileId不得命中 + headers: + Cache-Control: {schema: {type: string, enum: [no-store]}} + Content-Disposition: {schema: {type: string}} + content: + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: + schema: {type: string, format: binary} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + internalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} + parameters: + OperatorId: + name: X-Operator-Id + in: header + required: true + schema: {$ref: '#/components/schemas/Id'} + TenantId: + name: tenantId + in: path + required: true + description: 当前客户ID,必须大于0 + schema: {$ref: '#/components/schemas/Id'} + BusinessType: + name: businessType + in: path + required: true + schema: {type: string, enum: [ORDER_BATCH_IMPORT_SOURCE, ORDER_BATCH_IMPORT_ERROR_REPORT]} + BatchNo: + name: batchNo + in: path + required: true + schema: {type: string, pattern: '^OBI-[0-9A-F]{20}$'} + FileId: + name: fileId + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + responses: + Metadata: + description: 客户批量导入文件元数据 + content: + application/json: + schema: + type: object + required: [code, data] + properties: + code: {type: string, enum: ['200']} + data: {$ref: '#/components/schemas/Metadata'} + schemas: + Id: {type: string, pattern: '^[1-9][0-9]*$'} + Metadata: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes] + properties: + fileId: {$ref: '#/components/schemas/Id'} + ownerScope: {type: string, enum: [CUSTOMER]} + tenantId: {$ref: '#/components/schemas/Id'} + businessType: {type: string, enum: [ORDER_BATCH_IMPORT_SOURCE, ORDER_BATCH_IMPORT_ERROR_REPORT]} + businessId: {type: string, pattern: '^OBI-[0-9A-F]{20}$'} + fileName: {type: string} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string, enum: [application/vnd.openxmlformats-officedocument.spreadsheetml.sheet]} + sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 5242880} + createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移} diff --git a/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml b/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml new file mode 100644 index 000000000..03bf340a3 --- /dev/null +++ b/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml @@ -0,0 +1,110 @@ +openapi: 3.0.3 +info: + title: 海外异常订单证据文件内部契约 + version: 1.0.0 + description: >- + File 只保存文件对象并校验复合归属;异常订单状态、完成门禁和证据引用由 Lastleg 持有。 + 所有接口仅接受内部服务令牌,业务失败按统一响应体 code 判断。 +paths: + /internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}: + post: + summary: 上传海外异常订单证据 + description: >- + tenantId 固定为异常订单所属客户ID,exceptionOrderId 固定为异常订单内部ID。 + 单文件最大10MiB,仅支持TXT、PDF、JPG、PNG;File不校验异常状态。 + security: [] + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ExceptionOrderId' + - {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + responses: + '200': + description: 返回文件元数据 + content: + application/json: + schema: {$ref: '#/components/schemas/MetadataEnvelope'} + '403': {description: 内部服务令牌无效} + /internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}/{fileId}: + get: + summary: 校验并读取海外异常订单证据元数据 + description: 必须同时匹配客户ID、异常订单ID、文件类型与文件ID,否则返回不存在或归属不匹配。 + security: [] + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ExceptionOrderId' + - $ref: '#/components/parameters/FileId' + responses: + '200': + description: 返回匹配复合归属的文件元数据 + content: + application/json: + schema: {$ref: '#/components/schemas/MetadataEnvelope'} + '403': {description: 内部服务令牌无效} + /internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}/{fileId}/content: + get: + summary: 下载海外异常订单证据内容 + description: 下载前执行与元数据接口相同的复合归属校验,响应禁止缓存。 + security: [] + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ExceptionOrderId' + - $ref: '#/components/parameters/FileId' + responses: + '200': {description: 文件二进制;携带Content-Disposition并设置Cache-Control no-store} + '403': {description: 内部服务令牌无效} +components: + parameters: + InternalToken: + name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} + TenantId: + name: tenantId + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + ExceptionOrderId: + name: exceptionOrderId + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + FileId: + name: fileId + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + schemas: + MetadataEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string, nullable: true} + data: {$ref: '#/components/schemas/Metadata'} + Metadata: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes] + properties: + fileId: {type: string, pattern: '^[1-9][0-9]*$'} + ownerScope: {type: string, enum: [CUSTOMER]} + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + businessType: {type: string, enum: [OVERSEAS_EXCEPTION_EVIDENCE]} + businessId: {type: string, pattern: '^[1-9][0-9]*$', description: 异常订单内部ID} + fileName: {type: string} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string} + sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760} + createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移} diff --git a/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml b/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml new file mode 100644 index 000000000..9444e9a4d --- /dev/null +++ b/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml @@ -0,0 +1,125 @@ +openapi: 3.0.3 +info: + title: 海外换标 PDF 文件内部契约 + version: 1.0.0 + description: >- + File 只保存文件并强制客户、业务类型、业务ID与文件ID复合归属。 + OVERSEAS_RELABEL_TOTAL_DOCUMENT 的 businessId 为 application.id; + OVERSEAS_RELABEL_ITEM_DOCUMENT 的 businessId 为 item.id。 + Lastleg 必须先校验申请与明细存在、明细所属申请及其客户归属、业务状态和权限。 + 客户归属未知时失败关闭,不得使用平台归属或替代业务ID。 +security: + - InternalServiceToken: [] +paths: + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}: + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/BusinessType' + - $ref: '#/components/parameters/BusinessId' + post: + summary: 上传换标总文件或单品文件 + description: 非空 PDF,Content-Type 必须为 application/pdf,内容需有 PDF 签名,最大 10MiB(10485760字节,含边界)。 + parameters: + - name: X-Operator-Id + in: header + required: true + schema: {type: integer, format: int64, minimum: 1} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + encoding: + file: {contentType: application/pdf} + responses: + '200': + description: 统一响应体;业务失败按 code 判断 + content: + application/json: + schema: {$ref: '#/components/schemas/MetadataEnvelope'} + '403': {description: 内部令牌无效} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}: + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/BusinessType' + - $ref: '#/components/parameters/BusinessId' + - $ref: '#/components/parameters/FileId' + get: + summary: 校验复合归属并读取元数据 + responses: + '200': + description: 仅返回全部归属字段匹配且有效的文件,未匹配按统一业务错误返回 + content: + application/json: + schema: {$ref: '#/components/schemas/MetadataEnvelope'} + '403': {description: 内部令牌无效} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content: + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/BusinessType' + - $ref: '#/components/parameters/BusinessId' + - $ref: '#/components/parameters/FileId' + get: + summary: 校验复合归属并下载 PDF + responses: + '200': + description: 文件内容;Content-Disposition 附件下载,Cache-Control no-store + content: + application/pdf: + schema: {type: string, format: binary} + '403': {description: 内部令牌无效} +components: + schemas: + MetadataEnvelope: + type: object + required: [code, data] + properties: + code: {type: string} + message: {type: string, nullable: true} + data: {$ref: '#/components/schemas/Metadata'} + Metadata: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes] + properties: + fileId: {type: string, pattern: '^[1-9][0-9]*$'} + ownerScope: {type: string, enum: [CUSTOMER]} + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + businessType: {type: string, enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]} + businessId: {type: string, pattern: '^[1-9][0-9]*$'} + fileName: {type: string} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string, enum: [application/pdf]} + sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760} + createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移} + securitySchemes: + InternalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token + parameters: + TenantId: + name: tenantId + in: path + required: true + schema: {type: integer, format: int64, minimum: 1} + BusinessType: + name: businessType + in: path + required: true + schema: + type: string + enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT] + BusinessId: + name: businessId + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + FileId: + name: fileId + in: path + required: true + schema: {type: integer, format: int64, minimum: 1} diff --git a/file/src/main/resources/openapi/qualification-required-package-v1.yaml b/file/src/main/resources/openapi/qualification-required-package-v1.yaml new file mode 100644 index 000000000..99e4d0112 --- /dev/null +++ b/file/src/main/resources/openapi/qualification-required-package-v1.yaml @@ -0,0 +1,207 @@ +openapi: 3.0.3 +info: + title: Qualification Required Package API + version: 1.0.0 + description: 营业执照为必填资质;法人身份证为选填资质,缺省时不创建、不替换法人身份证记录。 +paths: + /api/merchant/v1/files/qualifications: + post: + summary: 客户单独上传或替换选填资质 + description: >- + 需要 merchant:qualification:upload;客户范围只取登录 Token,不接受客户ID。 + LEGAL_PERSON_ID 可在已有营业执照后单独新增或替换,不要求重新上传营业执照。 + BUSINESS_LICENSE 不允许走此入口,必须与18位统一社会信用代码通过 required-package 一并上传。 + security: [{bearerAuth: []}] + parameters: + - {$ref: '#/components/parameters/QualificationType'} + - {$ref: '#/components/parameters/CertificateNo'} + - {$ref: '#/components/parameters/ValidTo'} + - {$ref: '#/components/parameters/Replace'} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + maxLength: 5242880 + description: PDF/JPG/PNG/BMP,最大5MB,声明媒体类型必须与文件签名一致 + responses: + '200': + description: 成功或业务失败,失败时按 code 判断并保留 requestId/traceId + content: + application/json: + schema: + oneOf: + - {$ref: '#/components/schemas/QualificationUploadSuccess'} + - {$ref: '#/components/schemas/ErrorResponse'} + '401': {$ref: '#/components/responses/Error'} + '403': {$ref: '#/components/responses/Error'} + /api/merchant/v1/files/qualifications/required-package: + post: + summary: 客户上传必需资质包 + description: 需要 merchant:qualification:upload;营业执照必填,法人身份证选填。 + security: [{bearerAuth: []}] + parameters: + - {$ref: '#/components/parameters/UnifiedSocialCreditCode'} + - {$ref: '#/components/parameters/BusinessLicenseValidTo'} + - {$ref: '#/components/parameters/LegalPersonIdValidTo'} + - {$ref: '#/components/parameters/ReplaceBusinessLicense'} + - {$ref: '#/components/parameters/ReplaceLegalPersonId'} + requestBody: {$ref: '#/components/requestBodies/RequiredPackage'} + responses: + '200': {$ref: '#/components/responses/PackageResult'} + /api/platform/v1/files/qualifications/{tenantId}/required-package: + post: + summary: 平台为客户上传必需资质包 + description: 需要 platform:customer:create 或 platform:customer:edit;营业执照必填,法人身份证选填。 + security: [{bearerAuth: []}] + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/UnifiedSocialCreditCode'} + - {$ref: '#/components/parameters/BusinessLicenseValidTo'} + - {$ref: '#/components/parameters/LegalPersonIdValidTo'} + - {$ref: '#/components/parameters/ReplaceBusinessLicense'} + - {$ref: '#/components/parameters/ReplaceLegalPersonId'} + requestBody: {$ref: '#/components/requestBodies/RequiredPackage'} + responses: + '200': {$ref: '#/components/responses/PackageResult'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + QualificationType: + name: qualificationType + in: query + required: true + schema: {type: string, enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]} + CertificateNo: + name: certificateNo + in: query + required: false + schema: {type: string, maxLength: 128} + ValidTo: + name: validTo + in: query + required: false + schema: {type: string, format: date} + Replace: + name: replace + in: query + required: false + description: 同类有当前资质时必须显式为true;成功后旧文件元数据标记为已替代 + schema: {type: boolean, default: false} + TenantId: + name: tenantId + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + UnifiedSocialCreditCode: + name: unifiedSocialCreditCode + in: query + required: true + schema: {type: string, pattern: '^[0-9A-Za-z]{18}$'} + BusinessLicenseValidTo: + name: businessLicenseValidTo + in: query + required: false + schema: {type: string, format: date} + LegalPersonIdValidTo: + name: legalPersonIdValidTo + in: query + required: false + description: 仅上传legalPersonId时允许填写 + schema: {type: string, format: date} + ReplaceBusinessLicense: + name: replaceBusinessLicense + in: query + required: false + schema: {type: boolean, default: false} + ReplaceLegalPersonId: + name: replaceLegalPersonId + in: query + required: false + description: 未上传legalPersonId时必须为false + schema: {type: boolean, default: false} + requestBodies: + RequiredPackage: + required: true + content: + multipart/form-data: + schema: + type: object + required: [businessLicense] + properties: + businessLicense: + type: string + format: binary + description: 营业执照,必填;PDF/JPG/PNG/BMP,最大5MB + legalPersonId: + type: string + format: binary + nullable: true + description: 法人身份证,选填;PDF/JPG/PNG/BMP,最大5MB + responses: + Error: + description: 认证或授权失败,响应包含请求关联元数据 + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + PackageResult: + description: 资质包上传结果 + content: + application/json: + schema: + type: object + required: [code, data] + properties: + code: {type: string, enum: ['200']} + data: {$ref: '#/components/schemas/PackageUploadResult'} + schemas: + Id: {type: string, pattern: '^[1-9][0-9]*$'} + QualificationUploadSuccess: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string, enum: ['200']} + message: {type: string} + data: {$ref: '#/components/schemas/QualificationUploadResult'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + ErrorResponse: + type: object + required: [code, message, requestId, traceId, timestamp] + properties: + code: {type: string, example: A0403} + message: {type: string} + data: {nullable: true} + requestId: {type: string, minLength: 1} + traceId: {type: string, minLength: 1} + timestamp: {type: string, format: date-time} + QualificationUploadResult: + type: object + required: [qualificationId, tenantStatus, documentUrl] + properties: + qualificationId: {$ref: '#/components/schemas/Id'} + reviewId: + allOf: [{$ref: '#/components/schemas/Id'}] + nullable: true + description: 上传不自动送审,当前固定为null + tenantStatus: {type: string, enum: [PROFILE_REQUIRED, REJECTED]} + documentUrl: {type: string, minLength: 1} + PackageUploadResult: + type: object + required: [businessLicenseQualificationId, tenantStatus, businessLicenseDocumentUrl] + properties: + businessLicenseQualificationId: {$ref: '#/components/schemas/Id'} + legalPersonIdQualificationId: + allOf: [{$ref: '#/components/schemas/Id'}] + nullable: true + tenantStatus: {type: string} + businessLicenseDocumentUrl: {type: string, minLength: 1} + legalPersonIdDocumentUrl: {type: string, nullable: true} diff --git a/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java b/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java new file mode 100644 index 000000000..fd86f9e01 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java @@ -0,0 +1,63 @@ +package com.baiyue.file.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.file.config.ExportDefinitionRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.Resource; +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.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(classes = {ExportProviderClient.class, ExportProviderClientTest.TestConfig.class}) +class ExportProviderClientTest { + @MockBean private IamExportProviderClient iam; + @MockBean private OrderExportProviderClient order; + @MockBean private WarehouseExportProviderClient warehouse; + @MockBean private FirstlegExportProviderClient firstleg; + @MockBean private FinanceExportProviderClient finance; + @Resource private ExportProviderClient client; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean + MeterRegistry meterRegistry() { + return new SimpleMeterRegistry(); + } + } + + @Test void routesTheRequestToTheAllowlistedServiceSelectedByTheDefinition() { + var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L)); + when(order.batch(anyString(),anyString(),any())).thenReturn(response); + var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("order-service"); + ExportBatchRequest request=new ExportBatchRequest("9","0","PLATFORM","7",1, Map.of(),null,null,500); + + var actual=client.batch("token","ORDER_LIST",definition,request); + + assertSame(response,actual); + verify(order).batch("token","ORDER_LIST",request); + verifyNoInteractions(iam,warehouse,firstleg,finance); + } + + @Test void routesFinanceExportsToFinanceService() { + var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L)); + when(finance.batch(anyString(),anyString(),any())).thenReturn(response); + var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("finance-service"); + ExportBatchRequest request=new ExportBatchRequest("9","0","PLATFORM","7",1, Map.of(),null,null,500); + + var actual=client.batch("token","FINANCE_RECONCILIATION_STATEMENT_LIST",definition,request); + + assertSame(response,actual); + verify(finance).batch("token","FINANCE_RECONCILIATION_STATEMENT_LIST",request); + verifyNoInteractions(iam,order,warehouse,firstleg); + } +} diff --git a/file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java b/file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java new file mode 100644 index 000000000..964b44893 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java @@ -0,0 +1,30 @@ +package com.baiyue.file.client; + +import feign.Request; +import feign.Response; +import feign.codec.Decoder; +import org.junit.jupiter.api.Test; +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); + byte[] body=new byte[ExportProviderFeignConfiguration.MAX_RESPONSE_BYTES+1]; + Response response=Response.builder().status(200).reason("OK") + .request(Request.create(Request.HttpMethod.POST,"http://iam-service/internal/exports/v1/CUSTOMER_LIST/batches",Map.of(),null, StandardCharsets.UTF_8,null)) + .body(body).build(); + + assertThrows(ExportProviderResponseTooLargeException.class,() -> decoder.decode(response,Object.class)); + } +} diff --git a/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java b/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java new file mode 100644 index 000000000..1252a954f --- /dev/null +++ b/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java @@ -0,0 +1,47 @@ +package com.baiyue.file.config; + +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 CustomsFileExportContractTest { + private static final String TYPE = "LOADING_LIST_BUY_DECLARATION_ITEMS"; + + @Test void applicationAndNacosRegisterTheSameFirstlegProviderContract() throws Exception { + for (String file : new String[]{ + "file/src/main/resources/application.yml", + "config/nacos/local/file-service.yml", + "config/nacos/cn/file-service.yml"}) { + String source = Files.readString(repositoryRoot().resolve(file)); + assertThat(source).contains(TYPE + ":") + .contains("provider-service: firstleg-service") + .contains("provider-path: /internal/exports/v1/" + TYPE + "/batches") + .contains("required-permission: platform:firstleg:customs:manage") + .contains("app-scope: PLATFORM") + .doesNotContain("loading-list:export"); + } + } + + @Test void openApiFreezesOwnerScopeSizeVisibilityAndColumns() throws Exception { + String source = Files.readString(repositoryRoot() + .resolve("file/src/main/resources/openapi/customs-file-and-export-v1.yaml")); + + assertThat(source).contains("LOADING_LIST_CUSTOMS_SOURCE", "LOADING_LIST_CUSTOMS_RESULT") + .contains("maximum: 52428800", "no-store", "CUSTOMER owner scope", "loadingPlanId") + .contains("platform:firstleg:customs:manage", "platform:firstleg:customs:list", + "merchant:order:edit", "merchant:order:view") + .contains("customerName", "orderNo", "inboundCode", "sku", "chineseName", "englishName", + "cartonCount", "productQuantity", "grossWeight", "netWeight", "volume", "mark", + "customerRemark") + .doesNotContain("productNameZh", "productNameEn", "shippingMark", "loading-list:export", + "platform:firstleg:loading-list:list"); + } + + private static Path repositoryRoot() { + Path current = Path.of("").toAbsolutePath(); + return Files.isDirectory(current.resolve("file")) ? current : current.getParent(); + } +} diff --git a/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java new file mode 100644 index 000000000..fbe7daa45 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java @@ -0,0 +1,74 @@ +package com.baiyue.file.config; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class DomesticWarehouseOrderExportContractTest { + private static final String TYPE = "DOMESTIC_WAREHOUSE_ORDER_LIST"; + + @Test void applicationAndNacosRegisterTheSameOrderProvider() throws Exception { + for (String file : List.of("file/src/main/resources/application.yml", + "config/nacos/local/file-service.yml", "config/nacos/cn/file-service.yml")) { + String source = Files.readString(repositoryRoot().resolve(file)); + assertThat(source).contains(TYPE + ":") + .contains("provider-service: order-service") + .contains("provider-path: /internal/exports/v1/" + TYPE + "/batches") + .contains("required-permission: platform:order:export") + .contains("app-scope: PLATFORM"); + Map root = map(new Yaml().load(source)); + Map baiyue = map(root.get("baiyue")); + Map fileConfig = map(baiyue.get("file")); + Map export = map(fileConfig.get("export")); + Map definitions = map(export.get("definitions")); + Map orderDefinition = map(definitions.get(TYPE)); + assertThat(orderDefinition.get("batch-size")) + .as("%s must stay within Firstleg's 200-order summary limit", file) + .isEqualTo(200); + } + } + + @Test void fileOpenApiFreezesSelectionPrecedenceFiltersAndBusinessColumns() throws Exception { + String source = Files.readString(repositoryRoot().resolve( + "file/src/main/resources/openapi/domestic-order-export-v1.yaml")); + Map document = map(new Yaml().load(source)); + Map schemas = map(map(document.get("components")).get("schemas")); + Map paths = map(document.get("paths")); + Map parameters = map(map(schemas.get("DomesticOrderExportParameters")).get("properties")); + Map columns = map(map(schemas.get("DomesticOrderExportColumns")).get("properties")); + + assertThat(parameters).containsKeys("selectedOrderNos", "tenantId", "keyword", "orderNo", "mainStatus", + "customerId", "receivingWarehouseId", "destinationWarehouseId", "inboundCode", + "internalOrderStatus", "expectedArrivalDate", "feeReviewStatus", "channelName", "containerNo", + "cabinetNo", "sailingDate", "loaded", "fullContainer", "measurementReviewStatus", "fbaCode", + "createdFrom", "createdTo").doesNotContainKeys("page", "size", "orderId"); + assertThat(columns.keySet()).containsExactly("inboundCode", "orderNo", "mainStatus", "customerName", + "channelName", "channelChanged", "receivingWarehouseName", "destinationWarehouseName", + "expectedShipDate", "expectedArrivalAt", "expectedVolumeCbm", "totalPackageCount", "totalQuantity", + "actualGrossWeightKg", "actualVolumeCbm", "chargeableWeightKg", "chargeableVolumeCbm", + "feeReviewStatus", "skuNos", "taxMode", "containerMode", "customsInspection", "containerNo", + "cabinetNo", "vesselName", "voyageNo", "createdAt"); + assertThat(columns).containsKeys("expectedVolumeCbm", "expectedArrivalAt") + .doesNotContainKeys("orderId", "tenantId", "customerId", "inboundId", "receivingWarehouseId", + "destinationWarehouseId"); + assertThat(source).contains("platform:order:export", "selectedOrderNos 非空时优先", "no-store"); + assertThat(paths).containsKey("/api/platform/v1/files/export-tasks/{taskId}/retry"); + assertThat(map(map(schemas.get("ExportTaskRetryState")).get("properties"))) + .containsKeys("attemptCount", "maxAttempts", "nextRetryAt", "failureMessage"); + assertThat(source).contains("PENDING 且已自动失败过的退避任务立即到期并复用当前投递消息"); + } + + private static Path repositoryRoot() { + Path current = Path.of("").toAbsolutePath(); + return Files.isDirectory(current.resolve("file")) ? current : current.getParent(); + } + + @SuppressWarnings("unchecked") + private static Map map(Object value) { return (Map) value; } +} diff --git a/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java b/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java new file mode 100644 index 000000000..9df6f6712 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java @@ -0,0 +1,44 @@ +package com.baiyue.file.config; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ExportDefinitionRegistryTest { + @Test void acceptsTheControlledCustomerProvider() { + ExportDefinitionRegistry registry=new ExportDefinitionRegistry(); registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("iam-service","/internal/exports/v1/CUSTOMER_LIST/batches"))); + assertDoesNotThrow(registry::validate); + } + @Test void acceptsTheControlledFinanceProvider() { + ExportDefinitionRegistry registry=new ExportDefinitionRegistry(); + var finance=definition("finance-service","/internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches"); + finance.setRequiredPermission("platform:finance:customer-statement:export"); + registry.setDefinitions(Map.of("FINANCE_RECONCILIATION_STATEMENT_LIST", finance)); + assertDoesNotThrow(registry::validate); + } + @Test void acceptsTheReceivableStatisticsProvider() { + ExportDefinitionRegistry registry=new ExportDefinitionRegistry(); + var finance=definition("finance-service","/internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches"); + finance.setRequiredPermission("platform:finance:receivable-statistics:export"); + registry.setDefinitions(Map.of("FINANCE_RECEIVABLE_STATISTICS", finance)); + assertDoesNotThrow(registry::validate); + } + @Test void rejectsSsrfAndPathTypeMismatch() { + ExportDefinitionRegistry registry=new ExportDefinitionRegistry(); + assertThrows(IllegalStateException.class, () -> registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("evil-service","/internal/exports/v1/OTHER/batches")))); + } + @Test void rejectedRefreshKeepsTheLastValidDefinitions() { + ExportDefinitionRegistry registry=new ExportDefinitionRegistry(); + var valid=definition("iam-service","/internal/exports/v1/CUSTOMER_LIST/batches"); + registry.setDefinitions(Map.of("CUSTOMER_LIST", valid)); + assertThrows(IllegalStateException.class, () -> registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("evil-service","/internal/exports/v1/CUSTOMER_LIST/batches")))); + assertDoesNotThrow(() -> registry.require("CUSTOMER_LIST")); + org.junit.jupiter.api.Assertions.assertSame(valid, registry.require("CUSTOMER_LIST")); + } + private ExportDefinitionRegistry.Definition definition(String service,String path){ + var d=new ExportDefinitionRegistry.Definition();d.setProviderService(service);d.setProviderPath(path);d.setRequiredPermission("platform:customer:export");d.setAppScope("PLATFORM");d.setMaxRows(200000);d.setMaxAttempts(3);d.setBatchSize(500);return d; + } +} diff --git a/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java b/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java new file mode 100644 index 000000000..41bcb25fa --- /dev/null +++ b/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java @@ -0,0 +1,62 @@ +package com.baiyue.file.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.junit.jupiter.api.Assertions.assertTrue; + +class ExportInfrastructureContractTest { + @Test void deploymentPinsConcurrencyFourAndStartsAllSchedulersDisabled() throws Exception { + String compose=read("..","deploy","docker-compose.yml"); + assertTrue(compose.contains("FILE_EXPORT_CONSUMER_CONCURRENCY: ${FILE_EXPORT_CONSUMER_CONCURRENCY:-4}")); + assertTrue(compose.contains("FILE_EXPORT_CONSUMER_ENABLED: ${FILE_EXPORT_CONSUMER_ENABLED:-false}")); + assertTrue(compose.contains("FILE_EXPORT_PUBLISHER_ENABLED: ${FILE_EXPORT_PUBLISHER_ENABLED:-false}")); + assertTrue(compose.contains("FILE_EXPORT_RECONCILER_ENABLED: ${FILE_EXPORT_RECONCILER_ENABLED:-false}")); + assertTrue(compose.contains("-Xms1g -Xmx2g")); + assertTrue(compose.contains("MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-}")); + assertTrue(compose.contains("ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER-}")); + String deployScript=read("..","deploy","scripts","deploy-services.sh"); + assertTrue(deployScript.contains("部署 file 服务时,环境文件必须显式包含 MQ_TOPIC_PREFIX")); + assertTrue(deployScript.contains("测试环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为 test-")); + assertTrue(deployScript.contains("环境文件必须包含非空 ROCKETMQ_NAME_SERVER")); + } + + @Test void rocketMqBootstrapCreatesBothGroupsAndAReadableWorkerDlq() throws Exception { + String compose=read("..","deploy","docker-compose.rocketmq.yml"); + assertTrue(compose.contains("baiyue-file-export-command-v1\" -r 4 -w 4")); + assertTrue(compose.contains("baiyue-file-export-worker-v1\"")); + assertTrue(compose.contains("baiyue-file-export-dlq-handler-v1\"")); + assertTrue(compose.contains("%DLQ%$${MQ_TOPIC_PREFIX}baiyue-file-export-worker-v1\" -r 1 -w 1 -p 6")); + assertTrue(compose.contains("MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX?")); + assertTrue(compose.contains("sh -eu -c 'until sh mqadmin clusterList")); + assertTrue(compose.contains("| grep -q \"BaiyueCluster\"")); + assertTrue(compose.contains("restart: \"on-failure:3\""), + "RocketMQ bootstrap must limit automatic restart retries to three"); + String environment=read("..","deploy","env",".env.infra-test.example"); + assertTrue(environment.contains("ROCKETMQ_BROKER_IP=rocketmq-broker")); + assertTrue(environment.contains("MQ_TOPIC_PREFIX=test-")); + assertTrue(read("..","deploy","rocketmq","broker.conf.template").contains("autoCreateSubscriptionGroup=false")); + } + + @Test void brokerRendererRejectsUnreachableAndUnexpandedAddresses() throws Exception { + String script=read("..","deploy","rocketmq","render-broker-config.sh"); + assertTrue(script.contains("localhost|127.0.0.1")); + assertTrue(script.contains("*'${'*")); + assertTrue(script.contains("broker.conf still contains an unresolved placeholder")); + } + + @Test void databaseBaselineContainsTaskOutboxAndPreseededCreationGuard() throws Exception { + String sql=read("src","main","resources","db","file.sql"); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS file_export_task (")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS file_export_outbox (")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS file_export_task_guard (")); + assertTrue(sql.contains("VALUES ('GLOBAL_CREATE')")); + } + + private String read(String first,String... more) throws Exception { + return Files.readString(Path.of(first,more), StandardCharsets.UTF_8); + } +} diff --git a/file/src/test/java/com/baiyue/file/config/LoadingEvidenceFileContractTest.java b/file/src/test/java/com/baiyue/file/config/LoadingEvidenceFileContractTest.java new file mode 100644 index 000000000..6597b2159 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/config/LoadingEvidenceFileContractTest.java @@ -0,0 +1,29 @@ +package com.baiyue.file.config; + +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 LoadingEvidenceFileContractTest { + @Test + void freezesPhysicalContainerOwnershipWithoutInboundCompatibilityPath() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/loading-evidence-v1.yaml")); + + assertThat(yaml) + .contains("/api/platform/v1/files/loading-evidence/{containerBookingId}:") + .contains("/internal/file/v1/loading-evidence/{containerBookingId}:") + .contains("ownerScope: {type: string, enum: [PLATFORM]}") + .contains("tenantId: {type: string, enum: ['0']}") + .contains("description: 物理containerBookingId") + .contains("LOADING_PHOTO_STEP_1", "LOADING_PHOTO_STEP_2", "LOADING_PHOTO_STEP_3", + "LOADING_PHOTO_STEP_4", "LOADING_SEAL") + .contains("x-required-permission: platform:firstleg:inbound:loading:edit") + .contains("x-required-permission: platform:firstleg:inbound:loading:view") + .contains("国内PDA禁止直连File", "Cache-Control固定no-store") + .doesNotContain("platform:firstleg:pda:work") + .doesNotContain("/{tenantId}/{inboundId}", "businessId=inboundId"); + } +} diff --git a/file/src/test/java/com/baiyue/file/config/OceanBookingFileContractTest.java b/file/src/test/java/com/baiyue/file/config/OceanBookingFileContractTest.java new file mode 100644 index 000000000..724226afa --- /dev/null +++ b/file/src/test/java/com/baiyue/file/config/OceanBookingFileContractTest.java @@ -0,0 +1,29 @@ +package com.baiyue.file.config; + +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 OceanBookingFileContractTest { + @Test + void freezesInternalPlatformOwnedBookingDocumentContract() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-booking-file-v1.yaml")); + + assertThat(yaml) + .contains("/internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}:") + .contains("ownerScope: {type: string, enum: [PLATFORM]}") + .contains("description: 平台固定0,与订舱客户快照无关") + .contains("description: oceanBookingId") + .contains("x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]") + .contains("x-lifecycle-owner: FIRSTLEG") + .contains("x-file-object-semantics: IMMUTABLE_CREATE_ONLY") + .contains("任意格式,最大50MiB") + .contains("maximum: 52428800") + .contains("name: X-Internal-Service-Token", "Cache-Control") + .doesNotContain("TRUCK_APPOINTMENT_ATTACHMENT", "/api/platform/", "/api/merchant/", + "Idempotency-Key", "expectedVersion", "replace"); + } +} diff --git a/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java new file mode 100644 index 000000000..0717cd6ce --- /dev/null +++ b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java @@ -0,0 +1,52 @@ +package com.baiyue.file.config; + +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 QualificationAndOrderFileOpenApiContractTest { + @Test + void legalPersonIdHasADedicatedSingleFileContractWithErrorMetadata() throws Exception { + String yaml = Files.readString(Path.of( + "src/main/resources/openapi/qualification-required-package-v1.yaml")); + + assertThat(yaml) + .contains("/api/merchant/v1/files/qualifications:") + .contains("enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]") + .contains("merchant:qualification:upload", "maxLength: 5242880", "PDF/JPG/PNG/BMP") + .contains("name: replace", "requestId:", "traceId:") + .doesNotContain("enum: [BUSINESS_LICENSE, LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]"); + } + + @Test + void qualificationPackageRequiresOnlyTheBusinessLicenseFile() throws Exception { + String yaml = Files.readString(Path.of( + "src/main/resources/openapi/qualification-required-package-v1.yaml")); + + assertThat(yaml) + .contains("required: [businessLicense]") + .contains("legalPersonId:", "法人身份证,选填") + .contains("legalPersonIdQualificationId:", "legalPersonIdDocumentUrl:") + .doesNotContain("required: [businessLicense, legalPersonId]"); + } + + @Test + void orderBatchCategoriesAreCustomerOwnedXlsxObjectsWithStableBatchNumber() throws Exception { + String yaml = Files.readString(Path.of( + "src/main/resources/openapi/order-batch-import-file-v1.yaml")); + + assertThat(yaml) + .contains("ORDER_BATCH_IMPORT_SOURCE", "ORDER_BATCH_IMPORT_ERROR_REPORT") + .contains("/api/merchant/v1/files/order-batch-imports/{batchNo}/error-reports/{fileId}:") + .contains("需要merchant:order:create") + .contains("ownerScope: {type: string, enum: [CUSTOMER]}") + .contains("pattern: '^OBI-[0-9A-F]{20}$'") + .contains("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .contains("maximum: 5242880") + .contains("Cache-Control", "no-store") + .doesNotContain("ORDER_IMPORT_SOURCE", "ORDER_IMPORT_ERROR"); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java new file mode 100644 index 000000000..fcc1536a2 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java @@ -0,0 +1,89 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.service.InternalBusinessFileService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +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.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(InternalBusinessFileController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class InternalBusinessFileControllerTest { + @MockBean InternalBusinessFileService service; + @Resource InternalBusinessFileController controller; + + @Test void zeroTenantUploadAndMetadataRemainBehindInternalToken() { + var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", "%PDF-1.4".getBytes()); + var metadata = new InternalBusinessFileService.Metadata(81L, "PLATFORM", 0L, "CONTAINER_CLEARANCE_DOCUMENT", + "41", "clearance.pdf", "hash", "application/pdf", file.getSize(), java.time.LocalDateTime.of(2026, 9, 9, 12, 0)); + when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata); + when(service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)).thenReturn(metadata); + + assertThat(controller.upload("internal-token", 7L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", file).data()) + .isEqualTo(metadata); + assertThat(controller.metadata("internal-token", 0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L).data()) + .isEqualTo(metadata); + } + + @Test void zeroTenantDownloadReturnsOwnedContentWithoutCaching() { + 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_DECLARATION", "51", 91L); + + assertThat(response.getBody()).containsExactly(1, 2); + assertThat(response.getHeaders().getCacheControl()).contains("no-store"); + verify(service).download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L); + } + + @Test void arbitraryResultDownloadUsesAttachmentAndFallsBackToBinaryContentType() { + when(service.download(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 92L)) + .thenReturn(new InternalBusinessFileService.Download("完成文件.unknown", "invalid content type", new byte[]{3})); + + var response = controller.download("internal-token", 10001L, + "LOADING_LIST_CUSTOMS_RESULT", "51", 92L); + + assertThat(response.getHeaders().getCacheControl()).contains("no-store"); + assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment"); + assertThat(response.getHeaders().getContentType()).isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + } + + @Test void unicodeSpecialFileNameUsesUtf8AttachmentDisposition() { + String fileName = "订舱 确认 🚢 #[]()+,最终版.xlsx"; + when(service.download(0L, "OCEAN_BOOKING_DOCUMENT", "61", 93L)) + .thenReturn(new InternalBusinessFileService.Download(fileName, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{4})); + + var response = controller.download("internal-token", 0L, + "OCEAN_BOOKING_DOCUMENT", "61", 93L); + + assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment"); + assertThat(response.getHeaders().getContentDisposition().getFilename()).isEqualTo(fileName); + assertThat(response.getHeaders().getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION)) + .contains("filename*="); + assertThat(response.getHeaders().getCacheControl()).contains("no-store"); + } + + @Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() { + var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes()); + assertThatThrownBy(() -> controller.upload("wrong", 7L, 0L, + "CONTAINER_CLEARANCE_DOCUMENT", "41", file)) + .isInstanceOf(BizException.class).hasMessageContaining("令牌无效"); + assertThatThrownBy(() -> controller.metadata("internal-token", -1L, + "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)) + .isInstanceOf(BizException.class).hasMessageContaining("不能为负数"); + assertThatThrownBy(() -> controller.download("wrong", 10001L, + "OCEAN_BOOKING_DOCUMENT", "61", 81L)) + .isInstanceOf(BizException.class).hasMessageContaining("令牌无效"); + verifyNoInteractions(service); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/InternalLoadingEvidenceControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalLoadingEvidenceControllerTest.java new file mode 100644 index 000000000..207fece60 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/InternalLoadingEvidenceControllerTest.java @@ -0,0 +1,52 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.service.LoadingEvidenceService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +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.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(InternalLoadingEvidenceController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class InternalLoadingEvidenceControllerTest { + @MockBean LoadingEvidenceService service; + @Resource InternalLoadingEvidenceController controller; + + @Test void downloadsOwnedEvidenceBehindInternalToken() { + when(service.download(91L, 101L)).thenReturn( + new LoadingEvidenceService.Download("door.jpg", "image/jpeg", new byte[]{1, 2, 3})); + + var response = controller.download("internal-token", 91L, 101L); + + assertThat(response.getBody()).containsExactly(1, 2, 3); + assertThat(response.getHeaders().getContentType().toString()).isEqualTo("image/jpeg"); + assertThat(response.getHeaders().getCacheControl()).contains("no-store"); + verify(service).download(91L, 101L); + } + + @Test void rejectsInvalidTokenBeforeReadingEvidence() { + assertThatThrownBy(() -> controller.download("wrong", 91L, 101L)) + .isInstanceOf(BizException.class).hasMessageContaining("凭证无效"); + verifyNoInteractions(service); + } + + @Test void fallsBackForInvalidOrNonImageContentTypes() { + when(service.download(91L, 101L)).thenReturn( + new LoadingEvidenceService.Download("door.jpg", "not a/media/type", new byte[]{1})); + when(service.download(91L, 102L)).thenReturn( + new LoadingEvidenceService.Download("door.txt", "text/plain", new byte[]{2})); + + assertThat(controller.download("internal-token", 91L, 101L).getHeaders().getContentType()) + .isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + assertThat(controller.download("internal-token", 91L, 102L).getHeaders().getContentType()) + .isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java new file mode 100644 index 000000000..89e0a53e4 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java @@ -0,0 +1,76 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.service.MeasurementImageService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +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.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(InternalMeasurementImageController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class InternalMeasurementImageControllerTest { + @MockBean + private MeasurementImageService images; + @Resource + private InternalMeasurementImageController controller; + + @Test + void shouldHandleCase01() { + var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE, + new byte[]{1, 2, 3}); + var result = new MeasurementImageService.UploadResult(41L, "measure.png", + MediaType.IMAGE_PNG_VALUE, 3L, "NOT_REQUIRED", null, null, null, null, null); + when(images.uploadMeasurementEvidence(11L, 21L, 31L, file)).thenReturn(result); + + var response = controller.upload("internal-token", 31L, 11L, 21L, file); + + assertThat(response.data().fileId()).isEqualTo(41L); + assertThat(response.data().recognitionStatus()).isEqualTo("NOT_REQUIRED"); + verify(images).uploadMeasurementEvidence(11L, 21L, 31L, file); + } + + @Test + void shouldHandleCase02() { + var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE, + new byte[]{1}); + assertThatThrownBy(() -> controller.upload("bad-token", 31L, 11L, 21L, file)) + .isInstanceOf(BizException.class) + .hasMessageContaining("凭证无效"); + } + @Test + void shouldHandleCase03() { + when(images.download(11L, 21L, 41L)).thenReturn( + new MeasurementImageService.Download("measure.png", MediaType.IMAGE_PNG_VALUE, + new byte[]{1, 2, 3})); + + var response = controller.download("internal-token", 11L, 21L, 41L); + + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.IMAGE_PNG); + assertThat(response.getBody()).containsExactly(1, 2, 3); + verify(images).download(11L, 21L, 41L); + } + + @Test + void storesAnUnmatchedMojiaReceiptImageWithoutAnInboundId() { + var file = new MockMultipartFile("file", "mojia.jpg", MediaType.IMAGE_JPEG_VALUE, + new byte[]{1, 2, 3}); + var result = new MeasurementImageService.UploadResult(41L, "mojia.jpg", + MediaType.IMAGE_JPEG_VALUE, 3L, "NOT_REQUIRED", null, null, null, null, null); + when(images.uploadMojiaReceiptEvidence(11L, 51L, 31L, file)).thenReturn(result); + + var response = controller.uploadReceipt("internal-token", 31L, 11L, 51L, file); + + assertThat(response.data().fileId()).isEqualTo(41L); + verify(images).uploadMojiaReceiptEvidence(11L, 51L, 31L, file); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/InternalPriceBookFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalPriceBookFileControllerTest.java new file mode 100644 index 000000000..e7fde88d1 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/InternalPriceBookFileControllerTest.java @@ -0,0 +1,67 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.service.PriceBookFileService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +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; + +@SpringJUnitConfig(InternalPriceBookFileController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class InternalPriceBookFileControllerTest { + @MockBean PriceBookFileService service; + @Resource InternalPriceBookFileController controller; + + @Test + void uploadsWithAuthenticatedOperatorAndBusinessId() { + var file = new MockMultipartFile("file", "price.xlsx", "application/octet-stream", new byte[]{1}); + var metadata = metadata(); + when(service.upload(41L, 7L, file)).thenReturn(metadata); + + var response = controller.upload("internal-token", 7L, 41L, file); + + assertThat(response.data()).isEqualTo(metadata); + verify(service).upload(41L, 7L, file); + } + + @Test + void rejectsInvalidToken() { + assertThatThrownBy(() -> controller.metadata("bad-token", 7L, 41L, 99L)) + .isInstanceOf(BizException.class).hasMessageContaining("凭证无效"); + } + + @Test + void rejectsMissingOperatorOrBusinessOwnership() { + assertThatThrownBy(() -> controller.metadata("internal-token", 0L, 41L, 99L)) + .isInstanceOf(BizException.class).hasMessageContaining("必须大于0"); + assertThatThrownBy(() -> controller.metadata("internal-token", 7L, 0L, 99L)) + .isInstanceOf(BizException.class).hasMessageContaining("必须大于0"); + } + + @Test + void downloadsAsAttachmentWithoutCaching() { + PriceBookFileService.FileMetadata metadata = metadata(); + when(service.download(41L, 99L)).thenReturn( + new PriceBookFileService.Download(metadata, new byte[]{1, 2, 3})); + + var response = controller.download("internal-token", 7L, 41L, 99L); + + assertThat(response.getBody()).containsExactly(1, 2, 3); + assertThat(response.getHeaders().getCacheControl()).contains("no-store"); + assertThat(response.getHeaders().getContentDisposition().isAttachment()).isTrue(); + } + + private static PriceBookFileService.FileMetadata metadata() { + return new PriceBookFileService.FileMetadata(99L, 41L, "price.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 3L, "hash", + PriceBookFileService.BUSINESS_TYPE); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/MerchantOrderBatchImportFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/MerchantOrderBatchImportFileControllerTest.java new file mode 100644 index 000000000..43b3d5e52 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/MerchantOrderBatchImportFileControllerTest.java @@ -0,0 +1,63 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.InternalBusinessFileService; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.util.ReflectionTestUtils; + +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.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class MerchantOrderBatchImportFileControllerTest { + @Test + void downloadsOnlyTheAuthenticatedCustomersErrorReportAsNoStoreXlsx() { + var files = mock(InternalBusinessFileService.class); + var controller = new MerchantOrderBatchImportFileController(); + ReflectionTestUtils.setField(controller, "files", files); + String batchNo = "OBI-0123456789ABCDEF0123"; + byte[] content = {0x50, 0x4b, 0x03, 0x04}; + when(files.download(10001L, "ORDER_BATCH_IMPORT_ERROR_REPORT", batchNo, 81L)) + .thenReturn(new InternalBusinessFileService.Download("order-batch-errors.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content)); + + var response = controller.downloadErrorReport(merchant(), batchNo, 81L); + + assertThat(response.getBody()).containsExactly(content); + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")); + assertThat(response.getHeaders().getCacheControl()).contains("no-store"); + assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment"); + assertThat(response.getHeaders().getContentDisposition().getFilename()).isEqualTo("order-batch-errors.xlsx"); + verify(files).download(10001L, "ORDER_BATCH_IMPORT_ERROR_REPORT", batchNo, 81L); + } + + @Test + void routeRequiresOrderCreateAndRejectsPlatformIdentity() throws Exception { + var method = MerchantOrderBatchImportFileController.class.getMethod("downloadErrorReport", + AuthenticatedUser.class, String.class, long.class); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('merchant:order:create')"); + + var files = mock(InternalBusinessFileService.class); + var controller = new MerchantOrderBatchImportFileController(); + ReflectionTestUtils.setField(controller, "files", files); + var platform = new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of()); + + assertThatThrownBy(() -> controller.downloadErrorReport(platform, + "OBI-0123456789ABCDEF0123", 81L)).isInstanceOf(BizException.class); + verifyNoInteractions(files); + } + + private static AuthenticatedUser merchant() { + return new AuthenticatedUser(2L, 10001L, 10001L, 2L, "merchant", "MERCHANT", "WEB", Set.of()); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/PlatformExportTaskControllerContractTest.java b/file/src/test/java/com/baiyue/file/controller/PlatformExportTaskControllerContractTest.java new file mode 100644 index 000000000..cde0a587e --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/PlatformExportTaskControllerContractTest.java @@ -0,0 +1,23 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PlatformExportTaskControllerContractTest { + @Test + void exposesThePlatformFileRouteAndProtectsListingAndDownloads() throws Exception { + RequestMapping mapping = PlatformExportTaskController.class.getAnnotation(RequestMapping.class); + assertEquals("/api/platform/v1/files/export-tasks", mapping.value()[0]); + assertEquals("isAuthenticated()", PlatformExportTaskController.class + .getDeclaredMethod("create", AuthenticatedUser.class, CreateExportTaskRequest.class) + .getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:download:list')", PlatformExportTaskController.class + .getDeclaredMethod("download", AuthenticatedUser.class, long.class) + .getAnnotation(PreAuthorize.class).value()); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/PlatformLoadingEvidenceControllerTest.java b/file/src/test/java/com/baiyue/file/controller/PlatformLoadingEvidenceControllerTest.java new file mode 100644 index 000000000..cd83d7ced --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/PlatformLoadingEvidenceControllerTest.java @@ -0,0 +1,72 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.LoadingEvidenceService; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.multipart.MultipartFile; + +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.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PlatformLoadingEvidenceControllerTest { + private final LoadingEvidenceService service = mock(LoadingEvidenceService.class); + private final PlatformLoadingEvidenceController controller = new PlatformLoadingEvidenceController(); + + PlatformLoadingEvidenceControllerTest() throws Exception { + var field = PlatformLoadingEvidenceController.class.getDeclaredField("service"); + field.setAccessible(true); + field.set(controller, service); + } + + @Test + void uploadsPlatformOwnedContainerEvidence() { + var file = new MockMultipartFile("file", "door.jpg", "image/jpeg", new byte[]{1}); + when(service.upload(91L, 7L, "PHOTO_STEP_1", file)).thenReturn( + new LoadingEvidenceService.UploadResult(101L, "PLATFORM", 0L, + "LOADING_PHOTO_STEP_1", "91", "door.jpg", "image/jpeg", 1L)); + + var response = controller.upload(platform(), 91L, "PHOTO_STEP_1", file); + + assertThat(response.data()).extracting(LoadingEvidenceService.UploadResult::ownerScope, + LoadingEvidenceService.UploadResult::tenantId, LoadingEvidenceService.UploadResult::businessId) + .containsExactly("PLATFORM", 0L, "91"); + verify(service).upload(91L, 7L, "PHOTO_STEP_1", file); + } + + @Test + void rejectsMerchantBeforeAccessingContainerEvidence() { + assertThatThrownBy(() -> controller.download(merchant(), 91L, 101L)) + .isInstanceOf(BizException.class).hasMessageContaining("平台端身份"); + } + + @Test + void platformEndpointsExcludePdaWorkPermission() throws Exception { + var upload = PlatformLoadingEvidenceController.class.getMethod("upload", AuthenticatedUser.class, + long.class, String.class, MultipartFile.class); + var download = PlatformLoadingEvidenceController.class.getMethod("download", AuthenticatedUser.class, + long.class, long.class); + + assertThat(upload.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:firstleg:inbound:loading:edit')") + .doesNotContain("pda:work"); + assertThat(download.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:firstleg:inbound:loading:view')") + .doesNotContain("pda:work"); + } + + private static AuthenticatedUser platform() { + return new AuthenticatedUser(7L, 0L, 0L, 7L, "operator", "PLATFORM", "PDA", Set.of()); + } + + private static AuthenticatedUser merchant() { + return new AuthenticatedUser(8L, 88L, 88L, 8L, "merchant", "MERCHANT", "WEB", Set.of()); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java b/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java new file mode 100644 index 000000000..681256990 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java @@ -0,0 +1,86 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.MeasurementImageService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.lang.reflect.Method; +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.mock; +import static org.mockito.Mockito.verify; + +@SpringJUnitConfig(PlatformMeasurementImageController.class) +class PlatformMeasurementImageControllerContractTest { + @MockBean + private MeasurementImageService images; + @Resource + private PlatformMeasurementImageController controller; + + @Test + void shouldHandleCase01() throws Exception { + RequestMapping base = PlatformMeasurementImageController.class.getAnnotation(RequestMapping.class); + assertThat(base.value()).containsExactly("/api/platform/v1/files/measurements"); + Method method = PlatformMeasurementImageController.class.getDeclaredMethod("upload", + AuthenticatedUser.class, long.class, long.class, org.springframework.web.multipart.MultipartFile.class); + assertThat(method.getAnnotation(PostMapping.class).consumes()) + .containsExactly(MediaType.MULTIPART_FORM_DATA_VALUE); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:firstleg:inbound:weight:edit')"); + } + + @Test + void shouldHandleCase02() { + var file = new MockMultipartFile("file", "weight.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1}); + var merchant = new AuthenticatedUser(2L, 10001L, 10001L, 2L, + "merchant", "MERCHANT", "WEB", Set.of()); + + assertThatThrownBy(() -> controller.upload(merchant, 10001L, 88L, file)) + .isInstanceOf(BizException.class); + } + + @Test + void shouldHandleCase03() { + var platform = new AuthenticatedUser(1L, 0L, 0L, 1L, + "admin", "PLATFORM", "WEB", Set.of()); + org.mockito.Mockito.when(images.download(10001L, 88L, 99L)).thenReturn( + new MeasurementImageService.Download("weight.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1, 2})); + + controller.download(platform, 10001L, 88L, 99L); + + verify(images).download(10001L, 88L, 99L); + } + + @Test + void rejectsPlatformTokenWithCustomerTenantScope() { + var file = new MockMultipartFile("file", "weight.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1}); + var malformed = new AuthenticatedUser(1L, 10001L, 10001L, 1L, + "admin", "PLATFORM", "WEB", Set.of()); + + assertThatThrownBy(() -> controller.upload(malformed, 10001L, 88L, file)) + .isInstanceOf(BizException.class); + } + + @Test + void thumbnailUsesViewPermission() throws Exception { + Method method = PlatformMeasurementImageController.class.getDeclaredMethod("thumbnail", + AuthenticatedUser.class, long.class, long.class, long.class); + + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/{tenantId}/{inboundId}/{fileId}/thumbnail"); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:firstleg:inbound:measure:view')"); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java b/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java new file mode 100644 index 000000000..eaff89224 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java @@ -0,0 +1,103 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.ProductImageService; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +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.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProductImageControllerContractTest { + @Test + void merchantRouteUsesMultipartAndOrderPermissions() throws Exception { + RequestMapping mapping = MerchantProductImageController.class.getAnnotation(RequestMapping.class); + assertThat(mapping.value()).contains("/api/merchant/v1/files/product-images"); + var upload = MerchantProductImageController.class.getDeclaredMethod( + "upload", AuthenticatedUser.class, org.springframework.web.multipart.MultipartFile.class); + assertThat(upload.getAnnotation(PostMapping.class).consumes()) + .contains(MediaType.MULTIPART_FORM_DATA_VALUE); + assertThat(upload.getAnnotation(PreAuthorize.class).value()) + .contains("merchant:order:create").contains("merchant:order:edit"); + } + + @Test + void merchantUploadAndDownloadAlwaysUseAuthenticatedCustomer() { + ProductImageService service = mock(ProductImageService.class); + MerchantProductImageController controller = new MerchantProductImageController(); + ReflectionTestUtils.setField(controller, "images", service); + var user = merchant(10001L); + var file = new MockMultipartFile("file", "product.png", "image/png", new byte[] {1}); + var uploaded = new ProductImageService.UploadResult(31L, "product.png", "image/png", 1L); + when(service.upload(10001L, 2L, file)).thenReturn(uploaded); + when(service.download(10001L, 31L)).thenReturn( + new ProductImageService.Download("product.png", "image/png", new byte[] {1})); + + assertThat(controller.upload(user, file).data()).isEqualTo(uploaded); + assertThat(controller.download(user, 31L).getBody()).containsExactly(1); + verify(service).upload(10001L, 2L, file); + verify(service).download(10001L, 31L); + } + + @Test + void internalValidationRequiresSharedTokenAndForwardsCustomerScope() { + ProductImageService service = mock(ProductImageService.class); + InternalProductImageController controller = new InternalProductImageController(); + ReflectionTestUtils.setField(controller, "images", service); + ReflectionTestUtils.setField(controller, "internalToken", "token"); + controller.initializeToken(); + var metadata = new ProductImageService.UploadResult(31L, "product.png", "image/png", 1L); + when(service.validate(10001L, 31L)).thenReturn(metadata); + + assertThat(controller.validate("token", 10001L, 31L).data()).isEqualTo(metadata); + assertThatThrownBy(() -> controller.validate("wrong", 10001L, 31L)) + .isInstanceOf(BizException.class).hasMessageContaining("凭证无效"); + verify(service).validate(10001L, 31L); + } + + @Test + void rejectsPlatformOrMalformedIdentityBeforeFileAccess() { + ProductImageService service = mock(ProductImageService.class); + MerchantProductImageController controller = new MerchantProductImageController(); + ReflectionTestUtils.setField(controller, "images", service); + var platform = new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of()); + var file = new MockMultipartFile("file", "product.png", "image/png", new byte[] {1}); + + assertThatThrownBy(() -> controller.upload(platform, file)).isInstanceOf(BizException.class); + } + + @Test + void platformDownloadUsesTenantScopeAndOrderListPermission() throws Exception { + RequestMapping mapping = PlatformProductImageController.class.getAnnotation(RequestMapping.class); + assertThat(mapping.value()).contains("/api/platform/v1/files/product-images"); + var download = PlatformProductImageController.class.getDeclaredMethod( + "download", AuthenticatedUser.class, long.class, long.class); + assertThat(download.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:order:list')"); + + ProductImageService service = mock(ProductImageService.class); + PlatformProductImageController controller = new PlatformProductImageController(); + ReflectionTestUtils.setField(controller, "images", service); + when(service.download(10001L, 31L)).thenReturn( + new ProductImageService.Download("product.png", "image/png", new byte[] {1})); + + var platform = new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of()); + assertThat(controller.download(platform, 10001L, 31L).getBody()).containsExactly(1); + verify(service).download(10001L, 31L); + } + + private static AuthenticatedUser merchant(long tenantId) { + return new AuthenticatedUser(2L, tenantId, tenantId, 2L, "merchant", "MERCHANT", "WEB", Set.of()); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java new file mode 100644 index 000000000..1ea6be0ab --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java @@ -0,0 +1,366 @@ +package com.baiyue.file.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.security.AuthenticatedUser; +import com.baiyue.file.client.IamQualificationClient; +import com.baiyue.file.service.LocalQualificationStorage; +import com.baiyue.file.service.QualificationPackageService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; + +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Set; + +import jakarta.annotation.Resource; + +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.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("企业资质文件接口契约功能") +@SpringJUnitConfig(classes = {QualificationFileController.class, PlatformQualificationFileController.class}) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class QualificationFileContractTest { + @MockBean private LocalQualificationStorage defaultStorage; + @MockBean private IamQualificationClient defaultIamClient; + @MockBean private QualificationPackageService packageService; + @Resource private PlatformQualificationFileController platformController; + @Resource private QualificationFileController merchantController; + + @Test + void fileServiceExposesTheOnlyPublicMultipartUploadEntry() { + RequestMapping baseMapping = QualificationFileController.class.getAnnotation(RequestMapping.class); + assertTrue(Arrays.asList(baseMapping.value()) + .contains("/api/merchant/v1/files/qualifications")); + + boolean hasMultipartPost = Arrays.stream(QualificationFileController.class.getDeclaredMethods()) + .map(method -> method.getAnnotation(PostMapping.class)) + .filter(java.util.Objects::nonNull) + .flatMap(mapping -> Arrays.stream(mapping.consumes())) + .anyMatch(MediaType.MULTIPART_FORM_DATA_VALUE::equals); + + assertTrue(hasMultipartPost, "企业资质真实上传入口必须保持 multipart/form-data 契约"); + } + + @Test + void platformControllerExposesMultipartUploadForCustomerDrafts() { + RequestMapping mapping = PlatformQualificationFileController.class.getAnnotation(RequestMapping.class); + assertTrue(Arrays.asList(mapping.value()).contains("/api/platform/v1/files/qualifications")); + boolean hasMultipartPost = Arrays.stream(PlatformQualificationFileController.class.getDeclaredMethods()) + .map(method -> method.getAnnotation(PostMapping.class)) + .filter(java.util.Objects::nonNull) + .flatMap(post -> Arrays.stream(post.consumes())) + .anyMatch(MediaType.MULTIPART_FORM_DATA_VALUE::equals); + assertTrue(hasMultipartPost); + } + + @Test + void merchantAndPlatformExposeDedicatedRequiredPackageRoutes() { + assertTrue(hasMultipartRoute(QualificationFileController.class, "/required-package")); + assertTrue(hasMultipartRoute(PlatformQualificationFileController.class, + "/{tenantId}/required-package")); + } + + @Test + void requiredPackageLetsServiceReturnControlledValidationForMissingParts() { + assertRequiredPackageParts(QualificationFileController.class); + assertRequiredPackageParts(PlatformQualificationFileController.class); + } + + @Test + void platformQualificationPreviewUsesTheCustomerListPermission() throws Exception { + var method = PlatformQualificationFileController.class.getDeclaredMethod( + "download", AuthenticatedUser.class, long.class, String.class); + + assertEquals("hasAuthority('platform:customer:list')", method.getAnnotation(PreAuthorize.class).value()); + } + + @Test + void merchantSingleQualificationUploadRequiresTheUploadPermission() throws Exception { + var method = QualificationFileController.class.getDeclaredMethod( + "upload", AuthenticatedUser.class, org.springframework.web.multipart.MultipartFile.class, + String.class, String.class, LocalDate.class, boolean.class); + + assertEquals("hasAuthority('merchant:qualification:upload')", + method.getAnnotation(PreAuthorize.class).value()); + } + + @Test + void platformQualificationRejectsCustomerScopedPlatformToken() { + var storage = mock(LocalQualificationStorage.class); + var controller = platformController(storage, mock(IamQualificationClient.class)); + var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes()); + var malformed = new AuthenticatedUser(1L, 10001L, 10001L, 1L, + "admin", "PLATFORM", "WEB", Set.of()); + + assertThrows(BizException.class, + () -> controller.upload(malformed, 10001L, file, + "BANK_ACCOUNT_LICENSE", null, null, false)); + verify(storage, never()).store(any(Long.class), any()); + } + + @Test + void platformQualificationRejectsPlatformScopeAsCustomerTarget() { + var storage = mock(LocalQualificationStorage.class); + var controller = platformController(storage, mock(IamQualificationClient.class)); + var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes()); + + assertThrows(BizException.class, + () -> controller.upload(platformUser(), 0L, file, + "BANK_ACCOUNT_LICENSE", null, null, false)); + verify(storage, never()).store(any(Long.class), any()); + } + + @Test + void platformReplacementIsForwardedAndCleansThePreviousFileAfterIamSucceeds() { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var controller = platformController(storage, iamClient); + var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes()); + var stored = new LocalQualificationStorage.StoredFile(Path.of("new.pdf"), "new.pdf", + "/api/merchant/v1/files/qualifications/11111111-1111-1111-1111-111111111111.pdf"); + var oldUrl = "/api/merchant/v1/files/qualifications/22222222-2222-2222-2222-222222222222.pdf"; + when(storage.store(10001L, file)).thenReturn(stored); + when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new IamQualificationClient.PlatformQualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl))); + + var response = controller.upload(platformUser(), 10001L, file, "BANK_ACCOUNT_LICENSE", null, + LocalDate.of(2030, 12, 31), true); + + var ordered = inOrder(storage, iamClient); + ordered.verify(storage).store(10001L, file); + var requestCaptor = org.mockito.ArgumentCaptor.forClass( + IamQualificationClient.PlatformQualificationRequest.class); + ordered.verify(iamClient).submitPlatform(eq("internal-token"), requestCaptor.capture()); + ordered.verify(storage).deleteByDocumentUrl(10001L, oldUrl); + assertTrue(requestCaptor.getValue().replace()); + assertEquals(stored.documentUrl(), requestCaptor.getValue().documentUrl()); + assertEquals(stored.documentUrl(), response.data().documentUrl()); + } + + @Test + void platformReplacementNeverDeletesTheCurrentUpload() { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var controller = platformController(storage, iamClient); + var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes()); + var currentUrl = "/api/merchant/v1/files/qualifications/11111111-1111-1111-1111-111111111111.pdf"; + var stored = new LocalQualificationStorage.StoredFile(Path.of("new.pdf"), "new.pdf", currentUrl); + when(storage.store(10001L, file)).thenReturn(stored); + when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new IamQualificationClient.PlatformQualificationResponse(31L, null, "PROFILE_REQUIRED", currentUrl))); + + controller.upload(platformUser(), 10001L, file, "BANK_ACCOUNT_LICENSE", null, null, true); + + verify(storage, never()).deleteByDocumentUrl(10001L, currentUrl); + } + + @Test + void platformTransportFailurePreservesTheFileBecauseTheIamOutcomeIsUnknown() { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var controller = platformController(storage, iamClient); + var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes()); + var stored = storedFile(); + when(storage.store(10001L, file)).thenReturn(stored); + when(iamClient.submitPlatform(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout")); + + var exception = assertThrows(BizException.class, + () -> controller.upload(platformUser(), 10001L, file, "BANK_ACCOUNT_LICENSE", null, null, true)); + + assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode()); + verify(storage, never()).delete(stored); + } + + @Test + void explicitPlatformBusinessRejectionDeletesTheUnreferencedFile() { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var controller = platformController(storage, iamClient); + var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes()); + var stored = storedFile(); + when(storage.store(10001L, file)).thenReturn(stored); + when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn( + ApiResponse.failure(ResultCode.CONFLICT, "duplicate", null, null)); + + var exception = assertThrows(BizException.class, + () -> controller.upload(platformUser(), 10001L, file, "BANK_ACCOUNT_LICENSE", null, null, false)); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(storage).delete(stored); + } + + @Test + void merchantTransportFailureAlsoPreservesTheFileForReconciliation() { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var controller = merchantController(storage, iamClient); + var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes()); + var stored = storedFile(); + when(storage.store(10001L, file)).thenReturn(stored); + when(iamClient.submit(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout")); + + var exception = assertThrows(BizException.class, + () -> controller.upload(merchantUser(), file, "FREIGHT_FORWARDER_CERTIFICATE", null, null, false)); + + assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode()); + verify(storage, never()).delete(stored); + } + + @Test + void merchantLegalPersonIdReplacementUsesTheAuthenticatedTenantAndCleansTheReplacedFile() { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var controller = merchantController(storage, iamClient); + var file = new MockMultipartFile("file", "legal-person.png", "image/png", new byte[] {1, 2, 3}); + var stored = storedFile(); + var oldUrl = "/api/merchant/v1/files/qualifications/22222222-2222-2222-2222-222222222222.png"; + when(storage.store(10001L, file)).thenReturn(stored); + when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new IamQualificationClient.QualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl))); + + controller.upload(merchantUser(), file, "LEGAL_PERSON_ID", null, LocalDate.of(2030, 1, 1), true); + + var requestCaptor = org.mockito.ArgumentCaptor.forClass(IamQualificationClient.QualificationRequest.class); + verify(iamClient).submit(eq("internal-token"), requestCaptor.capture()); + verify(storage).deleteByDocumentUrl(10001L, oldUrl); + assertEquals(10001L, requestCaptor.getValue().tenantId()); + assertEquals("LEGAL_PERSON_ID", requestCaptor.getValue().qualificationType()); + assertTrue(requestCaptor.getValue().replace()); + } + + @Test + void merchantQualificationRejectsAPlatformIdentityBeforeWritingTheFile() { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var controller = merchantController(storage, iamClient); + var file = new MockMultipartFile("file", "legal-person.pdf", "application/pdf", "%PDF-1.4".getBytes()); + + assertThrows(BizException.class, + () -> controller.upload(platformUser(), file, "LEGAL_PERSON_ID", null, null, true)); + + verify(storage, never()).store(any(Long.class), any()); + verify(iamClient, never()).submit(any(), any()); + } + + @Test + void merchantUploadRejectsMissingOrUnsupportedTypesBeforeWritingTheFile() { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var controller = merchantController(storage, iamClient); + var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes()); + + assertThrows(BizException.class, + () -> controller.upload(merchantUser(), file, null, null, null, false)); + assertThrows(BizException.class, + () -> controller.upload(merchantUser(), file, "UNKNOWN", null, null, false)); + + verify(storage, never()).store(any(Long.class), any()); + verify(iamClient, never()).submit(any(), any()); + } + + @Test + void everyOptionalQualificationTypeIsAcceptedAndForwarded() { + for (String qualificationType : Set.of( + "LEGAL_PERSON_ID", "FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE")) { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var controller = merchantController(storage, iamClient); + var file = new MockMultipartFile("file", "qualification.pdf", "application/pdf", "%PDF-1.4".getBytes()); + when(storage.store(10001L, file)).thenReturn(storedFile()); + when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new IamQualificationClient.QualificationResponse(31L, null, "PROFILE_REQUIRED", null))); + + controller.upload(merchantUser(), file, qualificationType, null, null, false); + + var requestCaptor = org.mockito.ArgumentCaptor.forClass( + IamQualificationClient.QualificationRequest.class); + verify(iamClient).submit(eq("internal-token"), requestCaptor.capture()); + assertEquals(qualificationType, requestCaptor.getValue().qualificationType()); + } + } + + @Test + void singleUploadStillRejectsTheBusinessLicenseBeforeWritingTheFile() { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var controller = merchantController(storage, iamClient); + var file = new MockMultipartFile("file", "qualification.pdf", "application/pdf", "%PDF-1.4".getBytes()); + + assertThrows(BizException.class, + () -> controller.upload(merchantUser(), file, "BUSINESS_LICENSE", null, null, false)); + + verify(storage, never()).store(any(Long.class), any()); + verify(iamClient, never()).submit(any(), any()); + } + + private PlatformQualificationFileController platformController( + LocalQualificationStorage storage, IamQualificationClient iamClient) { + var controller = platformController; + ReflectionTestUtils.setField(controller, "storage", storage); + ReflectionTestUtils.setField(controller, "iamClient", iamClient); + return controller; + } + + private static boolean hasMultipartRoute(Class controllerType, String route) { + return Arrays.stream(controllerType.getDeclaredMethods()) + .map(method -> method.getAnnotation(PostMapping.class)) + .filter(java.util.Objects::nonNull) + .anyMatch(mapping -> Arrays.asList(mapping.value()).contains(route) + && Arrays.asList(mapping.consumes()).contains(MediaType.MULTIPART_FORM_DATA_VALUE)); + } + + private static void assertRequiredPackageParts(Class controllerType) { + var method = Arrays.stream(controllerType.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals("uploadRequiredPackage")) + .findFirst().orElseThrow(); + var parts = Arrays.stream(method.getParameters()) + .map(parameter -> parameter.getAnnotation(RequestPart.class)) + .filter(java.util.Objects::nonNull) + .collect(java.util.stream.Collectors.toMap(RequestPart::value, RequestPart::required)); + assertEquals(Boolean.FALSE, parts.get("businessLicense")); + assertEquals(Boolean.FALSE, parts.get("legalPersonId")); + } + + private QualificationFileController merchantController( + LocalQualificationStorage storage, IamQualificationClient iamClient) { + var controller = merchantController; + ReflectionTestUtils.setField(controller, "storage", storage); + ReflectionTestUtils.setField(controller, "iamClient", iamClient); + return controller; + } + + private static LocalQualificationStorage.StoredFile storedFile() { + return new LocalQualificationStorage.StoredFile(Path.of("new.pdf"), "new.pdf", + "/api/merchant/v1/files/qualifications/11111111-1111-1111-1111-111111111111.pdf"); + } + + private static AuthenticatedUser platformUser() { + return new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of()); + } + + private static AuthenticatedUser merchantUser() { + return new AuthenticatedUser(2L, 10001L, 10001L, 2L, "merchant", "MERCHANT", "WEB", Set.of()); + } +} diff --git a/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java b/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java new file mode 100644 index 000000000..63c167e07 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java @@ -0,0 +1,70 @@ +package com.baiyue.file.mapper; + +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.apache.ibatis.annotations.Delete; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ExportMapperContractTest { + @Test void finalSuccessCommitIsGuardedByVersionLeaseAndDatabaseDeadline() throws Exception { + String sql=update(FileExportTaskMapper.class,"succeed"); + assertTrue(sql.contains("dispatch_version=#{dispatchVersion}")); + assertTrue(sql.contains("lease_owner=#{owner}")); + assertTrue(sql.contains("execution_deadline_at>CURRENT_TIMESTAMP(3)")); + } + + @Test void sentRedispatchKeepsTheSameVersionAndExcludesQuarantinedRows() throws Exception { + String sql=update(FileExportOutboxMapper.class,"redispatchSent"); + assertTrue(sql.contains("o.status='SENT'")); + assertTrue(sql.contains("t.dispatch_version=o.dispatch_version")); + assertTrue(sql.contains("WHERE id IN (SELECT id FROM (SELECT o.id")); + assertTrue(sql.contains("ORDER BY o.sent_at,o.id LIMIT 20")); + assertFalse(sql.contains("SET o.status")); + assertFalse(sql.contains("dispatch_version=dispatch_version+1")); + assertFalse(sql.contains("QUARANTINED")); + } + + @Test void missingOutboxDetectionUsesDatabaseTimeAndLetsAnyExistingStatusBlockDuplication() throws Exception { + String sql=select(FileExportTaskMapper.class,"missingOutbox"); + assertTrue(sql.contains("CURRENT_TIMESTAMP(3)")); + assertTrue(sql.contains("NOT EXISTS")); + assertTrue(sql.contains("o.dispatch_version=file_export_task.dispatch_version")); + assertFalse(sql.contains("o.status")); + } + + @Test void retentionCleanupCanOnlyDeleteOldSentRows() throws Exception { + Method target=java.util.Arrays.stream(FileExportOutboxMapper.class.getDeclaredMethods()).filter(candidate -> candidate.getName().equals("deleteRetainedSent")).findFirst().orElseThrow(); + String sql=String.join(" ",target.getAnnotation(Delete.class).value()).replaceAll("\\s+"," "); + assertTrue(sql.contains("status='SENT'")); + assertTrue(sql.contains("CURRENT_TIMESTAMP(3)")); + assertFalse(sql.contains("PENDING")); + assertFalse(sql.contains("CLAIMED")); + } + + @Test void manualExpediteOnlyAdvancesTheCurrentPendingBackoffMessage() throws Exception { + String taskSql=update(FileExportTaskMapper.class,"expeditePendingRetry"); + String outboxSql=update(FileExportOutboxMapper.class,"expeditePendingRetry"); + for (String sql : java.util.List.of(taskSql,outboxSql)) { + assertTrue(sql.contains("dispatch_version=#{dispatchVersion}")); + assertTrue(sql.contains("status='PENDING'")); + assertTrue(sql.contains("next_retry_at>CURRENT_TIMESTAMP(3)")); + assertFalse(sql.contains("dispatch_version=dispatch_version+1")); + } + assertTrue(taskSql.contains("attempt_count>0")); + assertTrue(outboxSql.contains("task_id=#{taskId}")); + } + + private String update(Class type,String method) throws Exception { + Method target=java.util.Arrays.stream(type.getDeclaredMethods()).filter(candidate -> candidate.getName().equals(method)).findFirst().orElseThrow(); + return String.join(" ",target.getAnnotation(Update.class).value()).replaceAll("\\s+"," "); + } + private String select(Class type,String method) throws Exception { + Method target=java.util.Arrays.stream(type.getDeclaredMethods()).filter(candidate -> candidate.getName().equals(method)).findFirst().orElseThrow(); + return String.join(" ",target.getAnnotation(Select.class).value()).replaceAll("\\s+"," "); + } +} diff --git a/file/src/test/java/com/baiyue/file/mq/ExportCommandConsumerTest.java b/file/src/test/java/com/baiyue/file/mq/ExportCommandConsumerTest.java new file mode 100644 index 000000000..80c82a301 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/mq/ExportCommandConsumerTest.java @@ -0,0 +1,57 @@ +package com.baiyue.file.mq; + +import com.baiyue.file.service.ExportTaskExecutor; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer; +import org.apache.rocketmq.common.message.MessageExt; +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.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@SpringJUnitConfig(classes = {ExportCommandConsumer.class, ExportCommandConsumerTest.TestConfig.class}) +@TestPropertySource(properties = { + "baiyue.file.export.consumer.enabled=true", + "baiyue.file.export.instance-id=file-1", + "baiyue.file.export.consumer.concurrency=4", + "baiyue.file.export.consumer.consume-timeout-minutes=90", + "baiyue.file.export.consumer.max-reconsume-times=2" +}) +class ExportCommandConsumerTest { + @MockBean private ExportTaskExecutor executor; + @Resource private ExportCommandConsumer consumer; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + } + + @Test void appliesConfiguredConcurrencyAndDispatchesAValidCommand() { + DefaultMQPushConsumer pushConsumer=new DefaultMQPushConsumer(); + consumer.prepareStart(pushConsumer); + assertEquals(4,pushConsumer.getConsumeThreadMin()); + assertEquals(4,pushConsumer.getConsumeThreadMax()); + assertEquals(90,pushConsumer.getConsumeTimeout()); + assertEquals(2,pushConsumer.getMaxReconsumeTimes()); + + MessageExt message=new MessageExt(); + message.setBody("{\"schemaVersion\":1,\"eventId\":\"event-1\",\"taskId\":\"9\",\"exportType\":\"CUSTOMER_LIST\",\"dispatchVersion\":3}".getBytes(StandardCharsets.UTF_8)); + consumer.onMessage(message); + verify(executor).execute(9L,3,"file-1"); + } + + @Test void rejectsMalformedMessagesSoRocketMqCanRedeliverThem() { + MessageExt message=new MessageExt();message.setBody("not-json".getBytes(StandardCharsets.UTF_8)); + assertThrows(IllegalArgumentException.class,() -> consumer.onMessage(message)); + } +} diff --git a/file/src/test/java/com/baiyue/file/mq/ExportDlqHandlerTest.java b/file/src/test/java/com/baiyue/file/mq/ExportDlqHandlerTest.java new file mode 100644 index 000000000..4134adc1a --- /dev/null +++ b/file/src/test/java/com/baiyue/file/mq/ExportDlqHandlerTest.java @@ -0,0 +1,46 @@ +package com.baiyue.file.mq; + +import com.baiyue.file.service.ExportStateService; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.Resource; +import org.apache.rocketmq.common.message.MessageExt; +import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; +import org.junit.jupiter.api.Test; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(classes = {ExportDlqHandler.class, ExportDlqHandlerTest.TestConfig.class}) +@TestPropertySource(properties = "baiyue.file.export.consumer.enabled=true") +class ExportDlqHandlerTest { + @MockBean private ExportStateService states; + @Resource private ExportDlqHandler handler; + @Resource private MeterRegistry meters; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean MeterRegistry meterRegistry() { return new SimpleMeterRegistry(); } + } + @Test void databaseFailuresCannotFallIntoAnUnconsumedSecondaryDlq() { + RocketMQMessageListener listener=ExportDlqHandler.class.getAnnotation(RocketMQMessageListener.class); + org.junit.jupiter.api.Assertions.assertEquals(Integer.MAX_VALUE,listener.maxReconsumeTimes()); + } + @Test void quarantinesUsingUserPropertiesWithoutParsingTheBody() { + MessageExt message=new MessageExt(); + message.putUserProperty("eventId","event-1");message.putUserProperty("taskId","9");message.putUserProperty("dispatchVersion","3");message.putUserProperty("schemaVersion","1"); + handler.onMessage(message); + verify(states).quarantine(eq("event-1"),eq(9L),eq(3),contains("自动隔离")); + org.junit.jupiter.api.Assertions.assertEquals(1,meters.counter("export_dlq_messages").count()); + } + @Test void malformedPropertiesAreAcknowledgedWithoutTouchingTasks() { + double before = meters.counter("export_dlq_messages").count(); + handler.onMessage(new MessageExt()); + verifyNoInteractions(states); + org.junit.jupiter.api.Assertions.assertEquals(before + 1, meters.counter("export_dlq_messages").count()); + } +} diff --git a/file/src/test/java/com/baiyue/file/mq/ExportOutboxPublisherTest.java b/file/src/test/java/com/baiyue/file/mq/ExportOutboxPublisherTest.java new file mode 100644 index 000000000..4fb15cb82 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/mq/ExportOutboxPublisherTest.java @@ -0,0 +1,74 @@ +package com.baiyue.file.mq; + +import com.baiyue.file.entity.FileExportOutbox; +import com.baiyue.file.repository.ExportOutboxRepository; +import jakarta.annotation.Resource; +import org.apache.rocketmq.spring.core.RocketMQTemplate; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.messaging.Message; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(ExportOutboxPublisher.class) +@TestPropertySource(properties = { + "baiyue.file.export.publisher.enabled=true", + "baiyue.file.export.instance-id=file-1", + "baiyue.file.export.publisher.batch-size=50", + "baiyue.file.export.publisher.lease-seconds=60" +}) +class ExportOutboxPublisherTest { + @MockBean private ExportOutboxRepository outbox; + @MockBean private RocketMQTemplate rocket; + @Resource private ExportOutboxPublisher publisher; + + @Test void publishesAllDlqLocatorFieldsAsRocketMqUserProperties() { + FileExportOutbox row=row(); + when(outbox.publishableIds(50)).thenReturn(List.of(1L));when(outbox.claim(1L,"file-1",60)).thenReturn(true);when(outbox.find(1L)).thenReturn(row); + + publisher.publish(); + + @SuppressWarnings("rawtypes") ArgumentCaptor message=ArgumentCaptor.forClass(Message.class); + verify(rocket).syncSend(eq("test-export:EXPORT_TASK"),message.capture(),eq(3000L)); + assertEquals("event-1",message.getValue().getHeaders().get("eventId")); + assertEquals("9",message.getValue().getHeaders().get("taskId")); + assertEquals("3",message.getValue().getHeaders().get("dispatchVersion")); + assertEquals("1",message.getValue().getHeaders().get("schemaVersion")); + verify(outbox).sent(1L,"file-1"); + } + + @Test void returnsFailedSendsToPendingWithTheFirstBackoffInsteadOfGivingUp() { + FileExportOutbox row=row();row.setAttemptCount(1); + when(outbox.publishableIds(50)).thenReturn(List.of(1L));when(outbox.claim(1L,"file-1",60)).thenReturn(true);when(outbox.find(1L)).thenReturn(row); + doThrow(new RuntimeException("broker down")).when(rocket).syncSend(anyString(),any(Message.class),anyLong()); + + publisher.publish(); + + verify(outbox).sendFailed(1L,"file-1",5,"broker down"); + verify(outbox,never()).sent(anyLong(),anyString()); + } + + @Test void stillRetriesAfterThirtyConsecutiveBrokerFailuresWithoutADeadState() { + FileExportOutbox row=row();row.setAttemptCount(30); + when(outbox.publishableIds(50)).thenReturn(List.of(1L)); + when(outbox.claim(1L,"file-1",60)).thenReturn(true); + when(outbox.find(1L)).thenReturn(row); + doThrow(new RuntimeException("broker down")).when(rocket).syncSend(anyString(),any(Message.class),anyLong()); + for(int i=0;i<30;i++) publisher.publish(); + + verify(outbox,times(30)).sendFailed(1L,"file-1",900,"broker down"); + verify(outbox,never()).sent(anyLong(),anyString()); + } + + private FileExportOutbox row(){ + FileExportOutbox row=new FileExportOutbox();row.setId(1L);row.setEventId("event-1");row.setTaskId(9L);row.setDispatchVersion(3); + row.setTopic("test-export");row.setTag("EXPORT_TASK");row.setMessageKey("9");row.setPayloadJson("{}");row.setAttemptCount(1);return row; + } +} diff --git a/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java b/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java new file mode 100644 index 000000000..a08d53383 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java @@ -0,0 +1,146 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +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.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class BusinessFileStorageTest { + @TempDir Path root; + + @Test void arbitraryCustomsFormatIsStoredAsAttachmentObject() { + BusinessFileStorage storage = storage(); + var file = new MockMultipartFile("file", "folder/manifest.custom", "application/x-custom", new byte[]{1, 2, 3}); + + var stored = storage.store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true); + + assertThat(stored.originalName()).isEqualTo("manifest.custom"); + assertThat(stored.contentType()).isEqualTo("application/x-custom"); + assertThat(storage.read(stored.objectKey())).containsExactly(1, 2, 3); + } + + @Test void customsFileAboveFiftyMegabytesIsRejectedBeforeRead() { + BusinessFileStorage storage = storage(); + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(BusinessFileCategory.CUSTOMS_MAX_SIZE + 1); + + assertThatThrownBy(() -> storage.store(10001L, 51L, file, + BusinessFileCategory.CUSTOMS_MAX_SIZE, true)) + .isInstanceOf(BizException.class).hasMessageContaining("50MB"); + } + + @Test void rejectsZeroByteAndAcceptsExactFiftyMibArbitraryBinary() throws Exception { + BusinessFileStorage storage = storage(); + var empty = new MockMultipartFile("file", "empty.bin", "application/octet-stream", new byte[0]); + assertThatThrownBy(() -> storage.store(10001L, 61L, empty, + BusinessFileCategory.CUSTOMS_MAX_SIZE, true)) + .isInstanceOf(BizException.class).hasMessageContaining("不能为空"); + + MultipartFile boundary = new SparseMultipartFile("exact-50m.bin", "application/x-booking-binary", + BusinessFileCategory.CUSTOMS_MAX_SIZE); + var stored = storage.store(10001L, 61L, boundary, + BusinessFileCategory.CUSTOMS_MAX_SIZE, true); + + assertThat(stored.size()).isEqualTo(BusinessFileCategory.CUSTOMS_MAX_SIZE); + assertThat(stored.contentType()).isEqualTo("application/x-booking-binary"); + assertThat(Files.size(root.resolve(stored.objectKey()))) + .isEqualTo(BusinessFileCategory.CUSTOMS_MAX_SIZE); + } + + @Test void preservesUnicodeSpacesAndSpecialCharactersWithoutSplittingSurrogatePairs() { + BusinessFileStorage storage = storage(); + String original = "folder\\" + "超".repeat(280) + " 中文 空格 #[]()+,🚢.xlsx "; + var file = new MockMultipartFile("file", original, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1, 2}); + + var stored = storage.store(10001L, 61L, file, + BusinessFileCategory.CUSTOMS_MAX_SIZE, true); + + assertThat(stored.originalName()).endsWith(" 中文 空格 #[]()+,🚢.xlsx").contains("🚢"); + assertThat(stored.originalName()).doesNotContain("/", "\\", "\r", "\n"); + assertThat(stored.originalName().codePointCount(0, stored.originalName().length())).isEqualTo(255); + } + + @Test void legacyBusinessFilesRetainRestrictedFormatsAndTenMegabyteLimit() { + BusinessFileStorage storage = storage(); + var file = new MockMultipartFile("file", "archive.zip", "application/zip", new byte[]{1}); + + assertThatThrownBy(() -> storage.store(10001L, 51L, file, + BusinessFileCategory.STANDARD_MAX_SIZE, false)) + .isInstanceOf(BizException.class).hasMessageContaining("仅支持"); + } + + @Test void orderBatchCategoriesAcceptOnlyRealXlsxWithinFiveMegabytes() { + BusinessFileStorage storage = storage(); + var xlsx = new MockMultipartFile("file", "orders.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + new byte[]{0x50, 0x4b, 0x03, 0x04, 1, 2}); + + var stored = storage.store(10001L, "OBI-0123456789ABCDEF0123", xlsx, + BusinessFileCategory.ORDER_BATCH_IMPORT_SOURCE); + + assertThat(stored.objectKey()).endsWith(".xlsx"); + assertThat(stored.contentType()).isEqualTo(xlsx.getContentType()); + + var wrongContent = new MockMultipartFile("file", "orders.xlsx", "application/octet-stream", + new byte[]{0x50, 0x4b, 0x03, 0x04}); + assertThatThrownBy(() -> storage.store(10001L, "OBI-0123456789ABCDEF0123", wrongContent, + BusinessFileCategory.ORDER_BATCH_IMPORT_SOURCE)) + .isInstanceOf(BizException.class).hasMessageContaining("仅支持XLSX"); + + var fakeXlsx = new MockMultipartFile("file", "orders.xlsx", xlsx.getContentType(), new byte[]{1, 2, 3, 4}); + assertThatThrownBy(() -> storage.store(10001L, "OBI-0123456789ABCDEF0123", fakeXlsx, + BusinessFileCategory.ORDER_BATCH_IMPORT_ERROR_REPORT)) + .isInstanceOf(BizException.class).hasMessageContaining("不是有效XLSX"); + + MultipartFile tooLarge = new SparseMultipartFile("orders.xlsx", xlsx.getContentType(), + BusinessFileCategory.ORDER_IMPORT_MAX_SIZE + 1); + assertThatThrownBy(() -> storage.store(10001L, "OBI-0123456789ABCDEF0123", tooLarge, + BusinessFileCategory.ORDER_BATCH_IMPORT_SOURCE)) + .isInstanceOf(BizException.class).hasMessageContaining("5MB"); + } + + private BusinessFileStorage storage() { + BusinessFileStorage storage = new BusinessFileStorage(); + ReflectionTestUtils.setField(storage, "configuredRoot", root); + storage.initializeRoot(); + return storage; + } + + private record SparseMultipartFile(String originalFilename, String contentType, long size) + implements MultipartFile { + @Override public String getName() { return "file"; } + @Override public String getOriginalFilename() { return originalFilename; } + @Override public String getContentType() { return contentType; } + @Override public boolean isEmpty() { return size == 0; } + @Override public long getSize() { return size; } + @Override public byte[] getBytes() { throw new UnsupportedOperationException(); } + @Override public InputStream getInputStream() { + return new ByteArrayInputStream(new byte[0]); + } + @Override public void transferTo(File destination) throws IOException { + try (RandomAccessFile output = new RandomAccessFile(destination, "rw")) { + output.setLength(size); + } + } + @Override public void transferTo(Path destination) throws IOException { + transferTo(destination.toFile()); + } + } +} diff --git a/file/src/test/java/com/baiyue/file/service/DeliveryBusinessFileTest.java b/file/src/test/java/com/baiyue/file/service/DeliveryBusinessFileTest.java new file mode 100644 index 000000000..0d562b5b1 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/DeliveryBusinessFileTest.java @@ -0,0 +1,98 @@ +package com.baiyue.file.service; + +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import com.baiyue.file.controller.InternalBusinessFileController; +import com.baiyue.common.core.exception.BizException; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicLong; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class DeliveryBusinessFileTest { + @TempDir Path root; + @BeforeAll static void metadata() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "delivery-files"), FileObjectEntity.class); + } + + @ParameterizedTest + @ValueSource(strings={"DELIVERY_LOADING_PHOTO","DELIVERY_BOL","DELIVERY_RECEIPT"}) + void immutableUploadsPreserveOldContentAndRealTimestamp(String type) { + var storage = storage(); + var files = mock(FileObjectMapper.class); + var service = service(files, storage); + var ids = new AtomicLong(800); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> { + FileObjectEntity row = call.getArgument(0); row.setId(ids.incrementAndGet()); return 1; + }); + var first = service.upload(11, type, "71", 9, new MockMultipartFile("file", "资料 🚢.bin", "application/octet-stream", new byte[]{0,1,2})); + var second = service.upload(11, type, "71", 9, new MockMultipartFile("file", "资料 🚢.bin", "application/octet-stream", new byte[]{3,4})); + var captor = org.mockito.ArgumentCaptor.forClass(FileObjectEntity.class); + verify(files, times(2)).insert(captor.capture()); + assertThat(first.fileId()).isNotEqualTo(second.fileId()); + assertThat(first.createdAt()).isEqualTo(captor.getAllValues().get(0).getCreatedAt()).isNotNull(); + assertThat(first.ownerScope()).isEqualTo("CUSTOMER"); + assertThat(storage.read(captor.getAllValues().get(0).getObjectKey())).containsExactly(0,1,2); + assertThat(storage.read(captor.getAllValues().get(1).getObjectKey())).containsExactly(3,4); + verify(files, never()).updateById(any(FileObjectEntity.class)); + } + + @ParameterizedTest + @ValueSource(strings={"DELIVERY_LOADING_PHOTO","DELIVERY_BOL","DELIVERY_RECEIPT"}) + void enforcesFiftyMibAndNonemptyBoundary(String type) throws Exception { + var storage = storage(); var category = BusinessFileCategory.require(type); + assertThatThrownBy(() -> storage.store(11,"71",new MockMultipartFile("file",new byte[0]),category)).isInstanceOf(BizException.class); + var file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); when(file.getSize()).thenReturn(52428801L); + assertThatThrownBy(() -> storage.store(11,"71",file,category)).isInstanceOf(BizException.class).hasMessageContaining("50MB"); + when(file.getSize()).thenReturn(52428800L); when(file.getContentType()).thenReturn("application/x-custom"); + when(file.getOriginalFilename()).thenReturn("boundary.custom"); + doAnswer(call -> { try (var out=new java.io.RandomAccessFile(((Path)call.getArgument(0)).toFile(),"rw")) { out.setLength(52428800L); } return null; }).when(file).transferTo(any(Path.class)); + var stored=storage.store(11,"71",file,category); + assertThat(java.nio.file.Files.size(root.resolve(stored.objectKey()))).isEqualTo(52428800L); + } + + @ParameterizedTest + @ValueSource(strings={"DELIVERY_LOADING_PHOTO","DELIVERY_BOL","DELIVERY_RECEIPT"}) + void downloadQueriesEveryOwnershipKeyAndRejectsUnauthorizedAccess(String type) { + var files=mock(FileObjectMapper.class); var storage=mock(BusinessFileStorage.class); var service=service(files,storage); + when(files.selectOne(any())).thenAnswer(call -> { + LambdaQueryWrapper query=call.getArgument(0); + assertThat(query.getSqlSegment()).contains("tenant_id", "business_type", "business_id", "id", "status", "deleted"); + assertThat(query.getParamNameValuePairs().values()).containsExactlyInAnyOrder(12L,type,"72",802L,"ACTIVE",false); + return null; + }); + assertThatThrownBy(() -> service.download(12,type,"72",802)).isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(0,type,"71",801)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(11,type,"not-an-id",801)).isInstanceOf(BizException.class); + var controller=new InternalBusinessFileController(); + ReflectionTestUtils.setField(controller,"expected","test-internal"); ReflectionTestUtils.setField(controller,"service",service); + assertThatThrownBy(() -> controller.download("bad",11,type,"71",801)).isInstanceOf(BizException.class); + verifyNoInteractions(storage); + } + @ParameterizedTest + @ValueSource(strings={"DELIVERY_CUSTOMER_DOCUMENT","DELIVERY_BILL_OF_LADING","DELIVERY_POD","DELIVERY_PHOTO","DELIVERY_EXPRESS_RECEIPT"}) + void rejectsRemovedCategoriesWithoutAliases(String type) { + assertThatThrownBy(() -> BusinessFileCategory.require(type)) + .isInstanceOf(BizException.class).hasMessageContaining("不支持的附件业务类型"); + } + + private BusinessFileStorage storage() { + var storage=new BusinessFileStorage(); ReflectionTestUtils.setField(storage,"configuredRoot",root); storage.initializeRoot(); return storage; + } + private InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) { + var service=new InternalBusinessFileService(); ReflectionTestUtils.setField(service,"files",files); ReflectionTestUtils.setField(service,"storage",storage); return service; + } +} diff --git a/file/src/test/java/com/baiyue/file/service/DeliveryFileContractTest.java b/file/src/test/java/com/baiyue/file/service/DeliveryFileContractTest.java new file mode 100644 index 000000000..4cc89e060 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/DeliveryFileContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; + +class DeliveryFileContractTest { + @Test + @SuppressWarnings("unchecked") + void freezesThreeOrderedCategoriesAndMatchesRuntimePolicy() throws Exception { + String text = Files.readString(Path.of("src/main/resources/openapi/delivery-files-v1.yaml")); + Map yaml = new Yaml().load(text); + Map info = (Map) yaml.get("info"); + Map mapping = (Map) info.get("x-category-mapping"); + assertThat(mapping.keySet()).containsExactly("LOADING_PHOTO", "BOL", "RECEIPT"); + List types = List.of("DELIVERY_LOADING_PHOTO", "DELIVERY_BOL", "DELIVERY_RECEIPT"); + assertThat(mapping.values()).containsExactlyElementsOf(types); + Map titles = (Map) info.get("x-category-titles"); + assertThat(titles.keySet()).containsExactlyElementsOf(mapping.keySet()); + assertThat(titles.values()).containsExactly("装车照片", "BOL文件(含签名)", "签收文件(POD签收/快递签收)"); + Map components = (Map) yaml.get("components"); + Map parameters = (Map) components.get("parameters"); + Map type = (Map) parameters.get("Type"); + Map schema = (Map) type.get("schema"); + assertThat((List) schema.get("enum")).containsExactlyElementsOf(types); + assertThat(Arrays.stream(BusinessFileCategory.values()).map(Enum::name) + .filter(name -> name.startsWith("DELIVERY_")).toList()).containsExactlyElementsOf(types); + assertThat(text).contains("totalCategoryCount固定3", "非空类别x/3", "每类0至20", "不强制必传", "不增加完成门禁或签名识别"); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/ExportReconcilerTest.java b/file/src/test/java/com/baiyue/file/service/ExportReconcilerTest.java new file mode 100644 index 000000000..c316aa08d --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/ExportReconcilerTest.java @@ -0,0 +1,61 @@ +package com.baiyue.file.service; + +import com.baiyue.file.entity.FileExportTask; +import com.baiyue.file.repository.ExportOutboxRepository; +import com.baiyue.file.repository.ExportTaskRepository; +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.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; + +import java.time.Duration; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(classes = {ExportReconciler.class, ExportReconcilerTest.TestConfig.class}) +@TestPropertySource(properties = { + "baiyue.file.export.reconciler.enabled=true", + "baiyue.file.export.reconciler.redispatch-grace-seconds=60", + "baiyue.file.export.reconciler.sent-stale-seconds=600", + "baiyue.file.export.retention-days=7" +}) +class ExportReconcilerTest { + @MockBean private ExportTaskRepository tasks; + @MockBean private ExportOutboxRepository outbox; + @MockBean private ExportStateService states; + @MockBean private ExportStorage storage; + @Resource private ExportReconciler reconciler; + @Test void performsEveryRecoveryResponsibilityWithoutExecutingBusinessExports() { + FileExportTask missing=task(1L,"PENDING"), stale=task(2L,"RUNNING"), expired=task(3L,"SUCCEEDED");expired.setObjectKey("exports/3/file.xlsx"); + when(outbox.releaseExpiredClaims()).thenReturn(2);when(outbox.redispatchSent(600,60)).thenReturn(3); + when(tasks.missingOutbox(60)).thenReturn(List.of(missing));when(states.ensureOutbox(missing)).thenReturn(true); + when(tasks.staleRunning()).thenReturn(List.of(stale));when(states.recoverStale(stale)).thenReturn(true); + when(tasks.expiredArtifactsDb()).thenReturn(List.of(expired));when(storage.delete(expired.getObjectKey())).thenReturn(true); + when(storage.cleanupOrphans(eq(Duration.ofHours(24)),any())).thenReturn(1); + + reconciler.reconcile(); + + verify(outbox).releaseExpiredClaims();verify(outbox).redispatchSent(600,60); + verify(states).ensureOutbox(missing);verify(states).recoverStale(stale);verify(tasks).markExpired(3L); + verify(storage).cleanupOrphans(eq(Duration.ofHours(24)),any()); + } + + @Test void deletesOnlyTheConfiguredDailyBatchOfRetainedSentOutboxRows() { + reconciler.cleanupRetainedOutbox(); + verify(outbox).deleteRetainedSent(7,5000); + } + + private FileExportTask task(long id,String status){FileExportTask task=new FileExportTask();task.setId(id);task.setStatus(status);return task;} + + @Configuration + static class TestConfig { + @Bean SimpleMeterRegistry meterRegistry() { return new SimpleMeterRegistry(); } + } +} diff --git a/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java new file mode 100644 index 000000000..7237343eb --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java @@ -0,0 +1,114 @@ +package com.baiyue.file.service; + +import com.baiyue.file.repository.ExportOutboxRepository; +import com.baiyue.file.repository.ExportTaskRepository; +import com.baiyue.file.entity.FileExportTask; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(ExportStateService.class) +class ExportStateServiceTest { + @MockBean private ExportTaskRepository tasks; + @MockBean private ExportOutboxRepository outbox; + @MockBean private ExportCommandFactory commands; + @MockBean private ExportMetrics metrics; + @Resource private ExportStateService service; + + @Test void creationLimitsAndDuplicateDetectionRunUnderTheDatabaseGuard() { + FileExportTask task=task(9L,1,"PENDING"); + task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setExportType("CUSTOMER_LIST");task.setParametersJson("{}"); + when(commands.create(task,1)).thenReturn(mock(com.baiyue.file.entity.FileExportOutbox.class)); + when(outbox.insert(any())).thenReturn(true); + + ExportStateService.CreationResult result=service.createConstrained(task); + + org.junit.jupiter.api.Assertions.assertTrue(result.created()); + InOrder order=inOrder(tasks,outbox); + order.verify(tasks).lockCreationGuard(); + order.verify(tasks).recentDuplicate(0L,"PLATFORM",7L,"CUSTOMER_LIST","{}"); + order.verify(tasks).activeForUser(0L,"PLATFORM",7L); + order.verify(tasks).activeForType("CUSTOMER_LIST"); + order.verify(tasks).insert(task); + order.verify(tasks).initializeDispatchTime(9L); + order.verify(outbox).insert(any()); + } + + @Test void duplicateCreationReturnsTheExistingTaskWithoutWritingAnotherOutbox() { + FileExportTask request=task(9L,1,"PENDING"), existing=task(8L,1,"PENDING"); + request.setTenantId(0L);request.setAppCode("PLATFORM");request.setRequestedBy(7L);request.setExportType("CUSTOMER_LIST");request.setParametersJson("{}"); + when(tasks.recentDuplicate(0L,"PLATFORM",7L,"CUSTOMER_LIST","{}")).thenReturn(existing); + + ExportStateService.CreationResult result=service.createConstrained(request); + + org.junit.jupiter.api.Assertions.assertFalse(result.created()); + org.junit.jupiter.api.Assertions.assertSame(existing,result.task()); + verify(tasks).lockCreationGuard(); + verify(tasks,never()).insert(any()); + verifyNoInteractions(outbox); + } + + @Test void activeTaskLimitsAreEnforcedAfterTakingTheCreationGuard() { + FileExportTask request=task(9L,1,"PENDING"); + request.setTenantId(0L);request.setAppCode("PLATFORM");request.setRequestedBy(7L);request.setExportType("CUSTOMER_LIST");request.setParametersJson("{}"); + when(tasks.activeForUser(0L,"PLATFORM",7L)).thenReturn(3L); + + assertThrows(com.baiyue.common.core.exception.BizException.class,() -> service.createConstrained(request)); + + InOrder order=inOrder(tasks); + order.verify(tasks).lockCreationGuard(); + order.verify(tasks).recentDuplicate(0L,"PLATFORM",7L,"CUSTOMER_LIST","{}"); + order.verify(tasks).activeForUser(0L,"PLATFORM",7L); + verify(tasks,never()).insert(any()); + verifyNoInteractions(outbox); + } + + @Test void quarantineRequiresOutboxAndTaskToChangeAtomically() { + when(tasks.find(9L)).thenReturn(task(9L,3,"PENDING")); + when(outbox.quarantine("event-1",9L,3,"broken")).thenReturn(true); + when(tasks.failQuarantined(9L,3,"broken")).thenReturn(false); + + assertThrows(IllegalStateException.class,() -> service.quarantine("event-1",9L,3,"broken")); + verify(tasks).failQuarantined(9L,3,"broken"); + } + + @Test void oldDlqMessageThatMatchesNoOutboxDoesNotTouchTheCurrentTask() { + when(tasks.find(9L)).thenReturn(task(9L,3,"PENDING")); + when(outbox.quarantine(anyString(),anyLong(),anyInt(),anyString())).thenReturn(false); + + assertDoesNotThrow(() -> service.quarantine("old-event",9L,2,"broken")); + verify(tasks).find(9L); + verifyNoMoreInteractions(tasks); + } + + @Test void matchingDlqMessageDoesNotDamageAnAlreadyRunningTask() { + when(tasks.find(9L)).thenReturn(task(9L,3,"RUNNING")); + + assertDoesNotThrow(() -> service.quarantine("event-1",9L,3,"broken")); + verifyNoInteractions(outbox); + verify(tasks,never()).failQuarantined(anyLong(),anyInt(),anyString()); + } + + @Test void pendingBackoffIsExpeditedWithoutCreatingADuplicateCommand() { + FileExportTask task=task(9L,3,"PENDING"); + when(outbox.expeditePendingRetry(9L,3)).thenReturn(true); + when(tasks.expeditePendingRetry(9L,3)).thenReturn(true); + + org.junit.jupiter.api.Assertions.assertTrue(service.expeditePendingRetry(task)); + + InOrder order=inOrder(outbox,tasks); + order.verify(outbox).expeditePendingRetry(9L,3); + order.verify(tasks).expeditePendingRetry(9L,3); + verifyNoInteractions(commands); + } + + private FileExportTask task(long id,int version,String status){ + FileExportTask task=new FileExportTask();task.setId(id);task.setDispatchVersion(version);task.setStatus(status);return task; + } +} diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskExecutorTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskExecutorTest.java new file mode 100644 index 000000000..3fb8ae681 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/ExportTaskExecutorTest.java @@ -0,0 +1,114 @@ +package com.baiyue.file.service; + +import com.baiyue.file.config.ExportDefinitionRegistry; +import com.baiyue.file.entity.FileExportTask; +import com.baiyue.file.repository.ExportTaskRepository; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.Resource; +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.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(classes = {ExportTaskExecutor.class, ExportTaskExecutorTest.TestConfig.class}) +@TestPropertySource(properties = { + "baiyue.file.export.task.lease-seconds=300", + "baiyue.file.export.task.heartbeat-seconds=60", + "baiyue.file.export.task.max-duration-minutes=30", + "baiyue.file.export.retention-days=7" +}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class ExportTaskExecutorTest { + @MockBean private ExportTaskRepository tasks; + @MockBean private ExportDefinitionRegistry definitions; + @MockBean private GenericXlsxExportRunner runner; + @MockBean private ExportStateService states; + @MockBean private ExportStorage storage; + @Resource private ExportTaskExecutor executor; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean MeterRegistry meterRegistry() { return new SimpleMeterRegistry(); } + } + + @Test void aCommandThatCannotClaimTheTaskIsAcknowledgedWithoutExecutingOrChangingState() { + when(tasks.claim(9L,1,"file-1",300,30)).thenReturn(false); + + executor.execute(9L,1,"file-1"); + + verifyNoInteractions(definitions,runner,states); + executor.shutdownWatchdogs(); + } + + @Test void watchdogRenewsTheLeaseWhileTheRunnerIsStillWriting() { + ReflectionTestUtils.setField(executor, "leaseSeconds", 5); + ReflectionTestUtils.setField(executor, "heartbeatSeconds", 1); + FileExportTask running=task(1,"file-1"); + when(tasks.claim(9L,1,"file-1",5,30)).thenReturn(true);when(tasks.find(9L)).thenReturn(running); + when(tasks.heartbeat(9L,1,"file-1",5)).thenReturn(true);when(tasks.succeed(anyLong(),anyInt(),anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong(),anyInt())).thenReturn(true); + when(definitions.require("CUSTOMER_LIST")).thenReturn(new ExportDefinitionRegistry.Definition()); + when(runner.run(eq(running),any(),any(AtomicBoolean.class),any())).thenAnswer(invocation -> { + Thread.sleep(1200); + return new GenericXlsxExportRunner.Result("file.xlsx","exports/9/test.xlsx","application/xlsx",100,1); + }); + + executor.execute(9L,1,"file-1"); + + verify(tasks,atLeastOnce()).heartbeat(9L,1,"file-1",5); + executor.shutdownWatchdogs(); + } + + @Test void aLateFailureFromAnOldExecutionCannotRetryOrFailANewerVersionOwnedByTheSameInstance() { + when(tasks.claim(9L,1,"file-1",300,30)).thenReturn(true); + FileExportTask first=task(1,"file-1"), newer=task(2,"file-1"); + when(tasks.find(9L)).thenReturn(first,newer); + when(definitions.require("CUSTOMER_LIST")).thenReturn(new ExportDefinitionRegistry.Definition()); + when(runner.run(eq(first),any(),any(),any())).thenThrow(new RuntimeException("late failure")); + + executor.execute(9L,1,"file-1"); + + verifyNoInteractions(states); + executor.shutdownWatchdogs(); + } + + @Test void deletesTheGeneratedFileWhenTheFinalDatabaseCommitThrows() { + FileExportTask running=task(1,"file-1"); + when(tasks.claim(9L,1,"file-1",300,30)).thenReturn(true); + when(tasks.find(9L)).thenReturn(running); + when(definitions.require("CUSTOMER_LIST")).thenReturn(new ExportDefinitionRegistry.Definition()); + when(runner.run(eq(running),any(),any(),any())).thenReturn( + new GenericXlsxExportRunner.Result("file.xlsx","exports/9/result.xlsx","application/xlsx",100,1)); + when(tasks.succeed(anyLong(),anyInt(),anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong(),anyInt())) + .thenThrow(new RuntimeException("database unavailable")); + when(states.retryAfterFailure(eq(running),eq("file-1"),intThat(value -> value >= 48 && value <= 72),anyString())).thenReturn(true); + + executor.execute(9L,1,"file-1"); + + verify(storage).delete("exports/9/result.xlsx"); + verify(states).retryAfterFailure(eq(running),eq("file-1"),intThat(value -> value >= 48 && value <= 72),eq("database unavailable")); + executor.shutdownWatchdogs(); + } + + @Test void retryBackoffAlwaysStaysWithinTheDocumentedTwentyPercentJitter() { + for (int i=0;i<100;i++) { + int first=ExportTaskExecutor.jitteredBackoffSeconds(60); + int second=ExportTaskExecutor.jitteredBackoffSeconds(900); + org.junit.jupiter.api.Assertions.assertTrue(first>=48 && first<=72); + org.junit.jupiter.api.Assertions.assertTrue(second>=720 && second<=1080); + } + } + + private FileExportTask task(int version,String owner){ + FileExportTask task=new FileExportTask();task.setId(9L);task.setExportType("CUSTOMER_LIST");task.setStatus("RUNNING"); + task.setDispatchVersion(version);task.setLeaseOwner(owner);task.setAttemptCount(1);task.setMaxAttempts(3);return task; + } +} diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java new file mode 100644 index 000000000..d715efdae --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java @@ -0,0 +1,203 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest; +import com.baiyue.file.entity.FileExportTask; +import com.baiyue.file.repository.ExportTaskRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +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.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; + +import com.baiyue.file.config.ExportDefinitionRegistry; +import java.util.Map; +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Set; +import java.util.stream.IntStream; + +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.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(classes = {ExportTaskService.class, ExportTaskServiceTest.TestConfig.class}) +class ExportTaskServiceTest { + @MockBean private ExportTaskRepository repository; + @MockBean private ExportStorage storage; + @MockBean private ExportDefinitionRegistry registry; + @MockBean private ExportStateService states; + @MockBean private ExportMetrics metrics; + @Resource private ExportTaskService service; + @Test + void createsPersistedTaskOnlyWhenSourceExportPermissionIsPresent() { + when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); + when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation -> + new ExportStateService.CreationResult(invocation.getArgument(0), true)); + + var response = service.create(user(1L, Set.of("platform:customer:export")), + new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of("status", "ACTIVE"))); + + assertEquals("PENDING", response.status()); + assertEquals("CUSTOMER_LIST", response.exportType()); + verify(states).createConstrained(any(FileExportTask.class)); + BizException denied = assertThrows(BizException.class, () -> service.create(user(1L, Set.of()), + new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of()))); + assertEquals(ResultCode.FORBIDDEN, denied.getResultCode()); + } + + @Test + void refusesAnotherUsersTaskUnlessDownloadAllIsGranted() { + when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); + FileExportTask task = new FileExportTask(); + task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(2L); + when(repository.find(9L)).thenReturn(task); + + BizException denied = assertThrows(BizException.class, () -> service.get(user(1L, Set.of()), 9L)); + assertEquals(ResultCode.FORBIDDEN, denied.getResultCode()); + assertEquals("9", service.get(user(1L, Set.of("platform:download:all")), 9L).id()); + } + + @Test + void rejectsParametersDeeperThanTwoLevels() { + when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); + var request = new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", + Map.of("level1", Map.of("level2", List.of(Map.of("level3", "value"))))); + + BizException error=assertThrows(BizException.class,() -> service.create(user(1L,Set.of("platform:customer:export")),request)); + assertEquals(ResultCode.VALIDATION_FAILED,error.getResultCode()); + } + + @Test + void rejectsPlatformTokenWithCustomerTenantScope() { + var malformed = new AuthenticatedUser(1L, 10001L, 10001L, 1L, + "user1", "PLATFORM", "WEB", Set.of("platform:customer:export")); + assertEquals(ResultCode.FORBIDDEN, assertThrows(BizException.class, + () -> service.create(malformed, + new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of()))) + .getResultCode()); + } + + @Test + void buyDeclarationExportRequiresOnePositiveLoadingPlanId() { + var definition = definition(); + definition.setProviderService("firstleg-service"); + definition.setProviderPath("/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches"); + definition.setRequiredPermission("platform:firstleg:customs:manage"); + when(registry.require("LOADING_LIST_BUY_DECLARATION_ITEMS")).thenReturn(definition); + when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation -> + new ExportStateService.CreationResult(invocation.getArgument(0), true)); + var user = user(1L, Set.of("platform:firstleg:customs:manage")); + + var response = service.create(user, new CreateExportTaskRequest( + "LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", Map.of("loadingPlanId", "51"))); + + assertEquals("LOADING_LIST_BUY_DECLARATION_ITEMS", response.exportType()); + assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user, + new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", Map.of()))) + .getResultCode()); + assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user, + new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", + Map.of("loadingPlanId", 0)))) + .getResultCode()); + assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user, + new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", + Map.of("loadingPlanId", 51L)))) + .getResultCode()); + } + + @Test + void domesticOrderExportAcceptsSelectedBusinessNumbersAndTheCompleteFilterSnapshot() throws Exception { + var definition = definition(); + definition.setProviderService("order-service"); + definition.setProviderPath("/internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches"); + definition.setRequiredPermission("platform:order:export"); + when(registry.require(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST)).thenReturn(definition); + when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation -> + new ExportStateService.CreationResult(invocation.getArgument(0), true)); + var user = user(1L, Set.of("platform:order:export")); + List selected = IntStream.range(0, 5000).mapToObj(index -> "ORD-" + index).toList(); + + var selectedResponse = service.create(user, new CreateExportTaskRequest( + ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST, "XLSX", "国内仓订单", Map.of( + "selectedOrderNos", selected))); + + assertEquals(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST, selectedResponse.exportType()); + var filters = new LinkedHashMap(); + filters.put("tenantId", "10001"); filters.put("keyword", "订单"); filters.put("orderNo", "ORD-1"); + filters.put("mainStatus", "WAIT_MEASURE"); filters.put("customerId", "10001"); + filters.put("receivingWarehouseId", "11"); filters.put("destinationWarehouseId", "12"); + filters.put("inboundCode", "IN-1"); filters.put("internalOrderStatus", "APPROVED"); + filters.put("expectedArrivalDate", "2026-09-01"); filters.put("feeReviewStatus", "PENDING"); + filters.put("channelName", "海运"); filters.put("containerNo", "CONT-1"); + filters.put("cabinetNo", "CAB-1"); filters.put("sailingDate", "2026-09-02"); + filters.put("loaded", false); filters.put("fullContainer", true); + filters.put("measurementReviewStatus", "APPROVED"); filters.put("fbaCode", "FBA-1"); + filters.put("createdFrom", "2026-08-01T00:00:00"); filters.put("createdTo", "2026-09-01T23:59:59"); + + service.create(user, new CreateExportTaskRequest( + ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST, "XLSX", "国内仓订单", filters)); + + var tasks = org.mockito.ArgumentCaptor.forClass(FileExportTask.class); + verify(states, org.mockito.Mockito.times(2)).createConstrained(tasks.capture()); + Map selectedSnapshot = new ObjectMapper().readValue(tasks.getAllValues().get(0).getParametersJson(), Map.class); + Map filterSnapshot = new ObjectMapper().readValue(tasks.getAllValues().get(1).getParametersJson(), Map.class); + assertEquals(5000, ((List) selectedSnapshot.get("selectedOrderNos")).size()); + assertEquals(filters, filterSnapshot); + } + + @Test + void domesticOrderExportRequiresItsDedicatedPermission() { + var definition = definition(); + definition.setProviderService("order-service"); + definition.setProviderPath("/internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches"); + definition.setRequiredPermission("platform:order:export"); + when(registry.require(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST)).thenReturn(definition); + + BizException denied = assertThrows(BizException.class, () -> service.create(user(1L, Set.of("platform:order:list")), + new CreateExportTaskRequest(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST, + "XLSX", "国内仓订单", Map.of()))); + + assertEquals(ResultCode.FORBIDDEN, denied.getResultCode()); + assertTrue(denied.getMessage().contains("权限")); + } + + @Test + void pendingTaskInAutomaticBackoffCanBeRetriedImmediately() { + FileExportTask task = new FileExportTask(); + task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(1L); + task.setExportType("CUSTOMER_LIST"); task.setStatus("PENDING"); task.setAttemptCount(2); + when(repository.find(9L)).thenReturn(task); + when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); + when(states.expeditePendingRetry(task)).thenReturn(true); + + var response = service.retry(user(1L, Set.of("platform:customer:export")), 9L); + + assertEquals("PENDING", response.status()); + assertEquals(2, response.attemptCount()); + verify(states).expeditePendingRetry(task); + verify(storage, org.mockito.Mockito.never()).delete(any()); + } + + private AuthenticatedUser user(long id, Set permissions) { + return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions); + } + private ExportDefinitionRegistry.Definition definition(){ + var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("iam-service");definition.setProviderPath("/internal/exports/v1/CUSTOMER_LIST/batches");definition.setRequiredPermission("platform:customer:export");definition.setAppScope("PLATFORM");definition.setMaxRows(200000);definition.setMaxAttempts(3);definition.setBatchSize(500); + return definition; + } + + @Configuration + static class TestConfig { + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + } +} diff --git a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java new file mode 100644 index 000000000..d983ad4ba --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java @@ -0,0 +1,195 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.export.ExportContracts.*; +import com.baiyue.file.client.ExportProviderClient; +import com.baiyue.file.config.ExportDefinitionRegistry; +import com.baiyue.file.entity.FileExportTask; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(classes = {GenericXlsxExportRunner.class, LocalExportStorage.class, + GenericXlsxExportRunnerTest.TestConfig.class}) +class GenericXlsxExportRunnerTest { + @TempDir static Path temp; + @MockBean private ExportProviderClient provider; + @SpyBean private LocalExportStorage storage; + @Resource private GenericXlsxExportRunner runner; + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("baiyue.file.storage-root", () -> temp.toString()); + registry.add("baiyue.security.internal-service-token", () -> "internal-token"); + registry.add("baiyue.file.export.task.max-file-size-mb", () -> 50); + } + @Test void writesRealTypedWorkbookAndCarriesSnapshotAcrossBatches() throws Exception { + List columns=List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18),new ExportColumn("integer","整数",ExportColumnType.INTEGER,12),new ExportColumn("decimal","金额",ExportColumnType.DECIMAL,12),new ExportColumn("flag","布尔",ExportColumnType.BOOLEAN,10),new ExportColumn("date","日期",ExportColumnType.DATE,12),new ExportColumn("time","时间",ExportColumnType.DATETIME,20)); + List> firstRows=IntStream.range(0,100).mapToObj(index -> Map.of("text",index==0?"=SUM(1,2)":"row-"+index,"integer",12,"decimal","3.50","flag",true,"date","2026-08-03","time","2026-08-03T21:00:00")).toList(); + var first=new ExportBatchResponse(1,"sha256:test","snapshot-9",columns,firstRows,"10",true,101L); + var second=new ExportBatchResponse(1,"sha256:test","snapshot-9",null,List.of(Map.of("text","\n=SUM(1,2)","integer",13,"decimal",4.5,"flag",false,"date","2026-08-04","time","2026-08-04T21:00:00")),null,false,101L); + when(provider.batch(anyString(),eq("CUSTOMER_LIST"),any(),any())).thenReturn(ApiResponse.success(first),ApiResponse.success(second)); + FileExportTask task=new FileExportTask();task.setId(9L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setDispatchVersion(1);task.setExportType("CUSTOMER_LIST");task.setTaskName("客户列表");task.setParametersJson("{}"); + var definition=new ExportDefinitionRegistry.Definition();definition.setMaxRows(200000);definition.setBatchSize(500); + var result=runner.run(task,definition,new AtomicBoolean(),ignored->{ }); + assertEquals(101,result.rows()); + try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())){ + var row=workbook.getSheetAt(0).getRow(1);assertEquals("'=SUM(1,2)",row.getCell(0).getStringCellValue()); + assertEquals(CellType.NUMERIC,row.getCell(1).getCellType());assertEquals(CellType.NUMERIC,row.getCell(2).getCellType());assertEquals(CellType.BOOLEAN,row.getCell(3).getCellType());assertEquals(CellType.NUMERIC,row.getCell(4).getCellType());assertEquals(CellType.NUMERIC,row.getCell(5).getCellType()); + assertEquals("'\n=SUM(1,2)",workbook.getSheetAt(0).getRow(101).getCell(0).getStringCellValue()); + assertTrue(workbook.getNumCellStyles()<10,"日期样式必须复用,不能按单元格创建"); + } + ArgumentCaptor requests=ArgumentCaptor.forClass(ExportBatchRequest.class);verify(provider,times(2)).batch(anyString(),eq("CUSTOMER_LIST"),eq(definition),requests.capture()); + assertNull(requests.getAllValues().get(0).snapshotToken());assertEquals("snapshot-9",requests.getAllValues().get(1).snapshotToken()); + } + + @Test void deletesTheCompletedFileWhenReadingItsFinalSizeFails() throws Exception { + var batch=new ExportBatchResponse(1,"sha256:test","snapshot-9", + List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18)), + List.of(Map.of("text","row")),null,false,1L); + when(provider.batch(anyString(),eq("CUSTOMER_LIST"),any(),any())).thenReturn(ApiResponse.success(batch)); + FileExportTask task=new FileExportTask();task.setId(9L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setDispatchVersion(1);task.setExportType("CUSTOMER_LIST");task.setTaskName("客户列表");task.setParametersJson("{}"); + var definition=new ExportDefinitionRegistry.Definition();definition.setMaxRows(200000);definition.setBatchSize(500); + doThrow(new RuntimeException("size unavailable")).when(storage).size(anyString()); + long filesBefore; + try (var paths=Files.walk(temp.resolve("exports"))) { + filesBefore = paths.filter(Files::isRegularFile).count(); + } + + assertThrows(ExportFailure.class,() -> runner.run(task,definition,new AtomicBoolean(),ignored->{ })); + + try(var paths=Files.walk(temp.resolve("exports"))) { + assertEquals(filesBefore, paths.filter(Files::isRegularFile).count()); + } + } + + @Test void buyDeclarationProviderMustReturnTheFrozenThirteenColumnsInOrder() { + List invalid = ExportSchemaContracts.BUY_DECLARATION_COLUMNS.stream() + .map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList(); + invalid = new java.util.ArrayList<>(invalid); + invalid.set(4, new ExportColumn("productNameZh", "中文品名", ExportColumnType.STRING, 18)); + var batch = new ExportBatchResponse(1, "sha256:buy-declaration", "snapshot-51", + invalid, List.of(), null, false, 0L); + when(provider.batch(anyString(), eq(ExportSchemaContracts.BUY_DECLARATION_ITEMS), any(), any())) + .thenReturn(ApiResponse.success(batch)); + FileExportTask task = new FileExportTask(); task.setId(51L); task.setTenantId(0L); + task.setAppCode("PLATFORM"); task.setRequestedBy(7L); task.setDispatchVersion(1); + task.setExportType(ExportSchemaContracts.BUY_DECLARATION_ITEMS); task.setTaskName("买单报关明细"); + task.setParametersJson("{\"loadingPlanId\":\"51\"}"); + var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500); + + assertThrows(ExportFailure.class, + () -> runner.run(task, definition, new AtomicBoolean(), ignored -> { })); + } + + @Test void frozenBuyDeclarationProviderColumnsGenerateWorkbookDirectly() throws Exception { + List columns = ExportSchemaContracts.BUY_DECLARATION_COLUMNS.stream() + .map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList(); + Map row = new java.util.LinkedHashMap<>(); + row.put("customerName", "客户A"); row.put("orderNo", "O-1"); row.put("inboundCode", "IN-1"); + row.put("sku", "SKU-1"); row.put("chineseName", "中文品名"); row.put("englishName", "English name"); + row.put("cartonCount", 2); row.put("productQuantity", 20); row.put("grossWeight", "12.5"); + row.put("netWeight", "11.5"); row.put("volume", "1.25"); row.put("mark", "MARK-1"); + row.put("customerRemark", "备注"); + var batch = new ExportBatchResponse(1, "sha256:buy-declaration", "snapshot-51", + columns, List.of(row), null, false, 1L); + when(provider.batch(anyString(), eq(ExportSchemaContracts.BUY_DECLARATION_ITEMS), any(), any())) + .thenReturn(ApiResponse.success(batch)); + FileExportTask task = new FileExportTask(); task.setId(52L); task.setTenantId(0L); + task.setAppCode("PLATFORM"); task.setRequestedBy(7L); task.setDispatchVersion(1); + task.setExportType(ExportSchemaContracts.BUY_DECLARATION_ITEMS); task.setTaskName("买单报关明细"); + task.setParametersJson("{\"loadingPlanId\":\"51\"}"); + var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500); + + var result = runner.run(task, definition, new AtomicBoolean(), ignored -> { }); + + assertEquals(1L, result.rows()); + try (var workbook = new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())) { + var sheet = workbook.getSheetAt(0); + assertEquals("客户名称", sheet.getRow(0).getCell(0).getStringCellValue()); + assertEquals("客户A", sheet.getRow(1).getCell(0).getStringCellValue()); + assertEquals("中文品名", sheet.getRow(0).getCell(4).getStringCellValue()); + assertEquals("唛头", sheet.getRow(0).getCell(11).getStringCellValue()); + } + } + + @Test void domesticOrderProviderMustReturnTheFrozenBusinessColumnsWithoutInternalIds() throws Exception { + List invalid = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_COLUMNS.stream() + .filter(column -> !"expectedVolumeCbm".equals(column.key())) + .map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList(); + var batch = new ExportBatchResponse(1, "sha256:domestic-orders", "snapshot-orders", + invalid, List.of(), null, false, 0L); + when(provider.batch(anyString(), eq(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST), any(), any())) + .thenReturn(ApiResponse.success(batch)); + FileExportTask task = domesticOrderTask(61L, Map.of()); + var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500); + + assertThrows(ExportFailure.class, + () -> runner.run(task, definition, new AtomicBoolean(), ignored -> { })); + } + + @Test void domesticOrderColumnsGenerateWorkbookAndPassTheSelectionSnapshotToOrder() throws Exception { + List columns = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_COLUMNS.stream() + .map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList(); + Map row = new java.util.LinkedHashMap<>(); + row.put("inboundCode", "IN-1"); row.put("orderNo", "ORD-1"); + row.put("expectedVolumeCbm", "1.25"); row.put("expectedArrivalAt", "2026-09-03T12:30:00"); + var batch = new ExportBatchResponse(1, "sha256:domestic-orders", "snapshot-orders", + columns, List.of(row), null, false, 1L); + when(provider.batch(anyString(), eq(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST), any(), any())) + .thenReturn(ApiResponse.success(batch)); + Map parameters = Map.of("selectedOrderNos", List.of("ORD-1", "ORD-2")); + FileExportTask task = domesticOrderTask(62L, parameters); + var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500); + + var result = runner.run(task, definition, new AtomicBoolean(), ignored -> { }); + + try (var workbook = new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())) { + var sheet = workbook.getSheetAt(0); + assertEquals("订单号", sheet.getRow(0).getCell(1).getStringCellValue()); + assertEquals("预计到货时间", sheet.getRow(0).getCell(9).getStringCellValue()); + assertEquals("预计体积(m³)", sheet.getRow(0).getCell(10).getStringCellValue()); + assertEquals(1.25d, sheet.getRow(1).getCell(10).getNumericCellValue()); + } + ArgumentCaptor request = ArgumentCaptor.forClass(ExportBatchRequest.class); + verify(provider).batch(anyString(), eq(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST), + eq(definition), request.capture()); + assertEquals(parameters, request.getValue().parameters()); + } + + private FileExportTask domesticOrderTask(long id, Map parameters) throws Exception { + FileExportTask task = new FileExportTask(); task.setId(id); task.setTenantId(0L); + task.setAppCode("PLATFORM"); task.setRequestedBy(7L); task.setDispatchVersion(1); + task.setExportType(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST); task.setTaskName("国内仓订单"); + task.setParametersJson(new ObjectMapper().writeValueAsString(parameters)); + return task; + } + + @Configuration + static class TestConfig { + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + } +} diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java new file mode 100644 index 000000000..3f1c1fe34 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -0,0 +1,376 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.springframework.mock.web.MockMultipartFile; + +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class InternalBusinessFileServiceTest { + @Mock FileObjectMapper files; + @Mock BusinessFileStorage storage; + @InjectMocks InternalBusinessFileService service; + + @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } + + @Test void customsDocumentUploadPersistsExactBusinessOwnership() { + MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes()); + when(storage.store(10001L, "41", file, BusinessFileCategory.CUSTOMS_DOCUMENT)).thenReturn(new BusinessFileStorage.Stored( + "business-files/10001/41/key.txt", "text/plain", 7, "a".repeat(64), "invoice.txt")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(71L); return 1; + }); + + var result = service.upload(10001L, "CUSTOMS_DOCUMENT", "41", 7L, file); + + assertThat(result.fileId()).isEqualTo(71L); + assertThat(result.businessType()).isEqualTo("CUSTOMS_DOCUMENT"); + assertThat(result.businessId()).isEqualTo("41"); + assertThat(result.contentType()).isEqualTo("text/plain"); + } + + @Test void platformContainerAndLoadingDocumentsSupportZeroTenantScope() { + MockMultipartFile file = new MockMultipartFile("file", "customs.pdf", "application/pdf", "%PDF-1.4".getBytes()); + when(storage.store(eq(0L), anyString(), same(file), any(BusinessFileCategory.class))).thenAnswer(invocation -> + new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf", + "application/pdf", file.getSize(), "b".repeat(64), "customs.pdf")); + when(storage.store(0L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO)).thenReturn(new BusinessFileStorage.Stored( + "business-files/0/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_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(0L, "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_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(0L, "OVERSEAS_UNLOAD_PHOTO", "51"); + verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_CLEARANCE_DOCUMENT); + verify(storage).store(0L, "51", file, BusinessFileCategory.LOADING_CUSTOMS_DECLARATION); + verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_DELIVERY_ORDER); + verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_PICKUP_ORDER); + verify(storage).store(0L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO); + assertThatThrownBy(() -> service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("卸柜照片必须归属平台"); + } + + @Test void oceanBookingDocumentUsesPlatformAndBookingOwnership() { + MockMultipartFile file = new MockMultipartFile("file", "booking.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx".getBytes()); + when(storage.store(0L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT)) + .thenReturn(new BusinessFileStorage.Stored("business-files/0/61/booking", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + file.getSize(), "f".repeat(64), "booking.xlsx")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(111L); return 1; + }); + + var metadata = service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file); + + assertThat(metadata).extracting(InternalBusinessFileService.Metadata::ownerScope, + InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId) + .containsExactly("PLATFORM", 0L, "OCEAN_BOOKING_DOCUMENT", "61"); + assertThatThrownBy(() -> service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("必须归属平台"); + } + + @Test void oceanBookingDocumentRejectsWrongCustomerBookingAndTruckOwnership() { + FileObjectEntity value = new FileObjectEntity(); value.setId(111L); value.setTenantId(0L); + value.setBusinessType("OCEAN_BOOKING_DOCUMENT"); value.setBusinessId("61"); + value.setOriginalName("booking.xlsx"); value.setObjectKey("business-files/0/61/booking"); + value.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + value.setSha256("f".repeat(64)); value.setSizeBytes(8L); + when(files.selectOne(any())).thenReturn(value, null, null); + + assertThat(service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).ownerScope()) + .isEqualTo("PLATFORM"); + assertThatThrownBy(() -> service.metadata(10002L, "OCEAN_BOOKING_DOCUMENT", "61", 111L)) + .isInstanceOf(BizException.class).hasMessageContaining("必须归属平台"); + assertThatThrownBy(() -> service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "62", 111L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(10001L, "TRUCK_APPOINTMENT_ATTACHMENT", "61", 111L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } + + @Test void bookingFileScopeRemainsPlatformRegardlessOfOptionalCustomerSnapshot() { + FileObjectEntity value = new FileObjectEntity(); value.setId(111L); value.setTenantId(0L); + value.setBusinessType("OCEAN_BOOKING_DOCUMENT"); value.setBusinessId("61"); + value.setOriginalName("booking.bin"); value.setObjectKey("business-files/0/61/object"); + when(files.selectOne(any())).thenReturn(value); + when(storage.read(value.getObjectKey())).thenReturn(new byte[]{1, 2}); + // Customer snapshot is deliberately absent from File commands before and after clearing it. + assertThat(service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).ownerScope()).isEqualTo("PLATFORM"); + assertThat(service.download(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).content()).containsExactly(1, 2); + assertThat(service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).fileId()).isEqualTo(111L); + assertThatThrownBy(() -> service.download(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 111L)) + .isInstanceOf(BizException.class).hasMessageContaining("必须归属平台"); + verify(storage, times(1)).read(value.getObjectKey()); + } + + @Test void rejectsNegativeTenantAndInvalidOwnershipInputs() { + MockMultipartFile file = new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()); + assertThatThrownBy(() -> service.upload(-1L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("归属"); + assertThatThrownBy(() -> service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "0", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("业务ID"); + assertThatThrownBy(() -> service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 0L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("操作人"); + verifyNoInteractions(files, storage); + } + + @Test void overseasRelabelPhotoPersistsExactTaskOwnership() { + MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes()); + when(storage.store(100L, "81", file, BusinessFileCategory.OVERSEAS_RELABEL_PHOTO)).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 loadingCustomsSourceAndResultAreCustomerOwnedArbitraryFormatObjects() { + MockMultipartFile file = new MockMultipartFile("file", "资料包.zip", "application/zip", "zip".getBytes()); + when(storage.store(eq(10001L), eq("51"), same(file), any(BusinessFileCategory.class))) + .thenReturn(new BusinessFileStorage.Stored("business-files/10001/51/key", "application/zip", + file.getSize(), "e".repeat(64), "资料包.zip")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(101L); return 1; + }); + + var source = service.upload(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file); + var result = service.upload(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 7L, file); + + assertThat(source).extracting(InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId) + .containsExactly(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51"); + assertThat(result.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RESULT"); + verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_SOURCE); + verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_RESULT); + } + + @Test void loadingCustomsCategoriesRejectPlatformOwnerScope() { + var file = new MockMultipartFile("file", "source.bin", "application/octet-stream", new byte[]{1}); + + assertThatThrownBy(() -> service.upload(0L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); + assertThatThrownBy(() -> service.metadata(0L, "LOADING_LIST_CUSTOMS_RESULT", "51", 91L)) + .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); + verifyNoInteractions(files, storage); + } + + @Test void unsupportedBusinessTypeFailsClosed() { + assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L, + new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()))) + .isInstanceOf(BizException.class).hasMessageContaining("不支持"); + verifyNoInteractions(files, storage); + } + + @Test void orderBatchSourceAndErrorReportUseCustomerAndBatchNumberOwnership() { + String batchNo = "OBI-0123456789ABCDEF0123"; + var file = new MockMultipartFile("file", "orders.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + new byte[]{0x50, 0x4b, 0x03, 0x04, 1}); + when(storage.store(eq(10001L), eq(batchNo), same(file), any(BusinessFileCategory.class))) + .thenReturn(new BusinessFileStorage.Stored("business-files/10001/" + batchNo + "/object.xlsx", + file.getContentType(), file.getSize(), "9".repeat(64), file.getOriginalFilename())); + AtomicLong ids = new AtomicLong(300); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(ids.incrementAndGet()); + return 1; + }); + + var source = service.upload(10001L, "ORDER_BATCH_IMPORT_SOURCE", batchNo, 7L, file); + var error = service.upload(10001L, "ORDER_BATCH_IMPORT_ERROR_REPORT", batchNo, 7L, file); + + assertThat(source).extracting(InternalBusinessFileService.Metadata::ownerScope, + InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::businessId) + .containsExactly("CUSTOMER", 10001L, batchNo); + assertThat(error.businessType()).isEqualTo("ORDER_BATCH_IMPORT_ERROR_REPORT"); + verify(storage).store(10001L, batchNo, file, BusinessFileCategory.ORDER_BATCH_IMPORT_SOURCE); + verify(storage).store(10001L, batchNo, file, BusinessFileCategory.ORDER_BATCH_IMPORT_ERROR_REPORT); + } + + @Test void orderBatchFilesRejectPlatformOwnerMalformedBatchAndWrongCompositeOwnership() { + String batchNo = "OBI-0123456789ABCDEF0123"; + var file = new MockMultipartFile("file", "orders.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + new byte[]{0x50, 0x4b, 0x03, 0x04}); + assertThatThrownBy(() -> service.upload(0L, "ORDER_BATCH_IMPORT_SOURCE", batchNo, 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); + assertThatThrownBy(() -> service.upload(10001L, "ORDER_BATCH_IMPORT_SOURCE", "41", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("批量导入业务编号"); + + FileObjectEntity value = new FileObjectEntity(); value.setId(401L); value.setTenantId(10001L); + value.setBusinessType("ORDER_BATCH_IMPORT_SOURCE"); value.setBusinessId(batchNo); + value.setOriginalName("orders.xlsx"); value.setObjectKey("business-files/10001/" + batchNo + "/object.xlsx"); + value.setContentType(file.getContentType()); value.setSha256("8".repeat(64)); value.setSizeBytes(file.getSize()); + when(files.selectOne(any())).thenReturn(value, null, null, null); + + assertThat(service.metadata(10001L, "ORDER_BATCH_IMPORT_SOURCE", batchNo, 401L).fileId()).isEqualTo(401L); + assertThatThrownBy(() -> service.metadata(10002L, "ORDER_BATCH_IMPORT_SOURCE", batchNo, 401L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(10001L, "ORDER_BATCH_IMPORT_ERROR_REPORT", batchNo, 401L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(10001L, "ORDER_BATCH_IMPORT_SOURCE", + "OBI-FFFFFFFFFFFFFFFFFFFF", 401L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } + + @Test void downloadUsesValidatedMetadataObjectKey() { + FileObjectEntity value = new FileObjectEntity(); value.setId(71L); value.setTenantId(10001L); + value.setBusinessType("CUSTOMS_RECEIPT"); value.setBusinessId("61"); value.setOriginalName("release.txt"); + value.setObjectKey("business-files/10001/61/release.txt"); value.setContentType("text/plain"); + when(files.selectOne(any())).thenReturn(value); + when(storage.read(value.getObjectKey())).thenReturn("released".getBytes()); + + var result = service.download(10001L, "CUSTOMS_RECEIPT", "61", 71L); + + assertThat(result.fileName()).isEqualTo("release.txt"); + assertThat(result.content()).isEqualTo("released".getBytes()); + verify(storage).read(value.getObjectKey()); + } + + @Test void platformMetadataAndDownloadRequireExactCompositeOwnership() { + FileObjectEntity value = new FileObjectEntity(); value.setId(91L); value.setTenantId(0L); + 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_DECLARATION", "51", 91L).fileName()) + .isEqualTo("declaration.pdf"); + 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_DECLARATION", "52", 91L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "51", 92L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + verify(storage).read(value.getObjectKey()); + } + + @Test void concurrentRepeatedUploadsCreateDistinctImmutableObjectsForFirstlegToVersion() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "booking.bin", + "application/x-booking", new byte[]{1, 2, 3}); + when(storage.store(0L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT)) + .thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/object", + "application/x-booking", 3, "a".repeat(64), "booking.bin")); + AtomicLong ids = new AtomicLong(200); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(ids.incrementAndGet()); + return 1; + }); + var executor = Executors.newFixedThreadPool(2); + try { + var firstFuture = executor.submit(() -> + service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file)); + var secondFuture = executor.submit(() -> + service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file)); + + var first = firstFuture.get(); + var second = secondFuture.get(); + assertThat(first.fileId()).isNotEqualTo(second.fileId()); + assertThat(first.businessId()).isEqualTo("61"); + assertThat(second.businessId()).isEqualTo("61"); + verify(files, times(2)).insert(any(FileObjectEntity.class)); + verify(files, never()).updateById(any(FileObjectEntity.class)); + } finally { + executor.shutdownNow(); + } + } + + @Test void inboundHoldEvidenceIsCustomerOwnedAndScopedToInboundId() { + MockMultipartFile file = new MockMultipartFile("file", "hold.pdf", "application/pdf", new byte[]{1, 2}); + when(storage.store(10001L, "61", file, BusinessFileCategory.INBOUND_HOLD_EVIDENCE)) + .thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/hold.pdf", + "application/pdf", 2, "a".repeat(64), "hold.pdf")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(301L); + return 1; + }); + + var result = service.upload(10001L, "INBOUND_HOLD_EVIDENCE", "61", 7L, file); + + assertThat(result.fileId()).isEqualTo(301L); + assertThat(result.ownerScope()).isEqualTo("CUSTOMER"); + assertThat(result.businessId()).isEqualTo("61"); + assertThatThrownBy(() -> service.upload(0L, "INBOUND_HOLD_EVIDENCE", "61", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); + } + + @Test void overseasExceptionEvidenceUsesExactCustomerAndExceptionOwnership() { + MockMultipartFile file = new MockMultipartFile( + "file", "异常处理证据.pdf", "application/pdf", "%PDF-1.4".getBytes()); + when(storage.store(10001L, "701", file, BusinessFileCategory.OVERSEAS_EXCEPTION_EVIDENCE)) + .thenReturn(new BusinessFileStorage.Stored("business-files/10001/701/evidence.pdf", + "application/pdf", file.getSize(), "b".repeat(64), "异常处理证据.pdf")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(801L); + return 1; + }); + + var result = service.upload(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 7L, file); + + assertThat(result).extracting(InternalBusinessFileService.Metadata::ownerScope, + InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId) + .containsExactly("CUSTOMER", 10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701"); + verify(storage).store(10001L, "701", file, BusinessFileCategory.OVERSEAS_EXCEPTION_EVIDENCE); + assertThatThrownBy(() -> service.upload(0L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); + } + + @Test void overseasExceptionEvidenceMetadataFailsClosedForEveryOwnershipMismatch() { + FileObjectEntity value = new FileObjectEntity(); + value.setId(801L); value.setTenantId(10001L); + value.setBusinessType("OVERSEAS_EXCEPTION_EVIDENCE"); value.setBusinessId("701"); + value.setOriginalName("evidence.pdf"); value.setObjectKey("business-files/10001/701/evidence.pdf"); + value.setContentType("application/pdf"); value.setSha256("b".repeat(64)); value.setSizeBytes(8L); + when(files.selectOne(any())).thenReturn(value, null, null, null); + + assertThat(service.metadata(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 801L).fileId()) + .isEqualTo(801L); + assertThatThrownBy(() -> service.metadata(10002L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 801L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "702", 801L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(10001L, "INBOUND_HOLD_EVIDENCE", "701", 801L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } + +} diff --git a/file/src/test/java/com/baiyue/file/service/LoadingEvidenceServiceTest.java b/file/src/test/java/com/baiyue/file/service/LoadingEvidenceServiceTest.java new file mode 100644 index 000000000..563c7db47 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/LoadingEvidenceServiceTest.java @@ -0,0 +1,70 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; + +import java.nio.file.Path; + +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.verify; +import static org.mockito.Mockito.when; +import org.mockito.ArgumentCaptor; + +@ExtendWith(MockitoExtension.class) +class LoadingEvidenceServiceTest { + @Mock MeasurementImageStorage storage; + @Mock FileObjectMapper files; + @InjectMocks LoadingEvidenceService service; + + @Test + void uploadsEvidenceWithDedicatedBusinessType() { + var multipart = new MockMultipartFile("file", "step.jpg", "image/jpeg", new byte[]{1}); + when(storage.storeLoading(0, 20, multipart)).thenReturn(new MeasurementImageStorage.Stored( + "loading-evidence/0/20/a.jpg", Path.of("a.jpg"), "image/jpeg", 1, "hash", "step.jpg")); + var result = service.upload(20, 7, "PHOTO_STEP_2", multipart); + assertThat(result.businessType()).isEqualTo("LOADING_PHOTO_STEP_2"); + assertThat(result.ownerScope()).isEqualTo("PLATFORM"); + assertThat(result.tenantId()).isZero(); + assertThat(result.businessId()).isEqualTo("20"); + var object = ArgumentCaptor.forClass(FileObjectEntity.class); + verify(files).insert(object.capture()); + assertThat(object.getValue()).extracting(FileObjectEntity::getTenantId, + FileObjectEntity::getBusinessId, FileObjectEntity::getBucketName) + .containsExactly(0L, "20", "loading-evidence"); + } + + @Test + void rejectsUnknownEvidenceType() { + var multipart = new MockMultipartFile("file", "x.jpg", "image/jpeg", new byte[]{1}); + assertThatThrownBy(() -> service.upload(20, 7, "UNKNOWN", multipart)).isInstanceOf(BizException.class); + } + + @Test + void validatesTenantBoundFile() { + FileObjectEntity object = new FileObjectEntity(); + object.setId(30L); object.setTenantId(0L); object.setBusinessId("20"); + object.setOriginalName("seal.jpg"); object.setBusinessType("LOADING_SEAL"); object.setContentType("image/jpeg"); + when(files.selectOne(any(Wrapper.class))).thenReturn(object); + var metadata = service.validate(20, 30); + assertThat(metadata.fileId()).isEqualTo(30L); + assertThat(metadata.ownerScope()).isEqualTo("PLATFORM"); + assertThat(metadata.tenantId()).isZero(); + assertThat(metadata.businessId()).isEqualTo("20"); + } + + @Test + void rejectsFileWhenContainerBookingDoesNotMatch() { + assertThatThrownBy(() -> service.validate(21, 30)) + .isInstanceOf(BizException.class).hasMessageContaining("装柜证据文件不存在"); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java new file mode 100644 index 000000000..e08d03c5c --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java @@ -0,0 +1,202 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.util.ReflectionTestUtils; + +import jakarta.annotation.Resource; + +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("本地企业资质文件存储功能") +@SpringJUnitConfig(LocalQualificationStorage.class) +class LocalQualificationStorageTest { + + @TempDir + static Path storageRoot; + @Resource private LocalQualificationStorage storage; + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("baiyue.file.storage-root", () -> storageRoot.toString()); + } + + @Test + void initializeVerifiesTheActualQualificationMountAndCleansUpItsProbe() throws Exception { + + storage.initialize(); + + Path qualificationRoot = storageRoot.resolve("qualifications"); + assertTrue(Files.isDirectory(qualificationRoot)); + try (var files = Files.list(qualificationRoot)) { + assertFalse(files.anyMatch(path -> path.getFileName().toString().startsWith(".file-service-probe-"))); + } + } + + @Test + void storeReturnsFileServiceUrlBackedByARealTenantFile() throws Exception { + storage.initialize(); + byte[] png = {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01}; + var upload = new MockMultipartFile("file", "license.png", "image/png", png); + + var stored = storage.store(10001L, upload); + + assertTrue(stored.documentUrl().matches( + "/api/merchant/v1/files/qualifications/[0-9a-f-]{36}\\.png")); + assertTrue(Files.isRegularFile(stored.path())); + assertEquals(stored.path().toAbsolutePath(), + storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath()); + BizException crossTenant = assertThrows(BizException.class, + () -> storage.load(10002L, stored.fileName())); + assertEquals(ResultCode.NOT_FOUND, crossTenant.getResultCode()); + assertEquals("File not found", crossTenant.getMessage()); + } + + @Test + void storesPdfQualificationAfterVerifyingItsSignature() throws Exception { + storage.initialize(); + var upload = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4\ncontent".getBytes()); + + var stored = storage.store(10001L, upload); + + assertTrue(stored.fileName().endsWith(".pdf")); + assertEquals(stored.path().toAbsolutePath(), + storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath()); + } + + @Test + void storesBmpQualificationWithEitherStandardMimeTypeAndKeepsTenantOwnership() throws Exception { + storage.initialize(); + byte[] bmp = {0x42, 0x4d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00}; + + for (String contentType : new String[] {"image/bmp", "image/x-ms-bmp"}) { + var upload = new MockMultipartFile("file", "license.bmp", contentType, bmp); + var stored = storage.store(10001L, upload); + + assertTrue(stored.fileName().endsWith(".bmp")); + assertEquals(stored.path().toAbsolutePath(), + storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath()); + BizException crossTenant = assertThrows(BizException.class, + () -> storage.load(10002L, stored.fileName())); + assertEquals(ResultCode.NOT_FOUND, crossTenant.getResultCode()); + } + } + + @Test + void rejectsBmpMimeTypeWhenTheFileSignatureIsNotBmp() { + storage.initialize(); + var upload = new MockMultipartFile("file", "license.bmp", "image/bmp", "not-bmp".getBytes()); + + BizException exception = assertThrows(BizException.class, + () -> storage.store(10001L, upload)); + + assertTrue(exception.getMessage().contains("文件内容与格式不匹配")); + } + + @Test + void deletesBmpQualificationByItsValidatedDocumentUrl() { + storage.initialize(); + byte[] bmp = {0x42, 0x4d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00}; + var stored = storage.store(10001L, + new MockMultipartFile("file", "license.bmp", "image/bmp", bmp)); + + storage.deleteByDocumentUrl(10001L, stored.documentUrl()); + + assertFalse(Files.exists(stored.path())); + } + + @Test + void rejectsQualificationFilesLargerThanTheClientFiveMegabyteLimit() { + storage.initialize(); + var upload = new MockMultipartFile("file", "oversized.pdf", "application/pdf", + new byte[5 * 1024 * 1024 + 1]); + + BizException exception = assertThrows(BizException.class, + () -> storage.store(10001L, upload)); + + assertTrue(exception.getMessage().contains("5 MB")); + } + + @Test + void deletesTheReplacedQualificationByItsValidatedDocumentUrl() { + storage.initialize(); + var upload = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4\ncontent".getBytes()); + var stored = storage.store(10001L, upload); + + storage.deleteByDocumentUrl(10001L, stored.documentUrl()); + + assertFalse(Files.exists(stored.path())); + } + + @Test + void doesNotDeleteAQualificationForAnUntrustedDocumentUrl() { + storage.initialize(); + var upload = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4\ncontent".getBytes()); + var stored = storage.store(10001L, upload); + + storage.deleteByDocumentUrl(10001L, "/untrusted/" + stored.fileName()); + + assertTrue(Files.exists(stored.path())); + } + + @Test + void legacySeedPathCannotBePretendedToBeAStoredFile() { + BizException exception = assertThrows(BizException.class, + () -> storage.load(10001L, "demo-business-license.jpg")); + + assertEquals(ResultCode.NOT_FOUND, exception.getResultCode()); + assertEquals("File not found", exception.getMessage()); + } + + @Test + void missingQualificationReturnsNotFoundWithoutRevealingOwnership() { + storage.initialize(); + + BizException exception = assertThrows(BizException.class, + () -> storage.load(10001L, "00000000-0000-0000-0000-000000000000.pdf")); + + assertEquals(ResultCode.NOT_FOUND, exception.getResultCode()); + assertEquals("File not found", exception.getMessage()); + } + + @Test + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + void invalidStorageFailsFastDuringStartup() throws Exception { + Path invalidRoot = storageRoot.resolve("root-is-file"); + Files.writeString(invalidRoot, "not-a-directory"); + ReflectionTestUtils.setField(storage, "configuredRoot", invalidRoot.toString()); + + IllegalStateException exception = assertThrows(IllegalStateException.class, storage::initialize); + + assertTrue(exception.getMessage().contains("存储根目录不可用")); + } + + @Test + void writeFailureReturnsServiceUnavailableInsteadOfBusinessError() throws Exception { + storage.initialize(); + long isolatedTenantId = 19999L; + Files.writeString(storageRoot.resolve("qualifications").resolve(String.valueOf(isolatedTenantId)), + "阻止创建租户目录"); + byte[] png = {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01}; + var upload = new MockMultipartFile("file", "license.png", "image/png", png); + + BizException exception = assertThrows(BizException.class, + () -> storage.store(isolatedTenantId, upload)); + + assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode()); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java b/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java new file mode 100644 index 000000000..ccb98797c --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java @@ -0,0 +1,225 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.file.client.WeightOcrClient; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; + +import java.nio.file.Path; +import java.math.BigDecimal; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; + +import javax.imageio.ImageIO; + +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.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(MeasurementImageService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class MeasurementImageServiceEvidenceTest { + @MockBean private MeasurementImageStorage storage; + @MockBean private FileObjectMapper files; + @MockBean private WeightOcrClient ocr; + @Resource private MeasurementImageService service; + + @BeforeAll + static void initializeEntityMetadata() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "measurement-image-test"), + FileObjectEntity.class); + } + + @Test + void shouldHandleCase01() { + var image = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE, + new byte[]{1, 2, 3}); + when(storage.store(11L, 21L, image)).thenReturn(new MeasurementImageStorage.Stored( + "measurements/11/21/a.png", Path.of("a.png"), MediaType.IMAGE_PNG_VALUE, + 3L, "hash", "measure.png")); + doAnswer(invocation -> { + FileObjectEntity object = invocation.getArgument(0); + object.setId(41L); + return 1; + }).when(files).insert(any(FileObjectEntity.class)); + + var result = service.uploadMeasurementEvidence(11L, 21L, 31L, image); + + assertThat(result.fileId()).isEqualTo(41L); + assertThat(result.recognitionStatus()).isEqualTo("NOT_REQUIRED"); + verifyNoInteractions(ocr); + } + + @Test + void storesUnmatchedMojiaReceiptUnderItsOwnBusinessType() { + var image = new MockMultipartFile("file", "mojia.jpg", MediaType.IMAGE_JPEG_VALUE, + new byte[]{1, 2, 3}); + when(storage.store(11L, 51L, image)).thenReturn(new MeasurementImageStorage.Stored( + "measurements/11/51/mojia.jpg", Path.of("mojia.jpg"), MediaType.IMAGE_JPEG_VALUE, + 3L, "hash", "mojia.jpg")); + ArgumentCaptor object = ArgumentCaptor.forClass(FileObjectEntity.class); + doAnswer(invocation -> { + FileObjectEntity value = invocation.getArgument(0); + value.setId(41L); + return 1; + }).when(files).insert(any(FileObjectEntity.class)); + + var result = service.uploadMojiaReceiptEvidence(11L, 51L, 31L, image); + + assertThat(result.fileId()).isEqualTo(41L); + verify(files).insert(object.capture()); + assertThat(object.getValue().getBusinessType()).isEqualTo("MOJIA_MEASUREMENT_RECEIPT"); + assertThat(object.getValue().getBusinessId()).isEqualTo("51"); + verifyNoInteractions(ocr); + } + + @Test + void shouldHandleCase02() { + FileObjectEntity object = new FileObjectEntity(); + object.setId(41L); + object.setTenantId(11L); + object.setBusinessType("INBOUND_MEASUREMENT_IMAGE"); + object.setBusinessId("21"); + when(files.selectOne(any())).thenReturn(object); + + assertThatThrownBy(() -> service.retryRecognition(11L, 21L, 41L)) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessageContaining("不执行重量OCR"); + verifyNoInteractions(ocr); + } + + @Test + void refusesAFileTupleFromAnotherCustomer() { + when(files.selectOne(any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + LambdaQueryWrapper query = invocation.getArgument(0); + query.getSqlSegment(); + assertThat(query.getParamNameValuePairs().values()) + .contains(11L, 41L, "21", "ACTIVE", false); + return null; + }); + + assertThatThrownBy(() -> service.download(11L, 21L, 41L)) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class); + verifyNoInteractions(storage); + } + + @Test + void forwardsSafeOcrTextAndCanonicalConfidence() { + FileObjectEntity object = new FileObjectEntity(); + object.setId(41L); + object.setTenantId(11L); + object.setBusinessType("INBOUND_WEIGHT_IMAGE"); + object.setBusinessId("21"); + object.setOriginalName("weight.jpg"); + object.setContentType(MediaType.IMAGE_JPEG_VALUE); + object.setObjectKey("measurements/11/21/weight.jpg"); + object.setSizeBytes(3L); + when(files.selectOne(any())).thenReturn(object); + when(storage.read(object.getObjectKey())).thenReturn(new byte[]{1, 2, 3}); + when(ocr.recognize(org.mockito.ArgumentMatchers.eq("internal-token"), any())) + .thenReturn(ApiResponse.success(new WeightOcrClient.Result("RECOGNIZED", + new BigDecimal("285.5"), new BigDecimal("0.5097191334"), "285.5\nKG", null))); + + var result = service.retryRecognition(11L, 21L, 41L); + + assertThat(result.confidence()).isEqualByComparingTo("0.50972"); + assertThat(result.ocrRawText()).isEqualTo("285.5\nKG"); + } + + @Test + void deletesMeasurementEvidenceThroughTheConfiguredLogicalDelete() { + FileObjectEntity object = measurementEvidence(); + when(files.selectOne(any())).thenReturn(object); + when(files.updateById(object)).thenReturn(1); + when(files.deleteById(41L)).thenReturn(1); + + service.deleteMeasurementEvidence(11L, 21L, 41L, 31L); + + assertThat(object.getStatus()).isEqualTo("DELETED"); + assertThat(object.getDeleted()).isFalse(); + var ordered = inOrder(files, storage); + ordered.verify(files).updateById(object); + ordered.verify(files).deleteById(41L); + ordered.verify(storage).delete(object.getObjectKey()); + } + + @Test + void deletesWeightImageThroughTheSameOwnedFileBoundary() { + FileObjectEntity object = measurementEvidence(); + object.setBusinessType("INBOUND_WEIGHT_IMAGE"); + when(files.selectOne(any())).thenReturn(object); + when(files.updateById(object)).thenReturn(1); + when(files.deleteById(41L)).thenReturn(1); + + service.deleteMeasurementEvidence(11L, 21L, 41L, 31L); + + verify(storage).delete(object.getObjectKey()); + } + + @Test + void createsBoundedJpegThumbnail() throws Exception { + FileObjectEntity object = measurementEvidence(); + object.setOriginalName("weight.png"); + object.setContentType(MediaType.IMAGE_PNG_VALUE); + when(files.selectOne(any())).thenReturn(object); + BufferedImage source = new BufferedImage(1200, 800, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream image = new ByteArrayOutputStream(); + ImageIO.write(source, "png", image); + when(storage.read(object.getObjectKey())).thenReturn(image.toByteArray()); + + MeasurementImageService.Download result = service.thumbnail(11L, 21L, 41L); + + BufferedImage thumbnail = ImageIO.read(new java.io.ByteArrayInputStream(result.content())); + assertThat(result.contentType()).isEqualTo(MediaType.IMAGE_JPEG_VALUE); + assertThat(thumbnail.getWidth()).isEqualTo(480); + assertThat(thumbnail.getHeight()).isEqualTo(320); + } + + @Test + void doesNotDeleteStoredContentWhenLogicalDeleteLosesTheRace() { + FileObjectEntity object = measurementEvidence(); + when(files.selectOne(any())).thenReturn(object); + when(files.updateById(object)).thenReturn(1); + when(files.deleteById(41L)).thenReturn(0); + + assertThatThrownBy(() -> service.deleteMeasurementEvidence(11L, 21L, 41L, 31L)) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessageContaining("已被删除"); + + verify(storage, never()).delete(any()); + } + + private FileObjectEntity measurementEvidence() { + FileObjectEntity object = new FileObjectEntity(); + object.setId(41L); + object.setTenantId(11L); + object.setBusinessType("INBOUND_MEASUREMENT_IMAGE"); + object.setBusinessId("21"); + object.setObjectKey("measurements/11/21/evidence.png"); + object.setStatus("ACTIVE"); + object.setDeleted(false); + return object; + } +} diff --git a/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java new file mode 100644 index 000000000..f2f826bf9 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasExceptionEvidenceContractTest { + @Test + void categoryAndOpenApiFreezeCustomerExceptionOwnership() throws Exception { + BusinessFileCategory category = BusinessFileCategory.require("OVERSEAS_EXCEPTION_EVIDENCE"); + assertThat(category.maxSizeBytes()).isEqualTo(10L * 1024 * 1024); + assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.STANDARD); + + try (var stream = getClass().getResourceAsStream( + "/openapi/overseas-exception-evidence-v1.yaml")) { + assertThat(stream).isNotNull(); + String yaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + assertThat(yaml).contains( + "/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}:", + "ownerScope: {type: string, enum: [CUSTOMER]}", + "businessType: {type: string, enum: [OVERSEAS_EXCEPTION_EVIDENCE]}", + "maximum: 10485760"); + } + } + + @Test + void initializationSqlHasEnoughWidthAndDocumentsTheCategory() throws Exception { + try (var stream = getClass().getResourceAsStream("/db/file.sql")) { + assertThat(stream).isNotNull(); + String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + assertThat(sql).contains("business_type VARCHAR(32)", "OVERSEAS_EXCEPTION_EVIDENCE"); + assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(32); + } + } +} diff --git a/file/src/test/java/com/baiyue/file/service/PriceBookFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/PriceBookFileServiceTest.java new file mode 100644 index 000000000..4c8637a86 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/PriceBookFileServiceTest.java @@ -0,0 +1,79 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; + +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.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PriceBookFileServiceTest { + @Mock PriceBookFileStorage storage; + @Mock FileObjectMapper files; + @InjectMocks PriceBookFileService service; + + @Test + void persistsPlatformOwnedPriceBookMetadata() { + var file = new MockMultipartFile("file", "price.xlsx", PriceBookFileStorage.XLSX_CONTENT_TYPE, + new byte[]{1}); + when(storage.store(41L, file)).thenReturn(new PriceBookFileStorage.Stored( + "price-books/0/41/a.xlsx", "price.xlsx", PriceBookFileStorage.XLSX_CONTENT_TYPE, + 1L, "hash")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + FileObjectEntity object = invocation.getArgument(0); + object.setId(99L); + return 1; + }); + + PriceBookFileService.FileMetadata result = service.upload(41L, 7L, file); + + assertThat(result.fileId()).isEqualTo(99L); + assertThat(result.businessId()).isEqualTo(41L); + assertThat(result.businessType()).isEqualTo(PriceBookFileService.BUSINESS_TYPE); + verify(files).insert(any(FileObjectEntity.class)); + } + + @Test + void rejectsDownloadWhenBusinessOwnershipDoesNotMatch() { + when(files.selectOne(any(Wrapper.class))).thenReturn(null); + assertThatThrownBy(() -> service.download(42L, 99L)) + .isInstanceOf(BizException.class).hasMessageContaining("不存在"); + } + + @Test + void downloadsOnlyOwnedPriceBookObject() { + FileObjectEntity object = object(42L, 99L); + when(files.selectOne(any(Wrapper.class))).thenReturn(object); + when(storage.read("price-books/0/42/a.xlsx")).thenReturn(new byte[]{1, 2}); + + PriceBookFileService.Download result = service.download(42L, 99L); + + assertThat(result.content()).containsExactly(1, 2); + assertThat(result.metadata().businessId()).isEqualTo(42L); + } + + private static FileObjectEntity object(long businessId, long fileId) { + FileObjectEntity object = new FileObjectEntity(); + object.setId(fileId); + object.setTenantId(0L); + object.setBusinessId(Long.toString(businessId)); + object.setBusinessType(PriceBookFileService.BUSINESS_TYPE); + object.setOriginalName("price.xlsx"); + object.setObjectKey("price-books/0/" + businessId + "/a.xlsx"); + object.setContentType(PriceBookFileStorage.XLSX_CONTENT_TYPE); + object.setSizeBytes(2L); + object.setSha256("hash"); + return object; + } +} diff --git a/file/src/test/java/com/baiyue/file/service/PriceBookFileStorageTest.java b/file/src/test/java/com/baiyue/file/service/PriceBookFileStorageTest.java new file mode 100644 index 000000000..e07285030 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/PriceBookFileStorageTest.java @@ -0,0 +1,97 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PriceBookFileStorageTest { + private static final String XLSX = PriceBookFileStorage.XLSX_CONTENT_TYPE; + @TempDir Path root; + private PriceBookFileStorage storage; + + @BeforeEach + void setUp() { + storage = new PriceBookFileStorage(); + ReflectionTestUtils.setField(storage, "configuredRoot", root); + ReflectionTestUtils.setField(storage, "maxSizeBytes", 1024L * 1024); + storage.initializeRoot(); + } + + @Test + void storesValidPackageEvenWhenTableMetadataIsMalformed() throws IOException { + Map entries = requiredEntries(); + entries.put("xl/tables/table1.xml", "".getBytes()); + var file = new MockMultipartFile("file", "2026-price.xlsx", XLSX, zip(entries)); + + PriceBookFileStorage.Stored stored = storage.store(101L, file); + + assertThat(stored.originalName()).isEqualTo("2026-price.xlsx"); + assertThat(stored.objectKey()).matches("price-books/0/101/[0-9a-f-]{36}\\.xlsx"); + assertThat(stored.sha256()).hasSize(64); + assertThat(Files.isRegularFile(root.resolve(stored.objectKey()))).isTrue(); + } + + @Test + void rejectsFakeXlsxExtension() { + var file = new MockMultipartFile("file", "fake.xlsx", XLSX, "not-a-zip".getBytes()); + assertThatThrownBy(() -> storage.store(1L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("有效XLSX"); + } + + @Test + void rejectsOversizedFile() throws IOException { + ReflectionTestUtils.setField(storage, "maxSizeBytes", 8L); + var file = new MockMultipartFile("file", "large.xlsx", XLSX, zip(requiredEntries())); + assertThatThrownBy(() -> storage.store(1L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("超过允许大小"); + } + + @Test + void rejectsTraversalFileName() throws IOException { + var file = new MockMultipartFile("file", "../price.xlsx", XLSX, zip(requiredEntries())); + assertThatThrownBy(() -> storage.store(1L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("文件名不安全"); + } + + @Test + void rejectsWrongMimeType() throws IOException { + var file = new MockMultipartFile("file", "price.xlsx", "text/plain", zip(requiredEntries())); + assertThatThrownBy(() -> storage.store(1L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("类型不正确"); + } + + private static Map requiredEntries() { + Map entries = new LinkedHashMap<>(); + entries.put("[Content_Types].xml", "".getBytes()); + entries.put("xl/workbook.xml", "".getBytes()); + entries.put("xl/_rels/workbook.xml.rels", "".getBytes()); + return entries; + } + + private static byte[] zip(Map entries) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + for (Map.Entry entry : entries.entrySet()) { + zip.putNextEntry(new ZipEntry(entry.getKey())); + zip.write(entry.getValue()); + zip.closeEntry(); + } + } + return bytes.toByteArray(); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/ProductImageStorageTest.java b/file/src/test/java/com/baiyue/file/service/ProductImageStorageTest.java new file mode 100644 index 000000000..07e6d728a --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/ProductImageStorageTest.java @@ -0,0 +1,62 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductImageStorageTest { + @TempDir Path root; + + @Test + void storesValidatedImageUnderCustomerDirectoryAndReadsItBack() throws Exception { + ProductImageStorage storage = storage(); + byte[] content = png(); + + var stored = storage.store(10001L, + new MockMultipartFile("file", "../product.png", "image/png", content)); + + assertThat(stored.objectKey()).startsWith("product-images/10001/").endsWith(".png"); + assertThat(stored.originalName()).isEqualTo("product.png"); + assertThat(storage.read(stored.objectKey())).isEqualTo(content); + } + + @Test + void rejectsEmptyOversizedForgedAndMismatchedFiles() throws Exception { + ProductImageStorage storage = storage(); + assertThatThrownBy(() -> storage.store(10001L, + new MockMultipartFile("file", "empty.png", "image/png", new byte[0]))) + .isInstanceOf(BizException.class).hasMessageContaining("不能为空"); + assertThatThrownBy(() -> storage.store(10001L, + new MockMultipartFile("file", "large.png", "image/png", new byte[5 * 1024 * 1024 + 1]))) + .isInstanceOf(BizException.class).hasMessageContaining("5MB"); + assertThatThrownBy(() -> storage.store(10001L, + new MockMultipartFile("file", "fake.jpg", "image/jpeg", "not-an-image".getBytes()))) + .isInstanceOf(BizException.class).hasMessageContaining("有效图片"); + assertThatThrownBy(() -> storage.store(10001L, + new MockMultipartFile("file", "mismatch.jpg", "image/jpeg", png()))) + .isInstanceOf(BizException.class).hasMessageContaining("不一致"); + } + + private ProductImageStorage storage() { + ProductImageStorage storage = new ProductImageStorage(); + ReflectionTestUtils.setField(storage, "configuredRoot", root); + storage.initializeRoot(); + return storage; + } + + private static byte[] png() throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + ImageIO.write(new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB), "png", output); + return output.toByteArray(); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java b/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java new file mode 100644 index 000000000..43d1b1944 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java @@ -0,0 +1,190 @@ +package com.baiyue.file.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.file.client.IamQualificationClient; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; + +import java.nio.file.Path; +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +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.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(QualificationPackageService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class QualificationPackageServiceTest { + @MockBean private LocalQualificationStorage storage; + @MockBean private IamQualificationClient iamClient; + @Resource private QualificationPackageService service; + private static final long TENANT_ID = 10001L; + private static final String BUSINESS_URL = + "/api/merchant/v1/files/qualifications/11111111-1111-1111-1111-111111111111.pdf"; + private static final String LEGAL_PERSON_URL = + "/api/merchant/v1/files/qualifications/22222222-2222-2222-2222-222222222222.png"; + + @Test + void storesBothFilesBeforeRegisteringOneIamPackage() { + var fixture = fixture(); + when(fixture.storage.store(TENANT_ID, fixture.businessLicense)).thenReturn(fixture.businessStored); + when(fixture.storage.store(TENANT_ID, fixture.legalPersonId)).thenReturn(fixture.legalPersonStored); + when(fixture.iamClient.submitRequiredPackage(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new IamQualificationClient.RequiredPackageResponse(31L, 32L, "PROFILE_REQUIRED", null, null))); + + var result = fixture.service.upload(TENANT_ID, fixture.businessLicense, fixture.legalPersonId, + "91310101abcde12345", null, null, false, false, false); + + var ordered = inOrder(fixture.storage, fixture.iamClient); + ordered.verify(fixture.storage).store(TENANT_ID, fixture.businessLicense); + ordered.verify(fixture.storage).store(TENANT_ID, fixture.legalPersonId); + var captor = ArgumentCaptor.forClass(IamQualificationClient.RequiredPackageRequest.class); + ordered.verify(fixture.iamClient).submitRequiredPackage(eq("internal-token"), captor.capture()); + assertEquals("91310101ABCDE12345", captor.getValue().unifiedSocialCreditCode()); + assertEquals("license.pdf", captor.getValue().businessLicenseOriginalFileName()); + assertEquals("legal-person.png", captor.getValue().legalPersonIdOriginalFileName()); + assertEquals(BUSINESS_URL, result.businessLicenseDocumentUrl()); + assertEquals(LEGAL_PERSON_URL, result.legalPersonIdDocumentUrl()); + } + + @Test + void legalPersonIdMayBeOmittedWithoutRelaxingTheBusinessLicense() { + var fixture = fixture(); + when(fixture.storage.store(TENANT_ID, fixture.businessLicense)).thenReturn(fixture.businessStored); + when(fixture.iamClient.submitRequiredPackage(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new IamQualificationClient.RequiredPackageResponse(31L, null, "PROFILE_REQUIRED", null, null))); + + var result = fixture.service.upload(TENANT_ID, fixture.businessLicense, null, + "91310101abcde12345", null, null, false, false, false); + + var captor = ArgumentCaptor.forClass(IamQualificationClient.RequiredPackageRequest.class); + verify(fixture.iamClient).submitRequiredPackage(eq("internal-token"), captor.capture()); + assertNull(captor.getValue().legalPersonIdDocumentUrl()); + assertNull(captor.getValue().legalPersonIdOriginalFileName()); + assertNull(captor.getValue().legalPersonIdValidTo()); + assertNull(result.legalPersonIdQualificationId()); + assertNull(result.legalPersonIdDocumentUrl()); + verify(fixture.storage, never()).store(TENANT_ID, fixture.legalPersonId); + } + + @Test + void missingBusinessLicenseAndOrphanedLegalPersonParametersStillFailClosed() { + var fixture = fixture(); + + assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, null, null, + "91310101ABCDE12345", null, null, false, false, false)); + assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, fixture.businessLicense, null, + "91310101ABCDE12345", null, LocalDate.of(2030, 1, 1), false, false, false)); + assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, fixture.businessLicense, null, + "91310101ABCDE12345", null, null, false, true, false)); + + verify(fixture.iamClient, never()).submitRequiredPackage(any(), any()); + } + + @Test + void secondStorageFailureDeletesTheFirstFileAndNeverCallsIam() { + var fixture = fixture(); + when(fixture.storage.store(TENANT_ID, fixture.businessLicense)).thenReturn(fixture.businessStored); + when(fixture.storage.store(TENANT_ID, fixture.legalPersonId)).thenThrow(new BizException("disk full")); + + assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, + fixture.businessLicense, fixture.legalPersonId, "91310101ABCDE12345", + null, null, false, false, false)); + + verify(fixture.storage).delete(fixture.businessStored); + verify(fixture.iamClient, never()).submitRequiredPackage(any(), any()); + } + + @Test + void explicitIamRejectionDeletesBothUnreferencedFiles() { + var fixture = storedFixture(); + when(fixture.iamClient.submitRequiredPackage(eq("internal-token"), any())).thenReturn( + ApiResponse.failure(ResultCode.CONFLICT, "duplicate", null, null)); + + var exception = assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, + fixture.businessLicense, fixture.legalPersonId, "91310101ABCDE12345", + null, null, false, false, false)); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(fixture.storage).delete(fixture.businessStored); + verify(fixture.storage).delete(fixture.legalPersonStored); + } + + @Test + void unknownIamOutcomePreservesBothFilesForReconciliation() { + var fixture = storedFixture(); + when(fixture.iamClient.submitRequiredPackage(eq("internal-token"), any())) + .thenThrow(new RuntimeException("timeout")); + + var exception = assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, + fixture.businessLicense, fixture.legalPersonId, "91310101ABCDE12345", + null, null, false, false, false)); + + assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode()); + verify(fixture.storage, never()).delete(fixture.businessStored); + verify(fixture.storage, never()).delete(fixture.legalPersonStored); + } + + @Test + void platformSuccessCleansReplacedFilesOnlyAfterIamCommit() { + var fixture = storedFixture(); + var oldBusinessUrl = "/api/merchant/v1/files/qualifications/33333333-3333-3333-3333-333333333333.pdf"; + var oldLegalPersonUrl = "/api/merchant/v1/files/qualifications/44444444-4444-4444-4444-444444444444.png"; + when(fixture.iamClient.submitPlatformRequiredPackage(eq("internal-token"), any())).thenReturn( + ApiResponse.success(new IamQualificationClient.RequiredPackageResponse( + 31L, 32L, "PROFILE_REQUIRED", oldBusinessUrl, oldLegalPersonUrl))); + + fixture.service.upload(TENANT_ID, fixture.businessLicense, fixture.legalPersonId, + "91310101ABCDE12345", null, null, true, true, true); + + var ordered = inOrder(fixture.iamClient, fixture.storage); + ordered.verify(fixture.iamClient).submitPlatformRequiredPackage(eq("internal-token"), any()); + ordered.verify(fixture.storage).deleteByDocumentUrl(TENANT_ID, oldBusinessUrl); + ordered.verify(fixture.storage).deleteByDocumentUrl(TENANT_ID, oldLegalPersonUrl); + } + + private Fixture storedFixture() { + var fixture = fixture(); + when(fixture.storage.store(TENANT_ID, fixture.businessLicense)).thenReturn(fixture.businessStored); + when(fixture.storage.store(TENANT_ID, fixture.legalPersonId)).thenReturn(fixture.legalPersonStored); + return fixture; + } + + private Fixture fixture() { + reset(storage, iamClient); + var businessLicense = new MockMultipartFile( + "businessLicense", "license.pdf", "application/pdf", "%PDF-1.4".getBytes()); + var legalPersonId = new MockMultipartFile( + "legalPersonId", "legal-person.png", "image/png", new byte[] {1, 2, 3}); + var businessStored = new LocalQualificationStorage.StoredFile( + Path.of("business.pdf"), "business.pdf", BUSINESS_URL); + var legalPersonStored = new LocalQualificationStorage.StoredFile( + Path.of("legal-person.png"), "legal-person.png", LEGAL_PERSON_URL); + return new Fixture(service, storage, iamClient, businessLicense, legalPersonId, + businessStored, legalPersonStored); + } + + private record Fixture(QualificationPackageService service, + LocalQualificationStorage storage, + IamQualificationClient iamClient, + MockMultipartFile businessLicense, + MockMultipartFile legalPersonId, + LocalQualificationStorage.StoredFile businessStored, + LocalQualificationStorage.StoredFile legalPersonStored) { } +} diff --git a/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java b/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java new file mode 100644 index 000000000..1b8c009ed --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java @@ -0,0 +1,103 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.util.Set; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; + +class RelabelDocumentFileTest { + @TempDir Path root; + + @ParameterizedTest + @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"}) + void pdfPolicyChecksContentTypeSignatureAndSize(BusinessFileCategory category) { + BusinessFileStorage storage = storage(); + byte[] exactLimit = new byte[10 * 1024 * 1024]; + System.arraycopy("%PDF-1.7".getBytes(StandardCharsets.US_ASCII), 0, exactLimit, 0, 8); + var file = new MockMultipartFile("file", "label.pdf", "application/pdf", exactLimit); + var saved = storage.store(11L, "41", file, category); + assertThat(saved.size()).isEqualTo(10 * 1024 * 1024); + assertThat(saved.objectKey()).endsWith(".pdf"); + assertThat(storage.read(saved.objectKey())).isEqualTo(exactLimit); + for (String type : new String[]{"image/png", "text/plain", "application/octet-stream"}) { + assertThatThrownBy(() -> storage.store(11L, "41", + new MockMultipartFile("file", "label.pdf", type, exactLimit), category)) + .isInstanceOf(BizException.class).hasMessageContaining("仅支持PDF"); + } + assertThatThrownBy(() -> storage.store(11L, "41", + new MockMultipartFile("file", "fake.pdf", "application/pdf", "not-pdf".getBytes()), category)) + .isInstanceOf(BizException.class).hasMessageContaining("不是有效PDF"); + assertThatThrownBy(() -> storage.store(11L, "41", + new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]), category)) + .isInstanceOf(BizException.class).hasMessageContaining("不能为空"); + assertThatThrownBy(() -> storage.store(11L, "41", + new MockMultipartFile("file", "large.pdf", "application/pdf", new byte[exactLimit.length + 1]), category)) + .isInstanceOf(BizException.class).hasMessageContaining("10MB"); + } + + @ParameterizedTest + @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"}) + void uploadAndReadRequireExactCustomerBusinessTypeBusinessAndFile(BusinessFileCategory category) throws Exception { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "relabel-test"), FileObjectEntity.class); + FileObjectMapper files = mock(FileObjectMapper.class); + InternalBusinessFileService service = new InternalBusinessFileService(); + ReflectionTestUtils.setField(service, "files", files); + ReflectionTestUtils.setField(service, "storage", storage()); + FileObjectEntity[] persisted = new FileObjectEntity[1]; + when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> { + persisted[0] = call.getArgument(0); persisted[0].setId(801L); return 1; + }); + var pdf = new MockMultipartFile("file", "label.pdf", "application/pdf", "%PDF-1.7".getBytes()); + var saved = service.upload(11L, category.name(), "41", 7L, pdf); + assertThat(saved.ownerScope()).isEqualTo("CUSTOMER"); + assertThat(saved.tenantId()).isEqualTo(11L); + assertThat(saved.businessType()).isEqualTo(category.name()); + assertThat(saved.businessId()).isEqualTo("41"); + when(files.selectOne(any())).thenAnswer(call -> { + LambdaQueryWrapper query = call.getArgument(0); + assertThat(query.getSqlSegment()).contains("tenant_id", "business_type", "business_id", "id", "status", "deleted"); + return Set.copyOf(query.getParamNameValuePairs().values()) + .equals(Set.of(11L, category.name(), "41", 801L, "ACTIVE", false)) ? persisted[0] : null; + }); + assertThat(service.download(11L, category.name(), "41", 801L).content()).isEqualTo(pdf.getBytes()); + assertThatThrownBy(() -> service.download(12L, category.name(), "41", 801L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(11L, category.name(), "42", 801L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(11L, category.name(), "41", 802L)).isInstanceOf(BizException.class); + String other = category == BusinessFileCategory.OVERSEAS_RELABEL_TOTAL_DOCUMENT + ? "OVERSEAS_RELABEL_ITEM_DOCUMENT" : "OVERSEAS_RELABEL_TOTAL_DOCUMENT"; + assertThatThrownBy(() -> service.download(11L, other, "41", 801L)).isInstanceOf(BizException.class); + clearInvocations(files); + for (long owner : new long[]{0L, -1L}) { + assertThatThrownBy(() -> service.upload(owner, category.name(), "41", 7L, pdf)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(owner, category.name(), "41", 801L)).isInstanceOf(BizException.class); + } + for (String id : new String[]{"0", "-1", "invalid", "9223372036854775808"}) { + assertThatThrownBy(() -> service.upload(11L, category.name(), id, 7L, pdf)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(11L, category.name(), id, 801L)).isInstanceOf(BizException.class); + } + verifyNoInteractions(files); + } + + private BusinessFileStorage storage() { + BusinessFileStorage storage = new BusinessFileStorage(); + ReflectionTestUtils.setField(storage, "configuredRoot", root); + storage.initializeRoot(); + return storage; + } +} diff --git a/file/src/test/java/com/baiyue/file/service/SizeLimitedOutputStreamTest.java b/file/src/test/java/com/baiyue/file/service/SizeLimitedOutputStreamTest.java new file mode 100644 index 000000000..f38e8f069 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/SizeLimitedOutputStreamTest.java @@ -0,0 +1,19 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SizeLimitedOutputStreamTest { + @Test void rejectsTheWriteBeforeAnyBytesBeyondTheLimitReachStorage() throws Exception { + ByteArrayOutputStream target=new ByteArrayOutputStream(); + SizeLimitedOutputStream output=new SizeLimitedOutputStream(target,4); + output.write(new byte[]{1,2,3,4}); + + assertThrows(SizeLimitedOutputStream.ExportSizeExceededException.class,() -> output.write(5)); + assertArrayEquals(new byte[]{1,2,3,4},target.toByteArray()); + } +} diff --git a/finance/AGENTS.md b/finance/AGENTS.md new file mode 100644 index 000000000..87820bedc --- /dev/null +++ b/finance/AGENTS.md @@ -0,0 +1,60 @@ +# Finance Service Instructions + +## 模块定位 + +- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。 +- 产品范围先读 `../../docs/财务管理/财务管理产品确认表.md` 与 `财务核心五页产品与接口验收契约.md`,跨服务契约再读 `Firstleg-Finance跨服务契约.md`;专题 README、证据矩阵和测试环境报告只用于状态或验收问题,不作为普通开发首读。 +- Firstleg 拥有入库/柜次业务费用和履约事实,File 拥有凭证文件;Finance 通过内部接口读取或消费快照,不跨库查询,也不复制上游业务状态机。 + +## 快速查询 + +| 目标 | 首选入口 | +|---|---| +| 平台账户、费用池、账单、收款与成本 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 | +| 客户资金账户、授信与充值 | `controller/ClientFinanceWalletController.java`、`controller/PlatformFinanceWalletController.java`、`controller/InternalFinanceWalletController.java`、`service/FinanceWalletService.java`、`dto/FinanceWalletDtos.java`;支持静态收款码、截图申请、平台审核、手工充扣、授信配置及供业务模块使用的冻结/结算/释放,第三方支付回调尚未接入 | +| 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` | +| 应收款月度统计 | `controller/ReceivableStatisticsController.java`、`service/ReceivableStatisticsService.java`、`dto/ReceivableStatisticsDtos.java`;按结算单生成月份和客户汇总 CNY 正式应收结算单 | +| 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` | +| 对账单客户确认 | `controller/ClientFinanceController.java`、`service/ReconciliationStatementService.java`;客户接口 `/api/client/finance/v1/statements/{statementId}/confirm` | +| 财务单据编号 | `service/FinanceDocumentNumberGenerator.java`;对账单、应收结算单、应付结算单、充值申请、账户流水分别使用 `REC`、`AR`、`AP`、`RC`、`AC` 前缀 | +| Finance 导出数据 Provider | `controller/FinanceExportProviderController.java`、`service/FinanceExportProviderService.java`;由 File `ExportProviderClient` 按已登记 `exportType` 分批拉取,不从 Admin 直接生成文件 | +| 财务 OpenAPI | 五页 `../../docs/财务管理/openapi/finance-core-five-pages-v1.yaml`;人工财务 `finance-f1-manual-v1.yaml`;资金账户 `finance-wallet-v1.yaml`;柜级聚合 `src/main/resources/openapi/finance-container-settlement-v1.yaml` | +| 业务费用来源事件入口 | Firstleg:`controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java`;海外完整快照:`controller/InternalBusinessFeeSourceController.java` 的 `/internal/finance/v1/business-fee-source-events`、`service/BusinessFeeSourceEventService.java`;事件模型 `../common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java` | +| 查询模型、客户可见性与账单行入仓码 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java`;`StatementLine.inboundCode` 从对应费用来源不可变快照读取,供 Client 跳转客户隔离的入仓关系详情 | +| 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` | +| Firstleg 费用来源与柜次成本快照 | `client/FirstlegFeeSourceClient.java`、`client/FirstlegCostSnapshotClient.java`;生产端见 `../firstleg/AGENTS.md` | +| Warehouse 供应商校验与名称快照 | `client/WarehouseSupplierClient.java`、`service/FinanceSupplierValidator.java`;供应商事实来源及类别契约见 `../warehouse/AGENTS.md`、`../warehouse/src/main/resources/openapi/supplier-v1.yaml` | +| 收款/成本凭证 | `client/FinanceFileClient.java`;文件归属与下载规则见 `../file/AGENTS.md` | +| 异常与响应包装 | `controller/FinanceExceptionHandler.java`、`controller/FinanceResponses.java` | +| 数据库 | `src/main/resources/db/finance.sql`、`src/main/resources/db/fee-source-event-migration.sql`;存量/测试环境统一入口为 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | +| 配置与网关 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/finance-service.yml`、`../gateway/src/main/resources/application.yml` | +| 独立服务部署链 | `../Jenkinsfile`、`../Jenkinsfile.service`、`../deploy/service-map.yml`、`../deploy/docker-compose.yml`、`../deploy/scripts/deploy-services.sh`;契约检查见 `../scripts/finance-deployment-contract.test.mjs`,部署授权边界见 `../../docs/测试环境Jenkins部署指南.md` | +| 测试环境全链路验收模块 | `../scripts/admin-test-env-modules/finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs` 及同名 `*-contract.test.mjs`;仅表示验收入口,不代表环境已部署或验收通过 | +| 测试 | `src/test/java/com/baiyue/finance/`;执行 `mvn -pl finance -am test` | + +## 契约与边界 + +- 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。 +- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。 +- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。 +- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。 +- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。 +- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。 +- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。 +- 海外业务费用事件只接受非 `FIRSTLEG` 来源、`APPROVED` 审核快照和 `PUBLISHED` 活跃版本或明确 `VOIDED` 动作;按 `eventId + payloadHash` 防重放冲突,并以 `sourceSystem + sourceType + sourceFeeId + sourceVersion` 单调落库。事件携带完整客户、业务、方向、供应商、付款与来源哈希事实,Finance 不跨区同步回查 Lastleg。 +- 海外 `PAYABLE + DIRECT_PAID` 审核版本必须生成 `BUSINESS_DIRECT_PAID` 已确认成本并计入已付成本/客户流水;不得进入 ON_ACCOUNT 应付池或再次生成 AP 结算。调整或作废须作废旧版本成本;客户隔离、同键异载荷冲突和已生成单据冻结不得放宽。 +- 整柜成本分摊使用 Firstleg 返回的真实客户归属与订单体积快照;不得信任前端提交的订单归属或体积。凭证必须由 File 元数据校验客户和业务类型,依赖不可用时不得静默跳过。 +- 应付费用来源、成本登记和应付结算单引用的供应商必须通过 Warehouse 校验 ID 与 `ACTIVE` 状态;费用项目 `supplierCategory` 为空时允许任意启用供应商,非空时严格校验类别,不得从费用编码或名称猜测。名称快照只能取 Warehouse 返回值,不能接受自由文本替代。Warehouse 不可用、供应商停用或非空类别不匹配时必须失败关闭;修改该链路时同步检查事件消费、人工成本、结算创建/付款、Nacos 客户端地址和双方契约测试。 +- 修改平台或客户 DTO 时同步检查 Admin `../../apps/admin-web/src/modules/finance/`、Client `../../apps/client-web/src/modules/finance/`、Gateway 路由、权限码和契约测试;客户响应不得暴露供应商成本、内部备注、平台凭证或其他客户数据。 +- 客户账单明细以业务 `inboundCode` 展示和联查入仓关系;缺失时返回空并显示“—”,不得把 `inboundId`、`sourceId` 或其他内部 ID 当业务编号回退。Finance 只从同客户费用来源快照取值,Client 通过 `first-leg/public.ts` 复用客户隔离的详情组件。 +- 修改 Finance 导出字段、筛选或 `exportType` 时,同步检查 `FinanceExportProviderService`、File `ExportDefinitionRegistry`/`ExportProviderClient`、Admin `modules/finance/` 与 `modules/downloads/`、导出权限和双方契约测试;页面只创建异步下载任务,不绕过 File 导出中心直接拼装业务文件。 +- Finance 费用池必须保留上游 `direction`、客户/供应商名称和付款证据快照;Firstleg 的 `DIRECT_PAID` 校验仍归 Firstleg。Finance 上传端只创建受客户范围约束的 `FINANCE_VOUCHER` 文件,不替代上游费用审核与付款快照校验。 + +## 验证 + +- 文档或索引变更只做路径核对和 `git diff --check`;代码变更至少运行 `mvn -pl finance -am test`。 +- 跨服务契约变更同时运行 Finance 与直接生产者/消费者测试。未经用户明确要求,不在本机启动 Java、Nacos 或数据库。 diff --git a/finance/pom.xml b/finance/pom.xml new file mode 100644 index 000000000..8da2c4701 --- /dev/null +++ b/finance/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + finance-service + + com.baiyue.sharedcommon-event-contract${project.version} + com.baiyue.sharedbaiyue-common-web${project.version} + com.baiyue.sharedbaiyue-common-openapi${project.version} + com.baiyue.sharedbaiyue-common-security${project.version} + com.baiyue.sharedbaiyue-common-audit${project.version} + com.baomidoumybatis-plus-spring-boot3-starter + org.springframework.bootspring-boot-starter-jdbc + com.mysqlmysql-connector-jruntime + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery + org.springframework.cloudspring-cloud-starter-openfeign + org.springframework.cloudspring-cloud-starter-loadbalancer + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config + org.springframework.bootspring-boot-starter-actuator + org.springframework.bootspring-boot-starter-testtest + + + + org.springframework.bootspring-boot-maven-plugin + + + diff --git a/finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java b/finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java new file mode 100644 index 000000000..b29f68e94 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java @@ -0,0 +1,20 @@ +package com.baiyue.finance; + +import org.apache.ibatis.annotations.Mapper; +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; + +/** Finance 独立服务启动入口。首期只承载人工账单、收款和成本台账。 */ +@SpringBootApplication +@MapperScan(basePackages = "com.baiyue.finance", annotationClass = Mapper.class) +@EnableFeignClients(basePackages = "com.baiyue.finance.client") +@EnableScheduling +public class BaiyueFinanceApplication { + + public static void main(String[] args) { + SpringApplication.run(BaiyueFinanceApplication.class, args); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java b/finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java new file mode 100644 index 000000000..4ea174f4b --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java @@ -0,0 +1,22 @@ +package com.baiyue.finance.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.ResponseEntity; + +@FeignClient(name="file-service", contextId="financeFileClient", url="${baiyue.clients.file.url:}", path="/internal/file/v1/business-files") +public interface FinanceFileClient { + @PostMapping(value="/{tenantId}/FINANCE_VOUCHER/{customerId}", consumes="multipart/form-data") + ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token, @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, @PathVariable long customerId, @RequestPart("file") MultipartFile file); + @GetMapping("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}") + ApiResponse metadata(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long tenantId, + @PathVariable long customerId, @PathVariable long fileId); + @GetMapping("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}/content") + ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, @PathVariable long customerId, + @PathVariable long fileId); + record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String sha256, String contentType, Long sizeBytes) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java b/finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java new file mode 100644 index 000000000..2b5ce3606 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java @@ -0,0 +1,19 @@ +package com.baiyue.finance.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.List; + +@FeignClient(name="firstleg-service", contextId="financeFirstlegCostSnapshotClient", url="${baiyue.clients.firstleg.url:}", path="/internal/firstleg/v1/finance-cost-snapshots") +public interface FirstlegCostSnapshotClient { + @GetMapping("/customers/{customerId}/loading-plans/{loadingPlanId}") + ApiResponse loadingPlan(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long customerId, + @PathVariable long loadingPlanId); + record ContainerSnapshot(long loadingPlanId, long containerBookingId, long customerId, + List orders) {} + record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java b/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java new file mode 100644 index 000000000..94621c0ec --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java @@ -0,0 +1,58 @@ +package com.baiyue.finance.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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@FeignClient(name = "firstleg-service", contextId = "financeFirstlegFeeSourceClient", + url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1") +public interface FirstlegFeeSourceClient { + @GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot") + ApiResponse inbound(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long inboundId, @PathVariable long feeId, + @RequestParam int sourceVersion); + + @GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot") + ApiResponse container(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long containerId, @PathVariable long feeId, + @RequestParam int sourceVersion); + + @PostMapping("/fee-sources/commands/void") + ApiResponse voidSource( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @RequestHeader("X-Request-Hash") String requestHash, + @RequestBody VoidCommand request); + + record VoidCommand(String sourceType, long sourceFeeId, int sourceVersion, + long customerId, Long inboundId, Long containerId, + long operatorId, String operatorName, String reason) {} + + record CommandResult(String sourceType, long sourceFeeId, int sourceVersion, + String status, boolean replayed) {} + + record Snapshot(long tenantId, String sourceType, long feeId, int feeVersion, + Long inboundId, String inboundCode, Long orderId, Long containerId, + Long containerBookingId, String containerNo, long customerId, + String customerName, Long supplierId, String supplierName, + String supplierCategory, + String settlementMode, LocalDateTime paidAt, String paymentMethod, + String paymentReferenceNo, List paymentVoucherFileIds, + Long feeItemId, String feeItemCode, + String feeItemName, String direction, LocalDate businessDate, + String currency, BigDecimal totalAmount, String reviewStatus, + String publicationStatus, + LocalDateTime publishedAt, BigDecimal chargeableQuantity, + String chargeUnit, BigDecimal actualGrossWeightKg, + BigDecimal actualVolumeCbm, String sourceHash, int snapshotVersion) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java b/finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java new file mode 100644 index 000000000..c0591cbe2 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java @@ -0,0 +1,24 @@ +package com.baiyue.finance.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "iam-service", contextId = "financeIamCustomerClient", + url = "${baiyue.clients.iam.url:}", path = "/internal/iam") +public interface IamCustomerClient { + @GetMapping("/finance-customers") + ApiResponse> page( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestParam int page, + @RequestParam int size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) Long customerId); + + record Customer(long id, String tenantCode, String tenantName, String status, + String contactName, String contactPhone) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java b/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java new file mode 100644 index 000000000..1aab9c099 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java @@ -0,0 +1,18 @@ +package com.baiyue.finance.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(name = "event-bridge-service", contextId = "financeWalletResultEventBridgeClient", + url = "${OVERSEAS_EVENT_BRIDGE_URL:${baiyue.wallet-event.event-bridge-url:}}") +public interface WalletResultEventBridgeClient { + @PostMapping("/internal/event-bridge/events") + ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + + record Acknowledgement(String eventId, String result, String receivedAt) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java b/finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java new file mode 100644 index 000000000..674af5986 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java @@ -0,0 +1,20 @@ +package com.baiyue.finance.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 org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "warehouse-service", contextId = "financeWarehouseSupplierClient", + url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/suppliers") +public interface WarehouseSupplierClient { + @GetMapping("/{supplierId}") + ApiResponse get(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long supplierId, + @RequestParam(defaultValue = "true") boolean requireActive, + @RequestParam(required = false) String category); + + record SupplierSnapshot(long supplierId, String name, String category, String status) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java new file mode 100644 index 000000000..f266046d7 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java @@ -0,0 +1,71 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.service.FinanceService; +import com.baiyue.finance.service.FinanceQueryService; +import com.baiyue.finance.service.ReconciliationStatementService; +import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "客户财务只读") +@RestController +@RequestMapping("/api/client/finance/v1") +public class ClientFinanceController { + private final FinanceService service; + private final FinanceQueryService queryService; + private final ReconciliationStatementService reconciliationStatementService; + public ClientFinanceController(FinanceService service, FinanceQueryService queryService, + ReconciliationStatementService reconciliationStatementService) { + this.service = service; + this.queryService = queryService; + this.reconciliationStatementService = reconciliationStatementService; + } + + @GetMapping("/bills") + @PreAuthorize("hasAuthority('merchant:finance:statement:list')") + @Operation(summary = "客户只读查询账单") + public ApiResponse bills(@AuthenticationPrincipal AuthenticatedUser user) { return FinanceResponses.success(queryService.statements(user.tenantId(), true)); } + + @GetMapping("/bills/{billId}") + @PreAuthorize("hasAuthority('merchant:finance:statement:view')") + @Operation(summary = "客户只读查看账单详情") + public ApiResponse bill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId) { return FinanceResponses.success(queryService.statement(user.tenantId(), billId, true)); } + + @GetMapping("/receipts") + @PreAuthorize("hasAuthority('merchant:finance:statement:view')") + @Operation(summary = "客户只读查询收款记录") + public ApiResponse receipts(@AuthenticationPrincipal AuthenticatedUser user) { + return FinanceResponses.success(queryService.clientReceipts(user.tenantId())); + } + + @GetMapping("/statements") + @PreAuthorize("hasAuthority('merchant:finance:statement:list')") + @Operation(summary = "客户只读查询对账单") + public ApiResponse statements(@AuthenticationPrincipal AuthenticatedUser user) { + return FinanceResponses.success(queryService.statements(user.tenantId(), true)); + } + + @PostMapping("/statements/{statementId}/confirm") + @PreAuthorize("hasAuthority('merchant:finance:statement:confirm')") + @Operation(summary = "客户确认本人已出账对账单") + public ApiResponse confirmStatement(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long statementId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody CustomerConfirmationRequest request) { + return FinanceResponses.success(reconciliationStatementService.confirmForCustomer( + user.tenantId(), user.userId(), statementId, idempotencyKey, request)); + } + + @GetMapping("/receivable-settlements") + @PreAuthorize("hasAuthority('merchant:finance:statement:list')") + @Operation(summary = "客户只读查询结算应收") + public ApiResponse receivableSettlements(@AuthenticationPrincipal AuthenticatedUser user) { + return FinanceResponses.success(queryService.receivableSettlements(user.tenantId(), true)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceWalletController.java b/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceWalletController.java new file mode 100644 index 000000000..2f6d44b2e --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceWalletController.java @@ -0,0 +1,64 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.service.FinanceWalletService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; + +@RestController +@RequestMapping("/api/client/finance/v1") +public class ClientFinanceWalletController { + private final FinanceWalletService service; + + public ClientFinanceWalletController(FinanceWalletService service) { this.service = service; } + + @GetMapping("/account") + @PreAuthorize("hasAuthority('merchant:finance:account:view')") + public ApiResponse account(@AuthenticationPrincipal AuthenticatedUser user) { + return FinanceResponses.success(service.account(user.tenantId())); + } + + @GetMapping("/account/ledger") + @PreAuthorize("hasAuthority('merchant:finance:account:view')") + public ApiResponse ledger(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return FinanceResponses.success(service.ledger(user.tenantId(), page, size)); + } + + @GetMapping("/recharge-requests") + @PreAuthorize("hasAuthority('merchant:finance:account:view')") + public ApiResponse requests(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return FinanceResponses.success(service.rechargeRequests(user.tenantId(), page, size)); + } + + @GetMapping("/recharge-channels") + @PreAuthorize("hasAuthority('merchant:finance:recharge:create')") + public ApiResponse channels() { return FinanceResponses.success(service.channels()); } + + @PostMapping(value = "/recharge-requests", consumes = "multipart/form-data") + @PreAuthorize("hasAuthority('merchant:finance:recharge:create')") + public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @RequestParam BigDecimal amount, + @RequestParam String paymentMethod, + @RequestPart("file") MultipartFile file) { + return FinanceResponses.success(service.submitRecharge(user.tenantId(), user.userId(), user.displayName(), + idempotencyKey, amount, paymentMethod, file)); + } + + @GetMapping("/recharge-requests/{requestId}/voucher") + @PreAuthorize("hasAuthority('merchant:finance:account:view')") + public ResponseEntity voucher(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long requestId) { + return service.clientVoucher(user.tenantId(), requestId); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinanceExceptionHandler.java b/finance/src/main/java/com/baiyue/finance/controller/FinanceExceptionHandler.java new file mode 100644 index 000000000..732174ecf --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/FinanceExceptionHandler.java @@ -0,0 +1,60 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.finance.service.FinanceEventException; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.Instant; +import java.util.UUID; + +/** Finance-owned error codes stay stable even when the common result catalog evolves. */ +@Order(Ordered.HIGHEST_PRECEDENCE) +@RestControllerAdvice(basePackages = "com.baiyue.finance.controller") +public class FinanceExceptionHandler { + @ExceptionHandler(FinanceEventException.class) + public ResponseEntity> event(FinanceEventException error) { + return ResponseEntity.ok(response(error.code(), error.getMessage())); + } + @ExceptionHandler(BizException.class) + public ResponseEntity> business(BizException error) { + ResultCode result = error.getResultCode(); + HttpStatus status = result == ResultCode.SERVICE_UNAVAILABLE ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK; + return ResponseEntity.status(status).body(response(financeCode(result), error.getMessage())); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> validation(IllegalArgumentException error) { + return ResponseEntity.ok(response("FIN_400", error.getMessage())); + } + + private static String financeCode(ResultCode result) { + return switch (result) { + case NOT_FOUND -> "FIN_404"; + case CONFLICT -> "FIN_409"; + case SERVICE_UNAVAILABLE -> "FIN_503"; + case VALIDATION_FAILED, BAD_REQUEST -> "FIN_400"; + default -> "FIN_001"; + }; + } + + private static ApiResponse response(String code, String message) { + String requestId = first(MDC.get("requestId"), MDC.get("request_id")); + if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString(); + String traceId = first(MDC.get("traceId"), MDC.get("trace_id")); + if (!StringUtils.hasText(traceId)) traceId = requestId; + return new ApiResponse<>(code, message, null, requestId, traceId, Instant.now()); + } + + private static String first(String first, String second) { + return StringUtils.hasText(first) ? first : second; + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinanceExportProviderController.java b/finance/src/main/java/com/baiyue/finance/controller/FinanceExportProviderController.java new file mode 100644 index 000000000..9ccc627b9 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/FinanceExportProviderController.java @@ -0,0 +1,51 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.finance.service.FinanceExportProviderService; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +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; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** Internal export provider consumed only by File Service. */ +@RestController +@RequestMapping("/internal/exports/v1") +public class FinanceExportProviderController { + private final FinanceExportProviderService service; + private final String configuredToken; + private byte[] internalToken; + + public FinanceExportProviderController(FinanceExportProviderService service, + @Value("${baiyue.security.internal-service-token}") String configuredToken) { + this.service = service; + this.configuredToken = configuredToken; + } + + @PostConstruct + void initializeToken() { + byte[] configured = configuredToken == null ? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8); + if (configured.length < 32) throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + internalToken = configured; + } + + @PostMapping("/{exportType}/batches") + public ApiResponse batch( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable String exportType, + @RequestBody ExportBatchRequest request) { + byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(internalToken, supplied)) throw new BizException(ResultCode.FORBIDDEN, "Invalid internal service token"); + return ApiResponse.success(service.exportBatch(exportType, request)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java new file mode 100644 index 000000000..1dffaf72d --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java @@ -0,0 +1,127 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest; +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest; +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest; +import com.baiyue.finance.service.FinancePhase1OperationService; +import com.baiyue.finance.service.FinanceCustomerAccountService; +import org.springframework.beans.factory.annotation.Autowired; +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.PutMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +@RestController +@RequestMapping("/api/platform/finance/v1") +public class FinancePhase1OperationController { + private final FinancePhase1OperationService service; + private final FinanceCustomerAccountService customerAccountService; + + @Autowired + public FinancePhase1OperationController(FinancePhase1OperationService service, + FinanceCustomerAccountService customerAccountService) { + this.service = service; + this.customerAccountService = customerAccountService; + } + + @GetMapping("/accounts") + @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") + public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int pageSize, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) Long customerId) { + return customerAccountService.list(page, pageSize, keyword, status, customerId); + } + + @GetMapping("/accounts/{customerId}/ledger") + @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") + public Object listDerivedCustomerAccountLedger(@PathVariable long customerId, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int pageSize, + @RequestParam(required = false) String businessType, + @RequestParam(required = false) LocalDate startDate, + @RequestParam(required = false) LocalDate endDate) { + return service.listDerivedCustomerAccountLedger(customerId, page, pageSize, businessType, startDate, endDate); + } + + @GetMapping("/fee-sources") + @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") + public Object listFinanceFeeSources(@RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int pageSize, + @RequestParam(required = false) Long customerId, + @RequestParam(required = false) String direction, + @RequestParam(required = false) String status, + @RequestParam(required = false) String reviewStatus, + @RequestParam(required = false) Boolean statementGenerated, + @RequestParam(required = false) LocalDate businessDateFrom, + @RequestParam(required = false) LocalDate businessDateTo, + @RequestParam(required = false) String keyword) { + return service.listFinanceFeeSources(page, pageSize, customerId, direction, status, reviewStatus, + statementGenerated, businessDateFrom, businessDateTo, keyword); + } + + @GetMapping("/fee-sources/{feeSourceId}") + @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") + public ApiResponse getFinanceFeeSource(@PathVariable long feeSourceId) { + return FinanceResponses.success(service.getFinanceFeeSource(feeSourceId)); + } + + @PostMapping("/fee-sources/{feeSourceId}/review") + @PreAuthorize("hasAuthority('platform:finance:fee-source:review')") + public ApiResponse reviewFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long feeSourceId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ReviewRequest request) { + return FinanceResponses.success(service.reviewFinanceFeeSource(feeSourceId, user.userId(), key, request)); + } + + @PutMapping("/fee-sources/{feeSourceId}") + @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") + public ApiResponse editFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long feeSourceId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody FeeSourceEditRequest request) { + return FinanceResponses.success(service.editFinanceFeeSource(feeSourceId, user.userId(), key, request, false)); + } + + @PostMapping("/fee-sources/{feeSourceId}/submit") + @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") + public ApiResponse submitFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long feeSourceId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody FeeSourceActionRequest request) { + return FinanceResponses.success(service.submitFinanceFeeSource(feeSourceId, user.userId(), key, request)); + } + + @PostMapping("/fee-sources/{feeSourceId}/adjust") + @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") + public ApiResponse adjustFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long feeSourceId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody FeeSourceEditRequest request) { + return FinanceResponses.success(service.editFinanceFeeSource(feeSourceId, user.userId(), key, request, true)); + } + + @PostMapping("/fee-sources/{feeSourceId}/void") + @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") + public ApiResponse voidFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long feeSourceId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody FeeSourceActionRequest request) { + return FinanceResponses.success(service.voidFinanceFeeSource( + feeSourceId, user.userId(), user.username(), key, request)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinanceResponses.java b/finance/src/main/java/com/baiyue/finance/controller/FinanceResponses.java new file mode 100644 index 000000000..9a1ae5941 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/FinanceResponses.java @@ -0,0 +1,23 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import org.slf4j.MDC; +import org.springframework.util.StringUtils; + +import java.util.UUID; + +final class FinanceResponses { + private FinanceResponses() {} + + static ApiResponse success(T data) { + String requestId = first(MDC.get("requestId"), MDC.get("request_id")); + if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString(); + String traceId = first(MDC.get("traceId"), MDC.get("trace_id")); + if (!StringUtils.hasText(traceId)) traceId = requestId; + return ApiResponse.success(data, requestId, traceId); + } + + private static String first(String first, String second) { + return StringUtils.hasText(first) ? first : second; + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java b/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java new file mode 100644 index 000000000..fd335a729 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java @@ -0,0 +1,132 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetailEnvelope; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementListEnvelope; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest; +import com.baiyue.finance.service.FinanceSettlementService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; +import org.slf4j.MDC; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.util.StringUtils; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/platform/finance/v1") +public class FinanceSettlementController { + private final FinanceSettlementService service; + + public FinanceSettlementController(FinanceSettlementService service) { + this.service = service; + } + + @GetMapping("/receivable-settlements") + @PreAuthorize("hasAuthority('platform:finance:settlement-ar:list')") + @Operation(summary = "查询正式应收结算单") + public SettlementListEnvelope receivableSettlements( + @Parameter(description = "可选客户筛选;省略时查询全部客户") + @RequestParam(required = false) Long customerId) { + return success(service.listSettlements("RECEIVABLE", customerId)); + } + + @GetMapping("/payable-settlements") + @PreAuthorize("hasAuthority('platform:finance:settlement-ap:list')") + @Operation(summary = "查询正式应付结算单") + public SettlementListEnvelope payableSettlements( + @Parameter(description = "可选客户筛选;省略时查询全部客户") + @RequestParam(required = false) Long customerId) { + return success(service.listSettlements("PAYABLE", customerId)); + } + + @PostMapping("/receivable-settlements") + @PreAuthorize("hasAuthority('platform:finance:settlement-ar:create')") + public SettlementDetailEnvelope createReceivableSettlement( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody CreateReceivableSettlementRequest request) { + return success(service.createReceivableSettlement(user.userId(), idempotencyKey, request)); + } + + @PostMapping("/payable-settlements") + @PreAuthorize("hasAuthority('platform:finance:settlement-ap:create')") + public SettlementDetailEnvelope createPayableSettlement( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody CreatePayableSettlementRequest request) { + return success(service.createPayableSettlement(user.userId(), idempotencyKey, request)); + } + + @GetMapping("/settlements/{settlementId}") + @PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:list','platform:finance:settlement-ap:list')") + public SettlementDetailEnvelope getSettlement(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long settlementId) { + SettlementDetail detail = service.getSettlement(settlementId); + String direction = detail.summary().direction(); + String listPermission = "RECEIVABLE".equals(direction) + ? "platform:finance:settlement-ar:list" : "platform:finance:settlement-ap:list"; + if (user == null || !user.permissions().contains(listPermission)) { + throw new AccessDeniedException("无权查看该方向结算单"); + } + return success(detail); + } + + @PostMapping("/settlements/{settlementId}/transactions") + @PreAuthorize("hasAuthority('platform:finance:payment:create')") + public SettlementDetailEnvelope createSettlementPayment( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long settlementId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody CreateSettlementPaymentRequest request) { + return success(service.createSettlementPayment(settlementId, user.userId(), idempotencyKey, request)); + } + + @PostMapping("/settlements/{settlementId}/actions") + @PreAuthorize("hasAuthority('platform:finance:settlement:void')") + public SettlementDetailEnvelope actionSettlement( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long settlementId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody SettlementActionRequest request) { + return success(service.actionSettlement(settlementId, user.userId(), idempotencyKey, request)); + } + + private static SettlementDetailEnvelope success(SettlementDetail detail) { + String requestId = first(MDC.get("requestId"), MDC.get("request_id")); + if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString(); + String traceId = first(MDC.get("traceId"), MDC.get("trace_id")); + if (!StringUtils.hasText(traceId)) traceId = requestId; + return new SettlementDetailEnvelope("200", "success", detail, requestId, traceId, Instant.now()); + } + + private static SettlementListEnvelope success(List summaries) { + String requestId = first(MDC.get("requestId"), MDC.get("request_id")); + if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString(); + String traceId = first(MDC.get("traceId"), MDC.get("trace_id")); + if (!StringUtils.hasText(traceId)) traceId = requestId; + return new SettlementListEnvelope("200", "success", summaries, requestId, traceId, Instant.now()); + } + + private static String first(String first, String second) { + return StringUtils.hasText(first) ? first : second; + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java new file mode 100644 index 000000000..88f9dc71e --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java @@ -0,0 +1,38 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.finance.service.BusinessFeeSourceEventService; +import com.baiyue.finance.service.FinanceEventException; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Map; + +/** Receives full immutable fee snapshots from cross-region business producers. */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/internal/finance/v1/business-fee-source-events") +public class InternalBusinessFeeSourceController { + private final BusinessFeeSourceEventService service; + @Value("${baiyue.security.internal-service-token}") private String expectedToken; + + @PostMapping + public ApiResponse> consume( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody EventEnvelope event) { + if (token == null || expectedToken == null || expectedToken.isBlank() + || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), + expectedToken.getBytes(StandardCharsets.UTF_8))) { + throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效"); + } + return FinanceResponses.success(Map.of("feeSourceId", service.consume(event))); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalFinanceFeeSourceController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalFinanceFeeSourceController.java new file mode 100644 index 000000000..8ff92e919 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalFinanceFeeSourceController.java @@ -0,0 +1,36 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.finance.dto.FinanceFeeSourceEvent; +import com.baiyue.finance.service.FinanceFeeSourceEventService; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/internal/finance/v1/fee-source-events") +public class InternalFinanceFeeSourceController { + private final FinanceFeeSourceEventService service; + @Value("${baiyue.security.internal-service-token}") + private String expectedToken; + + @PostMapping + public ApiResponse> consume( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody FinanceFeeSourceEvent event) { + if (token == null || expectedToken == null || expectedToken.isBlank() + || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8))) { + throw new com.baiyue.finance.service.FinanceEventException("FIN-EVT-401", "内部服务令牌无效"); + } + return FinanceResponses.success(Map.of("feeSourceId", service.consume(event))); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalFinanceWalletController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalFinanceWalletController.java new file mode 100644 index 000000000..e9eccddfd --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalFinanceWalletController.java @@ -0,0 +1,72 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest; +import com.baiyue.finance.service.FinanceWalletService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/finance/v1/wallet") +public class InternalFinanceWalletController { + private final FinanceWalletService service; + private final String expectedToken; + + public InternalFinanceWalletController(FinanceWalletService service, + @Value("${baiyue.security.internal-service-token}") String expectedToken) { + this.service = service; + this.expectedToken = expectedToken; + } + + @GetMapping("/accounts/{customerId}") + public ApiResponse account(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long customerId) { + check(token); + return FinanceResponses.success(service.account(customerId)); + } + + @PostMapping("/accounts/{customerId}/holds") + public ApiResponse freeze(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @PathVariable long customerId, + @RequestBody WalletFreezeRequest request) { + check(token); + return FinanceResponses.success(service.freeze(customerId, idempotencyKey, request)); + } + + @PostMapping("/accounts/{customerId}/holds/{businessType}/{businessNo}/settle") + public ApiResponse settle(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @PathVariable long customerId, @PathVariable String businessType, + @PathVariable String businessNo, + @RequestBody(required = false) WalletHoldActionRequest request) { + check(token); + return FinanceResponses.success(service.settle(customerId, businessType, businessNo, + idempotencyKey, request)); + } + + @PostMapping("/accounts/{customerId}/holds/{businessType}/{businessNo}/release") + public ApiResponse release(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @PathVariable long customerId, @PathVariable String businessType, + @PathVariable String businessNo, + @RequestBody(required = false) WalletHoldActionRequest request) { + check(token); + return FinanceResponses.success(service.release(customerId, businessType, businessNo, + idempotencyKey, request)); + } + + private void check(String token) { + if (!StringUtils.hasText(expectedToken) || token == null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), + expectedToken.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java new file mode 100644 index 000000000..51a94c358 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java @@ -0,0 +1,37 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.WalletCommandEventService; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/internal/finance/v1/wallet-command-events") +public class InternalWalletCommandEventController { + private final WalletCommandEventService service; + @Value("${baiyue.security.internal-service-token}") private String expectedToken; + + @PostMapping + public ApiResponse> consume( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody EventEnvelope event) { + if (token == null || expectedToken == null || expectedToken.isBlank() + || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), + expectedToken.getBytes(StandardCharsets.UTF_8))) { + throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效"); + } + return FinanceResponses.success(Map.of("eventId", service.consume(event))); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceContainerSettlementController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceContainerSettlementController.java new file mode 100644 index 000000000..0e10ae51a --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceContainerSettlementController.java @@ -0,0 +1,33 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.finance.dto.FinanceContainerSettlementDtos.ContainerSettlementDetails; +import com.baiyue.finance.service.FinanceContainerSettlementQueryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "柜级财务聚合") +@RestController +@RequestMapping("/api/platform/finance/v1") +public class PlatformFinanceContainerSettlementController { + private final FinanceContainerSettlementQueryService service; + + public PlatformFinanceContainerSettlementController(FinanceContainerSettlementQueryService service) { + this.service = service; + } + + @GetMapping("/container-settlement-details") + @PreAuthorize("hasAuthority('platform:finance:cost:view') and " + + "hasAuthority('platform:finance:settlement-ar:list') and " + + "hasAuthority('platform:finance:settlement-ap:list')") + @Operation(summary = "按客户和柜次批量查询已结算柜费及成本分摊") + public ApiResponse details(@RequestParam long customerId, + @RequestParam long containerBookingId) { + return FinanceResponses.success(service.details(customerId, containerBookingId)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java new file mode 100644 index 000000000..be239533d --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -0,0 +1,236 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.service.FinanceService; +import com.baiyue.finance.service.FinanceQueryService; +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.client.FirstlegCostSnapshotClient; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.beans.factory.annotation.Autowired; + +@Tag(name = "人工财务台账") +@RestController +@RequestMapping("/api/platform/finance/v1") +public class PlatformFinanceController { + private final FinanceService service; + private final FinanceFileClient fileClient; + private final FirstlegCostSnapshotClient costSnapshotClient; + private final FinanceQueryService queryService; + @Value("${baiyue.security.internal-service-token:}") private String fileToken; + @Autowired + public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient, + FirstlegCostSnapshotClient costSnapshotClient, FinanceQueryService queryService) { + this.service = service; + this.fileClient = fileClient; + this.costSnapshotClient = costSnapshotClient; + this.queryService = queryService; + } + + /** Kept for direct unit tests of write behavior. */ + public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient, + FirstlegCostSnapshotClient costSnapshotClient) { + this(service, fileClient, costSnapshotClient, null); + } + + @GetMapping("/customer-accounts/{customerId}") + @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") + @Operation(summary = "客户正式应收账户及收款流水") + public ApiResponse customerAccount(@PathVariable long customerId) { + return FinanceResponses.success(queryService.customerAccount(customerId)); + } + + @GetMapping("/customer-accounts/{customerId}/summary") + @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") + @Operation(summary = "客户正式应收应付汇总") + public ApiResponse customerAccountSummary(@PathVariable long customerId) { + return FinanceResponses.success(queryService.customerAccountSummary(customerId)); + } + + @GetMapping("/fee-pool") + @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") + @Operation(summary = "客户应收应付费用池") + public ApiResponse feePool( + @Parameter(description = "可选客户筛选;省略时查询全部客户") + @RequestParam(required = false) Long customerId, + @RequestParam(required = false) String direction, + @RequestParam(required = false) Long supplierId) { + return FinanceResponses.success(queryService.feePool(customerId, direction, supplierId)); + } + + @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") + public ApiResponse feePool( + @Parameter(description = "可选客户筛选;省略时查询全部客户") + @RequestParam(required = false) Long customerId, + @RequestParam(required = false) String direction) { + return FinanceResponses.success(queryService.feePool(customerId, direction)); + } + + @GetMapping("/statements") + @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')") + @Operation(summary = "客户对账单") + public ApiResponse statements( + @Parameter(description = "可选客户筛选;省略时查询全部客户") + @RequestParam(required = false) Long customerId) { + return FinanceResponses.success(queryService.reconciliationStatements(customerId)); + } + + @PostMapping(value = "/vouchers", consumes = "multipart/form-data") + @PreAuthorize("hasAnyAuthority('platform:finance:receipt:create','platform:finance:cost:create','platform:finance:fee-source:edit')") + public ApiResponse uploadVoucher(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long customerId, + @RequestPart("file") MultipartFile file) { + if (customerId <= 0) throw new IllegalArgumentException("客户范围无效"); + var response = fileClient.upload(fileToken, user.userId(), customerId, customerId, file); + if (response == null || response.data() == null) throw new IllegalStateException("File服务未返回有效上传结果"); + return FinanceResponses.success(response.data()); + } + + @GetMapping("/customers/{tenantId}/terms") + @PreAuthorize("hasAuthority('platform:finance:customer-terms:view')") + @Operation(summary = "查询客户账期") + public ApiResponse terms(@PathVariable long tenantId) { return FinanceResponses.success(termsView(tenantId, service.customerTerms(tenantId))); } + + @GetMapping("/bills") + @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')") + @Operation(summary = "查询平台账单") + public ApiResponse bills(@RequestParam long customerId) { return FinanceResponses.success(queryService.statements(customerId, false)); } + + @PutMapping("/customers/{tenantId}/terms") + @PreAuthorize("hasAuthority('platform:finance:customer-terms:update')") + @BusinessAudit(event = "FINANCE_CUSTOMER_TERMS_UPDATE", resourceType = "CUSTOMER") + public ApiResponse saveTerms(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @RequestBody TermsRequest request) { + return FinanceResponses.success(termsView(tenantId, service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key))); + } + + @GetMapping("/bills/{billId}") + @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')") + public ApiResponse bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); } + + @GetMapping("/bills/{billId}/revisions") + @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')") + public ApiResponse revisions(@PathVariable long billId, @RequestParam long customerId) { + return FinanceResponses.success(queryService.billRevisions(customerId, billId)); + } + + @GetMapping("/receipts") + @PreAuthorize("hasAuthority('platform:finance:receipt:view')") + public ApiResponse receipts(@RequestParam(required = false) Long customerId) { + if (customerId == null) throw new IllegalArgumentException("查询收款记录必须指定客户"); + return FinanceResponses.success(queryService.platformReceipts(customerId)); + } + + @PostMapping("/receipts") + @PreAuthorize("hasAuthority('platform:finance:receipt:create')") + @BusinessAudit(event = "FINANCE_RECEIPT_CREATE", resourceType = "FINANCE_RECEIPT") + public ApiResponse receipt(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReceiptRequest request) { + if (request.amount() == null || request.amount().signum() <= 0) throw new IllegalArgumentException("收款金额必须大于0"); + if (request.receivedDate() == null) throw new IllegalArgumentException("到账日期不能为空"); + if (request.allocations() == null || request.allocations().isEmpty()) throw new IllegalArgumentException("收款至少需要分配一张账单"); + if (request.currency() != null && !"CNY".equals(request.currency())) throw new IllegalArgumentException("首期仅支持CNY"); + var allocated = request.allocations().stream().map(ReceiptAllocationInput::amount).reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add); + if (request.amount().compareTo(allocated) != 0) throw new IllegalArgumentException("收款金额必须等于分配金额合计"); + var allocations = request.allocations().stream().map(a -> new FinanceService.ReceiptAllocation(a.billId(), a.amount())).toList(); + var internal = new FinanceService.ReceiptRequest(null, 1L, request.receivedDate().atStartOfDay(), request.paymentMethod(), null, request.voucherFileIds(), request.remark(), allocations); + return FinanceResponses.success(resourceView(service.createReceipt(request.customerId(), user.userId(), internal, key))); + } + + @GetMapping("/cost-entries") + @PreAuthorize("hasAuthority('platform:finance:cost:view')") + public ApiResponse costs(@RequestParam(required = false) Long customerId) { + if (customerId == null) throw new IllegalArgumentException("查询成本记录必须指定客户"); + return FinanceResponses.success(queryService.payableSettlements(customerId)); + } + + @GetMapping("/cost-entries/{costId}") + @PreAuthorize("hasAuthority('platform:finance:cost:view')") + public ApiResponse cost(@PathVariable long costId, @RequestParam long customerId) { + return FinanceResponses.success(queryService.cost(customerId, costId)); + } + + @PostMapping("/cost-entries") + @PreAuthorize("hasAuthority('platform:finance:cost:create')") + @BusinessAudit(event = "FINANCE_COST_CREATE", resourceType = "FINANCE_COST") + public ApiResponse cost(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CostRequest request) { + if (request.currency() != null && !"CNY".equals(request.currency())) throw new IllegalArgumentException("首期仅支持CNY"); + java.util.List volumes = java.util.List.of(); + Long containerBookingId = null; + if ("CONTAINER_SHARED".equals(request.costType())) { + if (request.containerId() == null) throw new IllegalArgumentException("整柜成本必须指定柜次"); + var response = costSnapshotClient.loadingPlan(fileToken, request.customerId(), request.containerId()); + var snapshot = response == null ? null : response.data(); + if (response == null || !response.successful()) { + throw new IllegalArgumentException(response == null || response.message() == null + ? "Firstleg柜内订单体积快照不可用" : response.message()); + } + if (snapshot == null || snapshot.customerId() != request.customerId() + || snapshot.loadingPlanId() != request.containerId() + || snapshot.containerBookingId() <= 0 + || snapshot.orders() == null || snapshot.orders().isEmpty()) { + throw new IllegalArgumentException("Firstleg未返回有效柜内订单体积快照"); + } + containerBookingId = snapshot.containerBookingId(); + volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(), + v.inboundId(), snapshot.containerBookingId(), snapshot.customerId(), v.volumeCbm())).toList(); + } + var internal = new FinanceService.CostRequest(null, request.costType(), request.orderId(), null, + containerBookingId, null, null, "MANUAL", request.expenseName(), request.amount(), + request.voucherFileIds(), request.remark()); + return FinanceResponses.success(resourceView(service.createCost(request.customerId(), user.userId(), internal, volumes, key))); + } + + @PutMapping("/bills/{billId}") + @PreAuthorize("hasAuthority('platform:finance:bill:update')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody UpdateBillRequest request) { return FinanceResponses.success(resourceView(service.updateBill(user.userId(), billId, request.customerId(), request.billDate(), request.lines(), request.expectedVersion(), key))); } + + @PostMapping("/bills/{billId}/adjust") + @PreAuthorize("hasAuthority('platform:finance:bill:adjust')") + public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody AdjustBillRequest request) { return FinanceResponses.success(resourceView(service.adjustBill(user.userId(), billId, request.lines(), request.expectedVersion(), request.reason(), key))); } + + @PostMapping("/bills/{billId}/void") + @PreAuthorize("hasAuthority('platform:finance:bill:void')") + public ApiResponse voidBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody ReasonRequest request) { return FinanceResponses.success(resourceView(service.voidBill(user.userId(), billId, request.expectedVersion(), request.reason(), key))); } + + private static com.baiyue.finance.dto.FinanceReadModels.CustomerTerms termsView(long customerId, Map row) { + Object name = row.getOrDefault("customer_name_snapshot", row.get("customerNameSnapshot")); + Object days = row.getOrDefault("credit_days", row.get("creditDays")); + return new com.baiyue.finance.dto.FinanceReadModels.CustomerTerms(customerId, name == null ? null : name.toString(), + days == null ? 30 : ((Number) days).intValue(), String.valueOf(row.getOrDefault("status", "ACTIVE"))); + } + + private static com.baiyue.finance.dto.FinanceReadModels.ResourceResult resourceView(Map row) { + long id = number(row.get("id"), 0L).longValue(); + Object no = first(row, "bill_no", "receipt_no", "cost_no"); + Object version = row.get("version"); + return new com.baiyue.finance.dto.FinanceReadModels.ResourceResult(id, no == null ? null : no.toString(), + String.valueOf(row.getOrDefault("status", "DRAFT")), version instanceof Number n ? n.intValue() : null); + } + + private static Object first(Map row, String... keys) { + for (String key : keys) if (row.get(key) != null) return row.get(key); + return null; + } + + private static Number number(Object value, Number fallback) { return value instanceof Number n ? n : fallback; } + + public record TermsRequest(String customerName, Integer creditDays) {} + public record BillLineInput(long sourceFeeId, int sourceFeeVersion, Long orderId, Long inboundId, String feeItemName, java.math.BigDecimal amount) {} + public record VersionRequest(int expectedVersion) {} + public record ReasonRequest(int expectedVersion, String reason) {} + public record UpdateBillRequest(long customerId, java.time.LocalDate billDate, java.util.List lines, int expectedVersion) {} + public record AdjustBillRequest(int expectedVersion, String reason, java.util.List lines) {} + public record ReceiptRequest(long customerId, java.time.LocalDate receivedDate, java.math.BigDecimal amount, String currency, String paymentMethod, java.util.List voucherFileIds, String remark, java.util.List allocations) {} + public record ReceiptAllocationInput(long billId, java.math.BigDecimal amount) {} + public record CostRequest(long customerId, String costType, Long orderId, Long containerId, String expenseName, java.math.BigDecimal amount, String currency, java.util.List voucherFileIds, String remark) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java new file mode 100644 index 000000000..2ad9c3bac --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java @@ -0,0 +1,65 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.finance.client.FinanceFileClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +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.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; + +@RestController +@RequestMapping("/api/platform/finance/v1/vouchers") +@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") +public class PlatformFinanceVoucherController { + private final FinanceFileClient files; + private final String token; + + public PlatformFinanceVoucherController(FinanceFileClient files, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.files = files; + this.token = token; + } + + public record Voucher(String fileId, String fileName, String contentType, Long sizeBytes) {} + + @GetMapping("/{fileId}") + public ApiResponse metadata(@PathVariable long fileId, @RequestParam long customerId) { + var file = scopedFile(customerId, fileId); + return FinanceResponses.success(new Voucher(String.valueOf(fileId), file.fileName(), file.contentType(), file.sizeBytes())); + } + + @GetMapping("/{fileId}/content") + public ResponseEntity content(@PathVariable long fileId, @RequestParam long customerId) { + var file = scopedFile(customerId, fileId); + var response = files.download(token, customerId, customerId, fileId); + if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "付款凭证读取失败,请稍后重试"); + } + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(file.fileName() == null ? "付款凭证" : file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.APPLICATION_OCTET_STREAM).body(response.getBody()); + } + + private FinanceFileClient.Metadata scopedFile(long customerId, long fileId) { + if (customerId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "客户或付款凭证无效"); + var response = files.metadata(token, customerId, customerId, fileId); + var file = response == null ? null : response.data(); + if (file == null || !Long.valueOf(fileId).equals(file.fileId()) + || !Long.valueOf(customerId).equals(file.tenantId()) + || !"FINANCE_VOUCHER".equals(file.businessType()) + || !String.valueOf(customerId).equals(file.businessId())) { + throw new BizException(ResultCode.BAD_REQUEST, "付款凭证不存在或不属于当前客户"); + } + return file; + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java new file mode 100644 index 000000000..c026b5321 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java @@ -0,0 +1,65 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.CreditLimitRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest; +import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; +import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest; +import com.baiyue.finance.service.FinanceWalletService; +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.*; + +@RestController +@RequestMapping("/api/platform/finance/v1") +public class PlatformFinanceWalletController { + private final FinanceWalletService service; + + public PlatformFinanceWalletController(FinanceWalletService service) { this.service = service; } + + @GetMapping("/accounts/{customerId}/recharge-requests") + @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") + public PageEnvelope requests(@PathVariable long customerId, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int pageSize) { + var result = service.rechargeRequests(customerId, page, pageSize); + return PageEnvelope.success(result.records(), result.page(), result.size(), result.total()); + } + + @PostMapping("/recharge-requests/{requestId}/review") + @PreAuthorize("hasAuthority('platform:finance:recharge:review')") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long requestId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @RequestBody RechargeReviewRequest request) { + return FinanceResponses.success(service.reviewRecharge(requestId, user.userId(), user.displayName(), + idempotencyKey, request)); + } + + @PostMapping("/accounts/{customerId}/adjustments") + @PreAuthorize("hasAuthority('platform:finance:customer-account:adjust')") + public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long customerId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @RequestBody AccountAdjustmentRequest request) { + return FinanceResponses.success(service.adjust(customerId, user.userId(), user.displayName(), + idempotencyKey, request)); + } + + @PutMapping("/accounts/{customerId}/credit-limit") + @PreAuthorize("hasAuthority('platform:finance:customer-account:adjust')") + public ApiResponse creditLimit(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long customerId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @RequestBody CreditLimitRequest request) { + return FinanceResponses.success(service.setCreditLimit(customerId, user.userId(), user.displayName(), + idempotencyKey, request)); + } + + @GetMapping("/recharge-requests/{requestId}/voucher") + @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") + public ResponseEntity voucher(@PathVariable long requestId) { return service.voucher(requestId); } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/ReceivableStatisticsController.java b/finance/src/main/java/com/baiyue/finance/controller/ReceivableStatisticsController.java new file mode 100644 index 000000000..a8f88c608 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/ReceivableStatisticsController.java @@ -0,0 +1,42 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse; +import com.baiyue.finance.service.ReceivableStatisticsService; +import com.baiyue.finance.service.FinanceEventException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.YearMonth; + +@Tag(name = "应收款统计") +@RestController +@RequestMapping("/api/platform/finance/v1/receivable-statistics") +public class ReceivableStatisticsController { + private final ReceivableStatisticsService service; + + public ReceivableStatisticsController(ReceivableStatisticsService service) { + this.service = service; + } + + @GetMapping + @PreAuthorize("hasAuthority('platform:finance:receivable-statistics:list')") + @Operation(summary = "按生成月份和客户汇总CNY正式应收结算单") + public ApiResponse query( + @RequestParam @DateTimeFormat(pattern = "yyyy-MM") String month, + @RequestParam(required = false) Long customerId, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int pageSize) { + try { + return FinanceResponses.success(service.query(YearMonth.parse(month), customerId, page, pageSize)); + } catch (java.time.format.DateTimeParseException exception) { + throw new FinanceEventException("FIN-STAT-400", "统计月份格式必须为yyyy-MM"); + } + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java b/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java new file mode 100644 index 000000000..a799e61ea --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java @@ -0,0 +1,57 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest; +import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest; +import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail; +import com.baiyue.finance.service.ReconciliationStatementService; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/platform/finance/v1") +public class ReconciliationStatementController { + private final ReconciliationStatementService service; + + public ReconciliationStatementController(ReconciliationStatementService service) { + this.service = service; + } + + @PostMapping("/statements") + @PreAuthorize("hasAuthority('platform:finance:reconciliation:create')") + public ApiResponse createReconciliationStatement( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody CreateReconciliationRequest request) { + return FinanceResponses.success(service.create(user.userId(), idempotencyKey, request)); + } + + @GetMapping("/reconciliation-statements/{statementId}") + @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')") + public ApiResponse getReconciliationStatement(@PathVariable long statementId) { + return FinanceResponses.success(service.detailForPlatform(statementId)); + } + + @PostMapping("/reconciliation-statements/{statementId}/actions") + @PreAuthorize("(#request.action != null && #request.action.equalsIgnoreCase('SUBMIT') && hasAuthority('platform:finance:reconciliation:create'))" + + " || (#request.action != null && (#request.action.equalsIgnoreCase('APPROVE') || #request.action.equalsIgnoreCase('REJECT')) && hasAuthority('platform:finance:reconciliation:review'))" + + " || (#request.action != null && #request.action.equalsIgnoreCase('ISSUE') && hasAuthority('platform:finance:reconciliation:issue'))" + + " || (#request.action != null && #request.action.equalsIgnoreCase('ADJUST') && hasAuthority('platform:finance:reconciliation:adjust'))" + + " || (#request.action != null && (#request.action.equalsIgnoreCase('VOID') || #request.action.equalsIgnoreCase('REOPEN')) && hasAuthority('platform:finance:reconciliation:void'))") + public ApiResponse actionReconciliationStatement( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long statementId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody ReconciliationActionRequest request) { + return FinanceResponses.success(service.action(user.userId(), statementId, idempotencyKey, request)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceContainerSettlementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceContainerSettlementDtos.java new file mode 100644 index 000000000..1ccc7c162 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceContainerSettlementDtos.java @@ -0,0 +1,40 @@ +package com.baiyue.finance.dto; + +import java.math.BigDecimal; +import java.util.List; + +/** Read-only container finance projection for the loading-list workbench. */ +public final class FinanceContainerSettlementDtos { + private FinanceContainerSettlementDtos() {} + + public record ContainerSettlementDetails( + long customerId, + long containerBookingId, + List settledSourceFeeIds, + List costs) {} + + public record ContainerCostDetail( + long costId, + String costNo, + String costType, + String costOrigin, + Long sourceFeeId, + Integer sourceFeeVersion, + long containerBookingId, + String supplierName, + String feeItemName, + BigDecimal amount, + String currency, + String status, + List allocations) {} + + public record ContainerCostAllocation( + long allocationId, + long orderId, + Long inboundId, + BigDecimal volumeCbm, + BigDecimal totalVolumeCbm, + BigDecimal allocationRate, + BigDecimal allocatedAmount, + boolean roundingAdjustment) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceFeeSourceEvent.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceFeeSourceEvent.java new file mode 100644 index 000000000..ae88b33eb --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceFeeSourceEvent.java @@ -0,0 +1,12 @@ +package com.baiyue.finance.dto; + +import java.time.LocalDateTime; + +public record FinanceFeeSourceEvent( + String eventId, String eventType, int eventVersion, String producer, + LocalDateTime occurredAt, String tenantId, String aggregateType, + String aggregateId, int sequence, String idempotencyKey, + String payloadHash, Payload payload +) { + public record Payload(String inboundId, String containerId, String feeId, int feeVersion) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java new file mode 100644 index 000000000..51aaf07f3 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java @@ -0,0 +1,89 @@ +package com.baiyue.finance.dto; + +import org.slf4j.MDC; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public final class FinancePhase1Dtos { + private FinancePhase1Dtos() {} + + public record PageMeta(int page, int pageSize, long total) {} + public record PageEnvelope(String code, String message, List data, PageMeta page, + String requestId, String traceId, Instant timestamp) { + public static PageEnvelope success(List data, int page, int pageSize, long total) { + String requestId = correlation("requestId", "request_id"); + String traceId = correlation("traceId", "trace_id"); + if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString(); + if (!StringUtils.hasText(traceId)) traceId = requestId; + return new PageEnvelope<>("200", "success", data, new PageMeta(page, pageSize, total), requestId, traceId, Instant.now()); + } + } + + public record AccountSummary(long customerId, String customerCode, String customerName, + String contactName, String contactPhone, + BigDecimal outstandingReceivable, BigDecimal pendingBilling, + BigDecimal receivedInPeriod, BigDecimal totalCost, + BigDecimal paidCost, BigDecimal outstandingPayable, + BigDecimal actualBalance, BigDecimal frozenBalance, + BigDecimal availableBalance, BigDecimal creditLimit, + BigDecimal creditUsed, BigDecimal availableCredit, + LocalDate creditExpiresOn, boolean creditActive, + int accountVersion, + long pendingRechargeCount, + String status, LocalDateTime lastChangedAt) {} + + public record LedgerEntry(long ledgerId, LocalDateTime occurredAt, String businessType, + String documentType, long documentId, String documentNo, + BigDecimal receivableChange, BigDecimal payableChange, + BigDecimal outstandingReceivableAfter, String operatorName, + String remark, String walletBusinessNo, String changeType, + BigDecimal walletAmount, BigDecimal actualBalanceAfter, + BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter, + BigDecimal creditLimitAfter, BigDecimal creditUsedAfter, + BigDecimal availableCreditAfter, + String sourceType, String sourceNo, LocalDate occurredDate, + String paymentMethod, String referenceNo) {} + + public record FeeSourceSummary(long feeSourceId, String sourceSystem, String sourceType, + String sourceBusinessId, long sourceFeeId, int sourceVersion, + String direction, long customerId, String customerName, + Long supplierId, String supplierName, Long orderId, + Long inboundId, String inboundCode, Long containerId, + String containerNo, LocalDate businessDate, String feeItemName, + BigDecimal quantity, String quantityUnit, BigDecimal unitPrice, + LocalDate periodStart, LocalDate periodEnd, BigDecimal amount, + String currency, String originalCurrency, BigDecimal originalAmount, + BigDecimal exchangeRate, String exchangeRateMonth, String reviewStatus, + String sourceStatus, boolean statementGenerated) {} + + public record FeeSourceDetail(long feeSourceId, String sourceSystem, String sourceType, + String sourceBusinessId, long sourceFeeId, + int sourceVersion, String direction, long customerId, + String customerName, Long supplierId, String supplierName, + Long orderId, Long inboundId, String inboundCode, Long containerId, + String containerNo, LocalDate businessDate, String feeItemName, + BigDecimal quantity, String quantityUnit, BigDecimal unitPrice, + LocalDate periodStart, LocalDate periodEnd, BigDecimal amount, + String currency, String originalCurrency, BigDecimal originalAmount, + BigDecimal exchangeRate, String exchangeRateMonth, String reviewStatus, + String sourceStatus, boolean statementGenerated, + String reviewReason, String sourceHash, + int operationVersion, List operationLogs) {} + + public record OperationLog(LocalDateTime occurredAt, String action, String operatorName, + String result, String remark) {} + public record ReviewRequest(String action, String reason, int expectedVersion) {} + public record FeeSourceEditRequest(BigDecimal amount, String reason, int expectedVersion) {} + public record FeeSourceActionRequest(String reason, int expectedVersion) {} + + private static String correlation(String first, String second) { + String value = MDC.get(first); + return StringUtils.hasText(value) ? value : MDC.get(second); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java new file mode 100644 index 000000000..ccd4630db --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java @@ -0,0 +1,226 @@ +package com.baiyue.finance.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Instant; +import java.util.List; + +/** Stable read projections used by the Finance pages. */ +public final class FinanceReadModels { + private FinanceReadModels() {} + + public record CustomerAccount( + long customerId, + String currency, + String accountMode, + BigDecimal totalReceivable, + BigDecimal totalReceived, + BigDecimal outstandingAmount, + List entries) {} + + public record CustomerAccountSummary( + long customerId, + String currency, + BigDecimal totalReceivable, + BigDecimal receivedAmount, + BigDecimal outstandingReceivable, + BigDecimal pendingBilling, + BigDecimal totalCost, + BigDecimal paidCost, + BigDecimal outstandingPayable, + Instant asOf, + LocalDateTime lastChangedAt) {} + + public record AccountEntry( + String entryType, + long resourceId, + String resourceNo, + LocalDateTime occurredAt, + BigDecimal debitAmount, + BigDecimal creditAmount, + String currency, + String status) {} + + public record FeePoolItem( + String poolItemId, + long customerId, + String direction, + String sourceType, + long sourceId, + int sourceVersion, + Long orderId, + Long inboundId, + Long containerId, + String feeItemName, + BigDecimal amount, + String currency, + String settlementMode, + LocalDateTime paidAt, + String paymentMethod, + String paymentReferenceNo, + List paymentVoucherFileIds, + String status, + LocalDateTime createdAt) {} + + public record Statement( + long id, + long customerId, + String billNo, + String customerName, + LocalDate billDate, + LocalDate dueDate, + BigDecimal totalAmount, + BigDecimal receivedAmount, + BigDecimal outstandingAmount, + String currency, + String status, + int version, + String customerConfirmationStatus, + Long customerConfirmedBy, + String customerConfirmedByName, + LocalDateTime customerConfirmedAt, + long createdBy, + String createdByName, + LocalDateTime createdAt, + long updatedBy, + String updatedByName, + LocalDateTime updatedAt) {} + + public record ReconciliationSummary( + long statementId, + String statementNo, + long customerId, + String customerName, + LocalDate periodStart, + LocalDate periodEnd, + int creditDays, + String currency, + int lineCount, + int sourceCount, + BigDecimal sourceAmount, + BigDecimal adjustmentAmount, + BigDecimal totalReceivable, + BigDecimal receivedAmount, + BigDecimal outstandingAmount, + LocalDateTime lastReceiptAt, + boolean settled, + String status, + LocalDateTime submittedAt, + LocalDateTime reviewedAt, + LocalDateTime issuedAt, + int version, + String customerConfirmationStatus, + Long customerConfirmedBy, + String customerConfirmedByName, + LocalDateTime customerConfirmedAt, + String remark, + long createdBy, + String createdByName, + LocalDateTime createdAt, + long updatedBy, + String updatedByName, + LocalDateTime updatedAt) {} + + public record StatementDetail(Statement statement, List lines) {} + + public record StatementLine( + long id, + String sourceType, + long sourceId, + int sourceVersion, + Long orderId, + Long inboundId, + String inboundCode, + String feeItemCode, + String feeItemName, + BigDecimal amount, + String currency) {} + + public record ReceivableSettlement( + long id, + long customerId, + String settlementNo, + LocalDate billDate, + LocalDate dueDate, + BigDecimal receivableAmount, + BigDecimal receivedAmount, + BigDecimal outstandingAmount, + String currency, + String status, + int version) {} + + public record PayableSettlement( + long id, + long customerId, + String settlementNo, + String costType, + Long orderId, + Long inboundId, + Long containerId, + String supplierName, + String feeItemName, + BigDecimal payableAmount, + BigDecimal paidAmount, + BigDecimal outstandingAmount, + String currency, + String status, + LocalDateTime createdAt, + int version) {} + + public record CostDetail(PayableSettlement cost, List allocations) {} + + public record CostAllocation( + long id, + long costId, + long orderId, + Long inboundId, + BigDecimal volumeCbm, + BigDecimal totalVolumeCbm, + BigDecimal allocationRate, + BigDecimal allocatedAmount, + boolean roundingAdjustment) {} + + /** Client-safe receipt view. Voucher and operator/internal fields are deliberately absent. */ + public record ClientReceipt( + long id, + String receiptNo, + BigDecimal receivedAmount, + LocalDateTime receivedAt, + String paymentMethod, + String currency, + String status, + List allocations) {} + + public record ClientReceiptAllocation(long billId, String billNo, BigDecimal appliedAmount) {} + + public record PlatformReceipt( + long id, + long customerId, + String receiptNo, + BigDecimal receivedAmount, + LocalDateTime receivedAt, + String paymentMethod, + String voucherNo, + List voucherFileIds, + String currency, + String status, + String remark, + int version, + List allocations) {} + + public record BillRevision( + long id, + long billId, + int version, + String reason, + String billSnapshot, + String lineSnapshot, + long createdBy, + String createdByName, + LocalDateTime createdAt) {} + + public record CustomerTerms(long customerId, String customerName, int creditDays, String status) {} + + public record ResourceResult(long id, String resourceNo, String status, Integer version) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java new file mode 100644 index 000000000..4b4621849 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java @@ -0,0 +1,55 @@ +package com.baiyue.finance.dto; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** Stable DTOs for the phase-one AR/AP settlement operations. */ +public final class FinanceSettlementDtos { + private FinanceSettlementDtos() {} + + public record CreateReceivableSettlementRequest(long reconciliationStatementId, String currency) {} + public record CreatePayableSettlementRequest(long customerId, long supplierId, List feeSourceIds, + LocalDate dueDate, String currency) {} + public record CreateSettlementPaymentRequest(LocalDate transactionDate, BigDecimal amount, String currency, + String method, String referenceNo, List voucherFileIds, + Integer expectedVersion, String remark) {} + public record SettlementActionRequest(String action, String reason, Integer expectedVersion) {} + + public record SettlementSummary(long settlementId, String settlementNo, String direction, long customerId, + String customerName, Long supplierId, String supplierName, + Long reconciliationStatementId, String reconciliationStatementNo, + String businessPeriod, String feeItemSummary, String currency, + BigDecimal totalAmount, BigDecimal paidAmount, BigDecimal outstandingAmount, + LocalDate generatedDate, LocalDate dueDate, String paymentStatus, + String status, long createdBy, String createdByName, + LocalDateTime createdAt, LocalDateTime updatedAt, + LocalDateTime lastPaymentAt, int version) {} + public record SettlementLine(long feeSourceId, String sourceType, long sourceFeeId, int sourceVersion, + String direction, long customerId, String customerName, Long supplierId, + String supplierName, Long orderId, String orderNo, Long inboundId, + String inboundCode, Long containerId, String containerNo, + LocalDate businessDate, String feeItemName, + BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency, + String reviewStatus, String sourceStatus, + boolean statementGenerated) {} + public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate, + BigDecimal amount, String method, String referenceNo, + List voucherFileIds, long createdBy, String createdByName, + LocalDateTime createdAt, + String remark) {} + public record SettlementRevision(int revisionNo, String action, BigDecimal beforeAmount, + BigDecimal afterAmount, String reason, long operatorId, String operatorName, + LocalDateTime occurredAt) {} + public record SettlementDetail(@JsonUnwrapped SettlementSummary summary, List lines, + List transactions, + List revisions) {} + public record SettlementListEnvelope(String code, String message, List data, + String requestId, String traceId, Instant timestamp) {} + public record SettlementDetailEnvelope(String code, String message, SettlementDetail data, + String requestId, String traceId, Instant timestamp) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java new file mode 100644 index 000000000..9f48b1313 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java @@ -0,0 +1,64 @@ +package com.baiyue.finance.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class FinanceWalletDtos { + private FinanceWalletDtos() { } + + public record WalletAccount(String currencyCode, BigDecimal actualBalance, + BigDecimal frozenBalance, BigDecimal availableBalance, + BigDecimal creditLimit, BigDecimal creditUsed, + BigDecimal creditFrozen, BigDecimal availableCredit, + LocalDate creditExpiresOn, boolean creditActive, + int version, LocalDateTime updatedAt) { } + + public record WalletPage(List records, long total, int page, int size, int pages) { + public static WalletPage of(List records, long total, int page, int size) { + return new WalletPage<>(records, total, page, size, + total == 0 ? 0 : (int) ((total + size - 1) / size)); + } + } + + public record WalletLedgerEntry(long ledgerId, String businessNo, String changeType, + BigDecimal amount, BigDecimal actualBalanceAfter, + BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter, + BigDecimal creditLimitAfter, BigDecimal creditUsedAfter, + BigDecimal availableCreditAfter, + LocalDate creditExpiresOnAfter, + String sourceType, String sourceNo, LocalDate occurredDate, + String paymentMethod, String referenceNo, String operatorName, + String remark, LocalDateTime createdAt) { } + + public record RechargeRequest(long requestId, String requestNo, BigDecimal amount, + String paymentMethod, long voucherFileId, String voucherFileName, + String status, int version, String submittedByName, + String reviewedByName, String reviewRemark, + LocalDateTime createdAt, LocalDateTime reviewedAt) { } + + public record RechargeChannel(String paymentMethod, String displayName, + boolean enabled, String qrCodeUrl) { } + + public record RechargeReviewRequest(String decision, String remark, int expectedVersion) { } + + public record AccountAdjustmentRequest(String type, BigDecimal amount, LocalDate occurredDate, + String paymentMethod, String referenceNo, String remark, + int expectedAccountVersion) { } + + public record CreditLimitRequest(BigDecimal creditLimit, LocalDate creditExpiresOn, String remark, + int expectedAccountVersion) { } + + public record WalletFreezeRequest(BigDecimal amount, String businessType, + String businessNo, String remark) { } + + public record WalletHoldActionRequest(String remark) { } + + public record WalletHold(long holdId, long customerId, String businessType, + String businessNo, BigDecimal amount, + BigDecimal actualFrozenAmount, BigDecimal creditFrozenAmount, + String status, Long freezeLedgerId, Long actionLedgerId, + WalletAccount account, LocalDateTime createdAt, + LocalDateTime completedAt) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/ReceivableStatisticsDtos.java b/finance/src/main/java/com/baiyue/finance/dto/ReceivableStatisticsDtos.java new file mode 100644 index 000000000..975be2937 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/ReceivableStatisticsDtos.java @@ -0,0 +1,19 @@ +package com.baiyue.finance.dto; + +import java.math.BigDecimal; +import java.time.YearMonth; +import java.util.List; + +public final class ReceivableStatisticsDtos { + private ReceivableStatisticsDtos() {} + + public record ReceivableStatisticsRow(long customerId, String customerName, String currency, + BigDecimal totalReceivable, BigDecimal totalReceived, + BigDecimal totalOutstanding) {} + + public record ReceivableStatisticsResponse(YearMonth statisticsMonth, String currency, + BigDecimal totalReceivable, BigDecimal totalReceived, + BigDecimal totalOutstanding, long totalCustomers, + int page, int pageSize, + List records) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java new file mode 100644 index 000000000..6c923a0af --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java @@ -0,0 +1,57 @@ +package com.baiyue.finance.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class ReconciliationStatementDtos { + private ReconciliationStatementDtos() {} + + public record CreateReconciliationRequest(long customerId, String currency, String generationMode, + List feeSourceIds, LocalDate periodStart, + LocalDate periodEnd, String remark) {} + + public record ReconciliationActionRequest(String action, String reason, int expectedVersion, + List adjustmentLines) {} + + public record CustomerConfirmationRequest(int expectedVersion) {} + + public record CustomerConfirmationResult(long statementId, String confirmationStatus, + long confirmedBy, String confirmedByName, + LocalDateTime confirmedAt, int version) {} + + public record StatementAdjustmentLineRequest(long statementLineId, BigDecimal adjustedAmount, + String reason) {} + + public record ReconciliationDetail(long statementId, String statementNo, long customerId, + String customerName, LocalDate periodStart, LocalDate periodEnd, + int creditDays, String currency, BigDecimal totalReceivable, + BigDecimal receivedAmount, BigDecimal outstandingAmount, + boolean settled, String status, int version, + String customerConfirmationStatus, Long customerConfirmedBy, + String customerConfirmedByName, LocalDateTime customerConfirmedAt, + long createdBy, String createdByName, LocalDateTime createdAt, + long updatedBy, String updatedByName, LocalDateTime updatedAt, String remark, + List lines, List paymentHistory, + List revisions) {} + + public record ReconciliationLine(long statementLineId, long feeSourceId, int sourceVersion, + String sourceHash, String direction, String sourceType, + String sourceNo, Long orderId, Long inboundId, Long containerId, + String inboundCode, String containerNo, LocalDate businessDate, + BigDecimal quantity, BigDecimal unitPrice, + String feeItemName, BigDecimal sourceAmount, + BigDecimal statementAmount, String currency, + String adjustmentReason) {} + + public record PaymentHistory(long transactionId, String transactionType, + LocalDate transactionDate, BigDecimal amount, + String method, String referenceNo, List voucherFileIds, + long createdBy, String createdByName, + LocalDateTime createdAt, String remark) {} + + public record ReconciliationRevision(int revisionNo, String action, BigDecimal beforeAmount, + BigDecimal afterAmount, String reason, long operatorId, String operatorName, + LocalDateTime occurredAt) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/mapper/FinanceContainerSettlementQueryMapper.java b/finance/src/main/java/com/baiyue/finance/mapper/FinanceContainerSettlementQueryMapper.java new file mode 100644 index 000000000..4e3d05723 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/mapper/FinanceContainerSettlementQueryMapper.java @@ -0,0 +1,143 @@ +package com.baiyue.finance.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.math.BigDecimal; +import java.util.List; + +@Mapper +public interface FinanceContainerSettlementQueryMapper { + + @Select(""" + SELECT COUNT(*) AS customerLinkCount + FROM ( + SELECT customer_id, container_booking_id AS container_id FROM fin_fee_source + WHERE customer_id = #{customerId} AND container_booking_id = #{containerBookingId} + UNION ALL + SELECT tenant_id AS customer_id, container_id FROM fin_cost + WHERE tenant_id = #{customerId} AND container_id = #{containerBookingId} + ) links + """) + OwnershipRow ownership(@Param("customerId") long customerId, + @Param("containerBookingId") long containerBookingId); + + @Select(""" + SELECT DISTINCT settled.source_fee_id + FROM ( + SELECT f.source_fee_id + FROM fin_settlement s + JOIN fin_settlement_line sl ON sl.tenant_id = s.tenant_id AND sl.settlement_id = s.id + JOIN fin_fee_source f ON f.id = sl.fee_source_id AND f.customer_id = sl.tenant_id + WHERE s.tenant_id = #{customerId} + AND s.status = 'PAID' + AND f.customer_id = #{customerId} + AND f.container_booking_id = #{containerBookingId} + AND f.source_system = 'FIRSTLEG' + AND f.source_type = 'CONTAINER_FEE' + UNION ALL + SELECT c.source_fee_id + FROM fin_cost c + WHERE c.tenant_id = #{customerId} + AND c.container_id = #{containerBookingId} + AND c.cost_origin = 'FIRSTLEG_DIRECT_PAID' + AND c.source_system = 'FIRSTLEG' + AND c.source_type = 'CONTAINER_FEE' + AND c.status = 'CONFIRMED' + AND c.source_fee_id IS NOT NULL + ) settled + ORDER BY settled.source_fee_id + """) + List settledSourceFeeIds(@Param("customerId") long customerId, + @Param("containerBookingId") long containerBookingId); + + @Select(""" + SELECT c.id AS costId, c.cost_no AS costNo, c.cost_type AS costType, c.cost_origin AS costOrigin, + c.source_fee_id AS sourceFeeId, c.source_fee_version AS sourceFeeVersion, + c.container_id AS containerBookingId, c.supplier_name_snapshot AS supplierName, + c.fee_item_name AS feeItemName, c.cost_amount AS amount, c.currency_code AS currency, + c.status, + a.id AS allocationId, a.order_id AS orderId, a.inbound_id AS inboundId, + a.volume_cbm AS volumeCbm, a.total_volume_cbm AS totalVolumeCbm, + a.allocation_rate AS allocationRate, a.allocated_amount AS allocatedAmount, + a.rounding_adjustment AS roundingAdjustment + FROM fin_cost c + LEFT JOIN fin_cost_allocation a ON a.tenant_id = c.tenant_id AND a.cost_id = c.id + WHERE c.tenant_id = #{customerId} AND c.container_id = #{containerBookingId} + ORDER BY c.created_at DESC, c.id DESC, a.id + """) + List costs(@Param("customerId") long customerId, + @Param("containerBookingId") long containerBookingId); + + final class OwnershipRow { + private long customerLinkCount; + + public long getCustomerLinkCount() { return customerLinkCount; } + public void setCustomerLinkCount(long value) { this.customerLinkCount = value; } + } + + final class CostAllocationRow { + private long costId; + private String costNo; + private String costType; + private String costOrigin; + private Long sourceFeeId; + private Integer sourceFeeVersion; + private long containerBookingId; + private String supplierName; + private String feeItemName; + private BigDecimal amount; + private String currency; + private String status; + private Long allocationId; + private Long orderId; + private Long inboundId; + private BigDecimal volumeCbm; + private BigDecimal totalVolumeCbm; + private BigDecimal allocationRate; + private BigDecimal allocatedAmount; + private boolean roundingAdjustment; + + public long getCostId() { return costId; } + public void setCostId(long value) { this.costId = value; } + public String getCostNo() { return costNo; } + public void setCostNo(String value) { this.costNo = value; } + public String getCostType() { return costType; } + public void setCostType(String value) { this.costType = value; } + public String getCostOrigin() { return costOrigin; } + public void setCostOrigin(String value) { this.costOrigin = value; } + public Long getSourceFeeId() { return sourceFeeId; } + public void setSourceFeeId(Long value) { this.sourceFeeId = value; } + public Integer getSourceFeeVersion() { return sourceFeeVersion; } + public void setSourceFeeVersion(Integer value) { this.sourceFeeVersion = value; } + public long getContainerBookingId() { return containerBookingId; } + public void setContainerBookingId(long value) { this.containerBookingId = value; } + public String getSupplierName() { return supplierName; } + public void setSupplierName(String value) { this.supplierName = value; } + public String getFeeItemName() { return feeItemName; } + public void setFeeItemName(String value) { this.feeItemName = value; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal value) { this.amount = value; } + public String getCurrency() { return currency; } + public void setCurrency(String value) { this.currency = value; } + public String getStatus() { return status; } + public void setStatus(String value) { this.status = value; } + public Long getAllocationId() { return allocationId; } + public void setAllocationId(Long value) { this.allocationId = value; } + public Long getOrderId() { return orderId; } + public void setOrderId(Long value) { this.orderId = value; } + public Long getInboundId() { return inboundId; } + public void setInboundId(Long value) { this.inboundId = value; } + public BigDecimal getVolumeCbm() { return volumeCbm; } + public void setVolumeCbm(BigDecimal value) { this.volumeCbm = value; } + public BigDecimal getTotalVolumeCbm() { return totalVolumeCbm; } + public void setTotalVolumeCbm(BigDecimal value) { this.totalVolumeCbm = value; } + public BigDecimal getAllocationRate() { return allocationRate; } + public void setAllocationRate(BigDecimal value) { this.allocationRate = value; } + public BigDecimal getAllocatedAmount() { return allocatedAmount; } + public void setAllocatedAmount(BigDecimal value) { this.allocatedAmount = value; } + public boolean isRoundingAdjustment() { return roundingAdjustment; } + public void setRoundingAdjustment(boolean value) { this.roundingAdjustment = value; } + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java new file mode 100644 index 000000000..2089ae768 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -0,0 +1,228 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.event.BusinessFeeSourceChangedV1; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.RegionCode; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Consumes complete overseas business fee snapshots without a cross-region synchronous callback. */ +@Service +@RequiredArgsConstructor +public class BusinessFeeSourceEventService { + private static final Set ACTIONS = Set.of("PUBLISHED", "ADJUSTED", "VOIDED"); + private static final Set DIRECTIONS = Set.of("RECEIVABLE", "PAYABLE"); + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final FinanceSupplierValidator supplierValidator; + + @Transactional + public long consume(EventEnvelope envelope) { + try { + envelope.requireValid(); + } catch (RuntimeException exception) { + throw error("FIN-EVT-001", "跨区费用事件信封无效"); + } + if (!EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(envelope.eventType()) + || envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.OVERSEAS + || envelope.targetRegion() != RegionCode.CN) { + throw error("FIN-EVT-001", "跨区费用事件类型、版本或区域无效"); + } + BusinessFeeSourceChangedV1 source = payload(envelope); + validate(envelope, source); + String sourceSystem = source.sourceSystem().trim().toUpperCase(); + String eventJson = json(envelope); + List> inbox = jdbc.queryForList( + "SELECT payload_hash,status,result_resource_id FROM fin_inbox_event WHERE source_system=? AND event_id=? FOR UPDATE", + sourceSystem, envelope.eventId()); + if (!inbox.isEmpty()) { + Map row = inbox.get(0); + if (!envelope.payloadHash().equals(row.get("payload_hash"))) + throw error("FIN-EVT-002", "同一事件ID对应不同费用快照"); + if ("PROCESSED".equals(row.get("status")) && row.get("result_resource_id") instanceof Number id) + return id.longValue(); + throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败"); + } + jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status) VALUES (?,?,?,?,?,'RECEIVED')", + envelope.eventId(), sourceSystem, envelope.eventType(), eventJson, envelope.payloadHash()); + long result = apply(sourceSystem, source); + jdbc.update("UPDATE fin_inbox_event SET status='PROCESSED',result_resource_id=?,processed_at=CURRENT_TIMESTAMP(3),error_code=NULL WHERE source_system=? AND event_id=?", + result, sourceSystem, envelope.eventId()); + return result; + } + + private long apply(String system, BusinessFeeSourceChangedV1 source) { + long feeId = positive(source.sourceFeeId(), "sourceFeeId"); + List> same = jdbc.queryForList( + "SELECT id,source_hash,status FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? AND source_fee_version=? FOR UPDATE", + system, source.sourceType(), feeId, source.sourceVersion()); + if (!same.isEmpty()) { + Map row = same.get(0); + if (!source.sourceHash().equals(row.get("source_hash"))) + throw error("FIN-EVT-004", "同一来源版本快照不一致"); + long id = ((Number) row.get("id")).longValue(); + if ("VOIDED".equals(source.action())) { + requireNotFrozen(id); + jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", id); + voidDirectPaidCost(system, source); + } + return id; + } + if ("VOIDED".equals(source.action())) + throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在"); + List> versions = jdbc.queryForList( + "SELECT source_fee_version FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? FOR UPDATE", + system, source.sourceType(), feeId); + if (versions.stream().anyMatch(row -> ((Number) row.get("source_fee_version")).intValue() > source.sourceVersion())) + throw error("FIN-EVT-008", "费用来源版本已过期"); + requireSourceNotFrozen(system, source.sourceType(), feeId); + jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system=? AND source_type=? AND source_fee_id=? AND status='AVAILABLE'", + system, source.sourceType(), feeId); + jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源版本已替代',version=version+1,updated_by=0 WHERE cost_origin='BUSINESS_DIRECT_PAID' AND source_system=? AND source_type=? AND source_fee_id=? AND status='CONFIRMED'", + system, source.sourceType(), feeId); + jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,business_type,business_id,business_no,inbound_code,warehouse_id,direction,order_id,inbound_id,container_id,container_booking_id,container_no,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,review_status,status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'APPROVED','AVAILABLE')", + system, feeId, source.sourceVersion(), positive(source.customerId(), "customerId"), + source.sourceType(), trim(source.sourceNo()), trim(source.businessType()), number(source.businessId()), + trim(source.businessNo()), trim(source.inboundCode()), number(source.warehouseId()), source.direction(), + number(source.orderId()), number(source.inboundId()), number(source.containerId()), + number(source.containerBookingId()), trim(source.containerNo()), source.feeItemCode().trim(), + source.feeItemName().trim(), source.totalAmount(), source.currency(), trim(source.settlementMode()), + source.paidAt(), trim(source.paymentMethod()), trim(source.paymentReferenceNo()), + voucherJson(source.paymentVoucherFileIds()), source.sourceHash(), json(source)); + long id = jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? AND source_fee_version=?", + Long.class, system, source.sourceType(), feeId, source.sourceVersion()); + linkSettledWalletHold(id, source); + if ("PAYABLE".equals(source.direction()) && "DIRECT_PAID".equals(source.settlementMode())) + materializeDirectPaidCost(system, feeId, source); + return id; + } + + private void validate(EventEnvelope envelope, BusinessFeeSourceChangedV1 value) { + if (value == null || !ACTIONS.contains(value.action()) || blank(value.sourceSystem()) + || "FIRSTLEG".equalsIgnoreCase(value.sourceSystem()) || blank(value.sourceType()) + || value.sourceVersion() < 1 || !envelope.tenantId().equals(value.customerId()) + || blank(value.businessType()) || blank(value.businessNo()) || blank(value.feeItemCode()) + || blank(value.feeItemName()) || !DIRECTIONS.contains(value.direction()) + || value.businessDate() == null || !"CNY".equals(value.currency()) + || value.totalAmount() == null || value.totalAmount().signum() <= 0 + || !Set.of("CNY", "USD").contains(value.originalCurrency()) + || value.originalAmount() == null || value.originalAmount().signum() <= 0 + || value.exchangeRate() == null || value.exchangeRate().signum() <= 0 + || blank(value.exchangeRateMonth()) + || (value.periodStart() == null) != (value.periodEnd() == null) + || value.periodStart() != null && value.periodStart().isAfter(value.periodEnd()) + || (value.chargeableQuantity() == null) != blank(value.quantityUnit()) + || !"APPROVED".equals(value.reviewStatus()) || blank(value.sourceHash())) { + throw error("FIN-EVT-012", "跨区费用来源快照字段不完整"); + } + positive(value.sourceFeeId(), "sourceFeeId"); + positive(value.customerId(), "customerId"); + if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) { + if (!"LASTLEG".equals(value.sourceSystem()) || !"OVERSEAS_CONTAINER".equals(value.businessType())) + throw error("FIN-EVT-012", "海外柜费来源不一致"); + positive(value.containerBookingId(), "containerBookingId"); + } + if (!"VOIDED".equals(value.action()) && (!"PUBLISHED".equals(value.publicationStatus()) + || !"ACTIVE".equals(value.sourceStatus()))) { + throw error("FIN-EVT-015", "费用来源尚未发布或已失效"); + } + if ("PAYABLE".equals(value.direction())) { + long supplierId = positive(value.supplierId(), "supplierId"); + var supplier = supplierValidator.requireActive(supplierId, trim(value.supplierCategory())); + if (blank(value.supplierName()) || !value.supplierName().equals(supplier.name())) + throw error("FIN-EVT-012", "应付来源供应商快照不一致"); + validateSettlement(value); + } else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory()) + || !blank(value.settlementMode()) || value.paidAt() != null + || !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo()) + || (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) { + throw error("FIN-EVT-012", "应收来源不得携带供应商或付款事实"); + } + BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, value.sourceSystem(), + value.sourceType(), value.sourceFeeId(), value.sourceVersion(), value.customerId(), value.customerName(), + value.sourceNo(), value.businessType(), value.businessId(), value.businessNo(), value.orderId(), + value.inboundId(), value.inboundCode(), value.containerId(), value.containerBookingId(), value.containerNo(), + value.warehouseId(), value.feeItemCode(), value.feeItemName(), value.direction(), value.supplierId(), + value.supplierName(), value.supplierCategory(), value.settlementMode(), value.paidAt(), value.paymentMethod(), + value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(), + value.totalAmount(), value.originalCurrency(), value.originalAmount(), value.exchangeRate(), + value.exchangeRateMonth(), value.chargeableQuantity(), value.quantityUnit(), value.unitPrice(), + value.periodStart(), value.periodEnd(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null); + String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned))); + if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致"); + } + + private void validateSettlement(BusinessFeeSourceChangedV1 value) { + boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty(); + if ("ON_ACCOUNT".equals(value.settlementMode())) { + if (value.paidAt() != null || !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo()) || files) + throw error("FIN-EVT-012", "账期应付不得携带已付事实"); + } else if (!"DIRECT_PAID".equals(value.settlementMode()) || value.paidAt() == null + || !Set.of("ONLINE", "BANK_TRANSFER", "CASH", "OTHER").contains(value.paymentMethod()) + || (blank(value.paymentReferenceNo()) && !files)) { + throw error("FIN-EVT-012", "直接已付来源快照不完整"); + } + } + + private void requireSourceNotFrozen(String system, String type, long feeId) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=? AND f.source_type=? AND f.source_fee_id=? AND (f.wallet_hold_id IS NOT NULL OR EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, system, type, feeId); + if (count != null && count > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止调整或替代"); + } + + private void requireNotFrozen(long id) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=? AND (f.wallet_hold_id IS NOT NULL OR EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, id); + if (count != null && count > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止作废"); + } + + private void linkSettledWalletHold(long feeSourceId, BusinessFeeSourceChangedV1 source) { + if (!"RECEIVABLE".equals(source.direction())) return; + jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE f.id=? AND f.status='AVAILABLE' AND h.status='SETTLED' AND h.amount=f.amount", + feeSourceId); + } + + private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) { + jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)", + positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(), + system, value.sourceType(), feeId, value.sourceVersion(), value.sourceHash(), number(value.orderId()), + number(value.inboundId()), number(value.containerBookingId()), number(value.warehouseId()), positive(value.supplierId(), "supplierId"), + value.supplierName(), value.feeItemCode(), value.feeItemName(), value.currency(), value.totalAmount(), + json(value.paymentVoucherFileIds()), value.paidAt(), value.paymentMethod(), value.paymentReferenceNo()); + } + + private void voidDirectPaidCost(String system, BusinessFeeSourceChangedV1 value) { + jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源费用已作废',version=version+1,updated_by=0 WHERE cost_origin='BUSINESS_DIRECT_PAID' AND source_system=? AND source_type=? AND source_fee_id=? AND source_fee_version=? AND status='CONFIRMED'", + system, value.sourceType(), positive(value.sourceFeeId(), "sourceFeeId"), value.sourceVersion()); + } + + private BusinessFeeSourceChangedV1 payload(EventEnvelope envelope) { + try { return objectMapper.treeToValue(envelope.payload(), BusinessFeeSourceChangedV1.class); } + catch (JsonProcessingException exception) { throw error("FIN-EVT-001", "跨区费用事件载荷无法解析"); } + } + private String json(Object value) { + try { return objectMapper.writeValueAsString(value == null ? List.of() : value); } + catch (JsonProcessingException exception) { throw error("FIN-EVT-001", "费用事件序列化失败"); } + } + private String voucherJson(List values) { + return values == null || values.isEmpty() ? null : json(values); + } + private static Long number(String value) { return blank(value) ? null : positive(value, "business identifier"); } + private static long positive(String value, String name) { + try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; } + catch (RuntimeException ignored) { } + throw error("FIN-EVT-001", name + "无效"); + } + private static boolean blank(String value) { return value == null || value.isBlank(); } + private static String trim(String value) { return blank(value) ? null : value.trim(); } + private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceContainerSettlementQueryService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceContainerSettlementQueryService.java new file mode 100644 index 000000000..beea4c0c2 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceContainerSettlementQueryService.java @@ -0,0 +1,68 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.finance.dto.FinanceContainerSettlementDtos.ContainerCostAllocation; +import com.baiyue.finance.dto.FinanceContainerSettlementDtos.ContainerCostDetail; +import com.baiyue.finance.dto.FinanceContainerSettlementDtos.ContainerSettlementDetails; +import com.baiyue.finance.mapper.FinanceContainerSettlementQueryMapper; +import com.baiyue.finance.mapper.FinanceContainerSettlementQueryMapper.CostAllocationRow; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class FinanceContainerSettlementQueryService { + private final FinanceContainerSettlementQueryMapper mapper; + + public FinanceContainerSettlementQueryService(FinanceContainerSettlementQueryMapper mapper) { + this.mapper = mapper; + } + + public ContainerSettlementDetails details(long customerId, long containerBookingId) { + if (customerId <= 0) throw new IllegalArgumentException("客户范围无效"); + if (containerBookingId <= 0) throw new IllegalArgumentException("货柜范围无效"); + + var ownership = mapper.ownership(customerId, containerBookingId); + if (ownership == null || ownership.getCustomerLinkCount() == 0) { + throw new BizException(ResultCode.NOT_FOUND, "柜次不存在或不属于客户"); + } + + List settledSourceFeeIds = mapper.settledSourceFeeIds(customerId, containerBookingId).stream() + .filter(value -> value != null && value > 0) + .distinct() + .toList(); + List costs = groupCosts(mapper.costs(customerId, containerBookingId)); + return new ContainerSettlementDetails(customerId, containerBookingId, settledSourceFeeIds, costs); + } + + private static List groupCosts(List rows) { + Map grouped = new LinkedHashMap<>(); + for (CostAllocationRow row : rows) { + CostAccumulator accumulator = grouped.computeIfAbsent(row.getCostId(), ignored -> new CostAccumulator(row)); + if (row.getAllocationId() != null) { + accumulator.allocations.add(new ContainerCostAllocation(row.getAllocationId(), row.getOrderId(), + row.getInboundId(), row.getVolumeCbm(), row.getTotalVolumeCbm(), row.getAllocationRate(), + row.getAllocatedAmount(), row.isRoundingAdjustment())); + } + } + return grouped.values().stream().map(CostAccumulator::view).toList(); + } + + private static final class CostAccumulator { + private final CostAllocationRow cost; + private final List allocations = new ArrayList<>(); + + private CostAccumulator(CostAllocationRow cost) { this.cost = cost; } + + private ContainerCostDetail view() { + return new ContainerCostDetail(cost.getCostId(), cost.getCostNo(), cost.getCostType(), + cost.getCostOrigin(), cost.getSourceFeeId(), cost.getSourceFeeVersion(), cost.getContainerBookingId(), + cost.getSupplierName(), cost.getFeeItemName(), cost.getAmount(), cost.getCurrency(), + cost.getStatus(), List.copyOf(allocations)); + } + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java new file mode 100644 index 000000000..c27ab8532 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java @@ -0,0 +1,204 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.finance.client.IamCustomerClient; +import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; +import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class FinanceCustomerAccountService { + private final JdbcTemplate jdbc; + private final IamCustomerClient iamCustomerClient; + private final String internalToken; + + public FinanceCustomerAccountService(JdbcTemplate jdbc, IamCustomerClient iamCustomerClient, + @Value("${baiyue.security.internal-service-token:}") String internalToken) { + this.jdbc = jdbc; + this.iamCustomerClient = iamCustomerClient; + this.internalToken = internalToken; + } + + public PageEnvelope list(int page, int pageSize, String keyword, String status, Long customerId) { + validatePage(page, pageSize); + String normalizedStatus = status == null || status.isBlank() ? null : status.trim().toUpperCase(); + if (normalizedStatus != null && !List.of("ACTIVE", "DISABLED").contains(normalizedStatus)) { + throw new FinanceEventException("FIN-REQ-400", "账户状态无效"); + } + ApiResponse> response; + try { + response = iamCustomerClient.page(internalToken, page, pageSize, blankToNull(keyword), normalizedStatus, customerId); + } catch (RuntimeException error) { + throw new FinanceEventException("FIN_503", "客户主数据暂不可用"); + } + if (response == null || !response.successful() || response.data() == null) { + throw new FinanceEventException("FIN_503", "客户主数据暂不可用"); + } + PageResult customers = response.data(); + Map metrics = loadMetrics(customers.records().stream().map(IamCustomerClient.Customer::id).toList()); + List rows = customers.records().stream().map(customer -> { + Metrics value = metrics.get(customer.id()); + return new AccountSummary(customer.id(), customer.tenantCode(), customer.tenantName(), + customer.contactName(), customer.contactPhone(), value.outstandingReceivable(), + value.pendingBilling(), value.receivedInPeriod(), value.totalCost(), value.paidCost(), + value.outstandingPayable(), + value.actualBalance(), value.frozenBalance(), value.availableBalance(), + value.creditLimit(), value.creditUsed(), value.availableCredit(), + value.creditExpiresOn(), value.creditActive(), + value.accountVersion(), value.pendingRechargeCount(), + customer.status(), value.lastChangedAt()); + }).toList(); + return PageEnvelope.success(rows, (int) customers.page(), (int) customers.size(), customers.total()); + } + + private Map loadMetrics(List customerIds) { + if (customerIds.isEmpty()) return Map.of(); + String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList()); + Map result = new HashMap<>(); + customerIds.forEach(id -> result.put(id, new MutableMetrics())); + jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray()) + .forEach(row -> metric(result, row, "outstandingReceivable", "amount")); + jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') GROUP BY customer_id", customerIds.toArray()) + .forEach(row -> metric(result, row, "pendingBilling", "amount")); + jdbc.queryForList("SELECT tenant_id customer_id,SUM(received_amount) amount FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED' GROUP BY tenant_id", customerIds.toArray()) + .forEach(row -> metric(result, row, "receivedInPeriod", "amount")); + jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT customer_id,amount FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") AND direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status NOT IN ('VOIDED','SUPERSEDED') UNION ALL SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND status='CONFIRMED') costs GROUP BY customer_id", concat(customerIds, 2)) + .forEach(row -> metric(result, row, "totalCost", "amount")); + jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND status='CONFIRMED' UNION ALL SELECT tenant_id customer_id,paid_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED') paid_costs GROUP BY customer_id", concat(customerIds, 2)) + .forEach(row -> metric(result, row, "paidCost", "amount")); + jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,outstanding_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' UNION ALL SELECT f.customer_id,f.amount FROM fin_fee_source f WHERE f.customer_id IN (" + placeholders + ") AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED')) payable_obligations GROUP BY customer_id", concat(customerIds, 2)) + .forEach(row -> metric(result, row, "outstandingPayable", "amount")); + jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray()) + .forEach(row -> walletMetric(result, row)); + jdbc.queryForList("SELECT customer_id,COUNT(*) pending_count FROM fin_recharge_request WHERE customer_id IN (" + placeholders + ") AND status='PENDING' GROUP BY customer_id", customerIds.toArray()) + .forEach(row -> pendingMetric(result, row)); + jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_recharge_request WHERE customer_id IN (" + placeholders + ")) changes GROUP BY customer_id", concat(customerIds, 7)) + .forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at")); + Map frozen = new HashMap<>(); + result.forEach((id, value) -> frozen.put(id, value.freeze())); + return frozen; + } + + private static void walletMetric(Map result, Map row) { + Object id = row.get("customer_id"); + if (!(id instanceof Number number) || result.get(number.longValue()) == null) return; + MutableMetrics target = result.get(number.longValue()); + target.actualBalance = decimal(row.get("actual_balance")); + target.frozenBalance = decimal(row.get("frozen_balance")); + target.creditLimit = decimal(row.get("credit_limit")); + BigDecimal creditFrozen = decimal(row.get("credit_frozen")); + target.creditExpiresOn = row.get("credit_expires_on") instanceof java.sql.Date value ? value.toLocalDate() + : row.get("credit_expires_on") instanceof LocalDate value ? value : null; + target.creditActive = target.creditLimit.signum() > 0 && target.creditExpiresOn != null + && !target.creditExpiresOn.isBefore(LocalDate.now()); + target.creditUsed = decimal(row.get("credit_used")); + if (!target.creditActive && target.creditUsed.signum() > 0) { + target.actualBalance = target.actualBalance.subtract(target.creditUsed); + target.creditUsed = BigDecimal.ZERO; + } + target.availableCredit = target.creditActive + ? target.creditLimit.subtract(target.creditUsed).subtract(creditFrozen) : BigDecimal.ZERO; + target.availableBalance = target.creditActive + ? target.actualBalance.subtract(target.frozenBalance.subtract(creditFrozen)).add(target.availableCredit) + : target.actualBalance.subtract(target.frozenBalance); + target.accountVersion = row.get("version") instanceof Number value ? value.intValue() : 0; + } + + private static void pendingMetric(Map result, Map row) { + Object id = row.get("customer_id"); + if (!(id instanceof Number number) || result.get(number.longValue()) == null) return; + Object count = row.get("pending_count"); + result.get(number.longValue()).pendingRechargeCount = count instanceof Number value ? value.longValue() : 0; + } + + private static BigDecimal decimal(Object value) { + return value == null ? BigDecimal.ZERO : value instanceof BigDecimal amount ? amount : new BigDecimal(value.toString()); + } + + private static void metric(Map result, Map row, String field, String valueKey) { + Object id = row.get("customer_id"); + if (!(id instanceof Number number)) return; + MutableMetrics target = result.get(number.longValue()); + if (target == null) return; + Object value = row.get(valueKey); + if ("lastChangedAt".equals(field)) { + target.lastChangedAt = value instanceof Timestamp timestamp ? timestamp.toLocalDateTime() + : value instanceof LocalDateTime dateTime ? dateTime : null; + } else if (value != null) { + target.set(field, value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString())); + } + } + + private static void validatePage(int page, int pageSize) { + if (page < 1 || pageSize < 1 || pageSize > 200) throw new FinanceEventException("FIN-REQ-400", "分页参数无效"); + } + + private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); } + + private static Object[] concat(List values, int copies) { + Object[] result = new Object[values.size() * copies]; + Object[] source = values.toArray(); + for (int i = 0; i < copies; i++) System.arraycopy(source, 0, result, i * source.length, source.length); + return result; + } + + private static final class MutableMetrics { + private BigDecimal outstandingReceivable = BigDecimal.ZERO; + private BigDecimal pendingBilling = BigDecimal.ZERO; + private BigDecimal receivedInPeriod = BigDecimal.ZERO; + private BigDecimal totalCost = BigDecimal.ZERO; + private BigDecimal paidCost = BigDecimal.ZERO; + private BigDecimal outstandingPayable = BigDecimal.ZERO; + private BigDecimal actualBalance = BigDecimal.ZERO; + private BigDecimal frozenBalance = BigDecimal.ZERO; + private BigDecimal availableBalance = BigDecimal.ZERO; + private BigDecimal creditLimit; + private BigDecimal creditUsed = BigDecimal.ZERO; + private BigDecimal availableCredit; + private LocalDate creditExpiresOn; + private boolean creditActive; + private int accountVersion; + private long pendingRechargeCount; + private LocalDateTime lastChangedAt; + private MutableMetrics() { + this.creditLimit = BigDecimal.ZERO.setScale(2); + this.availableCredit = BigDecimal.ZERO.setScale(2); + this.availableBalance = BigDecimal.ZERO.setScale(2); + } + void set(String field, BigDecimal value) { + switch (field) { + case "outstandingReceivable" -> outstandingReceivable = value; + case "pendingBilling" -> pendingBilling = value; + case "receivedInPeriod" -> receivedInPeriod = value; + case "totalCost" -> totalCost = value; + case "paidCost" -> paidCost = value; + case "outstandingPayable" -> outstandingPayable = value; + default -> { } + } + } + Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable, + actualBalance, frozenBalance, availableBalance, creditLimit, creditUsed, availableCredit, + creditExpiresOn, creditActive, + accountVersion, pendingRechargeCount, lastChangedAt); } + } + + private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling, + BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost, + BigDecimal outstandingPayable, BigDecimal actualBalance, BigDecimal frozenBalance, + BigDecimal availableBalance, BigDecimal creditLimit, BigDecimal creditUsed, + BigDecimal availableCredit, LocalDate creditExpiresOn, boolean creditActive, + int accountVersion, long pendingRechargeCount, + LocalDateTime lastChangedAt) { + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java new file mode 100644 index 000000000..d9c02ac8a --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java @@ -0,0 +1,55 @@ +package com.baiyue.finance.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.Set; + +@Component +public class FinanceDocumentNumberGenerator { + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT); + private static final Set SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC"); + private static final long MAX_DAILY_SEQUENCE = 9_999L; + + private final JdbcTemplate jdbc; + private final Clock clock; + + @Autowired + public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) { + this(jdbc, Clock.system(BUSINESS_ZONE)); + } + + FinanceDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) { + this.jdbc = jdbc; + this.clock = clock; + } + + @Transactional(propagation = Propagation.MANDATORY) + public String next(String prefix) { + if (!SUPPORTED_PREFIXES.contains(prefix)) { + throw new IllegalArgumentException("unsupported finance document prefix: " + prefix); + } + LocalDate sequenceDate = LocalDate.now(clock); + String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate); + jdbc.update( + "INSERT INTO fin_document_sequence(sequence_key,current_value) " + + "VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " + + "current_value=LAST_INSERT_ID(current_value+1)", + sequenceKey); + Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); + if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) { + throw new FinanceEventException("FIN_409", "当日" + prefix + "单据编号已用尽"); + } + return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" + + String.format(Locale.ROOT, "%04d", sequence); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceEventException.java b/finance/src/main/java/com/baiyue/finance/service/FinanceEventException.java new file mode 100644 index 000000000..1965712f5 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceEventException.java @@ -0,0 +1,12 @@ +package com.baiyue.finance.service; + +public final class FinanceEventException extends RuntimeException { + private final String code; + + public FinanceEventException(String code, String message) { + super(message); + this.code = code; + } + + public String code() { return code; } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java new file mode 100644 index 000000000..ce9af6f84 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java @@ -0,0 +1,511 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.common.core.export.ExportContracts.ExportColumn; +import com.baiyue.common.core.export.ExportContracts.ExportColumnType; +import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; +import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary; +import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail; +import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem; +import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** Provides versioned, read-only Finance export batches to File Service. */ +@Service +public class FinanceExportProviderService { + public static final String CUSTOMER_ACCOUNT_LIST = "FINANCE_CUSTOMER_ACCOUNT_LIST"; + public static final String CUSTOMER_ACCOUNT_LEDGER = "FINANCE_CUSTOMER_ACCOUNT_LEDGER"; + public static final String FEE_SOURCE_LIST = "FINANCE_FEE_SOURCE_LIST"; + public static final String RECONCILIATION_STATEMENT_LIST = "FINANCE_RECONCILIATION_STATEMENT_LIST"; + public static final String RECEIVABLE_SETTLEMENT_LIST = "FINANCE_RECEIVABLE_SETTLEMENT_LIST"; + public static final String RECEIVABLE_SETTLEMENT_DETAIL = "FINANCE_RECEIVABLE_SETTLEMENT_DETAIL"; + public static final String PAYABLE_SETTLEMENT_LIST = "FINANCE_PAYABLE_SETTLEMENT_LIST"; + public static final String PAYABLE_SETTLEMENT_DETAIL = "FINANCE_PAYABLE_SETTLEMENT_DETAIL"; + public static final String RECEIVABLE_STATISTICS = "FINANCE_RECEIVABLE_STATISTICS"; + + private static final int SCHEMA_VERSION = 1; + private static final int MAX_BATCH_SIZE = 500; + private static final int QUERY_PAGE_SIZE = 200; + private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512; + private static final Set TYPES = Set.of(CUSTOMER_ACCOUNT_LIST, CUSTOMER_ACCOUNT_LEDGER, + FEE_SOURCE_LIST, RECONCILIATION_STATEMENT_LIST, RECEIVABLE_SETTLEMENT_LIST, + RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_DETAIL, + RECEIVABLE_STATISTICS); + private static final Set SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED"); + private static final Set LEDGER_TYPES = Set.of("FEE_SOURCE_EDIT", "FEE_SOURCE_SUBMIT", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_REVIEW_REJECT", "FEE_SOURCE_ADJUST", "FEE_SOURCE_VOID", "RECONCILIATION_CREATED", + "SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "SUPPLIER_DIRECT_PAID", + "SUPPLIER_ON_ACCOUNT_PAYMENT", "ADJUSTMENT", "VOID"); + + private static final List ACCOUNT_COLUMNS = List.of( + column("customerCode", "客户编码", ExportColumnType.STRING, 18), column("customerName", "客户", ExportColumnType.STRING, 28), + column("contactName", "联系人", ExportColumnType.STRING, 16), column("contactPhone", "联系电话", ExportColumnType.STRING, 18), + column("outstandingReceivable", "待收客户款(CNY)", ExportColumnType.DECIMAL, 18), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16), + column("receivedInPeriod", "本期已收(CNY)", ExportColumnType.DECIMAL, 16), column("totalCost", "总成本(CNY)", ExportColumnType.DECIMAL, 16), + column("paidCost", "已线下支付供应商款(CNY)", ExportColumnType.DECIMAL, 24), column("outstandingPayable", "待付供应商款(CNY)", ExportColumnType.DECIMAL, 18), + column("status", "客户状态", ExportColumnType.STRING, 14), column("lastChangedAt", "最近变动", ExportColumnType.DATETIME, 21)); + private static final List LEDGER_COLUMNS = List.of( + column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21), + column("businessType", "业务类型", ExportColumnType.STRING, 18), column("documentNo", "关联单据", ExportColumnType.STRING, 24), + column("receivableChange", "应收变动(CNY)", ExportColumnType.DECIMAL, 18), column("payableChange", "应付变动(CNY)", ExportColumnType.DECIMAL, 18), + column("outstandingReceivableAfter", "变动后未收(CNY)", ExportColumnType.DECIMAL, 20), column("operatorName", "操作人", ExportColumnType.STRING, 16), + column("remark", "备注", ExportColumnType.STRING, 28)); + private static final List FEE_COLUMNS = List.of( + column("customerName", "客户", ExportColumnType.STRING, 28), column("direction", "收付方向", ExportColumnType.STRING, 14), + column("sourceType", "来源类型", ExportColumnType.STRING, 18), column("sourceFeeId", "来源单据ID", ExportColumnType.STRING, 22), + column("sourceVersion", "来源版本", ExportColumnType.INTEGER, 12), column("inboundCode", "入仓号", ExportColumnType.STRING, 20), + column("containerNo", "柜次号", ExportColumnType.STRING, 20), column("businessDate", "业务发生日", ExportColumnType.DATE, 14), + column("feeItemName", "费用项目", ExportColumnType.STRING, 22), column("quantity", "计费量", ExportColumnType.DECIMAL, 14), + column("unitPrice", "单价(CNY)", ExportColumnType.DECIMAL, 14), column("amount", "金额(CNY)", ExportColumnType.DECIMAL, 16), + column("reviewStatus", "审核状态", ExportColumnType.STRING, 14), + column("sourceStatus", "来源状态", ExportColumnType.STRING, 14), column("statementGenerated", "已生成对账单", ExportColumnType.BOOLEAN, 16)); + private static final List STATEMENT_COLUMNS = List.of( + column("statementNo", "对账单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28), + column("periodStart", "结算起始日", ExportColumnType.DATE, 14), column("periodEnd", "结算结束日", ExportColumnType.DATE, 14), + column("creditDays", "账期(天)", ExportColumnType.INTEGER, 12), column("currency", "货币", ExportColumnType.STRING, 10), + column("totalReceivable", "总应收金额(CNY)", ExportColumnType.DECIMAL, 20), column("receivedAmount", "已收金额(CNY)", ExportColumnType.DECIMAL, 18), + column("outstandingAmount", "未收金额(CNY)", ExportColumnType.DECIMAL, 18), column("settled", "是否结清", ExportColumnType.BOOLEAN, 12), + column("status", "审核状态", ExportColumnType.STRING, 14), column("remark", "备注", ExportColumnType.STRING, 28), + column("createdByName", "创建人", ExportColumnType.STRING, 16), column("createdAt", "创建时间", ExportColumnType.DATETIME, 21), + column("updatedByName", "更新人", ExportColumnType.STRING, 16), column("updatedAt", "更新时间", ExportColumnType.DATETIME, 21)); + private static final List SETTLEMENT_LIST_COLUMNS = List.of( + column("settlementNo", "结算单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28), + column("supplierName", "供应商", ExportColumnType.STRING, 24), column("currency", "货币", ExportColumnType.STRING, 10), + column("totalAmount", "总金额(CNY)", ExportColumnType.DECIMAL, 18), column("paidAmount", "已收/付金额(CNY)", ExportColumnType.DECIMAL, 20), + column("outstandingAmount", "未收/付金额(CNY)", ExportColumnType.DECIMAL, 20), column("businessPeriod", "业务期间", ExportColumnType.STRING, 18), + column("generatedDate", "生成日期", ExportColumnType.DATE, 14), column("dueDate", "到期日期", ExportColumnType.DATE, 14), + column("status", "状态", ExportColumnType.STRING, 16)); + private static final List SETTLEMENT_DETAIL_COLUMNS = List.of( + column("settlementNo", "结算单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28), + column("supplierName", "供应商", ExportColumnType.STRING, 24), column("feeItemName", "费用项目", ExportColumnType.STRING, 22), + column("inboundCode", "入仓号", ExportColumnType.STRING, 20), column("containerNo", "柜次号", ExportColumnType.STRING, 20), + column("businessDate", "业务发生日", ExportColumnType.DATE, 14), column("lineAmount", "明细金额(CNY)", ExportColumnType.DECIMAL, 18), + column("totalAmount", "结算总额(CNY)", ExportColumnType.DECIMAL, 18), column("paidAmount", "已收/付金额(CNY)", ExportColumnType.DECIMAL, 20), + column("outstandingAmount", "未收/付金额(CNY)", ExportColumnType.DECIMAL, 20), column("status", "状态", ExportColumnType.STRING, 16)); + private static final List RECEIVABLE_STATISTICS_COLUMNS = List.of( + column("statisticsMonth", "统计月份", ExportColumnType.STRING, 14), + column("customerName", "应收单位", ExportColumnType.STRING, 28), + column("currency", "币种", ExportColumnType.STRING, 10), + column("totalReceivable", "应收总额(CNY)", ExportColumnType.DECIMAL, 18), + column("totalReceived", "已收合计(CNY)", ExportColumnType.DECIMAL, 18), + column("totalOutstanding", "余额未收(CNY)", ExportColumnType.DECIMAL, 18)); + + private final FinanceCustomerAccountService accountService; + private final FinancePhase1OperationService operationService; + private final FinanceQueryService queryService; + private final ReconciliationStatementService reconciliationService; + private final FinanceSettlementService settlementService; + private final ReceivableStatisticsService receivableStatisticsService; + private final ObjectMapper objectMapper; + private final String internalServiceToken; + private byte[] snapshotSigningKey; + + public FinanceExportProviderService(FinanceCustomerAccountService accountService, + FinancePhase1OperationService operationService, + FinanceQueryService queryService, + ReconciliationStatementService reconciliationService, + FinanceSettlementService settlementService, + ReceivableStatisticsService receivableStatisticsService, + ObjectMapper objectMapper, + @Value("${baiyue.security.internal-service-token}") String internalServiceToken) { + this.accountService = accountService; + this.operationService = operationService; + this.queryService = queryService; + this.reconciliationService = reconciliationService; + this.settlementService = settlementService; + this.receivableStatisticsService = receivableStatisticsService; + this.objectMapper = objectMapper; + this.internalServiceToken = internalServiceToken; + } + + @PostConstruct + void initializeSigningKey() { + snapshotSigningKey = internalServiceToken.getBytes(StandardCharsets.UTF_8); + } + + public ExportBatchResponse exportBatch(String exportType, ExportBatchRequest request) { + String type = normalizeType(exportType); + ValidatedRequest validated = validate(type, request); + boolean firstBatch = validated.snapshotToken() == null; + String snapshot = firstBatch ? signSnapshot(type, validated) : verifySnapshot(type, validated); + BatchRows batch = rows(type, validated); + String nextCursor = String.valueOf(validated.offset() + batch.rows().size()); + return new ExportBatchResponse(SCHEMA_VERSION, schemaHash(columns(type)), snapshot, + firstBatch ? columns(type) : List.of(), batch.rows(), nextCursor, + validated.offset() + batch.rows().size() < batch.totalRows(), firstBatch ? batch.totalRows() : null); + } + + private BatchRows rows(String type, ValidatedRequest request) { + int offset = request.offset(); + int limit = request.source().limit(); + Map p = request.parameters(); + if (CUSTOMER_ACCOUNT_LIST.equals(type)) { + int querySize = Math.min(limit, QUERY_PAGE_SIZE); + int page = offset / querySize + 1; + var result = accountService.list(page, querySize, null, text(p, "status"), longValue(p, "customerId", false)); + return new BatchRows(result.data().stream().map(this::accountRow).toList(), result.page().total()); + } + if (CUSTOMER_ACCOUNT_LEDGER.equals(type)) { + int querySize = Math.min(limit, QUERY_PAGE_SIZE); + int page = offset / querySize + 1; + var result = operationService.listDerivedCustomerAccountLedger(longValue(p, "customerId", true), page, querySize, + text(p, "businessType"), date(p, "startDate"), date(p, "endDate")); + return new BatchRows(result.data().stream().map(this::ledgerRow).toList(), result.page().total()); + } + List> all = switch (type) { + case FEE_SOURCE_LIST -> feeRows(p); + case RECONCILIATION_STATEMENT_LIST -> statementRows(p); + case RECEIVABLE_SETTLEMENT_LIST -> settlementRows("RECEIVABLE", p); + case PAYABLE_SETTLEMENT_LIST -> settlementRows("PAYABLE", p); + case RECEIVABLE_SETTLEMENT_DETAIL -> settlementDetailRows("RECEIVABLE", p); + case PAYABLE_SETTLEMENT_DETAIL -> settlementDetailRows("PAYABLE", p); + case RECEIVABLE_STATISTICS -> receivableStatisticsRows(p); + default -> throw invalid("Unsupported Finance export type"); + }; + int from = Math.min(offset, all.size()); + int to = Math.min(from + limit, all.size()); + return new BatchRows(List.copyOf(all.subList(from, to)), (long) all.size()); + } + + private List> receivableStatisticsRows(Map parameters) { + YearMonth month = statisticsMonth(parameters); + Long customerId = longValue(parameters, "customerId", false); + return receivableStatisticsService.exportRows(month, customerId).stream() + .map(value -> receivableStatisticsRow(month, value)).toList(); + } + + private List> feeRows(Map p) { + Long customerId = longValue(p, "customerId", false); + String direction = text(p, "direction"); + String status = text(p, "status"); + String reviewStatus = text(p, "reviewStatus"); + Boolean generated = bool(p, "statementGenerated"); + LocalDate from = date(p, "businessDateFrom"), to = date(p, "businessDateTo"); + String keyword = lower(text(p, "keyword")); + List> rows = new ArrayList<>(); + for (FeePoolItem item : queryService.feePool(customerId, direction)) { + FeeSourceDetail detail = detail(item); + String sourceStatus = detail == null ? item.status() : detail.sourceStatus(); + String actualReview = detail == null ? null : detail.reviewStatus(); + Boolean actualGenerated = detail == null ? null : detail.statementGenerated(); + LocalDate businessDate = detail == null || detail.businessDate() == null + ? (item.createdAt() == null ? null : item.createdAt().toLocalDate()) : detail.businessDate(); + if (status != null && !status.equalsIgnoreCase(sourceStatus)) continue; + if (reviewStatus != null && !reviewStatus.equalsIgnoreCase(actualReview)) continue; + if (generated != null && !generated.equals(actualGenerated)) continue; + if (from != null && (businessDate == null || businessDate.isBefore(from))) continue; + if (to != null && (businessDate == null || businessDate.isAfter(to))) continue; + String searchable = lower(String.join(" ", value(item.poolItemId()), value(item.sourceId()), value(item.feeItemName()), + detail == null ? "" : value(detail.inboundCode()), detail == null ? "" : value(detail.containerNo()))); + if (keyword != null && !searchable.contains(keyword)) continue; + rows.add(feeRow(item, detail, businessDate)); + } + return rows; + } + + private FeeSourceDetail detail(FeePoolItem item) { + if ("MANUAL_COST".equals(item.sourceType())) return null; + String[] parts = item.poolItemId().split(":", -1); + if (parts.length != 2) return null; + try { return operationService.getFinanceFeeSource(Long.parseLong(parts[1])); } + catch (RuntimeException ignored) { return null; } + } + + private List> statementRows(Map p) { + Long customerId = longValue(p, "customerId", false); + String status = text(p, "status"), billNo = lower(text(p, "billNo")), containerNo = lower(text(p, "containerNo")); + LocalDate periodStartFrom = date(p, "periodStartFrom"), periodStartTo = date(p, "periodStartTo"); + LocalDate periodEndFrom = date(p, "periodEndFrom"), periodEndTo = date(p, "periodEndTo"); + LocalDate lastPaymentFrom = date(p, "lastPaymentFrom"), lastPaymentTo = date(p, "lastPaymentTo"); + LocalDate createdFrom = date(p, "createdFrom"), createdTo = date(p, "createdTo"); + Boolean settled = bool(p, "settled"); + List> rows = new ArrayList<>(); + queryService.statements(customerId, false).forEach(summary -> { + ReconciliationDetail detail = reconciliationService.detailForPlatform(summary.id()); + LocalDate lastPayment = detail.paymentHistory().stream().map(row -> row.transactionDate()).max(LocalDate::compareTo).orElse(null); + boolean containerMatches = containerNo == null || detail.lines().stream() + .map(line -> feeSourceContainerNo(line.feeSourceId())) + .anyMatch(value -> value != null && value.toLowerCase(Locale.ROOT).contains(containerNo)); + if (status != null && !status.equalsIgnoreCase(detail.status())) return; + if (billNo != null && !detail.statementNo().toLowerCase(Locale.ROOT).contains(billNo)) return; + if (!containerMatches || (settled != null && settled != detail.settled())) return; + if (!between(detail.periodStart(), periodStartFrom, periodStartTo) || !between(detail.periodEnd(), periodEndFrom, periodEndTo)) return; + if ((lastPaymentFrom != null || lastPaymentTo != null) && !between(lastPayment, lastPaymentFrom, lastPaymentTo)) return; + if (!between(detail.createdAt().toLocalDate(), createdFrom, createdTo)) return; + rows.add(statementRow(detail)); + }); + return rows; + } + + private String feeSourceContainerNo(long feeSourceId) { + try { + return operationService.getFinanceFeeSource(feeSourceId).containerNo(); + } catch (RuntimeException ignored) { + return null; + } + } + + private List> settlementRows(String direction, Map p) { + Long customerId = longValue(p, "customerId", false); + String status = text(p, "status"), no = lower(text(p, "settlementNo")), supplier = lower(text(p, "supplierName")); + return settlementService.listSettlements(direction, customerId).stream() + .filter(row -> status == null || status.equalsIgnoreCase(row.status())) + .filter(row -> no == null || row.settlementNo().toLowerCase(Locale.ROOT).contains(no)) + .filter(row -> supplier == null || lower(row.supplierName()) != null && lower(row.supplierName()).contains(supplier)) + .map(this::settlementRow).toList(); + } + + private List> settlementDetailRows(String direction, Map p) { + SettlementDetail detail = settlementService.getSettlement(longValue(p, "settlementId", true)); + if (!direction.equals(detail.summary().direction())) throw invalid("结算单方向与导出类型不匹配"); + if (detail.lines().isEmpty()) return List.of(settlementDetailRow(detail.summary(), null)); + return detail.lines().stream().map(line -> settlementDetailRow(detail.summary(), line)).toList(); + } + + private ValidatedRequest validate(String type, ExportBatchRequest request) { + if (request == null) throw invalid("Export batch request is required"); + positive(request.taskId(), "taskId"); positive(request.requestedBy(), "requestedBy"); + if (!"0".equals(request.tenantId()) || !"PLATFORM".equals(request.appCode())) throw invalid("Finance export only supports PLATFORM tenant 0"); + if (request.dispatchVersion() <= 0 || request.limit() < 1 || request.limit() > MAX_BATCH_SIZE) throw invalid("Finance export batch limit is invalid"); + Map parameters = request.parameters() == null ? Map.of() : request.parameters(); + Set allowed = allowedParameters(type); + if (!allowed.containsAll(parameters.keySet())) throw invalid(type + " contains unsupported parameters"); + validateParameters(type, parameters); + String snapshot = normalize(request.snapshotToken()); + if (snapshot != null && snapshot.length() > MAX_SNAPSHOT_TOKEN_LENGTH) throw invalid("snapshotToken is too long"); + int offset = integerCursor(request.cursor()); + if (snapshot == null && offset != 0) throw invalid("cursor requires a snapshotToken"); + return new ValidatedRequest(request, Map.copyOf(parameters), offset, snapshot); + } + + private void validateParameters(String type, Map p) { + Long customerId = longValue(p, "customerId", false); + if (customerId != null && customerId <= 0) throw invalid("customerId must be positive"); + String status = text(p, "status"); + if (CUSTOMER_ACCOUNT_LIST.equals(type) && status != null && !Set.of("ACTIVE", "DISABLED").contains(status)) throw invalid("account status is invalid"); + if (CUSTOMER_ACCOUNT_LEDGER.equals(type)) { + longValue(p, "customerId", true); + String businessType = text(p, "businessType"); + if (businessType != null && !LEDGER_TYPES.contains(businessType)) throw invalid("ledger businessType is invalid"); + range(p, "startDate", "endDate"); + } + if (FEE_SOURCE_LIST.equals(type)) { + String direction = text(p, "direction"); + if (direction != null && !Set.of("RECEIVABLE", "PAYABLE").contains(direction)) throw invalid("fee direction is invalid"); + String review = text(p, "reviewStatus"); + if (review != null && !Set.of("PENDING", "APPROVED", "REJECTED").contains(review)) throw invalid("reviewStatus is invalid"); + bool(p, "statementGenerated"); range(p, "businessDateFrom", "businessDateTo"); + max(text(p, "keyword"), 128, "keyword"); max(status, 64, "status"); + } + if (RECONCILIATION_STATEMENT_LIST.equals(type)) { + bool(p, "settled"); range(p, "periodStartFrom", "periodStartTo"); range(p, "periodEndFrom", "periodEndTo"); + range(p, "lastPaymentFrom", "lastPaymentTo"); range(p, "createdFrom", "createdTo"); + max(text(p, "billNo"), 128, "billNo"); max(text(p, "containerNo"), 128, "containerNo"); + } + if (type.contains("SETTLEMENT")) { + if (type.endsWith("_DETAIL")) longValue(p, "settlementId", true); + else { + if (status != null && !SETTLEMENT_STATUSES.contains(status)) throw invalid("settlement status is invalid"); + max(text(p, "settlementNo"), 128, "settlementNo"); max(text(p, "supplierName"), 128, "supplierName"); + } + } + if (RECEIVABLE_STATISTICS.equals(type)) statisticsMonth(p); + } + + private String signSnapshot(String type, ValidatedRequest request) { + return "v1." + Base64.getUrlEncoder().withoutPadding().encodeToString(hmac(snapshotSubject(type, request))); + } + + private String verifySnapshot(String type, ValidatedRequest request) { + String expected = signSnapshot(type, request); + if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), request.snapshotToken().getBytes(StandardCharsets.UTF_8))) { + throw invalid("snapshotToken does not match this export task"); + } + return request.snapshotToken(); + } + + private String snapshotSubject(String type, ValidatedRequest request) { + try { + ExportBatchRequest source = request.source(); + return String.join("\n", type, source.taskId(), source.tenantId(), source.appCode(), source.requestedBy(), + String.valueOf(source.dispatchVersion()), objectMapper.writeValueAsString(new TreeMap<>(request.parameters()))); + } catch (Exception exception) { throw invalid("Finance export parameters cannot be serialized"); } + } + + private byte[] hmac(String value) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(snapshotSigningKey, "HmacSHA256")); + return mac.doFinal(value.getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException exception) { throw new IllegalStateException("HmacSHA256 is unavailable", exception); } + } + + private Map accountRow(AccountSummary value) { + return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(), + "contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(), + "receivedInPeriod", value.receivedInPeriod(), "totalCost", value.totalCost(), "paidCost", value.paidCost(), + "outstandingPayable", value.outstandingPayable(), "status", value.status(), "lastChangedAt", value.lastChangedAt()); + } + private Map ledgerRow(LedgerEntry value) { + return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", value.businessType(), + "documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(), + "outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark()); + } + private Map feeRow(FeePoolItem item, FeeSourceDetail detail, LocalDate businessDate) { + return row("customerName", detail == null ? null : detail.customerName(), "direction", item.direction(), "sourceType", item.sourceType(), + "sourceFeeId", String.valueOf(item.sourceId()), "sourceVersion", item.sourceVersion(), "inboundCode", detail == null ? null : detail.inboundCode(), + "containerNo", detail == null ? null : detail.containerNo(), "businessDate", businessDate, "feeItemName", item.feeItemName(), + "quantity", detail == null ? null : detail.quantity(), "unitPrice", detail == null ? null : detail.unitPrice(), "amount", item.amount(), + "reviewStatus", detail == null ? null : detail.reviewStatus(), + "sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated()); + } + private Map statementRow(ReconciliationDetail value) { + return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(), + "periodEnd", value.periodEnd(), "creditDays", value.creditDays(), "currency", value.currency(), "totalReceivable", value.totalReceivable(), + "receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", value.status(), + "remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt()); + } + private Map settlementRow(SettlementSummary value) { + return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(), + "currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(), + "businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", value.status()); + } + private Map settlementDetailRow(SettlementSummary summary, SettlementLine line) { + return row("settlementNo", summary.settlementNo(), "customerName", summary.customerName(), "supplierName", summary.supplierName(), + "feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(), + "containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(), + "lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(), + "outstandingAmount", summary.outstandingAmount(), "status", summary.status()); + } + private Map receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) { + return row("statisticsMonth", month.toString(), "customerName", value.customerName(), + "currency", value.currency(), "totalReceivable", value.totalReceivable(), + "totalReceived", value.totalReceived(), "totalOutstanding", value.totalOutstanding()); + } + + private static List columns(String type) { + return switch (type) { + case CUSTOMER_ACCOUNT_LIST -> ACCOUNT_COLUMNS; + case CUSTOMER_ACCOUNT_LEDGER -> LEDGER_COLUMNS; + case FEE_SOURCE_LIST -> FEE_COLUMNS; + case RECONCILIATION_STATEMENT_LIST -> STATEMENT_COLUMNS; + case RECEIVABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_LIST -> SETTLEMENT_LIST_COLUMNS; + case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> SETTLEMENT_DETAIL_COLUMNS; + case RECEIVABLE_STATISTICS -> RECEIVABLE_STATISTICS_COLUMNS; + default -> throw invalid("Unsupported Finance export type"); + }; + } + private static Set allowedParameters(String type) { + return switch (type) { + case CUSTOMER_ACCOUNT_LIST -> Set.of("customerId", "status"); + case CUSTOMER_ACCOUNT_LEDGER -> Set.of("customerId", "businessType", "startDate", "endDate"); + case FEE_SOURCE_LIST -> Set.of("customerId", "direction", "status", "reviewStatus", "statementGenerated", "businessDateFrom", "businessDateTo", "keyword"); + case RECONCILIATION_STATEMENT_LIST -> Set.of("customerId", "status", "billNo", "containerNo", "periodStartFrom", "periodStartTo", "periodEndFrom", "periodEndTo", "settled", "lastPaymentFrom", "lastPaymentTo", "createdFrom", "createdTo"); + case RECEIVABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo"); + case PAYABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo", "supplierName"); + case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> Set.of("settlementId"); + case RECEIVABLE_STATISTICS -> Set.of("month", "customerId"); + default -> throw invalid("Unsupported Finance export type"); + }; + } + private static String normalizeType(String value) { + String type = normalize(value); + if (type == null || !TYPES.contains(type.toUpperCase(Locale.ROOT))) throw invalid("Unsupported Finance export type"); + return type.toUpperCase(Locale.ROOT); + } + private static ExportColumn column(String key, String title, ExportColumnType type, int width) { return new ExportColumn(key, title, type, width); } + private static String schemaHash(List columns) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (ExportColumn column : columns) digest.update((column.key() + "\u001f" + column.title() + "\u001f" + column.type() + "\u001f" + column.width() + "\n").getBytes(StandardCharsets.UTF_8)); + return "sha256:" + java.util.HexFormat.of().formatHex(digest.digest()); + } catch (GeneralSecurityException exception) { throw new ExceptionInInitializerError(exception); } + } + private static Map row(Object... values) { + Map row = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) row.put(String.valueOf(values[i]), values[i + 1]); + return row; + } + private static int integerCursor(String value) { + String normalized = normalize(value); + if (normalized == null) return 0; + try { int cursor = Integer.parseInt(normalized); if (cursor < 0) throw invalid("cursor must not be negative"); return cursor; } + catch (NumberFormatException exception) { throw invalid("cursor must be a numeric offset"); } + } + private static Long longValue(Map values, String key, boolean required) { + Object value = values.get(key); + if (value == null || value.toString().isBlank()) { if (required) throw invalid(key + " is required"); return null; } + if (!(value instanceof String)) throw invalid(key + " must be a string ID"); + try { long result = Long.parseLong(value.toString()); if (result <= 0) throw invalid(key + " must be positive"); return result; } + catch (NumberFormatException exception) { throw invalid(key + " must be a positive ID"); } + } + private static String text(Map values, String key) { + Object value = values.get(key); + if (value == null) return null; + if (!(value instanceof String text)) throw invalid(key + " must be a string"); + return normalize(text); + } + private static Boolean bool(Map values, String key) { + Object value = values.get(key); + if (value == null) return null; + if (!(value instanceof Boolean result)) throw invalid(key + " must be a boolean"); + return result; + } + private static LocalDate date(Map values, String key) { + String value = text(values, key); + if (value == null) return null; + try { return LocalDate.parse(value); } + catch (RuntimeException exception) { throw invalid(key + " must use YYYY-MM-DD"); } + } + private static YearMonth statisticsMonth(Map values) { + String value = text(values, "month"); + if (value == null) throw invalid("month is required"); + try { return YearMonth.parse(value); } + catch (RuntimeException exception) { throw invalid("month must use YYYY-MM"); } + } + private static void range(Map values, String fromKey, String toKey) { + LocalDate from = date(values, fromKey), to = date(values, toKey); + if (from != null && to != null && from.isAfter(to)) throw invalid(fromKey + " cannot be after " + toKey); + } + private static boolean between(LocalDate value, LocalDate from, LocalDate to) { + if (value == null) return from == null && to == null; + return (from == null || !value.isBefore(from)) && (to == null || !value.isAfter(to)); + } + private static void positive(String value, String field) { + try { if (value == null || Long.parseLong(value) <= 0) throw invalid(field + " must be a positive ID"); } + catch (NumberFormatException exception) { throw invalid(field + " must be a positive ID"); } + } + private static void max(String value, int max, String field) { if (value != null && value.length() > max) throw invalid(field + " is too long"); } + private static String lower(String value) { return value == null ? null : value.toLowerCase(Locale.ROOT); } + private static String normalize(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String value(Object value) { return value == null ? "" : value.toString(); } + private static BizException invalid(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); } + + private record ValidatedRequest(ExportBatchRequest source, Map parameters, int offset, String snapshotToken) {} + private record BatchRows(List> rows, long totalRows) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java new file mode 100644 index 000000000..b36277561 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java @@ -0,0 +1,254 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.client.FirstlegFeeSourceClient; +import com.baiyue.finance.dto.FinanceFeeSourceEvent; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +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.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class FinanceFeeSourceEventService { + private static final Set EVENT_TYPES = Set.of( + "FIRSTLEG_FEE_PUBLISHED", "FIRSTLEG_FEE_ADJUSTED", "FIRSTLEG_FEE_VOIDED"); + + private final JdbcTemplate jdbc; + private final FirstlegFeeSourceClient firstleg; + private final ObjectMapper objectMapper; + private final FinanceSupplierValidator supplierValidator; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + + @Transactional + public long consume(FinanceFeeSourceEvent event) { + validateEnvelope(event); + String eventJson = json(event); + String requestHash = sha256(eventJson); + List> existing = jdbc.queryForList( + "SELECT payload_hash,status,result_resource_id FROM fin_inbox_event WHERE source_system=? AND event_id=? FOR UPDATE", + "FIRSTLEG", event.eventId()); + if (!existing.isEmpty()) { + Map row = existing.get(0); + if (!requestHash.equals(row.get("payload_hash"))) { + throw error("FIN-EVT-002", "同一事件ID对应不同请求内容"); + } + if ("PROCESSED".equals(row.get("status")) && row.get("result_resource_id") instanceof Number id) { + return id.longValue(); + } + throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败"); + } + jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status) VALUES (?,?,?,?,?,'RECEIVED')", + event.eventId(), "FIRSTLEG", event.eventType(), eventJson, requestHash); + + FirstlegFeeSourceClient.Snapshot snapshot = loadSnapshot(event); + validateSnapshot(event, snapshot); + long resourceId = applyEvent(event, snapshot); + jdbc.update("UPDATE fin_inbox_event SET status='PROCESSED',result_resource_id=?,processed_at=CURRENT_TIMESTAMP(3),error_code=NULL WHERE source_system=? AND event_id=?", + resourceId, "FIRSTLEG", event.eventId()); + return resourceId; + } + + private long applyEvent(FinanceFeeSourceEvent event, FirstlegFeeSourceClient.Snapshot snapshot) { + List> sameVersion = jdbc.queryForList( + "SELECT id,source_hash,status FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND source_fee_version=? FOR UPDATE", + snapshot.sourceType(), snapshot.feeId(), snapshot.feeVersion()); + if (!sameVersion.isEmpty()) { + Map row = sameVersion.get(0); + if (!snapshot.sourceHash().equals(row.get("source_hash"))) { + throw error("FIN-EVT-004", "同一来源费用版本快照不一致"); + } + long id = ((Number) row.get("id")).longValue(); + if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) { + requireNotFrozen(id); + jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", id); + voidDirectPaidCost(snapshot); + } + return id; + } + if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) { + throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在"); + } + List> versions = jdbc.queryForList( + "SELECT source_fee_version FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? FOR UPDATE", + snapshot.sourceType(), snapshot.feeId()); + if (versions.stream().anyMatch(row -> ((Number) row.get("source_fee_version")).intValue() > snapshot.feeVersion())) { + throw error("FIN-EVT-008", "费用来源版本已过期"); + } + requireSourceNotFrozen(snapshot.sourceType(), snapshot.feeId()); + jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND status='AVAILABLE'", + snapshot.sourceType(), snapshot.feeId()); + jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源版本已替代',version=version+1,updated_by=0 WHERE cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND status='CONFIRMED'", + snapshot.sourceType(), snapshot.feeId()); + jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,container_booking_id,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,review_status,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'APPROVED','AVAILABLE')", + snapshot.feeId(), snapshot.feeVersion(), snapshot.customerId(), snapshot.sourceType(), + snapshot.inboundCode() == null || snapshot.inboundCode().isBlank() ? snapshot.containerNo() : snapshot.inboundCode(), + snapshot.direction(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(), + financeContainerBookingId(snapshot), + snapshot.feeItemCode(), snapshot.feeItemName(), snapshot.totalAmount(), snapshot.currency(), + snapshot.settlementMode(), snapshot.paidAt(), snapshot.paymentMethod(), + snapshot.paymentReferenceNo(), voucherJson(snapshot.paymentVoucherFileIds()), + snapshot.sourceHash(), json(snapshot)); + long sourceId = jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND source_fee_version=?", + Long.class, snapshot.sourceType(), snapshot.feeId(), snapshot.feeVersion()); + if ("PAYABLE".equals(snapshot.direction()) && "DIRECT_PAID".equals(snapshot.settlementMode())) { + materializeDirectPaidCost(snapshot); + } + return sourceId; + } + + private void requireSourceNotFrozen(String sourceType, long sourceFeeId) { + Integer frozen = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system='FIRSTLEG' AND f.source_type=? AND f.source_fee_id=? AND (EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, sourceType, sourceFeeId); + if (frozen != null && frozen > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止调整或替代"); + } + + private void requireNotFrozen(long feeSourceId) { + Integer frozen = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=? AND (EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, feeSourceId); + if (frozen != null && frozen > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止作废"); + } + + private void materializeDirectPaidCost(FirstlegFeeSourceClient.Snapshot snapshot) { + String costNo = "DIRECT-" + snapshot.sourceType() + "-" + snapshot.feeId() + "-V" + snapshot.feeVersion(); + jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','FIRSTLEG_DIRECT_PAID','FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)", + snapshot.customerId(), costNo, snapshot.sourceType(), snapshot.feeId(), snapshot.feeVersion(), + snapshot.sourceHash(), snapshot.orderId(), snapshot.inboundId(), financeContainerBookingId(snapshot), + snapshot.supplierId(), snapshot.supplierName(), snapshot.feeItemCode(), snapshot.feeItemName(), + snapshot.currency(), snapshot.totalAmount(), voucherJsonOrEmpty(snapshot.paymentVoucherFileIds()), + snapshot.paidAt(), snapshot.paymentMethod(), snapshot.paymentReferenceNo()); + } + + private void voidDirectPaidCost(FirstlegFeeSourceClient.Snapshot snapshot) { + jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源费用已作废',version=version+1,updated_by=0 WHERE cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND source_fee_version=? AND status='CONFIRMED'", + snapshot.sourceType(), snapshot.feeId(), snapshot.feeVersion()); + } + + private FirstlegFeeSourceClient.Snapshot loadSnapshot(FinanceFeeSourceEvent event) { + FinanceFeeSourceEvent.Payload payload = event.payload(); + var response = payload.inboundId() != null + ? firstleg.inbound(internalToken, positive(payload.inboundId(), "inboundId"), + positive(payload.feeId(), "feeId"), payload.feeVersion()) + : firstleg.container(internalToken, positive(payload.containerId(), "containerId"), + positive(payload.feeId(), "feeId"), payload.feeVersion()); + if (response == null || response.data() == null || !"200".equals(response.code())) { + throw error("FIN-EVT-006", "Firstleg费用来源快照不可用"); + } + return response.data(); + } + + private void validateEnvelope(FinanceFeeSourceEvent event) { + if (event == null || blank(event.eventId()) || !EVENT_TYPES.contains(event.eventType()) + || !"firstleg-service".equals(event.producer()) || event.payload() == null + || blank(event.tenantId()) || blank(event.payload().feeId())) { + throw error("FIN-EVT-001", "费用来源事件信封无效"); + } + boolean inbound = event.payload().inboundId() != null; + boolean container = event.payload().containerId() != null; + if (inbound == container || !sha256(json(event.payload())).equals(event.payloadHash())) { + throw error("FIN-EVT-001", "费用来源事件载荷无效"); + } + } + + private void validateSnapshot(FinanceFeeSourceEvent event, FirstlegFeeSourceClient.Snapshot value) { + long tenantId = positive(event.tenantId(), "tenantId"); + long feeId = positive(event.payload().feeId(), "feeId"); + if (value.tenantId() != tenantId || value.customerId() != tenantId) throw error("FIN-EVT-007", "费用来源客户归属不一致"); + if (value.feeId() != feeId || value.feeVersion() != event.payload().feeVersion()) throw error("FIN-EVT-008", "费用来源版本不一致"); + if (!"APPROVED".equals(value.reviewStatus())) throw error("FIN-EVT-009", "费用未审核通过"); + if (!"FIRSTLEG_FEE_VOIDED".equals(event.eventType()) && !"PUBLISHED".equals(value.publicationStatus())) { + throw error("FIN-EVT-015", "费用来源尚未发布"); + } + if (!"CNY".equals(value.currency()) || value.totalAmount() == null || value.totalAmount().signum() <= 0) throw error("FIN-EVT-010", "费用金额或币种无效"); + if (!Set.of("RECEIVABLE", "PAYABLE").contains(value.direction())) throw error("FIN-EVT-011", "费用方向无效"); + if (!Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(value.sourceType()) || blank(value.feeItemCode()) || blank(value.feeItemName()) || blank(value.sourceHash())) throw error("FIN-EVT-012", "费用来源快照字段不完整"); + if ("PAYABLE".equals(value.direction())) { + if (value.supplierId() == null || value.supplierId() <= 0 + || blank(value.supplierName())) { + throw error("FIN-EVT-012", "应付费用来源供应商快照不完整"); + } + var supplier = supplierValidator.requireActive(value.supplierId(), value.supplierCategory()); + if (!value.supplierName().equals(supplier.name())) { + throw error("FIN-EVT-012", "应付费用来源供应商名称快照不一致"); + } + validatePayableSettlement(value); + } else if (value.supplierId() != null || !blank(value.supplierName()) + || !blank(value.supplierCategory())) { + throw error("FIN-EVT-012", "应收费用不得携带供应商快照"); + } else if (!blank(value.settlementMode()) || value.paidAt() != null + || !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo()) + || (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) { + throw error("FIN-EVT-012", "应收费用不得携带结算方式或已付快照"); + } + if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致"); + if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null + || !String.valueOf(value.containerId()).equals(event.payload().containerId()) + || value.containerBookingId() == null || value.containerBookingId() <= 0)) { + throw error("FIN-EVT-014", "柜子归属不一致"); + } + } + + private void validatePayableSettlement(FirstlegFeeSourceClient.Snapshot value) { + boolean hasVouchers = value.paymentVoucherFileIds() != null + && !value.paymentVoucherFileIds().isEmpty(); + if (hasVouchers && (value.paymentVoucherFileIds().size() > 20 + || value.paymentVoucherFileIds().stream().anyMatch(id -> id == null || id <= 0))) { + throw error("FIN-EVT-012", "付款凭证文件快照无效"); + } + if ("ON_ACCOUNT".equals(value.settlementMode())) { + if (value.paidAt() != null || !blank(value.paymentMethod()) + || !blank(value.paymentReferenceNo()) || hasVouchers) { + throw error("FIN-EVT-012", "账期应付不得携带已付快照"); + } + return; + } + if (!"DIRECT_PAID".equals(value.settlementMode()) || value.paidAt() == null + || !Set.of("ONLINE", "BANK_TRANSFER", "CASH", "OTHER").contains(value.paymentMethod()) + || (blank(value.paymentReferenceNo()) && !hasVouchers)) { + throw error("FIN-EVT-012", "直接已付费用来源快照不完整"); + } + } + + private String voucherJson(java.util.List values) { + return values == null || values.isEmpty() ? null : json(values); + } + + private String voucherJsonOrEmpty(java.util.List values) { + return values == null || values.isEmpty() ? "[]" : json(values); + } + + private static Long financeContainerBookingId(FirstlegFeeSourceClient.Snapshot snapshot) { + return "CONTAINER_FEE".equals(snapshot.sourceType()) ? snapshot.containerBookingId() : null; + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException e) { throw error("FIN-EVT-001", "事件序列化失败"); } + } + + private static long positive(String value, String field) { + try { + long parsed = Long.parseLong(value); + if (parsed > 0) return parsed; + } catch (RuntimeException ignored) { } + throw error("FIN-EVT-001", field + "无效"); + } + + private static String sha256(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + + private static boolean blank(String value) { return value == null || value.isBlank(); } + private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceOperatorSnapshot.java b/finance/src/main/java/com/baiyue/finance/service/FinanceOperatorSnapshot.java new file mode 100644 index 000000000..b531df958 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceOperatorSnapshot.java @@ -0,0 +1,26 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.security.AuthenticatedUser; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; + +final class FinanceOperatorSnapshot { + private FinanceOperatorSnapshot() { } + + static String currentName(long operatorId) { + if (operatorId == 0) return "系统"; + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof AuthenticatedUser user + && user.userId() != null && user.userId() == operatorId + && (StringUtils.hasText(user.displayName()) || StringUtils.hasText(user.username()))) { + return StringUtils.hasText(user.displayName()) ? user.displayName().trim() : user.username().trim(); + } + return null; + } + + static String requiredName(long operatorId) { + String name = currentName(operatorId); + return name == null ? "操作人 " + operatorId : name; + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java new file mode 100644 index 000000000..1a64ca3a3 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -0,0 +1,465 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary; +import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry; +import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog; +import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; +import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest; +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest; +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest; +import com.baiyue.finance.client.FirstlegFeeSourceClient; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.FeignException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.slf4j.MDC; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class FinancePhase1OperationService { + private static final String CUSTOMER_IDS = "SELECT tenant_id customer_id FROM fin_customer_settlement_profile UNION SELECT tenant_id FROM fin_bill UNION SELECT customer_id FROM fin_fee_source UNION SELECT tenant_id FROM fin_cost"; + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final FirstlegFeeSourceClient firstleg; + private final String internalToken; + + @Autowired + public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper, + FirstlegFeeSourceClient firstleg, + @Value("${baiyue.security.internal-service-token}") String internalToken) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.firstleg = firstleg; + this.internalToken = internalToken; + } + + public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) { + this(jdbc, objectMapper, null, ""); + } + + public PageEnvelope listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) { + page = page(page); pageSize = pageSize(pageSize); + String normalizedStatus = status == null || status.isBlank() ? null : status.trim().toUpperCase(java.util.Locale.ROOT); + if (normalizedStatus != null && !List.of("ACTIVE", "DISABLED").contains(normalizedStatus)) throw error("FIN-REQ-400", "账户状态无效"); + String filter = " WHERE (? IS NULL OR COALESCE(p.customer_name_snapshot,'') LIKE CONCAT('%',?,'%')) AND (? IS NULL OR COALESCE(p.status,'ACTIVE')=?)"; + Object[] filterArgs = {blankToNull(customerName), blankToNull(customerName), normalizedStatus, normalizedStatus}; + String base = " FROM (" + CUSTOMER_IDS + ") c LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=c.customer_id" + filter; + Long total = jdbc.queryForObject("SELECT COUNT(*)" + base, Long.class, filterArgs); + String select = "SELECT c.customer_id,COALESCE(p.customer_name_snapshot,CONCAT('客户',c.customer_id)) customer_name,COALESCE(p.status,'ACTIVE') account_status," + + "(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement ar WHERE ar.tenant_id=c.customer_id AND ar.direction='RECEIVABLE' AND ar.status<>'VOIDED') outstanding_receivable," + + "(SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='RECEIVABLE' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status='AVAILABLE' AND NOT EXISTS(SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED')) pending_billing," + + "(SELECT COALESCE(SUM(received_amount),0) FROM fin_receipt r WHERE r.tenant_id=c.customer_id AND r.status='CONFIRMED') received_period," + + "((SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND x.status='CONFIRMED')) total_cost," + + "((SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND x.status='CONFIRMED')+(SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')) paid_cost," + + "((SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')+(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS(SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED'))) outstanding_payable," + + "COALESCE((SELECT actual_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) actual_balance," + + "COALESCE((SELECT frozen_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) frozen_balance," + + "COALESCE((SELECT available_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_balance," + + "COALESCE((SELECT credit_limit FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_limit," + + "COALESCE((SELECT credit_used FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_used," + + "COALESCE((SELECT credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_frozen," + + "COALESCE((SELECT credit_limit-credit_used-credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_credit," + + "(SELECT credit_expires_on FROM fin_wallet_account w WHERE w.customer_id=c.customer_id) credit_expires_on," + + "COALESCE((SELECT version FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) account_version," + + "(SELECT COUNT(*) FROM fin_recharge_request rr WHERE rr.customer_id=c.customer_id AND rr.status='PENDING') pending_recharge_count," + + "GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_fee_source f WHERE f.customer_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_settlement x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?"; + Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2); + args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize; + List rows = jdbc.query(select, (rs, row) -> accountSummaryRow(rs), args); + return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total); + } + + private static AccountSummary accountSummaryRow(ResultSet rs) throws SQLException { + BigDecimal actualBalance = money(rs, "actual_balance"); + BigDecimal frozenBalance = money(rs, "frozen_balance"); + BigDecimal creditLimit = money(rs, "credit_limit"); + BigDecimal creditFrozen = money(rs, "credit_frozen"); + LocalDate creditExpiresOn = rs.getDate("credit_expires_on") == null + ? null : rs.getDate("credit_expires_on").toLocalDate(); + boolean creditActive = creditLimit.signum() > 0 && creditExpiresOn != null + && !creditExpiresOn.isBefore(LocalDate.now()); + BigDecimal creditUsed = money(rs, "credit_used"); + if (!creditActive && creditUsed.signum() > 0) { + actualBalance = actualBalance.subtract(creditUsed); + creditUsed = BigDecimal.ZERO; + } + BigDecimal availableCredit = creditActive + ? creditLimit.subtract(creditUsed).subtract(creditFrozen) : BigDecimal.ZERO; + BigDecimal availableBalance = creditActive + ? actualBalance.subtract(frozenBalance.subtract(creditFrozen)).add(availableCredit) + : actualBalance.subtract(frozenBalance); + return new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")), + rs.getString("customer_name"), null, null, money(rs, "outstanding_receivable"), + money(rs, "pending_billing"), money(rs, "received_period"), money(rs, "total_cost"), + money(rs, "paid_cost"), money(rs, "outstanding_payable"), actualBalance, frozenBalance, + availableBalance, creditLimit, creditUsed, availableCredit, creditExpiresOn, creditActive, + rs.getInt("account_version"), rs.getLong("pending_recharge_count"), rs.getString("account_status"), + time(rs, "last_changed_at")); + } + + public PageEnvelope listDerivedCustomerAccountLedger(long customerId, int page, int pageSize, + String businessType, LocalDate startDate, LocalDate endDate) { + customer(customerId); page = page(page); pageSize = pageSize(pageSize); + if (startDate != null && endDate != null && startDate.isAfter(endDate)) throw error("FIN-REQ-400", "开始日期不能晚于结束日期"); + List rows = new ArrayList<>(); + rows.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,created_by,created_by_name,remark FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "SETTLEMENT_ISSUED", "BILL", "bill_no", "total_amount", null), customerId)); + rows.addAll(jdbc.query("SELECT id,receipt_no,received_at received_time,received_amount,created_by,created_by_name,remark FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED'", (rs, n) -> raw(rs, "RECEIPT", "RECEIPT", "receipt_no", null, "received_amount"), customerId)); + rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND status='CONFIRMED'", (rs, n) -> raw(rs, "SUPPLIER_DIRECT_PAID", "DIRECT_PAID_COST", "cost_no", null, null), customerId)); + rows.addAll(jdbc.query("SELECT p.id,CONCAT(s.settlement_no,'-',p.id) payment_no,p.created_at,p.amount cost_amount,p.created_by,p.created_by_name,p.remark FROM fin_settlement_payment p JOIN fin_settlement s ON s.id=p.settlement_id AND s.tenant_id=p.tenant_id WHERE p.tenant_id=? AND p.transaction_type='PAYMENT' AND s.direction='PAYABLE' AND s.status<>'VOIDED'", (rs, n) -> raw(rs, "SUPPLIER_ON_ACCOUNT_PAYMENT", "AP_PAYMENT", "payment_no", null, null), customerId)); + rows.sort(Comparator.comparing(RawLedger::occurredAt).thenComparing(RawLedger::id)); + BigDecimal outstanding = BigDecimal.ZERO; + List projected = new ArrayList<>(); + for (RawLedger row : rows) { + BigDecimal receivableChange = row.receivable(); + outstanding = outstanding.add(receivableChange); + BigDecimal payable = List.of("DIRECT_PAID_COST", "AP_PAYMENT").contains(row.documentType()) ? row.amount() : BigDecimal.ZERO; + LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, row.operatorName(), row.remark(), + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + if ((businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) && inRange(entry.occurredAt(), startDate, endDate)) projected.add(entry); + } + BigDecimal derivedOutstanding = outstanding; + projected.addAll(jdbc.query("SELECT * FROM fin_wallet_ledger WHERE customer_id=?", (rs, n) -> { + LocalDate occurredDate = rs.getDate("occurred_date").toLocalDate(); + LocalDateTime occurredAt = occurredDate.atStartOfDay(); + String type = rs.getString("change_type"); + return new LedgerEntry(rs.getLong("id"), occurredAt, type, "WALLET_LEDGER", rs.getLong("id"), + rs.getString("business_no"), BigDecimal.ZERO, BigDecimal.ZERO, derivedOutstanding, + rs.getString("operator_name"), rs.getString("remark"), rs.getString("business_no"), type, + rs.getBigDecimal("amount"), rs.getBigDecimal("actual_balance_after"), + rs.getBigDecimal("frozen_balance_after"), rs.getBigDecimal("available_balance_after"), + rs.getBigDecimal("credit_limit_after"), rs.getBigDecimal("credit_used_after"), + rs.getBigDecimal("available_credit_after"), + rs.getString("source_type"), rs.getString("source_no"), occurredDate, + rs.getString("payment_method"), rs.getString("reference_no")); + }, customerId).stream().filter(entry -> + (businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) + && inRange(entry.occurredAt(), startDate, endDate)).toList()); + projected.sort(Comparator.comparing(LedgerEntry::occurredAt).reversed() + .thenComparing(Comparator.comparingLong(LedgerEntry::ledgerId).reversed())); + int from = Math.min((page - 1) * pageSize, projected.size()); + int to = Math.min(from + pageSize, projected.size()); + return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size()); + } + + public PageEnvelope listFinanceFeeSources(int page, int pageSize, Long customerId, + String direction, String sourceStatus, + String reviewStatus, + Boolean statementGenerated, LocalDate businessDateFrom, + LocalDate businessDateTo, String keyword) { + page = page(page); pageSize = pageSize(pageSize); + if (customerId != null) customer(customerId); + if (businessDateFrom != null && businessDateTo != null && businessDateFrom.isAfter(businessDateTo)) { + throw error("FIN-REQ-400", "开始日期不能晚于结束日期"); + } + String normalizedDirection = normalized(direction, List.of("RECEIVABLE", "PAYABLE"), "费用方向无效"); + String normalizedReview = normalized(reviewStatus, + List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效"); + String normalizedSourceStatus = normalized(sourceStatus, + List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效"); + String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)"; + String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END"; + String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')"; + StringBuilder where = new StringBuilder(" FROM fin_fee_source f WHERE 1=1"); + List args = new ArrayList<>(); + if (customerId != null) { where.append(" AND f.customer_id=?"); args.add(customerId); } + if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); } + if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); } + if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); } + if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); } + if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); } + if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); } + String normalizedKeyword = blankToNull(keyword); + if (normalizedKeyword != null) { + where.append(" AND (f.source_no LIKE CONCAT('%',?,'%') OR f.fee_item_name LIKE CONCAT('%',?,'%')") + .append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')") + .append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')") + .append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')") + .append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))"); + for (int i = 0; i < 6; i++) args.add(normalizedKeyword); + } + Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray()); + List queryArgs = new ArrayList<>(args); + queryArgs.add(pageSize); queryArgs.add((page - 1) * pageSize); + String select = "SELECT f.*," + generatedSql + " statement_generated," + effectiveStatusSql + " effective_status" + + where + " ORDER BY " + businessDateSql + " DESC,f.id DESC LIMIT ? OFFSET ?"; + List rows = jdbc.query(select, (rs, n) -> summary(rs), queryArgs.toArray()); + return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total); + } + + public FeeSourceDetail getFinanceFeeSource(long feeSourceId) { + if (feeSourceId <= 0) throw error("FIN-REQ-400", "费用来源ID无效"); + List rows = jdbc.query("SELECT * FROM fin_fee_source WHERE id=?", (rs, n) -> detail(rs), feeSourceId); + if (rows.isEmpty()) throw error("FIN-FEE-404", "费用来源不存在"); + return rows.get(0); + } + + @Transactional + public FeeSourceDetail reviewFinanceFeeSource(long feeSourceId, long operatorId, String key, ReviewRequest request) { + if (request == null || !List.of("APPROVE", "REJECT").contains(request.action()) || request.expectedVersion() < 0) throw error("FIN-REQ-400", "审核请求无效"); + if ("REJECT".equals(request.action()) && !StringUtils.hasText(request.reason())) throw error("FIN-REQ-400", "驳回原因不能为空"); + FeeSourceDetail before = getFinanceFeeSource(feeSourceId); + Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_REVIEW", key, "POST", "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/review", request); + if (replay != null) return getFinanceFeeSource(replay); + String review = "APPROVE".equals(request.action()) ? "APPROVED" : "REJECTED"; + int changed = jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status='PENDING'", + review, request.reason(), feeSourceId, request.expectedVersion()); + if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许审核"); + FeeSourceDetail after = getFinanceFeeSource(feeSourceId); + audit(after.customerId(), feeSourceId, "FEE_SOURCE_REVIEW_" + request.action(), request.reason(), before, after, operatorId); + complete(after.customerId(), operatorId, "FEE_SOURCE_REVIEW", key, feeSourceId); + return after; + } + + @Transactional + public FeeSourceDetail editFinanceFeeSource(long feeSourceId, long operatorId, String key, + FeeSourceEditRequest request, boolean adjustment) { + if (request == null || request.expectedVersion() < 0 || request.amount() == null + || request.amount().signum() <= 0 || !StringUtils.hasText(request.reason())) { + throw error("FIN-REQ-400", adjustment ? "费用调整请求无效" : "费用编辑请求无效"); + } + FeeSourceDetail before = getFinanceFeeSource(feeSourceId); + if (Set.of("FIRSTLEG", "LASTLEG").contains(before.sourceSystem())) { + throw error("FIN-FEE-409", "业务费用必须回到对应业务费用明细调整,Finance不可直接修改来源金额"); + } + String operation = adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT"; + String path = "/api/platform/finance/v1/fee-sources/" + feeSourceId + (adjustment ? "/adjust" : ""); + Long replay = reserve(before.customerId(), operatorId, operation, key, + adjustment ? "POST" : "PUT", path, request); + if (replay != null) return getFinanceFeeSource(replay); + if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可编辑或调整"); + String requiredStatus = adjustment ? "APPROVED" : before.reviewStatus(); + if (!adjustment && !List.of("DRAFT", "REJECTED").contains(requiredStatus)) { + throw error("FIN-FEE-409", "只有草稿或已驳回费用可编辑"); + } + int changed = jdbc.update("UPDATE fin_fee_source SET amount=?,review_status='DRAFT',review_reason=NULL,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?", + request.amount(), feeSourceId, request.expectedVersion(), requiredStatus); + if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许编辑或调整"); + FeeSourceDetail after = getFinanceFeeSource(feeSourceId); + audit(after.customerId(), feeSourceId, adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT", request.reason(), before, after, operatorId); + complete(after.customerId(), operatorId, operation, key, feeSourceId); + return after; + } + + @Transactional + public FeeSourceDetail submitFinanceFeeSource(long feeSourceId, long operatorId, String key, + FeeSourceActionRequest request) { + return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_SUBMIT", + "PENDING", List.of("DRAFT", "REJECTED"), false); + } + + @Transactional + public FeeSourceDetail voidFinanceFeeSource(long feeSourceId, long operatorId, + String operatorName, String key, + FeeSourceActionRequest request) { + if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) { + throw error("FIN-REQ-400", "费用操作请求无效"); + } + FeeSourceDetail before = getFinanceFeeSource(feeSourceId); + if ("LASTLEG".equals(before.sourceSystem())) { + throw error("FIN-FEE-409", "海外业务费用必须在海外费用来源中作废,Finance只消费作废事件"); + } + if (!"FIRSTLEG".equals(before.sourceSystem())) { + return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_VOID", + "VOIDED", List.of("DRAFT", "PENDING", "REJECTED", "APPROVED"), true); + } + Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_VOID", key, "POST", + "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/void", request); + if (replay != null) return getFinanceFeeSource(replay); + if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可作废"); + if (before.operationVersion() != request.expectedVersion()) { + throw error("FIN-FEE-409", "费用来源状态或版本已变化"); + } + if (!List.of("DRAFT", "PENDING", "REJECTED", "APPROVED").contains(before.reviewStatus()) + || !"AVAILABLE".equals(before.sourceStatus())) { + throw error("FIN-FEE-409", "费用来源当前状态不允许作废"); + } + FirstlegFeeSourceClient.VoidCommand command = new FirstlegFeeSourceClient.VoidCommand( + before.sourceType(), before.sourceFeeId(), before.sourceVersion(), before.customerId(), + before.inboundId(), before.containerId(), operatorId, + StringUtils.hasText(operatorName) ? operatorName.trim() : "SYSTEM", request.reason().trim()); + try { + var response = firstleg.voidSource(internalToken, key, hash(command), command); + if (response == null || !response.successful() || response.data() == null + || !"VOIDED".equals(response.data().status())) { + throw error("FIN-UPSTREAM-502", "Firstleg未确认费用来源作废"); + } + } catch (FeignException exception) { + throw error("FIN-UPSTREAM-503", "Firstleg费用作废服务暂不可用"); + } + audit(before.customerId(), feeSourceId, "FEE_SOURCE_VOID_REQUESTED", + request.reason(), before, before, operatorId); + complete(before.customerId(), operatorId, "FEE_SOURCE_VOID", key, feeSourceId); + return getFinanceFeeSource(feeSourceId); + } + + private FeeSourceDetail transitionFeeSource(long feeSourceId, long operatorId, String key, + FeeSourceActionRequest request, String operation, + String target, List allowedReviews, + boolean voidSource) { + if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) { + throw error("FIN-REQ-400", "费用操作请求无效"); + } + FeeSourceDetail before = getFinanceFeeSource(feeSourceId); + Long replay = reserve(before.customerId(), operatorId, operation, key, "POST", + "/api/platform/finance/v1/fee-sources/" + feeSourceId + (voidSource ? "/void" : "/submit"), request); + if (replay != null) return getFinanceFeeSource(replay); + if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可操作"); + if (!allowedReviews.contains(before.reviewStatus())) throw error("FIN-FEE-409", "费用来源当前状态不允许该操作"); + int changed = voidSource + ? jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?", + feeSourceId, request.expectedVersion(), before.reviewStatus()) + : jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=NULL,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?", + target, feeSourceId, request.expectedVersion(), before.reviewStatus()); + if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本已变化"); + FeeSourceDetail after = getFinanceFeeSource(feeSourceId); + audit(after.customerId(), feeSourceId, operation, request.reason(), before, after, operatorId); + complete(after.customerId(), operatorId, operation, key, feeSourceId); + return after; + } + + private FeeSourceDetail detail(ResultSet rs) throws SQLException { + JsonNode snapshot = parse(rs.getString("source_snapshot")); + long id = rs.getLong("id"); + boolean generated = Boolean.TRUE.equals(jdbc.queryForObject("SELECT COUNT(*)>0 FROM fin_bill_line WHERE source_id=? AND source_version=?", Boolean.class, id, rs.getInt("source_fee_version"))); + String status = rs.getString("status"); + if (generated && "AVAILABLE".equals(status)) status = "BILLED"; + BigDecimal quantity = decimal(snapshot, "chargeableQuantity"); + BigDecimal amount = rs.getBigDecimal("amount"); + BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount); + BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"), + quantity != null && quantity.signum() > 0 ? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null); + return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), + nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), + quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"), + amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount, + firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"), + defaultString(rs.getString("review_status"), "DRAFT"), status, generated, + rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id)); + } + + private FeeSourceSummary summary(ResultSet rs) throws SQLException { + JsonNode snapshot = parse(rs.getString("source_snapshot")); + boolean generated = rs.getBoolean("statement_generated"); + BigDecimal quantity = decimal(snapshot, "chargeableQuantity"); + BigDecimal amount = money(rs, "amount"); + BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount); + BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"), quantity != null && quantity.signum() > 0 + ? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null); + return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"), + firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), + rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), + jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), nullableLong(rs, "order_id"), + nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), + text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), + quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"), + amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount, + firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"), + defaultString(rs.getString("review_status"), "DRAFT"), + rs.getString("effective_status"), generated); + } + + private List logs(long customerId, long id) { + return jdbc.query("SELECT created_at,action,operator_name_snapshot,reason FROM fin_audit_log WHERE tenant_id=? AND resource_type='FIN_FEE_SOURCE' AND resource_id=? ORDER BY created_at DESC,id DESC", (rs, n) -> + new OperationLog(time(rs, "created_at"), rs.getString("action"), rs.getString("operator_name_snapshot"), "SUCCESS", rs.getString("reason")), customerId, id); + } + + private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) { + if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN-IDEM-400", "必须提供有效的Idempotency-Key"); + String hash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body)); + List> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", customerId, operatorId, operation, key); + if (!rows.isEmpty()) { + Map row = rows.get(0); + if (!hash.equals(row.get("request_hash"))) throw error("FIN-IDEM-409", "Idempotency-Key已用于其他请求"); + if ("SUCCEEDED".equals(row.get("status")) && row.get("result_resource_id") != null) return ((Number) row.get("result_resource_id")).longValue(); + throw error("FIN-IDEM-409", "相同请求正在处理中"); + } + jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')", customerId, operatorId, operation, key, hash); + return null; + } + + private void complete(long customerId, long operatorId, String operation, String key, long id) { + jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=? WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'", id, customerId, operatorId, operation, key); + } + + private void audit(long customerId, long id, String action, String reason, Object before, Object after, long operatorId) { + jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + customerId, "FIN_FEE_SOURCE", id, action, reason, json(before), json(after), operatorId, + FinanceOperatorSnapshot.requiredName(operatorId), MDC.get("requestId"), MDC.get("traceId")); + } + + private static RawLedger raw(ResultSet rs, String businessType, String documentType, String noColumn, String debitColumn, String creditColumn) throws SQLException { + LocalDateTime occurred = time(rs, documentType.equals("RECEIPT") ? "received_time" : "created_at"); + BigDecimal amount = List.of("DIRECT_PAID_COST", "AP_PAYMENT").contains(documentType) + ? money(rs, "cost_amount") : debitColumn != null ? money(rs, debitColumn) : money(rs, creditColumn); + BigDecimal receivable = debitColumn != null ? amount : creditColumn != null ? amount.negate() : BigDecimal.ZERO; + return new RawLedger(rs.getLong("id"), occurred, businessType, documentType, rs.getString(noColumn), amount, receivable, + rs.getLong("created_by"), rs.getString("created_by_name"), rs.getString("remark")); + } + private record RawLedger(long id, LocalDateTime occurredAt, String businessType, String documentType, + String documentNo, BigDecimal amount, BigDecimal receivable, long operatorId, + String operatorName, String remark) {} + + private static int page(int value) { if (value < 1) throw error("FIN-REQ-400", "页码必须大于0"); return value; } + private static int pageSize(int value) { if (value < 1 || value > 200) throw error("FIN-REQ-400", "每页数量必须在1到200之间"); return value; } + private static void customer(long value) { if (value <= 0) throw error("FIN-REQ-400", "客户ID无效"); } + private static boolean inRange(LocalDateTime value, LocalDate start, LocalDate end) { return value != null && (start == null || !value.toLocalDate().isBefore(start)) && (end == null || !value.toLocalDate().isAfter(end)); } + private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String normalized(String value, List allowed, String message) { + String normalized = blankToNull(value); + if (normalized == null) return null; + normalized = normalized.toUpperCase(java.util.Locale.ROOT); + if (!allowed.contains(normalized)) throw error("FIN-REQ-400", message); + return normalized; + } + private static BigDecimal money(ResultSet rs, String column) throws SQLException { BigDecimal value = rs.getBigDecimal(column); return value == null ? BigDecimal.ZERO : value; } + private static LocalDateTime time(ResultSet rs, String column) throws SQLException { Timestamp value = rs.getTimestamp(column); return value == null ? null : value.toLocalDateTime(); } + private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; } + private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } } + private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } } + 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 error("FIN-DATA-500", "幂等请求哈希失败"); } } + private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); } + private static Long jsonLong(JsonNode node, String field) { + JsonNode value = node.path(field); + if (value.isIntegralNumber() && value.canConvertToLong()) { + long parsed = value.longValue(); + return parsed > 0 ? parsed : null; + } + if (!value.isTextual()) return null; + try { + long parsed = Long.parseLong(value.textValue()); + return parsed > 0 ? parsed : null; + } catch (NumberFormatException ignored) { + return null; + } + } + private static Long firstLong(Long first, Long second) { return first != null ? first : second; } + private static BigDecimal firstDecimal(BigDecimal first, BigDecimal second) { return first != null ? first : second; } + private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first : second; } + private static String defaultText(JsonNode node, String field, String fallback) { String value = text(node, field); return value == null || value.isBlank() ? fallback : value; } + private static String defaultString(String value, String fallback) { return value == null || value.isBlank() ? fallback : value; } + private static BigDecimal decimal(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? null : new BigDecimal(value); } + private static LocalDate date(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? LocalDate.of(1970, 1, 1) : LocalDate.parse(value); } + private static LocalDate nullableDate(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? null : LocalDate.parse(value); } + private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java new file mode 100644 index 000000000..691d46130 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -0,0 +1,315 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.finance.dto.FinanceReadModels.AccountEntry; +import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt; +import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation; +import com.baiyue.finance.dto.FinanceReadModels.BillRevision; +import com.baiyue.finance.dto.FinanceReadModels.CustomerAccount; +import com.baiyue.finance.dto.FinanceReadModels.CustomerAccountSummary; +import com.baiyue.finance.dto.FinanceReadModels.CostAllocation; +import com.baiyue.finance.dto.FinanceReadModels.CostDetail; +import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem; +import com.baiyue.finance.dto.FinanceReadModels.PayableSettlement; +import com.baiyue.finance.dto.FinanceReadModels.PlatformReceipt; +import com.baiyue.finance.dto.FinanceReadModels.ReceivableSettlement; +import com.baiyue.finance.dto.FinanceReadModels.Statement; +import com.baiyue.finance.dto.FinanceReadModels.ReconciliationSummary; +import com.baiyue.finance.dto.FinanceReadModels.StatementDetail; +import com.baiyue.finance.dto.FinanceReadModels.StatementLine; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** Typed query facade for the five Finance pages and client-safe read APIs. */ +@Service +public class FinanceQueryService { + private static final String CNY = "CNY"; + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + + public FinanceQueryService(JdbcTemplate jdbc, ObjectMapper objectMapper) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + } + + public CustomerAccount customerAccount(long customerId) { + requireCustomer(customerId); + BigDecimal total = amount("SELECT COALESCE(SUM(total_amount),0) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", customerId); + BigDecimal received = amount("SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", customerId); + BigDecimal outstanding = amount("SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", customerId); + List entries = new ArrayList<>(); + entries.addAll(jdbc.query("SELECT id,settlement_no,created_at,total_amount,currency_code,status FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", (rs, rowNum) -> + new AccountEntry("RECEIVABLE", rs.getLong("id"), rs.getString("settlement_no"), time(rs, "created_at"), + rs.getBigDecimal("total_amount"), BigDecimal.ZERO, rs.getString("currency_code"), rs.getString("status")), customerId)); + entries.addAll(jdbc.query("SELECT id,receipt_no,received_at,received_amount,currency_code,status FROM fin_receipt WHERE tenant_id=? AND status<>'VOIDED'", (rs, rowNum) -> + new AccountEntry("RECEIPT", rs.getLong("id"), rs.getString("receipt_no"), time(rs, "received_at"), + BigDecimal.ZERO, rs.getBigDecimal("received_amount"), rs.getString("currency_code"), rs.getString("status")), customerId)); + entries.sort(Comparator.comparing(AccountEntry::occurredAt, Comparator.nullsLast(Comparator.reverseOrder()))); + return new CustomerAccount(customerId, CNY, "MANUAL_RECEIVABLE_LEDGER", total, received, outstanding, List.copyOf(entries)); + } + + public CustomerAccountSummary customerAccountSummary(long customerId) { + requireCustomer(customerId); + String sql = "SELECT " + + "COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.total_amount ELSE 0 END),0) total_receivable," + + "COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.paid_amount ELSE 0 END),0) received_amount," + + "COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_receivable," + + "(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='RECEIVABLE' " + + "AND f.currency_code='CNY' AND f.status='AVAILABLE' " + + "AND f.review_status='APPROVED' AND f.amount>0 " + + "AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " + + "WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," + + "((SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+" + + "(SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND c.status='CONFIRMED')) total_cost," + + "((SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND c.status='CONFIRMED')+" + + "(SELECT COALESCE(SUM(ps.paid_amount),0) FROM fin_settlement ps WHERE ps.tenant_id=? AND ps.direction='PAYABLE' AND ps.status<>'VOIDED')) paid_cost," + + "((SELECT COALESCE(SUM(os.outstanding_amount),0) FROM fin_settlement os WHERE os.tenant_id=? AND os.direction='PAYABLE' AND os.status<>'VOIDED')+" + + "(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') " + + "AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement aps ON aps.id=sl.settlement_id AND aps.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND aps.direction='PAYABLE' AND aps.status<>'VOIDED'))) outstanding_payable," + + "GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," + + "COALESCE((SELECT MAX(f.updated_at) FROM fin_fee_source f WHERE f.customer_id=?),'1970-01-01')," + + "COALESCE((SELECT MAX(c.updated_at) FROM fin_cost c WHERE c.tenant_id=?),'1970-01-01')) last_changed_at " + + "FROM fin_settlement s WHERE s.tenant_id=?"; + return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY, + money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"), + money(rs, "pending_billing"), money(rs, "total_cost"), money(rs, "paid_cost"), + money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")), + customerId, customerId, customerId, customerId, customerId, customerId, customerId, + customerId, customerId, customerId); + } + + public List feePool(Long customerId, String direction) { + return feePool(customerId, direction, null); + } + + public List feePool(Long customerId, String direction, Long supplierId) { + validateOptionalCustomer(customerId); + if (supplierId != null && supplierId <= 0) throw bad("供应商范围无效"); + String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT); + if (!List.of("ALL", "RECEIVABLE", "PAYABLE").contains(normalized)) throw bad("费用方向只能是RECEIVABLE或PAYABLE"); + List result = new ArrayList<>(); + String sourceSql = "SELECT id,customer_id,direction,source_type,source_fee_id,source_fee_version,order_id,inbound_id,container_id,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,status,created_at FROM fin_fee_source WHERE status='AVAILABLE'"; + List sourceArgs = new ArrayList<>(); + if (customerId != null) { sourceSql += " AND customer_id=?"; sourceArgs.add(customerId); } + if (!"ALL".equals(normalized)) { sourceSql += " AND direction=?"; sourceArgs.add(normalized); } + if ("PAYABLE".equals(normalized)) { + sourceSql += " AND review_status='APPROVED' AND settlement_mode='ON_ACCOUNT' AND currency_code='CNY' AND amount>0"; + sourceSql += " AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id WHERE sl.fee_source_id=fin_fee_source.id AND s.status<>'VOIDED')"; + if (supplierId != null) { + sourceSql += " AND CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED)=?"; + sourceArgs.add(supplierId); + } + } + sourceSql += " ORDER BY created_at DESC,id DESC"; + result.addAll(jdbc.query(sourceSql, (rs, rowNum) -> { + String rowDirection = rs.getString("direction"); + return new FeePoolItem(rowDirection + ":" + rs.getLong("id"), rs.getLong("customer_id"), rowDirection, + rs.getString("source_type"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), + nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), + rs.getString("fee_item_name"), rs.getBigDecimal("amount"), rs.getString("currency_code"), + rs.getString("settlement_mode"), time(rs, "paid_at"), rs.getString("payment_method"), + rs.getString("payment_reference_no"), fileIds(rs.getString("payment_voucher_file_ids")), + rs.getString("status"), time(rs, "created_at")); + }, sourceArgs.toArray())); + result.sort(Comparator.comparing(FeePoolItem::createdAt, Comparator.nullsLast(Comparator.reverseOrder()))); + return List.copyOf(result); + } + + public List statements(Long customerId, boolean clientVisibleOnly) { + validateOptionalCustomer(customerId); + String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : ""; + String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE 1=1"; + Object[] args = new Object[0]; + if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; } + return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC", + (rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"), + rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), + rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"), + rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"), + rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"), + rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"), + rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args); + } + + public List reconciliationStatements(Long customerId) { + validateOptionalCustomer(customerId); + String sql = "SELECT b.id,b.tenant_id,b.bill_no,b.customer_name_snapshot,b.currency_code,b.status,b.version,b.remark," + + "b.total_amount,b.received_amount,b.outstanding_amount,b.issued_at,b.customer_confirmation_status,b.customer_confirmed_by,b.customer_confirmed_by_name,b.customer_confirmed_at,b.created_by,b.created_by_name,b.created_at,b.updated_by,b.updated_by_name,b.updated_at," + + "COALESCE(p.credit_days,30) credit_days," + + "CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodStart')) AS DATE) period_start," + + "CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodEnd')) AS DATE) period_end," + + "COALESCE(l.line_count,0) line_count,COALESCE(l.source_count,0) source_count,COALESCE(l.source_amount,0) source_amount," + + "b.total_amount-COALESCE(l.source_amount,0) adjustment_amount,r.last_receipt_at," + + "a.submitted_at,a.reviewed_at " + + "FROM fin_bill b LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=b.tenant_id " + + "LEFT JOIN fin_audit_log c ON c.tenant_id=b.tenant_id AND c.resource_type='RECONCILIATION_STATEMENT' AND c.resource_id=b.id AND c.action='RECONCILIATION_CREATED' AND c.id=(SELECT MIN(c2.id) FROM fin_audit_log c2 WHERE c2.tenant_id=b.tenant_id AND c2.resource_type='RECONCILIATION_STATEMENT' AND c2.resource_id=b.id AND c2.action='RECONCILIATION_CREATED') " + + "LEFT JOIN (SELECT bl.tenant_id,bl.bill_id,COUNT(*) line_count,COUNT(DISTINCT bl.source_id) source_count,SUM(f.amount) source_amount FROM fin_bill_line bl JOIN fin_fee_source f ON f.id=bl.source_id AND f.customer_id=bl.tenant_id GROUP BY bl.tenant_id,bl.bill_id) l ON l.tenant_id=b.tenant_id AND l.bill_id=b.id " + + "LEFT JOIN (SELECT rb.tenant_id,rb.bill_id,MAX(r.received_at) last_receipt_at FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id AND r.status='CONFIRMED' GROUP BY rb.tenant_id,rb.bill_id) r ON r.tenant_id=b.tenant_id AND r.bill_id=b.id " + + "LEFT JOIN (SELECT tenant_id,resource_id,MAX(CASE WHEN action='RECONCILIATION_SUBMIT' THEN created_at END) submitted_at,MAX(CASE WHEN action IN ('RECONCILIATION_APPROVE','RECONCILIATION_REJECT') THEN created_at END) reviewed_at FROM fin_audit_log WHERE resource_type='RECONCILIATION_STATEMENT' GROUP BY tenant_id,resource_id) a ON a.tenant_id=b.tenant_id AND a.resource_id=b.id WHERE 1=1"; + Object[] args = new Object[0]; + if (customerId != null) { sql += " AND b.tenant_id=?"; args = new Object[]{customerId}; } + return jdbc.query(sql + " ORDER BY b.bill_date DESC,b.id DESC", (rs, rowNum) -> new ReconciliationSummary( + rs.getLong("id"), rs.getString("bill_no"), rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), + date(rs, "period_start"), date(rs, "period_end"), rs.getInt("credit_days"), rs.getString("currency_code"), + rs.getInt("line_count"), rs.getInt("source_count"), rs.getBigDecimal("source_amount"), rs.getBigDecimal("adjustment_amount"), + rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), + time(rs, "last_receipt_at"), rs.getBigDecimal("outstanding_amount").signum() == 0, rs.getString("status"), + time(rs, "submitted_at"), time(rs, "reviewed_at"), time(rs, "issued_at"), rs.getInt("version"), + rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"), + rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"), + rs.getString("remark"), + rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"), + rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args); + } + + public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) { + requireCustomer(customerId); + String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : ""; + List rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible, + (rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"), + rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), + rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"), + rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"), + rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"), + rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"), + rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), customerId, billId); + if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "账单不存在或不属于客户"); + List lines = jdbc.query("SELECT l.id,l.source_type,l.source_id,l.source_version,l.order_id,l.inbound_id," + + "NULLIF(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')),'null') inbound_code," + + "l.fee_item_code,l.fee_item_name,l.amount,l.currency_code FROM fin_bill_line l " + + "LEFT JOIN fin_fee_source f ON f.id=l.source_id AND f.customer_id=l.tenant_id " + + "WHERE l.tenant_id=? AND l.bill_id=? ORDER BY l.id", + (rs, rowNum) -> new StatementLine(rs.getLong("id"), rs.getString("source_type"), rs.getLong("source_id"), rs.getInt("source_version"), + nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), rs.getString("inbound_code"), rs.getString("fee_item_code"), rs.getString("fee_item_name"), + rs.getBigDecimal("amount"), rs.getString("currency_code")), customerId, billId); + return new StatementDetail(rows.get(0), lines); + } + + public StatementDetail platformStatement(long billId) { + Long customerId = jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?", Long.class, billId); + if (customerId == null) throw new BizException(ResultCode.NOT_FOUND, "账单不存在"); + return statement(customerId, billId, false); + } + + public List billRevisions(long customerId, long billId) { + statement(customerId, billId, false); + return jdbc.query("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC", + (rs, rowNum) -> new BillRevision(rs.getLong("id"), rs.getLong("bill_id"), rs.getInt("version"), rs.getString("reason"), + rs.getString("bill_snapshot"), rs.getString("line_snapshot"), rs.getLong("created_by"), + rs.getString("created_by_name"), time(rs, "created_at")), customerId, billId); + } + + public List receivableSettlements(Long customerId, boolean clientVisibleOnly) { + return statements(customerId, clientVisibleOnly).stream().map(row -> new ReceivableSettlement( + row.id(), row.customerId(), row.billNo(), row.billDate(), row.dueDate(), row.totalAmount(), row.receivedAmount(), + row.outstandingAmount(), row.currency(), row.status(), row.version())).toList(); + } + + public List payableSettlements(Long customerId) { + validateOptionalCustomer(customerId); + String sql = "SELECT id,tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version FROM fin_cost WHERE 1=1"; + Object[] args = new Object[0]; + if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; } + return jdbc.query(sql + " ORDER BY created_at DESC,id DESC", + (rs, rowNum) -> { + BigDecimal amount = rs.getBigDecimal("cost_amount"); + return new PayableSettlement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("cost_no"), rs.getString("cost_type"), + nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("supplier_name_snapshot"), + rs.getString("fee_item_name"), amount, BigDecimal.ZERO, amount, rs.getString("currency_code"), rs.getString("status"), + time(rs, "created_at"), rs.getInt("version")); + }, args); + } + + public CostDetail cost(long customerId, long costId) { + PayableSettlement cost = payableSettlements(customerId).stream() + .filter(item -> item.id() == costId).findFirst() + .orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "成本不存在或不属于客户")); + List allocations = jdbc.query( + "SELECT id,cost_id,order_id,inbound_id,volume_cbm,total_volume_cbm,allocation_rate,allocated_amount,rounding_adjustment FROM fin_cost_allocation WHERE tenant_id=? AND cost_id=? ORDER BY id", + (rs, rowNum) -> new CostAllocation(rs.getLong("id"), rs.getLong("cost_id"), rs.getLong("order_id"), + nullableLong(rs, "inbound_id"), rs.getBigDecimal("volume_cbm"), rs.getBigDecimal("total_volume_cbm"), + rs.getBigDecimal("allocation_rate"), rs.getBigDecimal("allocated_amount"), rs.getBoolean("rounding_adjustment")), + customerId, costId); + return new CostDetail(cost, allocations); + } + + public List clientReceipts(long customerId) { + requireCustomer(customerId); + return jdbc.query("SELECT id,receipt_no,received_amount,received_at,payment_method,currency_code,status FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED' ORDER BY received_at DESC,id DESC", + (rs, rowNum) -> new ClientReceipt(rs.getLong("id"), rs.getString("receipt_no"), rs.getBigDecimal("received_amount"), + time(rs, "received_at"), rs.getString("payment_method"), rs.getString("currency_code"), rs.getString("status"), allocations(customerId, rs.getLong("id"))), customerId); + } + + public List platformReceipts(long customerId) { + requireCustomer(customerId); + return jdbc.query("SELECT id,tenant_id,receipt_no,received_amount,received_at,payment_method,voucher_no,voucher_file_ids,currency_code,status,remark,version FROM fin_receipt WHERE tenant_id=? ORDER BY received_at DESC,id DESC", + (rs, rowNum) -> new PlatformReceipt(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("receipt_no"), rs.getBigDecimal("received_amount"), + time(rs, "received_at"), rs.getString("payment_method"), rs.getString("voucher_no"), fileIds(rs.getString("voucher_file_ids")), + rs.getString("currency_code"), rs.getString("status"), rs.getString("remark"), rs.getInt("version"), allocations(customerId, rs.getLong("id"))), customerId); + } + + private List fileIds(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception error) { + throw new IllegalStateException("财务凭证文件字段损坏", error); + } + } + + private List allocations(long customerId, long receiptId) { + return jdbc.query("SELECT rb.bill_id,b.bill_no,rb.applied_amount FROM fin_receipt_bill rb JOIN fin_bill b ON b.id=rb.bill_id AND b.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.receipt_id=? ORDER BY rb.id", + (rs, rowNum) -> new ClientReceiptAllocation(rs.getLong("bill_id"), rs.getString("bill_no"), rs.getBigDecimal("applied_amount")), customerId, receiptId); + } + + private BigDecimal amount(String sql, long customerId) { + BigDecimal value = jdbc.queryForObject(sql, BigDecimal.class, customerId); + return value == null ? BigDecimal.ZERO : value; + } + + private static BigDecimal money(ResultSet rs, String column) throws SQLException { + BigDecimal value = rs.getBigDecimal(column); + return value == null ? BigDecimal.ZERO : value; + } + + private static Long nullableLong(ResultSet rs, String column) throws SQLException { + long value = rs.getLong(column); + return rs.wasNull() ? null : value; + } + + private static LocalDateTime time(ResultSet rs, String column) throws SQLException { + Timestamp timestamp = rs.getTimestamp(column); + return timestamp == null ? null : timestamp.toLocalDateTime(); + } + + private static LocalDate date(ResultSet rs, String column) throws SQLException { + java.sql.Date date = rs.getDate(column); + return date == null ? null : date.toLocalDate(); + } + + private static void requireCustomer(long customerId) { + if (customerId <= 0) throw bad("客户范围无效"); + } + + private static void validateOptionalCustomer(Long customerId) { + if (customerId != null) requireCustomer(customerId); + } + + private static BizException bad(String message) { + return new BizException(ResultCode.BAD_REQUEST, message); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java new file mode 100644 index 000000000..b707dffec --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -0,0 +1,350 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** 首期人工财务台账:账单、收款和成本,不承载钱包、发票或复杂核销。 */ +@Service +public class FinanceService { + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final FinanceVoucherValidator voucherValidator; + private final FinanceSupplierValidator supplierValidator; + + public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator, + FinanceSupplierValidator supplierValidator) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.voucherValidator = voucherValidator; + this.supplierValidator = supplierValidator; + } + + public Map customerTerms(long tenantId) { + requireCustomer(tenantId); + List> rows = jdbc.queryForList( + "SELECT tenant_id, customer_name_snapshot, credit_days, status FROM fin_customer_settlement_profile WHERE tenant_id=?", + tenantId); + return rows.isEmpty() ? Map.of("tenantId", tenantId, "creditDays", 30, "status", "ACTIVE") : rows.get(0); + } + + public List> listBills(long tenantId) { + requireCustomer(tenantId); + return jdbc.queryForList("SELECT id,bill_no,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,version FROM fin_bill WHERE tenant_id=? ORDER BY bill_date DESC,id DESC", tenantId); + } + + public List> listClientBills(long tenantId) { + requireCustomer(tenantId); + return jdbc.queryForList("SELECT id,bill_no,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,version FROM fin_bill WHERE tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') ORDER BY bill_date DESC,id DESC", tenantId); + } + + public Map bill(long tenantId, long billId) { + requireCustomer(tenantId); + Map result = jdbc.queryForMap("SELECT * FROM fin_bill WHERE tenant_id=? AND id=?", tenantId, billId); + result.put("lines", jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id", tenantId, billId)); + return result; + } + + public Map clientBill(long tenantId, long billId) { + requireCustomer(tenantId); + Map result = jdbc.queryForMap("SELECT * FROM fin_bill WHERE tenant_id=? AND id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID')", tenantId, billId); + result.put("lines", jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id", tenantId, billId)); + return result; + } + + public Map billById(long billId) { + Long tenantId = jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?", Long.class, billId); + if (tenantId == null) throw bad("账单不存在"); + return bill(tenantId, billId); + } + + public List> billRevisions(long customerId, long billId) { + requireCustomer(customerId); + Number count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill WHERE id=? AND tenant_id=?", Number.class, billId, customerId); + if (count == null || count.intValue() != 1) throw bad("账单不存在或不属于客户"); + return jdbc.queryForList("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC", customerId, billId); + } + + public List> listReceipts(Long tenantId) { + String sql = "SELECT r.id,r.tenant_id,r.receipt_no,r.received_amount,r.received_at,r.payment_method,r.voucher_no,r.voucher_file_ids,r.status,rb.bill_id,rb.applied_amount FROM fin_receipt r LEFT JOIN fin_receipt_bill rb ON rb.receipt_id=r.id"; + return tenantId == null ? jdbc.queryForList(sql + " ORDER BY r.received_at DESC,r.id DESC") : jdbc.queryForList(sql + " WHERE r.tenant_id=? ORDER BY r.received_at DESC,r.id DESC", tenantId); + } + + public List> listCosts(Long tenantId) { + String sql = "SELECT * FROM fin_cost"; + return tenantId == null ? jdbc.queryForList(sql + " ORDER BY created_at DESC,id DESC") : jdbc.queryForList(sql + " WHERE tenant_id=? ORDER BY created_at DESC,id DESC", tenantId); + } + + @Transactional + public Map saveCustomerTerms(long tenantId, long operatorId, String customerName, + Integer creditDays, String idempotencyKey) { + requireCustomer(tenantId); + Long replay = reserve(tenantId, operatorId, "CUSTOMER_TERMS_SAVE", idempotencyKey, Map.of("customerName", String.valueOf(customerName), "creditDays", creditDays == null ? 30 : creditDays)); + if (replay != null) return customerTerms(tenantId); + int days = creditDays == null ? 30 : creditDays; + if (days < 0 || days > 3650) throw bad("账期必须在0到3650天之间"); + jdbc.update("INSERT INTO fin_customer_settlement_profile(tenant_id,customer_name_snapshot,credit_days,created_by,updated_by) VALUES(?,?,?,?,?) " + + "ON DUPLICATE KEY UPDATE customer_name_snapshot=VALUES(customer_name_snapshot), credit_days=VALUES(credit_days), updated_by=VALUES(updated_by), version=version+1", + tenantId, StringUtils.hasText(customerName) ? customerName : "客户" + tenantId, days, operatorId, operatorId); + complete(tenantId, operatorId, "CUSTOMER_TERMS_SAVE", idempotencyKey, tenantId); + return customerTerms(tenantId); + } + + @Transactional + public Map createReceipt(long tenantId, long operatorId, ReceiptRequest request, String idempotencyKey) { + requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey, request); + if (replay != null) return jdbc.queryForMap("SELECT * FROM fin_receipt WHERE id=? AND tenant_id=?", replay, tenantId); + validateVoucherFiles(tenantId, request.voucherFileIds()); + if (request.allocations() == null || request.allocations().isEmpty()) throw bad("收款至少需要分配一张账单"); + if (request.allocations().stream().anyMatch(a -> a == null || a.amount() == null || a.amount().signum() <= 0)) { + throw bad("分配金额必须大于0"); + } + long distinctBills = request.allocations().stream().map(ReceiptAllocation::billId).distinct().count(); + if (distinctBills != request.allocations().size()) throw bad("同一收款中账单ID不能重复"); + Map byBill = request.allocations().stream().collect( + java.util.stream.Collectors.toMap(ReceiptAllocation::billId, ReceiptAllocation::amount)); + BigDecimal total = byBill.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); + if (total.signum() <= 0) throw bad("收款金额必须大于0"); + for (var entry : byBill.entrySet()) { + Map bill = jdbc.queryForMap("SELECT outstanding_amount,status FROM fin_bill WHERE id=? AND tenant_id=? FOR UPDATE", entry.getKey(), tenantId); + BigDecimal outstanding = new BigDecimal(bill.get("outstanding_amount").toString()); + if ("DRAFT".equals(bill.get("status")) || "VOIDED".equals(bill.get("status")) + || entry.getValue().compareTo(outstanding) > 0) { + throw bad("只能分配已出账且未作废账单,且金额不得超过未收余额"); + } + } + KeyHolder holder = new GeneratedKeyHolder(); + jdbc.update(connection -> { + PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_receipt(tenant_id,receipt_no,subject_id,received_amount,received_at,payment_method,voucher_no,voucher_file_ids,remark,created_by,created_by_name,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS); + ps.setLong(1, tenantId); ps.setString(2, temporaryNo("RCPT")); ps.setLong(3, request.subjectId()); ps.setBigDecimal(4, total); ps.setObject(5, request.receivedAt() == null ? LocalDateTime.now() : request.receivedAt()); ps.setString(6, request.paymentMethod()); ps.setString(7, request.voucherNo()); ps.setString(8, json(request.voucherFileIds())); ps.setString(9, request.remark()); ps.setLong(10, operatorId); ps.setString(11, FinanceOperatorSnapshot.currentName(operatorId)); ps.setLong(12, operatorId); return ps; + }, holder); + long receiptId = holder.getKey().longValue(); + jdbc.update("UPDATE fin_receipt SET receipt_no=? WHERE id=?", stableNo("RCPT", receiptId), receiptId); + for (var entry : byBill.entrySet()) { + BigDecimal applied = entry.getValue(); + jdbc.update("INSERT INTO fin_receipt_bill(tenant_id,receipt_id,bill_id,applied_amount,created_by) VALUES(?,?,?,?,?)", tenantId, receiptId, entry.getKey(), applied, operatorId); + jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, updated_by_name=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, operatorId, FinanceOperatorSnapshot.currentName(operatorId), entry.getKey(), tenantId); + Integer settlementCount = jdbc.queryForObject( + "SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status<>'VOIDED'", + Integer.class, tenantId, entry.getKey()); + if (settlementCount != null && settlementCount > 0) { + int settlementUpdated = jdbc.update( + "UPDATE fin_settlement SET paid_amount=paid_amount+?,outstanding_amount=outstanding_amount-?,payment_status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,updated_by=?,version=version+1 WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status IN ('ISSUED','PARTIALLY_PAID') AND outstanding_amount>=?", + applied, applied, operatorId, tenantId, entry.getKey(), applied); + if (settlementUpdated != 1) throw new BizException(ResultCode.CONFLICT, "应收结算单状态或余额已变化"); + } + } + complete(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey, receiptId); + return jdbc.queryForMap("SELECT * FROM fin_receipt WHERE id=?", receiptId); + } + + private void validateVoucherFiles(long tenantId, List fileIds) { + voucherValidator.validate(tenantId, fileIds, "财务凭证"); + } + + @Transactional + public Map createCost(long tenantId, long operatorId, CostRequest request, String idempotencyKey) { + return createCost(tenantId, operatorId, request, List.of(), idempotencyKey); + } + + /** + * Creates a cost and, for a container-wide cost, persists the immutable + * volume snapshot used to allocate it. The snapshot is supplied by the + * Firstleg adapter because Finance must not join the Firstleg database. + */ + @Transactional + public Map createCost(long tenantId, long operatorId, CostRequest request, + List containerOrders, String idempotencyKey) { + requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "COST_CREATE", idempotencyKey, Map.of("cost", request, "containerOrders", containerOrders == null ? List.of() : containerOrders)); + if (replay != null) return jdbc.queryForMap("SELECT * FROM fin_cost WHERE id=? AND tenant_id=?", replay, tenantId); + if (!"SINGLE_ORDER".equals(request.costType()) && !"CONTAINER_SHARED".equals(request.costType())) throw bad("成本类型只能是SINGLE_ORDER或CONTAINER_SHARED"); + if (request.amount() == null || request.amount().signum() <= 0) throw bad("成本金额必须大于0"); + String supplierName = null; + if (request.supplierId() != null) { + if (request.supplierId() <= 0) throw bad("供应商ID无效"); + supplierName = supplierValidator.requireActive(request.supplierId(), null).name(); + } else if (org.springframework.util.StringUtils.hasText(request.supplierName())) { + throw bad("供应商名称必须来自Warehouse供应商主数据"); + } + validateVoucherFiles(tenantId, request.voucherFileIds()); + if ("SINGLE_ORDER".equals(request.costType()) && (request.orderId() == null || request.containerId() != null)) throw bad("单票成本必须指定订单且不能指定柜次"); + if ("CONTAINER_SHARED".equals(request.costType()) && (request.containerId() == null || containerOrders == null || containerOrders.isEmpty())) throw bad("整柜成本必须指定柜次及柜内有效订单体积"); + if ("SINGLE_ORDER".equals(request.costType())) validateSingleOrder(tenantId, request.orderId()); + validateContainerOrders(tenantId, request.containerId(), containerOrders); + String supplierNameSnapshot = supplierName; + KeyHolder holder = new GeneratedKeyHolder(); + jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,cost_amount,voucher_file_ids,remark,created_by,created_by_name,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS); + ps.setLong(1, tenantId); ps.setString(2, temporaryNo("COST")); ps.setString(3, request.costType()); ps.setObject(4, request.orderId()); ps.setObject(5, request.inboundId()); ps.setObject(6, request.containerId()); ps.setObject(7, request.supplierId()); ps.setString(8, supplierNameSnapshot); ps.setString(9, request.feeItemCode()); ps.setString(10, request.feeItemName()); ps.setBigDecimal(11, request.amount()); ps.setString(12, json(request.voucherFileIds())); ps.setString(13, request.remark()); ps.setLong(14, operatorId); ps.setString(15, FinanceOperatorSnapshot.currentName(operatorId)); ps.setLong(16, operatorId); return ps; }, holder); + long costId = holder.getKey().longValue(); + jdbc.update("UPDATE fin_cost SET cost_no=? WHERE id=?", stableNo("COST", costId), costId); + if ("CONTAINER_SHARED".equals(request.costType())) { + persistContainerAllocations(tenantId, operatorId, costId, request.amount(), containerOrders); + } + complete(tenantId, operatorId, "COST_CREATE", idempotencyKey, costId); + return jdbc.queryForMap("SELECT * FROM fin_cost WHERE id=?", costId); + } + + private void validateContainerOrders(long tenantId, Long containerId, List rows) { + if (rows == null) return; + java.util.Set seen = new java.util.HashSet<>(); + for (ContainerOrderVolume row : rows) { + if (row == null || row.orderId() <= 0 || row.containerId() != containerId || row.customerId() != tenantId + || row.volumeCbm() == null || row.volumeCbm().signum() <= 0 || !seen.add(row.orderId())) { + throw bad("柜次、订单、客户归属或体积无效"); + } + // The Firstleg snapshot is the authoritative ownership check for + // container costs. Fee sources may still be PENDING while a + // completed loading plan is already eligible for allocation. + } + } + + private void validateSingleOrder(long tenantId, long orderId) { + Number exists = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source WHERE customer_id=? AND order_id=? AND status='AVAILABLE'", Number.class, tenantId, orderId); + if (exists != null && exists.intValue() == 0) throw bad("单票订单不存在或不属于客户"); + } + + private void persistContainerAllocations(long tenantId, long operatorId, long costId, BigDecimal amount, + List rows) { + for (CostAllocation allocation : allocateContainerCost(amount, rows)) { + ContainerOrderVolume row = allocation.order(); + jdbc.update("INSERT INTO fin_cost_allocation(tenant_id,cost_id,order_id,inbound_id,volume_cbm,total_volume_cbm,allocation_rate,allocated_amount,rounding_adjustment) VALUES(?,?,?,?,?,?,?,?,?)", + tenantId, costId, row.orderId(), row.inboundId(), row.volumeCbm(), allocation.totalVolumeCbm(), + allocation.rate(), allocation.amount(), allocation.roundingAdjustment() ? 1 : 0); + if (allocation.roundingAdjustment()) { + jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot) VALUES(?,?,?,?,?,?,?,?,?)", + tenantId, "FIN_COST_ALLOCATION", costId, "ROUNDING_ADJUSTMENT", "尾差归集到最后一票", null, + json(Map.of("orderId", row.orderId(), "allocatedAmount", allocation.amount(), "totalAmount", amount)), + operatorId, FinanceOperatorSnapshot.requiredName(operatorId)); + } + } + } + + public static List allocateContainerCost(BigDecimal amount, List rows) { + if (amount == null || amount.signum() <= 0 || rows == null || rows.isEmpty()) throw bad("分摊金额和订单不能为空"); + BigDecimal totalVolume = rows.stream().map(ContainerOrderVolume::volumeCbm) + .reduce(BigDecimal.ZERO, BigDecimal::add); + if (totalVolume.signum() <= 0) throw bad("柜内有效订单总体积必须大于0"); + java.util.ArrayList result = new java.util.ArrayList<>(rows.size()); + BigDecimal allocated = BigDecimal.ZERO; + for (int i = 0; i < rows.size(); i++) { + ContainerOrderVolume row = rows.get(i); + boolean tail = i == rows.size() - 1; + BigDecimal rate = row.volumeCbm().divide(totalVolume, 10, java.math.RoundingMode.HALF_UP); + BigDecimal regularShare = amount.multiply(rate).setScale(6, java.math.RoundingMode.HALF_UP); + BigDecimal share = tail ? amount.subtract(allocated).setScale(6, java.math.RoundingMode.HALF_UP) : regularShare; + boolean roundingAdjustment = tail && share.compareTo(regularShare) != 0; + allocated = allocated.add(share); + result.add(new CostAllocation(row, totalVolume, rate, share, roundingAdjustment)); + } + return List.copyOf(result); + } + + private Long reserve(long tenantId, long operatorId, String operation, String key, Object request) { + if (!StringUtils.hasText(key) || key.length() > 128) throw bad("必须提供有效的 Idempotency-Key"); + String hash = hash(new IdempotencyRequest(httpMethod(operation), requestPath(operation), request)); + List> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id,updated_at FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", tenantId, operatorId, operation, key); + if (!rows.isEmpty()) { + Map row = rows.get(0); + if (!hash.equals(row.get("request_hash"))) throw new BizException(ResultCode.CONFLICT, "Idempotency-Key已用于其他请求"); + if ("SUCCEEDED".equals(row.get("status")) && row.get("result_resource_id") != null) return ((Number) row.get("result_resource_id")).longValue(); + if ("FAILED".equals(row.get("status"))) { + int recovered = jdbc.update("UPDATE fin_idempotency_record SET status='PROCESSING',error_code=NULL WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='FAILED'", + tenantId, operatorId, operation, key); + if (recovered == 1) return null; + } + LocalDateTime updatedAt = idempotencyUpdatedAt(row.get("updated_at")); + if ("PROCESSING".equals(row.get("status")) && updatedAt != null && updatedAt.isBefore(LocalDateTime.now().minusMinutes(5))) { + int recovered = jdbc.update("UPDATE fin_idempotency_record SET updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING' AND updated_at=?", + tenantId, operatorId, operation, key, updatedAt); + if (recovered == 1) return null; + } + throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中"); + } + jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')", tenantId, operatorId, operation, key, hash); + return null; + } + + private void complete(long tenantId, long operatorId, String operation, String key, long resourceId) { + jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=? WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'", resourceId, tenantId, operatorId, operation, key); + } + private static String temporaryNo(String prefix) { return prefix + "-PENDING-" + java.util.UUID.randomUUID(); } + private static String stableNo(String prefix, long id) { return prefix + "-" + String.format(java.util.Locale.ROOT, "%020d", id); } + private static LocalDateTime idempotencyUpdatedAt(Object value) { + if (value instanceof LocalDateTime dateTime) return dateTime; + if (value instanceof java.sql.Timestamp timestamp) return timestamp.toLocalDateTime(); + return null; + } + private static String httpMethod(String operation) { + return switch (operation) { + case "CUSTOMER_TERMS_SAVE", "BILL_UPDATE" -> "PUT"; + default -> "POST"; + }; + } + private static String requestPath(String operation) { + return switch (operation) { + case "CUSTOMER_TERMS_SAVE" -> "/api/platform/finance/v1/customers/{customerId}/terms"; + case "RECEIPT_CREATE" -> "/api/platform/finance/v1/receipts"; + case "COST_CREATE" -> "/api/platform/finance/v1/cost-entries"; + case "BILL_UPDATE" -> "/api/platform/finance/v1/bills/{billId}"; + case "BILL_ADJUST" -> "/api/platform/finance/v1/bills/{billId}/adjust"; + case "BILL_VOID" -> "/api/platform/finance/v1/bills/{billId}/void"; + default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation); + }; + } + private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } } + + private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); } + private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } + private String json(Object value) { try { return objectMapper.writeValueAsString(value == null ? List.of() : value); } catch (JsonProcessingException e) { throw new IllegalArgumentException("凭证字段格式错误", e); } } + + @Transactional + public Map updateBill(long operatorId, long billId, long requestedCustomerId, LocalDate billDate, List lines, int expectedVersion, String key) { + Long tenantId = billTenant(billId); if (tenantId != requestedCustomerId) throw bad("账单不属于指定客户"); Long replay = reserve(tenantId, operatorId, "BILL_UPDATE", key, Map.of("billId",billId,"customerId",requestedCustomerId,"billDate",String.valueOf(billDate),"expectedVersion",expectedVersion,"lines",String.valueOf(lines))); if (replay != null) return bill(tenantId,replay); + replaceDraftLines(tenantId, billId, lines); + BigDecimal total = lineTotal(lines); + LocalDate effectiveDate = billDate == null ? LocalDate.now() : billDate; + Object credit = customerTerms(tenantId).get("credit_days"); + if (credit == null) credit = customerTerms(tenantId).get("creditDays"); + LocalDate dueDate = effectiveDate.plusDays(credit == null ? 30 : ((Number) credit).intValue()); + int changed = jdbc.update("UPDATE fin_bill SET bill_date=?,due_date=?,version=version+1,updated_by=?,updated_by_name=? WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?", effectiveDate,dueDate,operatorId,FinanceOperatorSnapshot.currentName(operatorId),billId,tenantId,expectedVersion); + if (changed != 1) throw bad("仅草稿账单可修改且版本必须匹配"); + jdbc.update("UPDATE fin_bill SET total_amount=?, outstanding_amount=? WHERE id=? AND tenant_id=?", total, total, billId, tenantId); + complete(tenantId, operatorId, "BILL_UPDATE", key, billId); + return bill(tenantId,billId); + } + @Transactional + public Map adjustBill(long operatorId,long billId,List lines,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); Long replay=reserve(tenantId,operatorId,"BILL_ADJUST",key,Map.of("billId",billId,"expectedVersion",expectedVersion,"reason",reason,"lines",lines)); if(replay!=null) return bill(tenantId,replay); if (lines == null || lines.isEmpty()) throw bad("调整后至少需要一条费用明细"); Map current = bill(tenantId,billId); snapshot(tenantId,billId,expectedVersion,reason,current,operatorId); BigDecimal total = lineTotal(lines); BigDecimal received = new BigDecimal(current.get("received_amount").toString()); if (total.compareTo(received) < 0) throw bad("调整金额不能低于已收金额"); BigDecimal outstanding=total.subtract(received); String status=outstanding.signum()==0 ? "PAID" : (received.signum()==0 ? "ISSUED" : "PARTIALLY_PAID"); int c=jdbc.update("UPDATE fin_bill SET remark=?,total_amount=?,outstanding_amount=?,status=?,version=version+1,updated_by=?,updated_by_name=? WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND version=?",reason,total,outstanding,status,operatorId,FinanceOperatorSnapshot.currentName(operatorId),billId,tenantId,expectedVersion); if(c!=1) throw bad("账单状态或版本不允许调整"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); complete(tenantId,operatorId,"BILL_ADJUST",key,billId); return bill(tenantId,billId); } + @Transactional + public Map voidBill(long operatorId,long billId,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); Long replay=reserve(tenantId,operatorId,"BILL_VOID",key,Map.of("billId",billId,"expectedVersion",expectedVersion,"reason",reason)); if(replay!=null) return bill(tenantId,replay); if (!StringUtils.hasText(reason)) throw bad("作废原因不能为空"); int c=jdbc.update("UPDATE fin_bill SET status='VOIDED',voided_at=?,voided_by=?,void_reason=?,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('DRAFT','ISSUED') AND received_amount=0 AND version=?",LocalDateTime.now(),operatorId,reason.trim(),operatorId,FinanceOperatorSnapshot.currentName(operatorId),billId,tenantId,expectedVersion); if(c!=1) throw bad("已有收款、账单状态或版本不允许作废"); complete(tenantId,operatorId,"BILL_VOID",key,billId); return bill(tenantId,billId); } + private long billTenant(long billId){ Long t=jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?",Long.class,billId); if(t==null) throw bad("账单不存在"); return t; } + private void snapshot(long tenantId,long billId,int version,String reason,Map bill,long operatorId){ + var lines=jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id",tenantId,billId); + jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name) VALUES(?,?,?,?,?,?,?,?)",tenantId,billId,version,reason,json(bill),json(lines),operatorId,FinanceOperatorSnapshot.currentName(operatorId)); + } + private BigDecimal lineTotal(List lines) { if (lines == null || lines.isEmpty()) throw bad("账单至少需要一条费用明细"); java.util.Set sources = new java.util.HashSet<>(); return lines.stream().map(l -> { if (l.sourceFeeId() <= 0 || l.sourceFeeVersion() <= 0) throw bad("费用来源版本无效"); if (!sources.add(l.sourceFeeId()+":"+l.sourceFeeVersion())) throw bad("重复费用来源不允许合并"); if (l.amount() == null || l.amount().signum() < 0) throw bad("费用金额不能为负数"); return l.amount(); }).reduce(BigDecimal.ZERO, BigDecimal::add); } + private void replaceDraftLines(long tenantId, long billId, List lines) { lineTotal(lines); Map current=bill(tenantId,billId); if (!"DRAFT".equals(current.get("status"))) throw bad("仅草稿账单可修改"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); } + private void insertLines(long tenantId,long billId,List lines) { for (var line: lines) jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?)",tenantId,billId,"FIRSTLEG_FEE",line.sourceFeeId(),line.sourceFeeVersion(),line.orderId(),line.inboundId(),"MANUAL",line.feeItemName(),line.amount()); } + + public record ReceiptRequest(String receiptNo, long subjectId, LocalDateTime receivedAt, String paymentMethod, String voucherNo, List voucherFileIds, String remark, List allocations) {} + public record ReceiptAllocation(long billId, BigDecimal amount) {} + public record CostRequest(String costNo, String costType, Long orderId, Long inboundId, Long containerId, Long supplierId, String supplierName, String feeItemCode, String feeItemName, BigDecimal amount, List voucherFileIds, String remark) {} + public record ContainerOrderVolume(long orderId, Long inboundId, long containerId, long customerId, BigDecimal volumeCbm) {} + public record CostAllocation(ContainerOrderVolume order, BigDecimal totalVolumeCbm, BigDecimal rate, + BigDecimal amount, boolean roundingAdjustment) {} + private record IdempotencyRequest(String method, String path, Object body) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java new file mode 100644 index 000000000..2038ebd45 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -0,0 +1,633 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.MDC; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@Service +public class FinanceSettlementService { + private static final String CNY = "CNY"; + private static final Set PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER"); + private static final String SETTLEMENT_SUMMARY_SELECT = + "SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," + + "s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," + + "s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " + + "FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " + + "WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," + + "s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," + + "s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " + + "WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " + + "AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," + + "CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " + + "JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " + + "WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " + + "ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " + + "AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " + + "LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id "; + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final FinanceVoucherValidator voucherValidator; + private final FinanceSupplierValidator supplierValidator; + private final FinanceDocumentNumberGenerator documentNumbers; + + public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator, + FinanceSupplierValidator supplierValidator) { + this(jdbc, objectMapper, voucherValidator, supplierValidator, new FinanceDocumentNumberGenerator(jdbc)); + } + + @Autowired + public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator, + FinanceSupplierValidator supplierValidator, + FinanceDocumentNumberGenerator documentNumbers) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.voucherValidator = voucherValidator; + this.supplierValidator = supplierValidator; + this.documentNumbers = documentNumbers; + } + + @Transactional + public SettlementDetail createReceivableSettlement(long operatorId, String key, + CreateReceivableSettlementRequest request) { + if (request == null || request.reconciliationStatementId() <= 0) { + throw error("FIN_400", "对账单ID无效"); + } + requireCny(request.currency()); + Map bill = one( + "SELECT id,tenant_id,bill_no,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount " + + "FROM fin_bill WHERE id=? FOR UPDATE", + "对账单不存在", request.reconciliationStatementId()); + long customerId = number(bill, "tenant_id").longValue(); + if (!CNY.equals(text(bill, "currency_code"))) throw error("FIN_400", "对账单币种必须为CNY"); + if (!"ISSUED".equals(text(bill, "status"))) throw error("FIN_409", "只有已出账对账单可生成应收结算单"); + BigDecimal total = decimal(bill, "total_amount"); + if (total.signum() <= 0) throw error("FIN_400", "应收结算金额必须大于0"); + Long replay = reserve(customerId, operatorId, "SETTLEMENT_AR_CREATE", key, "POST", + "/api/platform/finance/v1/receivable-settlements", request); + if (replay != null) return getSettlement(replay); + Integer occupied = jdbc.queryForObject( + "SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' " + + "AND reconciliation_statement_id=? AND status<>'VOIDED'", Integer.class, + customerId, request.reconciliationStatementId()); + if (occupied != null && occupied > 0) throw error("FIN_409", "对账单已生成有效应收结算单"); + Integer lineCount = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?", + Integer.class, customerId, request.reconciliationStatementId()); + if (lineCount == null || lineCount == 0) throw error("FIN_409", "对账单没有可结算费用明细"); + + LocalDate generatedDate = LocalDate.now(); + long settlementId = insertSettlement(customerId, documentNumbers.next("AR"), "RECEIVABLE", + text(bill, "customer_name_snapshot"), null, null, request.reconciliationStatementId(), + date(bill, "bill_date").format(DateTimeFormatter.ofPattern("yyyy-MM")), CNY, total, + generatedDate, date(bill, "due_date"), operatorId); + int inserted = jdbc.update( + "INSERT INTO fin_settlement_line(tenant_id,settlement_id,fee_source_id,amount,currency_code,created_at) " + + "SELECT bl.tenant_id,?,f.id,bl.amount,bl.currency_code,CURRENT_TIMESTAMP(3) FROM fin_bill_line bl " + + "JOIN fin_fee_source f ON f.customer_id=bl.tenant_id AND f.id=bl.source_id " + + "WHERE bl.tenant_id=? AND bl.bill_id=?", + settlementId, customerId, request.reconciliationStatementId()); + if (inserted != lineCount) throw error("FIN_409", "对账单费用来源快照不完整"); + audit(customerId, settlementId, "CREATE", null, null, settlementSnapshot(total, BigDecimal.ZERO), operatorId); + complete(customerId, operatorId, "SETTLEMENT_AR_CREATE", key, settlementId); + return getSettlement(settlementId); + } + + @Transactional + public SettlementDetail createPayableSettlement(long operatorId, String key, + CreatePayableSettlementRequest request) { + validatePayableRequest(request); + long customerId = request.customerId(); + Long replay = reserve(customerId, operatorId, "SETTLEMENT_AP_CREATE", key, "POST", + "/api/platform/finance/v1/payable-settlements", request); + if (replay != null) return getSettlement(replay); + List ids = request.feeSourceIds().stream().distinct().sorted().toList(); + String placeholders = String.join(",", java.util.Collections.nCopies(ids.size(), "?")); + List args = new ArrayList<>(); + args.addAll(ids); + List> sources = jdbc.queryForList( + "SELECT id,customer_id,direction,currency_code,status,amount,fee_item_code,fee_item_name,settlement_mode,source_snapshot," + + "review_status," + + "CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED) supplier_id," + + "JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName')) supplier_name," + + "JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierCategory')) supplier_category " + + "FROM fin_fee_source WHERE id IN (" + placeholders + ") FOR UPDATE", args.toArray()); + if (sources.size() != ids.size() || sources.stream().anyMatch(row -> number(row, "customer_id").longValue() != customerId)) { + throw error("FIN_404", "费用来源不存在或不属于客户"); + } + for (Map source : sources) { + Long supplierId = nullableNumber(source, "supplier_id"); + if (supplierId == null || supplierId != request.supplierId()) throw error("FIN_409", "应付来源必须属于同一供应商"); + if (!"ON_ACCOUNT".equals(text(source, "settlement_mode"))) { + throw error("FIN_409", "只有ON_ACCOUNT账期应付费用可以生成应付结算单"); + } + if (!"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status")) + || !"APPROVED".equals(text(source, "review_status")) || decimal(source, "amount").signum() <= 0) { + throw error("FIN_409", "费用来源未审核、金额无效或状态不可用"); + } + if (!CNY.equals(text(source, "currency_code"))) throw error("FIN_400", "费用来源币种必须为CNY"); + Integer used = jdbc.queryForObject( + "SELECT COUNT(*) FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id " + + "WHERE sl.fee_source_id=? AND s.status<>'VOIDED'", Integer.class, number(source, "id").longValue()); + if (used != null && used > 0) throw error("FIN_409", "费用来源已被有效结算单占用"); + } + BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add); + if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0"); + String supplierCategory = supplierCategoryConstraint(sources); + String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategory).name(); + String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName")) + .filter(StringUtils::hasText).findFirst().orElse("客户" + customerId); + LocalDate generatedDate = LocalDate.now(); + long settlementId = insertSettlement(customerId, documentNumbers.next("AP"), "PAYABLE", customerName, + request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")), + CNY, total, generatedDate, request.dueDate(), operatorId); + for (Map source : sources) { + jdbc.update("INSERT INTO fin_settlement_line(tenant_id,settlement_id,fee_source_id,amount,currency_code,created_at) " + + "VALUES(?,?,?,?,?,CURRENT_TIMESTAMP(3))", customerId, settlementId, + number(source, "id").longValue(), decimal(source, "amount"), CNY); + } + audit(customerId, settlementId, "CREATE", null, null, settlementSnapshot(total, BigDecimal.ZERO), operatorId); + complete(customerId, operatorId, "SETTLEMENT_AP_CREATE", key, settlementId); + return getSettlement(settlementId); + } + + public SettlementDetail getSettlement(long settlementId) { + if (settlementId <= 0) throw error("FIN_404", "结算单不存在"); + Map row = one( + SETTLEMENT_SUMMARY_SELECT + "WHERE s.id=?", + "结算单不存在", settlementId); + SettlementSummary summary = summary(row); + List lines = jdbc.queryForList( + "SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," + + "f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot " + + "FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " + + "WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId) + .stream().map(source -> line(source, summary)).toList(); + List transactions = new ArrayList<>(jdbc.queryForList( + "SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark " + + "FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id", + summary.customerId(), settlementId).stream().map(this::transaction).toList()); + if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) { + transactions.addAll(jdbc.queryForList( + "SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark " + + "FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " + + "WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.created_at,r.id", + summary.customerId(), summary.reconciliationStatementId()).stream().map(this::transaction).toList()); + } + List> audits = jdbc.queryForList( + "SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log " + + "WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id", + summary.customerId(), settlementId); + List revisions = new ArrayList<>(); + for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index))); + return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions)); + } + + public List listSettlements(String direction, Long customerId) { + if (!Set.of("RECEIVABLE", "PAYABLE").contains(direction)) { + throw error("FIN_400", "结算单方向无效"); + } + if (customerId != null && customerId <= 0) { + throw error("FIN_400", "客户范围无效"); + } + String sql = SETTLEMENT_SUMMARY_SELECT + "WHERE s.direction=?"; + List args = new ArrayList<>(); + args.add(direction); + if (customerId != null) { + sql += " AND s.tenant_id=?"; + args.add(customerId); + } + sql += " ORDER BY s.generated_date DESC,s.id DESC"; + return jdbc.queryForList(sql, args.toArray()).stream().map(this::summary).toList(); + } + + @Transactional + public SettlementDetail createSettlementPayment(long settlementId, long operatorId, String key, + CreateSettlementPaymentRequest request) { + validatePaymentRequest(request); + Map settlement = one( + "SELECT id,tenant_id,direction,currency_code,total_amount,paid_amount,outstanding_amount,status,version,supplier_id " + + "FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId); + long customerId = number(settlement, "tenant_id").longValue(); + Long replay = reserve(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, "POST", + "/api/platform/finance/v1/settlements/" + settlementId + "/transactions", request); + if (replay != null) return getSettlement(replay); + if (!"PAYABLE".equals(text(settlement, "direction"))) throw error("FIN_409", "仅应付结算单可登记付款"); + if (!List.of("ISSUED", "PARTIALLY_PAID").contains(text(settlement, "status"))) { + throw error("FIN_409", "结算单状态不允许付款"); + } + if (!CNY.equals(text(settlement, "currency_code"))) throw error("FIN_400", "结算单币种必须为CNY"); + if (number(settlement, "version").intValue() != request.expectedVersion()) throw error("FIN_409", "结算单版本冲突"); + BigDecimal outstanding = decimal(settlement, "outstanding_amount"); + if (request.amount().compareTo(outstanding) > 0) throw error("FIN_409", "付款金额不得超过未付余额"); + String supplierCategory = payableSupplierCategory(customerId, settlementId); + supplierValidator.requireActive(number(settlement, "supplier_id").longValue(), supplierCategory); + validateVoucherFiles(customerId, request.voucherFileIds()); + + insertPayment(customerId, settlementId, request, operatorId); + BigDecimal paid = decimal(settlement, "paid_amount").add(request.amount()); + BigDecimal remaining = outstanding.subtract(request.amount()); + String status = remaining.signum() == 0 ? "PAID" : "PARTIALLY_PAID"; + int changed = jdbc.update("UPDATE fin_settlement SET paid_amount=?,outstanding_amount=?,payment_status=?,status=?," + + "updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND version=?", + paid, remaining, status, status, operatorId, settlementId, customerId, request.expectedVersion()); + if (changed != 1) throw error("FIN_409", "结算单版本冲突"); + audit(customerId, settlementId, "PAYMENT", request.remark(), + settlementSnapshot(decimal(settlement, "total_amount"), decimal(settlement, "paid_amount")), + settlementSnapshot(decimal(settlement, "total_amount"), paid), operatorId); + complete(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, settlementId); + return getSettlement(settlementId); + } + + @Transactional + public SettlementDetail actionSettlement(long settlementId, long operatorId, String key, + SettlementActionRequest request) { + validateSettlementAction(request); + Map settlement = one( + "SELECT id,tenant_id,total_amount,paid_amount,outstanding_amount,payment_status,status,version " + + "FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId); + long customerId = number(settlement, "tenant_id").longValue(); + Long replay = reserve(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, "POST", + "/api/platform/finance/v1/settlements/" + settlementId + "/actions", request); + if (replay != null) return getSettlement(replay); + if (number(settlement, "version").intValue() != request.expectedVersion()) { + throw error("FIN_409", "结算单版本冲突"); + } + String currentStatus = text(settlement, "status"); + String targetStatus; + if ("VOID".equals(request.action())) { + if (!"ISSUED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) { + throw error("FIN_409", "只有未发生收付款的生效结算单可以作废"); + } + targetStatus = "VOIDED"; + } else { + if (!"VOIDED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) { + throw error("FIN_409", "只有未发生收付款的已作废结算单可以重开"); + } + targetStatus = "ISSUED"; + } + int changed = jdbc.update("UPDATE fin_settlement SET status=?,updated_by=?,version=version+1 " + + "WHERE id=? AND tenant_id=? AND version=?", targetStatus, operatorId, settlementId, customerId, + request.expectedVersion()); + if (changed != 1) throw error("FIN_409", "结算单版本冲突"); + audit(customerId, settlementId, request.action(), request.reason(), settlementStateSnapshot(settlement), + settlementStateSnapshot(settlement, targetStatus), operatorId); + complete(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, settlementId); + return getSettlement(settlementId); + } + + private long insertSettlement(long customerId, String no, String direction, String customerName, + Long supplierId, String supplierName, Long reconciliationId, + String businessPeriod, String currency, BigDecimal total, + LocalDate generatedDate, LocalDate dueDate, long operatorId) { + KeyHolder holder = new GeneratedKeyHolder(); + jdbc.update(connection -> { + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO fin_settlement(tenant_id,settlement_no,direction,customer_name_snapshot,supplier_id," + + "supplier_name_snapshot,reconciliation_statement_id,business_period,currency_code,total_amount," + + "paid_amount,outstanding_amount,generated_date,due_date,payment_status,status,version,created_by," + + "updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,0,?,?,?,'UNPAID','ISSUED',0,?,?," + + "CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))", Statement.RETURN_GENERATED_KEYS); + statement.setLong(1, customerId); statement.setString(2, no); statement.setString(3, direction); + statement.setString(4, customerName); statement.setObject(5, supplierId); statement.setString(6, supplierName); + statement.setObject(7, reconciliationId); statement.setString(8, businessPeriod); statement.setString(9, currency); + statement.setBigDecimal(10, total); statement.setBigDecimal(11, total); statement.setDate(12, Date.valueOf(generatedDate)); + statement.setDate(13, Date.valueOf(dueDate)); statement.setLong(14, operatorId); statement.setLong(15, operatorId); + return statement; + }, holder); + if (holder.getKey() == null) throw error("FIN_001", "结算单创建后未返回主键"); + return holder.getKey().longValue(); + } + + private void insertPayment(long customerId, long settlementId, CreateSettlementPaymentRequest request, + long operatorId) { + jdbc.update("INSERT INTO fin_settlement_payment(tenant_id,settlement_id,transaction_type,transaction_no," + + "transaction_date,amount,currency_code,method,reference_no,voucher_file_ids,remark,created_by,created_by_name,created_at) " + + "VALUES(?,?,'PAYMENT',?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))", + customerId, settlementId, transactionNo(), request.transactionDate(), request.amount(), CNY, + request.method(), blank(request.referenceNo()), json(request.voucherFileIds()), blank(request.remark()), + operatorId, FinanceOperatorSnapshot.currentName(operatorId)); + } + + private void validatePayableRequest(CreatePayableSettlementRequest request) { + if (request == null || request.customerId() <= 0 || request.supplierId() <= 0 || request.dueDate() == null + || request.feeSourceIds() == null || request.feeSourceIds().isEmpty() + || request.feeSourceIds().stream().anyMatch(id -> id == null || id <= 0)) { + throw error("FIN_400", "应付结算请求字段不完整"); + } + if (new HashSet<>(request.feeSourceIds()).size() != request.feeSourceIds().size()) { + throw error("FIN_400", "费用来源ID不能重复"); + } + requireCny(request.currency()); + } + + private void validatePaymentRequest(CreateSettlementPaymentRequest request) { + if (request == null || request.transactionDate() == null || request.amount() == null || request.amount().signum() <= 0 + || request.expectedVersion() == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.method()) + || request.voucherFileIds() == null || request.voucherFileIds().isEmpty()) { + throw error("FIN_400", "付款请求字段不完整或金额无效"); + } + requireCny(request.currency()); + if (!PAYMENT_METHODS.contains(request.method())) throw error("FIN_400", "付款方式无效"); + if (new HashSet<>(request.voucherFileIds()).size() != request.voucherFileIds().size()) { + throw error("FIN_400", "付款凭证ID不能重复"); + } + if (request.remark() != null && request.remark().length() > 500) throw error("FIN_400", "付款备注不能超过500字"); + } + + private void validateSettlementAction(SettlementActionRequest request) { + if (request == null || !Set.of("VOID", "REOPEN").contains(request.action()) + || !StringUtils.hasText(request.reason()) || request.reason().length() > 500 + || request.expectedVersion() == null || request.expectedVersion() < 0) { + throw error("FIN_400", "结算单操作、原因或版本无效"); + } + } + + private void validateVoucherFiles(long customerId, List fileIds) { + voucherValidator.validate(customerId, fileIds, "付款凭证"); + } + + private String payableSupplierCategory(long customerId, long settlementId) { + return supplierCategoryConstraint(jdbc.queryForList( + "SELECT JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierCategory')) supplier_category FROM fin_settlement_line sl " + + "JOIN fin_fee_source f ON f.id=sl.fee_source_id AND f.customer_id=sl.tenant_id " + + "WHERE sl.tenant_id=? AND sl.settlement_id=?", + customerId, settlementId)); + } + + private String supplierCategoryConstraint(List> sources) { + Set categories = sources.stream().map(row -> text(row, "supplier_category")) + .filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet()); + if (categories.size() > 1) throw error("FIN_409", "应付费用来源供应商类别约束不一致"); + return categories.isEmpty() ? null : categories.iterator().next(); + } + + private Long reserve(long customerId, long operatorId, String operation, String key, + String method, String path, Object body) { + if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN_400", "必须提供有效的Idempotency-Key"); + String requestHash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body)); + List> rows = jdbc.queryForList( + "SELECT request_hash,status,result_resource_id,updated_at FROM fin_idempotency_record " + + "WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", + customerId, operatorId, operation, key); + if (!rows.isEmpty()) { + Map row = rows.get(0); + if (!requestHash.equals(text(row, "request_hash"))) throw error("FIN_409", "Idempotency-Key已用于不同请求"); + if ("SUCCEEDED".equals(text(row, "status")) && row.get("result_resource_id") != null) { + return number(row, "result_resource_id").longValue(); + } + if ("FAILED".equals(text(row, "status"))) { + int reset = jdbc.update("UPDATE fin_idempotency_record SET status='PROCESSING',error_code=NULL,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='FAILED'", + customerId, operatorId, operation, key); + if (reset == 1) return null; + } + LocalDateTime updatedAt = localTime(row.get("updated_at")); + if ("PROCESSING".equals(text(row, "status")) && updatedAt != null + && updatedAt.isBefore(LocalDateTime.now().minusMinutes(5))) { + int reset = jdbc.update("UPDATE fin_idempotency_record SET updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING' AND updated_at=?", + customerId, operatorId, operation, key, updatedAt); + if (reset == 1) return null; + } + throw error("FIN_409", "相同请求正在处理中或等待恢复"); + } + jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) " + + "VALUES(?,?,?,?,?,'PROCESSING')", customerId, operatorId, operation, key, requestHash); + return null; + } + + private void complete(long customerId, long operatorId, String operation, String key, long resourceId) { + int changed = jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=?,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? " + + "AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'", + resourceId, customerId, operatorId, operation, key); + if (changed != 1) throw error("FIN_409", "幂等请求完成状态冲突"); + } + + private void audit(long customerId, long resourceId, String action, String reason, + Object before, Object after, long operatorId) { + jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data," + + "operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + customerId, "FIN_SETTLEMENT", resourceId, action, reason, before == null ? null : json(before), + json(after), operatorId, FinanceOperatorSnapshot.requiredName(operatorId), + MDC.get("requestId"), MDC.get("traceId")); + } + + private SettlementSummary summary(Map row) { + return new SettlementSummary(number(row, "id").longValue(), text(row, "settlement_no"), + text(row, "direction"), number(row, "tenant_id").longValue(), text(row, "customer_name_snapshot"), + nullableNumber(row, "supplier_id"), text(row, "supplier_name_snapshot"), + nullableNumber(row, "reconciliation_statement_id"), text(row, "reconciliation_statement_no"), + text(row, "business_period"), text(row, "fee_item_summary"), + text(row, "currency_code"), decimal(row, "total_amount"), decimal(row, "paid_amount"), + decimal(row, "outstanding_amount"), date(row, "generated_date"), date(row, "due_date"), + text(row, "payment_status"), text(row, "status"), number(row, "created_by").longValue(), + text(row, "created_by_name"), time(row, "created_at"), time(row, "updated_at"), + nullableTime(row.get("last_payment_at")), number(row, "version").intValue()); + } + + private SettlementLine line(Map row, SettlementSummary settlement) { + JsonNode snapshot = parseTree(text(row, "source_snapshot")); + BigDecimal quantity = jsonDecimalOrNull(snapshot, "chargeableQuantity"); + BigDecimal amount = decimal(row, "amount"); + BigDecimal unitPrice = quantity == null || quantity.signum() == 0 ? null + : amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP); + Long supplierId = jsonLong(snapshot, "supplierId"); + if ("PAYABLE".equals(text(row, "direction")) && supplierId == null) { + throw error("FIN_409", "应付结算缺少供应商快照"); + } + return new SettlementLine(number(row, "fee_source_id").longValue(), text(row, "source_type"), + number(row, "source_fee_id").longValue(), number(row, "source_fee_version").intValue(), + text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"), + supplierId, jsonText(snapshot, "supplierName"), + nullableNumber(row, "order_id"), jsonText(snapshot, "orderNo"), + nullableNumber(row, "inbound_id"), jsonText(snapshot, "inboundCode"), nullableNumber(row, "container_id"), + jsonText(snapshot, "containerNo"), jsonDate(snapshot, "businessDate"), text(row, "fee_item_name"), + quantity, unitPrice, amount, text(row, "currency_code"), text(row, "review_status"), + text(row, "status"), true); + } + + private SettlementTransaction transaction(Map row) { + return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"), + date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"), + stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(), + text(row, "created_by_name"), + time(row, "created_at"), text(row, "remark")); + } + + private SettlementRevision revision(int revisionNo, Map row) { + JsonNode before = parseTree(text(row, "before_data")); + JsonNode after = parseTree(text(row, "after_data")); + return new SettlementRevision(revisionNo, text(row, "action"), jsonDecimal(before, "totalAmount"), + jsonDecimal(after, "totalAmount"), text(row, "reason"), number(row, "operator_id").longValue(), + text(row, "operator_name_snapshot"), + time(row, "created_at")); + } + + private Map one(String sql, String message, Object... args) { + List> rows = jdbc.queryForList(sql, args); + if (rows.isEmpty()) throw error("FIN_404", message); + return rows.get(0); + } + + private static void requireCny(String currency) { + if (!CNY.equals(currency)) throw error("FIN_400", "一期仅支持CNY"); + } + + private static Map settlementSnapshot(BigDecimal totalAmount, BigDecimal paidAmount) { + return Map.of("totalAmount", totalAmount, "paidAmount", paidAmount, + "outstandingAmount", totalAmount.subtract(paidAmount)); + } + + private static Map settlementStateSnapshot(Map settlement) { + return settlementStateSnapshot(settlement, text(settlement, "status")); + } + + private static Map settlementStateSnapshot(Map settlement, String status) { + return Map.of("totalAmount", decimal(settlement, "total_amount"), + "paidAmount", decimal(settlement, "paid_amount"), + "outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status); + } + + private String transactionNo() { + return "PAY-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" + + UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT); + } + + private String hash(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(json(value).getBytes(StandardCharsets.UTF_8))); + } catch (Exception failure) { + throw error("FIN_001", "幂等请求哈希失败"); + } + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (Exception failure) { throw error("FIN_001", "财务数据序列化失败"); } + } + + private JsonNode parseTree(String value) { + if (!StringUtils.hasText(value)) return objectMapper.createObjectNode(); + try { return objectMapper.readTree(value); } + catch (Exception failure) { throw error("FIN_001", "财务快照数据损坏"); } + } + + private List stringList(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { return objectMapper.readValue(value, new TypeReference>() {}); } + catch (Exception failure) { throw error("FIN_001", "财务凭证数据损坏"); } + } + + private static String text(Map row, String key) { + Object value = row.get(key); + return value == null ? null : value.toString(); + } + private static Number number(Map row, String key) { + Object value = row.get(key); + if (value instanceof Number number) return number; + if (value == null) throw error("FIN_001", "财务字段缺失:" + key); + return new BigDecimal(value.toString()); + } + private static Long nullableNumber(Map row, String key) { + Object value = row.get(key); + return value == null ? null : (value instanceof Number number ? number.longValue() : Long.valueOf(value.toString())); + } + private static BigDecimal decimal(Map row, String key) { + Object value = row.get(key); + if (value == null) return BigDecimal.ZERO; + return value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString()); + } + private static LocalDate date(Map row, String key) { + Object value = row.get(key); + if (value instanceof Date date) return date.toLocalDate(); + if (value instanceof LocalDate date) return date; + return LocalDate.parse(value.toString()); + } + private static LocalDateTime time(Map row, String key) { + Object value = row.get(key); + if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime(); + if (value instanceof LocalDateTime time) return time; + return LocalDateTime.parse(value.toString()); + } + private static LocalDateTime localTime(Object value) { + if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime(); + if (value instanceof LocalDateTime time) return time; + return value == null ? null : LocalDateTime.parse(value.toString()); + } + private static LocalDateTime nullableTime(Object value) { + return value == null ? null : localTime(value); + } + private static String blank(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static boolean truth(Object value) { return value != null && Boolean.parseBoolean(value.toString()); } + private static String jsonText(JsonNode node, String key) { + JsonNode value = node.path(key); + return value.isMissingNode() || value.isNull() ? null : value.asText(); + } + private static Long jsonLong(JsonNode node, String key) { + JsonNode value = node.path(key); + if (value.isIntegralNumber() && value.canConvertToLong()) { + long parsed = value.longValue(); + return parsed > 0 ? parsed : null; + } + if (!value.isTextual()) return null; + try { + long parsed = Long.parseLong(value.textValue()); + return parsed > 0 ? parsed : null; + } catch (NumberFormatException ignored) { + return null; + } + } + private static LocalDate jsonDate(JsonNode node, String key) { + String value = jsonText(node, key); + return StringUtils.hasText(value) ? LocalDate.parse(value) : null; + } + private static BigDecimal jsonDecimal(JsonNode node, String key) { + JsonNode value = node.path(key); + if (value.isNumber()) return value.decimalValue(); + if (value.isTextual() && StringUtils.hasText(value.asText())) return new BigDecimal(value.asText()); + return BigDecimal.ZERO; + } + private static BigDecimal jsonDecimalOrNull(JsonNode node, String key) { + JsonNode value = node.path(key); + if (value.isNumber()) return value.decimalValue(); + if (value.isTextual() && StringUtils.hasText(value.asText())) return new BigDecimal(value.asText()); + return null; + } + private static boolean jsonBoolean(JsonNode node, String key) { return node.path(key).asBoolean(false); } + private static String firstText(String first, String fallback) { return StringUtils.hasText(first) ? first : fallback; } + private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSupplierValidator.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSupplierValidator.java new file mode 100644 index 000000000..b719823be --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSupplierValidator.java @@ -0,0 +1,53 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.client.WarehouseSupplierClient; +import feign.FeignException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.Locale; +import java.util.Set; + +@Service +public class FinanceSupplierValidator { + private static final Set CATEGORIES = Set.of("TRUCKING", "CUSTOMS", "BOOKING", "CERTIFICATE"); + + private final WarehouseSupplierClient client; + private final String internalToken; + + public FinanceSupplierValidator(WarehouseSupplierClient client, + @Value("${baiyue.security.internal-service-token:}") String internalToken) { + this.client = client; + this.internalToken = internalToken; + } + + public WarehouseSupplierClient.SupplierSnapshot requireActive(long supplierId, String category) { + String normalizedCategory = normalizeCategory(category); + try { + var response = client.get(internalToken, supplierId, true, normalizedCategory); + var supplier = response == null ? null : response.data(); + if (response == null || !response.successful() || supplier == null + || supplier.supplierId() != supplierId || !"ACTIVE".equals(supplier.status()) + || (normalizedCategory != null && !normalizedCategory.equals(supplier.category())) + || !StringUtils.hasText(supplier.name())) { + throw new FinanceEventException("FIN_409", "供应商不存在、未启用或类别与费用来源不匹配"); + } + return supplier; + } catch (FeignException exception) { + if (exception.status() == 400 || exception.status() == 404 || exception.status() == 409) { + throw new FinanceEventException("FIN_409", "供应商不存在、未启用或类别与费用来源不匹配"); + } + throw new FinanceEventException("FIN_503", "供应商主数据服务暂不可用"); + } + } + + private static String normalizeCategory(String category) { + if (!StringUtils.hasText(category)) return null; + String normalized = category.trim().toUpperCase(Locale.ROOT); + if (!CATEGORIES.contains(normalized)) { + throw new FinanceEventException("FIN_409", "费用来源供应商类别无效"); + } + return normalized; + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceVoucherValidator.java b/finance/src/main/java/com/baiyue/finance/service/FinanceVoucherValidator.java new file mode 100644 index 000000000..ae00efec5 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceVoucherValidator.java @@ -0,0 +1,56 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.finance.client.FinanceFileClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Locale; +import java.util.HashSet; + +@Component +public class FinanceVoucherValidator { + private final FinanceFileClient fileClient; + private final String fileToken; + + @Autowired + public FinanceVoucherValidator(FinanceFileClient fileClient, + @Value("${baiyue.security.internal-service-token:}") String fileToken) { + this.fileClient = fileClient; + this.fileToken = fileToken; + } + + public void validate(long customerId, List fileIds, String message) { + if (fileIds == null || fileIds.isEmpty()) throw bad(message + "不能为空"); + if (fileClient == null || !StringUtils.hasText(fileToken)) throw unavailable("File服务校验能力不可用"); + if (new HashSet<>(fileIds).size() != fileIds.size()) throw bad(message + "文件ID不能重复"); + for (String raw : fileIds) { + final long fileId; + try { fileId = Long.parseLong(raw); } catch (RuntimeException invalid) { throw bad(message + "文件ID无效"); } + FinanceFileClient.Metadata metadata; + try { + var response = fileClient.metadata(fileToken, customerId, customerId, fileId); + metadata = response == null ? null : response.data(); + } catch (RuntimeException unavailable) { + throw unavailable("File服务校验不可用"); + } + if (metadata == null || metadata.fileId() == null || metadata.fileId() != fileId + || metadata.tenantId() == null || metadata.tenantId() != customerId + || !String.valueOf(customerId).equals(metadata.businessId()) + || !"FINANCE_VOUCHER".equals(metadata.businessType()) + || !StringUtils.hasText(metadata.contentType()) + || !metadata.contentType().toLowerCase(Locale.ROOT).startsWith("image/")) { + throw bad(message + "不存在、归属不匹配或不是有效图片"); + } + } + } + + private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } + private static BizException unavailable(String message) { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, message); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java new file mode 100644 index 000000000..0d1f81a09 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java @@ -0,0 +1,659 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.CreditLimitRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.RechargeChannel; +import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +public class FinanceWalletService { + private static final Set PAYMENT_METHODS = Set.of("WECHAT", "ALIPAY"); + private static final Set ADJUSTMENT_PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER"); + private static final Set IMAGE_TYPES = Set.of("image/jpeg", "image/png"); + + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final FinanceFileClient fileClient; + private final FinanceDocumentNumberGenerator numberGenerator; + private final String internalToken; + private final String wechatQrCodeUrl; + private final String alipayQrCodeUrl; + + public FinanceWalletService(JdbcTemplate jdbc, ObjectMapper objectMapper, + FinanceFileClient fileClient, + FinanceDocumentNumberGenerator numberGenerator, + @Value("${baiyue.security.internal-service-token:}") String internalToken, + @Value("${baiyue.finance.recharge.wechat-qr-code-url:}") String wechatQrCodeUrl, + @Value("${baiyue.finance.recharge.alipay-qr-code-url:}") String alipayQrCodeUrl, + @Value("${spring.profiles.active:}") String activeProfiles, + @Value("${DEPLOY_ENV:}") String deployEnvironment) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.fileClient = fileClient; + this.numberGenerator = numberGenerator; + this.internalToken = internalToken; + boolean development = "development".equalsIgnoreCase(deployEnvironment.trim()) + || List.of(activeProfiles.split(",")).stream().map(String::trim).anyMatch("dev"::equalsIgnoreCase); + this.wechatQrCodeUrl = rechargeQrCode(wechatQrCodeUrl, development); + this.alipayQrCodeUrl = rechargeQrCode(alipayQrCodeUrl, development); + } + + private static String rechargeQrCode(String configured, boolean development) { + String value = blankToNull(configured); + return value == null && development ? "/customer-recharge-qr.svg" : value; + } + + public WalletAccount account(long customerId) { + requirePositive(customerId, "客户"); + List rows = jdbc.query( + "SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,updated_at FROM fin_wallet_account WHERE customer_id=?", + (rs, n) -> accountRow(rs), customerId); + return rows.isEmpty() ? zeroAccount() : rows.get(0); + } + + public WalletPage ledger(long customerId, int page, int size) { + requirePositive(customerId, "客户"); validatePage(page, size); + Long total = jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger WHERE customer_id=?", Long.class, customerId); + List rows = jdbc.query( + "SELECT * FROM fin_wallet_ledger WHERE customer_id=? ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?", + (rs, n) -> ledgerRow(rs), customerId, size, (page - 1) * size); + return WalletPage.of(rows, total == null ? 0 : total, page, size); + } + + public WalletPage rechargeRequests(long customerId, int page, int size) { + requirePositive(customerId, "客户"); validatePage(page, size); + Long total = jdbc.queryForObject("SELECT COUNT(*) FROM fin_recharge_request WHERE customer_id=?", Long.class, customerId); + List rows = jdbc.query( + "SELECT * FROM fin_recharge_request WHERE customer_id=? ORDER BY CASE status WHEN 'PENDING' THEN 0 ELSE 1 END,created_at DESC,id DESC LIMIT ? OFFSET ?", + (rs, n) -> rechargeRow(rs), customerId, size, (page - 1) * size); + return WalletPage.of(rows, total == null ? 0 : total, page, size); + } + + public List channels() { + return List.of(new RechargeChannel("WECHAT", "微信支付", wechatQrCodeUrl != null, wechatQrCodeUrl), + new RechargeChannel("ALIPAY", "支付宝", alipayQrCodeUrl != null, alipayQrCodeUrl)); + } + + @Transactional + public RechargeRequest submitRecharge(long customerId, long operatorId, String operatorName, + String idempotencyKey, BigDecimal amount, + String paymentMethod, MultipartFile file) { + requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人"); + BigDecimal normalizedAmount = money(amount); + String method = paymentMethod(paymentMethod); + RechargeChannel channel = channels().stream().filter(value -> value.paymentMethod().equals(method)).findFirst().orElseThrow(); + if (!channel.enabled()) throw error("FIN-WALLET-CHANNEL-DISABLED", channel.displayName() + "收款二维码未配置"); + validateImage(file); + String requestHash = hash(Map.of("amount", normalizedAmount.toPlainString(), "paymentMethod", method, + "fileName", String.valueOf(file.getOriginalFilename()), "contentType", String.valueOf(file.getContentType()), + "size", file.getSize(), "fileHash", fileHash(file))); + Long replay = reserve(customerId, operatorId, "WALLET_RECHARGE_SUBMIT", idempotencyKey, requestHash); + if (replay != null) return requiredRecharge(replay, customerId); + + ApiResponse upload; + try { + upload = fileClient.upload(internalToken, operatorId, customerId, customerId, file); + } catch (RuntimeException exception) { + throw error("FIN_503", "付款截图暂时无法保存,请重试"); + } + if (upload == null || !upload.successful() || upload.data() == null || upload.data().fileId() == null) { + throw error("FIN_503", "付款截图暂时无法保存,请重试"); + } + String requestNo = numberGenerator.next("RC"); + KeyHolder holder = new GeneratedKeyHolder(); + jdbc.update(connection -> { + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO fin_recharge_request(customer_id,request_no,amount,payment_method,voucher_file_id,voucher_file_name,status,version,submitted_by,submitted_by_name) VALUES(?,?,?,?,?,?,'PENDING',0,?,?)", + Statement.RETURN_GENERATED_KEYS); + statement.setLong(1, customerId); statement.setString(2, requestNo); + statement.setBigDecimal(3, normalizedAmount); statement.setString(4, method); + statement.setLong(5, upload.data().fileId()); statement.setString(6, upload.data().fileName()); + statement.setLong(7, operatorId); statement.setString(8, clean(operatorName, 128)); + return statement; + }, holder); + long requestId = generatedId(holder); + complete(customerId, operatorId, "WALLET_RECHARGE_SUBMIT", idempotencyKey, requestId); + return requiredRecharge(requestId, customerId); + } + + @Transactional + public RechargeRequest reviewRecharge(long requestId, long operatorId, String operatorName, + String idempotencyKey, RechargeReviewRequest request) { + requirePositive(requestId, "充值申请"); requirePositive(operatorId, "操作人"); + if (request == null || request.expectedVersion() < 0) throw bad("审核请求无效"); + String decision = upper(request.decision()); + if (!Set.of("APPROVE", "REJECT").contains(decision)) throw bad("审核决定无效"); + if ("REJECT".equals(decision) && !StringUtils.hasText(request.remark())) throw bad("驳回时必须填写原因"); + RechargeRequest before = requiredRecharge(requestId, null); + String requestHash = hash(Map.of("requestId", requestId, "request", request)); + Long replay = reserve(customerIdOf(requestId), operatorId, "WALLET_RECHARGE_REVIEW", idempotencyKey, requestHash); + if (replay != null) return requiredRecharge(replay, null); + int updated = jdbc.update( + "UPDATE fin_recharge_request SET status=?,version=version+1,reviewed_by=?,reviewed_by_name=?,review_remark=?,reviewed_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='PENDING' AND version=?", + "APPROVE".equals(decision) ? "APPROVED" : "REJECTED", operatorId, clean(operatorName, 128), + clean(request.remark(), 500), requestId, request.expectedVersion()); + if (updated != 1) throw error("FIN-WALLET-409", "充值申请已处理或版本已变化"); + if ("APPROVE".equals(decision)) { + WalletAccount account = accountForUpdate(beforeCustomerId(requestId)); + WalletAccount afterAccount = rechargeAccount(account, before.amount()); + updateAccount(beforeCustomerId(requestId), afterAccount, account.version(), operatorId); + insertLedger(beforeCustomerId(requestId), "RECHARGE", before.amount(), afterAccount, + "CLIENT_RECHARGE", before.requestNo(), LocalDate.now(), before.paymentMethod(), null, + operatorId, operatorName, request.remark()); + } + RechargeRequest after = requiredRecharge(requestId, null); + audit(beforeCustomerId(requestId), "RECHARGE_REQUEST", requestId, "RECHARGE_" + decision, + request.remark(), before, after, operatorId, operatorName); + complete(beforeCustomerId(requestId), operatorId, "WALLET_RECHARGE_REVIEW", idempotencyKey, requestId); + return after; + } + + @Transactional + public WalletLedgerEntry adjust(long customerId, long operatorId, String operatorName, + String idempotencyKey, AccountAdjustmentRequest request) { + requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人"); + if (request == null || request.expectedAccountVersion() < 0) throw bad("账户调整请求无效"); + String type = upper(request.type()); + if (!Set.of("RECHARGE", "DEDUCTION").contains(type)) throw bad("账户调整类型无效"); + BigDecimal amount = money(request.amount()); + if (request.occurredDate() == null) throw bad("发生日期不能为空"); + if (request.occurredDate().isAfter(LocalDate.now())) throw bad("发生日期不能晚于今天"); + String adjustmentPaymentMethod = upper(request.paymentMethod()); + if (!ADJUSTMENT_PAYMENT_METHODS.contains(adjustmentPaymentMethod)) throw bad("充值或扣款方式无效"); + Long replay = reserve(customerId, operatorId, "WALLET_ACCOUNT_ADJUST", idempotencyKey, hash(request)); + if (replay != null) return requiredLedger(replay, customerId); + WalletAccount before = accountForUpdate(customerId); + if (before.version() != request.expectedAccountVersion()) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试"); + if ("DEDUCTION".equals(type) && before.creditActive() && before.availableBalance().compareTo(amount) < 0) { + throw error("FIN-WALLET-INSUFFICIENT", "扣款金额不能超过当前可用余额"); + } + BigDecimal signed = "RECHARGE".equals(type) ? amount : amount.negate(); + WalletAccount afterAccount = "RECHARGE".equals(type) + ? rechargeAccount(before, amount) : deductAccount(before, amount); + updateAccount(customerId, afterAccount, before.version(), operatorId); + String sourceType = "RECHARGE".equals(type) ? "MANUAL_RECHARGE" : "MANUAL_DEDUCTION"; + WalletLedgerEntry entry = insertLedger(customerId, type, signed, afterAccount, + sourceType, clean(request.referenceNo(), 128), request.occurredDate() == null ? LocalDate.now() : request.occurredDate(), + adjustmentPaymentMethod, clean(request.referenceNo(), 128), operatorId, operatorName, request.remark()); + audit(customerId, "WALLET_ACCOUNT", customerId, "MANUAL_" + type, request.remark(), before, + account(customerId), operatorId, operatorName); + complete(customerId, operatorId, "WALLET_ACCOUNT_ADJUST", idempotencyKey, entry.ledgerId()); + return entry; + } + + @Transactional + public WalletLedgerEntry setCreditLimit(long customerId, long operatorId, String operatorName, + String idempotencyKey, CreditLimitRequest request) { + requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人"); + if (request == null || request.expectedAccountVersion() < 0) throw bad("授信额度请求无效"); + BigDecimal limit = nonNegativeMoney(request.creditLimit(), "授信额度"); + LocalDate expiresOn = request.creditExpiresOn(); + if (expiresOn == null || !expiresOn.isAfter(LocalDate.now())) { + throw bad("授信到期日必须晚于今天"); + } + Long replay = reserve(customerId, operatorId, "WALLET_CREDIT_LIMIT", idempotencyKey, hash(request)); + if (replay != null) return requiredLedger(replay, customerId); + WalletAccount before = accountForUpdate(customerId); + if (before.version() != request.expectedAccountVersion()) { + throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试"); + } + BigDecimal existingDebt = before.actualBalance().signum() < 0 + ? before.actualBalance().negate() : before.creditUsed(); + if (limit.compareTo(existingDebt.add(before.creditFrozen())) < 0) { + throw error("FIN-WALLET-CREDIT-IN-USE", "授信额度不能低于已用与已冻结授信合计"); + } + BigDecimal delta = limit.subtract(before.creditLimit()); + if (delta.signum() == 0 && expiresOn.equals(before.creditExpiresOn())) throw bad("授信额度和有效期均未发生变化"); + BigDecimal carriedDebt = existingDebt; + WalletAccount after = accountState(before.actualBalance().max(BigDecimal.ZERO), before.frozenBalance(), limit, + carriedDebt, before.creditFrozen(), expiresOn, before.version()); + updateAccount(customerId, after, before.version(), operatorId); + WalletLedgerEntry entry = insertLedger(customerId, "CREDIT_LIMIT", delta, after, + "CREDIT_LIMIT_ADJUSTMENT", null, LocalDate.now(), null, null, + operatorId, operatorName, request.remark()); + audit(customerId, "WALLET_ACCOUNT", customerId, "CREDIT_LIMIT_UPDATE", request.remark(), + before, account(customerId), operatorId, operatorName); + complete(customerId, operatorId, "WALLET_CREDIT_LIMIT", idempotencyKey, entry.ledgerId()); + return entry; + } + + @Transactional + public WalletHold freeze(long customerId, String idempotencyKey, WalletFreezeRequest request) { + requirePositive(customerId, "客户"); + if (request == null) throw bad("冻结请求无效"); + BigDecimal amount = money(request.amount()); + String businessType = businessType(request.businessType()); + String businessNo = businessNo(request.businessNo()); + String requestHash = hash(Map.of("customerId", customerId, "amount", amount, + "businessType", businessType, "businessNo", businessNo, + "remark", String.valueOf(request.remark()))); + Long replay = reserve(customerId, 0, "WALLET_HOLD_FREEZE", idempotencyKey, requestHash); + if (replay != null) return requiredHold(replay, customerId); + WalletHold existing = findHold(customerId, businessType, businessNo, false); + if (existing != null) { + if (existing.amount().compareTo(amount) != 0) { + throw error("FIN-IDEMPOTENCY-CONFLICT", "同一业务单号的冻结金额不一致"); + } + complete(customerId, 0, "WALLET_HOLD_FREEZE", idempotencyKey, existing.holdId()); + return withAccount(existing); + } + WalletAccount before = accountForUpdate(customerId); + if (before.availableBalance().compareTo(amount) < 0) { + throw error("FIN-WALLET-INSUFFICIENT", "可用余额和授信额度不足"); + } + BigDecimal actualAvailable = before.actualBalance() + .subtract(before.frozenBalance().subtract(before.creditFrozen())).max(BigDecimal.ZERO); + BigDecimal actualFrozen = amount.min(actualAvailable); + BigDecimal creditFrozen = amount.subtract(actualFrozen); + WalletAccount after = accountState(before.actualBalance(), before.frozenBalance().add(amount), + before.creditLimit(), before.creditUsed(), before.creditFrozen().add(creditFrozen), + before.creditExpiresOn(), before.version()); + updateAccount(customerId, after, before.version(), 0); + long holdId = insertHold(customerId, businessType, businessNo, amount, actualFrozen, creditFrozen, + clean(request.remark(), 500)); + WalletLedgerEntry ledger = insertLedger(customerId, "FREEZE", amount.negate(), after, + businessType, businessNo, LocalDate.now(), null, businessNo, 0, "业务系统", request.remark()); + jdbc.update("UPDATE fin_wallet_hold SET freeze_ledger_id=? WHERE id=?", ledger.ledgerId(), holdId); + audit(customerId, "WALLET_HOLD", holdId, "FREEZE", request.remark(), before, after, 0, "业务系统"); + complete(customerId, 0, "WALLET_HOLD_FREEZE", idempotencyKey, holdId); + return requiredHold(holdId, customerId); + } + + @Transactional + public WalletHold settle(long customerId, String businessType, String businessNo, + String idempotencyKey, WalletHoldActionRequest request) { + return completeHold(customerId, businessType, businessNo, idempotencyKey, request, true); + } + + @Transactional + public WalletHold release(long customerId, String businessType, String businessNo, + String idempotencyKey, WalletHoldActionRequest request) { + return completeHold(customerId, businessType, businessNo, idempotencyKey, request, false); + } + + private WalletHold completeHold(long customerId, String rawBusinessType, String rawBusinessNo, + String idempotencyKey, WalletHoldActionRequest request, boolean settle) { + requirePositive(customerId, "客户"); + String businessType = businessType(rawBusinessType); + String businessNo = businessNo(rawBusinessNo); + String operation = settle ? "WALLET_HOLD_SETTLE" : "WALLET_HOLD_RELEASE"; + String requestHash = hash(Map.of("customerId", customerId, "businessType", businessType, + "businessNo", businessNo, "remark", String.valueOf(request == null ? null : request.remark()))); + Long replay = reserve(customerId, 0, operation, idempotencyKey, requestHash); + if (replay != null) return requiredHold(replay, customerId); + WalletAccount before = accountForUpdate(customerId); + WalletHold hold = findHold(customerId, businessType, businessNo, true); + if (hold == null) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在"); + if (!"FROZEN".equals(hold.status())) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理"); + BigDecimal actual = before.actualBalance(); + BigDecimal creditUsed = before.creditUsed(); + if (settle && before.creditActive()) { + actual = actual.subtract(hold.actualFrozenAmount()); + creditUsed = creditUsed.add(hold.creditFrozenAmount()); + } else if (settle) { + actual = actual.subtract(hold.amount()); + } + BigDecimal frozen = before.frozenBalance().subtract(hold.amount()); + BigDecimal creditFrozen = before.creditFrozen().subtract(hold.creditFrozenAmount()); + WalletAccount after = accountState(actual, frozen, before.creditLimit(), creditUsed, + creditFrozen, before.creditExpiresOn(), before.version()); + updateAccount(customerId, after, before.version(), 0); + String action = settle ? "SETTLED" : "RELEASED"; + WalletLedgerEntry ledger = insertLedger(customerId, settle ? "CONSUMPTION" : "UNFREEZE", + settle ? hold.amount().negate() : hold.amount(), after, businessType, businessNo, + LocalDate.now(), null, businessNo, 0, "业务系统", request == null ? null : request.remark()); + jdbc.update("UPDATE fin_wallet_hold SET status=?,action_ledger_id=?,completed_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='FROZEN'", + action, ledger.ledgerId(), hold.holdId()); + audit(customerId, "WALLET_HOLD", hold.holdId(), action, + request == null ? null : request.remark(), before, after, 0, "业务系统"); + complete(customerId, 0, operation, idempotencyKey, hold.holdId()); + return requiredHold(hold.holdId(), customerId); + } + + public ResponseEntity voucher(long requestId) { + RechargeRequest request = requiredRecharge(requestId, null); + long customerId = beforeCustomerId(requestId); + return fileClient.download(internalToken, customerId, customerId, request.voucherFileId()); + } + + public ResponseEntity clientVoucher(long customerId, long requestId) { + RechargeRequest request = requiredRecharge(requestId, customerId); + return fileClient.download(internalToken, customerId, customerId, request.voucherFileId()); + } + + private WalletAccount accountForUpdate(long customerId) { + jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,created_by,updated_by) VALUES(?,'CNY',0,0,0,0,0,0,NULL,0,0,0)", + customerId); + return jdbc.queryForObject( + "SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE", + (rs, n) -> accountRow(rs), customerId); + } + + private void updateAccount(long customerId, WalletAccount account, int expectedVersion, long operatorId) { + int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,credit_limit=?,credit_used=?,credit_frozen=?,credit_expires_on=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?", + account.actualBalance(), account.frozenBalance(), account.availableBalance(), account.creditLimit(), + account.creditUsed(), account.creditFrozen(), account.creditExpiresOn(), operatorId, customerId, expectedVersion); + if (updated != 1) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试"); + } + + private WalletLedgerEntry insertLedger(long customerId, String changeType, BigDecimal amount, + WalletAccount account, + String sourceType, String sourceNo, LocalDate occurredDate, + String paymentMethod, String referenceNo, long operatorId, + String operatorName, String remark) { + String businessNo = numberGenerator.next("AC"); + KeyHolder holder = new GeneratedKeyHolder(); + jdbc.update(connection -> { + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,credit_limit_after,credit_used_after,available_credit_after,credit_expires_on_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + Statement.RETURN_GENERATED_KEYS); + statement.setLong(1, customerId); statement.setString(2, businessNo); statement.setString(3, changeType); + statement.setBigDecimal(4, amount); statement.setBigDecimal(5, account.actualBalance()); + statement.setBigDecimal(6, account.frozenBalance()); statement.setBigDecimal(7, account.availableBalance()); + statement.setBigDecimal(8, account.creditLimit()); statement.setBigDecimal(9, account.creditUsed()); + statement.setBigDecimal(10, account.availableCredit()); statement.setObject(11, account.creditExpiresOn()); + statement.setString(12, sourceType); statement.setString(13, sourceNo); statement.setObject(14, occurredDate); + statement.setString(15, paymentMethod); statement.setString(16, referenceNo); + statement.setLong(17, operatorId); statement.setString(18, clean(operatorName, 128)); + statement.setString(19, clean(remark, 500)); return statement; + }, holder); + return requiredLedger(generatedId(holder), customerId); + } + + private WalletLedgerEntry requiredLedger(long ledgerId, long customerId) { + List rows = jdbc.query("SELECT * FROM fin_wallet_ledger WHERE id=? AND customer_id=?", + (rs, n) -> ledgerRow(rs), ledgerId, customerId); + if (rows.isEmpty()) throw error("FIN-WALLET-404", "账户流水不存在"); + return rows.get(0); + } + + WalletAccount rechargeAccount(WalletAccount before, BigDecimal amount) { + BigDecimal repaidCredit = before.creditActive() ? amount.min(before.creditUsed()) : BigDecimal.ZERO; + return accountState(before.actualBalance().add(amount.subtract(repaidCredit)), before.frozenBalance(), + before.creditLimit(), before.creditUsed().subtract(repaidCredit), before.creditFrozen(), + before.creditExpiresOn(), before.version()); + } + + WalletAccount deductAccount(WalletAccount before, BigDecimal amount) { + if (!before.creditActive()) { + return accountState(before.actualBalance().subtract(amount), before.frozenBalance(), + before.creditLimit(), BigDecimal.ZERO, before.creditFrozen(), before.creditExpiresOn(), before.version()); + } + BigDecimal actualFrozen = before.frozenBalance().subtract(before.creditFrozen()); + BigDecimal actualAvailable = before.actualBalance().subtract(actualFrozen).max(BigDecimal.ZERO); + BigDecimal actualDeduction = amount.min(actualAvailable); + return accountState(before.actualBalance().subtract(actualDeduction), before.frozenBalance(), + before.creditLimit(), before.creditUsed().add(amount.subtract(actualDeduction)), + before.creditFrozen(), before.creditExpiresOn(), before.version()); + } + + WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit, + BigDecimal creditFrozen, LocalDate creditExpiresOn, int version) { + return accountState(actual, frozen, creditLimit, BigDecimal.ZERO, creditFrozen, creditExpiresOn, version); + } + + WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit, + BigDecimal creditUsed, BigDecimal creditFrozen, + LocalDate creditExpiresOn, int version) { + boolean creditActive = creditLimit.signum() > 0 && creditExpiresOn != null + && !creditExpiresOn.isBefore(LocalDate.now()); + if (!creditActive && creditUsed.signum() > 0) { + actual = actual.subtract(creditUsed); + creditUsed = BigDecimal.ZERO.setScale(2); + } + BigDecimal availableCredit = creditActive + ? creditLimit.subtract(creditUsed).subtract(creditFrozen) : BigDecimal.ZERO.setScale(2); + BigDecimal actualFrozen = frozen.subtract(creditFrozen); + BigDecimal available = creditActive + ? actual.subtract(actualFrozen).add(availableCredit) + : actual.subtract(frozen); + if (frozen.signum() < 0 || creditLimit.signum() < 0 || creditFrozen.signum() < 0 + || creditUsed.signum() < 0 || actualFrozen.signum() < 0 + || (creditActive && (actual.signum() < 0 || availableCredit.signum() < 0))) { + throw error("FIN-WALLET-409", "账户余额状态无效"); + } + return new WalletAccount("CNY", actual, frozen, available, creditLimit, creditUsed, + creditFrozen, availableCredit, creditExpiresOn, creditActive, version, null); + } + + private WalletAccount accountRow(java.sql.ResultSet rs) throws java.sql.SQLException { + BigDecimal creditLimit = rs.getBigDecimal("credit_limit"); + BigDecimal creditFrozen = rs.getBigDecimal("credit_frozen"); + LocalDate expiresOn = rs.getDate("credit_expires_on") == null ? null : rs.getDate("credit_expires_on").toLocalDate(); + WalletAccount calculated = accountState(rs.getBigDecimal("actual_balance"), rs.getBigDecimal("frozen_balance"), + creditLimit, rs.getBigDecimal("credit_used"), creditFrozen, expiresOn, rs.getInt("version")); + return new WalletAccount(calculated.currencyCode(), calculated.actualBalance(), calculated.frozenBalance(), + calculated.availableBalance(), calculated.creditLimit(), calculated.creditUsed(), calculated.creditFrozen(), + calculated.availableCredit(), calculated.creditExpiresOn(), calculated.creditActive(), calculated.version(), + timestamp(rs.getTimestamp("updated_at"))); + } + + private WalletAccount zeroAccount() { + BigDecimal zero = BigDecimal.ZERO.setScale(2); + return new WalletAccount("CNY", zero, zero, zero, zero, zero, + zero, zero, null, false, 0, null); + } + + private long insertHold(long customerId, String businessType, String businessNo, BigDecimal amount, + BigDecimal actualFrozen, BigDecimal creditFrozen, String remark) { + KeyHolder holder = new GeneratedKeyHolder(); + try { + jdbc.update(connection -> { + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO fin_wallet_hold(customer_id,business_type,business_no,amount,actual_frozen_amount,credit_frozen_amount,status,remark) VALUES(?,?,?,?,?,?,'FROZEN',?)", + Statement.RETURN_GENERATED_KEYS); + statement.setLong(1, customerId); statement.setString(2, businessType); + statement.setString(3, businessNo); statement.setBigDecimal(4, amount); + statement.setBigDecimal(5, actualFrozen); statement.setBigDecimal(6, creditFrozen); + statement.setString(7, remark); return statement; + }, holder); + } catch (DuplicateKeyException exception) { + throw error("FIN-IDEMPOTENCY-CONFLICT", "同一业务单号已存在资金冻结记录"); + } + return generatedId(holder); + } + + private WalletHold requiredHold(long holdId, long customerId) { + List rows = jdbc.query("SELECT * FROM fin_wallet_hold WHERE id=? AND customer_id=?", + (rs, n) -> holdRow(rs), holdId, customerId); + if (rows.isEmpty()) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在"); + return withAccount(rows.get(0)); + } + + private WalletHold findHold(long customerId, String businessType, String businessNo, boolean forUpdate) { + String sql = "SELECT * FROM fin_wallet_hold WHERE customer_id=? AND business_type=? AND business_no=?" + + (forUpdate ? " FOR UPDATE" : ""); + List rows = jdbc.query(sql, (rs, n) -> holdRow(rs), customerId, businessType, businessNo); + return rows.isEmpty() ? null : rows.get(0); + } + + private WalletHold withAccount(WalletHold hold) { + return new WalletHold(hold.holdId(), hold.customerId(), hold.businessType(), hold.businessNo(), + hold.amount(), hold.actualFrozenAmount(), hold.creditFrozenAmount(), hold.status(), + hold.freezeLedgerId(), hold.actionLedgerId(), account(hold.customerId()), hold.createdAt(), + hold.completedAt()); + } + + private WalletHold holdRow(java.sql.ResultSet rs) throws java.sql.SQLException { + long customerId = rs.getLong("customer_id"); + Number freezeLedger = (Number) rs.getObject("freeze_ledger_id"); + Number actionLedger = (Number) rs.getObject("action_ledger_id"); + return new WalletHold(rs.getLong("id"), customerId, rs.getString("business_type"), + rs.getString("business_no"), rs.getBigDecimal("amount"), rs.getBigDecimal("actual_frozen_amount"), + rs.getBigDecimal("credit_frozen_amount"), rs.getString("status"), + freezeLedger == null ? null : freezeLedger.longValue(), actionLedger == null ? null : actionLedger.longValue(), + null, timestamp(rs.getTimestamp("created_at")), timestamp(rs.getTimestamp("completed_at"))); + } + + private RechargeRequest requiredRecharge(long requestId, Long customerId) { + String sql = "SELECT * FROM fin_recharge_request WHERE id=?" + (customerId == null ? "" : " AND customer_id=?"); + Object[] args = customerId == null ? new Object[]{requestId} : new Object[]{requestId, customerId}; + List rows = jdbc.query(sql, (rs, n) -> rechargeRow(rs), args); + if (rows.isEmpty()) throw error("FIN-WALLET-404", "充值申请不存在"); + return rows.get(0); + } + + private long customerIdOf(long requestId) { return beforeCustomerId(requestId); } + private long beforeCustomerId(long requestId) { + Long value = jdbc.queryForObject("SELECT customer_id FROM fin_recharge_request WHERE id=?", Long.class, requestId); + if (value == null || value <= 0) throw error("FIN-WALLET-404", "充值申请不存在"); + return value; + } + + private Long reserve(long customerId, long operatorId, String operation, String key, String requestHash) { + if (!StringUtils.hasText(key) || key.trim().length() > 128) throw bad("Idempotency-Key不能为空且不能超过128字符"); + String normalizedKey = key.trim(); + List> existing = jdbc.queryForList( + "SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", + customerId, operatorId, operation, normalizedKey); + if (!existing.isEmpty()) return replay(existing.get(0), requestHash); + try { + jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')", + customerId, operatorId, operation, normalizedKey, requestHash); + return null; + } catch (DuplicateKeyException exception) { + Map row = jdbc.queryForMap( + "SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", + customerId, operatorId, operation, normalizedKey); + return replay(row, requestHash); + } + } + + private Long replay(Map row, String requestHash) { + if (!requestHash.equals(String.valueOf(row.get("request_hash")))) throw error("FIN-IDEMPOTENCY-CONFLICT", "同一幂等键的请求内容不一致"); + if (!"SUCCEEDED".equals(String.valueOf(row.get("status"))) || row.get("result_resource_id") == null) { + throw error("FIN-IDEMPOTENCY-PROCESSING", "相同请求正在处理中,请稍后重试"); + } + return ((Number) row.get("result_resource_id")).longValue(); + } + + private void complete(long customerId, long operatorId, String operation, String key, long resourceId) { + jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=?,error_code=NULL WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'", + resourceId, customerId, operatorId, operation, key.trim()); + } + + private void audit(long customerId, String resourceType, long resourceId, String action, String reason, + Object before, Object after, long operatorId, String operatorName) { + jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + customerId, resourceType, resourceId, action, clean(reason, 500), json(before), json(after), operatorId, + clean(operatorName, 128), org.slf4j.MDC.get("requestId"), org.slf4j.MDC.get("traceId")); + } + + private WalletLedgerEntry ledgerRow(java.sql.ResultSet rs) throws java.sql.SQLException { + return new WalletLedgerEntry(rs.getLong("id"), rs.getString("business_no"), rs.getString("change_type"), + rs.getBigDecimal("amount"), rs.getBigDecimal("actual_balance_after"), rs.getBigDecimal("frozen_balance_after"), + rs.getBigDecimal("available_balance_after"), rs.getBigDecimal("credit_limit_after"), + rs.getBigDecimal("credit_used_after"), rs.getBigDecimal("available_credit_after"), + rs.getObject("credit_expires_on_after", LocalDate.class), + rs.getString("source_type"), rs.getString("source_no"), + rs.getObject("occurred_date", LocalDate.class), rs.getString("payment_method"), rs.getString("reference_no"), + rs.getString("operator_name"), rs.getString("remark"), timestamp(rs.getTimestamp("created_at"))); + } + + private RechargeRequest rechargeRow(java.sql.ResultSet rs) throws java.sql.SQLException { + return new RechargeRequest(rs.getLong("id"), rs.getString("request_no"), rs.getBigDecimal("amount"), + rs.getString("payment_method"), rs.getLong("voucher_file_id"), rs.getString("voucher_file_name"), + rs.getString("status"), rs.getInt("version"), rs.getString("submitted_by_name"), + rs.getString("reviewed_by_name"), rs.getString("review_remark"), timestamp(rs.getTimestamp("created_at")), + timestamp(rs.getTimestamp("reviewed_at"))); + } + + private String hash(Object value) { + try { return sha256(objectMapper.writeValueAsBytes(value)); } + catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); } + } + private String fileHash(MultipartFile file) { + try { return sha256(file.getBytes()); } + catch (Exception exception) { throw bad("付款截图无法读取"); } + } + private static String sha256(byte[] bytes) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (Exception exception) { throw error("FIN_500", "审计快照生成失败"); } + } + + private static BigDecimal money(BigDecimal value) { + if (value == null || value.signum() <= 0 || value.scale() > 2 || value.compareTo(new BigDecimal("999999999999.99")) > 0) { + throw bad("金额必须大于0且最多保留两位小数"); + } + return value.setScale(2, RoundingMode.UNNECESSARY); + } + private static BigDecimal nonNegativeMoney(BigDecimal value, String label) { + if (value == null || value.signum() < 0 || value.scale() > 2 + || value.compareTo(new BigDecimal("999999999999.99")) > 0) { + throw bad(label + "不能为负数且最多保留两位小数"); + } + return value.setScale(2, RoundingMode.UNNECESSARY); + } + private static String businessType(String value) { + String normalized = upper(value); + if (!normalized.matches("[A-Z][A-Z0-9_]{1,31}")) throw bad("业务类型格式无效"); + return normalized; + } + private static String businessNo(String value) { + String normalized = clean(value, 128); + if (normalized == null) throw bad("业务单号不能为空"); + return normalized; + } + private static String paymentMethod(String value) { + String normalized = upper(value); + if (!PAYMENT_METHODS.contains(normalized)) throw bad("付款方式无效"); + return normalized; + } + private static void validateImage(MultipartFile file) { + if (file == null || file.isEmpty()) throw bad("请上传付款截图"); + if (!IMAGE_TYPES.contains(String.valueOf(file.getContentType()).toLowerCase(Locale.ROOT))) throw bad("付款截图仅支持 JPG 或 PNG"); + } + private static void validatePage(int page, int size) { if (page < 1 || size < 1 || size > 200) throw bad("分页参数无效"); } + private static void requirePositive(long value, String label) { if (value <= 0) throw bad(label + "标识无效"); } + private static long generatedId(KeyHolder holder) { + Number key = holder.getKey(); if (key == null || key.longValue() <= 0) throw error("FIN_500", "业务记录创建失败"); return key.longValue(); + } + private static String upper(String value) { return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); } + private static String blankToNull(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String clean(String value, int max) { + if (!StringUtils.hasText(value)) return null; String normalized = value.trim(); + if (normalized.length() > max) throw bad("文本长度不能超过" + max + "个字符"); return normalized; + } + private static LocalDateTime timestamp(Timestamp value) { return value == null ? null : value.toLocalDateTime(); } + private static FinanceEventException bad(String message) { return error("FIN-REQ-400", message); } + private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java b/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java new file mode 100644 index 000000000..07ce5c305 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java @@ -0,0 +1,106 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse; +import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.List; + +@Service +public class ReceivableStatisticsService { + private static final String CURRENCY = "CNY"; + private final JdbcTemplate jdbc; + + public ReceivableStatisticsService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public ReceivableStatisticsResponse query(YearMonth month, Long customerId, int page, int pageSize) { + validate(month, customerId, page, pageSize); + List all = rows(month, customerId); + Totals totals = totals(all); + int from = (int) Math.min((long) (page - 1) * pageSize, all.size()); + int to = Math.min(from + pageSize, all.size()); + return new ReceivableStatisticsResponse(month, CURRENCY, totals.receivable(), totals.received(), + totals.outstanding(), all.size(), page, pageSize, List.copyOf(all.subList(from, to))); + } + + public List exportRows(YearMonth month, Long customerId) { + validate(month, customerId, 1, 1); + List rows = rows(month, customerId); + totals(rows); + return rows; + } + + private List rows(YearMonth month, Long customerId) { + LocalDate from = month.atDay(1); + LocalDate to = month.plusMonths(1).atDay(1); + StringBuilder sql = new StringBuilder(""" + SELECT s.tenant_id, + COALESCE(p.customer_name_snapshot, MAX(s.customer_name_snapshot)) customer_name_snapshot, + SUM(s.total_amount) total_receivable, + SUM(s.paid_amount) total_received, + SUM(s.outstanding_amount) total_outstanding + FROM fin_settlement s + LEFT JOIN fin_customer_settlement_profile p + ON p.tenant_id=s.tenant_id AND p.status='ACTIVE' + WHERE s.direction='RECEIVABLE' + AND s.status<>'VOIDED' + AND s.currency_code='CNY' + AND s.generated_date>=? + AND s.generated_date args = new ArrayList<>(List.of(from, to)); + if (customerId != null) { + sql.append(" AND s.tenant_id=?"); + args.add(customerId); + } + sql.append(" GROUP BY s.tenant_id,p.customer_name_snapshot ORDER BY customer_name_snapshot,s.tenant_id"); + return jdbc.query(sql.toString(), (rs, rowNum) -> checked(new ReceivableStatisticsRow( + rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), CURRENCY, + rs.getBigDecimal("total_receivable"), rs.getBigDecimal("total_received"), + rs.getBigDecimal("total_outstanding"))), args.toArray()); + } + + private static Totals totals(List rows) { + BigDecimal receivable = BigDecimal.ZERO; + BigDecimal received = BigDecimal.ZERO; + BigDecimal outstanding = BigDecimal.ZERO; + for (ReceivableStatisticsRow row : rows) { + checked(row); + receivable = receivable.add(row.totalReceivable()); + received = received.add(row.totalReceived()); + outstanding = outstanding.add(row.totalOutstanding()); + } + if (receivable.compareTo(received.add(outstanding)) != 0) inconsistent(); + return new Totals(receivable, received, outstanding); + } + + private static ReceivableStatisticsRow checked(ReceivableStatisticsRow row) { + if (row.customerId() <= 0 || row.customerName() == null || row.customerName().isBlank() + || row.totalReceivable() == null || row.totalReceived() == null || row.totalOutstanding() == null + || row.totalReceivable().signum() < 0 || row.totalReceived().signum() < 0 + || row.totalOutstanding().signum() < 0 + || row.totalReceivable().compareTo(row.totalReceived().add(row.totalOutstanding())) != 0) { + inconsistent(); + } + return row; + } + + private static void validate(YearMonth month, Long customerId, int page, int pageSize) { + if (month == null) throw new FinanceEventException("FIN-STAT-400", "统计月份不能为空"); + if (customerId != null && customerId <= 0) throw new FinanceEventException("FIN-STAT-400", "客户ID必须为正整数"); + if (page <= 0 || pageSize <= 0 || pageSize > 500) throw new FinanceEventException("FIN-STAT-400", "分页参数无效"); + } + + private static void inconsistent() { + throw new FinanceEventException("FIN-STAT-001", "应收统计金额勾稽异常,请核对正式结算数据"); + } + + private record Totals(BigDecimal receivable, BigDecimal received, BigDecimal outstanding) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java new file mode 100644 index 000000000..7311d5375 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -0,0 +1,563 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest; +import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest; +import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationResult; +import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest; +import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory; +import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail; +import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine; +import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.MDC; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +public class ReconciliationStatementService { + private static final Set MODES = Set.of("BY_CUTOFF", "SELECTED"); + private static final Set ACTIONS = Set.of("SUBMIT", "APPROVE", "REJECT", "ISSUE", "ADJUST", "VOID", "REOPEN"); + private static final Set REASON_REQUIRED_ACTIONS = Set.of("REJECT", "ADJUST", "VOID", "REOPEN"); + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final FinanceDocumentNumberGenerator documentNumbers; + + public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) { + this(jdbc, objectMapper, new FinanceDocumentNumberGenerator(jdbc)); + } + + @Autowired + public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper, + FinanceDocumentNumberGenerator documentNumbers) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.documentNumbers = documentNumbers; + } + + @Transactional + public ReconciliationDetail create(long operatorId, String key, CreateReconciliationRequest request) { + validateCreate(request); + Long replay = reserve(request.customerId(), operatorId, "RECONCILIATION_CREATE", key, + "POST", "/api/platform/finance/v1/statements", request); + if (replay != null) return detail(request.customerId(), replay); + + CustomerProfile customer = customer(request.customerId()); + long subjectId = activeSubject(); + List sources = eligibleSources(request); + if (sources.isEmpty()) { + throw error("FIN_409", "当前客户在所选账期内没有可生成的CNY应收费用,请调整账期或选择其他客户后重试"); + } + BigDecimal total = sources.stream().map(Source::amount).reduce(BigDecimal.ZERO, BigDecimal::add); + LocalDate dueDate = request.periodEnd().plusDays(customer.creditDays()); + String statementNo = documentNumbers.next("REC"); + + KeyHolder holder = new GeneratedKeyHolder(); + jdbc.update(connection -> { + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,remark,created_by,created_by_name,updated_by,updated_by_name) VALUES(?,?,?,?,? ,?,?,'DRAFT',?,0,?,?,?,?,?,?)", + Statement.RETURN_GENERATED_KEYS); + statement.setLong(1, request.customerId()); + statement.setString(2, statementNo); + statement.setLong(3, subjectId); + statement.setString(4, customer.name()); + statement.setString(5, "CNY"); + statement.setObject(6, request.periodEnd()); + statement.setObject(7, dueDate); + statement.setBigDecimal(8, total); + statement.setBigDecimal(9, total); + statement.setString(10, request.remark()); + statement.setLong(11, operatorId); + statement.setString(12, FinanceOperatorSnapshot.currentName(operatorId)); + statement.setLong(13, operatorId); + statement.setString(14, FinanceOperatorSnapshot.currentName(operatorId)); + return statement; + }, holder); + if (holder.getKey() == null) throw error("FIN_001", "对账单主键生成失败"); + long statementId = holder.getKey().longValue(); + for (Source source : sources) { + jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,source_no,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code) VALUES(?,?,?,?,?,?,?,?,?,?,?,'CNY')", + request.customerId(), statementId, "FIRSTLEG_FEE", source.id(), source.sourceVersion(), + source.sourceNo(), source.orderId(), source.inboundId(), source.feeItemCode(), source.feeItemName(), source.amount()); + int locked = jdbc.update("UPDATE fin_fee_source SET status='LOCKED' WHERE id=? AND customer_id=? AND status='AVAILABLE'", + source.id(), request.customerId()); + if (locked != 1) throw error("FIN_409", "费用来源已被其他对账单占用"); + } + CreationMetadata metadata = new CreationMetadata(request.generationMode().toUpperCase(Locale.ROOT), + request.periodStart(), request.periodEnd()); + audit(request.customerId(), statementId, "RECONCILIATION_CREATED", request.remark(), null, metadata, operatorId); + complete(request.customerId(), operatorId, "RECONCILIATION_CREATE", key, statementId); + return detail(request.customerId(), statementId); + } + + @Transactional + public ReconciliationDetail action(long operatorId, long statementId, String key, ReconciliationActionRequest request) { + validateAction(statementId, request); + Bill before = bill(statementId); + Long replay = reserve(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, + "POST", "/api/platform/finance/v1/reconciliation-statements/" + statementId + "/actions", request); + if (replay != null) return detail(before.customerId(), replay); + + String action = request.action().toUpperCase(Locale.ROOT); + if ("ADJUST".equals(action)) { + return adjust(operatorId, statementId, key, request, before); + } + Transition transition = transition(action, before.status()); + if ("VOID".equals(action)) requireVoidable(before.customerId(), statementId); + if ("REOPEN".equals(action) && "VOIDED".equals(before.status())) reacquireSources(before.customerId(), statementId); + snapshot(before, request.reason(), operatorId); + int changed = jdbc.update("UPDATE fin_bill SET status=?,issued_at=CASE WHEN ?='ISSUED' THEN CURRENT_TIMESTAMP(3) ELSE issued_at END,issued_by=CASE WHEN ?='ISSUED' THEN ? ELSE issued_by END,voided_at=CASE WHEN ?='VOIDED' THEN CURRENT_TIMESTAMP(3) ELSE voided_at END,voided_by=CASE WHEN ?='VOIDED' THEN ? ELSE voided_by END,void_reason=CASE WHEN ?='VOIDED' THEN ? ELSE void_reason END,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status=? AND version=?", + transition.to(), transition.to(), transition.to(), operatorId, transition.to(), transition.to(), operatorId, + transition.to(), request.reason(), operatorId, FinanceOperatorSnapshot.currentName(operatorId), + statementId, before.customerId(), transition.from(), request.expectedVersion()); + if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化"); + if ("ISSUE".equals(action)) { + jdbc.update("UPDATE fin_bill SET customer_confirmation_status='PENDING_CONFIRMATION',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?", + statementId, before.customerId()); + jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='BILLED' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status='LOCKED'", + before.customerId(), statementId, before.customerId()); + } else if ("VOID".equals(action)) { + jdbc.update("UPDATE fin_bill SET customer_confirmation_status='NOT_REQUIRED',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?", + statementId, before.customerId()); + jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='AVAILABLE' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status IN ('LOCKED','BILLED')", + before.customerId(), statementId, before.customerId()); + } else if ("REOPEN".equals(action)) { + jdbc.update("UPDATE fin_bill SET customer_confirmation_status='NOT_REQUIRED',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?", + statementId, before.customerId()); + } + ReconciliationDetail after = detail(before.customerId(), statementId); + audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId); + complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId); + return after; + } + + private ReconciliationDetail adjust(long operatorId, long statementId, String key, + ReconciliationActionRequest request, Bill before) { + if (!"ISSUED".equals(before.status())) throw error("FIN_409", "仅已出账对账单允许调整"); + snapshot(before, request.reason(), operatorId); + for (var line : request.adjustmentLines()) { + int changed = jdbc.update("UPDATE fin_bill_line SET amount=?,adjustment_reason=? WHERE id=? AND bill_id=? AND tenant_id=?", + line.adjustedAmount(), line.reason(), line.statementLineId(), statementId, before.customerId()); + if (changed != 1) throw error("FIN_404", "调整行不存在或不属于该对账单"); + } + BigDecimal total = jdbc.queryForObject( + "SELECT COALESCE(SUM(amount),0) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?", + BigDecimal.class, before.customerId(), statementId); + if (total == null) total = BigDecimal.ZERO; + if (total.compareTo(before.received()) < 0) throw error("FIN_409", "调整后金额不得低于已收金额"); + int changed = jdbc.update("UPDATE fin_bill SET total_amount=?,outstanding_amount=?-received_amount,customer_confirmation_status='PENDING_CONFIRMATION',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status='ISSUED' AND version=?", + total, total, operatorId, FinanceOperatorSnapshot.currentName(operatorId), statementId, + before.customerId(), request.expectedVersion()); + if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化"); + ReconciliationDetail after = detail(before.customerId(), statementId); + audit(before.customerId(), statementId, "RECONCILIATION_ADJUST", request.reason(), before, after, operatorId); + complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId); + return after; + } + + public ReconciliationDetail detail(long customerId, long statementId) { + Bill bill = bill(customerId, statementId); + CreationMetadata metadata = creationMetadata(customerId, statementId); + List lines = jdbc.query( + "SELECT f.id,l.id statement_line_id,f.source_type,f.source_no,f.source_fee_id,f.source_fee_version,f.source_hash,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id," + + "JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) inbound_code,JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) container_no," + + "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d') business_date," + + "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.quantity')) AS DECIMAL(20,6)) quantity," + + "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.unitPrice')) AS DECIMAL(20,6)) unit_price," + + "f.fee_item_code,f.fee_item_name,f.amount source_amount,l.amount statement_amount,l.adjustment_reason,f.currency_code,f.status " + + "FROM fin_bill_line l JOIN fin_fee_source f ON f.id=l.source_id AND f.customer_id=l.tenant_id WHERE l.tenant_id=? AND l.bill_id=? ORDER BY l.id", + (rs, row) -> line(rs), customerId, statementId); + List payments = jdbc.query( + "SELECT r.id,r.received_at,rb.applied_amount,r.payment_method,r.voucher_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.received_at,r.id", + (rs, row) -> payment(rs), customerId, statementId); + List revisions = jdbc.query( + "SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND action<>'RECONCILIATION_CUSTOMER_CONFIRM' AND before_data IS NOT NULL ORDER BY created_at,id", + (rs, row) -> revision(rs, row + 1), customerId, statementId); + return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(), + metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(), + bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(), + bill.customerConfirmationStatus(), bill.customerConfirmedBy(), bill.customerConfirmedByName(), + bill.customerConfirmedAt(), + bill.createdBy(), bill.createdByName(), bill.createdAt(), bill.updatedBy(), bill.updatedByName(), + bill.updatedAt(), bill.remark(), lines, payments, revisions); + } + + @Transactional + public CustomerConfirmationResult confirmForCustomer(long customerId, long operatorId, long statementId, + String key, CustomerConfirmationRequest request) { + if (customerId <= 0 || operatorId <= 0 || statementId <= 0 || request == null || request.expectedVersion() < 0) { + throw error("FIN_400", "客户确认请求无效"); + } + Bill before = bill(customerId, statementId); + Long replay = reserve(customerId, operatorId, "RECONCILIATION_CUSTOMER_CONFIRM", key, + "POST", "/api/client/finance/v1/statements/" + statementId + "/confirm", request); + if (replay != null) return customerConfirmation(customerId, replay); + if (!Set.of("ISSUED", "PARTIALLY_PAID", "PAID").contains(before.status())) { + throw error("FIN_409", "仅已出账对账单允许客户确认"); + } + if (!"PENDING_CONFIRMATION".equals(before.customerConfirmationStatus())) { + throw error("FIN_409", "对账单已确认或当前无需确认"); + } + String operatorName = FinanceOperatorSnapshot.requiredName(operatorId); + int changed = jdbc.update("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED',customer_confirmed_at=CURRENT_TIMESTAMP(3),customer_confirmed_by=?,customer_confirmed_by_name=?,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND customer_confirmation_status='PENDING_CONFIRMATION' AND version=?", + operatorId, operatorName, operatorId, operatorName, statementId, customerId, request.expectedVersion()); + if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化"); + CustomerConfirmationResult after = customerConfirmation(customerId, statementId); + audit(customerId, statementId, "RECONCILIATION_CUSTOMER_CONFIRM", null, before, after, operatorId); + complete(customerId, operatorId, "RECONCILIATION_CUSTOMER_CONFIRM", key, statementId); + return after; + } + + private CustomerConfirmationResult customerConfirmation(long customerId, long statementId) { + Bill current = bill(customerId, statementId); + if (!"CONFIRMED".equals(current.customerConfirmationStatus()) || current.customerConfirmedBy() == null + || current.customerConfirmedAt() == null) { + throw error("FIN_409", "对账单尚未完成客户确认"); + } + return new CustomerConfirmationResult(current.id(), current.customerConfirmationStatus(), + current.customerConfirmedBy(), current.customerConfirmedByName(), current.customerConfirmedAt(), current.version()); + } + + public ReconciliationDetail detailForPlatform(long statementId) { + return detail(bill(statementId).customerId(), statementId); + } + + private List eligibleSources(CreateReconciliationRequest request) { + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT id,source_fee_id,source_fee_version,source_type,source_no,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount FROM fin_fee_source WHERE customer_id=? AND direction='RECEIVABLE' AND status='AVAILABLE' AND currency_code='CNY' AND review_status='APPROVED' AND amount>0 AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.publicationStatus'))='PUBLISHED' AND STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.businessDate')),'%Y-%m-%d') BETWEEN ? AND ?"); + args.add(request.customerId()); args.add(request.periodStart()); args.add(request.periodEnd()); + if ("SELECTED".equalsIgnoreCase(request.generationMode())) { + List> selected = jdbc.queryForList( + "SELECT id,customer_id FROM fin_fee_source WHERE id IN (" + + String.join(",", java.util.Collections.nCopies(request.feeSourceIds().size(), "?")) + ") FOR UPDATE", + request.feeSourceIds().toArray()); + if (selected.size() != request.feeSourceIds().size() + || selected.stream().anyMatch(row -> number(row, "customer_id").longValue() != request.customerId())) { + throw error("FIN_404", "费用来源不存在或不属于客户"); + } + sql.append(" AND id IN ("); + for (int index = 0; index < request.feeSourceIds().size(); index++) { + if (index > 0) sql.append(','); + sql.append('?'); args.add(request.feeSourceIds().get(index)); + } + sql.append(')'); + } + sql.append(" ORDER BY id FOR UPDATE"); + List rows = jdbc.query(sql.toString(), (rs, row) -> source(rs), args.toArray()); + if ("SELECTED".equalsIgnoreCase(request.generationMode())) { + Set actual = new HashSet<>(rows.stream().map(Source::id).toList()); + if (!actual.equals(new HashSet<>(request.feeSourceIds()))) { + throw error("FIN_409", "所选费用来源不存在、跨客户或不符合结算条件"); + } + } + return rows; + } + + private CustomerProfile customer(long customerId) { + List rows = jdbc.query("SELECT customer_name_snapshot,credit_days FROM fin_customer_settlement_profile WHERE tenant_id=? AND status='ACTIVE'", + (rs, row) -> new CustomerProfile(rs.getString("customer_name_snapshot"), rs.getInt("credit_days")), customerId); + return rows.isEmpty() ? new CustomerProfile("客户" + customerId, 30) : rows.get(0); + } + + private long activeSubject() { + List rows = jdbc.query("SELECT id FROM fin_subject WHERE status='ACTIVE' ORDER BY id LIMIT 1", (rs, row) -> rs.getLong(1)); + if (rows.isEmpty()) throw error("FIN_409", "平台财务主体未配置"); + return rows.get(0); + } + + private Bill bill(long statementId) { + List rows = jdbc.query("SELECT b.*,COALESCE(p.credit_days,30) credit_days FROM fin_bill b LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=b.tenant_id WHERE b.id=?", + (rs, row) -> bill(rs), statementId); + if (rows.isEmpty()) throw error("FIN_404", "对账单不存在"); + return rows.get(0); + } + + private Bill bill(long customerId, long statementId) { + List rows = jdbc.query("SELECT b.*,COALESCE(p.credit_days,30) credit_days FROM fin_bill b LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=b.tenant_id WHERE b.id=? AND b.tenant_id=?", + (rs, row) -> bill(rs), statementId, customerId); + if (rows.isEmpty()) throw error("FIN_404", "对账单不存在或不属于客户"); + return rows.get(0); + } + + private CreationMetadata creationMetadata(long customerId, long statementId) { + List rows = jdbc.query("SELECT after_data FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND action='RECONCILIATION_CREATED' ORDER BY id LIMIT 1", + (rs, row) -> rs.getString(1), customerId, statementId); + if (rows.isEmpty()) throw error("FIN_001", "对账单期间审计快照缺失"); + try { + JsonNode node = objectMapper.readTree(rows.get(0)); + return new CreationMetadata(node.path("generationMode").asText(), LocalDate.parse(node.path("periodStart").asText()), LocalDate.parse(node.path("periodEnd").asText())); + } catch (Exception exception) { + throw error("FIN_001", "对账单期间审计快照损坏"); + } + } + + private void snapshot(Bill bill, String reason, long operatorId) { + List> lines = jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id", bill.customerId(), bill.id()); + jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name) VALUES(?,?,?,?,?,?,?,?)", + bill.customerId(), bill.id(), bill.version(), reason, json(bill), json(lines), operatorId, + FinanceOperatorSnapshot.currentName(operatorId)); + } + + private void requireVoidable(long customerId, long statementId) { + Long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_receipt_bill WHERE tenant_id=? AND bill_id=?", Long.class, customerId, statementId); + if (count != null && count > 0) throw error("FIN_409", "已有收款记录的对账单不可作废"); + count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND reconciliation_statement_id=? AND status<>'VOIDED'", + Long.class, customerId, statementId); + if (count != null && count > 0) throw error("FIN_409", "已有有效结算单的对账单不可作废"); + } + + private void reacquireSources(long customerId, long statementId) { + Integer expected = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?", + Integer.class, customerId, statementId); + int changed = jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='LOCKED' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status='AVAILABLE'", + customerId, statementId, customerId); + if (expected == null || expected == 0 || changed != expected) throw error("FIN_409", "原费用来源已被占用,无法重开对账单"); + } + + private Long reserve(long customerId, long operatorId, String operation, String key, + String method, String path, Object body) { + if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN_400", "必须提供有效的Idempotency-Key"); + String requestHash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body)); + List> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id,updated_at FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", + customerId, operatorId, operation, key); + if (!rows.isEmpty()) { + Map row = rows.get(0); + if (!requestHash.equals(row.get("request_hash"))) throw error("FIN_409", "Idempotency-Key已用于其他请求"); + if ("SUCCEEDED".equals(row.get("status")) && row.get("result_resource_id") != null) return ((Number) row.get("result_resource_id")).longValue(); + if ("FAILED".equals(row.get("status")) && jdbc.update("UPDATE fin_idempotency_record SET status='PROCESSING',error_code=NULL WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='FAILED'", + customerId, operatorId, operation, key) == 1) return null; + LocalDateTime updatedAt = localTime(row.get("updated_at")); + if ("PROCESSING".equals(row.get("status")) && updatedAt != null && updatedAt.isBefore(LocalDateTime.now().minusMinutes(5)) + && jdbc.update("UPDATE fin_idempotency_record SET updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING' AND updated_at=?", + customerId, operatorId, operation, key, updatedAt) == 1) return null; + throw error("FIN_409", "相同请求正在处理中"); + } + jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')", + customerId, operatorId, operation, key, requestHash); + return null; + } + + private void complete(long customerId, long operatorId, String operation, String key, long resourceId) { + int changed = jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=? WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'", + resourceId, customerId, operatorId, operation, key); + if (changed != 1) throw error("FIN_409", "幂等请求完成状态冲突"); + } + + private void audit(long customerId, long statementId, String action, String reason, + Object before, Object after, long operatorId) { + jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + customerId, "RECONCILIATION_STATEMENT", statementId, action, reason, before == null ? null : json(before), + after == null ? null : json(after), operatorId, FinanceOperatorSnapshot.requiredName(operatorId), + MDC.get("requestId"), MDC.get("traceId")); + } + + private static void validateCreate(CreateReconciliationRequest request) { + if (request == null || request.customerId() <= 0 || request.periodStart() == null || request.periodEnd() == null + || request.periodStart().isAfter(request.periodEnd())) throw error("FIN_400", "对账单客户和期间无效"); + if (!"CNY".equals(request.currency())) throw error("FIN_400", "一期仅支持CNY"); + String mode = request.generationMode() == null ? "" : request.generationMode().toUpperCase(Locale.ROOT); + if (!MODES.contains(mode)) throw error("FIN_400", "对账单生成方式无效"); + if (request.remark() != null && request.remark().length() > 500) throw error("FIN_400", "备注不能超过500个字符"); + if ("SELECTED".equals(mode)) { + if (request.feeSourceIds() == null || request.feeSourceIds().isEmpty()) throw error("FIN_400", "勾选生成必须选择费用来源"); + if (request.feeSourceIds().stream().anyMatch(id -> id == null || id <= 0) + || new HashSet<>(request.feeSourceIds()).size() != request.feeSourceIds().size()) { + throw error("FIN_400", "费用来源ID无效或重复"); + } + } else if (request.feeSourceIds() != null && !request.feeSourceIds().isEmpty()) { + throw error("FIN_400", "按截止期生成不能同时指定费用来源"); + } + } + + private static void validateAction(long statementId, ReconciliationActionRequest request) { + if (statementId <= 0 || request == null || request.expectedVersion() < 0) throw error("FIN_400", "对账单动作请求无效"); + if (request.action() == null || !ACTIONS.contains(request.action().toUpperCase(Locale.ROOT))) throw error("FIN_400", "对账单动作无效"); + String action = request.action().toUpperCase(Locale.ROOT); + if (request.reason() != null && request.reason().length() > 500) throw error("FIN_400", "原因不能超过500个字符"); + if (REASON_REQUIRED_ACTIONS.contains(action) && !StringUtils.hasText(request.reason())) throw error("FIN_400", "该动作必须填写原因"); + List lines = request.adjustmentLines(); + if ("ADJUST".equals(action)) { + if (lines == null || lines.isEmpty()) throw error("FIN_400", "调整必须包含至少一条明细"); + Set ids = new HashSet<>(); + for (var line : lines) { + if (line == null || line.statementLineId() <= 0 || line.adjustedAmount() == null + || line.adjustedAmount().signum() <= 0 || !StringUtils.hasText(line.reason()) + || line.reason().length() > 500 || !ids.add(line.statementLineId())) { + throw error("FIN_400", "调整明细无效或重复"); + } + } + } else if (lines != null && !lines.isEmpty()) { + throw error("FIN_400", "非调整动作不能提交调整明细"); + } + } + + private static Transition transition(String action, String status) { + return switch (action) { + case "SUBMIT" -> requireTransition(status, "DRAFT", "PENDING_REVIEW"); + case "APPROVE" -> requireTransition(status, "PENDING_REVIEW", "APPROVED"); + case "REJECT" -> requireTransition(status, "PENDING_REVIEW", "REJECTED"); + case "ISSUE" -> requireTransition(status, "APPROVED", "ISSUED"); + case "REOPEN" -> { + if (!Set.of("REJECTED", "VOIDED").contains(status)) throw error("FIN_409", "当前状态不允许重开"); + yield new Transition(status, "DRAFT"); + } + case "VOID" -> { + if (!Set.of("DRAFT", "PENDING_REVIEW", "APPROVED", "ISSUED").contains(status)) throw error("FIN_409", "当前状态不允许作废"); + yield new Transition(status, "VOIDED"); + } + default -> throw error("FIN_400", "对账单动作无效"); + }; + } + + private static Transition requireTransition(String actual, String expected, String target) { + if (!expected.equals(actual)) throw error("FIN_409", "当前状态不允许执行该动作"); + return new Transition(expected, target); + } + + private static String reconciliationStatus(String billStatus) { + return billStatus; + } + + private static Number number(Map row, String key) { + Object value = row.get(key); + if (value instanceof Number number) return number; + if (value == null) throw error("FIN_001", "财务字段缺失:" + key); + return new BigDecimal(value.toString()); + } + + private static Source source(ResultSet rs) throws SQLException { + return new Source(rs.getLong("id"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), + rs.getString("source_type"), rs.getString("source_no"), nullableLong(rs, "order_id"), + nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("fee_item_code"), + rs.getString("fee_item_name"), rs.getBigDecimal("amount")); + } + + private static ReconciliationLine line(ResultSet rs) throws SQLException { + return new ReconciliationLine(rs.getLong("statement_line_id"), rs.getLong("id"), + rs.getInt("source_fee_version"), rs.getString("source_hash"), rs.getString("direction"), + rs.getString("source_type"), rs.getString("source_no"), nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), + nullableLong(rs, "container_id"), rs.getString("inbound_code"), rs.getString("container_no"), + rs.getDate("business_date") == null ? null : rs.getDate("business_date").toLocalDate(), + rs.getBigDecimal("quantity"), rs.getBigDecimal("unit_price"), rs.getString("fee_item_name"), rs.getBigDecimal("source_amount"), + rs.getBigDecimal("statement_amount"), rs.getString("currency_code"), rs.getString("adjustment_reason")); + } + + private PaymentHistory payment(ResultSet rs) throws SQLException { + Timestamp received = rs.getTimestamp("received_at"); + return new PaymentHistory(rs.getLong("id"), "RECEIPT", received.toLocalDateTime().toLocalDate(), + rs.getBigDecimal("applied_amount"), rs.getString("payment_method"), rs.getString("voucher_no"), + stringList(rs.getString("voucher_file_ids")), rs.getLong("created_by"), rs.getString("created_by_name"), + rs.getTimestamp("created_at").toLocalDateTime(), rs.getString("remark")); + } + + private List stringList(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { + JsonNode node = objectMapper.readTree(value); + if (!node.isArray()) throw error("FIN_001", "收款凭证快照损坏"); + List result = new ArrayList<>(); + node.forEach(item -> { if (item.isTextual() && StringUtils.hasText(item.asText())) result.add(item.asText()); }); + return List.copyOf(result); + } catch (FinanceEventException exception) { + throw exception; + } catch (Exception exception) { + throw error("FIN_001", "收款凭证快照损坏"); + } + } + + private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException { + return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data")), + amountFromJson(rs.getString("after_data")), rs.getString("reason"), rs.getLong("operator_id"), + rs.getString("operator_name_snapshot"), + rs.getTimestamp("created_at").toLocalDateTime()); + } + + private BigDecimal amountFromJson(String value) { + if (!StringUtils.hasText(value)) return BigDecimal.ZERO; + try { + JsonNode node = objectMapper.readTree(value); + JsonNode amount = node.has("totalAmount") ? node.get("totalAmount") : node.get("total"); + return amount == null || amount.isNull() ? BigDecimal.ZERO : amount.decimalValue(); + } catch (Exception exception) { + throw error("FIN_001", "对账单修订快照损坏"); + } + } + + private static Bill bill(ResultSet rs) throws SQLException { + return new Bill(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), + rs.getString("customer_name_snapshot"), rs.getString("status"), rs.getBigDecimal("total_amount"), + rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), rs.getInt("version"), + rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"), + rs.getString("customer_confirmed_by_name"), timestamp(rs, "customer_confirmed_at"), + rs.getInt("credit_days"), rs.getString("remark"), rs.getLong("created_by"), + rs.getString("created_by_name"), rs.getTimestamp("created_at").toLocalDateTime(), rs.getLong("updated_by"), + rs.getString("updated_by_name"), + rs.getTimestamp("updated_at").toLocalDateTime()); + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); } + } + + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); } + } + + private static LocalDateTime localTime(Object value) { + if (value instanceof LocalDateTime dateTime) return dateTime; + if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime(); + return null; + } + + private static Long nullableLong(ResultSet rs, String column) throws SQLException { + long value = rs.getLong(column); + return rs.wasNull() ? null : value; + } + + private static LocalDateTime timestamp(ResultSet rs, String column) throws SQLException { + Timestamp value = rs.getTimestamp(column); + return value == null ? null : value.toLocalDateTime(); + } + + private static FinanceEventException error(String code, String message) { + return new FinanceEventException(code, message); + } + + private record CustomerProfile(String name, int creditDays) {} + private record CreationMetadata(String generationMode, LocalDate periodStart, LocalDate periodEnd) {} + private record Transition(String from, String to) {} + private record Source(long id, long sourceFeeId, int sourceVersion, String sourceType, String sourceNo, + Long orderId, Long inboundId, Long containerId, String feeItemCode, + String feeItemName, BigDecimal amount) {} + private record Bill(long id, long customerId, String number, String customerName, String status, + BigDecimal total, BigDecimal received, BigDecimal outstanding, int version, + String customerConfirmationStatus, Long customerConfirmedBy, + String customerConfirmedByName, LocalDateTime customerConfirmedAt, + int creditDays, String remark, long createdBy, String createdByName, + LocalDateTime createdAt, long updatedBy, String updatedByName, LocalDateTime updatedAt) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java new file mode 100644 index 000000000..843b3d644 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java @@ -0,0 +1,147 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventEnvelopeValidator; +import com.baiyue.common.event.EventIdentifiers; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.RegionCode; +import com.baiyue.common.event.WalletCommandCompletedV1; +import com.baiyue.common.event.WalletCommandRequestedV1; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class WalletCommandEventService { + private static final Set OPERATIONS = Set.of("FREEZE", "SETTLE", "RELEASE"); + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final FinanceWalletService walletService; + private final TransactionTemplate transactions; + + public String consume(EventEnvelope envelope) { + WalletCommandRequestedV1 command = validate(envelope); + String existing = existingHash(envelope.eventId()); + if (existing != null && !existing.equals(envelope.payloadHash())) { + throw new FinanceEventException("FIN-EVT-002", "同一事件ID对应不同资金指令"); + } + if (existing == null) reserve(envelope); + if (resultExists(envelope.eventId())) return envelope.eventId(); + + WalletCommandCompletedV1 result; + try { + WalletHold hold = transactions.execute(status -> execute(command)); + result = completed(envelope.eventId(), command, "SUCCEEDED", hold.status(), hold.amount(), null, null); + } catch (FinanceEventException exception) { + result = completed(envelope.eventId(), command, "FAILED", null, command.amount(), exception.code(), exception.getMessage()); + } catch (RuntimeException exception) { + result = completed(envelope.eventId(), command, "FAILED", null, command.amount(), "FIN-WALLET-COMMAND-FAILED", safe(exception.getMessage())); + } + WalletCommandCompletedV1 finalResult = result; + transactions.executeWithoutResult(status -> finalizeResult(envelope, finalResult)); + return envelope.eventId(); + } + + private WalletHold execute(WalletCommandRequestedV1 command) { + WalletHold hold = switch (command.operation()) { + case "FREEZE" -> walletService.freeze(command.customerId(), command.idempotencyKey(), + new WalletFreezeRequest(command.amount(), command.businessType(), command.businessNo(), command.remark())); + case "SETTLE" -> walletService.settle(command.customerId(), command.businessType(), command.businessNo(), + command.idempotencyKey(), new WalletHoldActionRequest(command.remark())); + case "RELEASE" -> walletService.release(command.customerId(), command.businessType(), command.businessNo(), + command.idempotencyKey(), new WalletHoldActionRequest(command.remark())); + default -> throw new FinanceEventException("FIN-EVT-001", "资金指令操作无效"); + }; + if ("SETTLE".equals(command.operation())) { + jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE h.id=? AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND f.amount=h.amount", + hold.holdId()); + } + return hold; + } + + private WalletCommandRequestedV1 validate(EventEnvelope envelope) { + try { EventEnvelopeValidator.requireValid(envelope); } + catch (RuntimeException exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令信封无效"); } + if (!EventTypes.WALLET_COMMAND_REQUESTED.equals(envelope.eventType()) + || envelope.eventVersion() != WalletCommandRequestedV1.VERSION + || envelope.sourceRegion() != RegionCode.OVERSEAS || envelope.targetRegion() != RegionCode.CN) { + throw new FinanceEventException("FIN-EVT-001", "资金指令类型、版本或区域无效"); + } + try { + WalletCommandRequestedV1 value = objectMapper.treeToValue(envelope.payload(), WalletCommandRequestedV1.class); + String operation = value.operation() == null ? "" : value.operation().trim().toUpperCase(Locale.ROOT); + if (!OPERATIONS.contains(operation) || value.customerId() <= 0 || blank(value.businessType()) + || blank(value.businessNo()) || blank(value.idempotencyKey()) + || ("FREEZE".equals(operation) && (value.amount() == null || value.amount().signum() <= 0))) { + throw new FinanceEventException("FIN-EVT-001", "资金指令字段无效"); + } + if (!String.valueOf(value.customerId()).equals(envelope.tenantId())) { + throw new FinanceEventException("FIN-EVT-007", "资金指令客户归属不一致"); + } + return new WalletCommandRequestedV1(operation, value.customerId(), value.businessType(), value.businessNo(), + value.amount(), value.remark(), value.idempotencyKey(), value.requestedAt()); + } catch (FinanceEventException exception) { throw exception; } + catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令载荷无法解析"); } + } + + private void reserve(EventEnvelope envelope) { + try { + jdbc.update("INSERT INTO fin_wallet_command_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) VALUES(?,?,?,CAST(? AS JSON),'PROCESSING',NOW(3))", + envelope.eventId(), envelope.eventType(), envelope.payloadHash(), json(envelope)); + } catch (DuplicateKeyException ignored) { } + } + + private void finalizeResult(EventEnvelope commandEnvelope, WalletCommandCompletedV1 result) { + JsonNode payload = objectMapper.valueToTree(result); + String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)); + EventEnvelope completed = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_COMPLETED, + WalletCommandCompletedV1.VERSION, Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, + String.valueOf(result.customerId()), "WALLET_HOLD", result.businessNo(), 1L, null, + commandEnvelope.traceId(), commandEnvelope.correlationId(), commandEnvelope.eventId(), payload, payloadHash).requireValid(); + jdbc.update("INSERT IGNORE INTO fin_wallet_event_outbox(event_id,event_key,payload_json,status,next_retry_at,created_at,updated_at) VALUES(?,?,CAST(? AS JSON),'PENDING',NOW(3),NOW(3),NOW(3))", + completed.eventId(), "WALLET_RESULT:" + commandEnvelope.eventId(), json(completed)); + jdbc.update("UPDATE fin_wallet_command_inbox SET status=?,result_json=CAST(? AS JSON),processed_at=NOW(3) WHERE event_id=?", + result.result(), json(result), commandEnvelope.eventId()); + } + + private String existingHash(String eventId) { + List rows = jdbc.queryForList("SELECT payload_hash FROM fin_wallet_command_inbox WHERE event_id=?", String.class, eventId); + return rows.isEmpty() ? null : rows.get(0); + } + private boolean resultExists(String eventId) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_event_outbox WHERE event_key=?", Integer.class, + "WALLET_RESULT:" + eventId); + return count != null && count > 0; + } + private WalletCommandCompletedV1 completed(String eventId, WalletCommandRequestedV1 command, String result, + String holdStatus, BigDecimal amount, String errorCode, String errorMessage) { + return new WalletCommandCompletedV1(eventId, command.operation(), command.customerId(), command.businessType(), + command.businessNo(), result, holdStatus, amount, errorCode, errorMessage, Instant.now()); + } + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金事件序列化失败"); } + } + private static boolean blank(String value) { return value == null || value.isBlank(); } + private static String safe(String value) { + if (value == null || value.isBlank()) return "资金指令执行失败"; + String normalized = value.replaceAll("[\\r\\n\\t]+", " "); + return normalized.length() <= 500 ? normalized : normalized.substring(0, 500); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java new file mode 100644 index 000000000..2a57aaed1 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java @@ -0,0 +1,68 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.finance.client.WalletResultEventBridgeClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +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 java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Slf4j +public class WalletResultOutboxRelay { + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final WalletResultEventBridgeClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.wallet-event.max-attempts:12}") private int maxAttempts; + private final String worker = UUID.randomUUID().toString(); + + @Scheduled(fixedDelayString = "${baiyue.wallet-event.fixed-delay-ms:5000}") + public void relay() { + recoverExpired(); + for (String eventId : candidates()) deliver(eventId); + } + + public void deliver(String eventId) { + LocalDateTime now = LocalDateTime.now(); + int claimed = jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by=?,locked_at=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?", + worker, now, now, eventId, now); + if (claimed != 1) return; + Map row = jdbc.queryForMap("SELECT payload_json,retry_count FROM fin_wallet_event_outbox WHERE event_id=?", eventId); + try { + EventEnvelope envelope = objectMapper.readValue(String.valueOf(row.get("payload_json")), EventEnvelope.class).requireValid(); + var response = client.receive(internalToken, envelope); + if (response == null || !response.successful() || response.data() == null + || !eventId.equals(response.data().eventId())) throw new IllegalStateException("海外事件桥未确认资金结果事件"); + jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=NOW(3),locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=NOW(3) WHERE event_id=? AND locked_by=?", + eventId, worker); + } catch (Exception exception) { + int retries = ((Number) row.get("retry_count")).intValue() + 1; + String status = retries >= maxAttempts ? "DEAD" : "PENDING"; + long delay = Math.min(1800, 5L << Math.min(retries, 8)); + jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND locked_by=?", + status, retries, now.plusSeconds(delay), safe(exception.getMessage()), eventId, worker); + log.warn("资金结果事件投递失败 eventId={}, reason={}", eventId, exception.getMessage()); + } + } + + private List candidates() { + return jdbc.queryForList("SELECT event_id FROM fin_wallet_event_outbox WHERE status='PENDING' AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class); + } + private void recoverExpired() { + jdbc.update("UPDATE fin_wallet_event_outbox SET status='PENDING',locked_by=NULL,locked_at=NULL,next_retry_at=NOW(3),updated_at=NOW(3) WHERE status='SENDING' AND locked_at 16 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fin_inbox_event' + AND column_name IN ('payload_hash','result_resource_id','error_code')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:Inbox幂等字段不完整'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fin_bill' + AND column_name IN ('customer_confirmation_status','customer_confirmed_by', + 'customer_confirmed_by_name','customer_confirmed_at')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance客户对账单确认字段不完整'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fin_cost' + AND column_name IN ('cost_origin','source_system','source_type','source_fee_id', + 'source_fee_version','source_hash','paid_at','payment_method', + 'payment_reference_no','warehouse_id')) <> 10 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance直接已付成本字段不完整'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fin_cost' + AND index_name='uk_fin_cost_source_version') <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance直接已付成本唯一索引不完整'; + END IF; +END$$ +DELIMITER ; +CALL finance_fee_source_assert_schema(); +DROP PROCEDURE IF EXISTS finance_fee_source_assert_schema; diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql new file mode 100644 index 000000000..d704277da --- /dev/null +++ b/finance/src/main/resources/db/finance.sql @@ -0,0 +1,576 @@ +CREATE DATABASE IF NOT EXISTS baiyue_finance + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_finance; + +CREATE TABLE IF NOT EXISTS fin_fee_source ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + source_system VARCHAR(32) NOT NULL COMMENT '费用来源系统:FIRSTLEG头程、LASTLEG海外仓等', + source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '来源系统费用记录ID', + source_fee_version INT UNSIGNED NOT NULL COMMENT '来源费用版本号,用于固化不可变来源版本', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '费用来源所属客户ID', + source_type VARCHAR(32) NOT NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用', + source_no VARCHAR(64) NULL COMMENT '来源入库单号或柜号快照,可为空', + business_type VARCHAR(32) NULL COMMENT '来源业务类型,如海外入仓、采购、卡车或柜级分摊', + business_id BIGINT UNSIGNED NULL COMMENT '来源业务记录ID,可为空', + business_no VARCHAR(128) NULL COMMENT '来源业务编号快照,可为空', + inbound_code VARCHAR(64) NULL COMMENT '来源入仓码快照;并非所有费用必填', + warehouse_id BIGINT UNSIGNED NULL COMMENT '来源海外仓或业务仓ID,可为空', + direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付', + order_id BIGINT UNSIGNED NULL COMMENT '来源头程订单ID,可为空', + inbound_id BIGINT UNSIGNED NULL COMMENT '来源入库单ID,可为空', + container_id BIGINT UNSIGNED NULL COMMENT '来源Firstleg装柜计划ID,可为空', + container_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱柜ID,可为空', + container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照,可为空', + fee_item_code VARCHAR(64) NOT NULL COMMENT '来源费用项目编码快照', + fee_item_name VARCHAR(200) NOT NULL COMMENT '来源费用项目名称快照', + amount DECIMAL(20,6) NOT NULL COMMENT '来源费用金额,精度6位小数', + currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空', + paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间', + payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式:ONLINE、BANK_TRANSFER、CASH、OTHER', + payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号', + payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', + source_hash VARCHAR(71) NOT NULL COMMENT '业务费用来源完整快照SHA-256哈希,兼容sha256前缀格式', + source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照', + review_status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回', + review_reason VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因', + operation_version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号', + wallet_hold_id BIGINT UNSIGNED NULL COMMENT '已由客户资金账户结算时关联的资金冻结记录ID', + status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SETTLED资金账户已结清、SUPERSEDED已被新版本替代、VOIDED已作废', + 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_fin_fee_source (source_system, source_type, source_fee_id, source_fee_version), + KEY idx_fin_fee_source_customer (customer_id, status), + KEY idx_fin_fee_source_customer_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id), + KEY idx_fin_fee_source_wallet_hold (wallet_hold_id), + CONSTRAINT chk_fin_fee_source_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0), + CONSTRAINT chk_fin_fee_source_settlement CHECK ( + (direction='RECEIVABLE' AND settlement_mode IS NULL AND paid_at IS NULL AND payment_method IS NULL + AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL) + OR (direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND paid_at IS NULL AND payment_method IS NULL + AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL) + OR (direction='PAYABLE' AND settlement_mode='DIRECT_PAID' AND paid_at IS NOT NULL + AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER') + AND (payment_reference_no IS NOT NULL OR JSON_LENGTH(payment_voucher_file_ids)>0)) + ) +) ENGINE=InnoDB COMMENT='多业务系统费用来源快照池;账单人工选择并固化明细'; + +CREATE TABLE IF NOT EXISTS fin_inbox_event ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + event_id VARCHAR(128) NOT NULL COMMENT '来源系统事件唯一ID', + source_system VARCHAR(32) NOT NULL COMMENT '事件来源系统标识', + event_type VARCHAR(64) NOT NULL COMMENT '来源事件类型', + payload JSON NOT NULL COMMENT '来源事件原始JSON载荷快照', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件内容SHA-256哈希,兼容sha256前缀格式', + result_resource_id BIGINT UNSIGNED NULL COMMENT '成功处理生成或定位的费用来源快照ID', + error_code VARCHAR(64) NULL COMMENT '处理失败时的稳定FIN错误码', + status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '事件处理状态:RECEIVED已接收、PROCESSED已完成、FAILED处理失败', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件接收时间', + processed_at DATETIME(3) NULL COMMENT '事件处理完成时间,未完成时为空', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_inbox_event (source_system, event_id) +) ENGINE=InnoDB COMMENT='多业务系统费用事件幂等接收记录'; + +CREATE TABLE IF NOT EXISTS fin_subject ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + subject_code VARCHAR(64) NOT NULL COMMENT '平台财务主体唯一编码', + subject_name VARCHAR(200) NOT NULL COMMENT '平台收付款及核算主体名称', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '财务主体状态,ACTIVE表示启用', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID,0表示系统初始化', + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后修改操作人ID,0表示系统初始化', + 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_fin_subject_code (subject_code) +) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体'; + +INSERT INTO fin_subject + (subject_code, subject_name, status, version, created_by, updated_by) +VALUES + ('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0) +ON DUPLICATE KEY UPDATE + subject_name = VALUES(subject_name), status = 'ACTIVE', updated_by = 0; + +CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + customer_name_snapshot VARCHAR(200) NOT NULL COMMENT '客户名称快照,客户主数据后续变更不追改', + credit_days INT UNSIGNED NOT NULL DEFAULT 30 COMMENT '客户账期自然日数,默认30天', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '客户结算配置状态,ACTIVE表示启用', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', + updated_by BIGINT UNSIGNED NOT 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_fin_customer_profile (tenant_id), + CONSTRAINT chk_fin_customer_profile_tenant CHECK (tenant_id > 0), + CONSTRAINT chk_fin_customer_profile_days CHECK (credit_days <= 3650) +) ENGINE=InnoDB COMMENT='客户结算配置;一客户一个结算对象'; + +CREATE TABLE IF NOT EXISTS fin_bill ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + bill_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一账单编号', + subject_id BIGINT UNSIGNED NOT NULL COMMENT '平台财务主体ID', + customer_name_snapshot VARCHAR(200) NOT NULL COMMENT '出账时客户名称快照', + currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + bill_date DATE NOT NULL COMMENT '账单日期', + due_date DATE NOT NULL COMMENT '到期日期,由账单日期加客户账期计算', + status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '账单状态:DRAFT草稿、ISSUED已出账、PARTIALLY_PAID部分收款、PAID已收清、VOIDED已作废', + total_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '账单全部明细总金额,精度6位小数', + received_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '账单累计已收金额,精度6位小数', + outstanding_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '账单未收余额,精度6位小数', + remark VARCHAR(500) NULL COMMENT '平台账单内部备注', + issued_at DATETIME(3) NULL COMMENT '出账时间,未出账时为空', + issued_by BIGINT UNSIGNED NULL COMMENT '出账操作人ID,未出账时为空', + customer_confirmation_status VARCHAR(24) NOT NULL DEFAULT 'NOT_REQUIRED' COMMENT '客户确认状态:NOT_REQUIRED无需确认、PENDING_CONFIRMATION待确认、CONFIRMED已确认', + customer_confirmed_by BIGINT UNSIGNED NULL COMMENT '客户确认人用户ID', + customer_confirmed_by_name VARCHAR(128) NULL COMMENT '客户确认人姓名快照', + customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认时间', + voided_at DATETIME(3) NULL COMMENT '作废时间,未作废时为空', + voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID,未作废时为空', + void_reason VARCHAR(500) NULL COMMENT '账单作废原因', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', + created_by_name VARCHAR(128) NULL COMMENT '创建操作人名称快照', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID', + updated_by_name VARCHAR(128) 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_fin_bill_no (tenant_id, bill_no), + KEY idx_fin_bill_customer_status_date (tenant_id, status, bill_date), + CONSTRAINT chk_fin_bill_tenant CHECK (tenant_id > 0), + CONSTRAINT chk_fin_bill_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_bill_amounts CHECK (total_amount >= 0 AND received_amount >= 0 AND outstanding_amount >= 0) +) ENGINE=InnoDB COMMENT='客户CNY账单;首期账单即应收台账'; + +CREATE TABLE IF NOT EXISTS fin_bill_line ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + bill_id BIGINT UNSIGNED NOT NULL COMMENT '所属账单ID', + source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_FEE' COMMENT '费用来源类型,FIRSTLEG_FEE表示头程费用', + source_id BIGINT UNSIGNED NOT NULL COMMENT '来源费用ID', + source_version INT UNSIGNED NOT NULL COMMENT '出账固化的来源费用版本号', + source_no VARCHAR(64) NULL COMMENT '来源费用单号快照,可为空', + order_id BIGINT UNSIGNED NULL COMMENT '来源头程订单ID,可为空', + inbound_id BIGINT UNSIGNED NULL COMMENT '来源入库单ID,可为空', + fee_item_code VARCHAR(64) NOT NULL COMMENT '出账时费用项目编码快照', + fee_item_name VARCHAR(200) NOT NULL COMMENT '出账时费用项目名称快照', + amount DECIMAL(20,6) NOT NULL COMMENT '账单明细金额,精度6位小数', + adjustment_reason VARCHAR(500) NULL COMMENT '最近一次账单调整行原因;完整历史保存在修订与审计记录', + currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_bill_line_source (source_type, source_id, source_version), + KEY idx_fin_bill_line_bill (tenant_id, bill_id), + CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0), + CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_bill_line_amount CHECK (amount >= 0) +) ENGINE=InnoDB COMMENT='账单费用来源快照明细'; + +CREATE TABLE IF NOT EXISTS fin_receipt ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + receipt_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一收款编号', + subject_id BIGINT UNSIGNED NOT NULL COMMENT '实际收款的平台财务主体ID', + currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + received_amount DECIMAL(20,6) NOT NULL COMMENT '本次实际到账金额,精度6位小数', + received_at DATETIME(3) NOT NULL COMMENT '实际到账时间', + payment_method VARCHAR(32) NULL COMMENT '收款方式编码', + voucher_no VARCHAR(128) NULL COMMENT '银行流水号或线下收款凭证号', + voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON', + status VARCHAR(16) NOT NULL DEFAULT 'CONFIRMED' COMMENT '收款状态:CONFIRMED已确认、VOIDED已作废', + remark VARCHAR(500) NULL COMMENT '平台收款内部备注', + voided_at DATETIME(3) NULL COMMENT '收款作废时间', + voided_by BIGINT UNSIGNED NULL COMMENT '收款作废操作人ID', + void_reason VARCHAR(500) NULL COMMENT '收款作废原因', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', + created_by_name VARCHAR(128) NULL COMMENT '创建操作人名称快照', + updated_by BIGINT UNSIGNED NOT 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_fin_receipt_no (tenant_id, receipt_no), + KEY idx_fin_receipt_customer_time (tenant_id, received_at), + CONSTRAINT chk_fin_receipt_tenant CHECK (tenant_id > 0), + CONSTRAINT chk_fin_receipt_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_receipt_amount CHECK (received_amount > 0) +) ENGINE=InnoDB COMMENT='平台财务人工登记收款及凭证'; + +CREATE TABLE IF NOT EXISTS fin_receipt_bill ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + receipt_id BIGINT UNSIGNED NOT NULL COMMENT '收款记录ID', + bill_id BIGINT UNSIGNED NOT NULL COMMENT '被分配收款的账单ID', + applied_amount DECIMAL(20,6) NOT NULL COMMENT '本笔收款分配到该账单的金额,精度6位小数', + created_by BIGINT UNSIGNED NOT NULL COMMENT '分配操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '收款分配创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_receipt_bill_pair (receipt_id, bill_id), + KEY idx_fin_receipt_bill_bill (tenant_id, bill_id), + CONSTRAINT chk_fin_receipt_bill_tenant CHECK (tenant_id > 0), + CONSTRAINT chk_fin_receipt_bill_amount CHECK (applied_amount > 0) +) ENGINE=InnoDB COMMENT='人工收款对应账单金额;UI不使用复杂核销术语'; + +CREATE TABLE IF NOT EXISTS fin_cost ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + cost_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一成本编号', + cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付', + cost_origin VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付、BUSINESS_DIRECT_PAID其他业务直接已付', + source_system VARCHAR(32) NULL COMMENT '直接已付来源系统,如FIRSTLEG或LASTLEG', + source_type VARCHAR(32) NULL COMMENT '来源费用类型快照', + source_fee_id BIGINT UNSIGNED NULL COMMENT '来源费用ID', + source_fee_version INT UNSIGNED NULL COMMENT '来源费用版本', + source_hash VARCHAR(71) NULL COMMENT '来源不可变快照SHA-256,兼容sha256前缀格式', + order_id BIGINT UNSIGNED NULL COMMENT '成本归属的头程订单ID,单票成本必填', + inbound_id BIGINT UNSIGNED NULL COMMENT '成本关联的入库单ID,可为空', + container_id BIGINT UNSIGNED NULL COMMENT 'Firstleg真实货柜containerBookingId,整柜公共成本必填', + warehouse_id BIGINT UNSIGNED NULL COMMENT '业务直接已付成本关联仓库ID,可为空', + supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse公共往来单位或供应商引用ID', + supplier_name_snapshot VARCHAR(200) NULL COMMENT '登记成本时供应商或成本对象名称快照', + fee_item_code VARCHAR(64) NOT NULL COMMENT '成本费用项目编码快照', + fee_item_name VARCHAR(200) NOT NULL COMMENT '成本费用项目名称快照', + currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额;来源直接已付且CONFIRMED计入已线下支付供应商款', + voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON', + paid_at DATETIME(3) NULL COMMENT '直接已付时间;人工补充成本可为空', + payment_method VARCHAR(32) NULL COMMENT '直接已付方式:ONLINE、BANK_TRANSFER、CASH、OTHER', + payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', + status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT '成本状态:DRAFT草稿、CONFIRMED已确认、VOIDED已作废', + remark VARCHAR(500) NULL COMMENT '平台成本内部备注', + confirmed_at DATETIME(3) NULL COMMENT '成本确认时间', + confirmed_by BIGINT UNSIGNED NULL COMMENT '成本确认操作人ID', + voided_at DATETIME(3) NULL COMMENT '成本作废时间', + voided_by BIGINT UNSIGNED NULL COMMENT '成本作废操作人ID', + void_reason VARCHAR(500) NULL COMMENT '成本作废原因', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', + created_by_name VARCHAR(128) NULL COMMENT '创建操作人名称快照', + updated_by BIGINT UNSIGNED NOT 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_fin_cost_no (tenant_id, cost_no), + UNIQUE KEY uk_fin_cost_source_version (source_system, source_type, source_fee_id, source_fee_version), + KEY idx_fin_cost_ticket (tenant_id, order_id, inbound_id), + KEY idx_fin_cost_container (tenant_id, container_id), + KEY idx_fin_cost_customer_origin_status (tenant_id, cost_origin, status), + CONSTRAINT chk_fin_cost_tenant CHECK (tenant_id > 0), + CONSTRAINT chk_fin_cost_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_cost_amount CHECK (cost_amount > 0), + CONSTRAINT chk_fin_cost_target CHECK ( + (cost_type = 'SINGLE_ORDER' AND order_id IS NOT NULL AND container_id IS NULL) + OR (cost_type = 'CONTAINER_SHARED' AND container_id IS NOT NULL) + OR (cost_type = 'SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL OR warehouse_id IS NOT NULL)) + ), + CONSTRAINT chk_fin_cost_origin CHECK ( + (cost_origin='MANUAL' AND source_system IS NULL AND source_type IS NULL AND source_fee_id IS NULL AND source_fee_version IS NULL AND source_hash IS NULL) + OR (cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_type IS NOT NULL + AND source_fee_id IS NOT NULL AND source_fee_version IS NOT NULL AND source_hash IS NOT NULL + AND status IN ('CONFIRMED','VOIDED') AND paid_at IS NOT NULL + AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')) + OR (cost_origin='BUSINESS_DIRECT_PAID' AND source_system NOT IN ('', 'FIRSTLEG') AND source_type IS NOT NULL + AND source_fee_id IS NOT NULL AND source_fee_version IS NOT NULL AND source_hash IS NOT NULL + AND status IN ('CONFIRMED','VOIDED') AND paid_at IS NOT NULL + AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')) + ) +) ENGINE=InnoDB COMMENT='成本事实;人工补充成本与业务直接已付来源成本统一留痕'; + +CREATE TABLE IF NOT EXISTS fin_cost_allocation ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + cost_id BIGINT UNSIGNED NOT NULL COMMENT '整柜公共成本ID', + order_id BIGINT UNSIGNED NOT NULL COMMENT '参与分摊的头程订单ID', + inbound_id BIGINT UNSIGNED NULL COMMENT '参与分摊订单关联的入库单ID,可为空', + volume_cbm DECIMAL(20,6) NOT NULL COMMENT '该订单分摊时有效体积快照,单位CBM、精度6位', + total_volume_cbm DECIMAL(20,6) NOT NULL COMMENT '柜内有效订单总体积快照,单位CBM、精度6位', + allocation_rate DECIMAL(20,10) NOT NULL COMMENT '订单体积占总体积的分摊比例,精度10位', + allocated_amount DECIMAL(20,6) NOT NULL COMMENT '分摊到该订单的成本金额,精度6位', + rounding_adjustment TINYINT NOT NULL DEFAULT 0 COMMENT '尾差归集标记:0未归集、1该行为实际尾差归集行', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变分摊快照创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_cost_allocation_order (cost_id, order_id), + KEY idx_fin_cost_allocation_query (tenant_id, order_id), + CONSTRAINT chk_fin_cost_allocation_volume CHECK (volume_cbm >= 0 AND total_volume_cbm > 0), + CONSTRAINT chk_fin_cost_allocation_amount CHECK (allocated_amount >= 0) +) ENGINE=InnoDB COMMENT='整柜成本按有效体积分摊的可审计结果'; + +CREATE TABLE IF NOT EXISTS fin_bill_revision ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + bill_id BIGINT UNSIGNED NOT NULL COMMENT '被调整账单ID', + version INT UNSIGNED NOT NULL COMMENT '调整前账单乐观锁版本号', + reason VARCHAR(500) NULL COMMENT '本次账单调整原因', + bill_snapshot JSON NOT NULL COMMENT '调整前账单主记录不可变JSON快照', + line_snapshot JSON NOT NULL COMMENT '调整前全部账单明细不可变JSON数组快照', + created_by BIGINT UNSIGNED NOT NULL COMMENT '执行调整的操作人ID', + created_by_name VARCHAR(128) NULL COMMENT '执行调整的操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '修订快照创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_bill_revision (bill_id, version), + KEY idx_fin_bill_revision_tenant (tenant_id, bill_id) +) ENGINE=InnoDB COMMENT='出账后调整前的不可变账单及明细版本快照'; + +CREATE TABLE IF NOT EXISTS fin_idempotency_record ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起幂等写操作的操作人ID', + operation VARCHAR(64) NOT NULL COMMENT '幂等隔离的财务操作类型', + idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方提供的幂等键', + request_hash CHAR(64) NOT NULL COMMENT '规范化完整请求的SHA-256十六进制哈希', + status VARCHAR(16) NOT NULL COMMENT '幂等处理状态:PROCESSING处理中、SUCCEEDED成功、FAILED失败', + result_resource_id BIGINT UNSIGNED NULL COMMENT '成功操作生成或更新的业务资源ID', + error_code VARCHAR(64) 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_fin_idempotency (tenant_id, operator_id, operation, idempotency_key) +) ENGINE=InnoDB COMMENT='Finance人工写操作幂等记录'; + +CREATE TABLE IF NOT EXISTS fin_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如REC:20260818', + current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_fin_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号'; + +CREATE TABLE IF NOT EXISTS fin_audit_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + resource_type VARCHAR(32) NOT NULL COMMENT '被审计财务资源类型', + resource_id BIGINT UNSIGNED NOT NULL COMMENT '被审计财务资源ID', + action VARCHAR(64) NOT NULL COMMENT '审计业务动作类型', + reason VARCHAR(500) NULL COMMENT '操作原因或说明', + before_data JSON NULL COMMENT '操作前业务数据JSON快照', + after_data JSON NULL COMMENT '操作后业务数据JSON快照', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name_snapshot VARCHAR(128) NOT NULL COMMENT '操作发生时的操作人名称快照', + request_id VARCHAR(128) NULL COMMENT '入口请求ID,用于问题定位', + trace_id VARCHAR(128) NULL COMMENT '分布式调用链追踪ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变审计记录创建时间', + PRIMARY KEY (id), + KEY idx_fin_audit_resource (tenant_id, resource_type, resource_id, created_at), + KEY idx_fin_audit_operator (operator_id, created_at) +) ENGINE=InnoDB COMMENT='Finance不可变业务审计明细'; + +CREATE TABLE IF NOT EXISTS fin_settlement ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式应收或应付结算单ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + settlement_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一结算单号', + direction VARCHAR(16) NOT NULL COMMENT '结算方向:RECEIVABLE应收、PAYABLE应付', + customer_name_snapshot VARCHAR(200) NOT NULL COMMENT '结算生成时客户名称快照', + supplier_id BIGINT UNSIGNED NULL COMMENT '应付结算供应商ID;应收结算为空', + supplier_name_snapshot VARCHAR(200) NULL COMMENT '应付结算供应商名称快照', + reconciliation_statement_id BIGINT UNSIGNED NULL COMMENT '应收结算关联的已出账客户对账单ID', + business_period VARCHAR(32) NULL COMMENT '结算业务期间快照', + currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + total_amount DECIMAL(20,6) NOT NULL COMMENT '正式结算总金额,精度6位小数', + paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额;PAYABLE已付计入已线下支付供应商款', + outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额;RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付', + generated_date DATE NOT NULL COMMENT '结算单生成日期', + due_date DATE NOT NULL COMMENT '结算到期日期', + payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清', + status VARCHAR(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', + updated_by BIGINT UNSIGNED NOT 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_fin_settlement_no (tenant_id, settlement_no), + UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction), + KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status), + KEY idx_fin_settlement_supplier_status (supplier_id, direction, status), + KEY idx_fin_settlement_receivable_statistics (direction, currency_code, generated_date, status, tenant_id), + CONSTRAINT chk_fin_settlement_tenant CHECK (tenant_id > 0), + CONSTRAINT chk_fin_settlement_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_settlement_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')), + CONSTRAINT chk_fin_settlement_amounts CHECK (total_amount >= 0 AND paid_amount >= 0 AND outstanding_amount >= 0 AND total_amount = paid_amount + outstanding_amount) +) ENGINE=InnoDB COMMENT='正式应收与应付结算单;客户账户金额由本表派生'; + +CREATE TABLE IF NOT EXISTS fin_settlement_line ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID', + fee_source_id BIGINT UNSIGNED NOT NULL COMMENT '结算固化的Finance费用来源ID', + amount DECIMAL(20,6) NOT NULL COMMENT '结算明细金额,精度6位小数', + currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '明细创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_settlement_line_source (settlement_id, fee_source_id), + KEY idx_fin_settlement_line_customer (tenant_id, settlement_id), + KEY idx_fin_settlement_line_source (fee_source_id), + CONSTRAINT chk_fin_settlement_line_tenant CHECK (tenant_id > 0), + CONSTRAINT chk_fin_settlement_line_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_settlement_line_amount CHECK (amount > 0) +) ENGINE=InnoDB COMMENT='正式结算单费用来源金额快照明细'; + +CREATE TABLE IF NOT EXISTS fin_settlement_payment ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单收付款记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID', + transaction_type VARCHAR(16) NOT NULL COMMENT '交易类型:RECEIPT收款、PAYMENT付款', + transaction_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一收付款业务号', + transaction_date DATE NOT NULL COMMENT '实际收款或付款日期', + amount DECIMAL(20,6) NOT NULL COMMENT '本次收款或付款金额,精度6位小数', + currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', + method VARCHAR(32) NOT NULL COMMENT '人工登记的收付款方式', + reference_no VARCHAR(128) NULL COMMENT '银行流水号或外部参考号', + voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组', + remark VARCHAR(500) NULL COMMENT '收付款内部备注', + created_by BIGINT UNSIGNED NOT NULL COMMENT '登记操作人ID', + created_by_name VARCHAR(128) NULL COMMENT '登记操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_settlement_payment_no (tenant_id, transaction_no), + KEY idx_fin_settlement_payment_settlement (tenant_id, settlement_id, created_at), + KEY idx_fin_settlement_payment_customer_type_time (tenant_id, transaction_type, created_at, settlement_id), + CONSTRAINT chk_fin_settlement_payment_tenant CHECK (tenant_id > 0), + CONSTRAINT chk_fin_settlement_payment_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0) +) ENGINE=InnoDB COMMENT='正式结算单人工收付款与凭证记录'; + + +-- 一件代发客户资金账户:实际余额优先,授信额度补足;冻结后结算或解冻。 +CREATE TABLE IF NOT EXISTS fin_wallet_account ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', + currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '当前仅CNY', + actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,授信消费可形成负余额', + frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额,包含实际资金与授信冻结', + available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '当前总可用余额;授信失效后不再计入', + credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度', + credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信有效期内累计已使用额度;到期后并入实际余额', + credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信', + credit_expires_on DATE NULL COMMENT '授信到期日,当天仍有效;次日失效', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额乐观锁版本', + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID', + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 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_fin_wallet_account_customer (customer_id, currency_code), + CONSTRAINT chk_fin_wallet_account_customer CHECK (customer_id > 0), + CONSTRAINT chk_fin_wallet_account_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_wallet_account_balance CHECK (frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen) +) ENGINE=InnoDB COMMENT='客户CNY资金账户主账'; + +CREATE TABLE IF NOT EXISTS fin_recharge_request ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', + request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####', + amount DECIMAL(20,2) NOT NULL COMMENT '申请充值金额', + payment_method VARCHAR(16) NOT NULL COMMENT 'WECHAT或ALIPAY', + voucher_file_id BIGINT UNSIGNED NOT NULL COMMENT '付款截图File ID', + voucher_file_name VARCHAR(255) NOT NULL COMMENT '付款截图文件名快照', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '审核乐观锁版本', + submitted_by BIGINT UNSIGNED NOT NULL COMMENT '客户提交人ID', + submitted_by_name VARCHAR(128) NULL COMMENT '客户提交人名称快照', + reviewed_by BIGINT UNSIGNED NULL COMMENT '平台审核人ID', + reviewed_by_name VARCHAR(128) NULL COMMENT '平台审核人名称快照', + review_remark VARCHAR(500) NULL COMMENT '审核说明或驳回原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', + reviewed_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_fin_recharge_request_no (request_no), + KEY idx_fin_recharge_customer_status (customer_id, status, created_at, id), + CONSTRAINT chk_fin_recharge_customer CHECK (customer_id > 0), + CONSTRAINT chk_fin_recharge_amount CHECK (amount > 0), + CONSTRAINT chk_fin_recharge_method CHECK (payment_method IN ('WECHAT','ALIPAY')), + CONSTRAINT chk_fin_recharge_status CHECK (status IN ('PENDING','APPROVED','REJECTED')) +) ENGINE=InnoDB COMMENT='客户付款截图充值申请;平台审核通过后入账'; + +CREATE TABLE IF NOT EXISTS fin_wallet_ledger ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', + business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####', + change_type VARCHAR(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整', + amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负', + actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额', + frozen_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后冻结金额', + available_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后可用余额', + credit_limit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后授信额度', + credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信', + available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信', + credit_expires_on_after DATE NULL COMMENT '变动后授信到期日', + source_type VARCHAR(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等', + source_no VARCHAR(128) NULL COMMENT '充值申请号或外部凭证号', + occurred_date DATE NOT NULL COMMENT '业务发生日期', + payment_method VARCHAR(32) NULL COMMENT '充值或扣款方式', + reference_no VARCHAR(128) NULL COMMENT '收款凭证号或关联单据号', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(128) NULL COMMENT '操作人名称快照', + remark VARCHAR(500) NULL COMMENT '变动说明', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_wallet_ledger_no (business_no), + KEY idx_fin_wallet_ledger_customer_time (customer_id, created_at, id), + KEY idx_fin_wallet_ledger_source (customer_id, source_type, source_no), + CONSTRAINT chk_fin_wallet_ledger_customer CHECK (customer_id > 0), + CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0 OR change_type='CREDIT_LIMIT'), + CONSTRAINT chk_fin_wallet_ledger_balance CHECK (frozen_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0) +) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水'; + +CREATE TABLE IF NOT EXISTS fin_wallet_hold ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', + business_type VARCHAR(32) NOT NULL COMMENT '调用方业务类型', + business_no VARCHAR(128) NOT NULL COMMENT '调用方业务单号', + amount DECIMAL(20,2) NOT NULL COMMENT '冻结总额', + actual_frozen_amount DECIMAL(20,2) NOT NULL COMMENT '冻结时占用实际资金', + credit_frozen_amount DECIMAL(20,2) NOT NULL COMMENT '冻结时占用授信', + status VARCHAR(16) NOT NULL COMMENT '冻结状态:FROZEN冻结中、SETTLED已结算、RELEASED已释放', + freeze_ledger_id BIGINT UNSIGNED NULL COMMENT '冻结流水ID', + action_ledger_id BIGINT UNSIGNED NULL COMMENT '结算或解冻流水ID', + remark VARCHAR(500) NULL COMMENT '业务说明', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '冻结记录创建时间', + completed_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_fin_wallet_hold_business (customer_id,business_type,business_no), + KEY idx_fin_wallet_hold_status (customer_id,status,created_at,id), + CONSTRAINT chk_fin_wallet_hold_customer CHECK (customer_id > 0), + CONSTRAINT chk_fin_wallet_hold_amount CHECK (amount > 0 AND actual_frozen_amount >= 0 AND credit_frozen_amount >= 0 AND amount=actual_frozen_amount+credit_frozen_amount), + CONSTRAINT chk_fin_wallet_hold_status CHECK (status IN ('FROZEN','SETTLED','RELEASED')) +) ENGINE=InnoDB COMMENT='客户资金冻结及结算生命周期'; + + +CREATE TABLE IF NOT EXISTS fin_wallet_command_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '海外资金指令事件ID', + event_type VARCHAR(96) NOT NULL COMMENT '事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化载荷SHA-256,包含sha256:前缀', + payload_json JSON NOT NULL COMMENT '冻结事件信封', + status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、SUCCEEDED或FAILED', + result_json JSON NULL COMMENT '最终资金执行结果', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + KEY idx_fin_wallet_command_status (status,received_at) +) ENGINE=InnoDB COMMENT='跨区客户资金指令幂等收件箱'; + +CREATE TABLE IF NOT EXISTS fin_wallet_event_outbox ( + event_id VARCHAR(64) NOT NULL COMMENT '资金结果事件ID', + event_key VARCHAR(160) NOT NULL COMMENT '业务幂等键', + payload_json JSON NOT NULL COMMENT '完整事件信封', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、PUBLISHED或DEAD', + retry_count INT NOT NULL DEFAULT 0 COMMENT '失败次数', + next_retry_at DATETIME(3) NOT NULL COMMENT '下次投递时间', + locked_by VARCHAR(128) NULL COMMENT '租约持有者', + locked_at DATETIME(3) NULL COMMENT '租约时间', + published_at DATETIME(3) NULL COMMENT '投递完成时间', + last_error VARCHAR(1000) 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), + UNIQUE KEY uk_fin_wallet_event_key (event_key), + KEY idx_fin_wallet_event_publish (status,next_retry_at,created_at) +) ENGINE=InnoDB COMMENT='国内Finance到海外资金结果可靠发送箱'; diff --git a/finance/src/main/resources/openapi/finance-container-settlement-v1.yaml b/finance/src/main/resources/openapi/finance-container-settlement-v1.yaml new file mode 100644 index 000000000..c7b0a8f0b --- /dev/null +++ b/finance/src/main/resources/openapi/finance-container-settlement-v1.yaml @@ -0,0 +1,109 @@ +openapi: 3.0.3 +info: + title: Finance Container Settlement Query API + version: 1.0.0 + description: 柜级已结算费用来源与成本分摊的固定请求量只读聚合契约。 +servers: + - url: /api +paths: + /platform/finance/v1/container-settlement-details: + get: + summary: 按客户和柜次查询已结算柜费及成本分摊 + description: >- + customerId 与 containerBookingId 必须在 Finance 的费用来源或成本快照中存在归属关系。 + 服务端固定执行三条集合 SQL,不按结算单或成本逐项查询;响应不包含整张结算单、 + 其他客户数据、付款凭证或内部备注。 + x-permissions-all-of: + - platform:finance:cost:view + - platform:finance:settlement-ar:list + - platform:finance:settlement-ap:list + x-query-count: 3 fixed set-based SQL statements + security: + - bearerAuth: [] + parameters: + - name: customerId + in: query + required: true + schema: {$ref: '#/components/schemas/Id'} + - name: containerBookingId + in: query + required: true + description: Firstleg 真实货柜 containerBookingId,不是装柜计划 loadingPlanId。 + schema: {$ref: '#/components/schemas/Id'} + responses: + '200': + description: 柜级财务聚合结果。 + content: + application/json: + schema: {$ref: '#/components/schemas/ContainerSettlementDetailsEnvelope'} + '403': {description: 权限不足。} + '404': {description: 柜次不存在或不属于客户。} +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + Id: + type: string + pattern: '^[1-9][0-9]*$' + Money: + type: number + format: decimal + minimum: 0 + ContainerSettlementDetailsEnvelope: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string, example: '200'} + message: {type: string, example: success} + data: {$ref: '#/components/schemas/ContainerSettlementDetails'} + requestId: {type: string} + traceId: {type: string} + timestamp: {type: string, format: date-time} + ContainerSettlementDetails: + type: object + required: [customerId, containerBookingId, settledSourceFeeIds, costs] + properties: + customerId: {$ref: '#/components/schemas/Id'} + containerBookingId: {$ref: '#/components/schemas/Id'} + settledSourceFeeIds: + type: array + uniqueItems: true + items: {$ref: '#/components/schemas/Id'} + costs: + type: array + items: {$ref: '#/components/schemas/ContainerCostDetail'} + ContainerCostDetail: + type: object + required: [costId, costNo, costType, costOrigin, containerBookingId, feeItemName, amount, currency, status, allocations] + properties: + costId: {$ref: '#/components/schemas/Id'} + costNo: {type: string} + costType: {type: string, enum: [SINGLE_ORDER, CONTAINER_SHARED, SOURCE_DIRECT_PAID]} + costOrigin: {type: string, enum: [MANUAL, FIRSTLEG_DIRECT_PAID, BUSINESS_DIRECT_PAID]} + sourceFeeId: {$ref: '#/components/schemas/Id'} + sourceFeeVersion: {type: integer, minimum: 1} + containerBookingId: {$ref: '#/components/schemas/Id'} + supplierName: {type: string, nullable: true} + feeItemName: {type: string} + amount: {$ref: '#/components/schemas/Money'} + currency: {type: string, enum: [CNY]} + status: {type: string, enum: [DRAFT, CONFIRMED, VOIDED]} + allocations: + type: array + items: {$ref: '#/components/schemas/ContainerCostAllocation'} + ContainerCostAllocation: + type: object + required: [allocationId, orderId, volumeCbm, totalVolumeCbm, allocationRate, allocatedAmount, roundingAdjustment] + properties: + allocationId: {$ref: '#/components/schemas/Id'} + orderId: {$ref: '#/components/schemas/Id'} + inboundId: + allOf: [{$ref: '#/components/schemas/Id'}] + nullable: true + volumeCbm: {$ref: '#/components/schemas/Money'} + totalVolumeCbm: {$ref: '#/components/schemas/Money'} + allocationRate: {type: number, format: decimal, minimum: 0, maximum: 1} + allocatedAmount: {$ref: '#/components/schemas/Money'} + roundingAdjustment: {type: boolean} diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java new file mode 100644 index 000000000..edce650ca --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java @@ -0,0 +1,243 @@ +package com.baiyue.finance; + +import com.baiyue.common.event.BusinessFeeSourceChangedV1; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.RegionCode; +import com.baiyue.finance.service.BusinessFeeSourceEventService; +import com.baiyue.finance.service.FinanceSupplierValidator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.databind.node.ObjectNode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BusinessFeeSourceEventServiceTest { + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class); + private final BusinessFeeSourceEventService service = new BusinessFeeSourceEventService(jdbc, json, suppliers); + + @Test + void importsOverseasFeeWithoutInventingInboundReference() { + EventEnvelope event = eventWithoutInbound(); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status,result_resource_id"), + eq("LASTLEG"), eq(event.eventId()))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT id,source_hash,status FROM fin_fee_source"), + eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(List.of()); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=?"), + eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L))).thenReturn(0); + when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), + eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L); + + assertThat(service.consume(event)).isEqualTo(901L); + + ArgumentCaptor values = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") + && sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), values.capture()); + assertThat(values.getValue()[3]).isEqualTo(10001L); + assertThat(values.getValue()[5]).isEqualTo("PO-US-001"); + assertThat(values.getValue()[9]).isNull(); + assertThat(values.getValue()[13]).isNull(); + verify(jdbc).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), eq(901L)); + } + + private EventEnvelope eventWithoutInbound() { + BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, "LASTLEG", + "OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001", + "OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null, + null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null, + null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY", + new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE, + "2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", null); + String sourceHash = EventPayloadHash.sha256( + EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned))); + BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG", + "OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001", + "OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null, + null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null, + null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY", + new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE, + "2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", sourceHash); + JsonNode node = json.valueToTree(payload); + String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701", + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.parse("2026-09-02T08:00:00Z"), + RegionCode.OVERSEAS, RegionCode.CN, "10001", "BUSINESS_FEE", "701", 1, + null, "123e4567-e89b-12d3-a456-426614174702", "LASTLEG:FEE:701:1", null, + node, payloadHash); + } + + @Test + void overseasDirectPaidCostUsesPhysicalContainerAndKeepsSourceIdentity() { + EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1); + when(suppliers.requireActive(71L, null)).thenReturn( + new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE")); + when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), + eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1))).thenReturn(901L); + assertThat(service.consume(event)).isEqualTo(901L); + ArgumentCaptor source = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), source.capture()); + assertThat(source.getValue()[0]).isEqualTo("LASTLEG"); + assertThat(source.getValue()[4]).isEqualTo("OVERSEAS_CONTAINER_FEE"); + assertThat(source.getValue()[14]).isEqualTo(41L); + assertThat(source.getValue()[15]).isEqualTo(61L); + ArgumentCaptor cost = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(startsWith("INSERT INTO fin_cost"), cost.capture()); + assertThat(cost.getValue()[9]).isEqualTo(61L); + } + + @Test + void rejectsForeignCurrencyAndMissingPhysicalContainerBeforeWriting() { + assertThatThrownBy(() -> service.consume(containerEvent("USD", "61", "PUBLISHED", 1))) + .isInstanceOf(com.baiyue.finance.service.FinanceEventException.class); + assertThatThrownBy(() -> service.consume(containerEvent("CNY", null, "PUBLISHED", 1))) + .isInstanceOf(com.baiyue.finance.service.FinanceEventException.class); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + @Test + void lateOlderVersionCannotSupersedeNewerSourceOrCost() { + when(suppliers.requireActive(71L, null)).thenReturn( + new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE")); + when(jdbc.queryForList(startsWith("SELECT source_fee_version"), eq("LASTLEG"), + eq("OVERSEAS_CONTAINER_FEE"), eq(701L))) + .thenReturn(List.of(Map.of("source_fee_version", 2))); + assertThatThrownBy(() -> service.consume(containerEvent("CNY", "61", "PUBLISHED", 1))) + .hasMessageContaining("版本已过期"); + verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class)); + verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class)); + } + + @Test + void replayReturnsOriginalSourceWithoutDuplicatingCost() { + EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1); + when(suppliers.requireActive(71L, null)).thenReturn( + new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE")); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("LASTLEG"), eq(event.eventId()))) + .thenReturn(List.of(Map.of("payload_hash", event.payloadHash(), "status", "PROCESSED", "result_resource_id", 901L))); + assertThat(service.consume(event)).isEqualTo(901L); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + private EventEnvelope containerEvent(String currency, String containerBookingId, String action, int version) { + EventEnvelope base = eventWithoutInbound(); + ObjectNode payload = base.payload().deepCopy(); + payload.put("sourceType", "OVERSEAS_CONTAINER_FEE"); + payload.put("businessType", "OVERSEAS_CONTAINER"); + payload.put("containerId", "41"); + payload.put("containerBookingId", containerBookingId); + payload.put("currency", currency); + payload.put("direction", "PAYABLE"); + payload.put("supplierId", "71"); + payload.put("supplierName", "供应商"); + payload.put("settlementMode", "DIRECT_PAID"); + payload.set("paidAt", json.valueToTree(Instant.parse("2026-09-02T08:00:00Z"))); + payload.put("paymentMethod", "BANK_TRANSFER"); + payload.put("paymentReferenceNo", "PAY-1"); + payload.put("sourceVersion", version); + payload.putNull("action"); + payload.putNull("sourceHash"); + payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + payload.put("action", action); + return new EventEnvelope(base.eventId(), base.eventType(), 1, base.occurredAt(), + RegionCode.OVERSEAS, RegionCode.CN, "10001", "BUSINESS_FEE", "701", version, + null, base.traceId(), "LASTLEG:FEE:701:" + version, null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } + + @Test + void sameEventIdWithDifferentPayloadIsRejectedWithoutWrites() { + var event = payableEvent("PUBLISHED", 1); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("LASTLEG"), eq(event.eventId()))) + .thenReturn(List.of(Map.of("payload_hash", "different", "status", "PROCESSED", "result_resource_id", 901L))); + assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("同一事件ID"); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + @Test + void sameSourceVersionWithDifferentHashCannotOverwriteCost() { + var event = payableEvent("PUBLISHED", 1); + existingVersion("different", "AVAILABLE"); + assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("同一来源版本"); + noFinancialWrites(); + } + + @Test + void voidedSourceCannotBeRevivedByPublicationReplay() { + var event = payableEvent("PUBLISHED", 1); + existingVersion(event.payload().get("sourceHash").asText(), "VOIDED"); + assertThat(service.consume(event)).isEqualTo(901L); + noFinancialWrites(); + } + + @Test + void voidTargetsOnlyMatchingOverseasCostVersion() { + var event = payableEvent("VOIDED", 1); + existingVersion(event.payload().get("sourceHash").asText(), "AVAILABLE"); + assertThat(service.consume(event)).isEqualTo(901L); + verify(jdbc).update(startsWith("UPDATE fin_cost SET status='VOIDED'"), + eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1)); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class)); + } + + @Test + void frozenSourceRejectsAdjustmentWithoutReplacingSourceOrCost() { + var event = payableEvent("ADJUSTED", 2); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=?"), + eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L))).thenReturn(1); + assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("禁止调整或替代"); + noFinancialWrites(); + } + + @Test + void frozenSourceRejectsVoidWithoutChangingSourceOrCost() { + var event = payableEvent("VOIDED", 1); + existingVersion(event.payload().get("sourceHash").asText(), "AVAILABLE"); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=?"), + eq(Integer.class), eq(901L))).thenReturn(1); + assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("禁止作废"); + noFinancialWrites(); + } + + private EventEnvelope payableEvent(String action, int version) { + when(suppliers.requireActive(71L, null)).thenReturn( + new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE")); + return containerEvent("CNY", "61", action, version); + } + + private void existingVersion(String hash, String status) { + when(jdbc.queryForList(startsWith("SELECT id,source_hash,status FROM fin_fee_source"), + eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1))) + .thenReturn(List.of(Map.of("id", 901L, "source_hash", hash, "status", status))); + } + + private void noFinancialWrites() { + verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); + verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class)); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class)); + verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class)); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java new file mode 100644 index 000000000..7e4737ea7 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java @@ -0,0 +1,151 @@ +package com.baiyue.finance; + +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.client.FirstlegCostSnapshotClient; +import com.baiyue.finance.controller.PlatformFinanceController; +import com.baiyue.finance.dto.FinanceReadModels.CustomerAccountSummary; +import com.baiyue.finance.service.FinanceQueryService; +import com.baiyue.finance.service.FinanceService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; + +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.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.springframework.jdbc.BadSqlGrammarException; + +class CustomerAccountSummaryBehaviorTest { + + @Test + void accountReadUsesFormalArSettlementsAndKeepsReceiptHistorySeparate() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(anyString(), org.mockito.ArgumentMatchers.eq(BigDecimal.class), + org.mockito.ArgumentMatchers.eq(9L))).thenReturn(new BigDecimal("100.00")); + when(jdbc.query(anyString(), any(RowMapper.class), org.mockito.ArgumentMatchers.eq(9L))) + .thenReturn(List.of()); + + var result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccount(9L); + + assertThat(result.accountMode()).isEqualTo("MANUAL_RECEIVABLE_LEDGER"); + var amountSql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc, org.mockito.Mockito.times(3)).queryForObject(amountSql.capture(), + org.mockito.ArgumentMatchers.eq(BigDecimal.class), org.mockito.ArgumentMatchers.eq(9L)); + assertThat(amountSql.getAllValues()).allSatisfy(sql -> assertThat(sql) + .contains("FROM fin_settlement", "direction='RECEIVABLE'", "status<>'VOIDED'")); + var entrySql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc, org.mockito.Mockito.times(2)).query(entrySql.capture(), any(RowMapper.class), + org.mockito.ArgumentMatchers.eq(9L)); + assertThat(entrySql.getAllValues()).anySatisfy(sql -> assertThat(sql) + .contains("settlement_no", "FROM fin_settlement", "direction='RECEIVABLE'")); + assertThat(entrySql.getAllValues()).anySatisfy(sql -> assertThat(sql) + .contains("FROM fin_receipt", "status<>'VOIDED'")); + } + + @Test + void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "260.00", "180.00", "80.00"); + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation -> + ((RowMapper) invocation.getArgument(1)).mapRow(rs, 0)); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + + CustomerAccountSummary result = service.customerAccountSummary(9L); + + assertThat(result.customerId()).isEqualTo(9L); + assertThat(result.currency()).isEqualTo("CNY"); + assertThat(result.totalReceivable()).isEqualByComparingTo("300.00"); + assertThat(result.receivedAmount()).isEqualByComparingTo("140.00"); + assertThat(result.outstandingReceivable()).isEqualByComparingTo("160.00"); + assertThat(result.pendingBilling()).isEqualByComparingTo("25.00"); + assertThat(result.totalCost()).isEqualByComparingTo("260.00"); + assertThat(result.paidCost()).isEqualByComparingTo("180.00"); + assertThat(result.outstandingPayable()).isEqualByComparingTo("80.00"); + + var sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc).queryForObject(sql.capture(), any(RowMapper.class), + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L), + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L), + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L), + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L), + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L)); + assertThat(sql.getValue()) + .contains("FROM fin_settlement s WHERE s.tenant_id=?") + .contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'") + .contains("os.direction='PAYABLE' AND os.status<>'VOIDED'") + .contains("f.settlement_mode='ON_ACCOUNT'", "c.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "ps.paid_amount") + .contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0") + .contains("b.status<>'VOIDED'", "FROM fin_settlement_line sl", "sl.fee_source_id=f.id", "aps.status<>'VOIDED'") + .doesNotContain("JSON_EXTRACT") + .doesNotContain("SUM(received_amount)", "FROM fin_receipt r"); + } + + @Test + void emptyCustomerReturnsZeroAmounts() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + ResultSet rs = summaryRow("0", "0", "0", "0", "0", "0", "0"); + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation -> + ((RowMapper) invocation.getArgument(1)).mapRow(rs, 0)); + + CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L); + + assertThat(result.totalReceivable()).isZero(); + assertThat(result.receivedAmount()).isZero(); + assertThat(result.outstandingReceivable()).isZero(); + assertThat(result.pendingBilling()).isZero(); + assertThat(result.totalCost()).isZero(); + assertThat(result.paidCost()).isZero(); + assertThat(result.outstandingPayable()).isZero(); + } + + @Test + void missingSettlementProjectionFailsClosedInsteadOfReturningFakeZeroAmounts() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null)); + + assertThatThrownBy(() -> new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L)) + .isInstanceOf(BadSqlGrammarException.class); + } + + @Test + void controllerUsesListPermissionAndDelegatesSummary() throws Exception { + FinanceQueryService query = mock(FinanceQueryService.class); + PlatformFinanceController controller = new PlatformFinanceController(mock(FinanceService.class), + mock(FinanceFileClient.class), mock(FirstlegCostSnapshotClient.class), query); + + controller.customerAccountSummary(11L); + + verify(query).customerAccountSummary(11L); + PreAuthorize permission = PlatformFinanceController.class + .getMethod("customerAccountSummary", long.class).getAnnotation(PreAuthorize.class); + assertThat(permission.value()).isEqualTo("hasAuthority('platform:finance:customer-account:list')"); + } + + private static ResultSet summaryRow(String total, String paid, String outstanding, + String pending, String totalCost, String paidCost, + String payable) throws Exception { + ResultSet rs = mock(ResultSet.class); + when(rs.getBigDecimal("total_receivable")).thenReturn(new BigDecimal(total)); + when(rs.getBigDecimal("received_amount")).thenReturn(new BigDecimal(paid)); + when(rs.getBigDecimal("outstanding_receivable")).thenReturn(new BigDecimal(outstanding)); + when(rs.getBigDecimal("pending_billing")).thenReturn(new BigDecimal(pending)); + when(rs.getBigDecimal("total_cost")).thenReturn(new BigDecimal(totalCost)); + when(rs.getBigDecimal("paid_cost")).thenReturn(new BigDecimal(paidCost)); + when(rs.getBigDecimal("outstanding_payable")).thenReturn(new BigDecimal(payable)); + when(rs.getTimestamp("last_changed_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 8, 13, 12, 0))); + return rs; + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementOpenApiContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementOpenApiContractTest.java new file mode 100644 index 000000000..09c7b29dd --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementOpenApiContractTest.java @@ -0,0 +1,29 @@ +package com.baiyue.finance; + +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 FinanceContainerSettlementOpenApiContractTest { + + @Test + void contractPublishesFixedQueryAggregateAndExactReadPermissions() throws Exception { + String openApi = Files.readString( + Path.of("src/main/resources/openapi/finance-container-settlement-v1.yaml"), + StandardCharsets.UTF_8); + + assertThat(openApi) + .contains("/platform/finance/v1/container-settlement-details:") + .contains("platform:finance:cost:view") + .contains("platform:finance:settlement-ar:list") + .contains("platform:finance:settlement-ap:list") + .contains("x-query-count: 3 fixed set-based SQL statements") + .contains("containerBookingId:", "不是装柜计划 loadingPlanId", + "settledSourceFeeIds:", "costs:", "allocations:") + .doesNotContain("- name: containerId"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementQueryMapperContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementQueryMapperContractTest.java new file mode 100644 index 000000000..db1107348 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementQueryMapperContractTest.java @@ -0,0 +1,30 @@ +package com.baiyue.finance; + +import com.baiyue.finance.mapper.FinanceContainerSettlementQueryMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceContainerSettlementQueryMapperContractTest { + + @Test + void mapperUsesThreeSetBasedStatementsAndNoPerSettlementOrPerCostLookup() { + assertThat(FinanceContainerSettlementQueryMapper.class.isAnnotationPresent(Mapper.class)).isTrue(); + var methods = FinanceContainerSettlementQueryMapper.class.getDeclaredMethods(); + assertThat(methods).hasSize(3); + assertThat(Arrays.stream(methods).allMatch(method -> method.isAnnotationPresent(Select.class))).isTrue(); + + String sql = Arrays.stream(methods) + .flatMap(method -> Arrays.stream(method.getAnnotation(Select.class).value())) + .reduce("", (left, right) -> left + "\n" + right); + assertThat(sql).contains("JOIN fin_settlement_line", "JOIN fin_fee_source", + "LEFT JOIN fin_cost_allocation", "c.tenant_id = #{customerId}", + "f.container_booking_id = #{containerBookingId}", + "c.container_id = #{containerBookingId}"); + assertThat(sql).doesNotContain("${"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementQueryServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementQueryServiceTest.java new file mode 100644 index 000000000..3dc4f94ff --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementQueryServiceTest.java @@ -0,0 +1,105 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.finance.mapper.FinanceContainerSettlementQueryMapper; +import com.baiyue.finance.mapper.FinanceContainerSettlementQueryMapper.CostAllocationRow; +import com.baiyue.finance.mapper.FinanceContainerSettlementQueryMapper.OwnershipRow; +import com.baiyue.finance.service.FinanceContainerSettlementQueryService; +import org.junit.jupiter.api.Test; + +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.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FinanceContainerSettlementQueryServiceTest { + + @Test + void returnsSettledSourcesAndGroupsAllCostAllocationsWithThreeFixedMapperCalls() { + FinanceContainerSettlementQueryMapper mapper = mock(FinanceContainerSettlementQueryMapper.class); + when(mapper.ownership(101L, 301L)).thenReturn(ownership(3)); + when(mapper.settledSourceFeeIds(101L, 301L)).thenReturn(List.of(901L, 902L)); + when(mapper.costs(101L, 301L)).thenReturn(List.of( + row(501L, 301L, 601L, 701L, false), + row(501L, 301L, 602L, 702L, true), + row(502L, 301L, null, null, false))); + + var result = new FinanceContainerSettlementQueryService(mapper).details(101L, 301L); + + assertThat(result.customerId()).isEqualTo(101L); + assertThat(result.containerBookingId()).isEqualTo(301L); + assertThat(result.settledSourceFeeIds()).containsExactly(901L, 902L); + assertThat(result.costs()).hasSize(2); + assertThat(result.costs().get(0).costId()).isEqualTo(501L); + assertThat(result.costs().get(0).allocations()).extracting(allocation -> allocation.orderId()) + .containsExactly(701L, 702L); + assertThat(result.costs().get(1).allocations()).isEmpty(); + verify(mapper, times(1)).ownership(101L, 301L); + verify(mapper, times(1)).settledSourceFeeIds(101L, 301L); + verify(mapper, times(1)).costs(101L, 301L); + } + + @Test + void rejectsUnknownAndCrossCustomerContainerBeforeReadingFinanceDetails() { + FinanceContainerSettlementQueryMapper mapper = mock(FinanceContainerSettlementQueryMapper.class); + var service = new FinanceContainerSettlementQueryService(mapper); + when(mapper.ownership(101L, 301L)).thenReturn(ownership(0)); + when(mapper.ownership(101L, 302L)).thenReturn(ownership(0)); + + assertThatThrownBy(() -> service.details(101L, 301L)) + .isInstanceOf(BizException.class).hasMessageContaining("不属于客户"); + assertThatThrownBy(() -> service.details(101L, 302L)) + .isInstanceOf(BizException.class).hasMessageContaining("不属于客户"); + verify(mapper, never()).settledSourceFeeIds(101L, 301L); + verify(mapper, never()).settledSourceFeeIds(101L, 302L); + verify(mapper, never()).costs(101L, 301L); + verify(mapper, never()).costs(101L, 302L); + } + + @Test + void rejectsInvalidBusinessIdsWithoutQueryingMapper() { + FinanceContainerSettlementQueryMapper mapper = mock(FinanceContainerSettlementQueryMapper.class); + var service = new FinanceContainerSettlementQueryService(mapper); + + assertThatThrownBy(() -> service.details(0, 301)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.details(101, 0)).isInstanceOf(IllegalArgumentException.class); + verify(mapper, never()).ownership(0, 301); + verify(mapper, never()).ownership(101, 0); + } + + private static OwnershipRow ownership(long customerLinks) { + var row = new OwnershipRow(); + row.setCustomerLinkCount(customerLinks); + return row; + } + + private static CostAllocationRow row(long costId, long containerBookingId, Long allocationId, + Long orderId, boolean roundingAdjustment) { + var row = new CostAllocationRow(); + row.setCostId(costId); + row.setCostNo("COST-" + costId); + row.setCostType("CONTAINER_SHARED"); + row.setCostOrigin("MANUAL"); + row.setContainerBookingId(containerBookingId); + row.setSupplierName("供应商"); + row.setFeeItemName("柜费"); + row.setAmount(new BigDecimal("100.00")); + row.setCurrency("CNY"); + row.setStatus("CONFIRMED"); + row.setAllocationId(allocationId); + row.setOrderId(orderId); + row.setInboundId(orderId == null ? null : orderId + 1000); + row.setVolumeCbm(allocationId == null ? null : new BigDecimal("1.00")); + row.setTotalVolumeCbm(allocationId == null ? null : new BigDecimal("2.00")); + row.setAllocationRate(allocationId == null ? null : new BigDecimal("0.50")); + row.setAllocatedAmount(allocationId == null ? null : new BigDecimal("50.00")); + row.setRoundingAdjustment(roundingAdjustment); + return row; + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java new file mode 100644 index 000000000..a7d673357 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java @@ -0,0 +1,54 @@ +package com.baiyue.finance; + +import com.baiyue.finance.controller.ClientFinanceController; +import com.baiyue.finance.controller.PlatformFinanceController; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class FinanceContractTest { + @Test + void applicationEnablesReliableOutboxScheduling() { + assertNotNull(BaiyueFinanceApplication.class.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class)); + } + + @Test + void controllersUseFrozenFinancePaths() throws Exception { + assertEquals("/api/platform/finance/v1", PlatformFinanceController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0]); + assertEquals("/api/client/finance/v1", ClientFinanceController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0]); + String sql = Files.readString(Path.of("src/main/resources/db/finance.sql")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS fin_bill")); + assertTrue(sql.contains("currency_code CHECK (currency_code = 'CNY')") || sql.contains("currency_code = 'CNY'")); + assertTrue(sql.contains("fin_fee_source") && sql.contains("fin_inbox_event")); + assertTrue(sql.contains("fin_bill_revision")); + assertTrue(sql.contains("PROCESSING") && sql.contains("SUCCEEDED") && sql.contains("FAILED")); + String service = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinanceService.java")); + assertTrue(service.contains("status='PROCESSING'") && service.contains("stableNo")); + assertTrue(service.contains("request_hash") && service.contains("result_resource_id") && service.contains("Idempotency-Key已用于其他请求")); + assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')")); + String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java")); + assertFalse(controller.contains("@PostMapping(\"/bills\")")); + assertFalse(controller.contains("/bills/{billId}/issue")); + assertFalse(service.contains("BILL_CREATE")); + assertFalse(service.contains("BILL_ISSUE")); + assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile")); + assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)")); + assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())")); + String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java")); + assertTrue(operations.contains("bl.source_id=f.id")); + assertTrue(operations.contains("Boolean.class, id, rs.getInt(\"source_fee_version\")")); + assertFalse(operations.contains("bl.source_id=f.source_fee_id")); + String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertTrue(unified.contains("CREATE DATABASE IF NOT EXISTS baiyue_finance")); + assertTrue(unified.contains("assert_finance_permissions")); + assertTrue(unified.contains("merchant:finance:statement:view")); + int walletEventSchema = unified.indexOf("CREATE TABLE IF NOT EXISTS fin_wallet_event_outbox"); + int finalPass = unified.lastIndexOf("SELECT 'PASS' AS migration_status"); + assertTrue(walletEventSchema >= 0); + assertTrue(finalPass > walletEventSchema); + assertTrue(unified.contains("CREATE PROCEDURE assert_wallet_event_schema()")); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java new file mode 100644 index 000000000..89dbc7f59 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java @@ -0,0 +1,83 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.finance.client.IamCustomerClient; +import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; +import com.baiyue.finance.service.FinanceCustomerAccountService; +import com.baiyue.finance.service.FinanceEventException; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FinanceCustomerAccountServiceTest { + @Test + void listsMasterCustomersWithoutImplicitPermanentCreditAndForwardsSearchFields() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + IamCustomerClient iam = mock(IamCustomerClient.class); + when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of()); + when(iam.page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of( + new IamCustomerClient.Customer(900000000000000001L, "TEN-001", "Alice Trading", "ACTIVE", "Alice", "13800138000")), + 1, 20, 1, 1))); + + var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token") + .list(1, 20, " Alice ", "active", 900000000000000001L); + + AccountSummary row = result.data().get(0); + assertThat(row.customerCode()).isEqualTo("TEN-001"); + assertThat(row.contactName()).isEqualTo("Alice"); + assertThat(row.contactPhone()).isEqualTo("13800138000"); + assertThat(row.outstandingReceivable()).isZero(); + assertThat(row.pendingBilling()).isZero(); + assertThat(row.creditLimit()).isZero(); + assertThat(row.availableCredit()).isZero(); + assertThat(row.availableBalance()).isZero(); + assertThat(row.creditExpiresOn()).isNull(); + assertThat(row.creditActive()).isFalse(); + verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L); + } + + @Test + void operatingFundQueriesUseFormalArUnbilledApDedupAndSupplierOnlyPaidAmounts() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + IamCustomerClient iam = mock(IamCustomerClient.class); + when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of()); + when(iam.page("internal-token", 1, 20, null, null, 9L)).thenReturn(ApiResponse.success( + new PageResult<>(List.of(new IamCustomerClient.Customer(9L, "C-9", "客户9", "ACTIVE", null, null)), + 1, 20, 1, 1))); + + new FinanceCustomerAccountService(jdbc, iam, "internal-token").list(1, 20, null, null, 9L); + + var sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc, atLeastOnce()).queryForList(sql.capture(), any(Object[].class)); + String all = String.join("\n", sql.getAllValues()); + assertThat(all) + .contains("SUM(outstanding_amount) amount FROM fin_settlement", "direction='RECEIVABLE'") + .contains("cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND status='CONFIRMED'") + .contains("direction='PAYABLE' AND status<>'VOIDED'") + .contains("NOT EXISTS (SELECT 1 FROM fin_settlement_line") + .contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'") + .contains("review_status='APPROVED'", "currency_code='CNY'", "amount>0"); + } + + @Test + void rejectsUnsupportedFinanceAccountStatus() { + var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token"); + assertThatThrownBy(() -> service.list(1, 20, null, "PENDING", null)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()) + .isEqualTo("FIN-REQ-400"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceExceptionHandlerTest.java b/finance/src/test/java/com/baiyue/finance/FinanceExceptionHandlerTest.java new file mode 100644 index 000000000..87581fddc --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceExceptionHandlerTest.java @@ -0,0 +1,31 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.finance.controller.FinanceExceptionHandler; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceExceptionHandlerTest { + private final FinanceExceptionHandler handler = new FinanceExceptionHandler(); + + @Test + void returnsStableFinanceConflictCodeAndTraceFields() { + var response = handler.business(new BizException(ResultCode.CONFLICT, "幂等键冲突")); + + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo("FIN_409"); + assertThat(response.getBody().requestId()).isNotBlank(); + assertThat(response.getBody().traceId()).isNotBlank(); + } + + @Test + void dependencyFailureKeepsInfrastructureHttpStatus() { + var response = handler.business(new BizException(ResultCode.SERVICE_UNAVAILABLE, "File服务不可用")); + + assertThat(response.getStatusCode().value()).isEqualTo(503); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo("FIN_503"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java new file mode 100644 index 000000000..eed2eb18e --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java @@ -0,0 +1,87 @@ +package com.baiyue.finance; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Assumptions; +import org.yaml.snakeyaml.Yaml; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceExportContractTest { + private static final List TYPES = List.of( + "FINANCE_CUSTOMER_ACCOUNT_LIST", "FINANCE_CUSTOMER_ACCOUNT_LEDGER", "FINANCE_FEE_SOURCE_LIST", + "FINANCE_RECONCILIATION_STATEMENT_LIST", "FINANCE_RECEIVABLE_SETTLEMENT_LIST", + "FINANCE_RECEIVABLE_SETTLEMENT_DETAIL", "FINANCE_PAYABLE_SETTLEMENT_LIST", "FINANCE_PAYABLE_SETTLEMENT_DETAIL", + "FINANCE_RECEIVABLE_STATISTICS"); + + @Test + void keepsAllFileDefinitionsOnTheSameNineTypes() throws Exception { + Path backendRoot = backendRoot(); + String application = Files.readString(backendRoot.resolve("file/src/main/resources/application.yml"), StandardCharsets.UTF_8); + String local = Files.readString(backendRoot.resolve("config/nacos/local/file-service.yml"), StandardCharsets.UTF_8); + String cn = Files.readString(backendRoot.resolve("config/nacos/cn/file-service.yml"), StandardCharsets.UTF_8); + + Yaml yaml = new Yaml(); + assertThat((Object) yaml.load(application)).isNotNull(); + assertThat((Object) yaml.load(local)).isNotNull(); + assertThat((Object) yaml.load(cn)).isNotNull(); + + for (String type : TYPES) { + assertThat(application).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches"); + assertThat(local).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches"); + assertThat(cn).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches"); + } + } + + @Test + void keepsOuterOpenApiOnTheSameNineTypesWhenAvailable() throws Exception { + Path outerRoot = outerRoot(); + Assumptions.assumeTrue(outerRoot != null, "outer product contract repository is not part of this checkout"); + + String openApi = Files.readString( + outerRoot.resolve("docs/财务管理/openapi/finance-core-five-pages-v1.yaml"), + StandardCharsets.UTF_8); + assertThat((Object) new Yaml().load(openApi)).isNotNull(); + assertThat(openApi).contains("/internal/exports/v1/{exportType}/batches:") + .contains("customerId?, status?, settlementNo?") + .contains("所有 DETAIL 仅 settlementId") + .contains("仅三类运营资金:待收客户款、待付供应商款、已线下支付供应商款") + .contains("operatingCards: [outstandingReceivable, outstandingPayable, paidCost]") + .contains("辅助字段只允许用于列表、明细或历史说明,不得生成第四类运营资金卡片") + .contains("AP outstanding加未制单有效ON_ACCOUNT来源并去重") + .contains("已线下支付供应商款;不存在客户线下支付含义") + .doesNotContain("DETAIL: settlementId, direction"); + for (String type : TYPES) { + assertThat(openApi).contains("- " + type); + } + } + + private static Path backendRoot() { + Path current = Path.of("").toAbsolutePath().normalize(); + while (current != null) { + if (Files.exists(current.resolve("pom.xml")) + && Files.isDirectory(current.resolve("finance")) + && Files.isDirectory(current.resolve("file"))) { + return current; + } + current = current.getParent(); + } + throw new IllegalStateException("backend repository root not found"); + } + + private static Path outerRoot() { + Path current = backendRoot(); + while (current != null) { + if (Files.isDirectory(current.resolve("apps/admin-web")) + && Files.isDirectory(current.resolve("docs/财务管理"))) { + return current; + } + current = current.getParent(); + } + return null; + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java new file mode 100644 index 000000000..8806fff05 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java @@ -0,0 +1,89 @@ +package com.baiyue.finance; + +import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem; +import com.baiyue.finance.service.FinanceQueryService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceFeePoolDirectionBehaviorTest { + @Test + void receivablePoolNeverContainsPayableSource() { + DirectionJdbc jdbc = new DirectionJdbc(); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + + List result = service.feePool(7L, "RECEIVABLE"); + + assertThat(result).isNotEmpty().allMatch(item -> "RECEIVABLE".equals(item.direction())); + assertThat(jdbc.sourceDirections).containsExactly("RECEIVABLE"); + assertThat(jdbc.costQueryCount).isZero(); + } + + @Test + void payablePoolNeverContainsReceivableSource() { + DirectionJdbc jdbc = new DirectionJdbc(); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + + List result = service.feePool(7L, "PAYABLE"); + + assertThat(result).isNotEmpty().allMatch(item -> "PAYABLE".equals(item.direction())); + assertThat(jdbc.sourceDirections).containsExactly("PAYABLE"); + assertThat(jdbc.costQueryCount).isZero(); + } + + @Test + void payablePoolOnlyQueriesApprovedUnsettledSourcesForTheSelectedSupplier() { + DirectionJdbc jdbc = new DirectionJdbc(); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + + service.feePool(7L, "PAYABLE", 81L); + + assertThat(jdbc.sourceSql).contains( + "review_status='APPROVED'", + "settlement_mode='ON_ACCOUNT'", + "currency_code='CNY'", + "NOT EXISTS (SELECT 1 FROM fin_settlement_line", + "s.status<>'VOIDED'", + "CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED)=?"); + assertThat(jdbc.sourceArgs).containsExactly(7L, "PAYABLE", 81L); + } + + private static final class DirectionJdbc extends JdbcTemplate { + private final List sourceDirections = new ArrayList<>(); + private int costQueryCount; + private String sourceSql; + private List sourceArgs = List.of(); + + @Override + @SuppressWarnings("unchecked") + public List query(String sql, RowMapper mapper, Object... args) { + if (sql.contains("FROM fin_fee_source")) { + sourceSql = sql; + sourceArgs = List.of(args); + String direction = String.valueOf(args[1]); + sourceDirections.add(direction); + return (List) List.of(item(direction, "FIRSTLEG_FEE", 1L)); + } + if (sql.contains("FROM fin_cost")) { + costQueryCount++; + return (List) List.of(item("PAYABLE", "MANUAL_COST", 2L)); + } + return List.of(); + } + + private static FeePoolItem item(String direction, String sourceType, long sourceId) { + return new FeePoolItem(direction + ":" + sourceId, 7L, direction, sourceType, sourceId, 1, + 11L, 21L, null, "清关费", new BigDecimal("100.00"), "CNY", + "PAYABLE".equals(direction) ? "ON_ACCOUNT" : null, null, null, null, List.of(), "AVAILABLE", + LocalDateTime.of(2026, 8, 11, 1, 0)); + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java new file mode 100644 index 000000000..5cee9c88a --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java @@ -0,0 +1,355 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.finance.client.FirstlegFeeSourceClient; +import com.baiyue.finance.dto.FinanceFeeSourceEvent; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceFeeSourceEventService; +import com.baiyue.finance.service.FinanceSupplierValidator; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HexFormat; +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.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import org.mockito.ArgumentCaptor; + +class FinanceFeeSourceEventServiceTest { + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + private final FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class); + private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + private final FinanceSupplierValidator supplierValidator = mock(FinanceSupplierValidator.class); + private final FinanceFeeSourceEventService service = new FinanceFeeSourceEventService(jdbc, firstleg, mapper, supplierValidator); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "internalToken", "test-token"); + lenient().when(supplierValidator.requireActive(anyLong(), any())).thenReturn( + new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot( + 71L, "现有供应商", "TRUCKING", "ACTIVE")); + } + + @Test + void importsApprovedSettlementInboundFee() { + FinanceFeeSourceEvent event = inboundEvent("published-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); + givenNewEvent(); + when(firstleg.inbound("test-token", 21, 31, 1)).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null))); + + assertThat(service.consume(event)).isEqualTo(101L); + verify(firstleg).inbound("test-token", 21, 31, 1); + verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") + && sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), any(Object[].class)); + } + + @Test + void importsApprovedSettlementContainerFeeWithCustomerOwnership() { + FinanceFeeSourceEvent event = containerEvent("container-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); + givenNewEvent(); + when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot("CONTAINER_FEE", 7, 1, null, 41L))); + + assertThat(service.consume(event)).isEqualTo(101L); + verify(firstleg).container("test-token", 41, 31, 1); + verify(jdbc).update(contains("status='SUPERSEDED'"), eq("CONTAINER_FEE"), eq(31L)); + ArgumentCaptor values = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") + && sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), values.capture()); + assertThat(values.getValue()[8]).as("fin_fee_source.container_id keeps loadingPlanId") + .isEqualTo(41L); + assertThat(values.getValue()[9]).as("fin_fee_source.container_booking_id stores physical container") + .isEqualTo(61L); + } + + @Test + void rejectsContainerFeeSnapshotWithoutPhysicalContainerBookingId() { + FinanceFeeSourceEvent event = containerEvent("container-missing-booking", "FIRSTLEG_FEE_PUBLISHED", 1, 7); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), + eq("container-missing-booking"))).thenReturn(List.of()); + var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1, + null, null, null, 41L, null, "C-41", 7, "客户", null, null, + null, null, null, null, null, List.of(), 61L, "CUSTOMS", "清关费", + "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", new BigDecimal("100.00"), + "APPROVED", "PUBLISHED", LocalDateTime.of(2026, 8, 11, 1, 0), + BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", 1); + when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot)); + + assertThatThrownBy(() -> service.consume(event)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-014"); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); + } + + @Test + void importsContainerPayableWithCounterpartySnapshotForApSettlement() { + FinanceFeeSourceEvent event = containerEvent("container-payable-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); + givenNewEvent(); + var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1, + null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商", + "TRUCKING", "ON_ACCOUNT", null, null, null, List.of(), + 61L, "TRUCKING", "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11), + "CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED", + LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null, + "source-hash-payable", 1); + when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot)); + + assertThat(service.consume(event)).isEqualTo(101L); + + verify(supplierValidator).requireActive(71L, "TRUCKING"); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class)); + ArgumentCaptor values = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") + && sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), values.capture()); + assertThat(values.getValue()[20].toString()) + .contains("\"supplierId\":71", "\"supplierName\":\"现有供应商\"", "\"supplierCategory\":\"TRUCKING\""); + } + + @Test + void importsPayableWithUnrestrictedActiveSupplier() { + FinanceFeeSourceEvent event = containerEvent("container-unrestricted-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); + givenNewEvent(); + var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1, + null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商", + null, "ON_ACCOUNT", null, null, null, List.of(), + 61L, "STORAGE", "仓储费", "PAYABLE", LocalDate.of(2026, 8, 11), + "CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED", + LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null, + "source-hash-unrestricted", 1); + when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot)); + + assertThat(service.consume(event)).isEqualTo(101L); + verify(supplierValidator).requireActive(71L, null); + } + + @Test + void importsDirectPaidSnapshotWithoutTreatingItAsOnAccount() { + FinanceFeeSourceEvent event = containerEvent("container-direct-paid-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); + givenNewEvent(); + var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1, + null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商", + "TRUCKING", "DIRECT_PAID", LocalDateTime.of(2026, 8, 11, 10, 0), + "BANK_TRANSFER", "BANK-001", List.of(901L), 61L, "TRUCKING", "拖车费", + "PAYABLE", LocalDate.of(2026, 8, 11), "CNY", new BigDecimal("300.00"), + "APPROVED", "PUBLISHED", LocalDateTime.of(2026, 8, 11, 11, 0), + null, null, null, null, "source-hash-direct", 1); + when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot)); + + assertThat(service.consume(event)).isEqualTo(101L); + + ArgumentCaptor values = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") + && sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), values.capture()); + assertThat(values.getValue()[14]).isEqualTo("DIRECT_PAID"); + assertThat(values.getValue()[16]).isEqualTo("BANK_TRANSFER"); + assertThat(values.getValue()[18]).isEqualTo("[901]"); + ArgumentCaptor costValues = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(startsWith("INSERT INTO fin_cost"), costValues.capture()); + assertThat(costValues.getValue()[0]).isEqualTo(7L); + assertThat(costValues.getValue()[1]).isEqualTo("DIRECT-CONTAINER_FEE-31-V1"); + assertThat(costValues.getValue()[2]).isEqualTo("CONTAINER_FEE"); + assertThat(costValues.getValue()[3]).isEqualTo(31L); + assertThat(costValues.getValue()[4]).isEqualTo(1); + assertThat(costValues.getValue()[8]).as("fin_cost.container_id stores containerBookingId") + .isEqualTo(61L); + assertThat(costValues.getValue()[15]).isEqualTo("[901]"); + assertThat(costValues.getValue()[17]).isEqualTo("BANK_TRANSFER"); + assertThat(costValues.getValue()[18]).isEqualTo("BANK-001"); + } + + @Test + void adjustmentCreatesNewImmutableVersionAndSupersedesAvailableVersion() { + FinanceFeeSourceEvent event = inboundEvent("adjusted-2", "FIRSTLEG_FEE_ADJUSTED", 2, 7); + givenNewEvent(); + when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null))); + + assertThat(service.consume(event)).isEqualTo(101L); + verify(jdbc).update(contains("status='SUPERSEDED'"), eq("INBOUND_FEE"), eq(31L)); + verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") + && sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), any(Object[].class)); + } + + @Test + void adjustmentRejectsSourceFrozenByActiveBillOrAp() { + FinanceFeeSourceEvent event = inboundEvent("adjusted-frozen", "FIRSTLEG_FEE_ADJUSTED", 2, 7); + givenNewEvent(); + when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))) + .thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null))); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system='FIRSTLEG'"), + eq(Integer.class), anyString(), eq(31L))).thenReturn(1); + + assertThatThrownBy(() -> service.consume(event)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-016"); + verify(jdbc, never()).update(contains("status='SUPERSEDED'"), anyLong()); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); + } + + @Test + void voidMarksExistingVersionWithoutDeletingHistory() { + FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-2"))).thenReturn(List.of()); + when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null))); + when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), eq(31L), eq(2))) + .thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE"))); + + assertThat(service.consume(event)).isEqualTo(88L); + verify(jdbc).update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", 88L); + verify(jdbc, never()).update(startsWith("DELETE"), any(Object[].class)); + } + + @Test + void voidRejectsSourceFrozenByActiveBillOrAp() { + FinanceFeeSourceEvent event = inboundEvent("voided-frozen", "FIRSTLEG_FEE_VOIDED", 2, 7); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-frozen"))) + .thenReturn(List.of()); + when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))) + .thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null))); + when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), eq(31L), eq(2))) + .thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE"))); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=?"), + eq(Integer.class), eq(88L))).thenReturn(1); + + assertThatThrownBy(() -> service.consume(event)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-016"); + verify(jdbc, never()).update(contains("SET status='VOIDED'"), eq(88L)); + } + + @Test + void sameEventAndContentReturnsFirstResourceWithoutCallingFirstlegAgain() throws Exception { + FinanceFeeSourceEvent event = inboundEvent("same-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); + String requestHash = sha256(mapper.writeValueAsString(event)); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("same-1"))) + .thenReturn(List.of(Map.of("payload_hash", requestHash, "status", "PROCESSED", "result_resource_id", 66L))); + + assertThat(service.consume(event)).isEqualTo(66L); + verifyNoInteractions(firstleg); + } + + @Test + void sameEventIdWithDifferentContentReturnsStableConflictCode() { + FinanceFeeSourceEvent event = inboundEvent("conflict-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("conflict-1"))) + .thenReturn(List.of(Map.of("payload_hash", "different", "status", "PROCESSED", "result_resource_id", 66L))); + + assertThatThrownBy(() -> service.consume(event)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-002"); + } + + @Test + void rejectsSnapshotBelongingToAnotherCustomer() { + FinanceFeeSourceEvent event = inboundEvent("tenant-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("tenant-1"))).thenReturn(List.of()); + when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(1))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 8, 1, 21L, null))); + + assertThatThrownBy(() -> service.consume(event)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-007"); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); + } + + @Test + void rejectsApprovedButUnpublishedNonVoidedFee() { + FinanceFeeSourceEvent event = inboundEvent("draft-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("draft-1"))).thenReturn(List.of()); + when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(1))).thenReturn( + ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "DRAFT", new BigDecimal("100.00")))); + + assertThatThrownBy(() -> service.consume(event)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-015"); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); + } + + @Test + void rejectsZeroAmount() { + assertInvalidAmount("zero-1", BigDecimal.ZERO); + } + + @Test + void rejectsNegativeAmount() { + assertInvalidAmount("negative-1", new BigDecimal("-0.01")); + } + + private void givenNewEvent() { + when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), anyString())).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), eq(31L), anyInt())).thenReturn(List.of()); + when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), anyString(), eq(31L), anyInt())).thenReturn(101L); + } + + @Test + void lateOlderVersionDoesNotReplaceNewerSource() { + givenNewEvent(); + when(firstleg.container("test-token", 41, 31, 1)).thenReturn( + ApiResponse.success(snapshot("CONTAINER_FEE", 7, 1, null, 41L))); + when(jdbc.queryForList(startsWith("SELECT source_fee_version"), eq("CONTAINER_FEE"), eq(31L))) + .thenReturn(List.of(Map.of("source_fee_version", 2))); + assertThatThrownBy(() -> service.consume(containerEvent("late-version", "FIRSTLEG_FEE_PUBLISHED", 1, 7))) + .hasMessageContaining("版本已过期"); + verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class)); + verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class)); + } + + private FinanceFeeSourceEvent inboundEvent(String id, String type, int version, long tenant) { + return event(id, type, version, tenant, new FinanceFeeSourceEvent.Payload("21", null, "31", version)); + } + + private FinanceFeeSourceEvent containerEvent(String id, String type, int version, long tenant) { + return event(id, type, version, tenant, new FinanceFeeSourceEvent.Payload(null, "41", "31", version)); + } + + private FinanceFeeSourceEvent event(String id, String type, int version, long tenant, FinanceFeeSourceEvent.Payload payload) { + try { + return new FinanceFeeSourceEvent(id, type, 1, "firstleg-service", LocalDateTime.of(2026, 8, 11, 1, 2), + String.valueOf(tenant), payload.inboundId() == null ? "CONTAINER" : "INBOUND", + payload.inboundId() == null ? payload.containerId() : payload.inboundId(), version, + id, sha256(mapper.writeValueAsString(payload)), payload); + } catch (Exception error) { + throw new AssertionError(error); + } + } + + private FirstlegFeeSourceClient.Snapshot snapshot(String sourceType, long customerId, int version, Long inboundId, Long containerId) { + return snapshot(sourceType, customerId, version, inboundId, containerId, "PUBLISHED", new BigDecimal("100.00")); + } + + private FirstlegFeeSourceClient.Snapshot snapshot(String sourceType, long customerId, int version, + Long inboundId, Long containerId, + String publicationStatus, BigDecimal amount) { + return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId, + inboundId == null ? null : "IB-21", 51L, containerId, containerId == null ? null : 61L, + containerId == null ? null : "C-41", customerId, "客户", null, null, null, + null, null, null, null, List.of(), 61L, "CUSTOMS", + "清关费", "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", amount, + "APPROVED", publicationStatus, LocalDateTime.of(2026, 8, 11, 1, 0), + BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", version); + } + + private void assertInvalidAmount(String eventId, BigDecimal amount) { + FinanceFeeSourceEvent event = inboundEvent(eventId, "FIRSTLEG_FEE_PUBLISHED", 1, 7); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq(eventId))).thenReturn(List.of()); + when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(1))).thenReturn( + ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "PUBLISHED", amount))); + + assertThatThrownBy(() -> service.consume(event)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-010"); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); + } + + private static String sha256(String value) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceMigrationContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceMigrationContractTest.java new file mode 100644 index 000000000..5eb6a2f2b --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceMigrationContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.finance; + +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 FinanceFeeSourceMigrationContractTest { + @Test + void moduleMigrationAddsImmutableSourceAndInboxIdempotencyFields() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/fee-source-event-migration.sql"), StandardCharsets.UTF_8); + assertThat(sql).contains("information_schema.columns", "source_type", "direction", "container_id", + "source_hash", "source_snapshot", "payload_hash", "result_resource_id", "error_code"); + assertThat(sql).contains("settlement_mode", "paid_at", "payment_method", + "payment_reference_no", "payment_voucher_file_ids"); + assertThat(sql).contains("cost_origin", "FIRSTLEG_DIRECT_PAID", "BUSINESS_DIRECT_PAID", "warehouse_id", "source_fee_version", + "uk_fin_cost_source_version", "Finance直接已付成本字段不完整"); + assertThat(sql).contains("AVAILABLE可结算", "SUPERSEDED已被新版本替代", "VOIDED已作废"); + assertThat(sql).contains("finance_fee_source_assert_schema"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java new file mode 100644 index 000000000..7b826cd3b --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -0,0 +1,390 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.client.FirstlegFeeSourceClient; +import com.baiyue.finance.controller.FinancePhase1OperationController; +import com.baiyue.finance.dto.FinancePhase1Dtos; +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; +import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinancePhase1OperationService; +import com.baiyue.finance.service.FinanceCustomerAccountService; +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.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +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.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.mock; +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 FinancePhase1OperationBehaviorTest { + @Mock FinancePhase1OperationService operationService; + @Mock FinanceCustomerAccountService customerAccountService; + + @Test + void controllerDelegatesFiveFrozenOperationsAndUsesAuthenticatedOperator() { + FinancePhase1OperationController controller = new FinancePhase1OperationController(operationService, customerAccountService); + AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of()); + var review = new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0); + var action = new FinancePhase1Dtos.FeeSourceActionRequest("提交审核", 1); + + controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE", 9L); + controller.listDerivedCustomerAccountLedger(9L, 2, 50, "RECEIPT", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31)); + controller.listFinanceFeeSources(1, 20, 9L, "RECEIVABLE", "AVAILABLE", "APPROVED", + false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9"); + controller.getFinanceFeeSource(88L); + controller.reviewFinanceFeeSource(user, 88L, "review-key", review); + controller.submitFinanceFeeSource(user, 88L, "submit-key", action); + controller.voidFinanceFeeSource(user, 88L, "void-key", action); + + verify(customerAccountService).list(1, 20, "柏悦", "ACTIVE", 9L); + verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any()); + verify(operationService).listFinanceFeeSources(eq(1), eq(20), eq(9L), eq("RECEIVABLE"), + eq("AVAILABLE"), eq("APPROVED"), eq(false), any(), any(), eq("IN-9")); + verify(operationService).getFinanceFeeSource(88L); + verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review); + verify(operationService).submitFinanceFeeSource(88L, 71L, "submit-key", action); + verify(operationService).voidFinanceFeeSource(88L, 71L, "finance", "void-key", action); + } + + @Test + void pageEnvelopeAlwaysContainsTraceMetadata() { + PageEnvelope response = PageEnvelope.success(List.of("row"), 1, 20, 1); + assertThat(response.code()).isEqualTo("200"); + assertThat(response.requestId()).isNotBlank(); + assertThat(response.traceId()).isNotBlank(); + assertThat(response.page().total()).isEqualTo(1); + } + + @Test + void ledgerRejectsInvalidCustomerBeforeAnyQuery() { + FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper()); + assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400"); + } + + @Test + void accountListReturnsFixedPagedDto() { + AccountJdbc jdbc = new AccountJdbc(); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + + PageEnvelope result = + service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE"); + + assertThat(result.data()).singleElement().satisfies(account -> { + assertThat(account.customerId()).isEqualTo(9L); + assertThat(account.customerName()).isEqualTo("客户9"); + assertThat(account.outstandingReceivable()).isEqualByComparingTo("100.00"); + }); + assertThat(result.page().total()).isEqualTo(1); + assertThat(result.requestId()).isNotBlank(); + assertThat(result.traceId()).isNotBlank(); + assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot"); + assertThat(jdbc.accountListSql) + .contains("ar.direction='RECEIVABLE'", "cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "FROM fin_settlement_line sl") + .contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'"); + assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)"); + } + + @Test + void ledgerQueriesEveryDerivedSourceWithinCustomerScope() { + AccountJdbc jdbc = new AccountJdbc(); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + + PageEnvelope result = + service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null); + + assertThat(result.data()).isEmpty(); + assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L, 9L); + assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql) + .contains("cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "status='CONFIRMED'")); + assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql) + .contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'")); + } + + @Test + void feeSourceListUsesFormalFieldsAndAllActiveFilters() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Long.class), any(Object[].class))) + .thenReturn(1L); + when(jdbc.query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(88L); + when(rs.getLong("source_fee_id")).thenReturn(9001L); + when(rs.getInt("source_fee_version")).thenReturn(3); + when(rs.getLong("customer_id")).thenReturn(9L); + when(rs.getLong("order_id")).thenReturn(19L); + when(rs.getLong("inbound_id")).thenReturn(29L); + when(rs.getLong("container_id")).thenReturn(39L); + when(rs.getString("source_system")).thenReturn("FIRSTLEG"); + when(rs.getString("source_type")).thenReturn("INBOUND_FEE"); + when(rs.getString("source_no")).thenReturn("IN-9"); + when(rs.getString("direction")).thenReturn("RECEIVABLE"); + when(rs.getString("fee_item_name")).thenReturn("入库操作费"); + when(rs.getString("currency_code")).thenReturn("CNY"); + when(rs.getString("review_status")).thenReturn("APPROVED"); + when(rs.getString("effective_status")).thenReturn("AVAILABLE"); + when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":\"79\",\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}"); + when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00")); + when(rs.getBoolean("statement_generated")).thenReturn(false); + return List.of(mapper.mapRow(rs, 0)); + }); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + + var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved", + false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9"); + + assertThat(result.page()).satisfies(meta -> { + assertThat(meta.page()).isEqualTo(2); + assertThat(meta.pageSize()).isEqualTo(50); + assertThat(meta.total()).isEqualTo(1); + }); + assertThat(result.data()).singleElement().satisfies(source -> { + assertThat(source.sourceSystem()).isEqualTo("FIRSTLEG"); + assertThat(source.sourceBusinessId()).isEqualTo("IN-9"); + assertThat(source.inboundCode()).isEqualTo("IN-9"); + assertThat(source.containerNo()).isEqualTo("CONT-9"); + assertThat(source.supplierId()).isEqualTo(79L); + assertThat(source.supplierName()).isEqualTo("供应商A"); + assertThat(source.reviewStatus()).isEqualTo("APPROVED"); + assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11)); + assertThat(source.statementGenerated()).isFalse(); + }); + verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class)); + } + + @Test + void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.query(startsWith("SELECT id,bill_no"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(81L); + when(rs.getLong("created_by")).thenReturn(71L); + when(rs.getString("bill_no")).thenReturn("REC-81"); + when(rs.getString("created_by_name")).thenReturn("platform_admin"); + when(rs.getString("remark")).thenReturn("八月账单"); + when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("5200.00")); + when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf("2026-08-17 13:00:00")); + return List.of(mapper.mapRow(rs, 0)); + }); + when(jdbc.query(startsWith("SELECT id,receipt_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of()); + when(jdbc.query(startsWith("SELECT id,cost_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + + PageEnvelope result = + service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null); + + assertThat(result.data()).singleElement().satisfies(entry -> { + assertThat(entry.businessType()).isEqualTo("SETTLEMENT_ISSUED"); + assertThat(entry.documentType()).isEqualTo("BILL"); + assertThat(entry.documentNo()).isEqualTo("REC-81"); + assertThat(entry.occurredAt()).isEqualTo(LocalDateTime.of(2026, 8, 17, 13, 0)); + assertThat(entry.operatorName()).isEqualTo("platform_admin"); + }); + } + + @Test + void approvedFeeCannotBeEditedAsDraft() throws Exception { + JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + + assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED"); + } + + @Test + void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception { + FinancePhase1OperationService service = new FinancePhase1OperationService( + feeSourceJdbc("APPROVED", true, 2), objectMapper()); + + FeeSourceDetail detail = service.getFinanceFeeSource(88L); + + assertThat(detail.feeSourceId()).isEqualTo(88L); + assertThat(detail.customerId()).isEqualTo(9L); + assertThat(detail.currency()).isEqualTo("CNY"); + assertThat(detail.reviewStatus()).isEqualTo("APPROVED"); + } + + @Test + void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception { + JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + + var request = new FinancePhase1Dtos.FeeSourceEditRequest( + new BigDecimal("120.00"), "更正金额", 1); + assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "edit-key", request, false)) + .isInstanceOf(FinanceEventException.class).hasMessageContaining("业务费用必须回到对应业务费用明细调整"); + assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "adjust-key", request, true)) + .isInstanceOf(FinanceEventException.class).hasMessageContaining("业务费用必须回到对应业务费用明细调整"); + verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET amount="), + (Object[]) any()); + } + + @Test + void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception { + JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), + eq("FEE_SOURCE_VOID"), eq("void-key"))).thenReturn(List.of()); + FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class); + when(firstleg.voidSource(eq("internal-token"), eq("void-key"), anyString(), any())) + .thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult( + "INBOUND_FEE", 700L, 3, "VOIDED", false))); + FinancePhase1OperationService service = new FinancePhase1OperationService( + jdbc, objectMapper(), firstleg, "internal-token"); + + FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance", + "void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2)); + + assertThat(result.sourceStatus()).isEqualTo("AVAILABLE"); + var command = org.mockito.ArgumentCaptor.forClass(FirstlegFeeSourceClient.VoidCommand.class); + verify(firstleg).voidSource(eq("internal-token"), eq("void-key"), anyString(), command.capture()); + assertThat(command.getValue()).satisfies(value -> { + assertThat(value.sourceType()).isEqualTo("INBOUND_FEE"); + assertThat(value.sourceFeeId()).isEqualTo(700L); + assertThat(value.sourceVersion()).isEqualTo(3); + assertThat(value.customerId()).isEqualTo(9L); + assertThat(value.inboundId()).isEqualTo(100L); + assertThat(value.operatorName()).isEqualTo("finance"); + assertThat(value.reason()).isEqualTo("测试作废"); + }); + verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET status='VOIDED'"), + (Object[]) any()); + } + + @Test + void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + AtomicInteger reads = new AtomicInteger(); + when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + String review = reads.getAndIncrement() == 0 ? "PENDING" : "APPROVED"; + int version = "PENDING".equals(review) ? 0 : 1; + return List.of(mapper.mapRow(resultSet(review, false, version), 0)); + }); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false); + when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), + eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of()); + when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L), + eq("FEE_SOURCE_REVIEW"), eq("review-key"), anyString())).thenReturn(1); + when(jdbc.update(startsWith("UPDATE fin_fee_source SET review_status="), + eq("APPROVED"), eq("核对无误"), eq(88L), eq(0))).thenReturn(1); + when(jdbc.update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"), + eq(88L), eq("FEE_SOURCE_REVIEW_APPROVE"), eq("核对无误"), anyString(), anyString(), + eq(71L), eq("操作人 71"), any(), any())).thenReturn(1); + when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), + eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + + FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key", + new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0)); + + assertThat(result.reviewStatus()).isEqualTo("APPROVED"); + assertThat(result.operationVersion()).isEqualTo(1); + verify(jdbc).update(startsWith("UPDATE fin_fee_source SET review_status="), eq("APPROVED"), eq("核对无误"), eq(88L), eq(0)); + } + + private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + return List.of(mapper.mapRow(resultSet(reviewStatus, included, version), 0)); + }); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false); + when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of()); + lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), + eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of()); + return jdbc; + } + + private static ResultSet resultSet(String reviewStatus, boolean included, int version) throws Exception { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(88L); + when(rs.getLong("source_fee_id")).thenReturn(700L); + when(rs.getLong("customer_id")).thenReturn(9L); + when(rs.getLong("order_id")).thenReturn(90L); + when(rs.getLong("inbound_id")).thenReturn(100L); + when(rs.getLong("container_id")).thenReturn(200L); + when(rs.wasNull()).thenReturn(false, false, true, false, false, true); + when(rs.getInt("source_fee_version")).thenReturn(3); + when(rs.getString("source_system")).thenReturn("FIRSTLEG"); + when(rs.getString("source_no")).thenReturn("IN-100"); + when(rs.getString("source_type")).thenReturn("INBOUND_FEE"); + when(rs.getString("direction")).thenReturn("RECEIVABLE"); + when(rs.getString("fee_item_name")).thenReturn("头程运费"); + when(rs.getString("currency_code")).thenReturn("CNY"); + when(rs.getString("status")).thenReturn("AVAILABLE"); + when(rs.getString("source_hash")).thenReturn("a".repeat(64)); + when(rs.getString("review_status")).thenReturn(reviewStatus); + when(rs.getString("review_reason")).thenReturn(null); + when(rs.getInt("operation_version")).thenReturn(version); + when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00")); + when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"inboundCode\":\"NYEXX2608180003\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"publicationStatus\":\"PUBLISHED\",\"operationVersion\":" + version + "}"); + return rs; + } + + private static ObjectMapper objectMapper() { + return new ObjectMapper().findAndRegisterModules(); + } + + private static final class AccountJdbc extends JdbcTemplate { + private final List ledgerCustomerIds = new ArrayList<>(); + private final List ledgerSql = new ArrayList<>(); + private String accountListSql = ""; + + @Override + public T queryForObject(String sql, Class requiredType, Object... args) { + if (sql.startsWith("SELECT COUNT(*)")) return requiredType.cast(1L); + return null; + } + + @Override + @SuppressWarnings("unchecked") + public List query(String sql, RowMapper rowMapper, Object... args) { + if (sql.startsWith("SELECT c.customer_id")) { + accountListSql = sql; + return (List) List.of(new FinancePhase1Dtos.AccountSummary( + 9L, "9", "客户9", null, null, new BigDecimal("100.00"), + new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO, + BigDecimal.ZERO, BigDecimal.ZERO, + BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, + BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null, false, 0, 0, + "ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0))); + } + if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no") + || sql.startsWith("SELECT id,cost_no") || sql.startsWith("SELECT p.id")) { + ledgerCustomerIds.add(((Number) Arrays.asList(args).get(0)).longValue()); + ledgerSql.add(sql); + return List.of(); + } + return List.of(); + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java new file mode 100644 index 000000000..7bc05f727 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java @@ -0,0 +1,45 @@ +package com.baiyue.finance; + +import com.baiyue.finance.controller.FinancePhase1OperationController; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinancePhase1PermissionContractTest { + @Test + void freezesG1AndG2ControllerPermissionsAndHttpActions() throws Exception { + assertEndpoint("listDerivedCustomerAccounts", GetMapping.class, + "platform:finance:customer-account:list", + int.class, int.class, String.class, String.class, Long.class); + assertEndpoint("listDerivedCustomerAccountLedger", GetMapping.class, + "platform:finance:customer-account:list", + long.class, int.class, int.class, + String.class, java.time.LocalDate.class, java.time.LocalDate.class); + assertEndpoint("getFinanceFeeSource", GetMapping.class, + "platform:finance:customer-arap:list", long.class); + assertEndpoint("reviewFinanceFeeSource", PostMapping.class, + "platform:finance:fee-source:review", + com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class, + com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest.class); + assertEndpoint("editFinanceFeeSource", PutMapping.class, + "platform:finance:fee-source:edit", + com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class, + com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest.class); + } + + private static void assertEndpoint(String methodName, + Class mapping, + String authority, + Class... parameterTypes) throws Exception { + Method method = FinancePhase1OperationController.class.getMethod(methodName, parameterTypes); + assertThat(method.isAnnotationPresent(mapping)).isTrue(); + assertThat(method.getAnnotation(PreAuthorize.class).value()).contains(authority); + assertThat(method.getAnnotation(PreAuthorize.class).value()).doesNotContain(":view"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java new file mode 100644 index 000000000..3fd92a3a3 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java @@ -0,0 +1,118 @@ +package com.baiyue.finance; + +import com.baiyue.finance.controller.PlatformFinanceController; +import com.baiyue.finance.controller.FinanceSettlementController; +import com.baiyue.finance.controller.FinancePhase1OperationController; +import com.baiyue.finance.controller.ReconciliationStatementController; +import io.swagger.v3.oas.annotations.Parameter; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestParam; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinancePlatformListContractTest { + @Test + void platformCustomerFiltersAreOptionalAndDocumentAllCustomerDefault() throws Exception { + for (String methodName : new String[]{"feePool", "statements", "receivableSettlements", "payableSettlements"}) { + Method method = find(methodName); + var customer = method.getParameters()[0]; + assertThat(customer.getType()).isEqualTo(Long.class); + assertThat(customer.getAnnotation(RequestParam.class).required()).isFalse(); + assertThat(customer.getAnnotation(Parameter.class).description()).contains("省略时查询全部客户"); + } + } + + @Test + void platformListPermissionsRemainSeparatedByReceivableAndPayableData() throws Exception { + Map expected = Map.of( + "feePool", "platform:finance:customer-arap:list", + "statements", "platform:finance:customer-statement:list", + "receivableSettlements", "platform:finance:settlement-ar:list", + "payableSettlements", "platform:finance:settlement-ap:list"); + for (var entry : expected.entrySet()) { + assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value()) + .contains(entry.getValue()) + .doesNotContain(":view"); + } + } + + @Test + void platformReadEndpointsDoNotAcceptLegacyViewAuthorities() { + String[] legacyAuthorities = { + "platform:finance:account:view", + "platform:finance:fee-source:view", + "platform:finance:bill:view", + "platform:finance:settlement-ar:view", + "platform:finance:settlement-ap:view" + }; + for (Class controller : new Class[]{ + PlatformFinanceController.class, + FinancePhase1OperationController.class, + ReconciliationStatementController.class, + FinanceSettlementController.class + }) { + for (Method method : controller.getDeclaredMethods()) { + PreAuthorize permission = method.getAnnotation(PreAuthorize.class); + if (permission == null) continue; + assertThat(permission.value()).doesNotContain(legacyAuthorities); + } + } + } + + @Test + void allCustomerFeePoolResponseCarriesCustomerIdentity() { + assertThat(Arrays.stream(com.baiyue.finance.dto.FinanceReadModels.FeePoolItem.class + .getRecordComponents()).map(java.lang.reflect.RecordComponent::getName)) + .contains("customerId", "settlementMode", "paidAt", "paymentMethod", + "paymentReferenceNo", "paymentVoucherFileIds"); + } + + @Test + void financeDetailsKeepOperatorIdsAndExposeNameSnapshots() { + assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.ReconciliationSummary.class, + "periodStart", "periodEnd", "lineCount", "sourceCount", "sourceAmount", "adjustmentAmount", + "lastReceiptAt", "submittedAt", "reviewedAt", "issuedAt"); + assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.Statement.class, + "createdBy", "createdByName", "updatedBy", "updatedByName"); + assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.StatementLine.class, + "orderId", "inboundId", "inboundCode", "feeItemName", "amount"); + assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.BillRevision.class, + "createdBy", "createdByName"); + assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail.class, + "createdBy", "createdByName", "updatedBy", "updatedByName"); + assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine.class, + "direction", "sourceType", "sourceNo", "orderId", "inboundCode", "containerNo", + "businessDate", "quantity", "unitPrice"); + assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory.class, + "voucherFileIds", "createdBy", "createdByName"); + assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class, + "operatorId", "operatorName"); + assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class, + "createdBy", "createdByName"); + assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class, + "operatorId", "operatorName"); + assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class, + "reconciliationStatementNo", "feeItemSummary", "createdBy", "createdByName", "createdAt", + "updatedAt", "lastPaymentAt"); + assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine.class, + "orderId", "orderNo", "inboundId", "inboundCode", "containerId", "containerNo"); + } + + private static void assertRecordFields(Class type, String... names) { + assertThat(Arrays.stream(type.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName)) + .contains(names); + } + + private static Method find(String name) throws Exception { + return switch (name) { + case "feePool" -> PlatformFinanceController.class.getMethod(name, Long.class, String.class); + case "statements" -> PlatformFinanceController.class.getMethod(name, Long.class); + default -> FinanceSettlementController.class.getMethod(name, Long.class); + }; + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java new file mode 100644 index 000000000..c54a04c07 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java @@ -0,0 +1,70 @@ +package com.baiyue.finance; + +import com.baiyue.finance.service.FinanceQueryService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +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 FinancePlatformOptionalCustomerQueryTest { + @Test + void allCustomerQueriesOmitCustomerPredicateAndArguments() { + RecordingJdbc jdbc = new RecordingJdbc(); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + + service.feePool(null, null); + service.statements(null, false); + service.reconciliationStatements(null); + service.receivableSettlements(null, false); + service.payableSettlements(null); + + assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> { + assertThat(call.sql()).doesNotContain("customer_id=?", "tenant_id=?"); + assertThat(call.args()).isEmpty(); + }); + } + + @Test + void customerFilterAddsPredicateAndRealCustomerArgument() { + RecordingJdbc jdbc = new RecordingJdbc(); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + + service.feePool(31L, null); + service.statements(31L, false); + service.reconciliationStatements(31L); + service.receivableSettlements(31L, false); + service.payableSettlements(31L); + + assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> { + assertThat(call.sql()).containsAnyOf("customer_id=?", "tenant_id=?"); + assertThat(call.args()).containsExactly(31L); + }); + } + + @Test + void optionalCustomerNeverTreatsZeroAsAllCustomers() { + FinanceQueryService service = new FinanceQueryService(new RecordingJdbc(), new ObjectMapper()); + + assertThatThrownBy(() -> service.statements(0L, false)).hasMessageContaining("客户范围无效"); + assertThatThrownBy(() -> service.reconciliationStatements(0L)).hasMessageContaining("客户范围无效"); + assertThatThrownBy(() -> service.feePool(-1L, null)).hasMessageContaining("客户范围无效"); + } + + private record Call(String sql, List args) {} + + private static final class RecordingJdbc extends JdbcTemplate { + private final List calls = new ArrayList<>(); + + @Override + public List query(String sql, RowMapper mapper, Object... args) { + calls.add(new Call(sql, List.of(args))); + return List.of(); + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java new file mode 100644 index 000000000..b0643af39 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java @@ -0,0 +1,145 @@ +package com.baiyue.finance; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.client.FirstlegCostSnapshotClient; +import com.baiyue.finance.controller.ClientFinanceController; +import com.baiyue.finance.controller.FinanceSettlementController; +import com.baiyue.finance.controller.PlatformFinanceController; +import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt; +import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation; +import com.baiyue.finance.service.FinanceQueryService; +import com.baiyue.finance.service.FinanceService; +import com.baiyue.finance.service.FinanceSettlementService; +import com.baiyue.finance.service.ReconciliationStatementService; +import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest; +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.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +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.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FinanceReadApiBehaviorTest { + @Mock FinanceService service; + @Mock FinanceQueryService queryService; + @Mock FinanceFileClient fileClient; + @Mock FirstlegCostSnapshotClient snapshotClient; + @Mock FinanceSettlementService settlementService; + @Mock ReconciliationStatementService reconciliationStatementService; + + @Test + void clientReceiptUsesAuthenticatedCustomerAndDoesNotExposeVoucherOrInternalFields() throws Exception { + AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of()); + ClientReceipt row = new ClientReceipt(4L, "RCPT-4", new BigDecimal("300.00"), LocalDateTime.of(2026, 8, 11, 10, 0), + "BANK_TRANSFER", "CNY", "CONFIRMED", List.of(new ClientReceiptAllocation(8L, "BILL-8", new BigDecimal("100.00")))); + when(queryService.clientReceipts(23L)).thenReturn(List.of(row)); + + var response = new ClientFinanceController(service, queryService, reconciliationStatementService).receipts(user); + + verify(queryService).clientReceipts(23L); + String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(response.data()); + assertThat(json).doesNotContainIgnoringCase("voucher").doesNotContain("createdBy").doesNotContain("updatedBy").doesNotContain("tenantId"); + assertThat(response.requestId()).isNotBlank(); + assertThat(response.traceId()).isNotBlank(); + + new ClientFinanceController(service, queryService, reconciliationStatementService).bill(user, 88L); + verify(queryService).statement(23L, 88L, true); + } + + @Test + void platformFeePoolPassesExplicitCustomerScopeAndReturnsTraceFields() { + var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService); + + var response = controller.feePool(31L, "RECEIVABLE"); + + verify(queryService).feePool(31L, "RECEIVABLE"); + assertThat(response.requestId()).isNotBlank(); + assertThat(response.traceId()).isNotBlank(); + } + + @Test + void fivePlatformPageMethodsUseTheirTypedQueryFacades() { + var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService); + var settlementController = new FinanceSettlementController(settlementService); + + controller.customerAccount(11L); + controller.statements(11L); + settlementController.receivableSettlements(11L); + settlementController.payableSettlements(11L); + + verify(queryService).customerAccount(11L); + verify(queryService).reconciliationStatements(11L); + verify(settlementService).listSettlements("RECEIVABLE", 11L); + verify(settlementService).listSettlements("PAYABLE", 11L); + } + + @Test + void platformListsDefaultToAllCustomersAndKeepOptionalFilter() { + var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService); + var settlementController = new FinanceSettlementController(settlementService); + + controller.feePool(null, null); + controller.statements(null); + settlementController.receivableSettlements(null); + settlementController.payableSettlements(null); + controller.statements(31L); + + verify(queryService).feePool(null, null); + verify(queryService).reconciliationStatements(null); + verify(settlementService).listSettlements("RECEIVABLE", null); + verify(settlementService).listSettlements("PAYABLE", null); + verify(queryService).reconciliationStatements(31L); + } + + @Test + void clientListsAlwaysUseAuthenticatedCustomerScope() { + AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of()); + var controller = new ClientFinanceController(service, queryService, reconciliationStatementService); + + controller.bills(user); + controller.statements(user); + controller.receivableSettlements(user); + + verify(queryService, org.mockito.Mockito.times(2)).statements(23L, true); + verify(queryService).receivableSettlements(23L, true); + verifyNoMoreInteractions(queryService); + } + + @Test + void clientConfirmationUsesOnlyAuthenticatedCustomerAndOperatorScope() { + AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of()); + CustomerConfirmationRequest request = new CustomerConfirmationRequest(4); + var controller = new ClientFinanceController(service, queryService, reconciliationStatementService); + + controller.confirmStatement(user, 88L, "confirm-key", request); + + verify(reconciliationStatementService).confirmForCustomer(23L, 7L, 88L, "confirm-key", request); + } + + @Test + void clientConfirmationHasIndependentWriteAuthority() throws Exception { + var method = ClientFinanceController.class.getMethod("confirmStatement", AuthenticatedUser.class, + long.class, String.class, CustomerConfirmationRequest.class); + PreAuthorize rule = method.getAnnotation(PreAuthorize.class); + PostMapping mapping = method.getAnnotation(PostMapping.class); + + assertThat(rule.value()).isEqualTo("hasAuthority('merchant:finance:statement:confirm')"); + assertThat(mapping.value()).containsExactly("/statements/{statementId}/confirm"); + assertThat(CustomerConfirmationRequest.class.getRecordComponents()) + .extracting(java.lang.reflect.RecordComponent::getName) + .containsExactly("expectedVersion"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java new file mode 100644 index 000000000..23a19d81b --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java @@ -0,0 +1,245 @@ +package com.baiyue.finance; + +import com.baiyue.finance.service.FinanceService; +import com.baiyue.finance.service.FinanceSupplierValidator; +import com.baiyue.finance.service.FinanceVoucherValidator; +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class FinanceServiceValidationTest { + @Test + void containerCostAllocatesByVolumeAndPutsRoundingOnLastOrder() { + var rows = List.of( + new FinanceService.ContainerOrderVolume(11L, 101L, 7L, 9L, new BigDecimal("1.00")), + new FinanceService.ContainerOrderVolume(12L, 102L, 7L, 9L, new BigDecimal("2.00")), + new FinanceService.ContainerOrderVolume(13L, 103L, 7L, 9L, new BigDecimal("3.00"))); + var allocations = FinanceService.allocateContainerCost(new BigDecimal("100.00"), rows); + assertEquals(new BigDecimal("16.666667"), allocations.get(0).amount()); + assertEquals(new BigDecimal("33.333333"), allocations.get(1).amount()); + assertEquals(new BigDecimal("50.000000"), allocations.get(2).amount()); + assertEquals(new BigDecimal("100.000000"), allocations.stream().map(FinanceService.CostAllocation::amount) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + assertEquals(false, allocations.get(2).roundingAdjustment()); + var tail = FinanceService.allocateContainerCost(new BigDecimal("100.00"), List.of( + new FinanceService.ContainerOrderVolume(21L, 201L, 7L, 9L, BigDecimal.ONE), + new FinanceService.ContainerOrderVolume(22L, 202L, 7L, 9L, BigDecimal.ONE), + new FinanceService.ContainerOrderVolume(23L, 203L, 7L, 9L, BigDecimal.ONE))); + assertEquals(true, tail.get(2).roundingAdjustment()); + } + + @Test + void rejectsEmptyContainerVolumeSnapshot() { + assertThrows(RuntimeException.class, () -> FinanceService.allocateContainerCost(BigDecimal.TEN, List.of())); + } + + @Test + void rejectsUnsupportedCostTypeBeforeWrite() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FinanceService service = service(jdbc); + var request = new FinanceService.CostRequest("C-1", "TICKET", 1L, null, null, null, null, "MANUAL", "拖车", BigDecimal.ONE, List.of("f"), null); + assertThrows(RuntimeException.class, () -> service.createCost(1L, 2L, request, "k1")); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), (Object[]) any()); + } + + @Test + void rejectsNonPositiveAllocation() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FinanceService service = service(jdbc); + var request = new FinanceService.ReceiptRequest("R-1", 1L, null, "BANK_TRANSFER", null, List.of("f"), null, + List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ZERO))); + assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k2")); + } + + @Test + void rejectsDuplicateBillAllocationInsteadOfMerging() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FinanceService service = service(jdbc); + var request = new FinanceService.ReceiptRequest("R-2", 1L, null, "BANK_TRANSFER", null, List.of("f"), null, + List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE), new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE))); + assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k3")); + } + + @Test + void receiptFailsClosedWhenFileValidationCapabilityIsUnavailable() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FinanceService service = new FinanceService(jdbc, new ObjectMapper(), + new FinanceVoucherValidator(null, ""), mock(FinanceSupplierValidator.class)); + var request = new FinanceService.ReceiptRequest("R-FILE", 1L, null, "BANK_TRANSFER", null, List.of("77"), null, + List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE))); + + BizException error = assertThrows(BizException.class, () -> service.createReceipt(9L, 2L, request, "file-down")); + + assertEquals(ResultCode.SERVICE_UNAVAILABLE, error.getResultCode()); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_receipt"), (Object[]) any()); + } + + @Test + void rejectsVoucherFromAnotherCustomer() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FinanceFileClient files = mock(FinanceFileClient.class); + when(files.metadata(anyString(), eq(9L), eq(9L), eq(77L))).thenReturn(ApiResponse.success( + new FinanceFileClient.Metadata(77L, 10L, "FINANCE_VOUCHER", "9", "v.jpg", "sha", "image/jpeg", 10L))); + FinanceService service = service(jdbc, files); + var request = new FinanceService.ReceiptRequest("R-3", 1L, null, "BANK_TRANSFER", null, List.of("77"), null, + List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE))); + assertThrows(RuntimeException.class, () -> service.createReceipt(9L, 2L, request, "k4")); + } + + @Test + void receiptAllocationUpdatesLinkedReceivableSettlement() { + JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class + ? 1 : RETURNS_DEFAULTS.answer(invocation)); + FinanceFileClient files = mock(FinanceFileClient.class); + when(files.metadata("internal-token", 9L, 9L, 77L)).thenReturn(ApiResponse.success( + new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 9L))); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(2L), eq("RECEIPT_CREATE"), eq("receipt-key"))).thenReturn(List.of()); + when(jdbc.queryForMap(startsWith("SELECT outstanding_amount"), eq(50L), eq(9L))).thenReturn( + java.util.Map.of("outstanding_amount", new BigDecimal("100.00"), "status", "ISSUED")); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Integer.class), eq(9L), eq(50L))).thenReturn(1); + when(jdbc.queryForMap(startsWith("SELECT * FROM fin_receipt WHERE id="), eq(701L))).thenReturn(java.util.Map.of("id", 701L)); + doAnswer(invocation -> { + org.springframework.jdbc.support.KeyHolder holder = invocation.getArgument(1); + holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L)); + return 1; + }).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class)); + FinanceService service = service(jdbc, files); + var request = new FinanceService.ReceiptRequest("R-4", 1L, java.time.LocalDateTime.of(2026, 8, 11, 10, 0), + "BANK_TRANSFER", null, List.of("77"), "首笔收款", + List.of(new FinanceService.ReceiptAllocation(50L, new BigDecimal("40.00")))); + + service.createReceipt(9L, 2L, request, "receipt-key"); + + verify(jdbc).update(contains("payment_status=CASE WHEN outstanding_amount=0"), + eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), + eq(2L), eq(9L), eq(50L), eq(new BigDecimal("40.00"))); + verify(jdbc).update(contains("status=CASE WHEN outstanding_amount=0"), + eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), eq(2L), isNull(), eq(50L), eq(9L)); + } + + @Test + void receiptAllocatesThreeHundredAcrossThreeIssuedBills() { + JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class + ? 1 : RETURNS_DEFAULTS.answer(invocation)); + FinanceFileClient files = mock(FinanceFileClient.class); + when(files.metadata("internal-token", 9L, 9L, 77L)).thenReturn(ApiResponse.success( + new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 9L))); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(2L), eq("RECEIPT_CREATE"), eq("receipt-three-key"))).thenReturn(List.of()); + for (long billId : List.of(50L, 51L, 52L)) { + when(jdbc.queryForMap(startsWith("SELECT outstanding_amount"), eq(billId), eq(9L))).thenReturn( + java.util.Map.of("outstanding_amount", new BigDecimal("100.00"), "status", "ISSUED")); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Integer.class), eq(9L), eq(billId))).thenReturn(0); + } + when(jdbc.queryForMap(startsWith("SELECT * FROM fin_receipt WHERE id="), eq(701L))).thenReturn( + java.util.Map.of("id", 701L, "received_amount", new BigDecimal("300.00"))); + doAnswer(invocation -> { + org.springframework.jdbc.support.KeyHolder holder = invocation.getArgument(1); + holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L)); + return 1; + }).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class)); + FinanceService service = service(jdbc, files); + var request = new FinanceService.ReceiptRequest("R-300", 1L, java.time.LocalDateTime.of(2026, 8, 11, 10, 0), + "BANK_TRANSFER", "BANK-300", List.of("77"), "三单收款", List.of( + new FinanceService.ReceiptAllocation(50L, new BigDecimal("100.00")), + new FinanceService.ReceiptAllocation(51L, new BigDecimal("100.00")), + new FinanceService.ReceiptAllocation(52L, new BigDecimal("100.00")))); + + var result = service.createReceipt(9L, 2L, request, "receipt-three-key"); + + assertEquals(new BigDecimal("300.00"), result.get("received_amount")); + for (long billId : List.of(50L, 51L, 52L)) { + verify(jdbc).update(startsWith("INSERT INTO fin_receipt_bill"), eq(9L), eq(701L), eq(billId), + eq(new BigDecimal("100.00")), eq(2L)); + } + } + + @Test + void rejectsSingleOrderWhenOrderNotOwnedByCustomer() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Number.class), any(), any())).thenReturn(0); + FinanceService service = service(jdbc); + var request = new FinanceService.CostRequest("C-2", "SINGLE_ORDER", 99L, null, null, null, null, "MANUAL", "货拉拉", BigDecimal.ONE, List.of("1"), null); + assertThrows(RuntimeException.class, () -> service.createCost(9L, 2L, request, "k5")); + } + + @Test + void acceptsAuthoritativeContainerSnapshotBeforeFeeSourceArrival() { + JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class + ? 1 : RETURNS_DEFAULTS.answer(invocation)); + FinanceFileClient files = mock(FinanceFileClient.class); + when(files.metadata("internal-token", 9L, 9L, 77L)).thenReturn(ApiResponse.success( + new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "cost.jpg", "sha", "image/jpeg", 9L))); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(2L), eq("COST_CREATE"), eq("container-cost-key"))) + .thenReturn(List.of()); + when(jdbc.queryForMap(startsWith("SELECT * FROM fin_cost WHERE id="), eq(701L))) + .thenReturn(java.util.Map.of("id", 701L)); + doAnswer(invocation -> { + org.springframework.jdbc.support.KeyHolder holder = invocation.getArgument(1); + holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L)); + return 1; + }).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class)); + FinanceService service = service(jdbc, files); + var request = new FinanceService.CostRequest("C-3", "CONTAINER_SHARED", null, null, 88L, + null, null, "MANUAL", "整柜成本", new BigDecimal("300.00"), List.of("77"), null); + var snapshot = List.of(new FinanceService.ContainerOrderVolume( + 99L, 1001L, 88L, 9L, new BigDecimal("1.25"))); + + service.createCost(9L, 2L, request, snapshot, "container-cost-key"); + + verify(jdbc, never()).queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Number.class), any(), any()); + verify(jdbc).update(startsWith("INSERT INTO fin_cost_allocation"), eq(9L), eq(701L), eq(99L), + eq(1001L), eq(new BigDecimal("1.25")), eq(new BigDecimal("1.25")), + any(BigDecimal.class), any(BigDecimal.class), eq(0)); + } + + @Test + void returnsBillRevisionHistoryOnlyForOwningCustomer() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill"), eq(Number.class), eq(50L), eq(9L))) + .thenReturn(1); + var rows = List.>of(java.util.Map.of("version", 2)); + when(jdbc.queryForList(startsWith("SELECT id,bill_id,version"), eq(9L), eq(50L))).thenReturn(rows); + FinanceService service = service(jdbc); + + assertEquals(rows, service.billRevisions(9L, 50L)); + } + + @Test + void rejectsBillRevisionHistoryFromAnotherCustomer() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill"), eq(Number.class), eq(50L), eq(9L))) + .thenReturn(0); + FinanceService service = service(jdbc); + + assertThrows(RuntimeException.class, () -> service.billRevisions(9L, 50L)); + verify(jdbc, never()).queryForList(startsWith("SELECT id,bill_id,version"), (Object[]) any()); + } + + private static FinanceService service(JdbcTemplate jdbc, FinanceFileClient files) { + return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(), + new FinanceVoucherValidator(files, "internal-token"), mock(FinanceSupplierValidator.class)); + } + + private static FinanceService service(JdbcTemplate jdbc) { + return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(), + mock(FinanceVoucherValidator.class), mock(FinanceSupplierValidator.class)); + } + +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java new file mode 100644 index 000000000..19f41a707 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -0,0 +1,713 @@ +package com.baiyue.finance; + +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.security.AuthenticatedUser; +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.controller.FinanceSettlementController; +import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary; +import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceDocumentNumberGenerator; +import com.baiyue.finance.service.FinanceSettlementService; +import com.baiyue.finance.service.FinanceSupplierValidator; +import com.baiyue.finance.service.FinanceVoucherValidator; +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.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementCreator; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.sql.Date; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +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.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import org.mockito.ArgumentCaptor; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.RETURNS_DEFAULTS; + +@ExtendWith(MockitoExtension.class) +class FinanceSettlementBehaviorTest { + @Mock FinanceSettlementService controllerService; + + @Test + void controllerReturnsFixedEnvelopeAndEnforcesDirectionPermission() { + FinanceSettlementController controller = new FinanceSettlementController(controllerService); + SettlementDetail payable = detail("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0); + when(controllerService.getSettlement(88L)).thenReturn(payable); + AuthenticatedUser apViewer = user(Set.of("platform:finance:settlement-ap:list")); + + var response = controller.getSettlement(apViewer, 88L); + + assertThat(response.code()).isEqualTo("200"); + assertThat(response.data()).isSameAs(payable); + assertThat(response.requestId()).isNotBlank(); + assertThatThrownBy(() -> controller.getSettlement( + user(Set.of("platform:finance:settlement-ar:list")), 88L)).isInstanceOf(AccessDeniedException.class); + } + + @Test + void controllerListsFormalSettlementsByDirection() { + FinanceSettlementController controller = new FinanceSettlementController(controllerService); + SettlementSummary receivable = detail("RECEIVABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("100.00"), 0).summary(); + when(controllerService.listSettlements("RECEIVABLE", 10L)).thenReturn(List.of(receivable)); + + var response = controller.receivableSettlements(10L); + + assertThat(response.code()).isEqualTo("200"); + assertThat(response.data()).containsExactly(receivable); + verify(controllerService).listSettlements("RECEIVABLE", 10L); + } + + @Test + void formalSettlementListUsesFinSettlementAndOptionalCustomerFilter() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + Map row = settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("100.00"), 0); + when(jdbc.queryForList(anyString(), eq("PAYABLE"))).thenReturn(List.of(row)); + when(jdbc.queryForList(anyString(), eq("PAYABLE"), eq(10L))).thenReturn(List.of(row)); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + var all = service.listSettlements("PAYABLE", null); + var scoped = service.listSettlements("PAYABLE", 10L); + + assertThat(all).singleElement().extracting(SettlementSummary::settlementId).isEqualTo(88L); + assertThat(scoped).singleElement().extracting(SettlementSummary::customerId).isEqualTo(10L); + ArgumentCaptor allSql = ArgumentCaptor.forClass(String.class); + ArgumentCaptor scopedSql = ArgumentCaptor.forClass(String.class); + verify(jdbc).queryForList(allSql.capture(), eq("PAYABLE")); + verify(jdbc).queryForList(scopedSql.capture(), eq("PAYABLE"), eq(10L)); + assertThat(allSql.getValue()).contains("FROM fin_settlement s", "WHERE s.direction=?") + .doesNotContain("FROM fin_cost", "AND s.tenant_id=?"); + assertThat(scopedSql.getValue()).contains("FROM fin_settlement s", "WHERE s.direction=?", "AND s.tenant_id=?") + .doesNotContain("FROM fin_cost"); + } + + @Test + void arCreationRejectsNonCnyBeforeDatabaseAccess() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + assertCode(() -> service.createReceivableSettlement(7L, "ar-key", + new CreateReceivableSettlementRequest(9L, "USD")), "FIN_400"); + + verify(jdbc, never()).queryForList(anyString(), (Object[]) any()); + } + + @Test + void payableCreationRejectsDuplicateSourcesBeforeIdempotencyReservation() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + assertCode(() -> service.createPayableSettlement(7L, "ap-key", + new CreatePayableSettlementRequest(10L, 20L, List.of(3L, 3L), LocalDate.now(), "CNY")), "FIN_400"); + + verify(jdbc, never()).update(startsWith("INSERT INTO fin_idempotency_record"), (Object[]) any()); + } + + @Test + void paymentFailsClosedWhenFileServiceIsUnavailable() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + FinanceFileClient fileClient = org.mockito.Mockito.mock(FinanceFileClient.class); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))) + .thenReturn(List.of(settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0))); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of()); + when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenThrow(new IllegalStateException("down")); + FinanceSettlementService service = service(jdbc, fileClient); + + assertThatThrownBy(() -> service.createSettlementPayment(88L, 7L, "pay-key", + payment(new BigDecimal("40.00"), 0))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + + verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any()); + verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET paid_amount"), (Object[]) any()); + } + + @Test + void paymentRejectsInvalidCurrencyMissingVoucherStaleVersionAndOverage() { + JdbcTemplate noDatabase = org.mockito.Mockito.mock(JdbcTemplate.class); + FinanceSettlementService validationService = service(noDatabase, org.mockito.Mockito.mock(FinanceFileClient.class)); + assertCode(() -> validationService.createSettlementPayment(88L, 7L, "usd-key", + new CreateSettlementPaymentRequest(LocalDate.now(), BigDecimal.ONE, "USD", "BANK_TRANSFER", "BANK-1", List.of("91"), 0, null)), "FIN_400"); + assertCode(() -> validationService.createSettlementPayment(88L, 7L, "voucher-key", + new CreateSettlementPaymentRequest(LocalDate.now(), BigDecimal.ONE, "CNY", "BANK_TRANSFER", "BANK-1", List.of(), 0, null)), "FIN_400"); + verify(noDatabase, never()).queryForList(anyString(), (Object[]) any()); + + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))) + .thenReturn(List.of(settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 2))); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + assertCode(() -> service.createSettlementPayment(88L, 7L, "stale-key", payment(new BigDecimal("40.00"), 1)), "FIN_409"); + assertCode(() -> service.createSettlementPayment(88L, 7L, "over-key", payment(new BigDecimal("100.01"), 2)), "FIN_409"); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any()); + } + + @Test + void unpaidSettlementCanBeVoidedAndReopenedWithAuditAndVersionCheck() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)); + Map issued = settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("100.00"), 2); + Map voided = settlementRow("PAYABLE", "VOIDED", BigDecimal.ZERO, + new BigDecimal("100.00"), 3); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L))) + .thenReturn(List.of(issued)); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_STATE_ACTION"), eq("void-key"))).thenReturn(List.of()); + stubSettlementDetail(jdbc, voided); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + SettlementDetail result = service.actionSettlement(88L, 7L, "void-key", + new SettlementActionRequest("VOID", "业务取消", 2)); + + assertThat(result.summary().status()).isEqualTo("VOIDED"); + verify(jdbc).update(startsWith("UPDATE fin_settlement SET status="), eq("VOIDED"), eq(7L), + eq(88L), eq(10L), eq(2)); + verify(jdbc).update(startsWith("INSERT INTO fin_audit_log"), eq(10L), eq("FIN_SETTLEMENT"), + eq(88L), eq("VOID"), eq("业务取消"), any(), any(), eq(7L), any(), any(), any()); + } + + @Test + void settlementActionRejectsPaidInvalidStateAndStaleVersion() { + JdbcTemplate paidJdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + when(paidJdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L))) + .thenReturn(List.of(settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"), + new BigDecimal("60.00"), 2))); + when(paidJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_STATE_ACTION"), anyString())).thenReturn(List.of()); + FinanceSettlementService paidService = service(paidJdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + assertCode(() -> paidService.actionSettlement(88L, 7L, "paid-key", + new SettlementActionRequest("VOID", "不能作废", 2)), "FIN_409"); + + JdbcTemplate staleJdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + when(staleJdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L))) + .thenReturn(List.of(settlementRow("PAYABLE", "VOIDED", BigDecimal.ZERO, + new BigDecimal("100.00"), 3))); + when(staleJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_STATE_ACTION"), eq("stale-action"))).thenReturn(List.of()); + FinanceSettlementService staleService = service(staleJdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + assertCode(() -> staleService.actionSettlement(88L, 7L, "stale-action", + new SettlementActionRequest("REOPEN", "恢复结算", 2)), "FIN_409"); + verify(staleJdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any()); + } + + @Test + void partialPaymentUpdatesOutstandingAndReturnsTypedDetail() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) + ? 1 : org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation)); + FinanceFileClient fileClient = org.mockito.Mockito.mock(FinanceFileClient.class); + Map before = settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("100.00"), 0); + Map after = settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"), + new BigDecimal("60.00"), 1); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))).thenReturn(List.of(before)); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after)); + when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries( + Map.entry("id", 501L), Map.entry("transaction_type", "PAYMENT"), + Map.entry("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))), + Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"), + Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"), + Map.entry("created_by", 7L), Map.entry("created_by_name", "platform_admin"), + Map.entry("created_at", Timestamp.valueOf(LocalDateTime.of(2026, 8, 11, 10, 0))), + Map.entry("remark", "首笔付款")))); + when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(10L), eq(88L))).thenReturn(List.of()); + when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success( + new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L))); + FinanceSettlementService service = service(jdbc, fileClient); + + SettlementDetail result = service.createSettlementPayment(88L, 7L, "pay-key", + payment(new BigDecimal("40.00"), 0)); + + assertThat(result.summary().status()).isEqualTo("PARTIALLY_PAID"); + assertThat(result.summary().paidAmount()).isEqualByComparingTo("40.00"); + assertThat(result.summary().outstandingAmount()).isEqualByComparingTo("60.00"); + assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType()) + .isEqualTo("PAYMENT"); + assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin"); + verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"), + any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void issuedReconciliationCreatesUniqueReceivableSettlement() { + Map settlement = settlementRow("RECEIVABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("300.00"), 0); + settlement.put("reconciliation_statement_id", 501L); + JdbcTemplate jdbc = creationJdbc(settlement); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,bill_no"), eq(501L))).thenReturn(List.of(Map.ofEntries( + Map.entry("id", 501L), Map.entry("tenant_id", 10L), Map.entry("bill_no", "REC-501"), + Map.entry("customer_name_snapshot", "客户10"), Map.entry("currency_code", "CNY"), + Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")), + Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00"))))); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-key"))).thenReturn(List.of()); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(0); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + SettlementDetail result = service.createReceivableSettlement(7L, "ar-key", + new CreateReceivableSettlementRequest(501L, "CNY")); + + assertThat(result.summary().direction()).isEqualTo("RECEIVABLE"); + assertThat(result.summary().totalAmount()).isEqualByComparingTo("300.00"); + assertThat(result.summary().reconciliationStatementId()).isEqualTo(501L); + verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(88L), eq(10L), eq(501L)); + } + + @Test + void receivableSettlementLineDoesNotRequireSupplierSnapshot() { + Map row = new java.util.HashMap<>(Map.ofEntries( + Map.entry("fee_source_id", 31L), Map.entry("source_type", "FBA_PRICING"), + Map.entry("source_fee_id", 41L), Map.entry("source_fee_version", 1), + Map.entry("direction", "RECEIVABLE"), Map.entry("customer_id", 10L), + Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"businessDate\":\"2026-08-11\"}"), + Map.entry("fee_item_name", "头程运费"), Map.entry("amount", new BigDecimal("100.00")), + Map.entry("currency_code", "CNY"), Map.entry("review_status", "APPROVED"), + Map.entry("status", "GENERATED"))); + row.put("order_id", null); + row.put("inbound_id", null); + row.put("container_id", null); + + SettlementLine line = ReflectionTestUtils.invokeMethod( + service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)), + "line", row, detail("RECEIVABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("100.00"), 0).summary()); + + assertThat(line.direction()).isEqualTo("RECEIVABLE"); + assertThat(line.supplierId()).isNull(); + assertThat(line.customerName()).isEqualTo("客户10"); + } + + @Test + void payableSettlementLineStillRequiresSupplierSnapshot() { + Map row = new java.util.HashMap<>(Map.ofEntries( + Map.entry("direction", "PAYABLE"), + Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"businessDate\":\"2026-08-11\"}"), + Map.entry("amount", new BigDecimal("100.00")))); + + assertCode(() -> ReflectionTestUtils.invokeMethod( + service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)), + "line", row, detail("PAYABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("100.00"), 0).summary()), "FIN_409"); + } + + @Test + void payableSettlementLineAcceptsStringEncodedSupplierIdSnapshot() { + Map row = new java.util.HashMap<>(Map.ofEntries( + Map.entry("fee_source_id", 31L), Map.entry("source_type", "INBOUND_FEE"), + Map.entry("source_fee_id", 41L), Map.entry("source_fee_version", 1), + Map.entry("direction", "PAYABLE"), Map.entry("customer_id", 10L), + Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"supplierId\":\"2090349552523644929\",\"supplierName\":\"供应商A\",\"businessDate\":\"2026-08-11\"}"), + Map.entry("fee_item_name", "装车费"), Map.entry("amount", new BigDecimal("80.00")), + Map.entry("currency_code", "CNY"), Map.entry("review_status", "APPROVED"), + Map.entry("status", "AVAILABLE"))); + row.put("order_id", null); + row.put("inbound_id", null); + row.put("container_id", null); + + SettlementLine line = ReflectionTestUtils.invokeMethod( + service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)), + "line", row, detail("PAYABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("80.00"), 0).summary()); + + assertThat(line.supplierId()).isEqualTo(2090349552523644929L); + assertThat(line.supplierName()).isEqualTo("供应商A"); + } + + @Test + void receivableCreationRejectsAlreadySettledStatement() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,bill_no"), eq(501L))).thenReturn(List.of(Map.ofEntries( + Map.entry("id", 501L), Map.entry("tenant_id", 10L), Map.entry("bill_no", "REC-501"), + Map.entry("customer_name_snapshot", "客户10"), Map.entry("currency_code", "CNY"), + Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")), + Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00"))))); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-duplicate"))).thenReturn(List.of()); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1); + + assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).createReceivableSettlement(7L, "ar-duplicate", + new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409"); + verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class)); + } + + @Test + void eligibleSameSupplierSourcesCreatePayableSettlement() { + JdbcTemplate jdbc = creationJdbc(settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("100.00"), 0)); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-key"))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L))); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(0); + FinanceSupplierValidator supplierValidator = supplierValidator(); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class), supplierValidator); + + SettlementDetail result = service.createPayableSettlement(7L, "ap-key", + new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY")); + + assertThat(result.summary().direction()).isEqualTo("PAYABLE"); + assertThat(result.summary().supplierId()).isEqualTo(20L); + assertThat(result.summary().totalAmount()).isEqualByComparingTo("100.00"); + verify(supplierValidator).requireActive(20L, null); + verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(10L), eq(88L), eq(31L), + eq(new BigDecimal("100.00")), eq("CNY")); + } + + @Test + void payableCreationHidesCrossCustomerSource() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-key"))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(11L, 20L))); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + assertCode(() -> service.createPayableSettlement(7L, "ap-key", + new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY")), "FIN_404"); + verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class)); + } + + @Test + void directPaidSourceCannotEnterPayableSettlement() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_AP_CREATE"), eq("ap-direct"))).thenReturn(List.of()); + Map source = payableSource(10L, 20L); + source.put("settlement_mode", "DIRECT_PAID"); + when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(source)); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + assertCode(() -> service.createPayableSettlement(7L, "ap-direct", + new CreatePayableSettlementRequest(10L, 20L, List.of(31L), + LocalDate.of(2026, 9, 30), "CNY")), "FIN_409"); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement"), any(Object[].class)); + } + + @Test + void payableCreationRejectsMixedSupplierIneligibleAndOccupiedSources() { + for (String condition : List.of("supplier", "ineligible", "occupied")) { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + Map source = payableSource(10L, "supplier".equals(condition) ? 21L : 20L); + if ("ineligible".equals(condition)) source.put("review_status", "PENDING"); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-" + condition))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(source)); + org.mockito.Mockito.lenient().when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))) + .thenReturn("occupied".equals(condition) ? 1 : 0); + + assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).createPayableSettlement(7L, "ap-" + condition, + new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY")), "FIN_409"); + verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class)); + } + } + + @Test + void settlementDetailRejectsMissingResource() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(999L))).thenReturn(List.of()); + + assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).getSettlement(999L), "FIN_404"); + } + + @Test + void finalPaymentMarksSettlementPaid() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)); + FinanceFileClient fileClient = org.mockito.Mockito.mock(FinanceFileClient.class); + Map before = settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"), new BigDecimal("60.00"), 1); + Map after = settlementRow("PAYABLE", "PAID", new BigDecimal("100.00"), BigDecimal.ZERO, 2); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))).thenReturn(List.of(before)); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-key"))).thenReturn(List.of()); + stubSettlementDetail(jdbc, after); + when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success( + new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L))); + FinanceSupplierValidator supplierValidator = supplierValidator(); + FinanceSettlementService service = service(jdbc, fileClient, supplierValidator); + + SettlementDetail result = service.createSettlementPayment(88L, 7L, "final-key", payment(new BigDecimal("60.00"), 1)); + + assertThat(result.summary().status()).isEqualTo("PAID"); + verify(supplierValidator).requireActive(20L, "BOOKING"); + assertThat(result.summary().outstandingAmount()).isZero(); + } + + @Test + void finalPaymentReplayReturnsFirstResultWithoutDuplicatePayment() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)); + FinanceFileClient fileClient = org.mockito.Mockito.mock(FinanceFileClient.class); + Map before = settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"), + new BigDecimal("60.00"), 1); + Map after = settlementRow("PAYABLE", "PAID", new BigDecimal("100.00"), BigDecimal.ZERO, 2); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))) + .thenReturn(List.of(before), List.of(after)); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"))).thenReturn(List.of()); + stubSettlementDetail(jdbc, after); + when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success( + new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L))); + FinanceSettlementService service = service(jdbc, fileClient); + + SettlementDetail first = service.createSettlementPayment(88L, 7L, "final-replay-key", + payment(new BigDecimal("60.00"), 1)); + ArgumentCaptor requestHash = ArgumentCaptor.forClass(String.class); + verify(jdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(10L), eq(7L), + eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"), requestHash.capture()); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"))).thenReturn(List.of(Map.of( + "request_hash", requestHash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L, + "updated_at", LocalDateTime.now()))); + + SettlementDetail replay = service.createSettlementPayment(88L, 7L, "final-replay-key", + payment(new BigDecimal("60.00"), 1)); + + assertThat(first.summary().status()).isEqualTo("PAID"); + assertThat(replay.summary().status()).isEqualTo("PAID"); + assertThat(replay.summary().outstandingAmount()).isZero(); + long paymentInserts = org.mockito.Mockito.mockingDetails(jdbc).getInvocations().stream() + .filter(invocation -> invocation.getMethod().getName().equals("update") + && invocation.getArguments().length > 0 + && String.valueOf((Object) invocation.getArgument(0)).startsWith("INSERT INTO fin_settlement_payment")) + .count(); + assertThat(paymentInserts).isEqualTo(1); + } + + @Test + void receivableSettlementDetailIncludesAppliedReceiptHistory() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + Map settlement = settlementRow("RECEIVABLE", "PARTIALLY_PAID", new BigDecimal("40.00"), + new BigDecimal("60.00"), 1); + settlement.put("reconciliation_statement_id", 501L); + stubSettlementDetail(jdbc, settlement); + when(jdbc.queryForList(startsWith("SELECT r.id,'RECEIPT'"), eq(10L), eq(501L))).thenReturn(List.of(Map.ofEntries( + Map.entry("id", 701L), Map.entry("transaction_type", "RECEIPT"), + Map.entry("transaction_date", Date.valueOf("2026-08-11")), Map.entry("amount", new BigDecimal("40.00")), + Map.entry("method", "BANK_TRANSFER"), Map.entry("reference_no", "RCPT-701"), + Map.entry("voucher_file_ids", "[\"91\"]"), Map.entry("created_by", 7L), + Map.entry("created_by_name", "platform_admin"), + Map.entry("created_at", Timestamp.valueOf("2026-08-11 10:00:00")), Map.entry("remark", "首笔收款")))); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + SettlementDetail result = service.getSettlement(88L); + + assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType()) + .isEqualTo("RECEIPT"); + assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00"); + assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin"); + } + + @Test + void settlementIdempotencyReplaysRejectsMismatchAndRecoversInterruptedRecords() { + Map body = Map.of("customerId", 10L, "amount", "100.00"); + JdbcTemplate firstJdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)); + when(firstJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("idem-key"))).thenReturn(List.of()); + FinanceSettlementService firstService = service(firstJdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + Long first = ReflectionTestUtils.invokeMethod(firstService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "idem-key", "POST", "/settlements", body); + ArgumentCaptor hash = ArgumentCaptor.forClass(String.class); + verify(firstJdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(10L), eq(7L), + eq("SETTLEMENT_AP_CREATE"), eq("idem-key"), hash.capture()); + assertThat(first).isNull(); + + JdbcTemplate replayJdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + when(replayJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("idem-key"))) + .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L, + "updated_at", LocalDateTime.now()))); + FinanceSettlementService replayService = service(replayJdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + assertThat((Long) ReflectionTestUtils.invokeMethod(replayService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "idem-key", "POST", "/settlements", body)).isEqualTo(88L); + assertCode(() -> ReflectionTestUtils.invokeMethod(replayService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "idem-key", "POST", "/settlements", Map.of("customerId", 10L, "amount", "101.00")), "FIN_409"); + + JdbcTemplate processingJdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + when(processingJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("processing-key"))) + .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now()))); + assertCode(() -> ReflectionTestUtils.invokeMethod(service(processingJdbc, org.mockito.Mockito.mock(FinanceFileClient.class)), + "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "processing-key", "POST", "/settlements", body), "FIN_409"); + + JdbcTemplate recoveryJdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)); + when(recoveryJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("failed-key"))) + .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "FAILED", "updated_at", LocalDateTime.now()))); + when(recoveryJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("stale-key"))) + .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now().minusMinutes(10)))); + FinanceSettlementService recoveryService = service(recoveryJdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "failed-key", "POST", "/settlements", body)).isNull(); + assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "stale-key", "POST", "/settlements", body)).isNull(); + + JdbcTemplate conflictJdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + FinanceSettlementService conflictService = service(conflictJdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + assertCode(() -> ReflectionTestUtils.invokeMethod(conflictService, "complete", 10L, 7L, "SETTLEMENT_AP_CREATE", "idem-key", 88L), "FIN_409"); + } + + @Test + void receivableCreationRejectsAnExistingActiveSettlement() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,bill_no"), eq(501L))).thenReturn(List.of(Map.ofEntries( + Map.entry("id", 501L), Map.entry("tenant_id", 10L), Map.entry("bill_no", "REC-501"), + Map.entry("customer_name_snapshot", "客户10"), Map.entry("currency_code", "CNY"), + Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")), + Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00"))))); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-key"))).thenReturn(List.of()); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + assertCode(() -> service.createReceivableSettlement(7L, "ar-key", new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409"); + verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class)); + } + + @Test + void payableCreationRejectsMixedSupplierAndOccupiedSources() { + JdbcTemplate mixedJdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + when(mixedJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("mixed-key"))).thenReturn(List.of()); + when(mixedJdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 21L))); + FinanceSettlementService mixedService = service(mixedJdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + CreatePayableSettlementRequest request = new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY"); + assertCode(() -> mixedService.createPayableSettlement(7L, "mixed-key", request), "FIN_409"); + + JdbcTemplate occupiedJdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + when(occupiedJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("occupied-key"))).thenReturn(List.of()); + when(occupiedJdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L))); + when(occupiedJdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(1); + FinanceSettlementService occupiedService = service(occupiedJdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + assertCode(() -> occupiedService.createPayableSettlement(7L, "occupied-key", request), "FIN_409"); + } + + @Test + void paymentRejectsAmountAboveOutstandingBeforeVoucherWrite() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))) + .thenReturn(List.of(settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0))); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + assertCode(() -> service.createSettlementPayment(88L, 7L, "pay-key", payment(new BigDecimal("100.01"), 0)), "FIN_409"); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any()); + } + + private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient) { + return service(jdbc, fileClient, supplierValidator()); + } + + private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient, + FinanceSupplierValidator supplierValidator) { + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT JSON_UNQUOTE"), anyLong(), anyLong())) + .thenReturn(List.of(new java.util.HashMap<>(Map.of("supplier_category", "BOOKING")))); + FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class); + org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR-260818-0001"); + org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP-260818-0001"); + return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(), + new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers); + } + + private static FinanceSupplierValidator supplierValidator() { + FinanceSupplierValidator supplierValidator = org.mockito.Mockito.mock(FinanceSupplierValidator.class); + org.mockito.Mockito.lenient().when(supplierValidator.requireActive(anyLong(), any())) + .thenReturn(new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot( + 20L, "供应商20", "BOOKING", "ACTIVE")); + return supplierValidator; + } + + private static CreateSettlementPaymentRequest payment(BigDecimal amount, int version) { + return new CreateSettlementPaymentRequest(LocalDate.of(2026, 8, 11), amount, "CNY", "BANK_TRANSFER", + "BANK-1", List.of("91"), version, "首笔付款"); + } + + private static JdbcTemplate creationJdbc(Map settlement) { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, + org.mockito.Mockito.withSettings().lenient().defaultAnswer(invocation -> + "update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation))); + org.mockito.Mockito.lenient().doAnswer(invocation -> { + KeyHolder holder = invocation.getArgument(1); + holder.getKeyList().add(Map.of("GENERATED_KEY", 88L)); + return 1; + }).when(jdbc).update(any(PreparedStatementCreator.class), any(KeyHolder.class)); + stubSettlementDetail(jdbc, settlement); + return jdbc; + } + + private static void stubSettlementDetail(JdbcTemplate jdbc, Map settlement) { + when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement)); + long customerId = ((Number) settlement.get("tenant_id")).longValue(); + when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of()); + } + + private static Map payableSource(long customerId, long supplierId) { + return new java.util.HashMap<>(Map.ofEntries( + Map.entry("id", 31L), Map.entry("customer_id", customerId), Map.entry("direction", "PAYABLE"), + Map.entry("currency_code", "CNY"), Map.entry("status", "AVAILABLE"), + Map.entry("amount", new BigDecimal("100.00")), Map.entry("review_status", "APPROVED"), + Map.entry("settlement_mode", "ON_ACCOUNT"), + Map.entry("supplier_id", supplierId), + Map.entry("fee_item_code", "FBA_HEAD_FREIGHT"), Map.entry("fee_item_name", "头程运费"), + Map.entry("supplier_name", "供应商20"), Map.entry("source_snapshot", + "{\"customerName\":\"客户10\",\"supplierId\":20,\"supplierName\":\"供应商20\"}"))); + } + + private static Map settlementRow(String direction, String status, BigDecimal paid, + BigDecimal outstanding, int version) { + java.util.HashMap row = new java.util.HashMap<>(Map.ofEntries( + Map.entry("id", 88L), Map.entry("tenant_id", 10L), Map.entry("settlement_no", "AP-20260811-001"), + Map.entry("direction", direction), Map.entry("customer_name_snapshot", "客户10"), + Map.entry("supplier_id", 20L), Map.entry("supplier_name_snapshot", "供应商20"), + Map.entry("business_period", "2026-08"), Map.entry("fee_item_summary", "拖车费"), + Map.entry("currency_code", "CNY"), + Map.entry("total_amount", paid.add(outstanding)), Map.entry("paid_amount", paid), + Map.entry("outstanding_amount", outstanding), Map.entry("generated_date", Date.valueOf("2026-08-11")), + Map.entry("due_date", Date.valueOf("2026-09-10")), + Map.entry("payment_status", paid.signum() == 0 ? "UNPAID" : status), Map.entry("status", status), + Map.entry("created_by", 7L), Map.entry("created_by_name", "platform_admin"), + Map.entry("created_at", Timestamp.valueOf("2026-08-11 09:00:00")), + Map.entry("updated_at", Timestamp.valueOf("2026-08-11 09:00:00")), Map.entry("version", version))); + row.put("reconciliation_statement_id", null); + row.put("reconciliation_statement_no", null); + row.put("last_payment_at", null); + return row; + } + + private static SettlementDetail detail(String direction, String status, BigDecimal paid, + BigDecimal outstanding, int version) { + return new SettlementDetail(new SettlementSummary(88L, "SET-88", direction, 10L, "客户10", 20L, + "供应商20", null, null, "2026-08", "拖车费", "CNY", paid.add(outstanding), paid, outstanding, + LocalDate.of(2026, 8, 11), LocalDate.of(2026, 9, 10), paid.signum() == 0 ? "UNPAID" : status, + status, 7L, "platform_admin", LocalDateTime.of(2026, 8, 11, 9, 0), + LocalDateTime.of(2026, 8, 11, 9, 0), null, version), List.of(), List.of(), List.of()); + } + + private static AuthenticatedUser user(Set permissions) { + return new AuthenticatedUser(7L, null, null, null, "finance", "ADMIN", "WEB", permissions); + } + + private static void assertCode(org.assertj.core.api.ThrowableAssert.ThrowingCallable call, String code) { + assertThatThrownBy(call).isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo(code); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java new file mode 100644 index 000000000..c8b178151 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.finance; + +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 FinanceSettlementSchemaContractTest { + + @Test + void moduleSchemaCreatesFormalSettlementTablesAndSummaryIndex() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8); + String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); + + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fin_settlement (") + .contains("CREATE TABLE IF NOT EXISTS fin_settlement_line (") + .contains("CREATE TABLE IF NOT EXISTS fin_settlement_payment (") + .contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)") + .contains("total_amount DECIMAL(20,6)", "paid_amount DECIMAL(20,6)", "outstanding_amount DECIMAL(20,6)") + .contains("updated_at DATETIME(3)"); + assertThat(unified) + .contains("CREATE TABLE IF NOT EXISTS fin_settlement (") + .contains("CREATE TABLE IF NOT EXISTS fin_settlement_line (") + .contains("CREATE TABLE IF NOT EXISTS fin_settlement_payment (") + .contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)") + .contains("'fin_settlement', 'fin_settlement_line', 'fin_settlement_payment')) <> 16"); + } + + private static Path resolveUnifiedSql() { + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java new file mode 100644 index 000000000..964d12bdd --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java @@ -0,0 +1,108 @@ +package com.baiyue.finance; + +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.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceSqlCommentContractTest { + private static final Pattern TABLE = Pattern.compile("CREATE TABLE IF NOT EXISTS (fin_[a-z0-9_]+) "); + private static final Pattern COLUMN = Pattern.compile("^\\s{2}([a-z][a-z0-9_]*)\\s+(?:BIGINT|INT|VARCHAR|CHAR|DECIMAL|DATETIME|DATE|JSON|TINYINT)\\b", Pattern.CASE_INSENSITIVE); + private static final Pattern CHINESE = Pattern.compile("[\\p{IsHan}]"); + + @Test + void everyFinanceBaselineColumnHasMeaningfulChineseComment() throws Exception { + List lines = Files.readAllLines(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8); + int tableCount = 0; + int columnCount = 0; + String currentTable = null; + List missing = new ArrayList<>(); + for (String line : lines) { + var table = TABLE.matcher(line); + if (table.find()) { + currentTable = table.group(1); + tableCount++; + continue; + } + if (currentTable == null) continue; + if (line.startsWith(") ENGINE=")) { + currentTable = null; + continue; + } + var column = COLUMN.matcher(line); + if (!column.find()) continue; + columnCount++; + int comment = line.indexOf("COMMENT '"); + if (comment < 0 || !CHINESE.matcher(line.substring(comment)).find()) { + missing.add(currentTable + "." + column.group(1)); + } + } + assertThat(tableCount).isEqualTo(23); + assertThat(columnCount).isEqualTo(353); + assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty(); + } + + @Test + void keyIsolationStatusSnapshotAndIdempotencyCommentsUseFrozenMeaning() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8); + assertThat(sql).contains("tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键'"); + assertThat(sql).contains("AVAILABLE可结算、SETTLED资金账户已结清、SUPERSEDED已被新版本替代、VOIDED已作废"); + assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败"); + assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照"); + assertThat(sql).contains("ON_ACCOUNT账期应付、DIRECT_PAID业务已付"); + assertThat(sql).contains("来源直接已付且CONFIRMED计入已线下支付供应商款"); + assertThat(sql).contains("RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付"); + assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希"); + } + + @Test + void unifiedMigrationAddsBillLineAdjustmentReasonForNewAndExistingSchemas() throws Exception { + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + Path unified = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + String sql = Files.readString(unified, StandardCharsets.UTF_8); + + assertThat(sql) + .contains("adjustment_reason VARCHAR(500) NULL COMMENT '最近一次账单调整行原因;完整历史保存在修订与审计记录'") + .contains("table_schema='baiyue_finance' AND table_name='fin_bill_line' AND column_name='adjustment_reason'") + .contains("ALTER TABLE baiyue_finance.fin_bill_line ADD COLUMN adjustment_reason") + .contains("queue_column_comment('baiyue_finance', 'fin_bill_line', 'adjustment_reason'"); + } + + @Test + void operatingFundAggregatesHaveDedicatedBaselineIndexes() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8); + assertThat(sql) + .contains("KEY idx_fin_fee_source_customer_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id)") + .contains("KEY idx_fin_cost_customer_origin_status (tenant_id, cost_origin, status)") + .contains("KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status)") + .contains("KEY idx_fin_settlement_line_source (fee_source_id)") + .contains("KEY idx_fin_settlement_payment_customer_type_time (tenant_id, transaction_type, created_at, settlement_id)"); + } + + @Test + void financeLedgerSourcesPersistOperatorNameSnapshotsInBaselineAndUnifiedSql() throws Exception { + String baseline = Files.readString(Path.of("src/main/resources/db/finance.sql"), 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); + + assertThat(baseline.split("created_by_name VARCHAR\\(128\\) NULL COMMENT '创建操作人名称快照'", -1)) + .hasSize(4); + assertThat(baseline) + .contains("updated_by_name VARCHAR(128) NULL COMMENT '最后修改操作人名称快照'") + .contains("created_by_name VARCHAR(128) NULL COMMENT '登记操作人名称快照'"); + assertThat(unified) + .contains("CALL finance_add_operator_snapshot_column('fin_bill', 'created_by_name', 'created_by')") + .contains("CALL finance_add_operator_snapshot_column('fin_bill', 'updated_by_name', 'updated_by')") + .contains("CALL finance_add_operator_snapshot_column('fin_receipt', 'created_by_name', 'created_by')") + .contains("CALL finance_add_operator_snapshot_column('fin_cost', 'created_by_name', 'created_by')") + .contains("CALL finance_add_operator_snapshot_column('fin_settlement_payment', 'created_by_name', 'created_by')") + .contains("CALL finance_add_operator_snapshot_column('fin_bill_revision', 'created_by_name', 'created_by')"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSubjectSeedContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSubjectSeedContractTest.java new file mode 100644 index 000000000..a20c7772e --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceSubjectSeedContractTest.java @@ -0,0 +1,25 @@ +package com.baiyue.finance; + +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 FinanceSubjectSeedContractTest { + @Test + void moduleAndUnifiedSqlSeedOneActivePlatformSubjectIdempotently() throws Exception { + String module = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8); + + for (String sql : new String[] {module, unified}) { + assertThat(sql) + .contains("INSERT INTO fin_subject") + .contains("('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)") + .contains("ON DUPLICATE KEY UPDATE") + .contains("status = 'ACTIVE'"); + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java new file mode 100644 index 000000000..39762271e --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java @@ -0,0 +1,53 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.finance.client.WarehouseSupplierClient; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceSupplierValidator; +import feign.FeignException; +import org.junit.jupiter.api.Test; + +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.Mockito.verify; +import static org.mockito.Mockito.when; + +class FinanceSupplierValidatorTest { + private final WarehouseSupplierClient client = mock(WarehouseSupplierClient.class); + private final FinanceSupplierValidator validator = new FinanceSupplierValidator(client, "internal-token"); + + @Test + void acceptsMissingCategoryAndRejectsInvalidExplicitCategory() { + var unrestricted = new WarehouseSupplierClient.SupplierSnapshot( + 20L, "任意启用供应商", "BOOKING", "ACTIVE"); + when(client.get("internal-token", 20L, true, null)).thenReturn(ApiResponse.success(unrestricted)); + assertThat(validator.requireActive(20L, null).name()).isEqualTo("任意启用供应商"); + verify(client).get("internal-token", 20L, true, null); + assertCode(() -> validator.requireActive(20L, "UNKNOWN"), "FIN_409"); + } + + @Test + void requiresActiveWarehouseSupplierWithMatchingCategory() { + var supplier = new WarehouseSupplierClient.SupplierSnapshot(20L, "正式供应商", "TRUCKING", "ACTIVE"); + when(client.get("internal-token", 20L, true, "TRUCKING")).thenReturn(ApiResponse.success(supplier)); + + assertThat(validator.requireActive(20L, "TRUCKING").name()).isEqualTo("正式供应商"); + verify(client).get("internal-token", 20L, true, "TRUCKING"); + } + + @Test + void rejectsMismatchedOrUnavailableSupplier() { + var mismatched = new WarehouseSupplierClient.SupplierSnapshot(20L, "订舱供应商", "BOOKING", "ACTIVE"); + when(client.get("internal-token", 20L, true, "TRUCKING")).thenReturn(ApiResponse.success(mismatched)); + assertCode(() -> validator.requireActive(20L, "TRUCKING"), "FIN_409"); + + when(client.get("internal-token", 21L, true, "BOOKING")).thenThrow(mock(FeignException.class)); + assertCode(() -> validator.requireActive(21L, "BOOKING"), "FIN_503"); + } + + private static void assertCode(org.assertj.core.api.ThrowableAssert.ThrowingCallable call, String code) { + assertThatThrownBy(call).isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo(code); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceVoucherValidatorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceVoucherValidatorTest.java new file mode 100644 index 000000000..e40ddf45c --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceVoucherValidatorTest.java @@ -0,0 +1,87 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.service.FinanceVoucherValidator; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FinanceVoucherValidatorTest { + @Test + void acceptsCustomerOwnedFinanceImage() { + FinanceFileClient files = mock(FinanceFileClient.class); + when(files.metadata("token", 9L, 9L, 77L)).thenReturn(ApiResponse.success( + new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L))); + + new FinanceVoucherValidator(files, "token").validate(9L, List.of("77"), "财务凭证"); + verify(files).metadata("token", 9L, 9L, 77L); + } + + @Test + void rejectsMismatchedFileIdentityOwnershipTypeAndMime() { + FinanceFileClient files = mock(FinanceFileClient.class); + List invalid = List.of( + new FinanceFileClient.Metadata(78L, 9L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L), + new FinanceFileClient.Metadata(77L, 10L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L), + new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "10", "receipt.jpg", "sha", "image/jpeg", 10L), + new FinanceFileClient.Metadata(77L, 9L, "OTHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L), + new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "receipt.pdf", "sha", "application/pdf", 10L)); + + for (FinanceFileClient.Metadata metadata : invalid) { + when(files.metadata(eq("token"), eq(9L), eq(9L), eq(77L))).thenReturn(ApiResponse.success(metadata)); + assertThatThrownBy(() -> new FinanceVoucherValidator(files, "token") + .validate(9L, List.of("77"), "付款凭证")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.BAD_REQUEST); + } + } + + @Test + void failsClosedWhenFileDependencyUnavailable() { + FinanceFileClient files = mock(FinanceFileClient.class); + when(files.metadata("token", 9L, 9L, 77L)).thenThrow(new IllegalStateException("down")); + + assertThatThrownBy(() -> new FinanceVoucherValidator(files, "token") + .validate(9L, List.of("77"), "付款凭证")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + } + + @Test + void rejectsMissingInvalidAndDuplicateFileIds() { + FinanceVoucherValidator validator = new FinanceVoucherValidator(mock(FinanceFileClient.class), "token"); + assertResult(() -> validator.validate(9L, List.of(), "财务凭证"), ResultCode.BAD_REQUEST); + assertResult(() -> validator.validate(9L, List.of("invalid"), "财务凭证"), ResultCode.BAD_REQUEST); + assertResult(() -> validator.validate(9L, List.of("77", "77"), "付款凭证"), ResultCode.BAD_REQUEST); + } + + @Test + void rejectsWrongFileIdAndCustomerOwnership() { + FinanceFileClient files = mock(FinanceFileClient.class); + when(files.metadata("token", 9L, 9L, 77L)).thenReturn(ApiResponse.success( + new FinanceFileClient.Metadata(78L, 10L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L))); + assertResult(() -> new FinanceVoucherValidator(files, "token") + .validate(9L, List.of("77"), "付款凭证"), ResultCode.BAD_REQUEST); + } + + @Test + void rejectsMissingToken() { + assertResult(() -> new FinanceVoucherValidator(mock(FinanceFileClient.class), "") + .validate(9L, List.of("77"), "财务凭证"), ResultCode.SERVICE_UNAVAILABLE); + } + + private static void assertResult(org.assertj.core.api.ThrowableAssert.ThrowingCallable call, + ResultCode resultCode) { + assertThatThrownBy(call).isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(resultCode); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java new file mode 100644 index 000000000..2b2ecd69c --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java @@ -0,0 +1,71 @@ +package com.baiyue.finance; + +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 FinanceWalletContractTest { + @Test + void unifiedMigrationDropsTheLegacyBalanceConstraintBeforeRewritingBalances() throws Exception { + Path migration = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(migration)) migration = Path.of("..").resolve(migration); + String sql = Files.readString(migration, StandardCharsets.UTF_8); + int procedure = sql.indexOf("CREATE PROCEDURE migrate_fin_wallet_credit()"); + int dropConstraint = sql.indexOf("ALTER TABLE fin_wallet_account DROP CHECK chk_fin_wallet_account_balance", procedure); + int rewriteBalance = sql.indexOf("SET actual_balance=actual_balance-credit_used", procedure); + + assertThat(procedure).isGreaterThanOrEqualTo(0); + assertThat(sql.substring(procedure, rewriteBalance)) + .contains("CHECK_CLAUSE LIKE '%actual_balance%'", "v_legacy_wallet_constraint OR v_credit_expiry_missing"); + assertThat(dropConstraint).isBetween(procedure, rewriteBalance - 1); + } + + @Test + void schemaKeepsBalancesConsistentAndRechargeReviewTraceable() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8); + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fin_wallet_account") + .contains("credit_expires_on DATE NULL") + .contains("actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0") + .contains("frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0") + .contains("CREATE TABLE IF NOT EXISTS fin_recharge_request") + .contains("status IN ('PENDING','APPROVED','REJECTED')") + .contains("CREATE TABLE IF NOT EXISTS fin_wallet_ledger") + .contains("amount <> 0 OR change_type='CREDIT_LIMIT'") + .contains("credit_expires_on_after DATE NULL") + .contains("uk_fin_wallet_ledger_no") + .contains("idx_fin_recharge_customer_status") + .contains("CREATE TABLE IF NOT EXISTS fin_wallet_hold") + .contains("amount=actual_frozen_amount+credit_frozen_amount") + .contains("CREATE TABLE IF NOT EXISTS fin_wallet_command_inbox") + .contains("payload_hash VARCHAR(71) NOT NULL"); + } + + @Test + void controllersEnforceCustomerScopeAndSeparateWritePermissions() throws Exception { + String client = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/ClientFinanceWalletController.java")); + String platform = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java")); + String internal = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/InternalFinanceWalletController.java")); + assertThat(client) + .contains("user.tenantId()") + .contains("merchant:finance:account:view") + .contains("merchant:finance:recharge:create") + .contains("consumes = \"multipart/form-data\"") + .doesNotContain("@RequestParam long customerId"); + assertThat(platform) + .contains("platform:finance:recharge:review") + .contains("platform:finance:customer-account:adjust") + .contains("/accounts/{customerId}/credit-limit") + .contains("Idempotency-Key"); + assertThat(internal) + .contains("/internal/finance/v1/wallet") + .contains("X-Internal-Service-Token") + .contains("/holds/{businessType}/{businessNo}/settle") + .contains("/holds/{businessType}/{businessNo}/release") + .contains("Idempotency-Key"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FirstlegFeeSourceClientContractTest.java b/finance/src/test/java/com/baiyue/finance/FirstlegFeeSourceClientContractTest.java new file mode 100644 index 000000000..de4e90a4b --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FirstlegFeeSourceClientContractTest.java @@ -0,0 +1,34 @@ +package com.baiyue.finance; + +import com.baiyue.finance.client.FirstlegFeeSourceClient; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.lang.reflect.RecordComponent; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class FirstlegFeeSourceClientContractTest { + @Test + void voidCommandFreezesInternalPathHeadersAndBusinessIdentity() throws Exception { + var method = FirstlegFeeSourceClient.class.getDeclaredMethod("voidSource", + String.class, String.class, String.class, FirstlegFeeSourceClient.VoidCommand.class); + + assertThat(method.getAnnotation(PostMapping.class).value()) + .containsExactly("/fee-sources/commands/void"); + assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).value()) + .isEqualTo("X-Internal-Service-Token"); + assertThat(method.getParameters()[1].getAnnotation(RequestHeader.class).value()) + .isEqualTo("Idempotency-Key"); + assertThat(method.getParameters()[2].getAnnotation(RequestHeader.class).value()) + .isEqualTo("X-Request-Hash"); + assertThat(method.getParameters()[3].isAnnotationPresent(RequestBody.class)).isTrue(); + assertThat(Arrays.stream(FirstlegFeeSourceClient.VoidCommand.class.getRecordComponents()) + .map(RecordComponent::getName)) + .containsExactly("sourceType", "sourceFeeId", "sourceVersion", "customerId", + "inboundId", "containerId", "operatorId", "operatorName", "reason"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/InternalFinanceFeeSourceControllerTest.java b/finance/src/test/java/com/baiyue/finance/InternalFinanceFeeSourceControllerTest.java new file mode 100644 index 000000000..256c38235 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/InternalFinanceFeeSourceControllerTest.java @@ -0,0 +1,43 @@ +package com.baiyue.finance; + +import com.baiyue.finance.controller.InternalFinanceFeeSourceController; +import com.baiyue.finance.dto.FinanceFeeSourceEvent; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceFeeSourceEventService; +import org.junit.jupiter.api.Test; +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.Mockito.mock; +import static org.mockito.Mockito.when; + +class InternalFinanceFeeSourceControllerTest { + private final FinanceFeeSourceEventService service = mock(FinanceFeeSourceEventService.class); + private final InternalFinanceFeeSourceController controller = controller(); + + @Test + void successfulAcknowledgementAlwaysHasTrackingFields() { + FinanceFeeSourceEvent event = mock(FinanceFeeSourceEvent.class); + when(service.consume(event)).thenReturn(91L); + + var response = controller.consume("internal-token", event); + + assertThat(response.data()).containsEntry("feeSourceId", 91L); + assertThat(response.requestId()).isNotBlank(); + assertThat(response.traceId()).isNotBlank(); + } + + @Test + void missingInternalTokenFailsClosedWithStableCode() { + assertThatThrownBy(() -> controller.consume(null, mock(FinanceFeeSourceEvent.class))) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-401"); + } + + private InternalFinanceFeeSourceController controller() { + InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service); + ReflectionTestUtils.setField(value, "expectedToken", "internal-token"); + return value; + } +} diff --git a/finance/src/test/java/com/baiyue/finance/InternalFinanceWalletControllerTest.java b/finance/src/test/java/com/baiyue/finance/InternalFinanceWalletControllerTest.java new file mode 100644 index 000000000..24a61d42f --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/InternalFinanceWalletControllerTest.java @@ -0,0 +1,60 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.finance.controller.InternalFinanceWalletController; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest; +import com.baiyue.finance.service.FinanceWalletService; +import org.junit.jupiter.api.Test; + +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.*; + +class InternalFinanceWalletControllerTest { + private static final String TOKEN = "finance-internal-token"; + + @Test + void missingOrIncorrectServiceTokenIsRejectedBeforeWalletAccess() { + FinanceWalletService service = mock(FinanceWalletService.class); + InternalFinanceWalletController controller = new InternalFinanceWalletController(service, TOKEN); + + assertThatThrownBy(() -> controller.account(null, 9L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.account("wrong-token", 9L)).isInstanceOf(BizException.class); + verifyNoInteractions(service); + } + + @Test + void correctServiceTokenAllowsAccountAndFreezeOperations() { + FinanceWalletService service = mock(FinanceWalletService.class); + InternalFinanceWalletController controller = new InternalFinanceWalletController(service, TOKEN); + WalletFreezeRequest request = new WalletFreezeRequest(new BigDecimal("88.50"), + "DROPSHIP", "DS-20260905-001", "一件代发预占"); + + assertThat(controller.account(TOKEN, 9L).successful()).isTrue(); + assertThat(controller.freeze(TOKEN, "freeze-key", 9L, request).successful()).isTrue(); + + verify(service).account(9L); + verify(service).freeze(9L, "freeze-key", request); + verifyNoMoreInteractions(service); + } + + @Test + void correctServiceTokenForwardsSettleAndReleaseWithIndependentIdempotencyKeys() { + FinanceWalletService service = mock(FinanceWalletService.class); + InternalFinanceWalletController controller = new InternalFinanceWalletController(service, TOKEN); + WalletHoldActionRequest settleRequest = new WalletHoldActionRequest("履约完成结算"); + WalletHoldActionRequest releaseRequest = new WalletHoldActionRequest("订单取消释放"); + + assertThat(controller.settle(TOKEN, "settle-key", 9L, "DROPSHIP", "DS-001", settleRequest) + .successful()).isTrue(); + assertThat(controller.release(TOKEN, "release-key", 9L, "DROPSHIP", "DS-002", releaseRequest) + .successful()).isTrue(); + + verify(service).settle(9L, "DROPSHIP", "DS-001", "settle-key", settleRequest); + verify(service).release(9L, "DROPSHIP", "DS-002", "release-key", releaseRequest); + verifyNoMoreInteractions(service); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/PlatformFinanceContainerSettlementControllerTest.java b/finance/src/test/java/com/baiyue/finance/PlatformFinanceContainerSettlementControllerTest.java new file mode 100644 index 000000000..8c189690f --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/PlatformFinanceContainerSettlementControllerTest.java @@ -0,0 +1,39 @@ +package com.baiyue.finance; + +import com.baiyue.finance.controller.PlatformFinanceContainerSettlementController; +import com.baiyue.finance.dto.FinanceContainerSettlementDtos.ContainerSettlementDetails; +import com.baiyue.finance.service.FinanceContainerSettlementQueryService; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; + +import java.lang.reflect.Method; +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.when; + +class PlatformFinanceContainerSettlementControllerTest { + + @Test + void delegatesOneAggregateReadAndRequiresAllThreeFinanceReadPermissions() throws Exception { + FinanceContainerSettlementQueryService service = mock(FinanceContainerSettlementQueryService.class); + var expected = new ContainerSettlementDetails(101L, 301L, List.of(901L), List.of()); + when(service.details(101L, 301L)).thenReturn(expected); + var controller = new PlatformFinanceContainerSettlementController(service); + + var response = controller.details(101L, 301L); + + assertThat(response.data()).isSameAs(expected); + verify(service).details(101L, 301L); + Method method = PlatformFinanceContainerSettlementController.class + .getMethod("details", long.class, long.class); + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/container-settlement-details"); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .contains("platform:finance:cost:view", "platform:finance:settlement-ar:list", + "platform:finance:settlement-ap:list"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/PlatformFinanceControllerBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/PlatformFinanceControllerBehaviorTest.java new file mode 100644 index 000000000..242a99628 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/PlatformFinanceControllerBehaviorTest.java @@ -0,0 +1,107 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.client.FirstlegCostSnapshotClient; +import com.baiyue.finance.controller.PlatformFinanceController; +import com.baiyue.finance.service.FinanceService; +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.time.Instant; +import java.util.List; +import java.util.Map; +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.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PlatformFinanceControllerBehaviorTest { + @Mock FinanceService service; + @Mock FinanceFileClient fileClient; + @Mock FirstlegCostSnapshotClient snapshotClient; + private PlatformFinanceController controller; + private final AuthenticatedUser user = new AuthenticatedUser( + 7L, null, null, null, "finance", "ADMIN", "WEB", Set.of()); + + @BeforeEach + void setUp() { + controller = new PlatformFinanceController(service, fileClient, snapshotClient); + ReflectionTestUtils.setField(controller, "fileToken", "internal-token"); + } + + @Test + void containerCostUsesFirstlegSnapshotInsteadOfBrowserData() { + var request = new PlatformFinanceController.CostRequest( + 10L, "CONTAINER_SHARED", null, 88L, "清关费", new BigDecimal("300.00"), + "CNY", List.of("91"), "整柜成本"); + var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 99L, 10L, List.of( + new FirstlegCostSnapshotClient.OrderVolume(101L, 1001L, new BigDecimal("1.25")), + new FirstlegCostSnapshotClient.OrderVolume(102L, 1002L, new BigDecimal("2.75")))); + when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot)); + when(service.createCost(eq(10L), eq(7L), any(), any(), eq("cost-key"))).thenReturn(Map.of("id", 1L)); + + controller.cost(user, "cost-key", request); + + verify(snapshotClient).loadingPlan("internal-token", 10L, 88L); + org.mockito.ArgumentCaptor cost = + org.mockito.ArgumentCaptor.forClass(FinanceService.CostRequest.class); + verify(service).createCost(eq(10L), eq(7L), cost.capture(), eq(List.of( + new FinanceService.ContainerOrderVolume(101L, 1001L, 99L, 10L, new BigDecimal("1.25")), + new FinanceService.ContainerOrderVolume(102L, 1002L, 99L, 10L, new BigDecimal("2.75")))), eq("cost-key")); + assertThat(cost.getValue().containerId()).as("Finance persists the physical containerBookingId") + .isEqualTo(99L); + } + + @Test + void containerCostRejectsEmptyFirstlegSnapshot() { + var request = new PlatformFinanceController.CostRequest( + 10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN, + "CNY", List.of("91"), null); + when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success( + new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 99L, 10L, List.of()))); + + assertThatThrownBy(() -> controller.cost(user, "cost-key", request)) + .hasMessageContaining("未返回有效柜内订单体积快照"); + verify(service, never()).createCost(any(Long.class), any(Long.class), any(), any(), any()); + } + + @Test + void containerCostPreservesFirstlegBusinessFailure() { + var request = new PlatformFinanceController.CostRequest( + 10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN, + "CNY", List.of("91"), null); + when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn( + new ApiResponse<>("A0404", "柜次不存在", null, "request", "trace", Instant.now())); + + assertThatThrownBy(() -> controller.cost(user, "cost-key", request)) + .hasMessage("柜次不存在"); + verify(service, never()).createCost(any(Long.class), any(Long.class), any(), any(), any()); + } + + @Test + void containerCostRejectsMismatchedLoadingPlanIdentity() { + var request = new PlatformFinanceController.CostRequest( + 10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN, + "CNY", List.of("91"), null); + var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(89L, 99L, 10L, List.of( + new FirstlegCostSnapshotClient.OrderVolume(101L, 1001L, BigDecimal.ONE))); + when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot)); + + assertThatThrownBy(() -> controller.cost(user, "cost-key", request)) + .hasMessageContaining("未返回有效柜内订单体积快照"); + verify(service, never()).createCost(any(Long.class), any(Long.class), any(), any(), any()); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java b/finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java new file mode 100644 index 000000000..62a70024d --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java @@ -0,0 +1,51 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.finance.controller.PlatformFinanceVoucherController; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PlatformFinanceVoucherControllerTest { + private final FinanceFileClient files = mock(FinanceFileClient.class); + private final PlatformFinanceVoucherController controller = new PlatformFinanceVoucherController(files, "test-token"); + + private FinanceFileClient.Metadata file(long customerId, String type, String businessId) { + return new FinanceFileClient.Metadata(91L, customerId, type, businessId, "付款.png", "sha", "image/png", 3L); + } + + @Test void returnsFilenameAndDownloadsWithinCustomerScope() { + when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(file(11, "FINANCE_VOUCHER", "11"), "test", "test")); + when(files.download("test-token", 11, 11, 91)).thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3})); + assertThat(controller.metadata(91, 11).data().fileName()).isEqualTo("付款.png"); + var content = controller.content(91, 11); + assertThat(content.getBody()).containsExactly(1, 2, 3); + assertThat(content.getHeaders().getCacheControl()).isEqualTo("no-store"); + assertThat(content.getHeaders().getFirst("Content-Disposition")).startsWith("attachment;"); + } + + @Test void rejectsWrongCustomerBusinessTypeAndBusinessIdBeforeDownload() { + for (var invalid : new FinanceFileClient.Metadata[]{file(12, "FINANCE_VOUCHER", "11"), file(11, "OTHER", "11"), file(11, "FINANCE_VOUCHER", "12")}) { + when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(invalid, "test", "test")); + assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class); + } + verify(files, never()).download(anyString(), anyLong(), anyLong(), anyLong()); + } + + @Test void rejectsMissingMetadataAndFailedContent() { + assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class); + when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(file(11, "FINANCE_VOUCHER", "11"), "test", "test")); + when(files.download("test-token", 11, 11, 91)).thenReturn(ResponseEntity.notFound().build()); + assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class); + } + + @Test void requiresPlatformListPermission() { + assertThat(PlatformFinanceVoucherController.class.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:finance:customer-arap:list')"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsControllerTest.java b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsControllerTest.java new file mode 100644 index 000000000..c1cb850bf --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsControllerTest.java @@ -0,0 +1,39 @@ +package com.baiyue.finance; + +import com.baiyue.finance.controller.ReceivableStatisticsController; +import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.ReceivableStatisticsService; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.math.BigDecimal; +import java.time.YearMonth; +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.Mockito.verify; +import static org.mockito.Mockito.when; + +class ReceivableStatisticsControllerTest { + @Test + void exposesTheDedicatedReadEndpointAndRejectsNonMonthInput() throws Exception { + ReceivableStatisticsService service = mock(ReceivableStatisticsService.class); + ReceivableStatisticsResponse response = new ReceivableStatisticsResponse(YearMonth.of(2026, 7), "CNY", + BigDecimal.TEN, BigDecimal.ONE, BigDecimal.valueOf(9), 0, 1, 20, List.of()); + when(service.query(YearMonth.of(2026, 7), null, 1, 20)).thenReturn(response); + ReceivableStatisticsController controller = new ReceivableStatisticsController(service); + + assertThat(ReceivableStatisticsController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/platform/finance/v1/receivable-statistics"); + assertThat(ReceivableStatisticsController.class.getMethod("query", String.class, Long.class, int.class, int.class) + .getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:receivable-statistics:list')"); + assertThat(controller.query("2026-07", null, 1, 20).data()).isSameAs(response); + verify(service).query(YearMonth.of(2026, 7), null, 1, 20); + assertThatThrownBy(() -> controller.query("2026/07", null, 1, 20)) + .isInstanceOf(FinanceEventException.class).hasMessageContaining("yyyy-MM"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java new file mode 100644 index 000000000..b6709ad8e --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java @@ -0,0 +1,64 @@ +package com.baiyue.finance; + +import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.ReceivableStatisticsService; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.math.BigDecimal; +import java.time.YearMonth; +import java.util.List; + +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.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ReceivableStatisticsServiceTest { + @Test + @SuppressWarnings("unchecked") + void aggregatesOnlyTheApprovedFormalCnyDimensionsAndPaginatesCustomers() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + List rows = List.of( + row(10001, "客户A", "300.00", "100.00", "200.00"), + row(10002, "客户B", "80.00", "80.00", "0.00")); + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(rows); + ReceivableStatisticsService service = new ReceivableStatisticsService(jdbc); + + var result = service.query(YearMonth.of(2026, 7), null, 1, 1); + + assertThat(result.currency()).isEqualTo("CNY"); + assertThat(result.totalReceivable()).isEqualByComparingTo("380.00"); + assertThat(result.totalReceived()).isEqualByComparingTo("180.00"); + assertThat(result.totalOutstanding()).isEqualByComparingTo("200.00"); + assertThat(result.totalCustomers()).isEqualTo(2); + assertThat(result.records()).containsExactly(rows.get(0)); + var sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc).query(sql.capture(), any(RowMapper.class), any(Object[].class)); + assertThat(sql.getValue()).contains("s.direction='RECEIVABLE'", "s.status<>'VOIDED'", "s.currency_code='CNY'", + "s.generated_date>=?", "s.generated_date new ReceivableStatisticsService(jdbc) + .query(YearMonth.of(2026, 7), null, 1, 20)) + .isInstanceOf(FinanceEventException.class) + .hasMessageContaining("勾稽异常"); + } + + private static ReceivableStatisticsRow row(long id, String name, String total, String received, String outstanding) { + return new ReceivableStatisticsRow(id, name, "CNY", new BigDecimal(total), + new BigDecimal(received), new BigDecimal(outstanding)); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java new file mode 100644 index 000000000..1064e9b0f --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java @@ -0,0 +1,597 @@ +package com.baiyue.finance; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.controller.ReconciliationStatementController; +import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest; +import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest; +import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest; +import com.baiyue.finance.dto.ReconciliationStatementDtos.StatementAdjustmentLineRequest; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceDocumentNumberGenerator; +import com.baiyue.finance.service.ReconciliationStatementService; +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.ArgumentCaptor; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementCreator; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +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.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.RETURNS_DEFAULTS; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReconciliationStatementBehaviorTest { + @Mock ReconciliationStatementService service; + + @Test + void controllerUsesAuthenticatedOperatorAndStableTraceResponse() { + ReconciliationStatementController controller = new ReconciliationStatementController(service); + AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of()); + CreateReconciliationRequest create = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L), + LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "八月对账"); + ReconciliationActionRequest action = new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of()); + + var created = controller.createReconciliationStatement(user, "create-key", create); + var acted = controller.actionReconciliationStatement(user, 501L, "action-key", action); + + verify(service).create(71L, "create-key", create); + verify(service).action(71L, 501L, "action-key", action); + assertThat(created.requestId()).isNotBlank(); + assertThat(created.traceId()).isNotBlank(); + assertThat(acted.requestId()).isNotBlank(); + assertThat(acted.traceId()).isNotBlank(); + } + + @Test + void selectedModeRejectsDuplicateFeeSourcesBeforeWriting() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L, 88L), + LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null); + + assertThatThrownBy(() -> target.create(71L, "create-key", request)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_400"); + verifyNoInteractions(jdbc); + } + + @Test + void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception { + JdbcTemplate jdbc = createJdbc(); + ReconciliationStatementService target = serviceWithNumber(jdbc); + CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(), + LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账"); + + var result = target.create(71L, "cutoff-key", request); + + assertThat(result.status()).isEqualTo("DRAFT"); + assertThat(result.totalReceivable()).isEqualByComparingTo("100.00"); + verify(jdbc).query(org.mockito.ArgumentMatchers.startsWith("SELECT id,source_fee_id"), any(RowMapper.class), + eq(9L), eq(LocalDate.of(2026, 8, 1)), eq(LocalDate.of(2026, 8, 31))); + } + + @Test + void selectedEligibleCnySourceCreatesStableDraftAndLocksSource() throws Exception { + JdbcTemplate jdbc = createJdbc(); + ReconciliationStatementService target = serviceWithNumber(jdbc); + CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L), + LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "八月对账"); + + var result = target.create(71L, "create-key", request); + + assertThat(result.statementId()).isEqualTo(501L); + assertThat(result.statementNo()).isEqualTo("REC-260818-0001"); + assertThat(result.currency()).isEqualTo("CNY"); + assertThat(result.createdByName()).isEqualTo("platform_admin"); + assertThat(result.updatedByName()).isEqualTo("platform_admin"); + assertThat(result.totalReceivable()).isEqualByComparingTo("100.00"); + assertThat(result.lines()).hasSize(1); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L)); + } + + @Test + void selectedCrossCustomerSourceIsHiddenAsNotFoundBeforeBillInsert() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> { + if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) return 1; + return RETURNS_DEFAULTS.answer(invocation); + }); + when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), + eq(9L), eq(71L), eq("RECONCILIATION_CREATE"), eq("create-key"))).thenReturn(List.of()); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT customer_name_snapshot"), + any(RowMapper.class), eq(9L))).thenReturn(List.of()); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT id FROM fin_subject"), any(RowMapper.class))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getLong(1)).thenReturn(1L); + return List.of(mapper.mapRow(rs, 0)); + }); + when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT id,customer_id"), eq(88L))) + .thenReturn(List.of(Map.of("id", 88L, "customer_id", 10L))); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + + assertThatThrownBy(() -> target.create(71L, "create-key", new CreateReconciliationRequest(9L, "CNY", + "SELECTED", List.of(88L), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null))) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404"); + verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class)); + } + + @Test + void submitUsesExpectedVersionAndPersistsRevisionBeforeStateChange() throws Exception { + JdbcTemplate jdbc = actionJdbc("DRAFT", "PENDING_REVIEW", "submit-key"); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + + var result = target.action(71L, 501L, "submit-key", + new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of())); + + assertThat(result.customerId()).isEqualTo(9L); + assertThat(result.status()).isEqualTo("PENDING_REVIEW"); + assertThat(result.version()).isEqualTo(1); + assertThat(result.periodStart()).isEqualTo(LocalDate.of(2026, 8, 1)); + verify(jdbc).update(anyString(), eq(9L), eq(501L), eq(0), eq("提交审核"), anyString(), anyString(), eq(71L), isNull()); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET status="), + eq("PENDING_REVIEW"), eq("PENDING_REVIEW"), eq("PENDING_REVIEW"), eq(71L), + eq("PENDING_REVIEW"), eq("PENDING_REVIEW"), eq(71L), eq("PENDING_REVIEW"), + eq("提交审核"), eq(71L), isNull(), eq(501L), eq(9L), eq("DRAFT"), eq(0)); + verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), (Object[]) any()); + } + + @Test + void approveFreezesInternalBillAndReturnsContractStatus() throws Exception { + JdbcTemplate jdbc = actionJdbc("PENDING_REVIEW", "APPROVED", "approve-key"); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + + var result = target.action(71L, 501L, "approve-key", + new ReconciliationActionRequest("APPROVE", "审核通过", 1, List.of())); + + assertThat(result.status()).isEqualTo("APPROVED"); + assertThat(result.version()).isEqualTo(2); + verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), (Object[]) any()); + } + + @Test + void issueFreezesSourcesOnlyAfterApproval() throws Exception { + JdbcTemplate jdbc = actionJdbc("APPROVED", "ISSUED", "issue-key"); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + + var result = target.action(71L, 501L, "issue-key", + new ReconciliationActionRequest("ISSUE", null, 2, List.of())); + + assertThat(result.status()).isEqualTo("ISSUED"); + assertThat(result.version()).isEqualTo(3); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='PENDING_CONFIRMATION'"), eq(501L), eq(9L)); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L)); + } + + @Test + void customerConfirmationChecksScopeStateVersionAndWritesAudit() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)); + AtomicInteger billReads = new AtomicInteger(); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L))) + .thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + boolean confirmed = billReads.getAndIncrement() > 0; + return List.of(mapper.mapRow(billResultSet("ISSUED", confirmed ? 4 : 3, + confirmed ? "CONFIRMED" : "PENDING_CONFIRMATION", confirmed ? 71L : null), 0)); + }); + when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), + eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of()); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + + var result = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3)); + + assertThat(result.confirmationStatus()).isEqualTo("CONFIRMED"); + assertThat(result.confirmedBy()).isEqualTo(71L); + assertThat(result.version()).isEqualTo(4); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"), + eq(71L), eq("操作人 71"), eq(71L), eq("操作人 71"), eq(501L), eq(9L), eq(3)); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_audit_log"), + eq(9L), eq("RECONCILIATION_STATEMENT"), eq(501L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), isNull(), + anyString(), anyString(), eq(71L), eq("操作人 71"), isNull(), isNull()); + + ArgumentCaptor requestHash = ArgumentCaptor.forClass(String.class); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_idempotency_record"), + eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"), requestHash.capture()); + when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), + eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of(Map.of( + "request_hash", requestHash.getValue(), "status", "SUCCEEDED", "result_resource_id", 501L, + "updated_at", LocalDateTime.now()))); + + var replay = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3)); + + assertThat(replay.confirmationStatus()).isEqualTo("CONFIRMED"); + verify(jdbc, org.mockito.Mockito.times(1)).update( + org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"), + eq(71L), eq("操作人 71"), eq(71L), eq("操作人 71"), eq(501L), eq(9L), eq(3)); + } + + @Test + void customerConfirmationHidesCrossCustomerAndRejectsInvalidState() throws Exception { + JdbcTemplate crossCustomer = mock(JdbcTemplate.class); + when(crossCustomer.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L))) + .thenReturn(List.of()); + assertThatThrownBy(() -> new ReconciliationStatementService(crossCustomer, new ObjectMapper()).confirmForCustomer( + 9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3))) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404"); + verify(crossCustomer, never()).queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), (Object[]) any()); + + JdbcTemplate draft = mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)); + when(draft.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L))) + .thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + return List.of(mapper.mapRow(billResultSet("DRAFT", 0, "NOT_REQUIRED", null), 0)); + }); + when(draft.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), + eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("draft-key"))).thenReturn(List.of()); + assertThatThrownBy(() -> new ReconciliationStatementService(draft, new ObjectMapper()).confirmForCustomer( + 9L, 71L, 501L, "draft-key", new CustomerConfirmationRequest(0))) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); + verify(draft, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"), (Object[]) any()); + + JdbcTemplate stale = mock(JdbcTemplate.class, invocation -> { + if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) { + Object sql = invocation.getArguments()[0]; + return sql instanceof String value && value.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'") ? 0 : 1; + } + return RETURNS_DEFAULTS.answer(invocation); + }); + when(stale.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L))) + .thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + return List.of(mapper.mapRow(billResultSet("ISSUED", 3, "PENDING_CONFIRMATION", null), 0)); + }); + when(stale.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), + eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("stale-key"))).thenReturn(List.of()); + assertThatThrownBy(() -> new ReconciliationStatementService(stale, new ObjectMapper()).confirmForCustomer( + 9L, 71L, 501L, "stale-key", new CustomerConfirmationRequest(2))) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); + } + + @Test + void sameIdempotencyKeyWithDifferentActionBodyIsRejected() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.query(anyString(), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + return List.of(mapper.mapRow(billResultSet("DRAFT", 0), 0)); + }); + when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), + eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("used-key"))).thenReturn(List.of(Map.of( + "request_hash", "different-request", "status", "SUCCEEDED", "result_resource_id", 501L, + "updated_at", LocalDateTime.now()))); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + + assertThatThrownBy(() -> target.action(71L, 501L, "used-key", + new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of()))) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); + verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_bill_revision"), (Object[]) any()); + } + + @Test + void invalidActionAndVersionConflictAreRejectedBeforeMutation() throws Exception { + JdbcTemplate jdbc = actionJdbc("DRAFT", "DRAFT", "invalid-key"); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + + assertThatThrownBy(() -> target.action(71L, 501L, "invalid-key", + new ReconciliationActionRequest("NOT_AN_ACTION", null, 0, List.of()))) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_400"); + assertThatThrownBy(() -> target.action(71L, 501L, "stale-version-key", + new ReconciliationActionRequest("SUBMIT", "提交审核", 99, List.of()))) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); + verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_bill_revision"), (Object[]) any()); + } + + @Test + void adjustIssuedStatementUpdatesSelectedLineAndKeepsRevisionHistory() throws Exception { + JdbcTemplate jdbc = actionJdbc("ISSUED", "ISSUED", "adjust-key"); + when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L))) + .thenReturn(new BigDecimal("280.00")); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + + target.action(71L, 501L, "adjust-key", new ReconciliationActionRequest("ADJUST", "运费更正", 3, + List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("280.00"), "运费更正")))); + + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill_line SET amount="), + eq(new BigDecimal("280.00")), eq("运费更正"), eq(601L), eq(501L), eq(9L)); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET total_amount="), + eq(new BigDecimal("280.00")), eq(new BigDecimal("280.00")), eq(71L), isNull(), eq(501L), eq(9L), eq(3)); + } + + @Test + void adjustmentBelowReceivedAmountIsRejected() throws Exception { + JdbcTemplate jdbc = actionJdbc("ISSUED", "ISSUED", "low-adjust-key"); + when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L))) + .thenReturn(new BigDecimal("80.00")); + when(jdbc.query(anyString(), any(RowMapper.class), (Object[]) any())) + .thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + ResultSet rs = billResultSet("ISSUED", 3); + when(rs.getBigDecimal("received_amount")).thenReturn(new BigDecimal("100.00")); + return List.of(mapper.mapRow(rs, 0)); + }); + + assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L, + "low-adjust-key", new ReconciliationActionRequest("ADJUST", "金额错误", 3, + List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("80.00"), "金额错误"))))) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); + verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET total_amount="), (Object[]) any()); + } + + @Test + void voidIssuedStatementReleasesSourcesWhenNoReceiptOrSettlementExists() throws Exception { + JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key"); + when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_receipt_bill"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L); + when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + + var result = target.action(71L, 501L, "void-key", + new ReconciliationActionRequest("VOID", "客户重开账期", 3, List.of())); + + assertThat(result.status()).isEqualTo("VOIDED"); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L)); + } + + @Test + void voidRejectsStatementWithReceiptOrValidSettlement() throws Exception { + for (String query : List.of("receipt", "settlement")) { + JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-" + query); + when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_receipt_bill"), eq(Long.class), eq(9L), eq(501L))) + .thenReturn("receipt".equals(query) ? 1L : 0L); + when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L))) + .thenReturn("settlement".equals(query) ? 1L : 0L); + assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L, + "void-" + query, new ReconciliationActionRequest("VOID", "作废原因", 3, List.of()))) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); + verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET status="), (Object[]) any()); + } + } + + @Test + void reopenVoidedStatementReacquiresEveryReleasedSource() throws Exception { + JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-key"); + when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L))).thenReturn(1); + ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + + var result = target.action(71L, 501L, "reopen-key", + new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of())); + + assertThat(result.status()).isEqualTo("DRAFT"); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L)); + } + + @Test + void reconciliationIdempotencyRecoversFailedAndStaleProcessingRecords() { + ReconciliationActionRequest body = new ReconciliationActionRequest("SUBMIT", null, 0, List.of()); + JdbcTemplate firstJdbc = mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)); + when(firstJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("first-key"))).thenReturn(List.of()); + ReconciliationStatementService firstService = new ReconciliationStatementService(firstJdbc, new ObjectMapper().findAndRegisterModules()); + + Long first = ReflectionTestUtils.invokeMethod(firstService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "first-key", "POST", "/statements/501/actions", body); + ArgumentCaptor hash = ArgumentCaptor.forClass(String.class); + verify(firstJdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_idempotency_record"), + eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("first-key"), hash.capture()); + assertThat(first).isNull(); + + JdbcTemplate replayJdbc = mock(JdbcTemplate.class); + when(replayJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("replay-key"))) + .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED", + "result_resource_id", 501L, "updated_at", LocalDateTime.now()))); + assertThat((Long) ReflectionTestUtils.invokeMethod( + new ReconciliationStatementService(replayJdbc, new ObjectMapper().findAndRegisterModules()), + "reserve", 9L, 71L, "RECONCILIATION_ACTION", "replay-key", "POST", "/statements/501/actions", body)) + .isEqualTo(501L); + + JdbcTemplate processingJdbc = mock(JdbcTemplate.class); + when(processingJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("processing-key"))) + .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now()))); + assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod( + new ReconciliationStatementService(processingJdbc, new ObjectMapper().findAndRegisterModules()), + "reserve", 9L, 71L, "RECONCILIATION_ACTION", "processing-key", "POST", "/statements/501/actions", body)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); + + JdbcTemplate recoveryJdbc = mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)); + when(recoveryJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("failed-key"))) + .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "FAILED", "updated_at", LocalDateTime.now()))); + when(recoveryJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("stale-key"))) + .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now().minusMinutes(10)))); + ReconciliationStatementService recoveryService = new ReconciliationStatementService(recoveryJdbc, new ObjectMapper().findAndRegisterModules()); + + assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "failed-key", "POST", "/statements/501/actions", body)).isNull(); + assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "stale-key", "POST", "/statements/501/actions", body)).isNull(); + } + + private static JdbcTemplate actionJdbc(String beforeStatus, String afterStatus, String key) throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class, org.mockito.Mockito.withSettings().lenient().defaultAnswer(invocation -> { + if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) { + Object[] arguments = invocation.getArguments(); + if (arguments.length > 1 && arguments[0] instanceof String sql + && sql.startsWith("UPDATE fin_bill SET status=")) { + return arguments[arguments.length - 1].equals(versionOf(beforeStatus)) ? 1 : 0; + } + return 1; + } + return RETURNS_DEFAULTS.answer(invocation); + })); + when(jdbc.query(anyString(), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + return List.of(mapper.mapRow(billResultSet(beforeStatus, versionOf(beforeStatus)), 0)); + }); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + return List.of(mapper.mapRow(billResultSet(afterStatus, versionOf(afterStatus)), 0)); + }); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT after_data"), any(RowMapper.class), eq(9L), eq(501L))) + .thenReturn(List.of("{\"generationMode\":\"SELECTED\",\"periodStart\":\"2026-08-01\",\"periodEnd\":\"2026-08-31\"}")); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT f.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of()); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT r.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of()); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT action,reason"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of()); + when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), + eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq(key))).thenReturn(List.of()); + when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT * FROM fin_bill_line"), eq(9L), eq(501L))).thenReturn(List.of()); + return jdbc; + } + + private static JdbcTemplate createJdbc() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class, org.mockito.Mockito.withSettings().lenient().defaultAnswer(invocation -> { + if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) return 1; + return RETURNS_DEFAULTS.answer(invocation); + })); + LocalDate start = LocalDate.of(2026, 8, 1); + LocalDate end = LocalDate.of(2026, 8, 31); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT customer_name_snapshot"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getString("customer_name_snapshot")).thenReturn("客户9"); + when(rs.getInt("credit_days")).thenReturn(30); + return List.of(mapper.mapRow(rs, 0)); + }); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT id FROM fin_subject"), any(RowMapper.class))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getLong(1)).thenReturn(1L); + return List.of(mapper.mapRow(rs, 0)); + }); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT id,source_fee_id"), any(RowMapper.class), + eq(9L), eq(start), eq(end), eq(88L))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + return List.of(mapper.mapRow(sourceResultSet(), 0)); + }); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT id,source_fee_id"), any(RowMapper.class), + eq(9L), eq(start), eq(end))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + return List.of(mapper.mapRow(sourceResultSet(), 0)); + }); + when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT id,customer_id"), eq(88L))) + .thenReturn(List.of(Map.of("id", 88L, "customer_id", 9L))); + org.mockito.Mockito.lenient().doAnswer(invocation -> { + KeyHolder holder = invocation.getArgument(1); + holder.getKeyList().add(Map.of("GENERATED_KEY", 501L)); + return 1; + }).when(jdbc).update(any(PreparedStatementCreator.class), any(KeyHolder.class)); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + ResultSet rs = billResultSet("DRAFT", 0); + when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("100.00")); + when(rs.getBigDecimal("outstanding_amount")).thenReturn(new BigDecimal("100.00")); + return List.of(mapper.mapRow(rs, 0)); + }); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT after_data"), any(RowMapper.class), eq(9L), eq(501L))) + .thenReturn(List.of("{\"generationMode\":\"SELECTED\",\"periodStart\":\"2026-08-01\",\"periodEnd\":\"2026-08-31\"}")); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT f.id"), any(RowMapper.class), eq(9L), eq(501L))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); + return List.of(mapper.mapRow(sourceResultSet(), 0)); + }); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT r.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of()); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT action,reason"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of()); + when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), + eq(9L), eq(71L), eq("RECONCILIATION_CREATE"), eq("create-key"))).thenReturn(List.of()); + when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), + eq(9L), eq(71L), eq("RECONCILIATION_CREATE"), eq("cutoff-key"))).thenReturn(List.of()); + return jdbc; + } + + private static ResultSet sourceResultSet() throws Exception { + ResultSet rs = mock(ResultSet.class, org.mockito.Mockito.withSettings().lenient()); + when(rs.getLong("id")).thenReturn(88L); + when(rs.getLong("source_fee_id")).thenReturn(700L); + when(rs.getInt("source_fee_version")).thenReturn(3); + when(rs.getString("source_type")).thenReturn("INBOUND_FEE"); + when(rs.getString("source_no")).thenReturn("IN-202608-1"); + when(rs.getString("direction")).thenReturn("RECEIVABLE"); + when(rs.getLong("customer_id")).thenReturn(9L); + when(rs.getLong("order_id")).thenReturn(300L); + when(rs.getLong("inbound_id")).thenReturn(400L); + when(rs.getLong("container_id")).thenReturn(0L); + when(rs.getString("fee_item_code")).thenReturn("FREIGHT"); + when(rs.getString("fee_item_name")).thenReturn("头程运费"); + when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00")); + when(rs.getString("currency_code")).thenReturn("CNY"); + when(rs.getString("status")).thenReturn("LOCKED"); + return rs; + } + + private static ResultSet billResultSet(String status, int version) throws Exception { + return billResultSet(status, version, "ISSUED".equals(status) ? "PENDING_CONFIRMATION" : "NOT_REQUIRED", null); + } + + private static ResultSet billResultSet(String status, int version, String confirmationStatus, Long confirmedBy) throws Exception { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(501L); + when(rs.getLong("tenant_id")).thenReturn(9L); + when(rs.getString("bill_no")).thenReturn("REC-260818-0001"); + when(rs.getString("customer_name_snapshot")).thenReturn("客户9"); + when(rs.getString("status")).thenReturn(status); + when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00")); + when(rs.getBigDecimal("received_amount")).thenReturn(BigDecimal.ZERO); + when(rs.getBigDecimal("outstanding_amount")).thenReturn(new BigDecimal("300.00")); + when(rs.getInt("version")).thenReturn(version); + when(rs.getString("customer_confirmation_status")).thenReturn(confirmationStatus); + when(rs.getLong("customer_confirmed_by")).thenReturn(confirmedBy == null ? 0L : confirmedBy); + when(rs.wasNull()).thenReturn(confirmedBy == null); + when(rs.getString("customer_confirmed_by_name")).thenReturn(confirmedBy == null ? null : "操作人 71"); + when(rs.getInt("credit_days")).thenReturn(30); + when(rs.getString("remark")).thenReturn("八月对账"); + when(rs.getString("created_by_name")).thenReturn("platform_admin"); + when(rs.getString("updated_by_name")).thenReturn("platform_admin"); + Timestamp timestamp = Timestamp.valueOf("2026-08-11 10:00:00"); + when(rs.getTimestamp("created_at")).thenReturn(timestamp); + when(rs.getTimestamp("updated_at")).thenReturn(timestamp); + when(rs.getTimestamp("customer_confirmed_at")).thenReturn(confirmedBy == null ? null : timestamp); + return rs; + } + + private static int versionOf(String status) { + return switch (status) { + case "DRAFT" -> 0; + case "PENDING_REVIEW" -> 1; + case "APPROVED" -> 2; + case "ISSUED" -> 3; + case "VOIDED" -> 4; + default -> 1; + }; + } + + private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) { + FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class); + org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC-260818-0001"); + return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java new file mode 100644 index 000000000..42481247f --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java @@ -0,0 +1,116 @@ +package com.baiyue.finance; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.RegionCode; +import com.baiyue.common.event.WalletCommandRequestedV1; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceWalletService; +import com.baiyue.finance.service.WalletCommandEventService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class WalletCommandEventServiceTest { + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + private final FinanceWalletService wallet = mock(FinanceWalletService.class); + private final TransactionTemplate transactions = mock(TransactionTemplate.class); + private final WalletCommandEventService service = new WalletCommandEventService(jdbc, mapper, wallet, transactions); + + @BeforeEach + void executeTransactionCallbacks() { + when(transactions.execute(any())).thenAnswer(invocation -> { + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(mock(TransactionStatus.class)); + }); + doAnswer(invocation -> { + Consumer callback = invocation.getArgument(0); + callback.accept(mock(TransactionStatus.class)); + return null; + }).when(transactions).executeWithoutResult(any()); + } + + @Test + void freezesWalletAndPublishesCompletedEvent() { + EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("88.50")); + when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId()))) + .thenReturn(List.of()); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); + when(wallet.freeze(eq(31L), eq("freeze-procurement-1"), any())).thenReturn( + new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("88.50"), + new BigDecimal("80.00"), new BigDecimal("8.50"), "FROZEN", 71L, null, + null, null, null)); + + assertThat(service.consume(envelope)).isEqualTo(envelope.eventId()); + + verify(wallet).freeze(eq(31L), eq("freeze-procurement-1"), argThat(request -> + request.amount().compareTo(new BigDecimal("88.50")) == 0 + && request.businessNo().equals("PO-1"))); + verify(jdbc).update(startsWith("INSERT IGNORE INTO fin_wallet_event_outbox"), any(Object[].class)); + verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), + eq("SUCCEEDED"), contains("\"holdStatus\":\"FROZEN\""), eq(envelope.eventId())); + } + + @Test + void settledWalletHoldMarksMatchingReceivableSourceSettled() { + EventEnvelope envelope = envelope("SETTLE", 31L, new BigDecimal("88.50")); + when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId()))) + .thenReturn(List.of()); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); + when(wallet.settle(eq(31L), eq("OVERSEAS_PROCUREMENT_FEE"), eq("PO-1"), + eq("freeze-procurement-1"), any())).thenReturn(new WalletHold(9L, 31L, + "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("88.50"), new BigDecimal("80.00"), + new BigDecimal("8.50"), "SETTLED", 71L, 72L, null, null, null)); + + service.consume(envelope); + + verify(jdbc).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), eq(9L)); + } + + @Test + void rejectsCustomerMismatchBeforeTouchingWallet() { + EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("10.00")); + envelope = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(), + envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), "32", envelope.aggregateType(), + envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), envelope.traceId(), + envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash()); + + EventEnvelope mismatched = envelope; + assertThatThrownBy(() -> service.consume(mismatched)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-007"); + verifyNoInteractions(wallet); + } + + private EventEnvelope envelope(String operation, long customerId, BigDecimal amount) { + WalletCommandRequestedV1 command = new WalletCommandRequestedV1(operation, customerId, + "OVERSEAS_PROCUREMENT_FEE", "PO-1", amount, "采购快递费", + "freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z")); + JsonNode payload = mapper.valueToTree(command); + return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_REQUESTED, + WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), + RegionCode.OVERSEAS, RegionCode.CN, String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1, + null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid(); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/controller/FinanceExportProviderControllerTest.java b/finance/src/test/java/com/baiyue/finance/controller/FinanceExportProviderControllerTest.java new file mode 100644 index 000000000..46c77df04 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/controller/FinanceExportProviderControllerTest.java @@ -0,0 +1,37 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.finance.service.FinanceExportProviderService; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.RequestMapping; + +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.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FinanceExportProviderControllerTest { + @Test + void exposesOnlyTheInternalVersionedProviderAndRequiresTheServiceToken() { + FinanceExportProviderService service = mock(FinanceExportProviderService.class); + String token = "12345678901234567890123456789012"; + FinanceExportProviderController controller = new FinanceExportProviderController(service, token); + controller.initializeToken(); + ExportBatchRequest request = new ExportBatchRequest("9", "0", "PLATFORM", "7", 1, Map.of(), null, null, 100); + ExportBatchResponse response = new ExportBatchResponse(1, "sha256:test", "snapshot", List.of(), List.of(), "0", false, 0L); + when(service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request)).thenReturn(response); + + assertThat(FinanceExportProviderController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/internal/exports/v1"); + assertThat(controller.batch(token, FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request).data()).isSameAs(response); + verify(service).exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request); + assertThatThrownBy(() -> controller.batch("wrong", FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request)) + .isInstanceOf(BizException.class); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java new file mode 100644 index 000000000..26720351a --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java @@ -0,0 +1,59 @@ +package com.baiyue.finance.service; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FinanceDocumentNumberGeneratorTest { + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai")); + + @Test + void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.registerBean(JdbcTemplate.class, () -> jdbc); + context.register(FinanceDocumentNumberGenerator.class); + context.refresh(); + + assertThat(context.getBean(FinanceDocumentNumberGenerator.class)).isNotNull(); + } + } + + @Test + void formatsShortDailySequenceForAllThreeDocuments() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L, 2L, 38L); + FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK); + + assertThat(generator.next("REC")).isEqualTo("REC-260818-0001"); + assertThat(generator.next("AR")).isEqualTo("AR-260818-0002"); + assertThat(generator.next("AP")).isEqualTo("AP-260818-0038"); + + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), + eq("REC:20260818")); + } + + @Test + void rejectsUnsupportedTypeAndOverflow() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK); + assertThatThrownBy(() -> generator.next("PAY")).isInstanceOf(IllegalArgumentException.class); + + when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L); + assertThatThrownBy(() -> generator.next("REC")) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java new file mode 100644 index 000000000..663f1c3e9 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java @@ -0,0 +1,158 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; +import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; +import com.baiyue.finance.dto.FinanceReadModels.Statement; +import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory; +import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail; +import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine; +import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow; +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 java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +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.mockito.Mockito.when; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class FinanceExportProviderServiceTest { + @Mock FinanceCustomerAccountService accountService; + @Mock FinancePhase1OperationService operationService; + @Mock FinanceQueryService queryService; + @Mock ReconciliationStatementService reconciliationService; + @Mock FinanceSettlementService settlementService; + @Mock ReceivableStatisticsService receivableStatisticsService; + private FinanceExportProviderService service; + + @BeforeEach + void setUp() { + service = new FinanceExportProviderService(accountService, operationService, queryService, + reconciliationService, settlementService, receivableStatisticsService, new ObjectMapper(), "12345678901234567890123456789012"); + service.initializeSigningKey(); + } + + @Test + void exportsStatementRowsUsingBusinessContainerNumberAndStableSnapshot() { + Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"), + null, new BigDecimal("300"), new BigDecimal("100"), new BigDecimal("200"), "CNY", "ISSUED", 3, + "CONFIRMED", 17L, "客户确认人", LocalDateTime.parse("2026-08-05T10:00:00"), + 7L, "创建人A", LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00")); + ReconciliationLine line = new ReconciliationLine(601L, 81L, 2, "hash", "RECEIVABLE", "CONTAINER_FEE", + "CONT-31", 11L, 21L, 31L, "IN-21", "CONT-31", LocalDate.parse("2026-08-15"), BigDecimal.TEN, + new BigDecimal("30"), "运费", + new BigDecimal("320"), new BigDecimal("300"), "CNY", "优惠调整"); + ReconciliationDetail detail = new ReconciliationDetail(501L, "REC-501", 10001L, "客户A", + LocalDate.parse("2026-08-01"), LocalDate.parse("2026-08-31"), 30, "CNY", new BigDecimal("300"), + new BigDecimal("100"), new BigDecimal("200"), false, "ISSUED", 3, + "CONFIRMED", 17L, "客户确认人", LocalDateTime.parse("2026-08-05T10:00:00"), 7L, "创建人A", + LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"), + "八月账单", List.of(line), List.of(new PaymentHistory(701L, "RECEIPT", LocalDate.parse("2026-08-20"), + new BigDecimal("100"), "BANK_TRANSFER", "PAY-701", List.of("9001"), 9L, "财务员", LocalDateTime.parse("2026-08-20T10:00:00"), "")), List.of()); + FeeSourceDetail source = new FeeSourceDetail(81L, "FIRSTLEG", "CONTAINER_FEE", "CONT-31", 9001L, 2, "RECEIVABLE", 10001L, + "客户A", null, null, null, 21L, "IN-21", 31L, "CONT-31", LocalDate.parse("2026-08-15"), "运费", + BigDecimal.TEN, "KG", new BigDecimal("30"), null, null, new BigDecimal("300"), "CNY", + "CNY", new BigDecimal("300"), BigDecimal.ONE, "2026-08", "APPROVED", + "AVAILABLE", true, null, "hash", 2, List.of()); + when(queryService.statements(10001L, false)).thenReturn(List.of(summary)); + when(reconciliationService.detailForPlatform(501L)).thenReturn(detail); + when(operationService.getFinanceFeeSource(81L)).thenReturn(source); + + var first = service.exportBatch(FinanceExportProviderService.RECONCILIATION_STATEMENT_LIST, + request(Map.of("customerId", "10001", "containerNo", "CONT-31"), null, null)); + var continued = service.exportBatch(FinanceExportProviderService.RECONCILIATION_STATEMENT_LIST, + request(Map.of("customerId", "10001", "containerNo", "CONT-31"), first.nextCursor(), first.snapshotToken())); + + assertThat(first.columns()).isNotEmpty(); + assertThat(first.rows()).hasSize(1); + assertThat(first.rows().get(0)).containsEntry("statementNo", "REC-501").containsEntry("customerName", "客户A"); + assertThat(first.totalRows()).isEqualTo(1L); + assertThat(continued.columns()).isEmpty(); + assertThat(continued.rows()).isEmpty(); + assertThat(continued.snapshotToken()).isEqualTo(first.snapshotToken()); + } + + @Test + void rejectsUnknownParametersAndSnapshotReuseAcrossFilters() { + assertThatThrownBy(() -> service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, + request(Map.of("page", 1), null, null))).isInstanceOf(BizException.class); + + var first = service.exportBatch(FinanceExportProviderService.RECONCILIATION_STATEMENT_LIST, + request(Map.of("status", "ISSUED"), null, null)); + assertThatThrownBy(() -> service.exportBatch(FinanceExportProviderService.RECONCILIATION_STATEMENT_LIST, + request(Map.of("status", "VOIDED"), first.nextCursor(), first.snapshotToken()))) + .isInstanceOf(BizException.class).hasMessageContaining("snapshotToken"); + } + + @Test + void capsFileBatchSizeToFinanceQueryPageLimitForAccountAndLedgerExports() { + when(accountService.list(1, 200, null, null, 10001L)) + .thenReturn(PageEnvelope.success(List.of(), 1, 200, 0)); + when(operationService.listDerivedCustomerAccountLedger(10001L, 1, 200, null, null, null)) + .thenReturn(PageEnvelope.success(List.of(), 1, 200, 0)); + + service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, + request(Map.of("customerId", "10001"), null, null, 500)); + service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LEDGER, + request(Map.of("customerId", "10001"), null, null, 500)); + + verify(accountService).list(1, 200, null, null, 10001L); + verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 200, null, null, null); + } + + @Test + void accountExportUsesOperatingFundNamesAndAcceptsSupplierPaymentLedgerFilters() { + when(accountService.list(1, 100, null, null, 10001L)) + .thenReturn(PageEnvelope.success(List.of(), 1, 100, 0)); + var account = service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, + request(Map.of("customerId", "10001"), null, null)); + assertThat(account.columns()).extracting(column -> column.title()).contains( + "待收客户款(CNY)", "待付供应商款(CNY)", "已线下支付供应商款(CNY)"); + + for (String businessType : List.of("SUPPLIER_DIRECT_PAID", "SUPPLIER_ON_ACCOUNT_PAYMENT")) { + when(operationService.listDerivedCustomerAccountLedger(10001L, 1, 100, businessType, null, null)) + .thenReturn(PageEnvelope.success(List.of(), 1, 100, 0)); + service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LEDGER, + request(Map.of("customerId", "10001", "businessType", businessType), null, null)); + verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 100, businessType, null, null); + } + } + + @Test + void exportsTheSameMonthlyCnyCustomerStatisticsWithoutExcludedPrototypeFields() { + when(receivableStatisticsService.exportRows(YearMonth.of(2026, 7), 10001L)).thenReturn(List.of( + new ReceivableStatisticsRow(10001L, "客户A", "CNY", new BigDecimal("300"), + new BigDecimal("100"), new BigDecimal("200")))); + + var result = service.exportBatch(FinanceExportProviderService.RECEIVABLE_STATISTICS, + request(Map.of("month", "2026-07", "customerId", "10001"), null, null)); + + assertThat(result.rows()).singleElement().satisfies(row -> assertThat(row) + .containsEntry("statisticsMonth", "2026-07") + .containsEntry("customerName", "客户A") + .containsEntry("currency", "CNY") + .doesNotContainKeys("fbaDirect", "fbaSpecialLine", "roundingDiscount")); + verify(receivableStatisticsService).exportRows(YearMonth.of(2026, 7), 10001L); + } + + private ExportBatchRequest request(Map parameters, String cursor, String snapshot) { + return request(parameters, cursor, snapshot, 100); + } + + private ExportBatchRequest request(Map parameters, String cursor, String snapshot, int limit) { + return new ExportBatchRequest("9001", "0", "PLATFORM", "7", 1, parameters, cursor, snapshot, limit); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceOperatorSnapshotTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceOperatorSnapshotTest.java new file mode 100644 index 000000000..40e09cf04 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceOperatorSnapshotTest.java @@ -0,0 +1,47 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceOperatorSnapshotTest { + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void capturesAuthenticatedUsernameForMatchingOperator() { + AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, + "platform_admin", "ADMIN", "WEB", Set.of()); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + + assertThat(FinanceOperatorSnapshot.currentName(71L)).isEqualTo("platform_admin"); + assertThat(FinanceOperatorSnapshot.requiredName(71L)).isEqualTo("platform_admin"); + assertThat(FinanceOperatorSnapshot.currentName(72L)).isNull(); + } + + @Test + void prefersAuthenticatedDisplayNameOverLoginUsername() { + AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, + "12555214478", "廖万昌", "MERCHANT", "WEB", Set.of(), false, null, null, false); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null)); + + assertThat(FinanceOperatorSnapshot.currentName(71L)).isEqualTo("廖万昌"); + } + + @Test + void labelsSystemGeneratedEntriesWithoutInventingAnOperator() { + assertThat(FinanceOperatorSnapshot.currentName(0L)).isEqualTo("系统"); + assertThat(FinanceOperatorSnapshot.currentName(71L)).isNull(); + assertThat(FinanceOperatorSnapshot.requiredName(71L)).isEqualTo("操作人 71"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceWalletBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceWalletBehaviorTest.java new file mode 100644 index 000000000..4a37ce725 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceWalletBehaviorTest.java @@ -0,0 +1,184 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +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.*; + +class FinanceWalletBehaviorTest { + @Test + void missingAccountReadsAsZeroWithoutCreatingData() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of()); + + var account = service(jdbc, "https://pay.example/wechat.png", "").account(9L); + + assertThat(account.actualBalance()).isEqualByComparingTo("0.00"); + assertThat(account.frozenBalance()).isEqualByComparingTo("0.00"); + assertThat(account.availableBalance()).isEqualByComparingTo("0.00"); + assertThat(account.creditLimit()).isEqualByComparingTo("0.00"); + assertThat(account.availableCredit()).isEqualByComparingTo("0.00"); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + @Test + void rechargeChannelsAreEnabledOnlyWhenQrCodeIsConfigured() { + var channels = service(mock(JdbcTemplate.class), "https://pay.example/wechat.png", "").channels(); + + assertThat(channels).satisfiesExactly( + wechat -> { assertThat(wechat.paymentMethod()).isEqualTo("WECHAT"); assertThat(wechat.enabled()).isTrue(); }, + alipay -> { assertThat(alipay.paymentMethod()).isEqualTo("ALIPAY"); assertThat(alipay.enabled()).isFalse(); }); + } + + @Test + void developmentProfileUsesTheNonPayableSampleQrCode() { + var channels = new FinanceWalletService(mock(JdbcTemplate.class), new ObjectMapper().findAndRegisterModules(), + mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "", "", + "cn", "development").channels(); + + assertThat(channels).allSatisfy(channel -> { + assertThat(channel.enabled()).isTrue(); + assertThat(channel.qrCodeUrl()).isEqualTo("/customer-recharge-qr.svg"); + }); + } + + @Test + void deductionMayMakeActualBalanceNegative() { + FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); + WalletAccount initial = service.accountState(new BigDecimal("10.00"), BigDecimal.ZERO, + BigDecimal.ZERO, BigDecimal.ZERO, null, 2); + + WalletAccount deducted = service.deductAccount(initial, new BigDecimal("10.01")); + + assertThat(deducted.actualBalance()).isEqualByComparingTo("-0.01"); + assertThat(deducted.availableBalance()).isEqualByComparingTo("-0.01"); + assertThat(deducted.creditActive()).isFalse(); + } + + @Test + void aMissingAccountNeverReceivesPermanentDefaultCredit() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of()); + + var account = service(jdbc, "x", "y").account(9L); + + assertThat(account.creditLimit()).isZero(); + assertThat(account.availableCredit()).isZero(); + assertThat(account.availableBalance()).isZero(); + assertThat(account.creditExpiresOn()).isNull(); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + @Test + void deductionUsesCashBeforeCreditAndRechargeRepaysCreditFirst() { + FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); + WalletAccount initial = service.accountState(new BigDecimal("100.00"), BigDecimal.ZERO, + new BigDecimal("50.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 3); + + WalletAccount deducted = service.deductAccount(initial, new BigDecimal("120.00")); + assertThat(deducted.actualBalance()).isEqualByComparingTo("0.00"); + assertThat(deducted.creditUsed()).isEqualByComparingTo("20.00"); + assertThat(deducted.availableCredit()).isEqualByComparingTo("30.00"); + assertThat(deducted.availableBalance()).isEqualByComparingTo("30.00"); + + WalletAccount recharged = service.rechargeAccount(deducted, new BigDecimal("25.00")); + assertThat(recharged.creditUsed()).isEqualByComparingTo("0.00"); + assertThat(recharged.actualBalance()).isEqualByComparingTo("5.00"); + assertThat(recharged.availableBalance()).isEqualByComparingTo("55.00"); + } + + @Test + void deductionNeverConsumesCashReservedByAnActiveHold() { + FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); + WalletAccount initial = service.accountState(new BigDecimal("100.00"), new BigDecimal("50.00"), + new BigDecimal("80.00"), new BigDecimal("20.00"), LocalDate.now().plusDays(7), 4); + + WalletAccount deducted = service.deductAccount(initial, new BigDecimal("80.00")); + + assertThat(deducted.actualBalance()).isEqualByComparingTo("30.00"); + assertThat(deducted.creditUsed()).isEqualByComparingTo("10.00"); + assertThat(deducted.frozenBalance()).isEqualByComparingTo("50.00"); + assertThat(deducted.creditFrozen()).isEqualByComparingTo("20.00"); + assertThat(deducted.availableCredit()).isEqualByComparingTo("50.00"); + assertThat(deducted.availableBalance()).isEqualByComparingTo("50.00"); + } + + @Test + void rechargeRepaysUsedCreditWithoutReleasingFrozenCredit() { + FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); + WalletAccount initial = service.accountState(new BigDecimal("5.00"), new BigDecimal("25.00"), + new BigDecimal("100.00"), new BigDecimal("40.00"), new BigDecimal("20.00"), + LocalDate.now().plusDays(7), 5); + + WalletAccount recharged = service.rechargeAccount(initial, new BigDecimal("50.00")); + + assertThat(recharged.actualBalance()).isEqualByComparingTo("15.00"); + assertThat(recharged.creditUsed()).isEqualByComparingTo("0.00"); + assertThat(recharged.creditFrozen()).isEqualByComparingTo("20.00"); + assertThat(recharged.frozenBalance()).isEqualByComparingTo("25.00"); + assertThat(recharged.availableCredit()).isEqualByComparingTo("80.00"); + assertThat(recharged.availableBalance()).isEqualByComparingTo("90.00"); + } + + @Test + void accountStateRejectsCreditUsedAndFrozenAboveTheLimit() { + FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); + + assertThatThrownBy(() -> service.accountState(BigDecimal.ZERO, new BigDecimal("20.00"), + new BigDecimal("50.00"), new BigDecimal("31.00"), new BigDecimal("20.00"), + LocalDate.now().plusDays(7), 1)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409"); + } + + @Test + void accountStateAllowsBalanceToBecomeNegativeWhileFundsRemainFrozen() { + FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); + + WalletAccount account = service.accountState(new BigDecimal("9.99"), new BigDecimal("10.00"), + new BigDecimal("20.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 1); + + assertThat(account.availableBalance()).isEqualByComparingTo("19.99"); + assertThat(account.creditActive()).isTrue(); + } + + @Test + void accountStateRejectsCreditFrozenAboveTotalFrozenAmount() { + FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); + + assertThatThrownBy(() -> service.accountState(new BigDecimal("100.00"), new BigDecimal("10.00"), + new BigDecimal("50.00"), new BigDecimal("10.01"), LocalDate.now().plusDays(7), 1)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409"); + } + + @Test + void expiredCreditNoLongerContributesToAvailableBalance() { + FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); + + WalletAccount account = service.accountState(BigDecimal.ZERO, BigDecimal.ZERO, + new BigDecimal("50.00"), new BigDecimal("20.00"), BigDecimal.ZERO, + LocalDate.now().minusDays(1), 1); + + assertThat(account.creditActive()).isFalse(); + assertThat(account.availableCredit()).isZero(); + assertThat(account.availableBalance()).isEqualByComparingTo("-20.00"); + } + + private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay) { + return new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(), + mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay, + "test", "test"); + } +} diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md new file mode 100644 index 000000000..de489a7d2 --- /dev/null +++ b/firstleg/AGENTS.md @@ -0,0 +1,102 @@ +# Firstleg Service 模块指引 + +## 职责与边界 + +- Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueFirstlegApplication.java` 对 `com.baiyue.firstleg` 根包按 `annotationClass = Mapper.class` 扫描;禁止恢复业务子域 Mapper 包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内编译门禁见 `MapperRegistrationArchitectureTest.java`、`BaiyueFirstlegApplicationContractTest.java`。 +- 负责履约任务/分配、入库单、测量/OCR、审核后初始计价、FBA 计费、客户在途柜量统计、国内出仓履约链路,以及海外仓业务国内出仓后的船节点查询任务和节点事实。 +- 订单主数据归 `order/`,托盘/库位归 `warehouse/`,文件对象归 `file/`,OCR 供应商归 `integration-service/`;通过客户端 DTO 和内部令牌协作。 +- 入库、分柜、装柜、PDA 和出仓能力均在本模块按对应服务端契约协作;`WAIT_SPLIT + APPROVED + CONFIRMED` 是入库链路状态节点,不是全量验收的范围终点。财务状态仍归 `finance`,本模块只生产不可变费用来源和柜次成本快照。 + +## 模块入口 + +散货托盘码:`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java`,契约 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按柜号、FBA 地址内部编号、三位序号生成冻结标签;内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为包含 141 等内部编号的完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 + +| 目标 | 入口 | +|---|---| +| 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | +| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | +| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | +| 入库单扣货、履约门禁与凭证 | `service/InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`、`client/InboundHoldFileClient.java`;凭证固定按客户、入库单和 `INBOUND_HOLD_EVIDENCE` 精确归属,只有进入不可变扣货历史的文件可下载 | +| Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 | +| 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`、`service/CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 | +| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。一件代发箱唛以 `fl_inbound_carton_label` 表示物理箱,`fl_inbound_carton_label_sku` 冻结箱内一条或多条 SKU;生成时经 `client/OrderCargoClient.java` 获取。缺 SKU 时禁止生成,打印面显示箱内 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` | +| 一件代发测量后 PDA 清点组托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量审核通过进入 `WAIT_PALLETIZE`,按预计托数预生成 `DP` 托盘码并下发独立任务,不创建国内库存;托盘信息单固定为每托一页 A4,包含托盘 Code 128 一维码、入仓码、总箱数与 SKU/商品名称/箱数/实点数量,不展示作业状态。PDA 逐箱清点,支持先扫箱或先扫托、归托、逐托封托和在未进入装柜计划前解除封托;合托解封时同步恢复所有参与任务及入仓单。预计托数与已生成托盘数不一致时禁止作业,全部箱和实际参与托盘完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` | +| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | +| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` | +| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | +| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` | +| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 | +| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 | +| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` | +| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` | +| 首页实时指标、客户运营统计/在途柜量 | 首页物理在途柜 TEU 与待清关票数从 `dashboard/PlatformDashboardMetricsController.java` 进入,契约见 `src/main/resources/openapi/dashboard-metrics-v1.yaml`;客户维度统计为 `customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/application/CustomerTransitStatisticsService.java`,契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` | +| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` | +| 客户入库结果与头程费用确认 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`;查询与确认接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing`、`/confirm`,契约 `src/main/resources/openapi/firstleg-v1.yaml` | +| 价格簿查询内部切片 | `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`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;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`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 | +| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | +| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | +| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | +| PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 | +| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/EventBridgeClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | +| 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` | +| 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | +| 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` | +| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 | +| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | +| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | +| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 | +| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | +| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 | +| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | +| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` | +| 仓库公共供应商快照 | `client/WarehouseSupplierClient.java`、`service/WarehouseSupplierSnapshotService.java`;报关、订舱和拖车分别消费 `customs-v1.yaml`、`ocean-bookings-v1.yaml`、`truck-appointments-v1.yaml` 中的供应商 ID,并由 Warehouse 返回服务端名称快照 | +| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | +| 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` | + +## 修改约束 + +- 移动包、拆分端口/适配器或引入新的跨切片依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-firstleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 +- 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。 +- 价格簿查询消费者依赖 `PriceBookQueryUseCase` 或稳定查询端口,不得反向依赖持久化 Mapper;移动旧查询类时同步更新生产调用、测试、Mock 与 import,避免同一职责在根 `repository/mapper` 和 `pricebookquery/` 双写。 +- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。 +- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。 +- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。 +- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 +- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。 +- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 +- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 +- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 +- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 +- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 +- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 +- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。 +- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。 +- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。 +- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 +- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。 +- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜完成时间加 72 小时计算,实际报空时间可为空。 +- 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 资源结算 + +- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺,但确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 +- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。 +- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 +- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 +- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 +- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。 +- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。 +- 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。 +- PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单,源单与余量子单可各自保留同一块槽及称重快照,因此 `fl_inbound_pallet_allocation` 的槽位与称重唯一键必须包含 `inbound_id`。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。 +- 数据库字段变更直接维护当前模块基线与 `../deploy/sql/头程数据库统一增量-MySQL8.sql`;已有 `phase3-pda-release-allocation-migration.sql` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。 + +## 全量验收导航 + +本模块承载订单履约、入库测量计费、订单编排、柜次、排柜、订舱、拖车、PDA 和 `LOADED` 门禁等全量链路;按本次验收范围读取对应业务域文档、Controller、DTO、OpenAPI、当前 SQL 定义和调用方,不以历史规划终点阻断联调。PDA 按 uni-app 在线更新安排开发与验收,不另设设备型号门禁。 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/docs/terminal-drayage-mail.md b/firstleg/docs/terminal-drayage-mail.md new file mode 100644 index 000000000..fa4e2a968 --- /dev/null +++ b/firstleg/docs/terminal-drayage-mail.md @@ -0,0 +1,34 @@ +# 码头车队邮件 + +## 行为 + +- 首次选择车队只保存车队;列表“发送邮件”提交当前版本和幂等键,向供应商资料中的单个邮箱发送提柜请求。 +- 更换为另一车队时,以该柜、原车队是否存在 `REQUEST_PICKUP` 邮件记录作为“是否已下发”的事实。已下发过时,与车队变更在同一数据库事务中依次生成原车队 `CANCEL_PICKUP`、新车队 `REQUEST_PICKUP` 两个任务,取消邮件使用原请求的收件地址快照;从未下发过时只生成新车队 `REQUEST_PICKUP`,不向原车队发送取消邮件。新车队必须为 ACTIVE/TRUCKING。 +- 同一车队重复选择不发邮件、不改变版本。列表“发送邮件”允许人工重复点击,每次使用最新版本都会新增一封提柜请求,便于测试或再次通知;同一个幂等键的请求不会重复入队。 +- 同柜严格顺序投递,取消未成功时新请求等待。失败不自动重发,避免 SMTP 超时后重复通知;人工核对未收到后通过邮件记录重试。进程中断超过五分钟的发送任务转为失败待核对。 +- `SENT` 表示 SMTP 已接受邮件,不代表车队已阅读或执行。前端入队后只提示待发送,不伪装为成功投递。邮件记录展示最近 100 封,展示收件人、冻结主题和正文、状态及失败信息。 +- 邮件发送、重试复用 `platform:firstleg:terminal-drayage:fleet:edit`,记录查看复用列表权限。已提柜、已卸柜或已还柜不再开放新的提柜邮件动作。 + +## 代码与存储 + +- `terminaldrayage/service/TerminalDrayageMailTemplates.java`:临时英文取消/提柜模板;正式文案在这里替换。已排队邮件保持原始正文快照。 +- `TerminalDrayageMailService.java`:联系资料校验、邮件入队与重试;`TerminalDrayageMailDispatcher.java`:SMTP 投递。 +- `terminaldrayage/mapper/TerminalDrayageMapper.java`:邮件存储与状态抢占;`fl_terminal_drayage_mail` 定义于 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 和国内统一 SQL 入口。 +- Warehouse `/internal/warehouse/v1/suppliers/{supplierId}/trucking-contact` 仅供内部令牌调用;地址不由浏览器提交。 +- 公网接口及动作以 `src/main/resources/openapi/terminal-drayage-v1.yaml`、Warehouse `supplier-v1.yaml` 为准。 + +## 启用配置 + +Firstleg 增加 Spring Boot mail starter,仅用于 SMTP,不引入独立邮件平台。默认 `TERMINAL_DRAYAGE_MAIL_ENABLED=false`,可保存任务但不会发信。启用前需配置正式发件账号并核对待发送任务;启用后会投递已排队邮件。 + +| 环境变量 | 用途 | +|---|---| +| `TERMINAL_DRAYAGE_MAIL_ENABLED` | 默认 false,true 启用投递 | +| `TERMINAL_DRAYAGE_MAIL_HOST` | SMTP 主机 | +| `TERMINAL_DRAYAGE_MAIL_PORT` | 默认 587 | +| `TERMINAL_DRAYAGE_MAIL_USERNAME` | SMTP 登录账号,同时作为邮件发件地址;QQ 邮箱填写完整邮箱地址 | +| `TERMINAL_DRAYAGE_MAIL_PASSWORD` | SMTP 授权码,仅通过环境注入,不使用 QQ 登录密码 | +| `TERMINAL_DRAYAGE_MAIL_SSL` | 默认 false,强制 STARTTLS;true 使用隐式 TLS,一般配合 465 端口 | +| `TERMINAL_DRAYAGE_MAIL_SCAN_DELAY` | 默认 10000 毫秒 | + +发布涉及 Admin、Firstleg、Warehouse 和新增邮件表;没有新增 IAM 权限。代码开发不授权部署、执行环境 SQL或向真实车队试发。UI 按现有列表与居中弹框承载,此新增交互依据当前用户需求,历史原型未定义邮件功能;待人工在 1920×1080、100% 缩放下截图确认。 diff --git a/firstleg/pom.xml b/firstleg/pom.xml index 344b9aca3..98dc43916 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -5,7 +5,11 @@ com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml firstleg-service + org.springframework.bootspring-boot-starter-mail + com.baiyue.sharedbaiyue-common-core${project.version} + 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} com.baomidoumybatis-plus-spring-boot3-starter com.baiyue.sharedbaiyue-common-web${project.version} @@ -13,11 +17,15 @@ 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.apache.poipoi-ooxml5.3.0 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..cdcc88bd8 --- /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|terminaldrayage)(?:\.|$)|(?: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 8532d169c..f3a095053 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -1,13 +1,18 @@ package com.baiyue.firstleg; +import org.apache.ibatis.annotations.Mapper; 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; /** * 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。 */ -@MapperScan("com.baiyue.firstleg.mapper") +@MapperScan(basePackages = "com.baiyue.firstleg", annotationClass = Mapper.class) +@EnableFeignClients(basePackages = "com.baiyue.firstleg") +@EnableScheduling @SpringBootApplication(scanBasePackages = "com.baiyue") public class BaiyueFirstlegApplication { public static void main(String[] args) { SpringApplication.run(BaiyueFirstlegApplication.class, args); } 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 new file mode 100644 index 000000000..f48074f63 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java @@ -0,0 +1,19 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +/** 将本地业务 Outbox 投递到跨区事件桥。 */ +@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient", url = "${baiyue.lastleg.event-bridge-url:}") +public interface EventBridgeClient { + @PostMapping("/internal/event-bridge/events") + ApiResponse receive( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + + record Acknowledgement(String eventId, String result, String receivedAt) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java new file mode 100644 index 000000000..e27a40718 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.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; + +@FeignClient(name = "file-service", contextId = "firstlegFeePaymentVoucherFileClient", + url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files") +public interface FeePaymentVoucherFileClient { + @GetMapping("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}") + ApiResponse metadata(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, + @PathVariable long customerId, + @PathVariable long fileId); + + record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String sha256, String contentType, Long sizeBytes) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/FinanceFeeSourceEventClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/FinanceFeeSourceEventClient.java new file mode 100644 index 000000000..f1087295a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/FinanceFeeSourceEventClient.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.firstleg.dto.FinanceFeeSourceEvent; +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 com.fasterxml.jackson.annotation.JsonProperty; + +@FeignClient(name = "finance-service", contextId = "firstlegFinanceFeeSourceEventClient", + url = "${baiyue.clients.finance.url:}", path = "/internal/finance/v1/fee-source-events") +public interface FinanceFeeSourceEventClient { + @PostMapping + ApiResponse receive( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @RequestBody FinanceFeeSourceEvent event); + + record Acknowledgement(@JsonProperty("feeSourceId") Long feeSourceId) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java new file mode 100644 index 000000000..7581947dd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "iam-service", contextId = "firstlegIamCustomerSnapshotClient", + url = "${baiyue.clients.iam.url:}", path = "/internal/iam") +public interface IamCustomerSnapshotClient { + @GetMapping("/finance-customers") + ApiResponse> page( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestParam("page") int page, + @RequestParam("size") int size, + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "customerId", required = false) Long customerId); + + record Customer(long id, String tenantCode, String tenantName, String customerNickname, String status, + String contactName, String contactPhone) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/InboundHoldFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/InboundHoldFileClient.java new file mode 100644 index 000000000..a9eaff744 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/InboundHoldFileClient.java @@ -0,0 +1,35 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.ResponseEntity; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +/** Stores and validates hold evidence against the exact customer inbound. */ +@FeignClient(name = "file-service", contextId = "inboundHoldFileClient", + url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files") +public interface InboundHoldFileClient { + @PostMapping(value = "/{tenantId}/INBOUND_HOLD_EVIDENCE/{inboundId}", consumes = "multipart/form-data") + ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, @PathVariable long inboundId, + @RequestPart("file") MultipartFile file); + + @GetMapping("/{tenantId}/INBOUND_HOLD_EVIDENCE/{inboundId}/{fileId}") + ApiResponse metadata(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, @PathVariable long inboundId, + @PathVariable long fileId); + + @GetMapping("/{tenantId}/INBOUND_HOLD_EVIDENCE/{inboundId}/{fileId}/content") + ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, @PathVariable long inboundId, + @PathVariable long fileId); + + record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId, + String fileName, String sha256, String contentType, Long sizeBytes) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java new file mode 100644 index 000000000..e025b7d3b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java @@ -0,0 +1,34 @@ +package com.baiyue.firstleg.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.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.ResponseEntity; + +/** Validates that a referenced File object belongs to the exact loading business resource. */ +@FeignClient(name = "file-service", contextId = "firstlegLoadingDocumentFileClient", + url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files") +public interface LoadingDocumentFileClient { + @PostMapping(value = "/{tenantId}/{businessType}/{businessId}", consumes = "multipart/form-data") + ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, @PathVariable String businessType, + @PathVariable String businessId, @RequestPart("file") MultipartFile file); + + @GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") + ApiResponse metadata(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, @PathVariable String businessType, + @PathVariable String businessId, @PathVariable long fileId); + + @GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}/content") + ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@PathVariable long fileId); + + record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String sha256, String contentType, Long sizeBytes) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java new file mode 100644 index 000000000..bd3410cf8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.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 org.springframework.http.ResponseEntity; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +/** Validate that a loading evidence file is owned by the physical cabinet. */ +@FeignClient(name = "file-service", contextId = "firstlegLoadingEvidenceFileClient", + url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/loading-evidence") +public interface LoadingEvidenceFileClient { + @PostMapping(value = "/{containerBookingId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, @PathVariable long containerBookingId, + @RequestParam String evidenceType, @RequestPart("file") MultipartFile file); + + @GetMapping("/{containerBookingId}/{fileId}") + ApiResponse validate(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long containerBookingId, + @RequestParam String evidenceType, + @PathVariable long fileId); + + @GetMapping("/{containerBookingId}/{fileId}/content") + ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long containerBookingId, + @RequestParam String evidenceType, + @PathVariable long fileId); + + record FileMetadata(Long fileId, String ownerScope, Long tenantId, String businessType, + String businessId, String fileName, String contentType) {} + record UploadResult(Long fileId, String ownerScope, Long tenantId, String businessType, + String businessId, String fileName, String contentType, Long sizeBytes) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java new file mode 100644 index 000000000..aa6d2ec29 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java @@ -0,0 +1,57 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; + +@FeignClient(name = "file-service", contextId = "firstlegMeasurementImageClient", + url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/measurement-images") +public interface MeasurementImageClient { + @PostMapping(value = "/{tenantId}/{inboundId}", consumes = "multipart/form-data") + ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, + @PathVariable long inboundId, + @RequestPart("file") MultipartFile file); + + @PostMapping(value = "/receipts/{tenantId}/{receiptId}", consumes = "multipart/form-data") + ApiResponse uploadReceipt(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, + @PathVariable long receiptId, + @RequestPart("file") MultipartFile file); + + @DeleteMapping("/{tenantId}/{inboundId}/{fileId}") + ApiResponse delete(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, + @PathVariable long inboundId, + @PathVariable long fileId); + + @DeleteMapping("/receipts/{tenantId}/{receiptId}/{fileId}") + ApiResponse deleteReceipt(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, + @PathVariable long receiptId, + @PathVariable long fileId); + + @GetMapping("/{tenantId}/{inboundId}/{fileId}") + org.springframework.http.ResponseEntity download( + @RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, + @PathVariable long inboundId, + @PathVariable long fileId); + + record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes, + String recognitionStatus, BigDecimal weightValue, String weightUnit, + Double confidence, String error) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OceanBookingFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OceanBookingFileClient.java new file mode 100644 index 000000000..6ffd2ab4c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OceanBookingFileClient.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.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.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.ResponseEntity; + +@FeignClient(name = "file-service", contextId = "oceanBookingFileClient", + url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files") +public interface OceanBookingFileClient { + @PostMapping(value = "/{tenantId}/{businessType}/{businessId}", consumes = "multipart/form-data") + ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, + @PathVariable String businessType, + @PathVariable String businessId, + @RequestPart("file") MultipartFile file); + + @GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") + ApiResponse metadata(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, + @PathVariable String businessType, + @PathVariable String businessId, + @PathVariable long fileId); + + @GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}/content") + ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, + @PathVariable String businessType, + @PathVariable String businessId, + @PathVariable long fileId); + + record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String sha256, String contentType, Long sizeBytes) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java new file mode 100644 index 000000000..7c9c3405f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -0,0 +1,83 @@ +package com.baiyue.firstleg.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.math.BigDecimal; +import java.util.List; +import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; + +@FeignClient(name = "order-service", contextId = "firstlegOrderCargoClient", + url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes") +public interface OrderCargoClient { + @GetMapping("/{tenantId}/{orderId}/{cargoId}") + ApiResponse detail(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, + @PathVariable long orderId, + @PathVariable long cargoId); + + @GetMapping("/{tenantId}/{orderId}") + ApiResponse> listForCartonLabels( + @RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, + @PathVariable long orderId); + + @GetMapping("/{tenantId}/{orderId}/inbound-snapshot") + ApiResponse inboundSnapshot( + @RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, + @PathVariable long orderId); + + @GetMapping("/{tenantId}/{orderId}/measurement-declaration") + ApiResponse measurementDeclaration( + @RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, @PathVariable long orderId); + + record MeasurementDeclarationSnapshot( + String referenceId, BigDecimal originalGrossWeightKg, + BigDecimal originalVolumeCbm, String defaultActualGoodsName) {} + + record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, + Integer originalPackageCount, BigDecimal originalGrossWeightKg, + BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn, + Integer unitsPerPackage, Integer totalQuantity, BigDecimal netWeightKg, + String cargoRemark, String customerRemark, String latestInternalNote, + String warehouseRemark, String customerServiceRemark) { + public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, + Integer originalPackageCount, BigDecimal originalGrossWeightKg, + BigDecimal originalVolumeCbm) { + this(cargoId, orderId, itemNo, cargoName, originalPackageCount, + originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null, null, null, null, null); + } + public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, + Integer originalPackageCount, BigDecimal originalGrossWeightKg, + BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn, + Integer unitsPerPackage, Integer totalQuantity, BigDecimal netWeightKg, + String cargoRemark) { + this(cargoId, orderId, itemNo, cargoName, originalPackageCount, originalGrossWeightKg, + originalVolumeCbm, skuNo, cargoNameEn, unitsPerPackage, totalQuantity, netWeightKg, + cargoRemark, null, null, null, null); + } + } + + record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, + String skuNo, Integer packageCount, Integer unitsPerCarton, + BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, + String shipmentId, String packingGroupNo) { + public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, + Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, + BigDecimal heightCm) { + this(cargoId, orderId, itemNo, cargoName, null, + packageCount, null, lengthCm, widthCm, heightCm, null, null); + } + public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, + String skuNo, Integer packageCount, BigDecimal lengthCm, + BigDecimal widthCm, BigDecimal heightCm, String shipmentId) { + this(cargoId, orderId, itemNo, cargoName, skuNo, + packageCount, null, lengthCm, widthCm, heightCm, shipmentId, null); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java new file mode 100644 index 000000000..8833d0115 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +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.Instant; + +@FeignClient(name = "order-service", contextId = "firstlegOrderFulfillmentCompletionClient", + url = "${baiyue.clients.order.url:}") +public interface OrderFulfillmentCompletionClient { + @PostMapping("/internal/order/v1/fulfillment-completions") + ApiResponse complete(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody Request request); + + record Request(String tenantId, String orderId, String orderNo, String inboundCode, + String deliveryNo, Instant completedAt, String eventId, String payloadHash) { } + record Result(Long orderId, String status, boolean replayed) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java new file mode 100644 index 000000000..75e8d48c7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java @@ -0,0 +1,34 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +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.util.List; + +/** Order-domain child-order contract used by the transactional PDA settlement. */ +@FeignClient(name = "order-service", contextId = "firstlegOrderFulfillmentSplitClient", + url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/fulfillment-splits") +public interface OrderFulfillmentSplitClient { + @PostMapping + ApiResponse split(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @RequestBody CreateRequest request); + + record CreateRequest(Long tenantId, Long parentOrderId, Long sourceOrderId, + Long loadingPlanId, Long containerBookingId, + Integer sourcePackageCount, Integer loadedPackageCount, + Integer remainingPackageCount, Integer expectedParentVersion, + String idempotencyKey, String operationId, + List lines, String operatorName) {} + record QuantityLine(Long sourceDetailId, Integer packageCount) {} + record Response(Long parentOrderId, String parentOrderNo, Integer parentVersion, + Child loadedChild, Child remainderChild, Integer sourcePackageCount, + Integer loadedPackageCount, Integer remainingPackageCount, + Long loadingPlanId, Long containerBookingId, String idempotencyKey) {} + record Child(Long orderId, String orderNo, Integer splitSequence, String splitRole, + Integer packageCount, Long sourceOrderId, Long loadingPlanId, + Long containerBookingId) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java new file mode 100644 index 000000000..bad194527 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java @@ -0,0 +1,36 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.LocalDateTime; +import java.util.List; + +/** Order-owned pending-review rows for the SmartLoading read model. */ +@FeignClient(name = "order-service", contextId = "firstlegPendingReviewOrderClient", + url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/pending-review-orders") +public interface PendingReviewOrderClient { + @GetMapping + ApiResponse> page( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestParam(required = false) List channelCodes, + @RequestParam(required = false) Long warehouseId, + @RequestParam(required = false) Long customerId, + @RequestParam(required = false) String inboundCode, + @RequestParam(required = false) String createdStart, + @RequestParam(required = false) String createdEnd, + @RequestParam(required = false) String expectedArrivalStart, + @RequestParam(required = false) String expectedArrivalEnd, + @RequestParam(required = false) List excludedOrderIds, + @RequestParam long page, + @RequestParam long size); + + record Row(Long orderId, String orderNo, String inboundCode, Long customerId, + String customerName, Long warehouseId, String channelCode, String channelName, + int totalPackageCount, LocalDateTime createdAt, LocalDateTime expectedArrivalAt, + String mainStatus) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java new file mode 100644 index 000000000..803aa9d7d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.ResponseEntity; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +/** File Service contract for immutable original price-book XLSX files. */ +@FeignClient(name = "file-service", contextId = "firstlegPriceBookFileClient", + url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/price-books") +public interface PriceBookFileClient { + @PostMapping(value = "/{businessId}", consumes = "multipart/form-data") + ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long businessId, + @RequestPart("file") MultipartFile file); + + @GetMapping("/{businessId}/{fileId}/metadata") + ApiResponse metadata(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long businessId, + @PathVariable long fileId); + + @GetMapping("/{businessId}/{fileId}/content") + ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long businessId, + @PathVariable long fileId); + + record FileMetadata(Long fileId, Long businessId, String fileName, String contentType, + Long sizeBytes, String sha256, String businessType) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookWarehouseClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookWarehouseClient.java new file mode 100644 index 000000000..708c9784d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookWarehouseClient.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.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 = "firstlegPriceBookWarehouseClient", + url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses") +public interface PriceBookWarehouseClient { + @GetMapping("/{tenantId}/{id}/active") + ApiResponse active(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable("tenantId") long tenantId, + @PathVariable("id") long id); + + record Detail(Warehouse warehouse, List
addresses) {} + record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName, + String warehouseType, String countryCode, String status) {} + record Address(String countryCode, String stateProvince, String city, String district, + String postalCode, String addressLine1, String addressLine2) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java new file mode 100644 index 000000000..9eb2c5dcf --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java @@ -0,0 +1,77 @@ +package com.baiyue.firstleg.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** Print Service contract for stateless Firstleg PDF rendering. */ +@FeignClient(name = "print-service", contextId = "firstlegPrintPdfClient", + url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs") +public interface PrintPdfClient { + @PostMapping(value = "/fba-pallet-labels", consumes = "application/json") + ResponseEntity generateFbaPalletLabels( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @RequestBody FbaPalletRequest request); + + record FbaPalletRequest(String templateCode, PageSpec pageSpec, String downloadFileName, List labels) {} + record FbaPalletLabel(String fbaWarehouseCode, String palletCode) {} + + @PostMapping(value = "/inbound-marks", consumes = "application/json") + ResponseEntity generateInboundMarks( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @RequestBody InboundMarkPdfRequest request); + + @PostMapping(value = "/freight-a4-documents", consumes = "application/json") + ResponseEntity generateFreightA4Documents( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @RequestBody FreightA4PdfRequest request); + + @PostMapping(value = "/dropship-pallet-a4-documents", consumes = "application/json") + ResponseEntity generateDropshipPalletA4Documents( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @RequestBody DropshipPalletA4PdfRequest request); + + record InboundMarkPdfRequest(String templateCode, PageSpec pageSpec, + String downloadFileName, List
".getBytes()); + zip.closeEntry(); + } finally { + Files.deleteIfExists(valid); + } + return malformed; + } + + private static Path minimalWorkbook() throws IOException { + Path file = Files.createTempFile("price-parser-", ".xlsx"); + try (XSSFWorkbook workbook = new XSSFWorkbook()) { + for (int i = 0; i < STANDARD_SHEETS.size(); i++) { + String sheetName = STANDARD_SHEETS.get(i); + var sheet = workbook.createSheet(sheetName); + sheet.createRow(1).createCell(0).setCellValue("船名航次:DANIEL K. INOUYE V.100E 上海出,预计开船日期:2026年8月12日"); + boolean shifted = sheetName.equals("纽约EXX海铁"); + int base = shifted ? 1 : 0; + sheet.createRow(3).createCell(base).setCellValue("FBA仓库"); + sheet.getRow(3).createCell(base + 1).setCellValue("华东仓(义乌)"); + sheet.getRow(3).createCell(base + 3).setCellValue("华南仓(深圳)"); + var row = sheet.createRow(5); + row.createCell(base).setCellValue("ONT8/LGB8"); + row.createCell(base + 1).setCellValue(1790); + row.createCell(base + 2).setCellValue(10.2); + row.createCell(base + 3).setCellValue(10.7); + row.createCell(base + 4).setCellValue("20天左右"); + row.createCell(base + 5).setCellValue("周日全天"); + row.createCell(base + 6).setCellValue("MATSON 上海周三开"); + } + var parcel = workbook.createSheet("海派"); + parcel.createRow(4).createCell(2).setCellValue("MASTON 周三开"); + parcel.getRow(4).createCell(5).setCellValue("MASTON 周四开"); + parcel.getRow(4).createCell(8).setCellValue("合德 周四开"); + parcel.getRow(4).createCell(11).setCellValue("普船统配"); + parcel.createRow(5).createCell(2).setCellValue("船开后15天左右"); + parcel.createRow(8).createCell(0).setCellValue("美西"); + parcel.createRow(9).createCell(0).setCellValue("美中"); + parcel.createRow(10).createCell(0).setCellValue("美东"); + for (int row = 8; row <= 10; row++) { + for (int col = 2; col <= 13; col++) parcel.getRow(row).createCell(col).setCellValue(15 + col / 10.0); + } + parcel.createRow(13).createCell(2).setCellValue("MASTON 周三开"); + parcel.createRow(14).createCell(2).setCellValue("船开后15天左右"); + parcel.createRow(17).createCell(0).setCellValue("美西"); + parcel.createRow(18).createCell(0).setCellValue("美中"); + parcel.createRow(19).createCell(0).setCellValue("美东"); + for (int row = 17; row <= 19; row++) { + for (int col = 2; col <= 13; col++) parcel.getRow(row).createCell(col).setCellValue(16 + col / 10.0); + } + var quick = workbook.createSheet("快速查询"); + quick.createRow(4).createCell(0).setCellValue("列1"); + try (OutputStream output = Files.newOutputStream(file)) { workbook.write(output); } + } + return file; + } + + private static Path minimalStandardWorkbook(String destination, String east, String yiwuTax, String dongguanTax) throws IOException { + Path file = Files.createTempFile("price-parser-unknown-", ".xlsx"); + try (XSSFWorkbook workbook = new XSSFWorkbook()) { + var sheet = workbook.createSheet("美森正班海卡"); + sheet.createRow(3).createCell(0).setCellValue("FBA仓库"); + var row = sheet.createRow(5); + row.createCell(0).setCellValue(destination); + row.createCell(1).setCellValue(Double.parseDouble(east)); + row.createCell(2).setCellValue(Double.parseDouble(yiwuTax)); + row.createCell(3).setCellValue(Double.parseDouble(dongguanTax)); + try (OutputStream output = Files.newOutputStream(file)) { workbook.write(output); } + } + return file; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java new file mode 100644 index 000000000..18f379ad1 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -0,0 +1,123 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("货柜数据库映射契约") +class ContainerBookingMapperContractTest { + @Test + @DisplayName("货柜映射平台级最终表且状态只从装柜计划读取") + void mapsFinalBookingContract() throws Exception { + assertThat(ContainerBookingEntity.class.getAnnotation(TableName.class).value()) + .isEqualTo("fl_container_booking"); + String select = String.join("\n", ContainerBookingMapper.class + .getDeclaredMethod("selectBooking", long.class).getAnnotation(Select.class).value()); + assertThat(select) + .contains("fl_loading_plan") + .contains("p.id AS plan_id") + .contains("p.plan_no AS plan_no") + .contains("completed_loading_at") + .contains("p.confirmed_at") + .contains("fl_container_exception_history") + .contains("destination_clearance_document_status") + .contains("delivery_order_document_status") + .contains("CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'") + .contains("plan_status") + .contains("p.plan_status != 'COMPLETED'") + .doesNotContain("<>") + .doesNotContain("tenant_id") + .doesNotContain("account_id"); + } + + @Test + @DisplayName("货柜分页按装柜明细客户归属精确筛选") + void filtersPageByStableCustomerId() throws Exception { + String page = String.join("\n", ContainerBookingMapper.class + .getDeclaredMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, + com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class) + .getAnnotation(Select.class).value()); + assertThat(page) + .contains("fl_ocean_booking_container_allocation customer_allocation") + .contains("customer_allocation.container_booking_id = b.id") + .contains("customer_booking.customer_id = #{query.customerId}") + .contains("customer_item.loading_plan_id = p.id") + .contains("customer_item.deleted = 0") + .contains("customer_item.account_id = #{query.customerId}"); + } + + @Test + @DisplayName("关联订舱 SQL 的预计到港参数与 Mapper 参数名一致") + void linkReleasedBookingUsesDeclaredEstimatedArrivalParameter() { + var method = Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals("linkReleasedBooking")) + .findFirst().orElseThrow(); + String sql = String.join("\n", method.getAnnotation(Update.class).value()); + assertThat(sql).doesNotContain("channel_code=#{channelCode}", "channel_name=#{channelName}"); + var parameterNames = Arrays.stream(method.getParameters()) + .map(parameter -> parameter.getAnnotation(Param.class).value()) + .toList(); + + assertThat(parameterNames).contains("estimatedPortArrivalAt"); + assertThat(sql) + .contains("estimated_port_arrival_at=#{estimatedPortArrivalAt}") + .doesNotContain("#{estimatedArrivalAt}"); + } + + @Test + @DisplayName("真实柜号只允许订舱关联或提柜后原子补录") + void onlyBookingLinkOrPickupAssignmentWritesContainerNumber() throws Exception { + String draftSql = String.join("\n", ContainerBookingMapper.class + .getDeclaredMethod("updateDraft", ContainerBookingEntity.class, int.class, + long.class, String.class).getAnnotation(Update.class).value()); + String linkSql = String.join("\n", Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()) + .filter(method -> method.getName().equals("linkReleasedBooking")) + .findFirst().orElseThrow().getAnnotation(Update.class).value()); + String pickupSql = String.join("\n", ContainerBookingMapper.class + .getDeclaredMethod("assignRealContainerNo", long.class, String.class, int.class, + long.class, String.class).getAnnotation(Update.class).value()); + assertThat(draftSql) + .contains("capacity_cbm") + .contains("p.plan_status!='EDITING'") + .contains("version=#{expectedVersion}") + .doesNotContain("container_no=", "cabinet_no=") + .doesNotContain("<") + .doesNotContain(">"); + assertThat(linkSql).contains("container_no=#{containerNo}", "source_type='SIMULATION'", + "ocean_booking_id IS NULL"); + assertThat(pickupSql) + .contains("container_no=#{containerNo}", "container_no IS NULL", "ocean_booking_id IS NOT NULL", + "ocean_booking_requirement_id IS NOT NULL", "version=#{version}", "fl_loading_pick_task", + "fl_loading_cabinet_evidence", "fl_container_document") + .doesNotContain("ocean_booking_id=#{"); + assertThat(Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()).map(java.lang.reflect.Method::getName)) + .doesNotContain("updateTransport", "unlinkReleasedBooking"); + } + + @Test + @DisplayName("初始化与统一增量锁定真实柜号权威、唯一和ISO结构约束") + void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception { + for (String source : new String[] { + Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql")), + Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")) + }) { + assertThat(source) + .contains("国内唯一权威真实集装箱号") + .contains("提柜后可原子补录") + .contains("UNIQUE KEY uk_container_booking_container_no (container_no)") + .contains("CONSTRAINT chk_container_booking_iso6346") + .contains("container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'"); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepositoryTest.java new file mode 100644 index 000000000..7d7626628 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepositoryTest.java @@ -0,0 +1,27 @@ +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 ContainerFeeDetailRepositoryTest { + @Test + void adjustmentUpdateOnlyTouchesAmountDateVersionAndReviewProjection() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java"), + StandardCharsets.UTF_8); + String method = source.substring(source.indexOf("public int adjust("), + source.indexOf("public int voidApproved(")); + + assertThat(method).contains("getBusinessDate", "getAmount", "getSourceVersion", + "getReviewStatus", "getReviewerId", "getReviewerName", "getReviewedAt", + "getReviewReason", "getUpdatedBy", "getUpdatedAt") + .doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot", + "getDirection", "getSupplierId", "getSupplierName", "getSupplierCategory", "getCurrency", + "getRemark"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java new file mode 100644 index 000000000..e00485be6 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java @@ -0,0 +1,64 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; +import org.apache.ibatis.annotations.Select; +import com.baiyue.firstleg.mapper.ContainerOverviewMapper; +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 ContainerOverviewSqlContractTest { + @Test void overviewIncludesExactlyTheTwoCurrentBusinessDocumentTypes() throws Exception { + String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER')") + .doesNotContain("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()); + assertThat(sql).contains("loading_plan_id AS loadingPlanId", + "container_booking_id AS containerBookingId", "task_status AS taskStatus", "version") + .doesNotContain("SELECT *"); + } + + @Test void exceptionQueriesSelectEveryExceptionRowConstructorField() throws Exception { + for (String method : java.util.List.of("exceptions", "latestException")) { + String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod(method, long.class) + .getAnnotation(Select.class).value()); + assertThat(sql).contains("SELECT id,action,reason", "operator_id operatorId", + "operator_name operatorName", "occurred_at occurredAt", "request_hash requestHash") + .doesNotContain("SELECT *"); + } + } + + @Test void transportTimelineReadsTheSingleLoadingPlanCorrectionAudit() throws Exception { + String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("transport", long.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("fl_loading_plan_history", "action_type='UPDATE_PRELOAD_TRANSPORT'") + .contains("before_snapshot", "after_snapshot", "operation_reason", "history.created_at") + .doesNotContain("history.occurred_at") + .doesNotContain("fl_container_transport_history"); + } + + @Test void developmentAndUnifiedSchemasContainImmutableIdempotentExceptionHistory() throws Exception { + String development = new ClassPathResource("db/phase3-smart-loading-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String migration = new ClassPathResource("db/phase3-container-overview.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + for (String sql : java.util.List.of(development, migration, unified)) { + assertThat(sql).contains("fl_container_exception_history", "operation_id", "request_hash", + "uk_container_exception_operation", "trg_container_exception_history_before_update", + "trg_container_exception_history_before_delete"); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerRepositoryLoadingTimeTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerRepositoryLoadingTimeTest.java new file mode 100644 index 000000000..1090a9a42 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerRepositoryLoadingTimeTest.java @@ -0,0 +1,47 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +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.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ContainerRepositoryLoadingTimeTest { + @Mock private ContainerBookingMapper mapper; + @InjectMocks private ContainerRepository repository; + + @Test + void projectsPlanConfirmationTimeWhenCompletedBookingHasNoSchedule() { + LocalDateTime completedAt = LocalDateTime.of(2026, 8, 27, 10, 15); + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setPlanStatus("COMPLETED"); + booking.setCompletedLoadingAt(completedAt); + when(mapper.selectBooking(7L)).thenReturn(booking); + + ContainerBookingEntity result = repository.find(7L); + + assertThat(result.getLoadingAt()).isEqualTo(completedAt); + } + + @Test + void preservesExistingScheduleForUncompletedBooking() { + LocalDateTime plannedAt = LocalDateTime.of(2026, 8, 28, 9, 0); + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setPlanStatus("LOADING"); + booking.setLoadingAt(plannedAt); + booking.setCompletedLoadingAt(LocalDateTime.of(2026, 8, 27, 10, 15)); + when(mapper.selectBooking(8L)).thenReturn(booking); + + ContainerBookingEntity result = repository.find(8L); + + assertThat(result.getLoadingAt()).isEqualTo(plannedAt); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerWorkbenchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerWorkbenchSqlContractTest.java new file mode 100644 index 000000000..261197b42 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerWorkbenchSqlContractTest.java @@ -0,0 +1,33 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +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 ContainerWorkbenchSqlContractTest { + @Test + void pagesDomesticAndOverseasScopesFromOwnedActualFactsWithoutCopyingShipNodeSql() throws Exception { + String sql = String.join("\n", ContainerBookingMapper.class + .getMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, + com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class) + .getAnnotation(Select.class).value()); + + assertThat(sql).contains("#{workbench}='DOMESTIC'", "#{workbench}='OVERSEAS'", + "#{query.scope}='ACTIVE'", "#{query.scope}='HISTORY'", "fl_outbound_dispatch", + "confirmed_at IS NOT NULL", "b.actual_empty_reported_at IS NULL", + "b.actual_empty_reported_at IS NOT NULL", "outbound_dispatch_status") + .doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact"); + } + + @Test + void baselineDocumentsTheSingleSeventyTwoHourEmptyReturnRule() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql")); + assertThat(sql).contains("预计还空时间(卸柜完成时间加72小时,UTC)") + .doesNotContain("卸柜照片上传成功时间加两天"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java new file mode 100644 index 000000000..aec810024 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java @@ -0,0 +1,174 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.domain.CustomerDimensionBoxType; +import com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +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.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerDimensionConfigSqlContractTest { + @Test + void allFourDatabaseEntrypointsDefineTheSameFourRowStructure() throws Exception { + List sqlEntrypoints = List.of( + resource("db/customer-dimension-config.sql"), + resource("db/firstleg.sql"), + resource("db/phase2-inbound-schema.sql"), + Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8) + ); + + for (String sql : sqlEntrypoints) { + assertThat(sql) + .contains("fl_customer_dimension_config") + .contains("box_type VARCHAR(24) NOT NULL") + .contains("inner_length_cm DECIMAL(9,3) NOT NULL") + .contains("inner_width_cm DECIMAL(9,3) NOT NULL") + .contains("inner_height_cm DECIMAL(9,3) NOT NULL") + .contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000") + .contains("default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ACTUAL'") + .contains("initialization_key VARCHAR(128) NOT NULL") + .contains("created_by BIGINT UNSIGNED NOT NULL") + .contains("updated_by BIGINT UNSIGNED NOT NULL") + .contains("version INT NOT NULL DEFAULT 0") + .contains("UNIQUE KEY uk_customer_dimension_config_tenant_box (tenant_id, box_type)") + .contains("CHECK (box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE'))") + .doesNotContain("UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id)"); + } + } + + @Test + void fixedDefaultsAndBatchInitializationCoverExactlyFourBoxTypes() throws Exception { + assertThat(CustomerDimensionBoxType.values()).extracting(CustomerDimensionBoxType::name) + .containsExactly("SMALL", "MEDIUM", "LARGE", "EXTRA_LARGE"); + assertThat(List.of(CustomerDimensionBoxType.values())) + .extracting(CustomerDimensionBoxType::defaultLengthCm) + .containsExactly(new java.math.BigDecimal("30.000"), new java.math.BigDecimal("40.000"), + new java.math.BigDecimal("50.000"), new java.math.BigDecimal("60.000")); + + Insert annotation = CustomerDimensionConfigMapper.class + .getDeclaredMethod("insertDefaults", List.class, long.class) + .getAnnotation(Insert.class); + String sql = String.join(" ", annotation.value()); + assertThat(sql) + .contains("INSERT IGNORE INTO fl_customer_dimension_config") + .contains("box_type, inner_length_cm, inner_width_cm, inner_height_cm") + .contains(" 11") + .contains("AND index_name = 'uk_customer_dimension_config_tenant'") + .contains("DROP TABLE baiyue_firstleg.fl_customer_dimension_config") + .contains("客户尺寸配置表不是可识别的旧单行结构") + .contains("SELECT id AS tenant_id, created_by") + .contains("FROM baiyue_iam.iam_tenant") + .contains("WHERE tenant_type = 'MERCHANT' AND deleted = 0 AND id > 0") + .contains("CONCAT('unified-four-box-default-v1:', tenant.tenant_id)") + .contains("ON DUPLICATE KEY UPDATE id = id") + .contains("现有企业客户未完整持有固定四箱规尺寸配置"); + + String repeatSafeClause = sql.substring(backfill, + sql.indexOf("-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql", backfill)); + assertThat(repeatSafeClause) + .doesNotContain("edge_addition_cm = VALUES") + .doesNotContain("inner_length_cm = VALUES") + .doesNotContain("remark = VALUES") + .doesNotContain("version = VALUES"); + } + + private static String resource(String path) throws Exception { + return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java new file mode 100644 index 000000000..6b75c8ca3 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java @@ -0,0 +1,112 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; +import org.apache.ibatis.annotations.Select; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; +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 DropshipPalletizationSqlContractTest { + @Test + void inboundPalletReadAndPrintUseTheRegisteredPlatformInboundPermission() throws Exception { + var controller = com.baiyue.firstleg.controller.PlatformDropshipPalletizationController.class; + for (var method : controller.getDeclaredMethods()) { + if (method.getName().equals("inboundPallets") || method.getName().equals("printPallet") + || method.getName().equals("printInboundPallets")) { + assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:firstleg:inbound:list')"); + } + } + String api = new ClassPathResource("openapi/dropship-palletization-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(api).contains("x-required-authority: platform:firstleg:inbound:list") + .contains("/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/print:") + .doesNotContain("x-required-authority: firstleg:inbound:list"); + } + + @Test + void pendingPdaTasksAreScopedByTheSelectedWarehouse() throws Exception { + String sql = DropshipPalletizationMapper.class + .getDeclaredMethod("pending", long.class, long.class, String.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("t.warehouse_id=#{warehouseId}") + .contains("t.assignee_id IS NULL OR t.assignee_id=#{operatorId}"); + } + + @Test + void moduleAndUnifiedSchemasExposeCountingPalletAndWholePalletAssignmentFacts() throws Exception { + String inbound = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String loading = new ClassPathResource("db/phase3-smart-loading-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + + for (String sql : new String[]{inbound, unified}) { + assertThat(sql) + .contains("counted_cartons INT UNSIGNED NOT NULL DEFAULT 0") + .contains("discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0") + .contains("pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托'") + .contains("expected_sku_quantity INT UNSIGNED NOT NULL") + .contains("actual_sku_quantity INT UNSIGNED NOT NULL") + .contains("count_operation_id VARCHAR(128) NOT NULL") + .contains("bind_operation_id VARCHAR(128) NULL"); + } + for (String sql : new String[]{loading, unified}) { + assertThat(sql) + .contains("unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE'") + .contains("pallet_id BIGINT UNSIGNED NULL") + .contains("indivisible TINYINT(1) NOT NULL DEFAULT 0") + .contains("active_assignment_key VARCHAR(96) GENERATED ALWAYS AS") + .contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key)") + .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet (") + .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton ("); + } + assertThat(unified) + .contains("CREATE PROCEDURE converge_loading_assignment_schema()") + .contains("CREATE PROCEDURE converge_dropship_palletization_schema()") + .contains("ALTER TABLE fl_dropship_pallet DROP CHECK chk_dropship_pallet_status") + .contains("ALTER TABLE fl_dropship_pallet DROP CHECK chk_dropship_pallet_status;\n END IF;\n UPDATE fl_dropship_pallet") + .contains("CHECK (pallet_status IN ('OPEN','SEALED'))") + .contains("CHANGE COLUMN operation_id count_operation_id") + .contains("DROP INDEX uk_loading_item_plan_detail") + .contains("ADD UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key)"); + } + + @Test + void sharedPalletsAreVisibleAndOnlyParticipatingOpenPalletsBlockCompletion() throws Exception { + String pallets = DropshipPalletizationMapper.class.getDeclaredMethod("pallets", long.class) + .getAnnotation(Select.class).value()[0]; + String open = DropshipPalletizationMapper.class.getDeclaredMethod("openPalletCount", long.class) + .getAnnotation(Select.class).value()[0]; + String complete = DropshipPalletizationMapper.class.getDeclaredMethod("completeTask", long.class) + .getAnnotation(org.apache.ibatis.annotations.Update.class).value()[0]; + + assertThat(pallets).contains("c.pallet_id=p.id AND c.task_id=#{taskId}"); + assertThat(open).contains("fl_dropship_pallet_carton c").contains("c.task_id=#{taskId}"); + assertThat(complete).contains("fl_dropship_pallet_carton c JOIN fl_dropship_pallet p") + .doesNotContain("p.task_id=#{taskId}"); + } + + @Test + void unsealIsAProtectedIdempotentPdaCommandAndBlocksActiveLoadingAssignments() throws Exception { + var controller = com.baiyue.firstleg.controller.PlatformDropshipPalletizationController.class; + var method = java.util.Arrays.stream(controller.getDeclaredMethods()) + .filter(item -> item.getName().equals("unseal")).findFirst().orElseThrow(); + assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:firstleg:pda:work')"); + String api = new ClassPathResource("openapi/dropship-palletization-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(api).contains("/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/unseal:") + .contains("$ref: '#/components/schemas/UnsealPalletRequest'"); + String assignment = DropshipPalletizationMapper.class.getDeclaredMethod("activeLoadingAssignmentCount", long.class) + .getAnnotation(Select.class).value()[0]; + assertThat(assignment).contains("fl_loading_plan_item").contains("deleted=0"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java new file mode 100644 index 000000000..2372ee74f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java @@ -0,0 +1,307 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.conditions.AbstractWrapper; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; +import com.baiyue.firstleg.entity.CarrierFbaRateEntity; +import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; +import com.baiyue.firstleg.entity.InboundPricingEntity; +import com.baiyue.firstleg.entity.InboundFeeEntity; +import com.baiyue.firstleg.mapper.CarrierFbaRateMapper; +import com.baiyue.firstleg.mapper.CustomerChargeRuleMapper; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; +import com.baiyue.firstleg.mapper.InboundFeeItemMapper; +import com.baiyue.firstleg.mapper.InboundFeeMapper; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper; +import com.baiyue.firstleg.mapper.InboundPricingMapper; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +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.junit.jupiter.SpringJUnitConfig; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +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.when; + +@SpringJUnitConfig(FbaPricingRepository.class) +class FbaPricingRepositoryRuleListTest { + @MockBean private CustomerChargeRuleMapper rules; + @MockBean private CarrierFbaRateMapper rates; + @MockBean private InboundPricingMapper pricing; + @MockBean private InboundFeeMapper fees; + @MockBean private InboundFeeItemMapper feeItems; + @MockBean private InboundPricingIdempotencyMapper idempotency; + @MockBean private InboundOutboxMapper outbox; + @MockBean private ContainerTierPricingMapper containerTierPricing; + @Resource private FbaPricingRepository repository; + @BeforeAll + static void initializeEntityMetadata() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), + CustomerChargeRuleEntity.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), + CarrierFbaRateEntity.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), + InboundPricingEntity.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), + InboundFeeEntity.class); + } + + @Test + void platformRuleListOmitsTenantConditionWhenTenantIsAbsent() { + assertThat(queryValues(null, null)).isEmpty(); + } + + @Test + void platformRuleListKeepsTenantAndRuleCodeFiltersWhenProvided() { + assertThat(queryValues(10001L, "CR-1")).containsExactlyInAnyOrder(10001L, "CR-1"); + } + + @Test + void platformRateListAlwaysUsesPublicScope() { + assertThat(rateQueryValues(null)).containsExactly(CarrierFbaRateEntity.PLATFORM_SCOPE); + } + + @Test + void platformRateListKeepsPublicScopeAndRateCodeFiltersWhenProvided() { + assertThat(rateQueryValues("RATE-1")) + .containsExactlyInAnyOrder(CarrierFbaRateEntity.PLATFORM_SCOPE, "RATE-1"); + } + + @Test + void platformRateCreateQueryLocksPublicScope() { + when(rates.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); + + repository.listRatesForUpdate(null); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(rates).selectList(wrapper.capture()); + assertThat(wrapper.getValue().getCustomSqlSegment()).contains("FOR UPDATE"); + assertThat(wrapper.getValue().getParamNameValuePairs().values()) + .containsExactly(CarrierFbaRateEntity.PLATFORM_SCOPE); + } + + @Test + void carrierRateMatchingAlwaysUsesPlatformScope() { + InboundPricingEntity inbound = new InboundPricingEntity(); + inbound.setTenantId(10001L); + inbound.setWarehouseId(21L); + inbound.setBusinessType("FBA_SEA"); + inbound.setChannelCode("SEA-TAX"); + inbound.setCarrierId(31L); + inbound.setDestinationCode("ONT8"); + when(rates.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); + + repository.matchRates(inbound, "KG", java.time.LocalDateTime.of(2026, 8, 1, 0, 0)); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(rates).selectList(wrapper.capture()); + wrapper.getValue().getSqlSegment(); + assertThat(wrapper.getValue().getParamNameValuePairs().values()) + .contains(CarrierFbaRateEntity.PLATFORM_SCOPE) + .doesNotContain(10001L); + } + + @Test + void internalVersionCalculationKeepsTenantAndRuleCodeFilters() { + CustomerChargeRuleEntity current = new CustomerChargeRuleEntity(); + current.setRuleVersion(3); + when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(current)); + + int result = repository.nextRuleVersion(10001L, "CR-1"); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(rules).selectList(wrapper.capture()); + wrapper.getValue().getSqlSegment(); + assertThat(wrapper.getValue().getParamNameValuePairs().values()) + .containsExactlyInAnyOrder(10001L, "CR-1"); + assertThat(result).isEqualTo(4); + } + + @Test + void idempotencyCompletionKeepsTenantAndRecordFilters() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + InboundPricingIdempotencyEntity.class); + InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity(); + value.setTenantId(10001L); + value.setId(88L); + + repository.completeIdempotency(value, 501L, java.time.LocalDateTime.now()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); + verify(idempotency).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture()); + assertThat(wrapper.getValue().getCustomSqlSegment()) + .contains("tenant_id") + .containsPattern("(?i)(^|\\W)id\\s*="); + assertThat(params(wrapper.getValue()).values()).contains(10001L, 88L); + } + + @Test + void feeSendFromInboundedOnlyChangesFeeStatus() { + when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any())) + .thenReturn(1); + + assertThat(repository.markInboundFeeSent(10001L, 88L, "WAIT_SPLIT")).isEqualTo(1); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture()); + assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus").doesNotContain("processStatus"); + assertThat(wrapper.getValue().getCustomSqlSegment()).contains("tenantId", "id", "processStatus", "feeStatus"); + assertThat(params(wrapper.getValue()).values()).contains(10001L, 88L, "WAIT_SPLIT", "CONFIRMED", "SENT"); + } + + @Test + void feeSendFromLegacyFlowAdvancesToWaitingPallet() { + when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any())) + .thenReturn(1); + + assertThat(repository.markInboundFeeSent(10001L, 88L, "FEE_CONFIRMING")).isEqualTo(1); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture()); + assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus").contains("processStatus"); + assertThat(wrapper.getValue().getCustomSqlSegment()).contains("tenantId", "id", "processStatus", "feeStatus"); + assertThat(params(wrapper.getValue()).values()) + .contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET"); + } + + @Test + void feeConfirmationAllowsPostLoadingInboundStatuses() { + String source; + try { + source = Files.readString(Path.of( + "src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java"), + StandardCharsets.UTF_8); + } catch (java.io.IOException exception) { + throw new AssertionError(exception); + } + assertThat(source) + .contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")"); + } + + @Test + void customerFeeConfirmationIsRequiredOnlyForLatestUnconfirmedSentFee() { + InboundFeeEntity waiting = new InboundFeeEntity(); + waiting.setId(12L); + waiting.setTenantId(10001L); + waiting.setInboundId(88L); + waiting.setFeeVersion(2); + waiting.setFeeStatus("SENT"); + InboundFeeEntity confirmed = new InboundFeeEntity(); + confirmed.setId(22L); + confirmed.setTenantId(10001L); + confirmed.setInboundId(99L); + confirmed.setFeeVersion(1); + confirmed.setFeeStatus("SENT"); + confirmed.setCustomerConfirmedAt(java.time.LocalDateTime.now()); + when(fees.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(waiting, confirmed)); + + assertThat(repository.customerFeeConfirmationRequiredMap( + 10001L, Map.of(88L, 10001L, 99L, 10001L))) + .containsEntry(88L, true) + .containsEntry(99L, false); + } + + @Test + void dropshipInitialPricingPreservesWaitingForPalletization() { + when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any())) + .thenReturn(1); + + assertThat(repository.markInboundCalculated( + 10001L, 88L, "WAIT_PALLETIZE", "CBM", new java.math.BigDecimal("10.000"), + new java.math.BigDecimal("1.0000"), new java.math.BigDecimal("1.0000"))) + .isEqualTo(1); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture()); + assertThat(wrapper.getValue().getSqlSet()).contains( + "feeStatus", "chargeableWeightKg", "chargeableVolumeCbm") + .doesNotContain("processStatus"); + assertThat(wrapper.getValue().getCustomSqlSegment()) + .contains("tenantId", "id", "processStatus", "feeStatus"); + assertThat(params(wrapper.getValue()).values()).contains(10001L, 88L, "WAIT_PALLETIZE"); + } + + @Test + void kilogramPricingStoresChargeableWeightAndSelectedVolumeTogether() { + when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any())) + .thenReturn(1); + + assertThat(repository.markInboundCalculated( + 10001L, 88L, "WAIT_SPLIT", "KG", new java.math.BigDecimal("297.000"), + new java.math.BigDecimal("9.0000"), new java.math.BigDecimal("1503.000"))) + .isEqualTo(1); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture()); + assertThat(wrapper.getValue().getSqlSet()) + .contains("chargeableWeightKg", "chargeableVolumeCbm"); + assertThat(params(wrapper.getValue()).values()).contains( + new java.math.BigDecimal("1503.000"), new java.math.BigDecimal("9.0000")); + } + + private Collection queryValues(Long tenantId, String ruleCode) { + when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); + + repository.listRules(tenantId, ruleCode); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(rules).selectList(wrapper.capture()); + wrapper.getValue().getSqlSegment(); + return wrapper.getValue().getParamNameValuePairs().values(); + } + + private Collection rateQueryValues(String rateCode) { + when(rates.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); + + repository.listRates(rateCode); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(rates).selectList(wrapper.capture()); + wrapper.getValue().getSqlSegment(); + return wrapper.getValue().getParamNameValuePairs().values(); + } + + @SuppressWarnings("rawtypes") + private static java.util.Map params(Wrapper wrapper) { + return ((AbstractWrapper) wrapper).getParamNameValuePairs(); + } + +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java new file mode 100644 index 000000000..144a900f1 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java @@ -0,0 +1,57 @@ +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 FeeItemMigrationSqlContractTest { + @Test + void temporarySeedTableIsNotReopenedWithinOneMysqlStatement() throws Exception { + assertMysqlCompatible(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertMysqlCompatible(resolve( + "firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql")); + } + + private static void assertMysqlCompatible(Path path) throws Exception { + String sql = Files.readString(path, StandardCharsets.UTF_8); + assertThat(sql) + .contains("LEFT JOIN tmp_fee_item_seed matched_seed") + .contains("matched_seed.fee_code = NULLIF(TRIM(f.fee_code), '')") + .contains("matched_seed.trade_scope = f.trade_scope") + .contains("193000000001000111, 0, 'FBA_HEAD_FREIGHT'") + .contains("fee_code = 'FBA_HEAD_FREIGHT'") + .contains("193000000001000112, 0, 'PRIVATE_LAST_MILE_DELIVERY'") + .contains("fee_code = 'PRIVATE_LAST_MILE_DELIVERY'") + .contains("warehouse_receivable = 1") + .doesNotContain("LEFT JOIN tmp_fee_item_seed by_code") + .doesNotContain("LEFT JOIN tmp_fee_item_seed by_semantic") + .doesNotContain("OR (SELECT COUNT(*) FROM tmp_fee_item_seed") + .doesNotContain("OR EXISTS (SELECT trade_scope, fee_type_name FROM tmp_fee_item_seed"); + } + + @Test + void inboundFeeSourceIndexUsesTheFinalVersionedDefinition() throws Exception { + assertFinalSourceIndex(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertFinalSourceIndex(resolve( + "firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql")); + } + + private static void assertFinalSourceIndex(Path path) throws Exception { + String sql = Files.readString(path, StandardCharsets.UTF_8); + assertThat(sql) + .contains("UNIQUE KEY `uk_inbound_fee_source` " + + "(`tenant_id`, `source_type`, `source_fee_id`, `source_fee_version`)") + .doesNotContain("inbound_fee_source_index_ddl") + .doesNotContain("DROP INDEX uk_inbound_fee_source"); + } + + private static Path resolve(String value) { + Path direct = Path.of(value); + if (Files.exists(direct)) return direct; + return Path.of("..", value).normalize(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java new file mode 100644 index 000000000..a1a95f3cd --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java @@ -0,0 +1,56 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceFeeSourceMigrationContractTest { + @Test + void freshSchemaAndMigrationExposeInboundFeeSupplierSnapshotsUsedByAllFeeFlows() throws Exception { + String baseline = new ClassPathResource("db/phase2-fee-item-and-inbound-detail.sql") + .getContentAsString(StandardCharsets.UTF_8); + String migration = new ClassPathResource("db/finance-fee-source-migration.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(baseline).contains( + "supplier_id BIGINT UNSIGNED NULL", + "supplier_name VARCHAR(200) NULL", + "supplier_category VARCHAR(16) NULL", + "settlement_mode VARCHAR(16) NULL", + "payment_voucher_file_ids JSON NULL"); + assertThat(migration).contains( + "firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'", + "firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name'", + "firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category'", + "firstleg_finance_add_column('fl_inbound_fee_detail', 'settlement_mode'", + "firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_voucher_file_ids'", + "Firstleg Finance迁移失败:入库费用供应商快照字段不完整"); + } + + @Test + void migrationDefinesIdempotentOutboxEnvelopeAndContainerFeeModel() throws Exception { + String sql = new ClassPathResource("db/finance-fee-source-migration.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql).contains("information_schema.columns", "event_key", "payload_hash", + "uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by", + "idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail", + "source_version", "supplier_id", "supplier_name", "supplier_category", + "settlement_mode", "paid_at", "payment_method", "payment_reference_no", + "payment_voucher_file_ids", + "CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot", + "uk_container_fee_source_version", + "CREATE TABLE IF NOT EXISTS fl_finance_fee_source_command", + "uk_finance_fee_command_idem", + "request_hash CHAR(64) NOT NULL", + "firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id'", "RECEIVABLE", "PAYABLE", + "firstleg_finance_add_column('fl_container_fee_source_snapshot', 'settlement_mode'", + "柜次费用已付版本快照字段不完整", + "firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'", + "Firstleg Finance迁移失败:入库费用供应商快照字段不完整", + "chk_container_fee_currency", "currency = 'CNY'", "firstleg_finance_assert_schema"); + } +} 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/InboundCartonLabelSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java new file mode 100644 index 000000000..9fe3da95f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java @@ -0,0 +1,51 @@ +package com.baiyue.firstleg.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 InboundCartonLabelSqlContractTest { + @Test + void baseSchemaAndExistingDatabaseMigrationKeepStableCartonKeys() throws Exception { + String schema = resource("db/phase2-inbound-schema.sql"); + String migration = resource("db/phase2-inbound-carton-label.sql"); + String versioningMigration = resource("db/phase2-carton-label-versioning-migration.sql"); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + + assertThat(schema).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") + .contains("units_per_carton INT UNSIGNED NULL") + .contains("UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code)") + .contains("UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index)") + .contains("inbound_detail_id BIGINT UNSIGNED NOT NULL"); + assertThat(migration).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") + .contains("units_per_carton INT UNSIGNED NULL") + .contains("固定二维码内容:入仓码#总箱数-箱下标") + .contains("idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id)"); + assertThat(versioningMigration).contains("phase2_carton_label_add_column('label_version'") + .contains("phase2_carton_label_add_column('batch_status'") + .contains("idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version)"); + assertThat(unified).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") + .contains("units_per_carton INT UNSIGNED NULL") + .contains("unified_carton_label_add_column('label_version'") + .contains("unified_carton_label_add_column('batch_status'") + .contains("unified_carton_label_add_column('cargo_fingerprint'") + .contains("unified_carton_label_add_column('voided_at'") + .contains("ALTER TABLE fl_inbound_carton_label DROP INDEX uk_carton_label_index") + .contains("ADD UNIQUE KEY uk_carton_label_index (tenant_id,inbound_id,label_version,carton_index)") + .contains("ADD KEY idx_carton_label_active (tenant_id,inbound_id,batch_status,label_version)") + .contains("uk_carton_label_box_code") + .contains("uk_carton_label_index") + .contains("稳定箱号映射表未创建") + .contains("稳定箱码映射兼容字段未完整创建") + .contains("箱唛有效批次索引未完整创建"); + } + + private String resource(String path) throws Exception { + return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCodeGlobalUniquenessContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCodeGlobalUniquenessContractTest.java new file mode 100644 index 000000000..f5b01c360 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCodeGlobalUniquenessContractTest.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundCodeGlobalUniquenessContractTest { + @Test + void inboundSchemaUsesTheSamePlatformWideInboundCodeIndex() throws Exception { + String sql = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("UNIQUE KEY uk_inbound_code (inbound_code)") + .doesNotContain("UNIQUE KEY uk_inbound_tenant_code (tenant_id, inbound_code)"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundFeeDetailMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundFeeDetailMapperContractTest.java new file mode 100644 index 000000000..16cbac3fc --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundFeeDetailMapperContractTest.java @@ -0,0 +1,26 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.InboundFeeDetailMapper; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundFeeDetailMapperContractTest { + @Test + void logicalDeleteIsScopedToCustomerInboundDetailAndActiveRow() throws Exception { + Method method = InboundFeeDetailMapper.class.getMethod( + "logicallyDelete", long.class, long.class, long.class); + String sql = String.join(" ", method.getAnnotation(Update.class).value()) + .replaceAll("\\s+", " ").toLowerCase(); + + assertThat(sql) + .contains("set deleted = 1") + .contains("tenant_id = #{tenantid}") + .contains("inbound_id = #{inboundid}") + .contains("id = #{detailid}") + .contains("deleted = 0"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java new file mode 100644 index 000000000..99ef11f61 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java @@ -0,0 +1,215 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.firstleg.dto.InboundOrderQuery; +import com.baiyue.firstleg.dto.UnifiedInboundQuery; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; +import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; +import com.baiyue.firstleg.mapper.InboundFileMapper; +import com.baiyue.firstleg.mapper.InboundInternalNoteMapper; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper; +import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper; +import com.baiyue.firstleg.mapper.InboundWeightResultMapper; +import org.junit.jupiter.api.Test; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.mockito.ArgumentCaptor; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(InboundOrderRepository.class) +class InboundOrderRepositoryTest { + @MockBean private InboundOrderMapper orders; + @MockBean private InboundTransportSnapshotMapper transports; + @MockBean private InboundPickupSnapshotMapper pickupSnapshots; + @MockBean private InboundPalletAllocationMapper pallets; + @MockBean private InboundFileMapper files; + @MockBean private InboundInternalNoteMapper notes; + @MockBean private InboundStatusHistoryMapper histories; + @MockBean private InboundWeightResultMapper weights; + @Resource private InboundOrderRepository repository; + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void unifiedQueryPushesExpandedFiltersIntoDatabasePage() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class); + when(orders.selectPage(any(Page.class), any(Wrapper.class))) + .thenReturn(new Page(1, 20)); + UnifiedInboundQuery query = new UnifiedInboundQuery( + 10001L, null, null, "LOADING", 30001L, 40001L, 50001L, + "RK-", "APPROVED", LocalDate.of(2026, 8, 6), "APPROVED", "美森", + "MSCU", "CAB", LocalDate.of(2026, 8, 8), true, true, "UNRECORDED", + List.of(77L, 78L), null, null, 1, 20); + + repository.pageUnifiedEntities(query, List.of("LOADING")); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(orders).selectPage(any(Page.class), captor.capture()); + String sql = captor.getValue().getCustomSqlSegment(); + assertThat(sql).contains("inbound_code", "order_status_snapshot", "expected_arrival_at", + "channel_name", "container_mode", "measure_review_status", "order_id IN", + "fl_inbound_fee", "fl_loading_plan_item", "fl_loading_plan", "fl_container_booking", + "actual_sailing_at", "estimated_sailing_at", "plan_status"); + assertThat(sql).doesNotContain("fl_inbound_transport_snapshot", "loading_status"); + LambdaQueryWrapper wrapper = + (LambdaQueryWrapper) captor.getValue(); + assertThat(wrapper.getParamNameValuePairs().values()) + .contains(10001L, 30001L, 40001L, 50001L, "%RK-%", "APPROVED", "美森", + "MSCU", "CAB", 77L, 78L); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void shouldApplyTenantCompanyAndWarehouseFilters() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class); + when(orders.selectPage(any(Page.class), any(Wrapper.class))) + .thenReturn(new Page(1, 20)); + InboundOrderQuery query = new InboundOrderQuery( + null, null, null, null, null, null, null, null, + null, null, null, null, 20001L, 30001L, null, null, null, 1, 20); + + repository.page(10001L, query); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(orders).selectPage(any(Page.class), captor.capture()); + assertThat(captor.getValue().getCustomSqlSegment()) + .contains("tenant_id") + .contains("company_id") + .contains("warehouse_id") + .contains("ORDER BY created_at DESC,id DESC"); + LambdaQueryWrapper wrapper = + (LambdaQueryWrapper) captor.getValue(); + assertThat(wrapper.getParamNameValuePairs().values()) + .contains(10001L, 20001L, 30001L); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void shouldBatchAggregateLocationCodesInAllocationOrderIncludingA0() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class); + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + InboundPalletAllocationEntity.class); + Page page = new Page<>(1, 20); + InboundOrderEntity first = new InboundOrderEntity(); + first.setId(1L); + first.setTenantId(10001L); + InboundOrderEntity second = new InboundOrderEntity(); + second.setId(2L); + second.setTenantId(10001L); + page.setRecords(java.util.List.of(first, second)); + when(orders.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page); + when(pallets.selectList(any(Wrapper.class))).thenReturn(java.util.List.of( + allocation(1L, 1, "A0"), + allocation(1L, 2, "A2"), + allocation(2L, 1, "B1"))); + + var result = repository.page(10001L, new InboundOrderQuery( + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, 1, 20)); + + assertThat(result.records()).extracting(record -> record.locationCodes()) + .containsExactly("A0、A2", "B1"); + ArgumentCaptor> captor = + ArgumentCaptor.forClass(Wrapper.class); + verify(pallets, times(1)).selectList(captor.capture()); + assertThat(captor.getValue().getCustomSqlSegment()) + .contains("tenant_id") + .contains("inbound_id IN") + .contains("ORDER BY inbound_id ASC,allocation_no ASC"); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void shouldQueryAndAggregateCrossTenantPageByInboundOwnership() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class); + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + InboundTransportSnapshotEntity.class); + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + InboundPalletAllocationEntity.class); + Page page = new Page<>(1, 20); + page.setRecords(java.util.List.of(order(1L, 10001L), order(2L, 10002L))); + when(orders.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page); + when(transports.selectList(any(Wrapper.class))).thenReturn(java.util.List.of( + transport(1L, 10001L, "T1"), + transport(2L, 10002L, "T2"), + transport(1L, 10002L, "WRONG"))); + when(pallets.selectList(any(Wrapper.class))).thenReturn(java.util.List.of( + allocation(1L, 10001L, 1, "A1"), + allocation(2L, 10002L, 1, "B1"), + allocation(1L, 10002L, 2, "WRONG"))); + + var result = repository.page(null, new InboundOrderQuery( + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, 1, 20)); + + assertThat(result.records()).extracting(record -> record.tenantId()) + .containsExactly(10001L, 10002L); + assertThat(result.records()).extracting(record -> record.locationCodes()) + .containsExactly("A1", "B1"); + ArgumentCaptor> orderCaptor = ArgumentCaptor.forClass(Wrapper.class); + verify(orders).selectPage(any(Page.class), orderCaptor.capture()); + assertThat(orderCaptor.getValue().getCustomSqlSegment()).doesNotContain("tenant_id"); + + ArgumentCaptor> transportCaptor = + ArgumentCaptor.forClass(Wrapper.class); + verify(transports).selectList(transportCaptor.capture()); + assertThat(transportCaptor.getValue().getCustomSqlSegment()) + .doesNotContain("tenant_id") + .contains("inbound_id IN"); + } + + private static InboundPalletAllocationEntity allocation( + long inboundId, int allocationNo, String locationCode) { + return allocation(inboundId, 10001L, allocationNo, locationCode); + } + + private static InboundPalletAllocationEntity allocation( + long inboundId, long tenantId, int allocationNo, String locationCode) { + InboundPalletAllocationEntity entity = new InboundPalletAllocationEntity(); + entity.setInboundId(inboundId); + entity.setTenantId(tenantId); + entity.setAllocationNo(allocationNo); + entity.setLocationCode(locationCode); + return entity; + } + + private static InboundOrderEntity order(long id, long tenantId) { + InboundOrderEntity entity = new InboundOrderEntity(); + entity.setId(id); + entity.setTenantId(tenantId); + return entity; + } + + private static InboundTransportSnapshotEntity transport( + long inboundId, long tenantId, String loadingStatus) { + InboundTransportSnapshotEntity entity = new InboundTransportSnapshotEntity(); + entity.setInboundId(inboundId); + entity.setTenantId(tenantId); + entity.setLoadingStatus(loadingStatus); + return entity; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java new file mode 100644 index 000000000..d2634bc51 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java @@ -0,0 +1,45 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.InboundOrderMapper; +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 InboundOrderStatusSqlContractTest { + @Test + void opensTheFulfillmentSpecificNextStepImmediatelyAfterMeasurementApproval() { + assertThat(sql("approveMeasurement")) + .contains("process_status = CASE WHEN fulfillment_type='ONE_CLICK_DROPSHIP'") + .contains("THEN 'WAIT_PALLETIZE' ELSE 'WAIT_SPLIT' END") + .contains("inbounded_at = CASE WHEN fulfillment_type='ONE_CLICK_DROPSHIP'") + .contains("THEN NULL ELSE CURRENT_TIMESTAMP(3) END") + .doesNotContain("process_status = 'WAIT_PRICING'"); + assertThat(sql("completeInbound")) + .contains("process_status = 'WAIT_SPLIT'") + .contains("inbounded_at = CURRENT_TIMESTAMP(3)") + .contains("process_status = 'INBOUNDING'"); + } + + @Test + void keepsConfirmedGrossAndNetSumsSeparateWhenMeasurementIsApproved() { + assertThat(sql("approveMeasurement")) + .contains("FROM fl_inbound_weight_result weight") + .contains("weight.recognition_status = 'CONFIRMED'") + .contains("weight.deleted = 0") + .contains("actual_gross_weight_kg = (SELECT SUM(weight.gross_weight_kg)") + .contains("actual_net_weight_kg = (SELECT SUM(weight.net_weight_kg)") + .doesNotContain("item.total_weight_kg"); + } + + private static String sql(String methodName) { + Method method = Arrays.stream(InboundOrderMapper.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst() + .orElseThrow(); + return String.join(" ", method.getAnnotation(Update.class).value()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java new file mode 100644 index 000000000..b9ac4d490 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java @@ -0,0 +1,104 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.client.WarehousePalletLocationClient; +import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; +import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper; +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.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundPalletAllocationRepositoryTest { + @Mock private InboundPalletAllocationMapper mapper; + @InjectMocks private InboundPalletAllocationRepository repository; + + @Test + void savesEveryBlockSlotIncludingReservedEmptySlots() { + var result = new WarehousePalletLocationClient.BlockResult(700L, "OCCUPIED", List.of( + resource(700L, 91L, 11L, "A01", 1, false), + resource(700L, 92L, 12L, "A02", 2, false), + resource(700L, 93L, 99L, "A0", 3, true))); + + repository.replaceBlock(10001L, 31L, 88L, 20, new BigDecimal("2.5000"), result); + + ArgumentCaptor inserted = + ArgumentCaptor.forClass(InboundPalletAllocationEntity.class); + verify(mapper, times(3)).insert(inserted.capture()); + assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getSlotNo) + .containsExactly(1, 2, 3); + assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getWeightResultId) + .containsOnlyNulls(); + assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getSlotStatus) + .containsOnly("RESERVED_EMPTY"); + assertThat(inserted.getAllValues().get(2).getSharedOverflow()).isTrue(); + } + + @Test + void bindsConfirmedWeightsByConfirmationTimeToTheNextEmptySlot() { + InboundPalletAllocationEntity firstEmpty = allocation(1L, 1); + InboundPalletAllocationEntity secondEmpty = allocation(2L, 2); + when(mapper.selectNextEmptyForUpdate(10001L, 31L)).thenReturn(firstEmpty, secondEmpty); + when(mapper.bindWeight(any(Long.class), any(Long.class), any(Long.class), any(Long.class), + any(Integer.class), any(Long.class), any(String.class), any(BigDecimal.class))).thenReturn(1); + InboundWeightResultEntity later = weight(102L, LocalDateTime.of(2026, 9, 4, 10, 2)); + InboundWeightResultEntity earlier = weight(101L, LocalDateTime.of(2026, 9, 4, 10, 1)); + + repository.bindConfirmedWeights(10001L, 31L, List.of(later, earlier)); + + org.mockito.InOrder order = org.mockito.Mockito.inOrder(mapper); + order.verify(mapper).bindWeight(1L, 10001L, 31L, 101L, 1, 501L, "P-501", + new BigDecimal("1.2000")); + order.verify(mapper).bindWeight(2L, 10001L, 31L, 102L, 2, 501L, "P-501", + new BigDecimal("1.2000")); + } + + @Test + void unbindRestoresOnlyTheBoundSlotToReservedEmpty() { + InboundPalletAllocationEntity bound = allocation(1L, 1); + bound.setWeightResultId(101L); + when(mapper.selectOne(any())).thenReturn(bound); + when(mapper.unbindWeight(1L, 10001L, 31L, 101L)).thenReturn(1); + + repository.unbindWeight(10001L, 31L, 101L); + + verify(mapper).unbindWeight(1L, 10001L, 31L, 101L); + } + + private static WarehousePalletLocationClient.BlockResource resource( + long blockId, long reservationId, long locationId, String code, int slotNo, boolean overflow) { + return new WarehousePalletLocationClient.BlockResource( + blockId, reservationId, locationId, code, slotNo, overflow, "OCCUPIED"); + } + + private static InboundPalletAllocationEntity allocation(long id, int slotNo) { + InboundPalletAllocationEntity value = new InboundPalletAllocationEntity(); + value.setId(id); + value.setSlotNo(slotNo); + return value; + } + + private static InboundWeightResultEntity weight(long id, LocalDateTime confirmedAt) { + InboundWeightResultEntity value = new InboundWeightResultEntity(); + value.setId(id); + value.setRecognitionStatus("CONFIRMED"); + value.setConfirmedAt(confirmedAt); + value.setPalletId(501L); + value.setPalletCode("P-501"); + value.setPalletCapacityCbm(new BigDecimal("1.2000")); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java new file mode 100644 index 000000000..ceb938c9c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java @@ -0,0 +1,138 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("主子入仓单SQL契约") +class InboundParentChildSqlContractTest { + + private final String sql = loadSql(); + + @Test + void definesParentChildRelation() { + assertThat(sql) + .contains("'fl_inbound_order', 'inbound_role'") + .contains("'fl_inbound_order', 'parent_inbound_id'") + .contains("'fl_inbound_order', 'root_inbound_id'") + .contains("'fl_inbound_order', 'package_count'") + .contains("'fl_inbound_order', 'split_purpose'") + .contains("SET split_purpose = 'REMAINDER'") + .contains("active_remainder_root_id") + .contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'") + .contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'CHILD'") + .contains("DROP INDEX uk_inbound_tenant_order") + .contains("uk_inbound_master_order") + .contains("uk_inbound_parent_split_sequence") + .contains("uk_inbound_one_active_remainder") + .contains("DROP INDEX uk_inbound_parent_sequence"); + } + + @Test + void definesSplitLedger() { + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history") + .contains("'fl_inbound_split_batch', 'loaded_child_inbound_id'") + .contains("'fl_inbound_split_batch', 'source_inbound_id'") + .contains("'fl_inbound_split_batch', 'remainder_child_inbound_id'") + .contains("'fl_inbound_split_batch', 'loaded_package_count'") + .contains("'fl_inbound_split_batch', 'remaining_package_count'") + .contains("'fl_inbound_split_batch', 'loading_plan_id'") + .contains("'fl_inbound_split_batch', 'container_booking_id'") + .contains("fk_inbound_split_loaded_child") + .contains("fk_inbound_split_source") + .contains("fk_inbound_split_remainder_child") + .contains("line_no INT UNSIGNED NOT NULL") + .contains("uk_inbound_split_batch_line") + .contains("uk_inbound_split_batch_source_child") + .contains("`source_measurement_item_id`, `child_inbound_id`") + .contains("package_count INT UNSIGNED NOT NULL") + .doesNotContain("quantity INT", "item_count INT", "carton_count INT"); + } + + @Test + void protectsSingleRemainderWritesAndConservation() { + assertThat(sql) + .contains("子入仓单必须绑定同一客户的有效主单") + .contains("parent_order.version > NEW.expected_parent_version") + .contains("NEW.loaded_package_count") + .contains("NEW.remaining_package_count") + .contains("NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count") + .contains("NEW.loaded_child_inbound_id IS NOT NULL") + .contains("NEW.child_inbound_id = batch.remainder_child_inbound_id") + .contains("source_order.package_count = NEW.loaded_package_count") + .contains("item.child_inbound_id <> NEW.remainder_child_inbound_id") + .contains("<> NEW.remaining_package_count") + .contains("根入仓单原始箱数与已完成分片及当前余量不守恒") + .contains("COALESCE(source_order.package_count, source_order.total_package_count) = NEW.package_count") + .contains("AND plan_item.deleted = 0))))\n OR NOT EXISTS (") + .contains("source_batch.inbound_id = COALESCE(batch.source_inbound_id, batch.parent_inbound_id)") + .contains("拆分批次来源必须是箱数守恒的根主单或余量分片") + .contains("SUM(split_item.package_count) > source_item.package_count") + .contains("子单承接箱数超过来源测量箱规箱数") + .contains("child_order.package_count >= NEW.package_count") + .contains("拆分数量台账不可修改") + .contains("拆分数量台账不可删除") + .contains("拆分历史不可修改") + .contains("拆分历史不可删除"); + } + + @Test + void validatesSingleRemainderAndMultiSkuTotals() { + assertThat(sql) + .contains("uk_inbound_parent_split_sequence") + .contains("uk_inbound_one_active_remainder") + .contains("uk_inbound_split_batch_line") + .contains("uk_inbound_split_batch_source_child") + .contains("COUNT(DISTINCT item.child_inbound_id)") + .contains("<> CASE WHEN NEW.remaining_package_count > 0 THEN 1 ELSE 0 END") + .contains("child_order.package_count <> SUM(item.package_count)"); + } + + @Test + void tracesMeasurements() { + assertThat(sql) + .contains("'fl_inbound_measurement', 'split_batch_id'") + .contains("'fl_inbound_measurement', 'source_measurement_id'") + .contains("'fl_inbound_measurement_item', 'split_item_id'") + .contains("'fl_inbound_measurement_item', 'source_measurement_item_id'") + .contains("source_measurement_id BIGINT UNSIGNED NOT NULL") + .contains("source_measurement_item_id BIGINT UNSIGNED NOT NULL") + .contains("fk_inbound_split_item_measurement") + .contains("fk_measure_split_batch") + .contains("fk_measure_source_batch") + .contains("fk_measure_item_split_item") + .contains("fk_measure_item_source_item") + .contains("idx_measure_split_source") + .contains("idx_measure_item_split_source"); + } + + @Test + void finalAssertionIsTableSpecificAndRejectsLegacyIndexes() { + assertThat(sql) + .contains("table_name = 'fl_inbound_split_batch'") + .contains("uk_inbound_split_source_once") + .contains("uk_inbound_one_active_remainder") + .contains("table_name = 'fl_inbound_split_item'") + .contains("uk_inbound_split_batch_source_child") + .contains("is_nullable = 'NO'") + .contains("'uk_inbound_one_secondary', 'uk_inbound_parent_sequence'") + .contains("'uk_inbound_split_batch_source_item'"); + } + + private static String loadSql() { + try { + return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java new file mode 100644 index 000000000..eea8d70ea --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java @@ -0,0 +1,44 @@ +package com.baiyue.firstleg.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 InboundStatusUnificationSqlContractTest { + @Test + void enforcesCanonicalWaitSplitStatusWithoutCompatibilityRewrite() throws Exception { + String module = normalize(new ClassPathResource("db/phase3-inbounded-status-unification.sql") + .getContentAsString(StandardCharsets.UTF_8)).trim(); + String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8)); + + assertThat(module) + .contains("CREATE PROCEDURE assert_wait_split_status()") + .contains("入库完成后的唯一状态为 WAIT_SPLIT") + .doesNotContain("兼容") + .doesNotContain("UPDATE fl_inbound_order") + .doesNotContain("UPDATE fl_inbound_status_history"); + assertThat(unified) + .contains("入库完成后的唯一状态为 WAIT_SPLIT") + .doesNotContain("历史 WAIT_SPLIT") + .doesNotContain("SET process_status = 'WAIT_SPLIT'") + .doesNotContain("SET from_status = 'WAIT_SPLIT'") + .doesNotContain("SET to_status = 'WAIT_SPLIT'"); + } + + private static Path unifiedPath() { + Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (Files.exists(path)) { + return path; + } + return Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + + private static String normalize(String value) { + return value.replace("\r\n", "\n").replace("\uFEFF", ""); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java new file mode 100644 index 000000000..36909bfcf --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java @@ -0,0 +1,106 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.DisplayName; +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; + +@DisplayName("入库审核SQL契约") +class InboundWorkflowSqlContractTest { + @Test + @DisplayName("扣货快照、不可变历史和幂等记录使用冻结字段与长度") + void shouldPersistInboundHoldSnapshotHistoryAndIdempotency() throws Exception { + String schema = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(schema) + .contains("hold_flag TINYINT(1) NOT NULL DEFAULT 0") + .contains("current_hold_reason VARCHAR(1000) NULL") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_hold_record") + .contains("hold_action VARCHAR(16) NOT NULL") + .contains("reason VARCHAR(1000) NOT NULL") + .contains("evidence_file_id BIGINT UNSIGNED NULL") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_idempotency") + .contains("idempotency_key VARCHAR(128) NOT NULL") + .contains("UNIQUE KEY uk_inbound_idempotency"); + } + + @Test + @DisplayName("基础表和增量迁移包含审核历史、幂等、Outbox和放行索引") + void shouldProvideWorkflowPersistenceBoundaries() throws Exception { + String schema = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String migration = new ClassPathResource("db/phase2-inbound-workflow-migration.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(schema) + .contains("CREATE TABLE IF NOT EXISTS fl_measure_review_record") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_idempotency") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_outbox") + .contains("idx_review_exception_release"); + assertThat(migration) + .contains("information_schema.statistics") + .contains("idx_review_exception_release"); + } + + @Test + @DisplayName("存量托盘分配表会补齐仓储资源租户") + void shouldMigrateWarehouseResourceTenantForExistingAllocationTable() throws Exception { + String migration = new ClassPathResource("db/phase2-fba-pricing-migration.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(migration) + .contains("phase2_fba_add_column('fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id'") + .contains("SET warehouse_resource_tenant_id = 0") + .contains("MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL"); + } + + @Test + @DisplayName("单箱当前测量记录包含箱号、设备元数据和唯一约束") + void shouldPersistCurrentCartonMeasurementFields() throws Exception { + String schema = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String migration = new ClassPathResource("db/phase2-carton-measurement-current-record.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + assertThat(schema) + .contains("box_code VARCHAR(128)") + .contains("machine_no VARCHAR(64)") + .contains("device_measured_at DATETIME(3)") + .contains("uk_measure_box_code"); + assertThat(migration) + .contains("phase2_add_carton_measurement_column") + .contains("uk_measure_box_code") + .contains("information_schema.STATISTICS"); + assertThat(unified) + .contains("CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_code'") + .contains("uk_measure_box_code") + .contains("单箱当前测量字段未完整创建"); + } + + @Test + @DisplayName("称重结果固化托盘快照、毛重和净重并允许托盘主数据复用") + void shouldPersistWeightPalletSnapshotsAndNetWeight() throws Exception { + String schema = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + + assertThat(schema) + .contains("pallet_tare_weight_kg DECIMAL(18,3) NOT NULL") + .contains("gross_weight_kg DECIMAL(18,3) NULL") + .contains("net_weight_kg DECIMAL(18,3) NULL") + .contains("idx_weight_inbound_pallet") + .doesNotContain("final_weight_kg") + .doesNotContain("uk_pallet_allocation_pallet"); + assertThat(unified) + .contains("CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_id'") + .contains("ALTER TABLE fl_inbound_weight_result DROP COLUMN final_weight_kg") + .contains("ALTER TABLE fl_inbound_pallet_allocation DROP INDEX uk_pallet_allocation_pallet") + .contains("称重托盘快照或毛重净重字段未正确收敛") + .contains("同票托盘唯一约束仍阻止托盘主数据复用"); + } +} 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/LoadingCompletionSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionSqlContractTest.java new file mode 100644 index 000000000..8ef0f7aa8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionSqlContractTest.java @@ -0,0 +1,32 @@ +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 LoadingCompletionSqlContractTest { + @Test + void baselineAndUnifiedSqlDefineDurableCompletionStateMachine() throws Exception { + assertContract(Files.readString(Path.of("src", "main", "resources", "db", + "phase3-smart-loading-schema.sql"), StandardCharsets.UTF_8)); + assertContract(Files.readString(Path.of("..", "deploy", "sql", + "头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8)); + } + + private static void assertContract(String sql) { + assertThat(sql) + .contains("fl_loading_completion_command") + .contains("fl_loading_completion_checkpoint") + .contains("uk_loading_completion_operation") + .contains("idx_loading_completion_dispatch") + .contains("PROCESSING","RETRYABLE","FAILED","COMPLETED") + .contains("COMPLETING","COMPLETION_FAILED") + .contains("READY","SETTLED","INBOUND_COMPLETED") + .contains("lease_owner","lease_until","next_retry_at") + .contains("failure_stage","failure_code","failure_request_id","failure_trace_id"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java new file mode 100644 index 000000000..063b025bb --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -0,0 +1,178 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.LoadingListQueryMapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("清单持久化SQL契约") +class LoadingListQueryMapperContractTest { + @Test + void annotationScriptsShouldParseAsMyBatisStatements() { + Configuration configuration = new Configuration(); + configuration.addMapper(LoadingListQueryMapper.class); + + assertThat(configuration.hasStatement( + LoadingListQueryMapper.class.getName() + ".selectPreloadPage")).isTrue(); + assertThat(configuration.hasStatement( + LoadingListQueryMapper.class.getName() + ".advance")).isTrue(); + } + + @Test + void bothListsShouldProjectTheSameLoadingPlanAggregate() throws Exception { + String preload = sql("selectPreloadPage", Select.class); + String confirmed = sql("selectConfirmedPage", Select.class); + + assertThat(preload) + .contains("FROM fl_loading_plan p") + .contains("JOIN fl_container_booking b") + .contains("LEFT JOIN fl_loading_plan_item i") + .contains("FROM fl_outbound_dispatch outbound") + .contains("outbound.container_booking_id = p.container_booking_id") + .contains("AS outbound_confirmed_at") + .doesNotContain("b.outbound_confirmed_at") + .doesNotContain("preload_pushed_at") + .contains("p.plan_status IN ('EDITING', 'LOADING')") + .doesNotContain("active_item.deleted = 0") + .contains("fl_preload_loading_plan_link") + .contains("fl_ocean_booking_container_allocation") + .contains("fl_loading_pick_task") + .contains("pda_task_status") + .contains("customer_item.account_id = #{query.customerId}") + .contains("b.warehouse_id = #{query.warehouseId}") + .doesNotContain("query.accountId") + .doesNotContain("PRELOAD_PUSHED"); + assertThat(confirmed) + .contains("FROM fl_loading_plan p") + .contains("FROM fl_outbound_dispatch outbound") + .doesNotContain("b.outbound_confirmed_at") + .contains("p.plan_status = 'COMPLETED'") + .contains("customer_item.account_id = #{query.customerId}") + .contains("b.warehouse_id = #{query.warehouseId}") + .doesNotContain("query.accountId") + .doesNotContain("fl_loading_list"); + } + + @Test + void detailItemsExposeFrozenOrderAndActiveContainerFacts() throws Exception { + String items = sql("selectItems", Select.class); + assertThat(items) + .contains("i.order_id", "i.order_no", "b.container_no") + .contains("inbound.tax_mode AS customs_tax_mode") + .contains("WHEN 'TAX_REFUND' THEN TRUE") + .contains("WHEN 'BUY_DECLARATION' THEN FALSE") + .contains("ELSE NULL") + .doesNotContain("fl_customs_container_document", "fl_customs_container_document_link") + .contains("b.record_status='ACTIVE'") + .contains("NULLIF(i.pallet_count,0)") + .contains("fl_inbound_weight_result weight") + .contains("weight.recognition_status='CONFIRMED'") + .contains("i.loading_plan_id = #{planId}") + .contains("i.deleted = 0"); + } + + @Test + void lifecycleAggregateKeepsTheSamePlanIdAcrossEveryStatus() throws Exception { + String aggregate = sql("selectAggregate", Select.class); + assertThat(aggregate) + .contains("WHERE p.id = #{planId}") + .contains("p.plan_status") + .doesNotContain("plan_status = 'COMPLETED'", "plan_status IN ('EDITING', 'LOADING')"); + } + + @Test + void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception { + String touch = sql("touchEditable", Update.class); + String transport = sql("updateTransport", Update.class); + String touchInitialFill = sql("touchForInitialContainerFill", Update.class); + String initialFill = sql("fillInitialContainerNo", Update.class); + String confirmPreload = sql("confirmLinkedPreload", Update.class); + String confirmPlan = sql("touchForPreloadConfirmation", Update.class); + String advance = sql("advance", Update.class); + + assertThat(touch) + .contains("plan_status = 'EDITING'") + .doesNotContain("preload_pushed_at") + .contains("fl_loading_pick_assignment", "item.item_status<>'PENDING'") + .contains("version = #{expectedVersion}") + .contains("version = version + 1"); + assertThat(transport) + .contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}") + .contains("p.plan_status='EDITING'") + .doesNotContain("preload_pushed_at") + .doesNotContain("#{containerNo} IS NOT NULL") + .contains("fl_loading_pick_task", "fl_loading_pick_assignment") + .doesNotContain("DELETE") + .doesNotContain("fl_loading_plan_item"); + assertThat(touchInitialFill) + .contains("p.plan_status IN ('EDITING','LOADING')") + .contains("p.version=#{expectedVersion}") + .contains("b.container_no IS NULL") + .doesNotContain("fl_loading_pick_assignment", "item_status"); + assertThat(initialFill) + .contains("p.plan_status IN ('EDITING','LOADING')") + .contains("b.container_no IS NULL") + .contains("#{containerNo} IS NOT NULL") + .doesNotContain("fl_loading_pick_assignment", "item_status"); + assertThat(advance) + .contains("plan_status = #{fromStatus}") + .contains("plan_status = #{toStatus}") + .contains("version = #{expectedVersion}") + .doesNotContain("ROLLED_BACK"); + assertThat(confirmPreload) + .contains("status = 'CONFIRMED'") + .contains("status = #{fromStatus}") + .contains("version = #{expectedVersion}") + .contains("version = version + 1"); + assertThat(confirmPlan) + .contains("plan_status = 'EDITING'") + .doesNotContain("preload_pushed_at") + .contains("version = #{expectedVersion}") + .contains("version = version + 1"); + } + + @Test + void everyWriteShouldAppendAnImmutableHistoryOperation() throws Exception { + String insert = sql("insertHistory", Insert.class); + assertThat(insert) + .contains("INSERT INTO fl_loading_plan_history") + .contains("operation_id") + .contains("before_snapshot") + .contains("after_snapshot") + .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"); + 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)) + .findFirst().orElseThrow(); + if (annotationType == Select.class) { + return String.join("\n", method.getAnnotation(Select.class).value()); + } + if (annotationType == Update.class) { + return String.join("\n", method.getAnnotation(Update.class).value()); + } + return String.join("\n", method.getAnnotation(Insert.class).value()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java new file mode 100644 index 000000000..cad36b70e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java @@ -0,0 +1,143 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.ItemRow; +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.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LoadingListRepositoryTest { + @Mock + private LoadingListQueryMapper mapper; + + private LoadingListRepository repository; + + @BeforeEach + void setUp() { + repository = new LoadingListRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + } + + @Test + void mapsRealContainerBookingIdIntoPreloadAndConfirmedSummaries() { + AggregateRow row = new AggregateRow(); + row.setPlanId(41L); + row.setContainerBookingId(61L); + row.setPlanNo("LP-41"); + row.setPlanStatus("LOADING"); + row.setVersion(1); + row.setContainerNo("MSCU-001"); + row.setCabinetNo("CAB-001"); + row.setPreloadLinkCount(2L); + row.setConfirmedPreloadCount(1L); + row.setOceanBookingId(81L); + row.setOceanBookingStatus("RELEASED"); + + Page preloadPage = new Page<>(1, 20); + preloadPage.setRecords(List.of(row)); + preloadPage.setTotal(1); + when(mapper.selectPreloadPage(any(), any())).thenReturn(preloadPage); + when(mapper.selectCustomers(List.of(41L))).thenReturn(List.of()); + + Page confirmedPage = new Page<>(1, 20); + confirmedPage.setRecords(List.of(row)); + confirmedPage.setTotal(1); + when(mapper.selectConfirmedPage(any(), any())).thenReturn(confirmedPage); + + var preload = repository.preloadPage(new PreloadQuery(null, null, null, null, null, null, 1, 20)) + .records().get(0); + var confirmed = repository.confirmedPage(new ConfirmedQuery(null, null, null, null, null, 1, 20)) + .records().get(0); + + assertThat(preload.planId()).isEqualTo(41L); + assertThat(preload.containerBookingId()).isEqualTo(61L); + assertThat(preload.containerNo()).isEqualTo("MSCU-001"); + assertThat(preload.cabinetNo()).isEqualTo("CAB-001"); + assertThat(preload.readiness().confirmationStatus()).isEqualTo("PARTIALLY_CONFIRMED"); + assertThat(preload.readiness().oceanBookingStatus()).isEqualTo("RELEASED"); + assertThat(confirmed.planId()).isEqualTo(41L); + assertThat(confirmed.containerBookingId()).isEqualTo(61L); + } + + @Test + void missingContainerNumberBlocksPreloadConfirmationAndPdaReadiness() { + AggregateRow row = new AggregateRow(); + row.setPlanId(41L); + row.setContainerBookingId(61L); + row.setPlanNo("LP-41"); + row.setPlanStatus("EDITING"); + row.setVersion(1); + row.setPreloadLinkCount(2L); + row.setConfirmedPreloadCount(0L); + row.setOceanBookingId(81L); + row.setOceanBookingStatus("RELEASED"); + when(mapper.selectItems(41L)).thenReturn(List.of()); + when(mapper.selectHistories(41L)).thenReturn(List.of()); + + var pending = repository.preloadDetail(row).summary().readiness(); + assertThat(pending.containerNoReady()).isFalse(); + assertThat(pending.canConfirm()).isFalse(); + assertThat(pending.blockingReasons()).contains("CONTAINER_NO_MISSING"); + + row.setConfirmedPreloadCount(2L); + var confirmed = repository.preloadDetail(row).summary().readiness(); + assertThat(confirmed.readyForPda()).isFalse(); + } + + @Test + void mapsFrozenCustomsTaxFactsPerInboundAcrossCustomers() { + AggregateRow aggregate = new AggregateRow(); + aggregate.setPlanId(41L); + aggregate.setContainerBookingId(61L); + aggregate.setPlanNo("LP-41"); + aggregate.setPlanStatus("COMPLETED"); + aggregate.setVersion(3); + ItemRow refund = item(101L, 11L, 201L, "IN-REFUND", "TAX_REFUND", true); + ItemRow declaration = item(102L, 12L, 202L, "IN-BUY", "BUY_DECLARATION", false); + ItemRow unknown = item(103L, 12L, 203L, "IN-UNKNOWN", null, null); + when(mapper.selectItems(41L)).thenReturn(List.of(refund, declaration, unknown)); + when(mapper.selectHistories(41L)).thenReturn(List.of()); + + var items = repository.confirmedDetail(aggregate).items(); + + assertThat(items).extracting(value -> value.inboundCode(), value -> value.accountId(), + value -> value.customsTaxMode(), value -> value.taxRefundFlag()) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("IN-REFUND", 11L, "TAX_REFUND", true), + org.assertj.core.groups.Tuple.tuple("IN-BUY", 12L, "BUY_DECLARATION", false), + org.assertj.core.groups.Tuple.tuple("IN-UNKNOWN", 12L, null, null)); + } + + private static ItemRow item(long itemId, long accountId, long inboundId, String inboundCode, + String customsTaxMode, Boolean taxRefundFlag) { + ItemRow row = new ItemRow(); + row.setItemId(itemId); + row.setAccountId(accountId); + row.setCustomerName("customer-" + accountId); + row.setCustomsTaxMode(customsTaxMode); + row.setTaxRefundFlag(taxRefundFlag); + row.setOrderId(300L + inboundId); + row.setOrderNo("ORDER-" + inboundId); + row.setInboundId(inboundId); + row.setInboundDetailId(400L + inboundId); + row.setInboundCode(inboundCode); + row.setPackageCount(1); + row.setLoadSequence(1); + row.setItemVersion(0); + return row; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java new file mode 100644 index 000000000..f7c09f27e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -0,0 +1,116 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.LoadingPickItemMapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoadingPickItemMapperContractTest { + @Test + void inboundProgressProjectionCanBeInstantiatedByMyBatis() throws Exception { + LoadingPickItemMapper.InboundProgress progress = LoadingPickItemMapper.InboundProgress.class + .getDeclaredConstructor().newInstance(); + + progress.setExpectedCount(5); + progress.setPickedCount(3); + progress.setTaskCount(2); + progress.setCompletedTaskCount(1); + progress.setUnresolvedCount(1); + + assertThat(LoadingPickItemMapper.InboundProgress.class).isNotInterface(); + assertThat(progress.getExpectedCount()).isEqualTo(5); + assertThat(progress.getPickedCount()).isEqualTo(3); + assertThat(progress.getTaskCount()).isEqualTo(2); + assertThat(progress.getCompletedTaskCount()).isEqualTo(1); + 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); + String sql = method.getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("plan_item.loading_plan_id = #{planId}") + .contains("plan_item.deleted = 0") + .contains("pick_task.loading_plan_id = #{planId}") + .doesNotContain("pick_task.task_status") + .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 + void terminalStatesAreDefinedInSchema() throws Exception { + String sql = new org.springframework.core.io.ClassPathResource("db/phase3-smart-loading-schema.sql") + .getContentAsString(java.nio.charset.StandardCharsets.UTF_8); + + assertThat(sql) + .contains("'PENDING', 'PICKING', 'PICKED', 'PARTIAL', 'SKIPPED'") + .contains("action_type VARCHAR(24) NOT NULL DEFAULT 'PICK'") + .contains("operation_reason VARCHAR(500) NULL"); + } + + @Test + void pdaWritesAndReadsRelyOnUpstreamTaskScopeInsteadOfWarehouseScope() throws Exception { + Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.class); + Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class, + int.class, long.class); + + assertThat(list.getAnnotation(Select.class).value()[0]) + .doesNotContain("booking.warehouse_id = #{warehouseId}") + .contains("plan_item.deleted = 0"); + assertThat(pick.getAnnotation(Update.class).value()[0]) + .doesNotContain("booking.warehouse_id = #{warehouseId}") + .contains("plan_item.deleted = 0"); + } + + @Test + void partialCanFinalizeTheQuantityAlreadyPersistedByIncrementalScanning() throws Exception { + Method partial = LoadingPickItemMapper.class.getDeclaredMethod("partial", long.class, int.class, + int.class, long.class); + String sql = partial.getAnnotation(Update.class).value()[0]; + + assertThat(sql) + .contains("item_status IN ('PENDING', 'PICKING')") + .contains("picked_package_count <= #{finalCount}") + .contains("#{finalCount} < planned_package_count"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java new file mode 100644 index 000000000..20799bb0b --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java @@ -0,0 +1,88 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.LoadingPickItemMapper; +import com.baiyue.firstleg.mapper.LoadingPickTaskMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Modifier; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoadingPickPdaTaskSqlContractTest { + @Test + void pickingUnitSnapshotsComeFromTheSameAuthoritativeLoadingPlanInEveryReadPath() throws Exception { + for (var method : LoadingPickItemMapper.class.getDeclaredMethods()) { + if (!java.util.Set.of("selectTaskItemsWithPlan", "selectTaskItemsWithAccount", "selectTaskItemWithAccount") + .contains(method.getName())) continue; + String sql = method.getAnnotation(Select.class).value()[0]; + assertThat(sql).contains("plan_item.unit_type", "plan_item.pallet_id", "plan_item.pallet_no", "plan_item.indivisible") + .contains("plan_item.id = pick_item.loading_plan_item_id"); + assertThat(sql).contains(method.getName().equals("selectTaskItemsWithPlan") + ? "plan_item.loading_plan_id = task.loading_plan_id" : "plan.id = pick_task.loading_plan_id"); + } + assertThat(LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithPlan", long.class)).isNotNull(); + } + + @Test + void queryRowsAreConcreteBeansThatMyBatisCanInstantiate() throws Exception { + assertThat(LoadingPickTaskMapper.PendingTaskRow.class.isInterface()).isFalse(); + assertThat(LoadingPickItemMapper.CartonLabelRow.class.isInterface()).isFalse(); + assertThat(Modifier.isPublic(LoadingPickTaskMapper.PendingTaskRow.class.getConstructor().getModifiers())) + .isTrue(); + assertThat(Modifier.isPublic(LoadingPickItemMapper.CartonLabelRow.class.getConstructor().getModifiers())) + .isTrue(); + } + + @Test + void pendingTasksAreLimitedToUnassignedOrCurrentOperatorWithoutCustomerInput() throws Exception { + String sql = LoadingPickTaskMapper.class + .getDeclaredMethod("selectPendingForOperator", long.class, long.class, String.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED')") + .contains("booking.record_status = 'ACTIVE'") + .contains("booking.warehouse_id = #{warehouseId}") + .contains("booking.source_type AS sourceType") + .contains("booking.record_status AS recordStatus") + .contains("booking.merged_into_container_booking_id AS mergedIntoContainerBookingId") + .contains("WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED'") + .contains("WHEN task.task_status = 'COMPLETING' THEN 'PROCESSING'") + .contains("WHEN task.task_status = 'COMPLETION_FAILED' THEN 'COMPLETION_FAILED'") + .contains("WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'") + .contains("pick_item.item_status IN ('PENDING', 'PICKING')") + .contains("THEN 'AWAITING_REVIEW'") + .contains("ELSE 'IN_PROGRESS' END AS workflowStatus") + .contains("assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}") + .contains("booking.warehouse_id AS warehouseId") + .doesNotContain("tenantId"); + } + + @Test + void taskDetailsExposeStableCartonCodesBoundToThePlannedInboundDetail() throws Exception { + String sql = LoadingPickItemMapper.class + .getDeclaredMethod("selectTaskCartonLabels", long.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("fl_inbound_carton_label") + .contains("label.tenant_id = plan_item.account_id") + .contains("label.inbound_id = plan_item.inbound_id") + .contains("label.inbound_detail_id = plan_item.inbound_detail_id") + .contains("label.box_code AS boxCode"); + } + + @Test + void taskDetailResolvesOnlyTheActiveBookingOwnedByItsCurrentPlan() throws Exception { + String sql = LoadingPickTaskMapper.class + .getDeclaredMethod("selectActiveBookingByTask", long.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("plan.id=task.loading_plan_id") + .contains("booking.id=plan.container_booking_id") + .contains("task.id=#{taskId}") + .contains("booking.record_status='ACTIVE'"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java new file mode 100644 index 000000000..3c921d8ec --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java @@ -0,0 +1,27 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.LoadingPickTaskMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoadingPickStableLookupContractTest { + @Test void stableLookupsUseCabinetOrPlanAndNotMutableContainerIdentity() throws Exception { + String cabinet = LoadingPickTaskMapper.class.getMethod("selectLoadingByCabinet", String.class, long.class) + .getAnnotation(Select.class).value()[0]; + String plan = LoadingPickTaskMapper.class.getMethod("selectLoadingByPlan", long.class, long.class) + .getAnnotation(Select.class).value()[0]; + String legacy = LoadingPickTaskMapper.class.getMethod("selectLoadingByContainerAndAccount", String.class, long.class) + .getAnnotation(Select.class).value()[0]; + assertThat(cabinet).contains("booking.cabinet_no=#{cabinetNo}", "booking.record_status='ACTIVE'") + .contains("booking.warehouse_id=#{warehouseId}") + .doesNotContain("task.container_no"); + assertThat(plan).contains("task.loading_plan_id=#{loadingPlanId}", "booking.record_status='ACTIVE'") + .contains("booking.warehouse_id=#{warehouseId}") + .doesNotContain("task.container_no"); + assertThat(legacy).contains("booking.container_no = #{containerNo}", + "booking.record_status = 'ACTIVE'", "booking.warehouse_id = #{warehouseId}") + .doesNotContain("task.container_no ="); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickTaskMappingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickTaskMappingContractTest.java new file mode 100644 index 000000000..8f967f19a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickTaskMappingContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity; +import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoadingPickTaskMappingContractTest { + @Test + void detailedAndInboundPickEntitiesUseDistinctTables() { + assertThat(LoadingPickTaskEntity.class.getAnnotation(TableName.class).value()) + .isEqualTo("fl_loading_pick_task"); + assertThat(InboundLoadingPickTaskEntity.class.getAnnotation(TableName.class).value()) + .isEqualTo("fl_inbound_loading_pick_task") + .isNotEqualTo(LoadingPickTaskEntity.class.getAnnotation(TableName.class).value()); + } + + @Test + void inboundMapperUsesInboundTaskTableAndFields() throws Exception { + Method method = InboundLoadingPickTaskMapper.class.getDeclaredMethod( + "changeStatus", long.class, long.class, String.class, String.class, + long.class, String.class, String.class, Integer.class, Integer.class, int.class); + String sql = method.getAnnotation(Update.class).value()[0]; + + assertThat(sql) + .contains("UPDATE fl_inbound_loading_pick_task") + .contains("tenant_id=#{tenantId}") + .contains("inbound_id=#{inboundId}") + .doesNotContain("fl_loading_pick_task"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/MojiaMeasurementReceiptSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/MojiaMeasurementReceiptSqlContractTest.java new file mode 100644 index 000000000..6cf89ad8f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/MojiaMeasurementReceiptSqlContractTest.java @@ -0,0 +1,36 @@ +package com.baiyue.firstleg.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 MojiaMeasurementReceiptSqlContractTest { + @Test + void schemaAndUnifiedMigrationKeepUnmatchedMeasurementsWithImageReferences() throws Exception { + String schema = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString( + Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + + for (String sql : new String[]{schema, unified}) { + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt") + .contains("box_code VARCHAR(128) NOT NULL") + .contains("scanned_shipment_id VARCHAR(64) NULL") + .contains("image_file_id BIGINT UNSIGNED NOT NULL") + .contains("status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED'") + .contains("idx_mojia_receipt_status_time") + .doesNotContain("picture_base64"); + } + assertThat(unified) + .contains("墨家未匹配测量收件箱字段未完整创建") + .contains("箱级测量实扫Shipment ID字段未创建") + .contains("测量计算规则版本字段长度不是64"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java new file mode 100644 index 000000000..f792fd39e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -0,0 +1,196 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper; +import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper; +import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +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") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("uk_inbound_parent_split_sequence") + .contains("uk_inbound_split_batch_line") + .contains("uk_inbound_split_batch_source_child") + .contains("child_order.package_count <> SUM(item.package_count)") + .contains("NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count") + .contains("SUM(split_item.package_count) > source_item.package_count") + .contains("CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0)"); + } + + @Test + void protectsPdaTerminalStates() throws Exception { + String sql = new ClassPathResource("db/phase3-smart-loading-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("'PICKED', 'PARTIAL', 'SKIPPED'") + .contains("NEW.item_status = 'PARTIAL'") + .contains("NEW.item_status = 'SKIPPED'") + .contains("NEW.picked_package_count = OLD.picked_package_count"); + } + + @Test + void removesLegacySingleChildGuardsAndExcludesLoadedSlices() throws Exception { + String sql = new ClassPathResource("db/phase3-split-preload-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(sql) + .contains("DROP INDEX uk_inbound_one_secondary") + .contains("DROP TRIGGER IF EXISTS trg_inbound_split_before_insert") + .contains("DROP TRIGGER IF EXISTS trg_inbound_split_before_update"); + + String candidateSql = SmartLoadingCandidateMapper.class.getMethod("candidates", Long.class) + .getAnnotation(Select.class).value()[0]; + assertThat(candidateSql).contains("o.split_purpose='REMAINDER'").doesNotContain("o.split_purpose='LOADED'"); + } + + @Test + void settlementPersistsRealPlanAndContainerIds() throws Exception { + String insert = InboundSplitSettlementMapper.class.getMethod("insertBatchWithChildren", + long.class, long.class, long.class, String.class, long.class, String.class, int.class, + int.class, Long.class, Long.class, int.class, int.class, Long.class, Long.class, + String.class, long.class, String.class) + .getAnnotation(Insert.class).value()[0]; + assertThat(insert) + .contains("source_inbound_id") + .contains("loading_plan_id") + .contains("container_booking_id") + .contains("#{loadingPlanId}") + .contains("#{containerBookingId}"); + + String clone = InboundSplitSettlementMapper.class.getMethod("cloneChildWithPurpose", + long.class, long.class, long.class, long.class, long.class, String.class, + String.class, int.class, + int.class, String.class, long.class, String.class) + .getAnnotation(Insert.class).value()[0]; + 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}"); + + String cloneItem = InboundSplitSettlementMapper.class.getMethod("cloneMeasurementItem", + long.class, long.class, long.class, long.class, long.class, long.class, int.class, int.class) + .getAnnotation(Insert.class).value()[0]; + assertThat(cloneItem).doesNotContain("unit_weight_kg").doesNotContain("total_weight_kg"); + } + + @Test + void readsSplitStatusAndAllowsPdaSettlementAfterPreloadLoading() throws Exception { + String lockParent = InboundSplitSettlementMapper.class.getMethod("lockParent", long.class) + .getAnnotation(Select.class).value()[0]; + assertThat(lockParent).contains("split_status AS splitStatus"); + + String markSource = InboundSplitSettlementMapper.class.getMethod("markSourceLoaded", + long.class, long.class, int.class, long.class, int.class) + .getAnnotation(Update.class).value()[0]; + assertThat(markSource) + .contains("process_status IN ('WAIT_SPLIT','LOADING')") + .contains("package_count=#{loadedPackageCount}") + .contains("split_status='FULLY_SPLIT'"); + } + + @Test + void partialPickingConvergesPalletSnapshotWithActualCartons() throws Exception { + String converge = InboundSplitSettlementMapper.class.getMethod("convergePartialPlanItem", + long.class, long.class, String.class) + .getAnnotation(Update.class).value()[0]; + assertThat(converge) + .contains("plan_item.pallet_count = CEIL") + .contains("pick_item.picked_package_count / NULLIF(plan_item.package_count,0)") + .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", + long.class, long.class, long.class, int.class) + .getAnnotation(Update.class).value()[0]; + assertThat(consume) + .contains("allocation_status = 'CONFIRMED'") + .doesNotContain("THEN 'RELEASED'"); + + String release = InboundPalletAllocationMapper.class.getMethod("markBlockReleased", + long.class, long.class, long.class) + .getAnnotation(Update.class).value()[0]; + assertThat(release) + .contains("allocation_status = 'RELEASED'") + .contains("slot_status = 'RELEASED'") + .contains("block_id=#{blockId}") + .contains("allocation_status = 'CONFIRMED'"); + + String transfer = InboundPalletAllocationMapper.class.getMethod("markBlockTransferred", + long.class, long.class, long.class) + .getAnnotation(Update.class).value()[0]; + assertThat(transfer) + .contains("allocation_status='TRANSFERRED'") + .contains("slot_status='TRANSFERRED'") + .contains("block_id=#{blockId}") + .contains("allocation_status='CONFIRMED'"); + } + + @Test + void allowsTransferredSourceAndRemainderChildToKeepTheSameBlockSlotIdentity() throws Exception { + String moduleSql = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unifiedSql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + + assertThat(moduleSql) + .contains("UNIQUE KEY uk_pallet_allocation_slot (tenant_id, inbound_id, block_id, slot_no)") + .contains("UNIQUE KEY uk_pallet_allocation_weight (tenant_id, inbound_id, weight_result_id)") + .doesNotContain("UNIQUE KEY uk_pallet_allocation_slot (tenant_id, block_id, slot_no)"); + assertThat(unifiedSql) + .contains("'tenant_id,inbound_id,block_id,slot_no'") + .contains("ADD UNIQUE KEY uk_pallet_allocation_slot (tenant_id,inbound_id,block_id,slot_no)") + .contains("'tenant_id,inbound_id,weight_result_id'") + .contains("ADD UNIQUE KEY uk_pallet_allocation_weight (tenant_id,inbound_id,weight_result_id)"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java new file mode 100644 index 000000000..2e1c0f81a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3BookingTruckPermissionSqlContractTest.java @@ -0,0 +1,138 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.controller.PlatformTruckAppointmentController; +import com.baiyue.firstleg.oceanbooking.controller.PlatformOceanBookingController; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class Phase3BookingTruckPermissionSqlContractTest { + private static final Pattern AUTHORITY = Pattern.compile("hasAuthority\\('([^']+)'\\)"); + private static final Pattern OCEAN_CODE = Pattern.compile("platform:firstleg:ocean-booking:[a-z-]+"); + private static final Pattern TRUCK_CODE = Pattern.compile("platform:firstleg:truck-appointment:[a-z-]+"); + + private static final Set OCEAN_CONTROLLER_PERMISSIONS = Set.of( + "platform:firstleg:ocean-booking:list", + "platform:firstleg:ocean-booking:create", + "platform:firstleg:ocean-booking:release", + "platform:firstleg:ocean-booking:edit", + "platform:firstleg:ocean-booking:delete"); + + private static final Set OCEAN_SQL_PERMISSIONS = Set.of( + "platform:firstleg:ocean-booking:list", + "platform:firstleg:ocean-booking:create", + "platform:firstleg:ocean-booking:release", + "platform:firstleg:ocean-booking:edit", + "platform:firstleg:ocean-booking:delete", + "platform:firstleg:ocean-booking:allocate"); + + private static final Set TRUCK_PERMISSIONS = Set.of( + "platform:firstleg:truck-appointment:list", + "platform:firstleg:truck-appointment:create", + "platform:firstleg:truck-appointment:edit", + "platform:firstleg:truck-appointment:operate"); + + @Test + void controllerAuthoritiesAndPersistedPermissionCodesMatchTheirActiveContracts() throws Exception { + assertThat(authorities(PlatformOceanBookingController.class)).isEqualTo(OCEAN_CONTROLLER_PERMISSIONS); + assertThat(authorities(PlatformTruckAppointmentController.class)).isEqualTo(TRUCK_PERMISSIONS); + + String oceanModule = resource("db/phase3-ocean-booking-schema.sql"); + String truckModule = resource("db/phase3-truck-appointment-schema.sql"); + String unified = unifiedSql(); + assertThat(codes(oceanModule, OCEAN_CODE)).isEqualTo(OCEAN_SQL_PERMISSIONS); + assertThat(codes(truckModule, TRUCK_CODE)).isEqualTo(TRUCK_PERMISSIONS); + assertThat(codes(unified, OCEAN_CODE)).isEqualTo(OCEAN_SQL_PERMISSIONS); + assertThat(codes(unified, TRUCK_CODE)).isEqualTo(TRUCK_PERMISSIONS); + } + + @Test + void unifiedSqlLocksCanonicalIdsScopeParentsAndRoleGrants() throws Exception { + String sql = unifiedSql(); + assertThat(sql) + .contains("UNIQUE KEY uk_permission_scope_code (tenant_id, app_code, permission_code)") + .contains("CREATE PROCEDURE assert_ocean_booking_permission_ids_available()") + .contains("CREATE PROCEDURE assert_truck_appointment_permission_ids_available()") + .contains("CREATE PROCEDURE assert_phase3_service_center_schema()") + .contains("CALL assert_phase3_service_center_schema();") + .contains("DROP PROCEDURE IF EXISTS assert_phase3_service_center_schema;") + .contains("三期订舱权限迁移失败:5102140-5102149 ID 或权限码被其他语义占用") + .contains("三期拖车权限迁移失败:5102150-5102154 ID 或权限码被其他语义占用") + .contains("(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list'") + .contains("(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate'") + .contains("(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'") + .contains("(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate'") + .contains("permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 6") + .contains("p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 12") + .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") + .doesNotContain("assert_phase3_booking_truck_permission_ids_available") + .doesNotContain("/trucking"); + + assertThat(count(sql, "(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list'")) + .isEqualTo(1); + assertThat(count(sql, "(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'")) + .isEqualTo(1); + assertThat(count(sql, "CREATE PROCEDURE assert_ocean_booking_permission_ids_available()")) + .isEqualTo(1); + assertThat(count(sql, "CREATE PROCEDURE assert_truck_appointment_permission_ids_available()")) + .isEqualTo(1); + assertThat(count(sql, "CREATE PROCEDURE assert_phase3_service_center_schema()")) + .isEqualTo(1); + } + + private static Set authorities(Class controller) { + Set result = new LinkedHashSet<>(); + Arrays.stream(controller.getDeclaredMethods()) + .map(method -> method.getAnnotation(PreAuthorize.class)) + .filter(annotation -> annotation != null) + .map(PreAuthorize::value) + .forEach(expression -> { + Matcher matcher = AUTHORITY.matcher(expression); + assertThat(matcher.find()).as(expression).isTrue(); + result.add(matcher.group(1)); + }); + return result; + } + + private static Set codes(String sql, Pattern pattern) { + Set result = new LinkedHashSet<>(); + Matcher matcher = pattern.matcher(sql); + while (matcher.find()) { + result.add(matcher.group()); + } + return result; + } + + private static int count(String text, String needle) { + int count = 0; + int offset = 0; + while ((offset = text.indexOf(needle, offset)) >= 0) { + count++; + offset += needle.length(); + } + return count; + } + + private static String resource(String path) throws Exception { + return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8); + } + + private static String unifiedSql() throws Exception { + Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(path)) { + path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java new file mode 100644 index 000000000..eb74c0acc --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java @@ -0,0 +1,94 @@ +package com.baiyue.firstleg.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 Phase3ChannelContainerMigrationContractTest { + + @Test + void moduleMigrationRestoresGuardsBeforeSurfacingFailures() throws Exception { + assertSafeWindow(moduleMigration(), false); + } + + @Test + void unifiedMigrationRestoresGuardsBeforeSurfacingFailures() throws Exception { + String sql = unifiedMigration(); + assertSafeWindow(sql, true); + assertNoDuplicateInboundColumns(sql); + } + + private static void assertNoDuplicateInboundColumns(String sql) { + int tableStart = sql.indexOf("CREATE TABLE IF NOT EXISTS fl_inbound_order ("); + int tableEnd = sql.indexOf(") ENGINE=InnoDB", tableStart); + String tableDefinition = sql.substring(tableStart, tableEnd); + + assertThat(tableStart).isGreaterThan(0); + assertThat(tableEnd).isGreaterThan(tableStart); + assertThat(countOccurrences(tableDefinition, " warehouse_id BIGINT UNSIGNED")).isEqualTo(1); + assertThat(countOccurrences(tableDefinition, " warehouse_name VARCHAR(128)")).isEqualTo(1); + assertThat(countOccurrences(tableDefinition, " channel_code VARCHAR(64)")).isEqualTo(1); + assertThat(countOccurrences(tableDefinition, " channel_name VARCHAR(128)")).isEqualTo(1); + } + + private static int countOccurrences(String text, String token) { + return (text.length() - text.replace(token, "").length()) / token.length(); + } + + private static void assertSafeWindow(String sql, boolean qualified) { + String containerTrigger = qualified + ? "CREATE TRIGGER baiyue_firstleg.trg_container_booking_before_update" + : "CREATE TRIGGER trg_container_booking_before_update"; + String pickTrigger = qualified + ? "CREATE TRIGGER baiyue_firstleg.trg_loading_pick_task_before_update" + : "CREATE TRIGGER trg_loading_pick_task_before_update"; + int call = sql.indexOf("CALL migrate_phase3_channel_containers();"); + int containerRestore = sql.indexOf(containerTrigger, call); + int pickRestore = sql.indexOf(pickTrigger, call); + int failureAssertion = sql.indexOf("CALL assert_phase3_channel_container_migration();", call); + + assertThat(sql) + .contains("DECLARE EXIT HANDLER FOR SQLEXCEPTION") + .contains("GET DIAGNOSTICS CONDITION 1") + .contains("@phase3_channel_sqlstate = RETURNED_SQLSTATE") + .contains("@phase3_channel_errno = MYSQL_ERRNO") + .contains("@phase3_channel_message = MESSAGE_TEXT") + .contains("渠道柜迁移失败:货柜只读触发器未恢复") + .contains("渠道柜迁移失败:PDA任务只读触发器未恢复") + .contains("current_plan_status IN ('LOADING', 'COMPLETED')") + .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") + .contains("已完成PDA拣货任务不可修改") + .contains("ensure_container_transport_history_schema") + .contains("ADD COLUMN operation_id VARCHAR(64) NOT NULL") + .contains("ADD COLUMN request_hash CHAR(64) NOT NULL") + .contains("旧表已有数据且缺少幂等字段") + .contains("NOT (OLD.channel_code <=> NEW.channel_code)") + .contains("NOT (OLD.channel_name <=> NEW.channel_name)") + .contains("NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)") + .contains("NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id)") + .doesNotContain("NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)") + .doesNotContain("NOT (OLD.vessel_name <=> NEW.vessel_name)") + .doesNotContain("NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)") + .doesNotContain("p.plan_status='COMPLETED'"); + assertThat(call).isGreaterThan(0); + assertThat(containerRestore).isGreaterThan(call); + assertThat(pickRestore).isGreaterThan(call); + assertThat(failureAssertion).isGreaterThan(containerRestore).isGreaterThan(pickRestore); + } + + private static String moduleMigration() throws Exception { + return new ClassPathResource("db/phase3-channel-container-migration.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static String unifiedMigration() throws Exception { + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + Path path = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + return Files.readString(path.normalize(), StandardCharsets.UTF_8); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java new file mode 100644 index 000000000..2d812a167 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -0,0 +1,374 @@ +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 java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class Phase3FinalUnifiedSqlContractTest { + private static final Path UNIFIED = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + private static final Pattern CREATE_TABLE = Pattern.compile( + "(?i)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(?:`[^`]+`\\.)?`?([a-zA-Z0-9_]+)`?\\s*\\("); + private static final Pattern COLUMN = Pattern.compile( + "(?i)^\\s*`?([a-zA-Z0-9_]+)`?\\s+(bigint|binary|bit|blob|boolean|char|date|datetime|decimal|double|enum|float|int|integer|json|longblob|longtext|mediumblob|mediumint|mediumtext|numeric|real|set|smallint|text|time|timestamp|tinyblob|tinyint|tinytext|varbinary|varchar|year)\\b"); + private static final Pattern CHINESE_COMMENT = Pattern.compile( + "(?i)\\bCOMMENT\\s*=?\\s*'(?:(?:''|\\\\'|[^'])*[\\u3400-\\u9fff](?:''|\\\\'|[^'])*)'"); + + @Test + void unifiedEntryOwnsPhase3OutboundCustomsPortAndEvidenceHardening() throws Exception { + String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8); + + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_outbound_dispatch") + .contains("CREATE TABLE IF NOT EXISTS fl_customs_container") + .contains("CREATE TABLE IF NOT EXISTS fl_customs_customer_document") + .contains("CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound") + .contains("CREATE TABLE IF NOT EXISTS fl_customs_completion_document") + .contains("terminal_trucking_company_id BIGINT UNSIGNED NULL") + .contains("phase3_container_supplier_snapshot_add_column") + .doesNotContain("phase3_customs_supplier_snapshot_add_column","CREATE TABLE IF NOT EXISTS fl_customs_case") + .contains("CREATE TABLE IF NOT EXISTS fl_port_schedule") + .contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history") + .contains("CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_review") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail") + .contains("trg_inbound_split_detail_before_insert") + .contains("trg_inbound_split_detail_before_update") + .contains("trg_inbound_split_detail_before_delete") + .contains("CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW', 'BATCH', 'PICK_PALLET', 'COMPLETE_LOADING'))") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence") + .contains("UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)") + .contains("UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key)") + .contains("normalize_loading_cabinet_evidence_collation") + .contains("table_collation<>'utf8mb4_0900_ai_ci'") + .contains("column_name IN ('container_booking_id','source_task_id','active_flag','operation_key','request_hash')") + .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_evidence", "phase3_loading_evidence_add_column") + .contains("CALL assert_phase3_final_schema()") + .contains("trg_port_schedule_history_before_update") + .contains("trg_port_schedule_history_before_delete") + .contains("trg_loading_pick_operation_before_delete"); + } + + @Test + void unifiedOceanBookingBlockIncludesExistingSchemaReconciliation() throws Exception { + String sql = normalise(Files.readString(UNIFIED)); + assertThat(sql) + .contains("DROP CHECK chk_ocean_booking_status") + .contains("ADD CONSTRAINT chk_ocean_booking_status CHECK") + .contains("DROP COLUMN failure_reason") + .contains("(status IN ('DRAFT','RELEASED'))") + .contains("platform:firstleg:ocean-booking:edit") + .contains("platform:firstleg:ocean-booking:delete") + .doesNotContain("reconcile_ocean_booking_legacy") + .doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'"); + } + + @Test + void permissionRangesDoNotOverlapAndBothPlatformRolesAreGranted() throws Exception { + String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8); + + for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id)); + for (int id : java.util.List.of(5102170,5102172)) assertThat(sql).contains(Integer.toString(id)); + assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175)") + .doesNotContain("(5102171,0,'PLATFORM','BUTTON'","(5102173,0,'PLATFORM','BUTTON'", + "(5102174,0,'PLATFORM','BUTTON'","(5102175,0,'PLATFORM','BUTTON'"); + for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id)); + for (int id = 5102190; id <= 5102193; id++) assertThat(sql).contains(Integer.toString(id)); + assertThat(sql) + .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:%')) <> 28") + .contains("platform:firstleg:pda:%") + .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") + .contains("角色授权未完整写入"); + } + + @Test + void phase3ModuleSqlBlocksAreContiguousAndSynchronised() throws Exception { + String unified = normalise(Files.readString(resolveUnified())); + assertBlock(unified, "-- 来源:order/src/main/resources/db/phase3-order-parent-child-schema.sql", + "-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql", + "order/src/main/resources/db/phase3-order-parent-child-schema.sql"); + assertBlock(unified, "-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql", + "-- 来源:firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql", + "firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql"); + assertBlock(unified, "-- 来源:firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql", + "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql", + "firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql"); + assertBlock(unified, "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql", + "-- 入库完成后的唯一状态为 WAIT_SPLIT。", + "iam/src/main/resources/db/phase3-warehouse-roles.sql"); + } + + @Test + void customsBaselineIsUniqueSynchronisedAndRunsBeforeFinalSchemaAssertion() throws Exception { + String unified = normalise(Files.readString(resolveUnified())); + String marker = "-- 来源:firstleg/src/main/resources/db/phase3-container-customs-schema.sql"; + String finalAssertion = "CREATE PROCEDURE assert_phase3_final_schema()"; + List tables = List.of( + "fl_customs_container", + "fl_customs_customer_document", + "fl_customs_customer_document_inbound", + "fl_customs_customer_document_version", + "fl_customs_frozen_document_inbound", + "fl_customs_completion_document", + "fl_customs_completion_document_version", + "fl_customs_container_history", + "fl_customs_command" + ); + + assertBlock(unified, marker, "/* Phase 3: link inbound preload plans", + "firstleg/src/main/resources/db/phase3-container-customs-schema.sql"); + assertThat(count(unified, marker)).isEqualTo(1); + assertThat(unified.indexOf(marker)).isLessThan(unified.indexOf(finalAssertion)); + for (String table : tables) { + String create = "CREATE TABLE IF NOT EXISTS " + table + " ("; + assertThat(count(unified, create)).as(table).isEqualTo(1); + assertThat(unified.indexOf(create)).as(table + " before final assertion") + .isLessThan(unified.indexOf(finalAssertion)); + } + } + + @Test + void financeFeeSourceMigrationsAreContiguousAndSynchronised() throws Exception { + String unified = normalise(Files.readString(resolveUnified())); + assertBlock(unified, "-- 来源:firstleg/src/main/resources/db/finance-fee-source-migration.sql", + "-- 来源:finance/src/main/resources/db/fee-source-event-migration.sql", + "firstleg/src/main/resources/db/finance-fee-source-migration.sql"); + assertBlock(unified, "-- 来源:finance/src/main/resources/db/fee-source-event-migration.sql", + "-- END FINANCE FEE SOURCE MODULE MIGRATIONS", + "finance/src/main/resources/db/fee-source-event-migration.sql"); + + assertThat(unified) + .contains("column_name IN ('source_type','source_no','direction','container_id','fee_item_code',") + .contains("column_name IN ('payload_hash','result_resource_id','error_code')") + .contains("column_name IN ('event_key', 'event_version', 'aggregate_sequence', 'payload_hash',") + .contains("CREATE TABLE IF NOT EXISTS fl_container_fee_detail"); + } + + @Test + void commentUpgradeIsIdempotentAndGuardsNineSchemaStructure() throws Exception { + String sql = normalise(Files.readString(resolveUnified())); + + assertThat(sql) + .contains("CREATE PROCEDURE queue_table_comment") + .contains("CREATE PROCEDURE queue_column_comment") + .contains("CREATE PROCEDURE apply_one_table_comments") + .contains("CREATE PROCEDURE apply_queued_comments") + .contains("NOT (BINARY v_current_comment=BINARY p_column_comment)") + .contains("SET v_signature_before=SHA2") + .contains("REPLACE(v_generation_expression,CONCAT(CHAR(92),CHAR(39)),CHAR(39))") + .contains("BINARY target.signature_before") + .contains("字段注释升级遇到未知数据类型") + .contains("字段注释升级遇到未知EXTRA定义") + .contains("字段注释升级遇到未知生成列结构") + .contains("MySQL DDL 会隐式提交") + .contains("不使用 GROUP_CONCAT") + .contains("DECLARE table_cursor CURSOR FOR") + .contains("CALL apply_one_table_comments(v_schema_name,v_table_name)") + .contains("CALL apply_queued_comments();") + .contains("PREPARE table_comment_batch_stmt FROM @table_comment_batch_sql") + .contains("九个业务数据库未完整创建") + .contains("存在缺失中文注释的业务表") + .contains("存在缺失中文注释的业务字段") + .doesNotContain("CREATE PROCEDURE upgrade_column_comment"); + assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(147); + assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2374); + assertThat(sql) + .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") + .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'") + .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_category'") + .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'container_booking_id'"); + assertThat(sql.indexOf("CALL apply_queued_comments();", + sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS"))) + .isGreaterThan(sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS")); + } + + @Test + void unifiedCreateDefinitionsHaveChineseTableAndColumnComments() throws Exception { + String sql = normalise(Files.readString(resolveUnified())); + List issues = new ArrayList<>(); + Matcher tableMatcher = CREATE_TABLE.matcher(sql); + int tableCount = 0; + int columnCount = 0; + while (tableMatcher.find()) { + tableCount++; + String table = tableMatcher.group(1); + int open = tableMatcher.end() - 1; + int close = matchingParen(sql, open); + int semicolon = sql.indexOf(';', close); + assertThat(semicolon).as("missing semicolon for %s", table).isGreaterThan(close); + if (!CHINESE_COMMENT.matcher(sql.substring(close + 1, semicolon)).find()) { + issues.add(table + ": table comment"); + } + for (String definition : splitDefinitions(sql.substring(open + 1, close))) { + Matcher columnMatcher = COLUMN.matcher(definition); + if (!columnMatcher.find()) continue; + columnCount++; + if (!table.startsWith("fl_customs_") && !CHINESE_COMMENT.matcher(definition).find()) { + issues.add(table + "." + columnMatcher.group(1)); + } + } + } + assertThat(tableCount).isGreaterThan(100); + assertThat(columnCount).isGreaterThan(2000); + assertThat(issues).isEmpty(); + } + + @Test + void passIsUniqueAndOnlyTheFinalExecutableStatement() throws Exception { + String sql = normalise(Files.readString(resolveUnified())); + String pass = "SELECT 'PASS' AS migration_status"; + assertThat(count(sql, pass)).isEqualTo(1); + int passIndex = sql.lastIndexOf(pass); + assertThat(passIndex).isGreaterThan(sql.lastIndexOf("USE ")) + .isGreaterThan(sql.lastIndexOf("ALTER TABLE ")) + .isGreaterThan(sql.lastIndexOf("CREATE TABLE ")) + .isGreaterThan(sql.lastIndexOf("CALL ")); + } + + @Test + void unifiedEntryUsesMysql8CompatibleConditionalDdl() throws Exception { + String sql = normalise(Files.readString(resolveUnified())); + + 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"); + } + + @Test + void unifiedPdaBlockDropsLegacyPerItemReleaseColumns() throws Exception { + String sql = normalise(Files.readString(resolveUnified())); + + assertThat(sql) + .contains("column_name='release_location_ids'") + .contains("ALTER TABLE fl_loading_pick_item DROP COLUMN release_location_ids") + .contains("column_name='release_mode'") + .contains("ALTER TABLE fl_loading_pick_item DROP COLUMN release_mode") + .doesNotContain("'fl_loading_pick_item', 'release_mode'") + .doesNotContain("'fl_loading_pick_item', 'release_location_ids'") + .doesNotContain("CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'release_mode'") + .doesNotContain("CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'release_location_ids'"); + } + + @Test + void legacySplitIndexesAreOnlyDroppedAndNeverRecreated() throws Exception { + String sql = normalise(Files.readString(resolveUnified())); + assertThat(sql) + .contains("DROP INDEX uk_inbound_one_child") + .contains("DROP INDEX uk_inbound_one_secondary") + .doesNotContain("UNIQUE KEY `uk_inbound_one_child`") + .doesNotContain("UNIQUE KEY `uk_inbound_one_secondary`") + .doesNotContain("UNIQUE KEY `uk_inbound_split_batch_source_item`"); + } + + @Test + void unifiedEntryEnforcesSingleRemainderWritesAndKeepsLegacyColumns() throws Exception { + String sql = normalise(Files.readString(resolveUnified())); + + assertThat(sql) + .contains("'fl_inbound_order', 'active_remainder_root_id'") + .contains("uk_inbound_one_active_remainder") + .contains("uk_inbound_split_source_once") + .contains("NEW.loaded_child_inbound_id IS NOT NULL") + .contains("NEW.child_inbound_id = batch.remainder_child_inbound_id") + .contains("source_order.package_count = NEW.loaded_package_count") + .contains("根入仓单原始箱数与已完成分片及当前余量不守恒") + .contains("'fl_inbound_split_batch', 'loaded_child_inbound_id'") + .contains("fk_inbound_split_loaded_child"); + } + + private static void assertBlock(String unified, String marker, String nextMarker, String modulePath) + throws Exception { + int start = unified.indexOf(marker); + int next = unified.indexOf(nextMarker, start + marker.length()); + assertThat(start).as("missing marker %s", marker).isGreaterThanOrEqualTo(0); + assertThat(next).as("missing next marker %s", nextMarker).isGreaterThan(start); + String module = normalise(Files.readString(resolve(modulePath))).trim(); + String block = unified.substring(start + marker.length(), next).trim(); + assertThat(block).as("module block drift: %s", modulePath) + .startsWith(module); + } + + private static Path resolveUnified() { + return resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); + } + + private static Path resolve(String path) { + Path direct = Path.of(path); + if (Files.exists(direct)) return direct; + return Path.of("..", path).normalize(); + } + + private static String normalise(String value) { + return value.replace("\r\n", "\n").replace("\uFEFF", ""); + } + + private static int count(String value, String token) { + return value.split(java.util.regex.Pattern.quote(token), -1).length - 1; + } + + private static int matchingParen(String sql, int open) { + int depth = 0; + char quote = 0; + for (int index = open; index < sql.length(); index++) { + char current = sql.charAt(index); + if (quote != 0) { + if (current == '\\') index++; + else if (current == quote) { + if (index + 1 < sql.length() && sql.charAt(index + 1) == quote) index++; + else quote = 0; + } + continue; + } + if (current == '\'' || current == '"' || current == '`') quote = current; + else if (current == '(') depth++; + else if (current == ')' && --depth == 0) return index; + } + throw new AssertionError("unclosed CREATE TABLE at " + open); + } + + private static List splitDefinitions(String body) { + List definitions = new ArrayList<>(); + int start = 0; + int depth = 0; + char quote = 0; + for (int index = 0; index < body.length(); index++) { + char current = body.charAt(index); + if (quote != 0) { + if (current == '\\') index++; + else if (current == quote) { + if (index + 1 < body.length() && body.charAt(index + 1) == quote) index++; + else quote = 0; + } + continue; + } + if (current == '\'' || current == '"' || current == '`') quote = current; + else if (current == '(') depth++; + else if (current == ')') depth--; + else if (current == ',' && depth == 0) { + definitions.add(body.substring(start, index)); + start = index + 1; + } + } + definitions.add(body.substring(start)); + return definitions; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java new file mode 100644 index 000000000..878d7df0a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java @@ -0,0 +1,65 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("船期价格表SQL契约") +class PriceBookSqlContractTest { + @Test + @DisplayName("基线包含整表版本、固定渠道、起运仓别名和两类价格明细") + void shouldProvidePriceBookTables() throws Exception { + String schema = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String migration = new ClassPathResource("db/phase2-fba-pricing-migration.sql") + .getContentAsString(StandardCharsets.UTF_8); + String channelTable = schema.substring( + schema.indexOf("CREATE TABLE IF NOT EXISTS fl_price_book_channel"), + schema.indexOf("ENGINE=InnoDB COMMENT='价格版本渠道及船期快照'") + ); + String rateTable = schema.substring( + schema.indexOf("CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate"), + schema.indexOf("ENGINE=InnoDB COMMENT='船司FBA目的仓价格版本'") + ); + assertThat(schema) + .contains("CREATE TABLE IF NOT EXISTS fl_price_channel_catalog") + .contains("CREATE TABLE IF NOT EXISTS fl_price_origin_alias") + .contains("CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping") + .contains("华南仓(深圳/东莞)") + .contains("CREATE TABLE IF NOT EXISTS fl_price_book_version") + .contains("CREATE TABLE IF NOT EXISTS fl_price_book_channel") + .contains("price_book_version_id") + .contains("price_model VARCHAR(32)") + .contains("pricing_tax_mode VARCHAR(16)") + .contains("source_sheet_name VARCHAR(128)") + .contains("source_row_number INT UNSIGNED") + .contains("destination_type VARCHAR(16)") + .contains("destination_warehouse_id BIGINT UNSIGNED") + .contains("destination_name VARCHAR(128)") + .contains("parse_issues_json JSON NULL") + .contains("DEFAULT 'PENDING_CONFIRMATION'"); + assertThat(schema) + .contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段'"); + assertThat(schema) + .contains("'ORIGIN_CODE', 'CN-YIWU-01', 193000000000200001") + .contains("'ORIGIN_CODE', 'CN-DONGGUAN-01', 193000000000200002") + .contains("'OVERSEAS_NAME', '洛杉矶仓', 193000000000200003") + .contains("'OVERSEAS_NAME', '纽约仓', 193000000000200004"); + assertThat(channelTable) + .contains("created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID'") + .contains("updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID'") + .contains("deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记'") + .contains("version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"); + assertThat(rateTable) + .contains("status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE'") + .contains("PENDING_CONFIRMATION、ACTIVE或SUPERSEDED"); + assertThat(migration) + .doesNotContain("fl_price_book_version") + .doesNotContain("'pricing_tax_mode'") + .doesNotContain("'price_book_version_id'"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ServiceCenterUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ServiceCenterUnifiedSqlContractTest.java new file mode 100644 index 000000000..550f3127e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ServiceCenterUnifiedSqlContractTest.java @@ -0,0 +1,42 @@ +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 ServiceCenterUnifiedSqlContractTest { + @Test + void unifiedMigrationContainsServiceCenterSchemaAndGrants() throws Exception { + Path working = Path.of(System.getProperty("user.dir")); + Path path = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(path)) { + path = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + String sql = Files.readString(path, StandardCharsets.UTF_8); + + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking") + .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_party") + .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement") + .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation") + .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history") + .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation") + .contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment") + .contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_history") + .contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment") + .contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation") + .contains("5102140") + .contains("5102149") + .contains("5102150") + .contains("5102154") + .contains("PLATFORM_ADMIN") + .contains("ORDER_OPERATOR") + .contains("统一迁移失败:订舱或拖车预约角色授权未完整写入") + .contains("assert_phase3_service_center_schema()") + .contains("CALL assert_phase3_service_center_schema();"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java new file mode 100644 index 000000000..b570a0592 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -0,0 +1,295 @@ +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.apache.ibatis.scripting.xmltags.XMLLanguageDriver; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.Set; +import java.util.Collection; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery; +import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery; +import java.util.List; +import java.util.Map; +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class SmartLoadingCandidateMapperContractTest { + @Test + void sharedPalletPaginationGroupsByPalletRatherThanInbound() throws Exception { + String sql = String.join(" ", SmartLoadingCandidateMapper.class + .getDeclaredMethod("globalCandidateUnits", Page.class, InboundCandidateQuery.class, Set.class) + .getAnnotation(Select.class).value()); + assertThat(sql).contains("MIN(CASE WHEN current_group.id IS NULL THEN eligible.inboundId END) inboundId") + .contains("CASE WHEN current_group.id IS NULL AND eligible.palletId IS NULL THEN eligible.inboundId END") + .contains("sortInboundId,palletId"); + String members = String.join(" ", com.baiyue.firstleg.mapper.DropshipPalletizationMapper.class + .getDeclaredMethod("palletInboundMembers", long.class).getAnnotation(Select.class).value()); + assertThat(members).contains("COUNT(DISTINCT c.id) availablePackageCount") + .contains("WHERE c.pallet_id=#{palletId}") + .contains("GROUP BY o.tenant_id,o.id") + .doesNotContain("#{inboundId}"); + String planMembers = String.join(" ", com.baiyue.firstleg.mapper.DropshipPalletizationMapper.class + .getDeclaredMethod("palletInboundMembersByPalletIds", Collection.class) + .getAnnotation(Select.class).value()); + assertThat(planMembers).contains("COUNT(DISTINCT c.id) availablePackageCount") + .contains(" assertThat(constructor.getParameterCount()).isEqualTo(26)); + } + + @Test + void candidateSelectColumnsFollowTheCanonicalConstructorOrder() throws Exception { + Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("candidates", Long.class); + String sql = String.join(" ", list.getAnnotation(Select.class).value()); + + assertThat(sql.indexOf("NULL locked,NULL blockReason")) + .isLessThan(sql.indexOf("o.order_status_snapshot orderStatusSnapshot")); + assertThat(sql.indexOf("o.order_status_snapshot orderStatusSnapshot")) + .isLessThan(sql.indexOf("o.expected_arrival_at expectedArrivalAt")); + } + + @Test + void selectsLatestApprovedBatch() throws Exception { + Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("candidates", Long.class); + String sql = String.join(" ", list.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("o.destination_warehouse_id destinationWarehouseId") + .contains("o.order_status_snapshot orderStatusSnapshot") + .contains("o.expected_arrival_at expectedArrivalAt") + .contains("m.status='APPROVED'") + .contains("SELECT MAX(m2.id)") + .contains("m2.status='APPROVED'") + .contains("WHERE inbound_detail_id IS NOT NULL AND package_count>0") + .contains("SUM(package_count) package_count") + .contains("GROUP BY tenant_id,measurement_id,inbound_detail_id") + .contains("fl_dropship_pallet_carton carton") + .contains("pallet.pallet_status='SEALED'") + .contains("source.fulfillment_type='ONE_CLICK_DROPSHIP'") + .contains("i.inbound_id=o.id") + .contains("o.warehouse_id=#{warehouseId}") + .contains("o.process_status='WAIT_SPLIT'") + .contains("unitGrossWeightKg") + .contains("unitNetWeightKg") + .contains("NULL coLoadGroupId", "NULL groupNo", "NULL memberCount", + "NULL groupTotalBoxes", "NULL locked", "NULL blockReason") + .contains("o.actual_gross_weight_kg") + .contains("o.actual_net_weight_kg") + .doesNotContain("i.unit_weight_kg") + .contains("SELECT SUM(li.package_count)") + .contains("li.deleted=0") + .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.inbound_detail_id ASC"); + + assertThat(sql.indexOf("NULL blockReason")) + .as("record constructor projection must keep status fields in their trailing positions") + .isLessThan(sql.indexOf("o.order_status_snapshot orderStatusSnapshot")); + } + + @Test + void globalCandidateQueryPagesUnitsBeforeLoadingDetailsAndHasNoContainerJoin() throws Exception { + Method units = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidateUnits", + Page.class, InboundCandidateQuery.class, Set.class); + String unitSql = String.join(" ", units.getAnnotation(Select.class).value()); + Method details = SmartLoadingCandidateMapper.class.getDeclaredMethod( + "globalCandidatesByUnits", List.class); + String detailSql = String.join(" ", details.getAnnotation(Select.class).value()); + + assertThat(unitSql) + .contains("#{query.warehouseId} IS NULL OR o.warehouse_id=#{query.warehouseId}") + .contains("o.channel_code IN") + .contains("query.orderStatuses") + .contains("status.name() == 'WAIT_MEASURE'") + .contains("status.name() == 'WAIT_SPLIT'") + .contains("status.name() == 'LOADING'") + .contains("NOT EXISTS (", "active_item.inbound_id=o.id", "active_item.deleted=0") + .contains("available_pallet.palletId palletId") + .contains("pallet.pallet_status='SEALED'") + .contains("pallet_item.pallet_id=pallet.id", "pallet_item.deleted=0") + .contains("o.fulfillment_type='ONE_CLICK_DROPSHIP' AND available_pallet.palletId IS NOT NULL") + .contains("CASE WHEN current_group.id IS NULL THEN eligible.palletId END palletId") + .contains("current_group.id groupId") + .contains("group_member_count.group_id=current_group.id") + .doesNotContain("fl_container_booking") + .doesNotContain("bookingId"); + assertThat(detailSql) + .contains("collection=\"units\"") + .contains("current_group.id=#{unit.groupId}") + .contains("o.tenant_id=#{unit.accountId}") + .contains("o.id=#{unit.inboundId}") + .contains("'WAIT_SPLIT' orderStatusSnapshot") + .contains("THEN 'LOADING' ELSE 'WAIT_MEASURE' END orderStatusSnapshot") + .doesNotContain("fl_container_booking"); + } + + @Test + void globalCandidateDateFiltersUseRepresentativeCreationAndExpectedArrivalDates() throws Exception { + Method units = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidateUnits", + Page.class, InboundCandidateQuery.class, Set.class); + String sql = String.join(" ", units.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("o.created_at createdAt") + .contains("MIN(eligible.createdAt)>=#{query.createdStart}") + .contains("MIN(eligible.createdAt)<DATE_ADD(#{query.createdEnd},INTERVAL 1 DAY)") + .contains("MIN(eligible.expectedArrivalAt)>=#{query.expectedArrivalStart}") + .contains("MIN(eligible.expectedArrivalAt)<DATE_ADD(#{query.expectedArrivalEnd},INTERVAL 1 DAY)"); + } + + @Test + void representativeCreationDateControlsWhetherTheWholeGroupMatchesTheClosedDateRange() { + List groupCreatedTimes = List.of( + LocalDateTime.parse("2026-09-01T09:00:00"), + LocalDateTime.parse("2026-09-10T09:00:00")); + + LocalDate representative = groupCreatedTimes.stream().min(LocalDateTime::compareTo) + .orElseThrow().toLocalDate(); + + assertThat(matchesClosedRange(representative, + LocalDate.parse("2026-09-10"), LocalDate.parse("2026-09-10"))) + .as("another member matching must not admit a group whose MIN creation date is outside") + .isFalse(); + assertThat(matchesClosedRange(representative, + LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-01"))) + .as("a matching MIN creation date admits the whole group") + .isTrue(); + } + + @Test + void globalCandidateDynamicSqlRendersForStandaloneAndGroupedUnits() throws Exception { + Configuration configuration = new Configuration(); + XMLLanguageDriver driver = new XMLLanguageDriver(); + Method units = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidateUnits", + Page.class, InboundCandidateQuery.class, Set.class); + String unitScript = String.join(" ", units.getAnnotation(Select.class).value()); + var query = new InboundCandidateQuery(List.of("MATSON_CLX"), 100L, 9L, "IN-", + java.time.LocalDate.parse("2026-09-01"), java.time.LocalDate.parse("2026-09-30"), + java.time.LocalDate.parse("2026-09-05"), java.time.LocalDate.parse("2026-10-05"), + List.of(com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus.WAIT_MEASURE, + com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus.WAIT_SPLIT), 1, 20); + String renderedUnits = driver.createSqlSource(configuration, unitScript, Map.class) + .getBoundSql(Map.of("query", query, "actualChannels", Set.of("MATSON_CLX_TRUCK"))).getSql(); + assertThat(renderedUnits) + .contains("o.channel_code IN") + .contains("MIN(eligible.createdAt)>=", "MIN(eligible.createdAt)=", "MIN(eligible.expectedArrivalAt) page = List.of( + new SmartLoadingCandidateMapper.CandidateUnit(null, 9L, 10L, null, 10L), + new SmartLoadingCandidateMapper.CandidateUnit(700L, null, null, null, 11L)); + String renderedDetails = driver.createSqlSource(configuration, detailScript, Map.class) + .getBoundSql(Map.of("units", page)).getSql(); + assertThat(renderedDetails).contains("current_group.id=?", "o.tenant_id=?", "o.id=?"); + } + + @Test + void workbenchContainerQueryExcludesCompletedAndMatchesRelatedInboundFacts() throws Exception { + Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("workbenchContainers", + Page.class, WorkbenchContainerQuery.class, Set.class); + String sql = String.join(" ", list.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("p.plan_status IN ('EDITING','LOADING')") + .contains("b.channel_group_code IN") + .contains("fl_loading_plan_item filter_item") + .contains("fl_inbound_order filter_inbound") + .contains("filter_item.inbound_code LIKE") + .contains("filter_inbound.created_at") + .doesNotContain("query.orderStatus", "orderStatuses") + .doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd") + .doesNotContain("p.plan_status IN ('EDITING','LOADING','COMPLETED')"); + } + + @Test + void detailUsesSameBatchRuleWithoutRestrictingTheCabinetWarehouse() throws Exception { + Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod( + "candidate", long.class, long.class, long.class); + String sql = String.join(" ", detail.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("o.destination_warehouse_id destinationWarehouseId") + .contains("o.order_status_snapshot orderStatusSnapshot") + .contains("o.expected_arrival_at expectedArrivalAt") + .contains("m.status='APPROVED'") + .contains("SELECT MAX(m2.id)") + .doesNotContain("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") + .contains("SELECT SUM(li.package_count)") + .contains("SUM(package_count) package_count") + .contains("GROUP BY tenant_id,measurement_id,inbound_detail_id") + .contains("fl_dropship_pallet_carton carton") + .contains("source.fulfillment_type='ONE_CLICK_DROPSHIP'") + .contains("i.inbound_id=o.id") + .contains("FOR UPDATE"); + + assertThat(sql.indexOf("NULL blockReason")) + .as("record constructor projection must keep status fields in their trailing positions") + .isLessThan(sql.indexOf("o.order_status_snapshot orderStatusSnapshot")); + } + + @Test + void channelContainerStatisticsCountOnlyActiveOrchestratableContainers() throws Exception { + Method method = SmartLoadingCandidateMapper.class.getDeclaredMethod( + "channelContainerCounts", long.class); + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("b.record_status='ACTIVE'") + .contains("b.warehouse_id=#{warehouseId}") + .contains("p.id IS NULL OR p.plan_status IN ('EDITING','LOADING')") + .contains("COUNT(DISTINCT b.id) availableContainerCount") + .contains("GROUP BY b.channel_code") + .doesNotContain("'COMPLETED'"); + } + + @Test + void planColoadSnapshotUsesOnePlanScopedBatchQuery() throws Exception { + Method method=SmartLoadingCandidateMapper.class.getDeclaredMethod("planColoadMembers",long.class); + String sql=String.join(" ",method.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("plan_item.loading_plan_id=#{planId}") + .contains("plan_item.deleted=0") + .contains("member.tenant_id=plan_item.account_id") + .contains("member.inbound_id=plan_item.inbound_id") + .contains("member.active_flag=1") + .contains("group_row.locked_plan_id=#{planId}") + .contains("group_row.group_status IN ('LOCKED','CONSUMED')") + .contains("SELECT DISTINCT") + .doesNotContain("WHERE member.inbound_id=#{inboundId}"); + } + + private static boolean matchesClosedRange(LocalDate arrival, LocalDate start, LocalDate end) { + return arrival != null && !arrival.isBefore(start) && !arrival.isAfter(end); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java new file mode 100644 index 000000000..36f5a9d45 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -0,0 +1,412 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.DisplayName; +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.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("智能装柜SQL契约") +class SmartLoadingSqlContractTest { + private static final Pattern PERMISSION_PATTERN = Pattern.compile("'((?:platform:firstleg:)[^']+)'"); + private static final Set PHASE3_PERMISSIONS = Set.of( + "platform:firstleg:container:list", + "platform:firstleg:container:create", "platform:firstleg:container:edit", + "platform:firstleg:smart-loading:list", + "platform:firstleg:smart-loading:assign", "platform:firstleg:smart-loading:delete", + "platform:firstleg:preload:list", + "platform:firstleg:preload:edit", + "platform:firstleg:preload:complete", "platform:firstleg:preload:confirm", + "platform:firstleg:loading-list:list"); + + @Test + @DisplayName("平台聚合与客户明细边界及原型快照字段完整") + void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception { + assertThat(migration()) + .contains("CREATE TABLE IF NOT EXISTS fl_document_sequence") + .contains("CREATE TABLE IF NOT EXISTS fl_container_booking") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_plan") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item") + .contains("CREATE TABLE IF NOT EXISTS fl_container_document") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_history") + .contains("account_id BIGINT UNSIGNED NOT NULL") + .contains("customer_name VARCHAR(128) NOT NULL") + .contains("warehouse_id BIGINT UNSIGNED NOT NULL") + .contains("cabinet_no VARCHAR(64) NOT NULL") + .contains("bill_of_lading_no VARCHAR(128)") + .contains("destination_warehouse_name VARCHAR(128)") + .contains("product_name_zh VARCHAR(255)") + .contains("product_name_en VARCHAR(255)") + .contains("product_image_file_id BIGINT UNSIGNED") + .contains("pallet_count INT UNSIGNED") + .contains("net_weight_kg DECIMAL(18,3)") + .contains("dimensional_weight_kg DECIMAL(18,3)") + .contains("product_quantity INT UNSIGNED") + .contains("units_per_carton INT UNSIGNED") + .contains("fba_tracking_no VARCHAR(128)") + .contains("shipment_no VARCHAR(128)") + .contains("shipping_mark VARCHAR(500)") + .contains("internal_remark VARCHAR(1000)") + .contains("customer_remark VARCHAR(1000)") + .contains("warehouse_remark VARCHAR(1000)"); + } + + @Test + @DisplayName("活动明细唯一锁、乐观锁和操作幂等字段完整") + void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception { + assertContainerLifecycleSchema(migration()); + assertContainerLifecycleSchema(unifiedMigration()); + assertThat(migration()) + .contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS") + .contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END") + .contains("active_assignment_key VARCHAR(96) GENERATED ALWAYS AS") + .contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key)") + .contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)") + .contains("UNIQUE KEY uk_container_booking_no (booking_no)") + .contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)") + .contains("UNIQUE KEY uk_loading_history_operation (operation_id)") + .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 { + String mapper = Files.readString(Path.of( + "src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java"), StandardCharsets.UTF_8); + assertThat(mapper) + .contains("SUM(package_count) package_count") + .contains("GROUP BY tenant_id,measurement_id,inbound_detail_id") + .contains("split_item.source_inbound_detail_id=i.inbound_detail_id") + .doesNotContain("split_item.source_measurement_item_id=i.id"); + } + + @Test + @DisplayName("全局候选分页具备平台维度和关联明细索引") + void shouldProvideGlobalCandidatePagingIndexes() throws Exception { + assertThat(new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8)) + .contains("idx_inbound_smart_loading (warehouse_id, process_status, channel_code, expected_arrival_at, id)") + .contains("idx_measure_inbound_status_id (tenant_id, inbound_id, status, id)") + .contains("idx_measure_item_candidate (tenant_id, measurement_id, inbound_detail_id)"); + assertThat(migration()) + .contains("idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted)"); + String unified = unifiedMigration(); + assertThat(unified) + .contains("CREATE PROCEDURE baiyue_firstleg.ensure_smart_loading_candidate_indexes()") + .contains("ADD KEY idx_inbound_smart_loading") + .contains("ADD KEY idx_measure_inbound_status_id") + .contains("ADD KEY idx_measure_item_candidate") + .contains("ADD KEY idx_loading_item_candidate") + .contains("ADD KEY idx_inbound_split_candidate"); + } + + @Test + @DisplayName("可编辑备注使用计划先行与明细单表CAS并允许已拣明细仅改备注") + void shouldUpdateEditableRemarksWithoutMultiTableTriggerConflict() throws Exception { + String mapper = Files.readString(Path.of( + "src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java"), StandardCharsets.UTF_8); + assertThat(mapper) + .contains("UPDATE fl_loading_plan_item i") + .contains("p.version=#{touchedPlanVersion}") + .contains("p.plan_status IN ('EDITING','LOADING')") + .contains("i.frozen_at IS NULL") + .doesNotContain("UPDATE fl_loading_plan_item i JOIN fl_loading_plan p") + .doesNotContain("p.version=p.version+1"); + assertEditableRemarksTriggerContract(migration()); + assertEditableRemarksTriggerContract(unifiedMigration()); + } + + private static void assertContainerLifecycleSchema(String sql) { + assertThat(sql) + .contains("source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION'") + .contains("record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'") + .contains("merged_into_container_booking_id BIGINT UNSIGNED NULL") + .contains("UNIQUE KEY uk_container_booking_container_no (container_no)") + .contains("KEY idx_container_booking_merge (record_status,merged_into_container_booking_id)") + .contains("CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING'))") + .contains("CHECK (record_status IN ('ACTIVE','MERGED'))") + .contains("column_name='source_type'") + .contains("column_name='record_status'") + .contains("column_name='merged_into_container_booking_id'"); + } + + private static void assertEditableRemarksTriggerContract(String sql) { + assertThat(sql) + .contains("IF NOT (NEW.internal_remark <=> OLD.internal_remark)") + .contains("OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)") + .contains("OR NOT (NEW.customer_service_remark <=> OLD.customer_service_remark) THEN") + .contains("IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted") + .contains("已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段") + .contains("ELSEIF NOT EXISTS (") + .contains("IF OLD.frozen_at IS NOT NULL THEN") + .contains("装柜完成后的明细永久冻结"); + } + + @Test + @DisplayName("货柜简单文件只保留目的港清关与DO,PU改为数字码") + void shouldAllowExactlyTwoSimpleContainerDocumentTypes() throws Exception { + assertThat(migration()) + .contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER'") + .contains("pickup_code VARCHAR(64) NULL") + .contains("file_hash VARCHAR(64) NULL") + .contains("completed_by BIGINT UNSIGNED NULL") + .contains("completed_at DATETIME(3) NULL"); + assertThat(unifiedMigration()) + .contains("pickup_code VARCHAR(64) NULL") + .contains("pickup_code_updated_by BIGINT UNSIGNED NULL") + .contains("pickup_code_updated_by_name VARCHAR(64) NULL") + .contains("pickup_code_updated_at DATETIME(3) NULL") + .contains("chk_container_booking_pickup_code") + .contains("CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$')"); + } + + @Test + @DisplayName("平台菜单按钮权限使用统一前缀并授权运营角色") + void shouldProvidePlatformPermissions() throws Exception { + assertThat(migration()) + .contains("platform:firstleg:container:view") + .doesNotContain("'platform:firstleg:smart-loading:push', '推送预装'") + .contains("platform:firstleg:preload:confirm") + .contains("platform:firstleg:loading-list:view") + .contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')") + .contains("智能装柜迁移失败:平台权限资源未完整创建") + .contains("智能装柜迁移失败:平台角色授权未完整写入"); + } + + @Test + @DisplayName("模块脚本和统一脚本均校验11项权限与两个角色22项授权") + void shouldAssertCompletePermissionGrants() throws Exception { + assertPermissionContract(migration()); + assertPermissionContract(unifiedMigration()); + assertThat(unifiedMigration()) + .contains("CALL migrate_legacy_loading_pick_task()") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task") + .contains("5102121, 0, 'PLATFORM'") + .contains("5102129, 0, 'PLATFORM'") + .contains("三期入库装柜角色授权未完整写入") + .contains("permission.permission_code IN (") + .contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')") + .contains("'firstleg:inbound:split', 'firstleg:inbound:preload'") + .contains(")) <> 18 THEN"); + } + + @Test + @DisplayName("计划状态只允许编辑中、装柜中和已完成单向推进") + void shouldEnforceOneWayStatusAndPermanentFreeze() throws Exception { + assertThat(migration()) + .contains("CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))") + .contains("OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')") + .contains("NEW.plan_status IN ('LOADING', 'COMPLETED')") + .contains("OLD.plan_status = 'LOADING' AND NEW.plan_status = 'EDITING'") + .doesNotContain("旧版本在推送预装时即写入 LOADING") + .doesNotContain("修复旧版本“推送预装即拣货中”的状态污染") + .contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'") + .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拣货记录的装柜明细仅允许收敛部分拣货实际箱数") + .contains("装柜完成后的明细永久冻结") + .contains("current_plan_status IN ('LOADING', 'COMPLETED')") + .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") + .doesNotContain("NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)") + .doesNotContain("NOT (OLD.vessel_name <=> NEW.vessel_name)") + .doesNotContain("NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)") + .doesNotContain("preload_status") + .doesNotContain("PRELOAD_PUSHED") + .doesNotContain("PRELOADING") + .doesNotContain("PRELOAD_COMPLETED") + .doesNotContain("CONFIRMED") + .doesNotContain("ROLLED_BACK") + .doesNotContain("WITHDRAWN"); + assertLoadingCompletionTrigger(migration()); + assertLoadingCompletionTrigger(unifiedMigration()); + assertThat(unifiedMigration().indexOf("CALL phase3_add_container_channel_group()")) + .isLessThan(unifiedMigration().indexOf("CREATE TRIGGER trg_container_booking_before_update")); + } + + private static void assertLoadingCompletionTrigger(String sql) { + int triggerStart = sql.indexOf("CREATE TRIGGER trg_loading_plan_before_update"); + int triggerEnd = sql.indexOf("DROP TRIGGER IF EXISTS trg_loading_plan_after_update", triggerStart); + assertThat(triggerStart).isGreaterThanOrEqualTo(0); + assertThat(triggerEnd).isGreaterThan(triggerStart); + String beforeUpdateTrigger = sql.substring(triggerStart, triggerEnd); + assertThat(beforeUpdateTrigger) + .contains("AND NOT EXISTS (") + .contains("pick_task.loading_plan_id = OLD.id") + .doesNotContain("pick_task.task_status") + .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 + @DisplayName("PDA三表、唯一索引、向上取整和不可变审计契约完整") + void shouldProvidePdaContract() throws Exception { + assertThat(migration()) + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task") + .contains("CALL migrate_legacy_loading_pick_task()") + .contains("source_total_columns = 13 AND source_current_columns = 13") + .contains("index_name = 'uk_loading_pick_task_booking'") + .contains("RENAME TABLE fl_loading_pick_task TO fl_inbound_loading_pick_task") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_history") + .contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)") + .contains("UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id)") + .contains("UNIQUE KEY uk_loading_pick_history_operation (operation_id)") + .contains("上层拆分按业务规则向上取整") + .contains("CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0)") + .contains("CALL repair_phase3_loading_pick_task_foreign_keys()") + .contains("GROUP_CONCAT(CONCAT('DROP FOREIGN KEY `'") + .contains("item_fk_count <> 1 OR item_target_count <> 1") + .contains("history_fk_count <> 1 OR history_target_count <> 1") + .contains("FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id)") + .contains("referenced_column_name = 'id'") + .contains("柜次PDA明细任务外键不唯一或指向错误") + .contains("柜次PDA历史任务外键不唯一或指向错误") + .doesNotContain("DROP FOREIGN KEY fk_loading_pick_item_task") + .doesNotContain("DROP FOREIGN KEY fk_loading_pick_history_task") + .contains("trg_loading_pick_task_before_update") + .contains("trg_loading_pick_item_before_update") + .contains("trg_loading_pick_item_before_delete") + .contains("trg_loading_pick_history_before_update") + .contains("trg_loading_pick_history_before_delete") + .contains("PDA唯一索引缺失") + .contains(")) <> 16 THEN"); + } + + @Test + @DisplayName("统一脚本只定义并调用一次智能装柜断言") + void shouldDefineSmartLoadingAssertionOnce() throws Exception { + String unified = unifiedMigration(); + assertThat(count(unified, "CREATE PROCEDURE assert_phase3_smart_loading_schema()")) + .isEqualTo(1); + assertThat(count(unified, "CALL assert_phase3_smart_loading_schema();")) + .isEqualTo(1); + assertThat(count(unified, "SELECT 'PASS' AS migration_status")) + .isEqualTo(1); + int relocation = unified.indexOf("CALL unified_relocate_inbound_loading_permissions();"); + int assertion = unified.indexOf("CALL assert_phase3_smart_loading_schema();"); + assertThat(relocation).isGreaterThan(0); + assertThat(assertion).isGreaterThan(relocation); + assertThat(unified.substring(relocation, assertion)) + .contains("tmp_phase3_inbound_role_grant") + .contains("DROP TEMPORARY TABLE IF EXISTS tmp_phase3_inbound_role_grant") + .contains("5102121, 0, 'PLATFORM'") + .contains("5102129, 0, 'PLATFORM'") + .contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')"); + } + + @Test + @DisplayName("详细拣货和入库汇总拣货使用独立完整表型") + void shouldKeepDetailedAndInboundPickTaskShapesSeparate() throws Exception { + String unified = unifiedMigration(); + assertThat(unified) + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task (") + .contains("loading_plan_id BIGINT UNSIGNED NOT NULL") + .contains("container_booking_id BIGINT UNSIGNED NOT NULL") + .contains("cabinet_no VARCHAR(64) NOT NULL") + .contains("container_no VARCHAR(64) NULL") + .contains("task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING'") + .contains("column_name = 'task_status' AND LOWER(column_type) = 'varchar(24)'") + .contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)") + .contains("UNIQUE KEY uk_loading_pick_task_booking (container_booking_id)") + .contains("KEY idx_loading_pick_container (container_no, task_status, started_at)") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task (") + .contains("tenant_id BIGINT UNSIGNED NOT NULL") + .contains("inbound_id BIGINT UNSIGNED NOT NULL") + .contains("expected_count INT UNSIGNED NULL") + .contains("picked_count INT UNSIGNED NULL") + .contains("UNIQUE KEY uk_loading_pick_tenant_inbound (tenant_id, inbound_id)") + .contains("KEY idx_loading_pick_status (tenant_id, status, updated_at)"); + } + + @Test + @DisplayName("迁移拒绝缺列错类型和混合拣货表型") + void shouldRejectMalformedLegacyPickTaskShapes() throws Exception { + assertMalformedShapeGuards(migration()); + assertMalformedShapeGuards(unifiedMigration()); + } + + private static String migration() throws Exception { + return new ClassPathResource("db/phase3-smart-loading-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static String unifiedMigration() throws Exception { + Path working = Path.of("").toAbsolutePath(); + Path file = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(file)) { + file = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + return Files.readString(file, StandardCharsets.UTF_8); + } + + private static void assertPermissionContract(String sql) { + int message = sql.indexOf("智能装柜迁移失败:平台角色授权未完整写入"); + assertThat(message).isGreaterThan(0); + + int grantStart = sql.lastIndexOf("INSERT IGNORE INTO iam_role_permission", message); + int grantEnd = sql.indexOf(';', grantStart); + assertThat(permissions(sql.substring(grantStart, grantEnd))).isEqualTo(PHASE3_PERMISSIONS); + + int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message); + String assertion = sql.substring(assertionStart, message); + assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS); + assertThat(assertion).contains("<> 22 THEN"); + } + + private static Set permissions(String sql) { + Matcher matcher = PERMISSION_PATTERN.matcher(sql); + return matcher.results().map(result -> result.group(1)).collect(Collectors.toSet()); + } + + private static void assertMalformedShapeGuards(String sql) { + assertThat(sql) + .containsAnyOf("source_total_columns = 11", "source_total = 11") + .containsAnyOf("source_smart_columns = 11", "source_smart = 11") + .containsAnyOf("source_total_columns = 12", "source_total = 12") + .containsAnyOf("source_inbound_columns = 12", "source_inbound = 12") + .contains("column_name = 'version'") + .contains("column_name = 'remark'") + .contains("column_name = 'updated_by'") + .contains("column_name = 'updated_by_name'") + .contains("column_name = 'created_at'") + .contains("column_name = 'updated_at'") + .contains("LOWER(column_type) = 'int'") + .contains("LOWER(column_type) = 'int unsigned'") + .contains("LOWER(extra) LIKE '%on update current_timestamp%'") + .contains("uk_loading_pick_tenant_inbound") + .contains("idx_loading_pick_status") + .contains("referenced_table_name = 'fl_inbound_order'") + .contains("字段签名未知或混合"); + } + + private static int count(String value, String token) { + return (value.length() - value.replace(token, "").length()) / token.length(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TenantScopedMutationRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TenantScopedMutationRepositoryTest.java new file mode 100644 index 000000000..03f7d1c7c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TenantScopedMutationRepositoryTest.java @@ -0,0 +1,161 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.AbstractWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.firstleg.entity.FeeItemEntity; +import com.baiyue.firstleg.entity.FulfillmentTaskEntity; +import com.baiyue.firstleg.entity.InboundFeeDetailEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; +import com.baiyue.firstleg.mapper.AssignmentHistoryMapper; +import com.baiyue.firstleg.mapper.FeeItemMapper; +import com.baiyue.firstleg.mapper.FulfillmentTaskMapper; +import com.baiyue.firstleg.mapper.InboundFeeDetailMapper; +import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper; +import com.baiyue.firstleg.mapper.InboundMeasurementMapper; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import com.baiyue.firstleg.mapper.InboundWeightResultMapper; +import com.baiyue.firstleg.mapper.InboundWeightRevisionMapper; +import com.baiyue.firstleg.mapper.MeasureReviewRecordMapper; +import jakarta.annotation.Resource; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig({FulfillmentTaskRepository.class, InboundMeasurementRepository.class, + FeeItemRepository.class}) +class TenantScopedMutationRepositoryTest { + private static final long TENANT = 10001L; + private static final long ID = 88L; + + @MockBean private FulfillmentTaskMapper tasks; + @MockBean private AssignmentHistoryMapper histories; + @MockBean private InboundOrderMapper inboundOrders; + @MockBean private InboundMeasurementMapper measurements; + @MockBean private InboundMeasurementItemMapper measurementItems; + @MockBean private InboundWeightResultMapper weights; + @MockBean private InboundWeightRevisionMapper revisions; + @MockBean private MeasureReviewRecordMapper reviews; + @MockBean private FeeItemMapper feeItems; + @MockBean private InboundFeeDetailMapper feeDetails; + @Resource private FulfillmentTaskRepository fulfillmentRepository; + @Resource private InboundMeasurementRepository measurementRepository; + @Resource private FeeItemRepository feeRepository; + + @BeforeAll + static void initializeMetadata() { + initialize(FulfillmentTaskEntity.class); + initialize(InboundWeightResultEntity.class); + initialize(InboundOrderEntity.class); + initialize(FeeItemEntity.class); + initialize(InboundFeeDetailEntity.class); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void fulfillmentUpdateMatchesTenantAndId() { + FulfillmentTaskEntity task = new FulfillmentTaskEntity(); + task.setTenantId(TENANT); + task.setId(ID); + when(tasks.update(any(), any(Wrapper.class))).thenReturn(1); + + assertThat(fulfillmentRepository.update(task)).isTrue(); + + ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); + verify(tasks).update(eq(task), wrapper.capture()); + assertTenantAndId(wrapper.getValue()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void measurementUpdateMatchesTenantAndId() { + InboundWeightResultEntity weight = new InboundWeightResultEntity(); + weight.setTenantId(TENANT); + weight.setId(ID); + when(weights.update(any(), any(Wrapper.class))).thenReturn(1); + + assertThat(measurementRepository.updateWeight(weight)).isTrue(); + + ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); + verify(weights).update(eq(weight), wrapper.capture()); + assertTenantAndId(wrapper.getValue()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void weightSummaryUpdateExplicitlyClearsNullGrossWeight() { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setTenantId(TENANT); + inbound.setId(ID); + inbound.setActualGrossWeightKg(null); + when(inboundOrders.update(any(), any(Wrapper.class))).thenReturn(1); + + assertThat(measurementRepository.updateInboundWeightSummary(inbound)).isTrue(); + + ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); + verify(inboundOrders).update(eq(inbound), wrapper.capture()); + assertTenantAndId(wrapper.getValue()); + assertThat(((LambdaUpdateWrapper) wrapper.getValue()).getSqlSet()) + .contains("actual_gross_weight_kg") + .contains("actual_net_weight_kg") + .contains("pallet_count"); + } + + @Test + void feeDetailDeleteMatchesTenantAndId() { + when(feeDetails.logicallyDelete(TENANT, 77L, ID)).thenReturn(1); + + assertThat(feeRepository.deleteDetail(TENANT, 77L, ID)).isEqualTo(1); + + verify(feeDetails).logicallyDelete(TENANT, 77L, ID); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void feeItemLookupIsAlwaysPlatformScoped() { + feeRepository.findItem(ID); + + ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); + verify(feeItems).selectOne(wrapper.capture()); + assertThat(wrapper.getValue().getCustomSqlSegment()).contains("tenant_id"); + assertThat(((AbstractWrapper) wrapper.getValue()).getParamNameValuePairs().values()) + .contains(0L, ID); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void feeItemBindingCountChecksAllCustomers() { + feeRepository.countBindings(ID); + + ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); + verify(feeDetails).selectCount(wrapper.capture()); + assertThat(wrapper.getValue().getCustomSqlSegment()).doesNotContain("tenant_id"); + assertThat(((AbstractWrapper) wrapper.getValue()).getParamNameValuePairs().values()) + .contains(ID); + } + + private static void initialize(Class entityType) { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), entityType); + } + + private static void assertTenantAndId(Wrapper wrapper) { + assertThat(wrapper.getCustomSqlSegment()) + .contains("tenant_id") + .containsPattern("(?i)(^|\\W)id\\s*="); + assertThat(((AbstractWrapper) wrapper).getParamNameValuePairs().values()) + .contains(TENANT, ID); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java new file mode 100644 index 000000000..07c9cada4 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java @@ -0,0 +1,43 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.entity.TruckAppointmentEntity; +import com.baiyue.firstleg.mapper.TruckAppointmentMapper; +import com.baomidou.mybatisplus.annotation.TableName; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TruckAppointmentMapperContractTest { + @Test void mapsToPhase3TableAndUsesOptimisticGuards() throws Exception { + assertThat(TruckAppointmentEntity.class.getAnnotation(TableName.class).value()).isEqualTo("fl_truck_appointment"); + String page = TruckAppointmentMapper.class.getMethod("page", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.Query.class).getAnnotation(Select.class).value()[0]; + assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "ocean_booking_id=#{q.oceanBookingId}", + "fl_ocean_booking", "fl_truck_appointment_attachment", "attachment_count") + .doesNotContain("fl_container_booking", "cabinet_no", "container_no"); + assertThat(page).doesNotContain("customer_id", "customerId", "customer_name"); + String candidates = TruckAppointmentMapper.class.getMethod("candidates", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery.class).getAnnotation(Select.class).value()[0]; + assertThat(candidates).contains("FROM fl_ocean_booking o", "o.status='RELEASED'", "d.valid=1", + "'BOOKING_CONFIRMATION','BILL_OF_LADING'", "t.ocean_booking_id=o.id", + "t.status NOT IN ('CANCELLED','EXCEPTION')", "bookingDocumentReady"); + assertThat(candidates).doesNotContain("fl_ocean_booking_container_allocation", "fl_container_booking", + "loadingPlanId", "containerBookingId", "cabinetNo", "containerNo"); + String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0]; + assertThat(transition).contains("status=#{toStatus}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1"); + String dispatch = TruckAppointmentMapper.class.getMethod("dispatch", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0]; + assertThat(dispatch).contains("status='DISPATCHED'", "bill_of_lading_no=#{billOfLadingNo}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1"); + String firstActive = TruckAppointmentMapper.class.getMethod("firstActiveForUpdate", long.class).getAnnotation(Select.class).value()[0]; + assertThat(firstActive).contains("ocean_booking_id=#{oceanBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE") + .contains("'CANCELLED','EXCEPTION'"); + String reschedule = TruckAppointmentMapper.class.getMethod("reschedule", long.class, java.time.LocalDateTime.class, String.class, int.class).getAnnotation(Update.class).value()[0]; + assertThat(reschedule).contains("status='RESCHEDULED'", "version=#{version}", "revision=revision+1"); + String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value()[0]; + assertThat(edit).contains("ocean_booking_id=#{e.oceanBookingId}", "carrier_id=#{e.carrierId}", + "planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}", + "version=version+1", "revision=revision+1"); + assertThat(edit).doesNotContain("container_booking_id", "loading_plan_id"); + assertThat(edit).doesNotContain("customer_id", "customer_name", "customerId", "customerName"); + assertThat(edit).doesNotContain("receivable_amount", "receivableAmount"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportRepositoryTest.java new file mode 100644 index 000000000..0252bdeea --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportRepositoryTest.java @@ -0,0 +1,71 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.CurrentContainerTransport; +import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +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.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.anyList; + +class UnifiedInboundTransportRepositoryTest { + private final UnifiedInboundTransportMapper mapper = mock(UnifiedInboundTransportMapper.class); + private final UnifiedInboundReadRepository repository = repository(); + + @Test + void batchKeepsParentChildAndDepartedUnitOnTheirOwnContainer() { + var oldDeparted = fact(11L, 101L, 501L, "OLD", null, LocalDateTime.parse("2026-08-01T08:00")); + var splitChild = fact(12L, 101L, 502L, "NEW", LocalDateTime.parse("2026-08-20T08:00"), null); + when(mapper.findCurrentByInboundIds(anyList())).thenReturn(List.of(oldDeparted, splitChild)); + + var result = repository.findTransports(Map.of(11L, 101L, 12L, 101L)); + + assertThat(result.get(11L).containerBookingId()).isEqualTo(501L); + assertThat(result.get(11L).actualSailingAt()).isEqualTo(LocalDateTime.parse("2026-08-01T08:00")); + assertThat(result.get(12L).containerBookingId()).isEqualTo(502L); + assertThat(result.get(12L).estimatedSailingAt()).isEqualTo(LocalDateTime.parse("2026-08-20T08:00")); + verify(mapper).findCurrentByInboundIds(anyList()); + } + + @Test + void noContainerReturnsNoFact() { + when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of()); + assertThat(repository.findTransports(Map.of(11L, 101L))).isEmpty(); + } + + @Test + void multipleEffectiveContainersAreAnInvariantConflict() { + when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of( + fact(11L, 101L, 501L, "A", null, null), fact(11L, 101L, 502L, "B", null, null))); + assertThatThrownBy(() -> repository.findTransports(Map.of(11L, 101L))) + .isInstanceOf(BizException.class).hasMessageContaining("多个有效柜次"); + } + + @Test + void filtersRowsOutsideInboundTenantOwnership() { + when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of( + fact(11L, 999L, 501L, "OTHER", null, null))); + assertThat(repository.findTransports(Map.of(11L, 101L))).isEmpty(); + } + + private UnifiedInboundReadRepository repository() { + var value = new UnifiedInboundReadRepository(); + ReflectionTestUtils.setField(value, "transports", mapper); + return value; + } + + private CurrentContainerTransport fact(Long inboundId, Long accountId, Long bookingId, String containerNo, + LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt) { + return new CurrentContainerTransport(inboundId, accountId, bookingId, containerNo, "CAB", "VESSEL", "V1", + estimatedSailingAt, actualSailingAt, null, null); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportSqlContractTest.java new file mode 100644 index 000000000..b3fbc7bca --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportSqlContractTest.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UnifiedInboundTransportSqlContractTest { + @Test + void queriesCurrentBusinessUnitContainersInOneBatchFromAuthoritativeBooking() throws Exception { + String sql = String.join(" ", UnifiedInboundTransportMapper.class + .getMethod("findCurrentByInboundIds", java.util.List.class).getAnnotation(Select.class).value()); + assertThat(sql).contains("fl_loading_plan_item", "i.deleted = 0", + "p.plan_status IN ('LOADING', 'COMPLETED')", "fl_container_booking", + "estimated_sailing_at", "actual_sailing_at", "estimated_port_arrival_at", + "actual_port_arrival_at", " { + claims[0]++; + return true; + }); + + assertThatThrownBy(() -> authenticator.authenticate("POST", "/openapi/v1/inbound-measurements", + new MeasurementRequestHeaders("device-provider", String.valueOf(NOW.toEpochMilli()), + "nonce-1", "00", "idem-1"), "{}", true)) + .isInstanceOf(BizException.class) + .satisfies(exception -> assertThat(((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.UNAUTHORIZED)); + assertThat(claims[0]).isZero(); + } + + @Test + @DisplayName("相同应用重复Nonce被拒绝") + void shouldRejectReplayedNonce() { + when(guard.claim(any(), any(), any())).thenReturn(false); + String timestamp = String.valueOf(NOW.toEpochMilli()); + String body = "{}"; + String canonical = MeasurementRequestAuthenticator.canonicalString("POST", + "/openapi/v1/inbound-measurements", timestamp, "nonce-1", "idem-1", + MeasurementRequestAuthenticator.sha256Hex(body)); + + assertThatThrownBy(() -> authenticator.authenticate("POST", "/openapi/v1/inbound-measurements", + new MeasurementRequestHeaders("device-provider", timestamp, "nonce-1", + MeasurementRequestAuthenticator.hmacHex(SECRET, canonical), "idem-1"), body, true)) + .isInstanceOf(BizException.class) + .satisfies(exception -> assertThat(((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.CONFLICT)); + } + + @Test + @DisplayName("联调关闭签名时只用固定AppKey和幂等键认证且不占用Nonce") + void shouldAcceptFixedAppKeyWithoutSignatureDuringIntegration() { + properties.setSignatureRequired(false); + properties.getApps().get("device-provider").setSecret(null); + + AuthenticatedMeasurementApp app = authenticator.authenticate("POST", + "/openapi/v1/inbound-measurements", + new MeasurementRequestHeaders("device-provider", null, null, null, "idem-1"), + "{}", true); + + assertThat(app.tenantId()).isEqualTo(10001L); + assertThat(app.subjectId()).isEqualTo(90001L); + verifyNoInteractions(guard); + } + + @Test + @DisplayName("关闭签名仍强制上传请求携带幂等键") + void shouldStillRequireIdempotencyKeyWithoutSignature() { + properties.setSignatureRequired(false); + + assertThatThrownBy(() -> authenticator.authenticate("POST", + "/openapi/v1/inbound-measurements", + new MeasurementRequestHeaders("device-provider", null, null, null, null), "{}", true)) + .isInstanceOf(BizException.class) + .hasMessageContaining("Idempotency-Key"); + } + + @Configuration + static class TestConfig { + @Bean + MeasurementOpenApiProperties properties() { + return new MeasurementOpenApiProperties(); + } + + @Bean + Clock clock() { + return Clock.fixed(NOW, ZoneOffset.UTC); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java new file mode 100644 index 000000000..09452d6af --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java @@ -0,0 +1,401 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.MeasurementImageClient; +import com.baiyue.firstleg.dto.CartonMeasurementCommand; +import com.baiyue.firstleg.dto.CartonMeasurementResult; +import com.baiyue.firstleg.domain.CustomerDimensionBoxType; +import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; +import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; +import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +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; +import static org.mockito.Mockito.mock; + +class CartonMeasurementServiceTest { + private final InboundMeasurementRepository repository = mock(InboundMeasurementRepository.class); + @SuppressWarnings("unchecked") + private final ObjectProvider resolverProvider = mock(ObjectProvider.class); + private final CartonLabelBindingResolver resolver = mock(CartonLabelBindingResolver.class); + private final MeasurementImageClient images = mock(MeasurementImageClient.class); + private final TransactionTemplate transactions = mock(TransactionTemplate.class); + private final CustomerDimensionConfigRepository dimensionConfigs = mock(CustomerDimensionConfigRepository.class); + private final CustomerDimensionWeightingService dimensionWeighting = + new CustomerDimensionWeightingService(dimensionConfigs); + private CartonMeasurementService service; + private final AtomicReference insertedItem = new AtomicReference<>(); + + @BeforeEach + void setUp() { + service = new CartonMeasurementService(repository, resolverProvider, images, transactions, + dimensionWeighting); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + when(resolverProvider.getIfAvailable()).thenReturn(resolver); + when(resolver.resolveByBoxCode(10001L, "RK-001#5-1")) + .thenReturn(new CartonLabelBindingResolver.Binding(70001L, 80001L, "RK-001", 1, 5, "50x40x30cm")); + when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("WAIT_MEASURE")); + when(dimensionConfigs.findAll(10001L)).thenReturn(dimensionRules(10001L)); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound("WAIT_MEASURE")); + when(images.upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any())) + .thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult( + 99001L, "measure.jpg", "image/jpeg", 10L, "NOT_REQUIRED", null, null, null, null))); + when(transactions.execute(any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(mock(org.springframework.transaction.TransactionStatus.class)); + }); + doAnswer(invocation -> { + insertedItem.set(invocation.getArgument(0)); + return null; + }).when(repository).insertItem(any()); + } + + @Test + void firstScanCreatesOneRecordWithConfirmedCountOne() { + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0); + when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null); + when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement); + when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get())); + when(repository.updateMeasurement(measurement)).thenReturn(true); + + CartonMeasurementResult result = service.upsert(10001L, 90001L, acceptanceCommand()); + + assertThat(result.created()).isTrue(); + assertThat(result.confirmedPackageCount()).isEqualTo(1); + assertThat(result.boxCode()).isEqualTo("RK-001#5-1"); + assertThat(result.scannedShipmentId()).isEqualTo("FBA-SCAN-002"); + assertThat(insertedItem.get().getScannedShipmentId()).isEqualTo("FBA-SCAN-002"); + assertThat(insertedItem.get().getMeasurementId()).isEqualTo(30001L); + assertThat(insertedItem.get().getTotalVolumeCbm()).isEqualByComparingTo("0.1010"); + assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("63.500"); + assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("45.200"); + assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("35.300"); + assertThat(measurement.getSourceType()).isEqualTo(MeasurementBatchService.SOURCE_THIRD_PARTY); + assertThat(measurement.getSourceCode()).isEqualTo("DEVICE"); + } + + @Test + void manualEntryCreatesManualMeasurementWithoutChangingDeviceEntrySemantics() { + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0); + when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null); + when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement); + when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get())); + when(repository.updateMeasurement(measurement)).thenReturn(true); + + CartonMeasurementResult result = service.upsertManualForInbound( + 10001L, 90001L, 70001L, acceptanceCommand()); + + assertThat(result.created()).isTrue(); + assertThat(measurement.getSourceType()).isEqualTo(MeasurementBatchService.SOURCE_MANUAL); + assertThat(measurement.getSourceCode()).isEqualTo("PLATFORM"); + } + + @Test + void measuringInboundWithoutMeasurementAllowsManualEntryBeforeSubmission() { + InboundOrderEntity measuring = inbound("MEASURING"); + when(repository.findInbound(10001L, 70001L)).thenReturn(measuring); + when(repository.lockInbound(10001L, 70001L)).thenReturn(measuring); + when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null); + when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(null); + when(repository.listItems(org.mockito.ArgumentMatchers.eq(10001L), + org.mockito.ArgumentMatchers.anyLong())).thenAnswer(invocation -> List.of(insertedItem.get())); + when(repository.updateMeasurement(any())).thenReturn(true); + + CartonMeasurementResult result = service.upsertManualForInbound( + 10001L, 90001L, 70001L, acceptanceCommand()); + + ArgumentCaptor measurement = + ArgumentCaptor.forClass(InboundMeasurementEntity.class); + verify(repository).insertMeasurement(measurement.capture()); + assertThat(result.created()).isTrue(); + assertThat(result.measurementId()).isEqualTo(measurement.getValue().getId()); + assertThat(measurement.getValue().getSourceType()).isEqualTo(MeasurementBatchService.SOURCE_MANUAL); + assertThat(measurement.getValue().getSourceCode()).isEqualTo("PLATFORM"); + assertThat(measurement.getValue().getStatus()).isEqualTo(MeasurementBatchService.STATUS_RECEIVED); + assertThat(insertedItem.get().getMeasurementId()).isEqualTo(measurement.getValue().getId()); + verify(images).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any()); + } + + @Test + void repeatedScanReplacesDeviceValuesButPreservesManualCountAndOldImageIsDeleted() { + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 4); + InboundMeasurementItemEntity existing = item(40001L, 30001L, 4, 88001L); + when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(existing); + when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement); + when(repository.updateItem(existing)).thenReturn(true); + when(repository.listItems(10001L, 30001L)).thenReturn(List.of(existing)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + + CartonMeasurementResult result = service.upsert(10001L, 90001L, acceptanceCommand()); + + assertThat(result.created()).isFalse(); + assertThat(result.confirmedPackageCount()).isEqualTo(4); + assertThat(existing.getLengthCm()).isEqualByComparingTo("59.500"); + assertThat(existing.getUnitVolumeCbm()).isEqualByComparingTo("0.101"); + assertThat(existing.getTotalVolumeCbm()).isEqualByComparingTo("0.4040"); + assertThat(existing.getVolumeWeightKg()).isEqualByComparingTo("67.468"); + assertThat(existing.getSourceImageFileId()).isEqualTo(99001L); + assertThat(existing.getScannedShipmentId()).isEqualTo("FBA-SCAN-002"); + verify(images).delete("internal-token", 90001L, 10001L, 70001L, 88001L); + } + + @Test + void scanOutsideConfiguredWeightingRulesKeepsImageAndUsesActualDimensions() { + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0); + when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null); + when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement); + when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get())); + when(repository.updateMeasurement(measurement)).thenReturn(true); + CartonMeasurementCommand oversize = new CartonMeasurementCommand( + "RK-001#5-1", new BigDecimal("60.100"), new BigDecimal("50.100"), + new BigDecimal("50.100"), "MJ-01", + LocalDateTime.parse("2026-08-07T10:30:00"), + new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()), + "FBA-SCAN-001"); + + CartonMeasurementResult result = service.upsert(10001L, 90001L, oversize); + + assertThat(result.created()).isTrue(); + assertThat(insertedItem.get().getSourceImageFileId()).isEqualTo(99001L); + assertThat(insertedItem.get().getLengthCm()).isEqualByComparingTo("60.100"); + assertThat(insertedItem.get().getCalculationRuleVersion()) + .isEqualTo("ACTUAL_DIMENSION_V1"); + assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("60.100"); + assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("50.100"); + assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("50.100"); + assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.151"); + assertThat(insertedItem.get().getOverLimitWarning()).isNull(); + verify(images, never()).delete(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE)); + } + + @Test + void submittedInboundRejectsDeviceBeforeUploadingImage() { + when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("MEASURE_PENDING")); + + assertThatThrownBy(() -> service.upsert(10001L, 90001L, command())) + .isInstanceOf(BizException.class).hasMessageContaining("不能新增或覆盖"); + + verify(images, never()).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any()); + } + + @Test + void waitMeasureWithFrozenLabelsAndReceivedDeviceBatchAllowsFirstScan() { + InboundMeasurementEntity received = measurement(30001L, "RECEIVED", 0); + when(repository.findLatestMeasurement(10001L, 70001L)).thenReturn(received); + when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null); + when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(received); + when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get())); + when(repository.updateMeasurement(received)).thenReturn(true); + + CartonMeasurementResult result = service.upsert(10001L, 90001L, command()); + + assertThat(result.created()).isTrue(); + assertThat(result.confirmedPackageCount()).isEqualTo(1); + } + + @Test + void staleWaitMeasureSnapshotStillRejectsWhenLatestBatchWasSubmitted() { + InboundMeasurementEntity submitted = measurement(30001L, "SUBMITTED", 1); + when(repository.findLatestMeasurement(10001L, 70001L)).thenReturn(submitted); + + assertThatThrownBy(() -> service.upsert(10001L, 90001L, command())) + .isInstanceOf(BizException.class).hasMessageContaining("不能新增或覆盖"); + verify(images, never()).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any()); + } + + @Test + void platformSimulationRejectsBoxCodeFromAnotherInboundBeforeUploadingImage() { + assertThatThrownBy(() -> service.upsertForInbound(10001L, 90001L, 70002L, command())) + .isInstanceOf(BizException.class) + .hasMessageContaining("箱号不属于当前入库单"); + + verify(images, never()).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any()); + } + + @Test + void confirmedPackageCountUsesMeasurementOptimisticVersion() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 2); + InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L); + when(repository.findItem(10001L, 40001L)).thenReturn(item); + when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement); + when(repository.updateItem(item)).thenReturn(true); + when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + + CartonMeasurementResult result = service.updateConfirmedPackageCount( + 10001L, 70001L, 40001L, 2, 5); + + assertThat(result.confirmedPackageCount()).isEqualTo(5); + assertThat(item.getPackageCount()).isEqualTo(5); + verify(repository).updateMeasurement(measurement); + } + + @Test + void confirmedPackageCountPreservesTheRuleSnapshotAfterCustomerConfigChanges() { + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 2); + InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L); + item.setOuterLengthCm(new BigDecimal("34")); + item.setOuterWidthCm(new BigDecimal("24")); + item.setOuterHeightCm(new BigDecimal("24")); + item.setUnitVolumeCbm(new BigDecimal("0.020")); + item.setCalculationRuleVersion("CUSTOMER_DIMENSION_SMALL_V3"); + when(repository.findItem(10001L, 40001L)).thenReturn(item); + when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement); + when(repository.updateItem(item)).thenReturn(true); + when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + + service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 3); + + assertThat(item.getTotalVolumeCbm()).isEqualByComparingTo("0.0600"); + assertThat(item.getOuterLengthCm()).isEqualByComparingTo("34"); + assertThat(item.getCalculationRuleVersion()).isEqualTo("CUSTOMER_DIMENSION_SMALL_V3"); + verify(dimensionConfigs, never()).findAll(10001L); + } + + @Test + void confirmedPackageCountDoesNotUseCartonWeightAsWeightFallback() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2); + measurement.setTotalPackageCount(1); + measurement.setTotalVolumeCbm(new BigDecimal("0.0600")); + measurement.setVolumeWeightKg(new BigDecimal("10.020")); + InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L); + item.setOuterLengthCm(new BigDecimal("50")); + item.setOuterWidthCm(new BigDecimal("40")); + item.setOuterHeightCm(new BigDecimal("30")); + item.setUnitVolumeCbm(new BigDecimal("0.1496")); + item.setCalculationRuleVersion("ACTUAL_DIMENSION_V1"); + when(repository.findInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findItem(10001L, 40001L)).thenReturn(item); + when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement); + when(repository.updateItem(item)).thenReturn(true); + when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item)); + when(repository.listWeights(10001L, 70001L)).thenReturn(List.of()); + when(repository.updateMeasurement(measurement)).thenReturn(true); + when(repository.updateInbound(inbound)).thenReturn(true); + + service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5); + + assertThat(inbound.getTotalPackageCount()).isEqualTo(5); + assertThat(inbound.getActualGrossWeightKg()).isNull(); + assertThat(inbound.getActualNetWeightKg()).isNull(); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480"); + assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916"); + verify(repository).updateInbound(inbound); + } + + @Test + void cartonMeasurementAfterConfirmedWeightKeepsNetWeightAndUpdatesVolume() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + inbound.setActualGrossWeightKg(new BigDecimal("30.000")); + inbound.setActualNetWeightKg(new BigDecimal("11.500")); + InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2); + measurement.setTotalPackageCount(1); + measurement.setTotalVolumeCbm(new BigDecimal("0.0600")); + measurement.setVolumeWeightKg(new BigDecimal("10.020")); + InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L); + item.setOuterLengthCm(new BigDecimal("50")); + item.setOuterWidthCm(new BigDecimal("40")); + item.setOuterHeightCm(new BigDecimal("30")); + item.setUnitVolumeCbm(new BigDecimal("0.1496")); + item.setCalculationRuleVersion("ACTUAL_DIMENSION_V1"); + InboundWeightResultEntity confirmedWeight = new InboundWeightResultEntity(); + confirmedWeight.setRecognitionStatus("CONFIRMED"); + confirmedWeight.setGrossWeightKg(new BigDecimal("30.000")); + confirmedWeight.setNetWeightKg(new BigDecimal("11.500")); + when(repository.findInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findItem(10001L, 40001L)).thenReturn(item); + when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement); + when(repository.updateItem(item)).thenReturn(true); + when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item)); + when(repository.listWeights(10001L, 70001L)).thenReturn(List.of(confirmedWeight)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + when(repository.updateInbound(inbound)).thenReturn(true); + + service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5); + + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("11.500"); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480"); + assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916"); + verify(repository).updateInbound(inbound); + } + + private CartonMeasurementCommand command() { + return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"), + new BigDecimal("40.200"), new BigDecimal("30.300"), + "MJ-01", LocalDateTime.parse("2026-08-07T10:30:00"), + new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()), + "FBA-SCAN-001"); + } + + private CartonMeasurementCommand acceptanceCommand() { + return new CartonMeasurementCommand( + "RK-001#5-1", new BigDecimal("59.500"), new BigDecimal("41.200"), + new BigDecimal("31.300"), "MJ-ADMIN-E2E-02", + LocalDateTime.parse("2026-08-08T10:01:00"), + new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()), + "FBA-SCAN-002"); + } + + private InboundOrderEntity inbound(String status) { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(70001L); inbound.setTenantId(10001L); inbound.setProcessStatus(status); + inbound.setTotalPackageCount(5); inbound.setVersion(0); + return inbound; + } + + private InboundMeasurementEntity measurement(long id, String status, int version) { + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(id); measurement.setTenantId(10001L); measurement.setInboundId(70001L); + measurement.setStatus(status); measurement.setVersion(version); + return measurement; + } + + private InboundMeasurementItemEntity item(long id, long measurementId, int packageCount, long imageId) { + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(id); item.setTenantId(10001L); item.setMeasurementId(measurementId); + item.setBoxCode("RK-001#5-1"); item.setPackageCount(packageCount); + item.setLengthCm(new BigDecimal("50")); item.setWidthCm(new BigDecimal("40")); + item.setHeightCm(new BigDecimal("30")); + item.setSourceImageFileId(imageId); item.setItemNo("B1"); item.setBoxSpecification("50x40x30cm"); + return item; + } + + private List dimensionRules(long tenantId) { + return java.util.Arrays.stream(CustomerDimensionBoxType.values()).map(type -> { + CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity(); + rule.setTenantId(tenantId); rule.setBoxType(type); rule.setVersion(0); + rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm()); + rule.setInnerHeightCm(type.defaultHeightCm()); + rule.setEdgeAdditionCm(new BigDecimal("2.000")); + return rule; + }).toList(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java new file mode 100644 index 000000000..81c0c1bf2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java @@ -0,0 +1,389 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.ContainerFeeDetailDtos; +import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest; +import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.Allocation; +import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupRequest; +import com.baiyue.firstleg.entity.ContainerFeeDetailEntity; +import com.baiyue.firstleg.entity.FeeItemEntity; +import com.baiyue.firstleg.entity.LoadingPlanEntity; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.repository.ContainerFeeDetailRepository; +import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository; +import com.baiyue.firstleg.repository.FeeItemRepository; +import com.baiyue.firstleg.repository.SmartLoadingRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.mockito.ArgumentCaptor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +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.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; + +class ContainerFeeDetailServiceTest { + private final ContainerFeeDetailRepository repository = mock(ContainerFeeDetailRepository.class); + private final SmartLoadingRepository loading = mock(SmartLoadingRepository.class); + private final FeeItemRepository feeItems = mock(FeeItemRepository.class); + private final FinanceFeeSourceOutboxService outbox = mock(FinanceFeeSourceOutboxService.class); + private final WarehouseSupplierSnapshotService suppliers = mock(WarehouseSupplierSnapshotService.class); + private final ContainerFeeSourceSnapshotRepository sourceSnapshots = mock(ContainerFeeSourceSnapshotRepository.class); + private final FeePaymentSnapshotValidator paymentSnapshots = mock(FeePaymentSnapshotValidator.class); + private final ContainerFeeDetailService service = + new ContainerFeeDetailService(repository, loading, feeItems, outbox, suppliers, sourceSnapshots, + paymentSnapshots); + + @BeforeEach + void paymentSnapshot() { + lenient().when(paymentSnapshots.validate(anyLong(), any(), any(), any(), any(), any(), any())) + .thenAnswer(invocation -> new FeePaymentSnapshotValidator.Snapshot( + invocation.getArgument(2), invocation.getArgument(3), invocation.getArgument(4), + invocation.getArgument(5), invocation.getArgument(6) == null ? List.of() : invocation.getArgument(6))); + } + + @Test + void rejectsContainerThatDoesNotContainCustomerOrders() { + LoadingPlanEntity plan = new LoadingPlanEntity(); + plan.setId(41L); + when(loading.plan(41L)).thenReturn(plan); + LoadingPlanItemEntity other = new LoadingPlanItemEntity(); + other.setAccountId(20002L); + when(loading.items(41L)).thenReturn(List.of(other)); + + assertThatThrownBy(() -> service.create(10001L, 41L, 9L, request(true))) + .isInstanceOf(BizException.class).hasMessageContaining("不属于该客户"); + verify(repository, never()).insert(any()); + } + + @Test + void approvedSettlementFeePublishesContainerSource() { + ownedPlan(); + ContainerFeeDetailEntity approved = new ContainerFeeDetailEntity(); + approved.setId(51L); + approved.setTenantId(10001L); + approved.setLoadingPlanId(41L); + approved.setContainerBookingId(61L); + approved.setFeeItemId(11L); + approved.setDirection("RECEIVABLE"); + approved.setSourceVersion(1); + when(repository.find(10001L, 41L, 51L)).thenReturn(approved); + when(feeItems.findItem(11L)).thenReturn(item()); + when(repository.updateSupplierSnapshot(eq(10001L), eq(41L), eq(51L), + eq(null), eq(null), eq(null), eq(9L))).thenReturn(1); + when(repository.review(10001L, 41L, 51L, "APPROVED", 9L, "admin", null)).thenReturn(1); + + service.approve(10001L, 41L, 51L, 9L, "admin"); + + verify(outbox).publishContainer(eq(FinanceFeeSourceOutboxService.PUBLISHED), + eq(10001L), eq(61L), eq(51L), eq(1), any(LocalDateTime.class)); + } + + @Test + void adjustmentIncrementsSourceVersionAndReturnsToReview() { + ownedPlan(); + ContainerFeeDetailEntity current = approvedFee(1); + ContainerFeeDetailEntity adjusted = approvedFee(2); + adjusted.setBusinessDate(LocalDate.of(2026, 8, 12)); + adjusted.setAmount(new BigDecimal("325.00")); + adjusted.setReviewStatus("PENDING"); + when(repository.find(10001L, 41L, 51L)).thenReturn(current); + when(repository.adjust(eq(10001L), eq(41L), eq(51L), eq(1), + eq(LocalDate.of(2026, 8, 12)), eq(new BigDecimal("325.00")), eq(9L), any())) + .thenReturn(1); + when(repository.find(10001L, 41L, 51L)).thenReturn(current, adjusted); + + ContainerFeeDetailEntity result = service.adjust(10001L, 41L, 51L, 9L, + adjustment(1, "325.00")); + + assertThat(result.getSourceVersion()).isEqualTo(2); + assertThat(result.getReviewStatus()).isEqualTo("PENDING"); + assertThat(result.getFeeItemId()).isEqualTo(11L); + assertThat(result.getDirection()).isEqualTo("PAYABLE"); + assertThat(result.getSupplierId()).isEqualTo(71L); + assertThat(result.getCurrency()).isEqualTo("CNY"); + assertThat(result.getRemark()).isEqualTo("原费用备注"); + verify(feeItems, never()).findItem(anyLong()); + } + + @Test + void exactAdjustmentReplayReturnsCurrentPendingVersionWithoutWriting() { + ownedPlan(); + ContainerFeeDetailEntity replay = approvedFee(2); + replay.setReviewStatus("PENDING"); + replay.setBusinessDate(LocalDate.of(2026, 8, 12)); + replay.setAmount(new BigDecimal("325.00")); + when(repository.find(10001L, 41L, 51L)).thenReturn(replay); + + assertThat(service.adjust(10001L, 41L, 51L, 9L, adjustment(1, "325.0"))) + .isSameAs(replay); + + verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(), + anyInt(), any(), any(), anyLong(), any()); + } + + @Test + void staleOrDifferentReplayIsRejectedWithoutWriting() { + ownedPlan(); + ContainerFeeDetailEntity current = approvedFee(2); + current.setReviewStatus("PENDING"); + current.setBusinessDate(LocalDate.of(2026, 8, 12)); + current.setAmount(new BigDecimal("325.00")); + when(repository.find(10001L, 41L, 51L)).thenReturn(current); + + assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L, + adjustment(1, "326.00"))).isInstanceOf(BizException.class) + .hasMessageContaining("当前版本的已审核"); + verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(), + anyInt(), any(), any(), anyLong(), any()); + } + + @Test + void concurrentVersionChangeReturnsConflictWithoutPublishing() { + ownedPlan(); + when(repository.find(10001L, 41L, 51L)).thenReturn(approvedFee(1)); + when(repository.adjust(eq(10001L), eq(41L), eq(51L), eq(1), any(), any(), eq(9L), any())) + .thenReturn(0); + + assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L, + adjustment(1, "325.00"))).isInstanceOf(BizException.class) + .hasMessageContaining("当前版本的已审核"); + + verify(outbox, never()).publishContainer(any(), anyLong(), anyLong(), anyLong(), + anyInt(), any()); + } + + @Test + void voidedOrUnapprovedFeeCannotBeAdjusted() { + ownedPlan(); + ContainerFeeDetailEntity voided = approvedFee(1); + voided.setSourceStatus("VOIDED"); + when(repository.find(10001L, 41L, 51L)).thenReturn(voided); + assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L, + adjustment(1, "325.00"))).isInstanceOf(BizException.class); + + ContainerFeeDetailEntity pending = approvedFee(1); + pending.setReviewStatus("PENDING"); + when(repository.find(10001L, 41L, 51L)).thenReturn(pending); + assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L, + adjustment(1, "325.00"))).isInstanceOf(BizException.class); + + verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(), + anyInt(), any(), any(), anyLong(), any()); + } + + @Test + void payableRequiresSupplierSnapshot() { + ownedPlan(); + when(feeItems.findItem(11L)).thenReturn(item()); + + var missingSupplier = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE", + LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY", + null, "整柜拖车费"); + + assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingSupplier)) + .isInstanceOf(BizException.class).hasMessageContaining("必须选择有效供应商"); + verify(repository, never()).insert(any()); + } + + @Test + void receivableClearsCounterpartyFields() { + ownedPlan(); + when(feeItems.findItem(11L)).thenReturn(item()); + + var request = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE", + LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY", + 71L, "整柜操作费"); + service.create(10001L, 41L, 9L, request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class); + verify(repository).insert(captor.capture()); + assertThat(captor.getValue().getSupplierId()).isNull(); + assertThat(captor.getValue().getSupplierName()).isNull(); + } + + @Test + void directPaidFieldsAreStoredAsBusinessSnapshot() { + ownedPlan(); + when(feeItems.findItem(11L)).thenReturn(item()); + when(suppliers.fee(71L, null)).thenReturn( + new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot( + 71L, "现有供应商", "TRUCKING", "ACTIVE", List.of(), "DOMESTIC")); + LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0); + var request = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE", + LocalDate.of(2026, 8, 20), new BigDecimal("300.00"), "CNY", 71L, + "DIRECT_PAID", paidAt, "BANK_TRANSFER", "BANK-001", List.of(901L), "已付拖车费"); + + service.create(10001L, 41L, 9L, request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class); + verify(repository).insert(captor.capture()); + assertThat(captor.getValue().getSettlementMode()).isEqualTo("DIRECT_PAID"); + assertThat(captor.getValue().getPaidAt()).isEqualTo(paidAt); + assertThat(captor.getValue().getPaymentMethod()).isEqualTo("BANK_TRANSFER"); + assertThat(captor.getValue().getPaymentVoucherFileIds()).containsExactly(901L); + } + + @Test + void createsAtomicCrossCustomerAllocationAndAllowsMissingInboundCode() { + LoadingPlanEntity plan = new LoadingPlanEntity(); + plan.setId(41L); + when(loading.plan(41L)).thenReturn(plan); + LoadingPlanItemEntity first = loadingItem(10001L, 501L, "FL-501", null, null); + LoadingPlanItemEntity second = loadingItem(10002L, 502L, "FL-502", 602L, "IB-602"); + when(loading.items(41L)).thenReturn(List.of(first, second)); + when(feeItems.findItem(11L)).thenReturn(item()); + var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE", + LocalDate.of(2026, 8, 20), new BigDecimal("300.00"), "CNY", null, "柜级操作费"); + var request = new AllocationGroupRequest(fee, List.of( + new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("120.00")), + new Allocation(10002L, 502L, "FL-502", null, null, new BigDecimal("180.00")))); + + List result = service.createAllocationGroup(41L, 9L, request); + + assertThat(result).hasSize(2); + ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class); + verify(repository, org.mockito.Mockito.times(2)).insert(captor.capture()); + assertThat(captor.getAllValues()).extracting(ContainerFeeDetailEntity::getTenantId) + .containsExactly(10001L, 10002L); + assertThat(captor.getAllValues().get(0).getAllocationOrderNo()).isEqualTo("FL-501"); + assertThat(captor.getAllValues().get(0).getAllocationInboundId()).isNull(); + assertThat(captor.getAllValues().get(0).getAllocationInboundCode()).isNull(); + assertThat(captor.getAllValues()).extracting(ContainerFeeDetailEntity::getAllocationGroupId) + .doesNotContainNull().allMatch(captor.getAllValues().get(0).getAllocationGroupId()::equals); + } + + @Test + void rejectsAllocationWhenPersistedRoundedRowsWouldExceedTotal() { + ownedPlan(); + when(feeItems.findItem(11L)).thenReturn(item()); + var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE", + LocalDate.of(2026, 8, 20), new BigDecimal("0.01"), "CNY", null, null); + var request = new AllocationGroupRequest(fee, List.of( + new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("0.005")), + new Allocation(10002L, 502L, "FL-502", null, null, new BigDecimal("0.005")))); + + assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request)) + .isInstanceOf(BizException.class).hasMessageContaining("分摊合计必须等于费用总额"); + verify(repository, never()).insert(any()); + } + + @Test + void rejectsAllocationThatRoundsToZero() { + ownedPlan(); + when(feeItems.findItem(11L)).thenReturn(item()); + var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE", + LocalDate.of(2026, 8, 20), new BigDecimal("0.00"), "CNY", null, null); + var request = new AllocationGroupRequest(fee, List.of( + new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("0.001")))); + + assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request)) + .isInstanceOf(BizException.class).hasMessageContaining("取整后必须大于0"); + verify(repository, never()).insert(any()); + } + + @Test + void rejectsGroupAdjustmentThatRoundsAnAllocationToZero() { + ownedPlan(); + when(repository.listGroup(41L, 81L)).thenReturn(List.of(approvedFee(1))); + var request = new ContainerFeeDetailDtos.AllocationGroupAdjustRequest(1, + LocalDate.of(2026, 8, 20), new BigDecimal("0.001"), List.of( + new ContainerFeeDetailDtos.AllocationAmount(51L, new BigDecimal("0.001")))); + + assertThatThrownBy(() -> service.adjustAllocationGroup(41L, 81L, 9L, request)) + .isInstanceOf(BizException.class).hasMessageContaining("取整后必须大于0"); + verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(), anyInt(), any(), any(), anyLong(), any()); + } + + @Test + void rejectsAllocationWhenInboundIdAndCodeAreOnlyPartiallyProvided() { + LoadingPlanEntity plan = new LoadingPlanEntity(); + plan.setId(41L); + when(loading.plan(41L)).thenReturn(plan); + when(loading.items(41L)).thenReturn(List.of(loadingItem(10001L, 501L, "FL-501", null, null))); + when(feeItems.findItem(11L)).thenReturn(item()); + var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE", + LocalDate.of(2026, 8, 20), new BigDecimal("100.00"), "CNY", null, null); + var request = new AllocationGroupRequest(fee, List.of( + new Allocation(10001L, 501L, "FL-501", 601L, null, new BigDecimal("100.00")))); + + assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request)) + .isInstanceOf(BizException.class).hasMessageContaining("同时填写或同时留空"); + verify(repository, never()).insert(any()); + } + + private void ownedPlan() { + LoadingPlanEntity plan = new LoadingPlanEntity(); + plan.setId(41L); + plan.setContainerBookingId(61L); + when(loading.plan(41L)).thenReturn(plan); + LoadingPlanItemEntity item = new LoadingPlanItemEntity(); + item.setAccountId(10001L); + item.setOrderId(501L); + item.setOrderNo("FL-501"); + when(loading.items(41L)).thenReturn(List.of(item)); + } + + private static LoadingPlanItemEntity loadingItem(long accountId, long orderId, String orderNo, + Long inboundId, String inboundCode) { + LoadingPlanItemEntity value = new LoadingPlanItemEntity(); + value.setAccountId(accountId); value.setOrderId(orderId); value.setOrderNo(orderNo); + value.setInboundId(inboundId); value.setInboundCode(inboundCode); + return value; + } + + private static FeeItemEntity item() { + FeeItemEntity value = new FeeItemEntity(); + value.setId(11L); + value.setFeeCode("TRUCKING"); + value.setFeeTypeName("拖车费"); + value.setWarehouseReceivable(true); + value.setWarehousePayable(true); + value.setStatus("ACTIVE"); + return value; + } + + private static ContainerFeeDetailEntity approvedFee(int version) { + ContainerFeeDetailEntity value = new ContainerFeeDetailEntity(); + value.setId(51L); + value.setTenantId(10001L); + value.setLoadingPlanId(41L); + value.setContainerBookingId(61L); + value.setFeeItemId(11L); + value.setFeeItemCodeSnapshot("TRUCKING"); + value.setFeeItemNameSnapshot("拖车费"); + value.setDirection("PAYABLE"); + value.setSupplierId(71L); + value.setSupplierName("现有供应商"); + value.setCurrency("CNY"); + value.setRemark("原费用备注"); + value.setBusinessDate(LocalDate.of(2026, 8, 11)); + value.setAmount(new BigDecimal("300.00")); + value.setSourceStatus("ACTIVE"); + value.setReviewStatus("APPROVED"); + value.setSourceVersion(version); + return value; + } + + private static AdjustRequest adjustment(int version, String amount) { + return new AdjustRequest(version, LocalDate.of(2026, 8, 12), new BigDecimal(amount)); + } + + private static ContainerFeeDetailDtos.SaveRequest request(boolean ignored) { + return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE", + LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY", + 71L, "整柜拖车费"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java new file mode 100644 index 000000000..01c6b1963 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java @@ -0,0 +1,204 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.ContainerOverviewDtos.ExceptionRequest; +import com.baiyue.firstleg.dto.ContainerOverviewDtos.PickupCodeRequest; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.mapper.ContainerOverviewMapper; +import com.baiyue.firstleg.repository.ContainerRepository; +import com.baiyue.firstleg.client.LoadingEvidenceFileClient; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +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.*; + +class ContainerOverviewServiceTest { + @Mock ContainerRepository containers; + @Mock ContainerOverviewMapper mapper; + @Mock LoadingEvidenceFileClient evidenceFiles; + @InjectMocks ContainerOverviewService service; + + @BeforeEach void setUp() { + MockitoAnnotations.openMocks(this); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + } + + @Test void overviewExposesAwaitingReviewWorkflowAndFirstlegPhotoDownloadPath() { + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setId(41L); booking.setVersion(3); booking.setPlanId(52L); + when(containers.find(41L)).thenReturn(booking); + when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow( + 61L, 52L, 41L, "LOADING", 7)); + when(mapper.itemCount(61L)).thenReturn(2); + when(mapper.completedItemCount(61L)).thenReturn(2); + when(mapper.unresolvedItemCount(61L)).thenReturn(0); + when(mapper.photos(52L)).thenReturn(java.util.List.of( + new ContainerOverviewMapper.PhotoRow( + 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()))); + 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(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()); + when(mapper.pickExceptionReasons(61L)).thenReturn(java.util.List.of()); + + var overview = service.overview(41L); + + assertThat(overview.pda().taskStatus()).isEqualTo("LOADING"); + assertThat(overview.pda().workflowStatus()).isEqualTo("AWAITING_REVIEW"); + assertThat(overview.pda().photos().get(0).downloadPath()) + .isEqualTo("/api/platform/v1/containers/41/pda/photos/101/download"); + assertThat(overview.pda().photos()).hasSize(2); + assertThat(overview.pda().photos().get(1).status()).isEqualTo("VOID"); + assertThat(overview.pda().photos().get(1).downloadPath()).isNull(); + assertThat(overview.pda().photoCounts()).singleElement().satisfies(count -> { + assertThat(count.evidenceType()).isEqualTo("LOADING_DOOR_OPEN"); + assertThat(count.count()).isEqualTo(1); + }); + assertThat(overview.documents()).extracting(document -> document.documentType()) + .containsExactly("DESTINATION_CLEARANCE"); + 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 overviewAllowsDraftContainerWithoutReleasedBookingOrPhysicalContainerNumber() { + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setId(41L); + booking.setCabinetNo("CAB-260830-0017"); + booking.setContainerNo(null); + booking.setOceanBookingId(null); + booking.setVersion(0); + when(containers.find(41L)).thenReturn(booking); + when(mapper.documents(41L)).thenReturn(java.util.List.of()); + when(mapper.exceptions(41L)).thenReturn(java.util.List.of()); + when(mapper.transport(41L)).thenReturn(java.util.List.of()); + + var overview = service.overview(41L); + + assertThat(overview.container().cabinetNo()).isEqualTo("CAB-260830-0017"); + assertThat(overview.container().containerNo()).isNull(); + assertThat(overview.container().version()).isZero(); + assertThat(overview.loadingPlanId()).isNull(); + assertThat(overview.pda()).isNull(); + assertThat(overview.documents()).isEmpty(); + assertThat(overview.timeline()).isEmpty(); + } + + @Test void downloadsOnlyActivePhotoOwnedByContainerLoadingPlan() { + ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); + when(containers.find(41L)).thenReturn(booking); + when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow( + 61L, 52L, 41L, "LOADING", 7)); + when(mapper.photos(52L)).thenReturn(java.util.List.of(new ContainerOverviewMapper.PhotoRow( + 71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()))); + when(evidenceFiles.download("internal-token", 41L, "LOADING_DOOR_OPEN", 101L)).thenReturn(ResponseEntity.ok() + .contentType(MediaType.IMAGE_JPEG).body(new byte[]{1, 2, 3})); + + var download = service.downloadPhoto(41L, 101L); + + assertThat(download.fileName()).isEqualTo("door.jpg"); + assertThat(download.contentType()).isEqualTo(MediaType.IMAGE_JPEG); + assertThat(download.content()).containsExactly(1, 2, 3); + } + + @Test void rejectsPhotoFromAnotherContainerBeforeCallingFileService() { + ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); + when(containers.find(41L)).thenReturn(booking); + when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow( + 61L, 52L, 41L, "LOADING", 7)); + when(mapper.photos(52L)).thenReturn(java.util.List.of()); + + assertThatThrownBy(() -> service.downloadPhoto(41L, 999L)) + .isInstanceOf(BizException.class).hasMessageContaining("不属于该货柜"); + verifyNoInteractions(evidenceFiles); + } + + @Test void rejectsVoidedPhotoBeforeCallingFileService() { + ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); + when(containers.find(41L)).thenReturn(booking); + when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow( + 61L, 52L, 41L, "LOADING", 7)); + when(mapper.photos(52L)).thenReturn(java.util.List.of(new ContainerOverviewMapper.PhotoRow( + 71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "void.jpg", "VOID", LocalDateTime.now()))); + + assertThatThrownBy(() -> service.downloadPhoto(41L, 101L)) + .isInstanceOf(BizException.class).hasMessageContaining("不属于该货柜"); + verifyNoInteractions(evidenceFiles); + } + + @Test void markLocksContainerAndPersistsIdempotentImmutableHistory() { + ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); booking.setVersion(3); + when(containers.findActiveForUpdate(41L)).thenReturn(booking); + + service.mark(41L, 7L, "operator", "mark-1", new ExceptionRequest(3, "broken seal")); + + verify(containers).findActiveForUpdate(41L); + verify(mapper).insertException(anyLong(), eq(41L), eq("MARK"), eq("broken seal"), eq(7L), + eq("operator"), eq("mark-1"), matches("[0-9a-f]{64}")); + } + + @Test void pickupCodePreservesLeadingZerosAndUsesContainerVersion() { + ContainerBookingEntity locked = new ContainerBookingEntity(); locked.setId(41L); locked.setVersion(3); + ContainerBookingEntity refreshed = new ContainerBookingEntity(); refreshed.setId(41L); refreshed.setVersion(4); refreshed.setPickupCode("00123456"); + when(containers.findActiveForUpdate(41L)).thenReturn(locked); + when(containers.updatePickupCode(41L, "00123456", 3, 7L, "operator")).thenReturn(1); + when(containers.find(41L)).thenReturn(refreshed); + when(mapper.documents(41L)).thenReturn(java.util.List.of()); + when(mapper.exceptions(41L)).thenReturn(java.util.List.of()); + when(mapper.transport(41L)).thenReturn(java.util.List.of()); + + var result = service.updatePickupCode(41L, 7L, "operator", new PickupCodeRequest("00123456", 3)); + + assertThat(result.container().pickupCode()).isEqualTo("00123456"); + assertThat(result.container().version()).isEqualTo(4); + } + + @Test void exactReplayReturnsWithoutLockingOrWriting() { + String hash = hash("MARK|41|3|broken seal"); + when(mapper.exceptionOperation(41L, 7L, "mark-1")).thenReturn(new ContainerOverviewMapper.ExceptionRow( + 1L, "MARK", "broken seal", 7L, "operator", LocalDateTime.now(), hash)); + + service.mark(41L, 7L, "operator", "mark-1", new ExceptionRequest(3, "broken seal")); + + verifyNoInteractions(containers); + verify(mapper, never()).insertException(anyLong(), anyLong(), anyString(), anyString(), anyLong(), + anyString(), anyString(), anyString()); + } + + @Test void reusedIdempotencyKeyWithDifferentRequestFailsClosed() { + when(mapper.exceptionOperation(41L, 7L, "mark-1")).thenReturn(new ContainerOverviewMapper.ExceptionRow( + 1L, "MARK", "other", 7L, "operator", LocalDateTime.now(), "a".repeat(64))); + + assertThatThrownBy(() -> service.mark(41L, 7L, "operator", "mark-1", + new ExceptionRequest(3, "broken seal"))).isInstanceOf(BizException.class) + .hasMessageContaining("不同异常操作"); + verifyNoInteractions(containers); + } + + private static String hash(String value) { + try { + return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java new file mode 100644 index 000000000..5be2b2072 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -0,0 +1,536 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; +import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; +import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest; +import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.repository.ContainerRepository; +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; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@DisplayName("货柜基础资料服务") +@SpringJUnitConfig(ContainerService.class) +class ContainerServiceTest { + @MockBean private ContainerRepository repository; + @MockBean private WarehouseMasterDataClient warehouses; + @MockBean private PriceBookQueryUseCase priceBooks; + @MockBean private OceanBookingRepository oceanBookings; + @MockBean private WarehouseSupplierSnapshotService suppliers; + @MockBean private FirstlegDocumentNumberGenerator documentNumbers; + @MockBean private SmartLoadingService smartLoadingService; + @Resource private ContainerService service; + + @BeforeEach + void setUpWarehouse() { + when(documentNumbers.next("CB")).thenReturn("CB-260818-0001"); + when(documentNumbers.next("CAB")).thenReturn("CAB-260818-0001"); + when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation -> + new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING", + invocation.getArgument(0, Long.class), null, null, 0, true, true, null, + "NOT_STARTED", null, null, null, List.of(), List.of(), List.of())); + when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation -> + activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓")); + when(suppliers.overseasTrucking(202L, "拖车公司A")) + .thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE", List.of(), "OVERSEAS")); + } + + @Test + @DisplayName("新增模拟柜从有效渠道冻结快照并生成稳定内部编号") + void createsSimulationContainerBooking() { + ChannelRow channel = channel(); + when(priceBooks.activeVersionId()).thenReturn(51L); + when(priceBooks.channels(51L, 101L)).thenReturn(java.util.List.of(channel)); + ContainerBookingEntity created = service.createSimulation(9L, "admin", simulation()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerBookingEntity.class); + verify(repository).insert(captor.capture()); + ContainerBookingEntity saved = captor.getValue(); + assertThat(created).isSameAs(saved); + assertThat(saved.getBookingNo()).isEqualTo("CB-260818-0001"); + assertThat(saved.getCabinetNo()).isEqualTo("CAB-260818-0001"); + assertThat(saved.getContainerNo()).isNull(); + assertThat(saved.getOceanBookingId()).isNull(); + assertThat(saved.getChannelGroupCode()).isEqualTo("MATSON_CLX"); + assertThat(saved.getChannelCode()).isNull(); + assertThat(saved.getChannelName()).isNull(); + assertThat(saved.getWarehouseId()).isEqualTo(101L); + assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓"); + assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000"); + assertThat(saved.getPlanId()).isEqualTo(501L); + assertThat(saved.getPlanNo()).isEqualTo("LP-260818-0001"); + assertThat(saved.getPlanStatus()).isEqualTo("EDITING"); + verify(smartLoadingService).createForContainer(saved.getId(), 9L, "admin"); + } + + @Test + @DisplayName("新增模拟柜拒绝柜型与固定规格不匹配") + void rejectsSimulationWithNonCanonicalSpecification() { + SimulationCreateRequest invalid = new SimulationCreateRequest( + "MATSON_CLX", 101L, "40HQ", new BigDecimal("100"), new BigDecimal("19500")); + + assertThatThrownBy(() -> service.createSimulation(9L, "admin", invalid)) + .isInstanceOf(BizException.class) + .hasMessageContaining("容量和货重上限"); + verify(repository, never()).insert(any()); + } + + @Test + @DisplayName("智能装柜草稿阶段完整编辑时保留稳定柜次号") + void updatesAllFieldsWhileEditing() { + ContainerBookingEntity current = booking("EDITING", 2); + when(repository.find(11L)).thenReturn(current); + when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2), + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1); + + service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request())); + + assertThat(current.getCabinetNo()).isEqualTo("柜-001"); + verify(repository).updateDraft(current, 2, 9L, "admin"); + } + + @Test + @DisplayName("待装柜阶段完整编辑时保留稳定柜次号") + void updatesAllFieldsWhileWaitingForLoading() { + ContainerBookingEntity current = booking("WAIT_LOADING", 2); + when(repository.find(11L)).thenReturn(current); + when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2), + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1); + + service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request())); + + assertThat(current.getCabinetNo()).isEqualTo("柜-001"); + verify(repository).updateDraft(current, 2, 9L, "admin"); + } + + @Test + @DisplayName("进入预装柜中后拒绝完整编辑") + void rejectsFullUpdateAfterPreloadPush() { + when(repository.find(11L)).thenReturn(booking("LOADING", 2)); + + assertThatThrownBy(() -> service.updateDraft( + 11L, 9L, "admin", new FullUpdateRequest(2, request()))) + .isInstanceOf(BizException.class) + .hasMessageContaining("基础资料不可完整编辑"); + + verify(repository, never()).updateDraft(any(), any(Integer.class), + any(Long.class), any(String.class)); + } + + @Test + @DisplayName("旧版本编辑请求触发乐观锁冲突") + void rejectsStaleVersion() { + when(repository.find(11L)).thenReturn(booking("EDITING", 3)); + + assertThatThrownBy(() -> service.updateDraft( + 11L, 9L, "admin", new FullUpdateRequest(2, request()))) + .isInstanceOf(BizException.class) + .hasMessageContaining("刷新后重试"); + } + + @Test + @DisplayName("虚拟柜拒绝选择非国内中转仓") + void rejectsNonDomesticWarehouse() { + when(warehouses.active(anyString(), anyLong(), anyLong())) + .thenReturn(activeWarehouse(101L, "FBA", "美国目的仓")); + + assertThatThrownBy(() -> service.createSimulation(9L, "admin", simulation())) + .isInstanceOf(BizException.class) + .hasMessageContaining("国内中转仓"); + + verify(repository, never()).insert(any()); + } + + @Test + @DisplayName("虚拟柜仓库名称以仓库主数据为准") + void replacesRequestedWarehouseName() { + when(priceBooks.activeVersionId()).thenReturn(51L); + when(priceBooks.channels(51L, 101L)).thenReturn(java.util.List.of(channel())); + service.createSimulation(9L, "admin", simulation()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerBookingEntity.class); + verify(repository).insert(captor.capture()); + assertThat(captor.getValue().getWarehouseName()).isEqualTo("上海中转仓"); + } + + @Test + @DisplayName("仓库主数据服务不可用时拒绝创建虚拟柜") + void rejectsUnavailableWarehouseService() { + when(warehouses.active(anyString(), anyLong(), anyLong())) + .thenThrow(new IllegalStateException("warehouse service unavailable")); + + assertThatThrownBy(() -> service.createSimulation(9L, "admin", simulation())) + .isInstanceOf(BizException.class) + .hasMessageContaining("暂时不可用"); + + verify(repository, never()).insert(any()); + } + + @ParameterizedTest + @ValueSource(strings = {"WAIT_LOADING", "EDITING"}) + @DisplayName("空计划及加入入仓单后的编辑计划均允许首次关联且保持清单身份") + void linksReleasedBookingAfterPreloading(String planStatus) { + ContainerBookingEntity container = booking(planStatus, 2); + container.setSourceType("SIMULATION"); + container.setPlanId(501L); + container.setPlanNo("LP-260818-0001"); + container.setContainerNo(null); + container.setWarehouseId(101L); container.setChannelGroupCode("EXX"); container.setChannelCode(null); container.setContainerType("40HQ"); + 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("EXX"); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HQ"); requirement.setCapacityCbm(new BigDecimal("68.0000")); requirement.setMaxWeightKg(new BigDecimal("19500.000")); + when(repository.find(11L)).thenReturn(container, container); + when(oceanBookings.find(21L)).thenReturn(booking); + when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L); + when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"), + same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1); + + ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, " ms cu 663987 0 ", 2)); + + assertThat(result).isSameAs(container); + assertThat(container.getCabinetNo()).isEqualTo("柜-001"); + assertThat(container.getPlanId()).isEqualTo(501L); + assertThat(container.getPlanNo()).isEqualTo("LP-260818-0001"); + assertThat(container.getPlanStatus()).isEqualTo(planStatus); + assertThat(container.getSourceType()).isEqualTo("SIMULATION"); + verify(repository, never()).hasActiveLoadingItems(11L); + verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"), + same(booking), eq(2), eq(9L), eq("admin")); + verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class)); + } + + @Test + @DisplayName("模拟柜允许不带真实柜号原子关联已发布订舱需求") + void linksReleasedBookingWithoutRealContainerNumber() { + stubLinkContext(); + ContainerBookingEntity container = repository.find(11L); + when(repository.find(11L)).thenReturn(container, container); + when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(), + any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"))).thenReturn(1); + + ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, null, 2)); + + assertThat(result.getContainerNo()).isNull(); + assertThat(result.getCabinetNo()).isEqualTo("柜-001"); + verify(repository, never()).containerNoExists(anyString(), anyLong()); + verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(), + any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin")); + verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class)); + } + + @Test + @DisplayName("预装已录入真实柜号时关联订舱保留原柜号") + void linksReleasedBookingWhilePreservingExistingRealContainerNumber() { + ContainerBookingEntity container = stubLinkContext(); + container.setContainerNo("MSCU6639870"); + when(repository.find(11L)).thenReturn(container, container); + when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"), + any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"))).thenReturn(1); + + ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, null, 2)); + + assertThat(result.getContainerNo()).isEqualTo("MSCU6639870"); + verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"), + any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin")); + verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class)); + } + + @Test + @DisplayName("关联订舱时拒绝用不同柜号覆盖预装已录入柜号") + void rejectsDifferentRealContainerNumberWhenLinkingReleasedBooking() { + ContainerBookingEntity container = stubLinkContext(); + container.setContainerNo("MSCU6639870"); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "TSTU1503306", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("与当前柜次已录入柜号不一致"); + + verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), + any(), anyInt(), anyLong(), anyString()); + verifyNoInteractions(oceanBookings); + } + + @Test + @DisplayName("提柜后按柜次版本原子补录真实柜号") + void assignsRealContainerNumberAfterPickup() { + ContainerBookingEntity linked = booking("EDITING", 3); + linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L); + ContainerBookingEntity assigned = booking("EDITING", 4); + assigned.setContainerNo("MSCU6639870"); assigned.setOceanBookingId(21L); assigned.setOceanBookingRequirementId(31L); + when(repository.find(11L)).thenReturn(linked, assigned); + when(repository.assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin")).thenReturn(1); + + ContainerBookingEntity result = service.assignRealContainerNo( + 11L, " ms cu 663987 0 ", 3, 9L, "admin"); + + assertThat(result.getContainerNo()).isEqualTo("MSCU6639870"); + verify(repository).assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin"); + } + + @Test + @DisplayName("提柜补录真实柜号仍拒绝全局重复号") + void rejectsDuplicateRealContainerNumberAfterPickup() { + ContainerBookingEntity linked = booking("EDITING", 3); + linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L); + when(repository.find(11L)).thenReturn(linked); + when(repository.containerNoExists("MSCU6639870", 11L)).thenReturn(true); + + assertThatThrownBy(() -> service.assignRealContainerNo( + 11L, "MSCU6639870", 3, 9L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("已存在"); + + verify(repository, never()).assignRealContainerNo(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + } + + @Test + @DisplayName("首次关联拒绝错误ISO 6346校验位") + void rejectsInvalidContainerNumberCheckDigit() { + stubLinkContext(); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU6639871", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("校验位"); + + verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), + any(), anyInt(), anyLong(), anyString()); + } + + @Test + @DisplayName("首次关联规范化后按全局唯一真实柜号拒绝冲突") + void rejectsNormalizedContainerNumberConflict() { + stubLinkContext(); + when(repository.containerNoExists("MSCU6639870", 11L)).thenReturn(true); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "ms cu6639870", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("已存在"); + } + + @Test + @DisplayName("同一订舱箱型数量允许绑定多个不同真实柜号") + void linksMultipleRealContainersToTheSameReleasedBookingRequirement() { + ContainerBookingEntity first = booking("WAIT_LOADING", 2); + first.setId(11L); first.setContainerNo(null); first.setWarehouseId(101L); + first.setChannelGroupCode("MATSON_CLX"); first.setChannelCode(null); first.setContainerType("40HQ"); + ContainerBookingEntity second = booking("WAIT_LOADING", 4); + second.setId(12L); second.setContainerNo(null); second.setWarehouseId(101L); + second.setChannelGroupCode("MATSON_CLX"); second.setChannelCode(null); second.setContainerType("40HQ"); + OceanBookingEntity oceanBooking = new OceanBookingEntity(); + oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); + oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX"); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HQ"); + when(repository.find(11L)).thenReturn(first); + when(repository.find(12L)).thenReturn(second); + when(oceanBookings.find(21L)).thenReturn(oceanBooking); + when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L, 1L); + when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"), + same(oceanBooking), eq(2), eq(9L), eq("admin"))).thenReturn(1); + when(repository.linkReleasedBooking(eq(12L), eq(21L), eq(31L), eq("TSTU1503306"), + same(oceanBooking), eq(4), eq(9L), eq("admin"))).thenReturn(1); + + service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)); + service.linkOceanBooking(12L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "TSTU1503306", 4)); + + ArgumentCaptor allocations = + ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class); + verify(oceanBookings, org.mockito.Mockito.times(2)).insert(allocations.capture()); + assertThat(allocations.getAllValues()).extracting(OceanBookingContainerAllocationEntity::getContainerBookingId) + .containsExactly(11L, 12L); + } + + @Test + @DisplayName("已经归属其他订舱的真实柜不能改绑") + void rejectsContainerAlreadyOwnedByAnotherBooking() { + ContainerBookingEntity container = booking("WAIT_LOADING", 2); + container.setContainerNo(null); + container.setOceanBookingId(22L); + when(repository.find(11L)).thenReturn(container); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("已关联真实订舱柜"); + + verifyNoInteractions(oceanBookings); + } + + @Test + @DisplayName("首次关联拒绝装柜仓库不一致") + void rejectsLinkWhenWarehouseDiffers() { + assertThreeFieldMismatch(102L, "MATSON_CLX", "40HQ"); + } + + @Test + @DisplayName("首次关联拒绝物流渠道不一致") + void rejectsLinkWhenChannelDiffers() { + assertThreeFieldMismatch(101L, "HEDE", "40HQ"); + } + + @Test + @DisplayName("首次关联拒绝柜型不一致") + void rejectsLinkWhenContainerTypeDiffers() { + assertThreeFieldMismatch(101L, "MATSON_CLX", "45HQ"); + } + + @Test + @DisplayName("未发布订舱不可关联") + void rejectsDraftBooking() { + ContainerBookingEntity container = booking("WAIT_LOADING", 2); + container.setContainerNo(null); + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setId(21L); booking.setStatus("DRAFT"); + when(repository.find(11L)).thenReturn(container); + when(oceanBookings.find(21L)).thenReturn(booking); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("已发布"); + } + + @Test + @DisplayName("订舱箱型需求达到数量上限时拒绝关联") + void rejectsRequirementOverAllocation() { + ContainerBookingEntity container = booking("WAIT_LOADING", 2); + container.setContainerNo(null); + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setId(21L); booking.setStatus("RELEASED"); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setId(31L); requirement.setQuantity(1); + when(repository.find(11L)).thenReturn(container); + when(oceanBookings.find(21L)).thenReturn(booking); + when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(1L); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("数量上限"); + verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), + any(), anyInt(), anyLong(), anyString()); + } + + private void assertThreeFieldMismatch(long bookingWarehouseId, String bookingChannel, + String requirementType) { + ContainerBookingEntity container = booking("WAIT_LOADING", 2); + container.setContainerNo(null); + container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null); + container.setContainerType("40HQ"); + 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, "MSCU6639870", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道分组或柜型不一致"); + verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), + any(), anyInt(), anyLong(), anyString()); + } + + private ContainerBookingEntity stubLinkContext() { + ContainerBookingEntity container = booking("WAIT_LOADING", 2); + container.setContainerNo(null); + container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null); + container.setContainerType("40HQ"); + OceanBookingEntity oceanBooking = new OceanBookingEntity(); + oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); + oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX"); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HQ"); + 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); + return container; + } + + @Test + @DisplayName("首次关联遇到有效装柜证据时拒绝") + void rejectsLinkWhenEvidenceExists() { + ContainerBookingEntity container = booking("EDITING", 2); + container.setContainerNo(null); + when(repository.find(11L)).thenReturn(container); + when(repository.hasEvidence(11L)).thenReturn(true); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("证据"); + verifyNoInteractions(oceanBookings); + } + + private static ApiResponse activeWarehouse(long warehouseId, + String type, + String name) { + return ApiResponse.success(new WarehouseMasterDataClient.Detail( + new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", null, name, type, "ACTIVE"))); + } + + private static ContainerBookingEntity booking(String status, int version) { + ContainerBookingEntity value = new ContainerBookingEntity(); + value.setId(11L); + value.setContainerNo("MSCU1234567"); + value.setCabinetNo("柜-001"); + value.setPlanStatus(status); + value.setVersion(version); + return value; + } + + private static SaveRequest request() { + return new SaveRequest(101L, "上海仓", "BOL-001", "柏悦轮", + "V001", "40HQ", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港", + "ONT8", "ONT8", new BigDecimal("68.0000"), new BigDecimal("19500.000"), 202L, "拖车公司A", + LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null, + LocalDateTime.of(2026, 8, 25, 8, 0), null, + LocalDateTime.of(2026, 8, 27, 8, 0), null); + } + + private static SimulationCreateRequest simulation() { + return new SimulationCreateRequest("MATSON_CLX", 101L, "40HQ", new BigDecimal("68.0000"), new BigDecimal("19500.000")); + } + + private static ChannelRow channel() { + ChannelRow channel = new ChannelRow(); channel.setChannelCode("MATSON_CLX_TRUCK"); channel.setChannelName("美西海运"); return channel; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerChargeRuleInitializationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerChargeRuleInitializationServiceTest.java new file mode 100644 index 000000000..527654467 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerChargeRuleInitializationServiceTest.java @@ -0,0 +1,76 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; +import com.baiyue.firstleg.repository.FbaPricingRepository; +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.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(CustomerChargeRuleInitializationService.class) +class CustomerChargeRuleInitializationServiceTest { + @MockBean private FbaPricingRepository repository; + @Resource private CustomerChargeRuleInitializationService service; + + @Test + void initializesDefaultWeightCoefficientRuleForNewCustomer() { + CustomerChargeRuleEntity result = service.initializeDefault( + 8L, 100L, "iam-customer-create:8", "柏悦物流"); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(CustomerChargeRuleEntity.class); + verify(repository).insertRule(captor.capture()); + CustomerChargeRuleEntity saved = captor.getValue(); + assertThat(result).isSameAs(saved); + assertThat(saved.getTenantId()).isEqualTo(8L); + assertThat(saved.getCustomerId()).isEqualTo(8L); + assertThat(saved.getCustomerName()).isEqualTo("柏悦物流"); + assertThat(saved.getRuleCode()).isEqualTo("DEFAULT-DIMENSION-WEIGHT"); + assertThat(saved.getRuleVersion()).isEqualTo(1); + assertThat(saved.getDefaultRule()).isTrue(); + assertThat(saved.getLengthFactor()).isEqualByComparingTo("1.100000"); + assertThat(saved.getWidthFactor()).isEqualByComparingTo("1.100000"); + assertThat(saved.getHeightFactor()).isEqualByComparingTo("1.100000"); + assertThat(saved.getConversionFactor()).isEqualByComparingTo("167.000000"); + assertThat(saved.getRemark()).contains( + "SMALL:30,20,20;系数:1.1;重量:5", + "EXTRA_LARGE:60,50,50;系数:1.1;重量:15"); + assertThat(saved.getInitializationKey()).isEqualTo("iam-customer-create:8"); + } + + @Test + void retryReturnsRuleOwnedBySameInitializationWithoutDuplicating() { + CustomerChargeRuleEntity existing = new CustomerChargeRuleEntity(); + existing.setId(191L); + when(repository.findRuleByInitializationKey(8L, "iam-customer-create:8")) + .thenReturn(existing); + + assertThat(service.initializeDefault(8L, 100L, "iam-customer-create:8", "柏悦物流")) + .isSameAs(existing); + verify(repository, never()).insertRule(any()); + } + + @Test + void compensationDeletesOnlyAnUnchangedInitializedRule() { + CustomerChargeRuleEntity value = service.initializeDefault( + 8L, 100L, "iam-customer-create:8", "柏悦物流"); + value.setVersion(0); + when(repository.findRuleByInitializationKeyForUpdate(8L, "iam-customer-create:8")) + .thenReturn(value); + when(repository.deleteUnchangedInitializedRule(8L, "iam-customer-create:8")) + .thenReturn(1); + + CustomerChargeRuleEntity locked = service.lockForCompensation(8L, "iam-customer-create:8"); + assertThat(service.isUnchangedDefault(locked)).isTrue(); + service.deleteUnchangedDefault(8L, "iam-customer-create:8"); + + verify(repository).deleteUnchangedInitializedRule(8L, "iam-customer-create:8"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java new file mode 100644 index 000000000..8809a8773 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java @@ -0,0 +1,250 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.domain.CustomerDimensionBoxType; +import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.GroupResponse; +import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse; +import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleUpdateRequest; +import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest; +import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; +import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; +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.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +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.anyLong; +import static org.mockito.ArgumentMatchers.eq; +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(CustomerDimensionConfigService.class) +class CustomerDimensionConfigServiceTest { + @MockBean + private CustomerDimensionConfigRepository repository; + @MockBean + private CustomerChargeRuleInitializationService chargeRuleInitialization; + @Resource + private CustomerDimensionConfigService service; + + @Test + void initializesFourDefaultsAtomicallyInFixedOrder() { + List persisted = defaultConfigs("registration-1"); + when(repository.findAll(8L)).thenReturn(List.of(), persisted); + when(repository.insertDefaults(any(), eq(100L))).thenReturn(4); + when(chargeRuleInitialization.initializeDefault(8L, 100L, "registration-1", "柏悦物流")) + .thenReturn(defaultChargeRule()); + + InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1", "柏悦物流"); + + assertThat(result).isEqualTo(new InitializeResponse(91L, 191L, true)); + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(repository).insertDefaults(captor.capture(), eq(100L)); + assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getBoxType) + .containsExactly(CustomerDimensionBoxType.values()); + assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getInnerLengthCm) + .containsExactly(new BigDecimal("30.000"), new BigDecimal("40.000"), + new BigDecimal("50.000"), new BigDecimal("60.000")); + assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getInnerWidthCm) + .containsExactly(new BigDecimal("20.000"), new BigDecimal("30.000"), + new BigDecimal("40.000"), new BigDecimal("50.000")); + assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getInnerHeightCm) + .containsExactly(new BigDecimal("20.000"), new BigDecimal("30.000"), + new BigDecimal("40.000"), new BigDecimal("50.000")); + assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getEdgeAdditionCm) + .containsOnly(new BigDecimal("2.000")); + assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getRemark) + .containsExactly("标准小箱", "标准中箱", "标准大箱", "特大箱"); + } + + @Test + void retryAfterLostResponseKeepsCreationOwnershipForSameKey() { + when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1")); + when(chargeRuleInitialization.initializeDefault(8L, 100L, "registration-1", "柏悦物流")) + .thenReturn(defaultChargeRule()); + + InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1", "柏悦物流"); + + assertThat(result).isEqualTo(new InitializeResponse(91L, 191L, true)); + verify(repository, never()).insertDefaults(any(), eq(100L)); + } + + @Test + void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() { + when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1")); + + InitializeResponse result = service.initializeDefault(8L, 100L, "registration-2", "柏悦物流"); + + assertThat(result).isEqualTo(new InitializeResponse(91L, null, false)); + verify(repository, never()).insertDefaults(any(), eq(100L)); + verify(chargeRuleInitialization, never()).initializeDefault(anyLong(), anyLong(), any(), any()); + } + + @Test + void returnsFixedOrderCalculatedOuterDimensionsAndThreeDecimalVolume() { + List shuffled = new ArrayList<>(defaultConfigs("registration-1")); + Collections.reverse(shuffled); + when(repository.findAll(8L)).thenReturn(shuffled); + + GroupResponse result = service.get(8L); + + assertThat(result.rules()).extracting(rule -> rule.boxType()) + .containsExactly(CustomerDimensionBoxType.values()); + assertThat(result.rules().get(0).boxTypeName()).isEqualTo("小箱"); + assertThat(result.rules().get(0).outerLengthCm()).isEqualByComparingTo("34.000"); + assertThat(result.rules().get(0).outerWidthCm()).isEqualByComparingTo("24.000"); + assertThat(result.rules().get(0).outerHeightCm()).isEqualByComparingTo("24.000"); + assertThat(result.rules().get(0).volumeCbm()).isEqualByComparingTo("0.020"); + } + + @Test + void rejectsMissingOrDuplicateBoxTypes() { + List rules = new ArrayList<>(updateRequest(0).rules()); + rules.set(3, rules.get(0)); + + assertThatThrownBy(() -> service.update(8L, 100L, new UpdateRequest(rules))) + .isInstanceOf(BizException.class) + .hasMessageContaining("重复"); + verify(repository, never()).findAll(8L); + } + + @Test + void rejectsAnyStaleVersionBeforeWritingRows() { + List persisted = defaultConfigs("registration-1"); + persisted.forEach(value -> value.setVersion(3)); + when(repository.findAll(8L)).thenReturn(persisted); + UpdateRequest request = updateRequest(3); + List rules = new ArrayList<>(request.rules()); + RuleUpdateRequest stale = rules.get(2); + rules.set(2, new RuleUpdateRequest(stale.boxType(), stale.innerLengthCm(), stale.innerWidthCm(), + stale.innerHeightCm(), stale.edgeAdditionCm(), stale.remark(), 2)); + + assertThatThrownBy(() -> service.update(8L, 100L, new UpdateRequest(rules))) + .isInstanceOf(BizException.class) + .hasMessageContaining("刷新后重试"); + verify(repository, never()).update(any()); + } + + @Test + void abortsWholeTransactionalUpdateWhenAnyOptimisticWriteFails() { + when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1")); + when(repository.update(any())).thenReturn(1, 0); + + assertThatThrownBy(() -> service.update(8L, 100L, updateRequest(0))) + .isInstanceOf(BizException.class) + .hasMessageContaining("刷新后重试"); + verify(repository, times(2)).update(any()); + } + + @Test + void returnsPersistedVersionsAfterUpdatingAllRules() { + List existing = defaultConfigs("registration-1"); + List persisted = defaultConfigs("registration-1"); + persisted.forEach(value -> value.setVersion(1)); + when(repository.findAll(8L)).thenReturn(existing, persisted); + when(repository.update(any())).thenReturn(1); + + GroupResponse result = service.update(8L, 100L, updateRequest(0)); + + assertThat(result.rules()).extracting(rule -> rule.version()).containsOnly(1); + verify(repository, times(2)).findAll(8L); + } + + @Test + void savesAndReturnsTheCustomerDefaultCalculationMode() { + List existing = defaultConfigs("registration-1"); + List saved = defaultConfigs("registration-1"); + saved.forEach(value -> value.setDefaultCalculationMode("MULTIPLY")); + when(repository.findAll(8L)).thenReturn(existing, saved); + when(repository.updateDefaultCalculationMode(8L, "MULTIPLY", 100L)).thenReturn(4); + + GroupResponse result = service.updateDefaultCalculationMode(8L, 100L, "MULTIPLY"); + + assertThat(result.defaultCalculationMode()).isEqualTo("MULTIPLY"); + verify(repository).updateDefaultCalculationMode(8L, "MULTIPLY", 100L); + } + + @Test + void compensationLocksAndDeletesExactlyFourUnchangedDefaults() { + when(repository.findDefaultsForCompensation(8L, "registration-1")) + .thenReturn(defaultConfigs("registration-1")); + when(repository.deleteUnchangedDefaults(8L, "registration-1")).thenReturn(4); + when(chargeRuleInitialization.lockForCompensation(8L, "registration-1")) + .thenReturn(defaultChargeRule()); + when(chargeRuleInitialization.isUnchangedDefault(any())).thenReturn(true); + + service.compensateDefault(8L, "registration-1"); + + verify(repository).deleteUnchangedDefaults(8L, "registration-1"); + verify(chargeRuleInitialization).deleteUnchangedDefault(8L, "registration-1"); + } + + @Test + void compensationDoesNotDeleteAnEditedGroup() { + List values = defaultConfigs("registration-1"); + values.get(2).setVersion(1); + when(repository.findDefaultsForCompensation(8L, "registration-1")).thenReturn(values); + + service.compensateDefault(8L, "registration-1"); + + verify(repository, never()).deleteUnchangedDefaults(8L, "registration-1"); + } + + @Test + void compensationRollsBackInsteadOfAcceptingPartialDelete() { + when(repository.findDefaultsForCompensation(8L, "registration-1")) + .thenReturn(defaultConfigs("registration-1")); + when(repository.deleteUnchangedDefaults(8L, "registration-1")).thenReturn(3); + + assertThatThrownBy(() -> service.compensateDefault(8L, "registration-1")) + .isInstanceOf(BizException.class) + .hasMessageContaining("补偿删除失败"); + } + + private static UpdateRequest updateRequest(int version) { + return new UpdateRequest(List.of(CustomerDimensionBoxType.values()).stream() + .map(type -> new RuleUpdateRequest(type, type.defaultLengthCm(), type.defaultWidthCm(), + type.defaultHeightCm(), new BigDecimal("2.000"), type.defaultRemark(), version)) + .toList()); + } + + private static List defaultConfigs(String initializationKey) { + List values = new ArrayList<>(); + long id = 91L; + for (CustomerDimensionBoxType type : CustomerDimensionBoxType.values()) { + CustomerDimensionConfigEntity value = new CustomerDimensionConfigEntity(); + value.setId(id++); + value.setTenantId(8L); + value.setBoxType(type); + value.setInnerLengthCm(type.defaultLengthCm()); + value.setInnerWidthCm(type.defaultWidthCm()); + value.setInnerHeightCm(type.defaultHeightCm()); + value.setEdgeAdditionCm(new BigDecimal("2.000")); + value.setDefaultCalculationMode(CustomerDimensionConfigService.DEFAULT_CALCULATION_MODE); + value.setRemark(type.defaultRemark()); + value.setVersion(0); + value.setInitializationKey(initializationKey); + values.add(value); + } + return values; + } + + private static com.baiyue.firstleg.entity.CustomerChargeRuleEntity defaultChargeRule() { + com.baiyue.firstleg.entity.CustomerChargeRuleEntity value = + new com.baiyue.firstleg.entity.CustomerChargeRuleEntity(); + value.setId(191L); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java new file mode 100644 index 000000000..2251a0256 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java @@ -0,0 +1,64 @@ +package com.baiyue.firstleg.service; + +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.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** Representative test-environment data flowing from dimension weighting into channel pricing. */ +class CustomerDimensionPricingFlowTest { + private final CustomerDimensionConfigRepository configs = mock(CustomerDimensionConfigRepository.class); + private final CustomerDimensionWeightingService dimensions = new CustomerDimensionWeightingService(configs); + private final FbaWarehouseRateEstimator pricing = new FbaWarehouseRateEstimator(); + + @Test + void channelRateChoosesWeightOrVolumeAfterCustomerDimensionWeighting() { + when(configs.findAll(10001L)).thenReturn(defaultRules()); + var small = dimensions.load(10001L).resolve( + new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("10")); + BigDecimal twoCartonsVolume = small.unitVolumeCbm().multiply(new BigDecimal("2")); + BigDecimal actualWeight = new BigDecimal("24"); + + var kgEstimate = pricing.estimate(rate("KG", "8.700000"), actualWeight, twoCartonsVolume); + var cbmEstimate = pricing.estimate(rate("CBM", "1400.000000"), actualWeight, twoCartonsVolume); + + assertThat(twoCartonsVolume).isEqualByComparingTo("0.006"); + assertThat(kgEstimate.quantity()).isEqualByComparingTo("24.000000"); + assertThat(kgEstimate.amount()).isEqualByComparingTo("208.80"); + assertThat(cbmEstimate.quantity()).isEqualByComparingTo("0.066116"); + assertThat(cbmEstimate.amount()).isEqualByComparingTo("92.56"); + } + + private RateRow rate(String unit, String price) { + RateRow rate = new RateRow(); + rate.setChannelCode("EXX_TRUCK"); + rate.setDestinationType("OVERSEAS"); + rate.setDestinationWarehouseId(193000000000200003L); + rate.setDestinationName("洛杉矶仓"); + rate.setChargeUnit(unit); + rate.setUnitPrice(new BigDecimal(price)); + rate.setCurrency("CNY"); + return rate; + } + + private List defaultRules() { + return Arrays.stream(CustomerDimensionBoxType.values()).map(type -> { + CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity(); + rule.setTenantId(10001L); rule.setBoxType(type); rule.setVersion(0); + rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm()); + rule.setInnerHeightCm(type.defaultHeightCm()); + rule.setEdgeAdditionCm(new BigDecimal("2.000")); + return rule; + }).toList(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java new file mode 100644 index 000000000..1f331b71e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java @@ -0,0 +1,115 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.domain.CustomerDimensionBoxType; +import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; +import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Arrays; +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.Mockito.when; + +class CustomerDimensionWeightingServiceTest { + private final CustomerDimensionConfigRepository repository = mock(CustomerDimensionConfigRepository.class); + private final CustomerDimensionWeightingService service = new CustomerDimensionWeightingService(repository); + + @BeforeEach + void setUp() { + when(repository.findAll(10001L)).thenReturn(defaultRules(10001L)); + } + + @Test + void measuredDimensionsUseSmallestContainingRuleAndAddEachSide() { + var weighted = service.load(10001L).resolve( + new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("10")); + + assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.SMALL); + assertThat(weighted.outerLengthCm()).isEqualByComparingTo("14.000"); + assertThat(weighted.outerWidthCm()).isEqualByComparingTo("14.000"); + assertThat(weighted.outerHeightCm()).isEqualByComparingTo("14.000"); + assertThat(weighted.rawVolumeCbm()).isEqualByComparingTo("0.002744"); + assertThat(weighted.unitVolumeCbm()).isEqualByComparingTo("0.003"); + } + + @Test + void deviceMeasurementsUseActualDimensionsAndAddEachSideWhenRuleMatches() { + var weighted = service.load(10001L).resolveDeviceMeasurement( + new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("10")); + + assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.SMALL); + assertThat(weighted.outerLengthCm()).isEqualByComparingTo("14.000"); + assertThat(weighted.outerWidthCm()).isEqualByComparingTo("14.000"); + assertThat(weighted.outerHeightCm()).isEqualByComparingTo("14.000"); + assertThat(weighted.calculationRuleVersion()).isEqualTo("CUSTOMER_DIMENSION_SMALL_V3"); + } + + @Test + void cartonRotationDoesNotChangeTheMatchedRule() { + var weighted = service.load(10001L).resolve( + new BigDecimal("20"), new BigDecimal("30"), new BigDecimal("20")); + + assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.SMALL); + } + + @Test + void crossingOneSmallBoxBoundaryUsesTheNextContainingRule() { + var weighted = service.load(10001L).resolve( + new BigDecimal("30.001"), new BigDecimal("20"), new BigDecimal("20")); + + assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.MEDIUM); + assertThat(weighted.unitVolumeCbm()).isEqualByComparingTo("0.020"); + } + + @Test + void dimensionsOutsideConfiguredWeightingRulesUseLargestTierAdditionOnActualDimensions() { + var weighted = service.load(10001L).resolve( + new BigDecimal("60.001"), new BigDecimal("50"), new BigDecimal("50")); + + assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.EXTRA_LARGE); + assertThat(weighted.edgeAdditionCm()).isEqualByComparingTo("2.000"); + assertThat(weighted.outerLengthCm()).isEqualByComparingTo("64.001"); + assertThat(weighted.outerWidthCm()).isEqualByComparingTo("54.000"); + assertThat(weighted.outerHeightCm()).isEqualByComparingTo("54.000"); + assertThat(weighted.calculationRuleVersion()).isEqualTo("CUSTOMER_DIMENSION_EXTRA_LARGE_V3"); + } + + @Test + void deviceDimensionsOutsideConfiguredRulesRemainUnchanged() { + var weighted = service.load(10001L).resolveDeviceMeasurement( + new BigDecimal("60.001"), new BigDecimal("50"), new BigDecimal("50")); + + assertThat(weighted.boxType()).isNull(); + assertThat(weighted.edgeAdditionCm()).isNull(); + assertThat(weighted.outerLengthCm()).isEqualByComparingTo("60.001"); + assertThat(weighted.outerWidthCm()).isEqualByComparingTo("50.000"); + assertThat(weighted.outerHeightCm()).isEqualByComparingTo("50.000"); + assertThat(weighted.calculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1"); + } + + @Test + void incompleteCustomerRulesAreRejectedBeforeMeasurementCalculation() { + when(repository.findAll(10001L)).thenReturn(defaultRules(10001L).subList(0, 3)); + + assertThatThrownBy(() -> service.load(10001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("配置不完整"); + } + + private List defaultRules(long tenantId) { + return Arrays.stream(CustomerDimensionBoxType.values()).map(type -> { + CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity(); + rule.setTenantId(tenantId); rule.setBoxType(type); rule.setVersion(3); + rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm()); + rule.setInnerHeightCm(type.defaultHeightCm()); + rule.setEdgeAdditionCm(new BigDecimal("2.000")); + return rule; + }).toList(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingTerminalRulesTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingTerminalRulesTest.java new file mode 100644 index 000000000..591141b94 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingTerminalRulesTest.java @@ -0,0 +1,62 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.domain.CustomerDimensionBoxType; +import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; +import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** Locks the four customer rules used by both batch and single-carton measurement paths. */ +class CustomerDimensionWeightingTerminalRulesTest { + private final CustomerDimensionConfigRepository repository = mock(CustomerDimensionConfigRepository.class); + private final CustomerDimensionWeightingService service = new CustomerDimensionWeightingService(repository); + + @Test + void resolvesAllFourConfiguredCartonRules() { + when(repository.findAll(10001L)).thenReturn(rules(10001L)); + var ruleSet = service.load(10001L); + + assertWeighted(ruleSet, "10", "10", "10", CustomerDimensionBoxType.SMALL, + "14", "14", "14", "0.003"); + assertWeighted(ruleSet, "35", "25", "25", CustomerDimensionBoxType.MEDIUM, + "39", "29", "29", "0.033"); + assertWeighted(ruleSet, "45", "35", "35", CustomerDimensionBoxType.LARGE, + "49", "39", "39", "0.075"); + assertWeighted(ruleSet, "55", "45", "45", CustomerDimensionBoxType.EXTRA_LARGE, + "59", "49", "49", "0.142"); + } + + private static void assertWeighted(CustomerDimensionWeightingService.RuleSet rules, + String length, String width, String height, + CustomerDimensionBoxType type, + String outerLength, String outerWidth, String outerHeight, + String unitVolume) { + var weighted = rules.resolve(new BigDecimal(length), new BigDecimal(width), new BigDecimal(height)); + assertThat(weighted.boxType()).isEqualTo(type); + assertThat(weighted.outerLengthCm()).isEqualByComparingTo(outerLength); + assertThat(weighted.outerWidthCm()).isEqualByComparingTo(outerWidth); + assertThat(weighted.outerHeightCm()).isEqualByComparingTo(outerHeight); + assertThat(weighted.unitVolumeCbm()).isEqualByComparingTo(unitVolume); + } + + private static List rules(long tenantId) { + return Arrays.stream(CustomerDimensionBoxType.values()).map(type -> { + CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity(); + rule.setTenantId(tenantId); + rule.setBoxType(type); + rule.setVersion(0); + rule.setInnerLengthCm(type.defaultLengthCm()); + rule.setInnerWidthCm(type.defaultWidthCm()); + rule.setInnerHeightCm(type.defaultHeightCm()); + rule.setEdgeAdditionCm(new BigDecimal("2.000")); + return rule; + }).toList(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java new file mode 100644 index 000000000..33b31b3e7 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java @@ -0,0 +1,178 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.CountCartonRequest; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.DiscrepancyAction; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.CountSkuLine; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.UnsealPalletRequest; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.TaskRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelSkuRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonSkuRow; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class DropshipPalletizationServiceTest { + private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class); + private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class); + private final DropshipPalletizationService service=new DropshipPalletizationService(); + DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); } + + @Test void sharedPalletCountsDistinctSkuCodesWithoutMergingInboundDetails(){ + when(mapper.inboundCode(1L,30L)).thenReturn("IN-1"); + when(mapper.palletsByInbound(1L,30L)).thenReturn(java.util.List.of(pallet())); + CartonRow first=carton(),second=carton(); second.setId(61L); second.setInboundId(31L); second.setBoxCode("BOX-2"); + CartonSkuRow sameSku=cartonSku(); sameSku.setInboundDetailId(41L); + CartonSkuRow differentSku=cartonSku(); differentSku.setInboundDetailId(42L); differentSku.setSkuCode("SKU-2"); + when(mapper.cartons(20L)).thenReturn(java.util.List.of(first,second)); + when(mapper.cartonSkus(61L)).thenReturn(java.util.List.of(sameSku)); + var shared=service.inboundPallets(1L,30L).get(0); + assertThat(shared.cartonCount()).isEqualTo(2); + assertThat(shared.skuCount()).isEqualTo(1); + assertThat(shared.items()).hasSize(2); + when(mapper.cartonSkus(61L)).thenReturn(java.util.List.of(sameSku,differentSku)); + assertThat(service.inboundPallets(1L,30L).get(0).skuCount()).isEqualTo(2); + when(mapper.cartons(20L)).thenReturn(java.util.List.of(first)); + assertThat(service.inboundPallets(1L,30L).get(0).skuCount()).isEqualTo(1); + when(mapper.cartons(20L)).thenReturn(java.util.List.of()); + assertThat(service.inboundPallets(1L,30L).get(0).skuCount()).isZero(); + } + + @Test void reviewCreatesExpectedNumberOfPrintablePalletCodes(){ + InboundOrderEntity inbound=dropshipInbound(); when(mapper.insertTask(anyLong(),eq(1L),eq(30L),eq(100L),eq(8))).thenReturn(1); + when(numbers.next("DP")).thenReturn("DP-260911-0001","DP-260911-0002"); + service.createTask(inbound,7L,"张三"); + verify(mapper,times(2)).insertPallet(anyLong(),anyLong(),eq(1L),eq(30L),eq(100L),startsWith("DP-"),startsWith("dropship-precreate-"),eq(7L),eq("张三")); + } + + @Test void replayedReviewDoesNotGenerateDuplicatePalletCodes(){ + when(mapper.insertTask(anyLong(),anyLong(),anyLong(),anyLong(),anyInt())).thenReturn(0); + service.createTask(dropshipInbound(),7L,"张三"); + verifyNoInteractions(numbers); verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString()); + } + + @Test void taskReadinessCountsGeneratedPalletsByInboundInsteadOfTaskId(){ + when(mapper.task(10L,100L)).thenReturn(task()); + when(mapper.palletCountByInbound(1L,30L)).thenReturn(2); + when(mapper.pallets(10L)).thenReturn(java.util.List.of()); + when(mapper.unboundCartons(10L)).thenReturn(java.util.List.of()); + + var result=service.task(10L,100L,7L); + + assertThat(result.generatedPalletCount()).isEqualTo(2); + assertThat(result.palletSetupReady()).isTrue(); + verify(mapper).palletCountByInbound(1L,30L); + } + + @Test void taskPrintReadsGeneratedPalletsFromTheInboundOrder(){ + PalletRow generated=pallet(); generated.setTaskId(99L); + when(mapper.task(10L,100L)).thenReturn(task()); + when(mapper.generatedPalletsByInbound(1L,30L)).thenReturn(java.util.List.of(generated)); + when(mapper.cartons(20L)).thenReturn(java.util.List.of()); + + var result=service.generatedPallets(10L,100L,7L); + + assertThat(result).singleElement().extracting("palletNo").isEqualTo("DP-260911-0001"); + verify(mapper).generatedPalletsByInbound(1L,30L); + } + + @Test void resolvesUncountedCartonBeforeQuantityConfirmation(){ + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.palletCountByInbound(1L,30L)).thenReturn(2); when(mapper.label(10L,"BOX-1")).thenReturn(label()); + var result=service.resolveCarton(10L,100L,"BOX-1",7L); + assertThat(result.inboundCode()).isEqualTo("IN-1"); assertThat(result.skuLines()).extracting("expectedQuantity").containsExactly(12); assertThat(result.canCount()).isTrue(); + } + + @Test void cartonCanBindDirectlyToAPreGeneratedPallet(){ + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.palletCountByInbound(1L,30L)).thenReturn(2); when(mapper.label(10L,"BOX-1")).thenReturn(label()); when(mapper.palletByNo("DP-260911-0001")).thenReturn(pallet()); + when(mapper.addCounted(10L,7L,1,0)).thenReturn(1); when(mapper.carton(eq(10L),anyLong())).thenReturn(carton()); + when(mapper.pallet(10L,20L,100L)).thenReturn(pallet()); when(mapper.cartons(20L)).thenReturn(java.util.List.of()); + service.countCarton(10L,countRequest("DP-260911-0001"),7L,"张三"); + verify(mapper).insertCarton(anyLong(),eq(10L),eq(20L),eq(1L),eq(30L),eq(40L),eq(50L),eq("SKU-1"),any(),eq("BOX-1"),eq(12),eq(12),eq("MATCH"),isNull(),eq("op-1"),eq(7L),eq("张三")); + verify(mapper).insertCartonSku(anyLong(),anyLong(),eq(40L),eq("SKU-1"),any(),eq(12),eq(12),eq("MATCH"),isNull(),eq(1)); + } + + @Test void cartonCanBindToAnOpenPalletGeneratedForAnotherTaskInTheSameWarehouse(){ + TaskRow current=task(); PalletRow shared=pallet(); shared.setTaskId(99L); shared.setTenantId(2L); shared.setInboundId(88L); + when(mapper.task(10L,100L)).thenReturn(current); when(mapper.palletCountByInbound(1L,30L)).thenReturn(2); + when(mapper.label(10L,"BOX-1")).thenReturn(label()); when(mapper.palletByNo("DP-260911-0001")).thenReturn(shared); + when(mapper.addCounted(10L,7L,1,0)).thenReturn(1); when(mapper.carton(eq(10L),anyLong())).thenReturn(carton()); + when(mapper.pallet(10L,20L,100L)).thenReturn(shared); when(mapper.cartons(20L)).thenReturn(java.util.List.of()); + service.countCarton(10L,countRequest("DP-260911-0001"),7L,"张三"); + verify(mapper).insertCarton(anyLong(),eq(10L),eq(20L),eq(1L),eq(30L),anyLong(),anyLong(),anyString(),any(),eq("BOX-1"),anyInt(),anyInt(),eq("MATCH"),isNull(),eq("op-1"),eq(7L),eq("张三")); + } + + @Test void palletFromAnotherWarehouseCannotBeMixed(){ + PalletRow other=pallet(); other.setWarehouseId(101L); + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.palletCountByInbound(1L,30L)).thenReturn(2); + when(mapper.label(10L,"BOX-1")).thenReturn(label()); when(mapper.palletByNo("DP-260911-0001")).thenReturn(other); + assertThatThrownBy(()->service.countCarton(10L,countRequest("DP-260911-0001"),7L,"张三")) + .isInstanceOf(BizException.class).hasMessageContaining("当前作业仓库"); + } + + @Test void unknownPalletCodeCannotCreateAnAdHocPallet(){ + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.palletCountByInbound(1L,30L)).thenReturn(2); when(mapper.label(10L,"BOX-1")).thenReturn(label()); + var request=countRequest("DP-260911-9999"); + assertThatThrownBy(()->service.countCarton(10L,request,7L,"张三")).isInstanceOf(BizException.class).hasMessageContaining("测量审核后生成"); + verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString()); + } + + private static InboundOrderEntity dropshipInbound(){InboundOrderEntity i=new InboundOrderEntity();i.setId(30L);i.setTenantId(1L);i.setWarehouseId(100L);i.setFulfillmentType("ONE_CLICK_DROPSHIP");i.setTotalPackageCount(8);i.setPalletCount(2);return i;} + @Test void incompletePreGeneratedPalletsBlockCounting(){ + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.palletCountByInbound(1L,30L)).thenReturn(0); when(mapper.label(10L,"BOX-1")).thenReturn(label()); + var result=service.resolveCarton(10L,100L,"BOX-1",7L); + assertThat(result.canCount()).isFalse(); assertThat(result.blockReason()).contains("预计 2 托").contains("当前仅生成 0 个托盘码"); + assertThatThrownBy(()->service.countCarton(10L,countRequest(null),7L,"张三")) + .isInstanceOf(BizException.class).hasMessageContaining("预计 2 托"); + } + + @Test void sealedPalletCanReopenBeforeItEntersALoadingPlan(){ + TaskRow current=task(); PalletRow sealed=sealedPallet(); + when(mapper.task(10L,100L)).thenReturn(current); when(mapper.pallet(10L,20L,100L)).thenReturn(sealed); + when(mapper.taskIdsByPallet(20L)).thenReturn(java.util.List.of(10L)); when(mapper.unseal(10L,20L,1,100L,"undo-1")).thenReturn(1); + when(mapper.palletCountByInbound(1L,30L)).thenReturn(2); when(mapper.pallets(10L)).thenReturn(java.util.List.of()); + var result=service.unseal(10L,20L,new UnsealPalletRequest(100L,"undo-1",1),7L,"张三"); + assertThat(result.taskStatus()).isEqualTo("CLAIMED"); + verify(mapper).activeLoadingAssignmentCount(20L); verify(mapper).unseal(10L,20L,1,100L,"undo-1"); + verify(mapper,never()).reopenTask(anyLong()); verify(mapper,never()).reopenInbound(anyLong(),anyLong(),anyLong()); + } + + @Test void unsealingACompletedSharedPalletReopensEveryParticipatingInbound(){ + TaskRow completed=task(); completed.setTaskStatus("COMPLETED"); completed.setProcessStatus("WAIT_SPLIT"); + TaskRow other=task(); other.setId(11L); other.setTenantId(2L); other.setInboundId(31L); other.setTaskStatus("COMPLETED"); other.setProcessStatus("WAIT_SPLIT"); + TaskRow reopened=task(); + when(mapper.task(10L,100L)).thenReturn(completed,completed,reopened); + when(mapper.task(11L,100L)).thenReturn(other); when(mapper.pallet(10L,20L,100L)).thenReturn(sealedPallet()); + when(mapper.taskIdsByPallet(20L)).thenReturn(java.util.List.of(10L,11L)); when(mapper.unseal(10L,20L,1,100L,"undo-2")).thenReturn(1); + when(mapper.reopenTask(10L)).thenReturn(1); when(mapper.reopenTask(11L)).thenReturn(1); + when(mapper.reopenInbound(1L,30L,7L)).thenReturn(1); when(mapper.reopenInbound(2L,31L,7L)).thenReturn(1); + when(mapper.palletCountByInbound(1L,30L)).thenReturn(2); when(mapper.pallets(10L)).thenReturn(java.util.List.of()); + service.unseal(10L,20L,new UnsealPalletRequest(100L,"undo-2",1),7L,"张三"); + verify(mapper).reopenTask(10L); verify(mapper).reopenTask(11L); + verify(mapper).reopenInbound(1L,30L,7L); verify(mapper).reopenInbound(2L,31L,7L); + } + + @Test void palletAssignedToALoadingPlanCannotBeUnsealed(){ + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.pallet(10L,20L,100L)).thenReturn(sealedPallet()); + when(mapper.activeLoadingAssignmentCount(20L)).thenReturn(1); + assertThatThrownBy(()->service.unseal(10L,20L,new UnsealPalletRequest(100L,"undo-3",1),7L,"张三")) + .isInstanceOf(BizException.class).hasMessageContaining("已进入装柜计划"); + verify(mapper,never()).unseal(anyLong(),anyLong(),anyInt(),anyLong(),anyString()); + } + + private static TaskRow task(){TaskRow t=new TaskRow();t.setId(10L);t.setTenantId(1L);t.setInboundId(30L);t.setWarehouseId(100L);t.setInboundCode("IN-1");t.setTaskStatus("CLAIMED");t.setAssigneeId(7L);t.setProcessStatus("WAIT_PALLETIZE");t.setTotalCartons(2);t.setCountedCartons(0);t.setAllocatedCartons(0);t.setDiscrepancyCartons(0);t.setExpectedPalletCount(2);t.setVersion(1);return t;} + private static LabelRow label(){LabelRow l=new LabelRow();l.setCartonLabelId(50L);l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setUnitsPerCarton(12);l.setInboundCode("IN-1");l.setBoxCode("BOX-1");return l;} + private static LabelSkuRow labelSku(){LabelSkuRow l=new LabelSkuRow();l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setExpectedQuantity(12);l.setLineNo(1);return l;} + private static CartonSkuRow cartonSku(){CartonSkuRow l=new CartonSkuRow();l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setExpectedQuantity(12);l.setActualQuantity(12);l.setDiscrepancyStatus("MATCH");l.setLineNo(1);return l;} + private static CountCartonRequest countRequest(String palletNo){return new CountCartonRequest(100L,"BOX-1",java.util.List.of(new CountSkuLine(40L,"SKU-1",12,DiscrepancyAction.MATCH,null)),palletNo,"op-1");} + private static PalletRow pallet(){PalletRow p=new PalletRow();p.setId(20L);p.setTaskId(10L);p.setTenantId(1L);p.setInboundId(30L);p.setWarehouseId(100L);p.setPalletNo("DP-260911-0001");p.setPalletStatus("OPEN");p.setVersion(1);return p;} + private static PalletRow sealedPallet(){PalletRow p=pallet();p.setPalletStatus("SEALED");return p;} + private static CartonRow carton(){CartonRow c=new CartonRow();c.setId(60L);c.setTaskId(10L);c.setPalletId(20L);c.setInboundId(30L);c.setInboundDetailId(40L);c.setPalletNo("DP-260911-0001");c.setSkuCode("SKU-1");c.setCargoName("货物");c.setBoxCode("BOX-1");c.setExpectedSkuQuantity(12);c.setActualSkuQuantity(12);c.setDiscrepancyStatus("MATCH");return c;} +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java new file mode 100644 index 000000000..b7d766724 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java @@ -0,0 +1,121 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest; +import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest; +import com.baiyue.firstleg.entity.CarrierFbaRateEntity; +import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; +import com.baiyue.firstleg.repository.FbaPricingRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("FBA计费基础资料版本") +@SpringJUnitConfig(FbaMasterDataService.class) +class FbaMasterDataServiceTest { + private static final LocalDateTime AUGUST = LocalDateTime.of(2026, 8, 1, 0, 0); + + @MockBean + private FbaPricingRepository repository; + @Resource + private FbaMasterDataService service; + + @Test + @DisplayName("平台列表不传企业时委托仓储查询全部企业规则") + void listsRulesAcrossTenantsWithoutTenantFilter() { + List expected = List.of(activeRule(AUGUST, null)); + when(repository.listRules(null, "CR-1")).thenReturn(expected); + + List result = service.listRules(null, "CR-1"); + + assertThat(result).isSameAs(expected); + verify(repository).listRules(null, "CR-1"); + } + + @Test + @DisplayName("有效期合法时创建客户规则") + void createsRuleWithValidPeriod() { + CustomerChargeRuleEntity old = activeRule(AUGUST.minusMonths(1), AUGUST); + when(repository.listRules(1L, "CR-1")).thenReturn(List.of(old)); + when(repository.nextRuleVersion(1L, "CR-1")).thenReturn(2); + doAnswer(invocation -> { ((CustomerChargeRuleEntity) invocation.getArgument(0)).setId(10L); return null; }) + .when(repository).insertRule(any()); + + CustomerChargeRuleEntity created = service.createRuleVersion( + 1L, 2L, request(AUGUST, AUGUST.plusMonths(1))); + + assertThat(created.getId()).isEqualTo(10L); + assertThat(created.getRuleVersion()).isEqualTo(2); + assertThat(created.getEffectiveFrom()).isEqualTo(AUGUST); + assertThat(created.getEffectiveTo()).isEqualTo(AUGUST.plusMonths(1)); + } + + @Test + @DisplayName("重叠配置允许新增且新增时固定不启用") + void createsOverlappingRuleAsNonDefault() { + when(repository.nextRuleVersion(1L, "CR-1")).thenReturn(2); + doAnswer(invocation -> { ((CustomerChargeRuleEntity) invocation.getArgument(0)).setId(11L); return null; }) + .when(repository).insertRule(any()); + + CustomerChargeRuleEntity created = service.createRuleVersion( + 1L, 2L, request(AUGUST.plusDays(1), AUGUST.plusMonths(2), true)); + + assertThat(created.getId()).isEqualTo(11L); + assertThat(created.getDefaultRule()).isFalse(); + verify(repository, never()).clearDefaultRule(anyLong(), anyLong(), anyLong()); + verify(repository, never()).setDefaultRule(anyLong(), anyLong(), anyLong()); + verify(repository, never()).listRules(1L, null); + } + + @Test + @DisplayName("船司价格版本固定写入平台公共作用域") + void createsCarrierRateInPlatformScope() { + when(repository.listRatesForUpdate(null)).thenReturn(List.of()); + when(repository.nextRateVersion("RATE-1")).thenReturn(1); + doAnswer(invocation -> { ((CarrierFbaRateEntity) invocation.getArgument(0)).setId(20L); return null; }) + .when(repository).insertRate(any()); + + CarrierFbaRateEntity created = service.createRateVersion(2L, carrierRateRequest()); + + assertThat(created.getTenantId()).isEqualTo(CarrierFbaRateEntity.PLATFORM_SCOPE); + verify(repository).listRatesForUpdate(null); + verify(repository).nextRateVersion("RATE-1"); + verify(repository).insertRate(any()); + } + + private CustomerRuleVersionRequest request(LocalDateTime from, LocalDateTime to) { + return request(from, to, null); + } + + private CustomerRuleVersionRequest request(LocalDateTime from, LocalDateTime to, Boolean defaultRule) { + return new CustomerRuleVersionRequest("CR-1", 100L, "客户A", "FBA_SEA", "KG", + "TAX_INCLUDED", new BigDecimal("167"), 3, "CEILING", "USD", from, to, null, + BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, defaultRule); + } + + private CarrierRateVersionRequest carrierRateRequest() { + return new CarrierRateVersionRequest("RATE-1", 21L, "义乌仓", "FBA_SEA", "SEA-TAX", + 31L, "船司A", "ONT8", "KG", new BigDecimal("2.50"), "USD", new BigDecimal("10"), + AUGUST, null, null); + } + + private CustomerChargeRuleEntity activeRule(LocalDateTime from, LocalDateTime to) { + CustomerChargeRuleEntity value = new CustomerChargeRuleEntity(); + value.setRuleCode("CR-1"); value.setRuleVersion(1); value.setCustomerId(100L); + value.setBusinessType("FBA_SEA"); value.setTaxMode("TAX_INCLUDED"); value.setStatus("ACTIVE"); + value.setEffectiveFrom(from); value.setEffectiveTo(to); return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java new file mode 100644 index 000000000..fa199eb69 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -0,0 +1,1071 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.domain.PriceModel; +import com.baiyue.firstleg.domain.PricingTaxMode; +import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; +import com.baiyue.firstleg.entity.CarrierFbaRateEntity; +import com.baiyue.firstleg.entity.InboundFeeEntity; +import com.baiyue.firstleg.entity.InboundFeeItemEntity; +import com.baiyue.firstleg.entity.InboundPricingEntity; +import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; +import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; +import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; +import com.baiyue.firstleg.pricing.ContainerTierAmountAllocator; +import com.baiyue.firstleg.repository.FbaPricingRepository; +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; +import org.mockito.ArgumentCaptor; +import org.apache.ibatis.annotations.Select; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.concurrent.atomic.AtomicReference; +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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("FBA费用状态机") +@SpringJUnitConfig(classes = {FbaPricingService.class, FbaPricingServiceTest.TestConfig.class}) +class FbaPricingServiceTest { + @MockBean private FinanceFeeSourceOutboxService financeOutbox; + private static final long TENANT = 10001L; + private static final long OPERATOR = 20001L; + private static final long INBOUND = 30001L; + private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 3, 10, 0); + @MockBean private FbaPricingRepository repository; + @MockBean private com.baiyue.firstleg.repository.InboundMeasurementRepository measurementRepository; + @MockBean private InboundFeeDetailService feeDetails; + @MockBean private ActivePriceBookEstimator priceBookEstimator; + @MockBean private ContainerTierAmountAllocator containerTierAllocator; + @Resource private FbaPricingService service; + + @Test + @DisplayName("客户B不能读取客户A的费用快照") + void rejectsCrossCustomerFeeLookup() { + assertThatThrownBy(() -> service.latest(10002L, INBOUND)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + + verify(repository).findLatestFee(10002L, INBOUND); + verify(repository, never()).findLatestFee(TENANT, INBOUND); + } + + @Test + @DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商") + void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() { + readyRepository(); + InboundPricingEntity inbound = inbound("100.000", "1.0001"); + inbound.setCarrierId(null); + inbound.setCarrierName(null); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( + estimate("KG", "167.017", "2.345678", "391.77", "10.00")); + + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1"); + + assertThat(response.convertedQuantity()).isEqualByComparingTo("167.017"); + assertThat(response.chargeableQuantity()).isEqualByComparingTo("167.017"); + assertThat(response.subtotalAmount()).isEqualByComparingTo("391.77"); + assertThat(response.totalAmount()).isEqualByComparingTo("401.77"); + assertThat(response.priceBookVersionId()).isEqualTo(701L); + assertThat(response.pricingTaxMode()).isEqualTo("TAX_INCLUDED"); + assertThat(response.conversionFactor()).isEqualByComparingTo("167"); + assertThat(response.roundingScale()).isEqualTo(6); + assertThat(response.roundingMode()).isEqualTo("HALF_UP"); + assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING"); + ArgumentCaptor fee = ArgumentCaptor.forClass(InboundFeeEntity.class); + verify(repository).insertFee(fee.capture()); + assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION"); + assertThat(fee.getValue().getPricingTaxMode()).isEqualTo("TAX_INCLUDED"); + assertThat(fee.getValue().getChargeRuleId()).isNull(); + assertThat(fee.getValue().getCarrierId()).isNull(); + assertThat(fee.getValue().getCustomerId()).isEqualTo(TENANT); + assertThat(fee.getValue().getConversionFactor()).isEqualByComparingTo("167"); + assertThat(fee.getValue().getRoundingScale()).isEqualTo(6); + assertThat(fee.getValue().getRoundingMode()).isEqualTo("HALF_UP"); + } + + @Test + @DisplayName("外箱规计费保存测量批次和尺寸来源快照") + void bindsSavedOuterMeasurementWhenCalculatingCustomerFee() { + readyRepository(); + InboundPricingEntity inbound = inbound("62.500", "1.0019"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(301L); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setOuterLengthCm(new BigDecimal("34")); + item.setOuterWidthCm(new BigDecimal("24")); + item.setOuterHeightCm(new BigDecimal("64")); + item.setCalculationRuleVersion("CUSTOMER_DIMENSION_SMALL_V3"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement); + when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(item)); + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( + estimate("CBM", "1.0019", "100", "100.19", "0")); + + service.calculate(TENANT, OPERATOR, INBOUND, NOW, + new BigDecimal("62.500"), new BigDecimal("1.0019"), + "OUTER", "OUTER", "calc-outer"); + + ArgumentCaptor fee = ArgumentCaptor.forClass(InboundFeeEntity.class); + verify(repository).insertFee(fee.capture()); + assertThat(fee.getValue().getMeasurementId()).isEqualTo(301L); + assertThat(fee.getValue().getMeasurementSource()).isEqualTo("OUTER"); + assertThat(fee.getValue().getActualVolumeCbm()).isEqualByComparingTo("1.0019"); + } + + @Test + @DisplayName("指定实际测量来源时必须存在测量批次和明细") + void requiresActualMeasurementDetailsForActualSource() { + readyRepository(); + InboundPricingEntity inbound = inbound("100.000", "0.7880"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + + assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, + new BigDecimal("100.000"), new BigDecimal("0.7880"), + "ACTUAL", "ACTUAL", "calc-actual-missing")) + .isInstanceOf(BizException.class) + .hasMessageContaining("实际测量数据不存在"); + + verify(priceBookEstimator, never()).estimate(any()); + verify(repository, never()).insertFee(any()); + } + + @Test + @DisplayName("订单客户字段为空时使用租户归属生成对客费用") + void usesTenantOwnershipWhenOrderCustomerIsMissing() { + readyRepository(); + InboundPricingEntity inbound = inbound("200.000", "0.2000"); + inbound.setCustomerId(null); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( + estimate("KG", "200.000", "2.000000", "400.00", "0.00")); + + assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-no-customer")) + .isNotNull(); + ArgumentCaptor fee = ArgumentCaptor.forClass(InboundFeeEntity.class); + verify(repository).insertFee(fee.capture()); + assertThat(fee.getValue().getCustomerId()).isEqualTo(TENANT); + } + + @Test + @DisplayName("不包税CBM使用生效价格表返回的计费量") + void calculatesTaxExcludedCbmFromActivePriceBook() { + readyRepository(); + InboundPricingEntity inbound = inbound("363.001", "0.5000"); + inbound.setPricingTaxMode("TAX_EXCLUDED"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); + rule.setId(601L); rule.setRuleCode("CBM-RULE"); rule.setRuleVersion(1); + rule.setChargeMode("CBM"); rule.setDefaultRule(true); + when(repository.matchRules(eq(inbound), any())).thenReturn(List.of(rule)); + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( + estimate("CBM", "1.0001", "100.000000", "100.01", "0.00")); + + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2"); + + assertThat(response.convertedQuantity()).isEqualByComparingTo("1.0001"); + assertThat(response.chargeableQuantity()).isEqualByComparingTo("1.0001"); + assertThat(response.totalAmount()).isEqualByComparingTo("100.01"); + assertThat(response.conversionFactor()).isEqualByComparingTo("363"); + assertThat(response.roundingScale()).isEqualTo(6); + assertThat(response.roundingMode()).isEqualTo("HALF_UP"); + assertThat(response.chargeRuleId()).isEqualTo(601L); + assertThat(response.ruleCode()).isEqualTo("CBM-RULE"); + } + + @Test + @DisplayName("待分柜边界可独立计费且不回退主流程状态") + void calculatesAtWaitSplitWithoutRollingBackProcessStatus() { + readyRepository(); + InboundPricingEntity inbound = inbound("100.000", "1.0000"); + inbound.setProcessStatus("WAIT_SPLIT"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( + estimate("KG", "167.000", "2.000000", "334.00", "0.00")); + + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split"); + + assertThat(response.feeStatus()).isEqualTo("CALCULATED"); + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); + verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG", + new BigDecimal("100.000"), new BigDecimal("1.0000"), new BigDecimal("167.000")); + verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 501L, OPERATOR, NOW); + verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any()); + } + + @Test + @DisplayName("待分柜边界确认费用后仍保持主流程状态") + void confirmsAtWaitSplitWithoutRollingBackProcessStatus() { + InboundPricingEntity inbound = inbound("10.000", "1.0000"); + inbound.setProcessStatus("WAIT_SPLIT"); + inbound.setFeeStatus("CALCULATED"); + InboundFeeEntity fee = savedFee("CALCULATED"); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CONFIRM", "confirm-wait-split")) + .thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.confirmFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1); + when(repository.markInboundConfirmed(TENANT, INBOUND)).thenReturn(1); + when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 501L)).thenReturn(fee); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + + FeeSnapshotResponse response = service.confirm( + TENANT, OPERATOR, INBOUND, "confirm-wait-split"); + + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); + verify(repository).markInboundConfirmed(TENANT, INBOUND); + } + + @Test + @DisplayName("装柜完成但客户未确认时仍允许确认新费用版本") + void confirmsNewFeeVersionAfterLoadingCompleted() { + InboundPricingEntity inbound = inbound("10.000", "1.0000"); + inbound.setProcessStatus("LOADED"); + inbound.setFeeStatus("CALCULATED"); + InboundFeeEntity fee = savedFee("CALCULATED"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CONFIRM", "confirm-loaded")) + .thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(899L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.confirmFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1); + when(repository.markInboundConfirmed(TENANT, INBOUND)).thenReturn(1); + when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 501L)).thenReturn(fee); + + FeeSnapshotResponse response = service.confirm(TENANT, OPERATOR, INBOUND, "confirm-loaded"); + + assertThat(response.nextProcessStatus()).isEqualTo("LOADED"); + verify(repository).markInboundConfirmed(TENANT, INBOUND); + } + + @Test + @DisplayName("审核未通过的测量快照也允许计费") + void allowsUnapprovedMeasurementSnapshot() { + readyRepository(); + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setMeasureReviewStatus("REJECTED"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( + estimate("CBM", "1.0000", "10", "10", "0")); + + assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3")).isNotNull(); + verify(priceBookEstimator).estimate(eq(TENANT), any()); + } + + @Test + @DisplayName("一件代发待合托状态允许初算且不改变作业状态") + void calculatesDropshipFeeWhileWaitingForPalletization() { + readyRepository(); + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_PALLETIZE"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( + estimate("CBM", "1.0000", "10", "10", "0")); + + FeeSnapshotResponse response = service.calculate( + TENANT, OPERATOR, INBOUND, NOW, "dropship-initial-pricing"); + + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PALLETIZE"); + verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_PALLETIZE", "CBM", + new BigDecimal("10.000"), new BigDecimal("1.0000"), new BigDecimal("1.0000")); + } + + @Test + @DisplayName("客户未确认时装柜完成订单仍可重新计算并替换已发送费用") + void recalculatesSentFeeBeforeCustomerConfirmation() { + readyRepository(); + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("LOADED"); + inbound.setFeeStatus("SENT"); + InboundFeeEntity sent = savedFee("SENT"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1); + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( + estimate("CBM", "1.0000", "10", "10", "0")); + + FeeSnapshotResponse response = service.calculate( + TENANT, OPERATOR, INBOUND, NOW, "resend-before-confirm"); + + assertThat(response.feeVersion()).isEqualTo(2); + assertThat(response.feeStatus()).isEqualTo("CALCULATED"); + assertThat(response.nextProcessStatus()).isEqualTo("LOADED"); + verify(repository).supersedeCurrent(TENANT, 501L, "SENT"); + verify(repository).markInboundCalculated(TENANT, INBOUND, "LOADED", "CBM", + new BigDecimal("10.000"), new BigDecimal("1.0000"), new BigDecimal("1.0000")); + } + + @Test + @DisplayName("客户已确认且装柜完成时拒绝重新计算已发送费用") + void rejectsRecalculationAfterCustomerConfirmation() { + readyRepository(); + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("LOADED"); + inbound.setFeeStatus("SENT"); + InboundFeeEntity sent = savedFee("SENT"); + sent.setCustomerConfirmedAt(NOW); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent); + + assertThatThrownBy(() -> service.calculate( + TENANT, OPERATOR, INBOUND, NOW, "resend-after-confirm")) + .isInstanceOf(BizException.class) + .hasMessageContaining("客户已确认"); + + verify(priceBookEstimator, never()).estimate(any()); + verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any()); + verify(repository, never()).insertFee(any()); + } + + @Test + @DisplayName("当前生效价格表无匹配价格时明确拒绝") + void rejectsMissingActivePriceBookRate() { + readyRepository(); + InboundPricingEntity inbound = inbound("10", "1"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(priceBookEstimator.estimate(anyLong(), any())).thenThrow( + new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格")); + + assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none")) + .isInstanceOf(BizException.class).hasMessageContaining("没有匹配价格"); + verify(repository, never()).insertFee(any()); + } + + @Test + @DisplayName("同一幂等键重复试算直接返回原费用快照") + void returnsOriginalSnapshotForRepeatedCalculation() { + InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L); + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed); + when(repository.findFee(TENANT, 501L)).thenReturn(savedFee("CALCULATED")); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00")); + when(repository.findBaseItem(TENANT, 501L)).thenReturn(item); + + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same"); + + assertThat(response.feeId()).isEqualTo(501L); + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); + verify(repository, never()).matchRules(any(), any()); + verify(repository, never()).insertFee(any()); + } + + @Test + @DisplayName("重复发送只返回首次结果且不会重复写Outbox") + void sendIsIdempotentAndDoesNotDuplicateOutbox() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("FEE_CONFIRMING"); + inbound.setFeeStatus("CONFIRMED"); + InboundFeeEntity confirmed = savedFee("CONFIRMED"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(confirmed); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-once")) + .thenReturn(null, completed("FEE_SEND", 501L)); + when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1); + when(repository.markInboundFeeSent(TENANT, INBOUND, "FEE_CONFIRMING")).thenReturn(1); + when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 501L)).thenReturn(confirmed); + InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00")); + when(repository.findBaseItem(TENANT, 501L)).thenReturn(item); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) + .when(repository).insertIdempotency(any()); + + FeeSnapshotResponse first = service.send(TENANT, OPERATOR, INBOUND, "send-once"); + FeeSnapshotResponse repeated = service.send(TENANT, OPERATOR, INBOUND, "send-once"); + + assertThat(first.nextProcessStatus()).isEqualTo("WAIT_PALLET"); + assertThat(repeated.nextProcessStatus()).isEqualTo("WAIT_PALLET"); + verify(financeOutbox, times(1)).publishInbound( + eq(FinanceFeeSourceOutboxService.PUBLISHED), eq(TENANT), eq(INBOUND), + eq(501L), eq(1), eq(NOW)); + verify(repository, times(1)).sendFee(TENANT, 501L, OPERATOR, NOW); + verify(feeDetails, times(2)).publishFreight(TENANT, INBOUND, 501L, OPERATOR, null, NOW); + } + + @Test + @DisplayName("已入库费用发送只发布费用且不回退主流程状态") + void sendsFeeAfterInboundWithoutRollingBackProcessStatus() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + inbound.setFeeStatus("CONFIRMED"); + InboundFeeEntity confirmed = savedFee("CONFIRMED"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(confirmed); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-inbounded")).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1); + when(repository.markInboundFeeSent(TENANT, INBOUND, "WAIT_SPLIT")).thenReturn(1); + when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 501L)).thenReturn(confirmed); + + FeeSnapshotResponse response = service.send(TENANT, OPERATOR, INBOUND, "send-inbounded"); + + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); + verify(repository).markInboundFeeSent(TENANT, INBOUND, "WAIT_SPLIT"); + verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED), + eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW)); + } + + @Test + @DisplayName("同一幂等键用于不同试算时点会冲突") + void rejectsReusedIdempotencyKeyWithDifferentRequest() { + InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L); + completed.setRequestHash("different"); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed); + + assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same")) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + } + + @Test + @DisplayName("已发布费用可生成新版本且不回退已入库状态") + void adjustsPublishedFeeWithoutRollingBackInbound() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + inbound.setFeeStatus("SENT"); + InboundFeeEntity current = savedFee("SENT"); + AtomicReference inserted = new AtomicReference<>(); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "adjust-1")).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(901L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1); + when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("SENT"), + any(), any(), eq("KG"), any())) + .thenReturn(1); + doAnswer(invocation -> { + InboundFeeEntity fee = invocation.getArgument(0); + fee.setId(502L); + inserted.set(fee); + return null; + }).when(repository).insertFee(any()); + when(repository.completeIdempotency(any(), eq(502L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 502L)).thenAnswer(invocation -> inserted.get()); + + FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("160"), new BigDecimal("8.00"), + new BigDecimal("20.00"), "客服修正", true), "adjust-1"); + + assertThat(response.feeVersion()).isEqualTo(2); + assertThat(response.feeStatus()).isEqualTo("SENT"); + assertThat(response.totalAmount()).isEqualByComparingTo("1300.00"); + assertThat(response.baselineChargeableQuantity()).isEqualByComparingTo("167.000000"); + assertThat(response.lowerChargeableQuantity()).isEqualByComparingTo("150.300000"); + assertThat(response.upperChargeableQuantity()).isEqualByComparingTo("183.700000"); + assertThat(response.tolerancePercent()).isEqualByComparingTo("10.00"); + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(inbound.getProcessStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("160.0000"); + verify(repository).supersedeCurrent(TENANT, 501L, "SENT"); + verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED), + eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW)); + } + + @Test + @DisplayName("客户确认已发送费用后记录确认人并锁定版本") + void customerConfirmsSentFeeAndLocksVersion() { + InboundFeeEntity current = savedFee("SENT"); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + when(repository.findIdempotency(TENANT, OPERATOR, "CUSTOMER_FEE_CONFIRM", "customer-confirm-1")) + .thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(910L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.confirmFeeByCustomer(TENANT, 501L, 1, OPERATOR, "客户成员", NOW)) + .thenAnswer(invocation -> { + current.setCustomerConfirmedBy(OPERATOR); + current.setCustomerConfirmedByName("客户成员"); + current.setCustomerConfirmedAt(NOW); + return 1; + }); + when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 501L)).thenReturn(current); + + FeeSnapshotResponse response = service.confirmByCustomer( + TENANT, OPERATOR, "客户成员", INBOUND, 1, "customer-confirm-1"); + + assertThat(response.customerConfirmationStatus()).isEqualTo("CONFIRMED"); + assertThat(response.customerConfirmedBy()).isEqualTo(OPERATOR); + assertThat(response.customerConfirmedByName()).isEqualTo("客户成员"); + assertThat(response.customerConfirmedAt()).isEqualTo(NOW); + } + + @Test + @DisplayName("客户确认后即拒绝平台再次修改") + void rejectsAdjustmentAfterCustomerConfirmation() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + InboundFeeEntity current = savedFee("SENT"); + current.setCustomerConfirmedAt(NOW); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + + assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("12.5"), new BigDecimal("8.00"), + BigDecimal.ZERO, "客服修正", true), "locked-adjust")) + .isInstanceOf(BizException.class) + .hasMessageContaining("客户已确认"); + + verify(repository, never()).insertIdempotency(any()); + verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any()); + } + + @Test + @DisplayName("初算费用调整后保持初算状态且不发布财务来源") + void adjustsCalculatedFeeWithoutPublishingIt() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + inbound.setFeeStatus("CALCULATED"); + InboundFeeEntity current = savedFee("CALCULATED"); + AtomicReference inserted = new AtomicReference<>(); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "adjust-calculated")).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(902L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1); + when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("CALCULATED"), + any(), any(), eq("KG"), any())) + .thenReturn(1); + doAnswer(invocation -> { + InboundFeeEntity fee = invocation.getArgument(0); + fee.setId(503L); + inserted.set(fee); + return null; + }).when(repository).insertFee(any()); + when(repository.completeIdempotency(any(), eq(503L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 503L)).thenAnswer(invocation -> inserted.get()); + + FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("160"), new BigDecimal("8.00"), + new BigDecimal("20.00"), "客服修正", true), "adjust-calculated"); + + assertThat(response.feeStatus()).isEqualTo("CALCULATED"); + assertThat(response.totalAmount()).isEqualByComparingTo("1300.00"); + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); + verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED"); + verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 503L, OPERATOR, NOW); + verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any()); + } + + @Test + @DisplayName("费用版本已变化时拒绝覆盖当前版本") + void rejectsAdjustmentWithStaleFeeVersion() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + InboundFeeEntity current = savedFee("CALCULATED"); + current.setFeeVersion(2); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + + assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("12.5"), new BigDecimal("8.00"), + BigDecimal.ZERO, "客服修正", true), "stale-adjust")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + + verify(repository, never()).insertIdempotency(any()); + verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any()); + } + + @Test + @DisplayName("人工计费量允许上下10%含边界") + void acceptsInclusiveToleranceBoundaries() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + InboundFeeEntity current = savedFee("CALCULATED"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "lower-bound")).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(903L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1); + when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("CALCULATED"), + any(), any(), eq("KG"), any())) + .thenReturn(1); + doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(504L); return null; }) + .when(repository).insertFee(any()); + when(repository.completeIdempotency(any(), eq(504L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 504L)).thenAnswer(invocation -> current); + + service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("150.3"), new BigDecimal("2"), BigDecimal.ZERO, + "下界确认", true), "lower-bound"); + + verify(repository).insertFee(argThat(fee -> + fee.getChargeableQuantity().compareTo(new BigDecimal("150.3000")) == 0)); + } + + @Test + @DisplayName("人工计费量越过10%或未二次确认返回冲突") + void rejectsOutOfToleranceOrUnconfirmedAdjustment() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(savedFee("CALCULATED")); + when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(904L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + + assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("183.7001"), new BigDecimal("2"), BigDecimal.ZERO, + "越界", true), "outside")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("167"), new BigDecimal("2"), BigDecimal.ZERO, + "未确认", false), "unconfirmed")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any()); + } + + @Test + @DisplayName("同客户同柜区域阶梯按总计费重命中临界档并按票分摊") + void appliesContainerCustomerTierAndAllocatedAmount() { + readyRepository(); + InboundPricingEntity inbound = inbound("100", "0.1"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1"), + memberRow(901L, 30002L, "100", "0.1") + )); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn( + regionEstimate("100", "10", "1000"), regionEstimate("200", "8", "1600")); + when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn( + new ContainerTierAmountAllocator.Allocation(new BigDecimal("200"), new BigDecimal("1600"), + Map.of(INBOUND, new BigDecimal("800.00"), 30002L, new BigDecimal("800.00")))); + + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "container-tier"); + + assertThat(response.chargeableQuantity()).isEqualByComparingTo("100"); + assertThat(response.unitPrice()).isEqualByComparingTo("8"); + assertThat(response.subtotalAmount()).isEqualByComparingTo("800.00"); + assertThat(response.totalAmount()).isEqualByComparingTo("800.00"); + ArgumentCaptor requests = + ArgumentCaptor.forClass(com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest.class); + verify(priceBookEstimator, times(2)).estimate(eq(TENANT), requests.capture()); + assertThat(requests.getAllValues().get(1).actualWeightKg()).isEqualByComparingTo("200"); + assertThat(requests.getAllValues().get(1).actualVolumeCbm()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("单票柜次保持原区域阶梯结果,不进行无意义分摊") + void keepsIndividualResultForSingleInboundContainer() { + readyRepository(); + InboundPricingEntity inbound = inbound("100", "0.1"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1") + )); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("100", "10", "1000")); + + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "single-container-tier"); + + assertThat(response.totalAmount()).isEqualByComparingTo("1000"); + verify(containerTierAllocator, never()).allocate(any(), any()); + verify(priceBookEstimator).estimate(eq(TENANT), any()); + } + + @Test + @DisplayName("无柜次关系不改变单票区域阶梯计价") + void keepsIndividualResultWithoutContainerRelation() { + readyRepository(); + InboundPricingEntity inbound = inbound("100", "0.1"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of()); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("100", "10", "1000")); + + assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "no-container-tier").totalAmount()) + .isEqualByComparingTo("1000"); + verify(containerTierAllocator, never()).allocate(any(), any()); + } + + @Test + @DisplayName("柜次成员查询只按客户和同一柜次隔离") + void scopesContainerTierMembersByCustomerAndContainer() throws NoSuchMethodException { + String sql = ContainerTierPricingMapper.class.getMethod("members", long.class, long.class) + .getAnnotation(Select.class).value()[0]; + String lockedSql = ContainerTierPricingMapper.class.getMethod("membersForUpdate", long.class, long.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(sql).contains("plan.container_booking_id=current_plan.container_booking_id") + .contains("grouped_item.account_id=#{customerId}") + .contains("grouped_inbound.tenant_id=#{customerId}") + .doesNotContain("grouped_inbound.destination_code") + .doesNotContain("grouped_inbound.destination_warehouse_id"); + assertThat(lockedSql).contains("plan.container_booking_id=#{containerBookingId}") + .contains("item.account_id=#{customerId}").contains("FOR UPDATE"); + } + + @Test + @DisplayName("柜内客户费用重算复用冻结选量和来源,并按统一阶梯分摊") + void recalculatesUnlockedContainerFeesFromFrozenSelections() { + readyRepository(); + InboundPricingEntity firstInbound = inbound("999", "9"); + InboundPricingEntity secondInbound = inbound("888", "8"); + secondInbound.setId(30002L); secondInbound.setInboundCode("RK-002"); + InboundFeeEntity first = savedFee("CALCULATED"); + first.setActualWeightKg(new BigDecimal("90.000")); first.setActualVolumeCbm(new BigDecimal("0.1000")); + first.setMeasurementId(301L); first.setMeasurementSource("OUTER"); + InboundFeeEntity second = savedFee("CALCULATED"); + second.setId(502L); second.setInboundId(30002L); second.setActualWeightKg(new BigDecimal("110.000")); + second.setActualVolumeCbm(new BigDecimal("0.1000")); second.setMeasurementId(302L); + second.setMeasurementSource("ACTUAL"); + when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( + memberRow(901L, INBOUND, "999", "9"), memberRow(901L, 30002L, "888", "8") + )); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(firstInbound); + when(repository.findInbound(TENANT, 30002L)).thenReturn(secondInbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(first); + when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(second); + when(repository.findRate(201L)).thenReturn(storedRate(201L, PriceModel.REGION_WEIGHT_TIER)); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2); + when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1); + when(repository.supersedeCurrent(eq(TENANT), anyLong(), anyString())).thenReturn(1); + when(repository.markInboundContainerFeeRecalculated(eq(TENANT), anyLong(), anyString(), + any(), any(), anyString(), any())) + .thenReturn(1); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn( + regionEstimate("200", "8", "1600"), regionEstimate("200", "8", "1600")); + when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn( + new ContainerTierAmountAllocator.Allocation(new BigDecimal("200"), new BigDecimal("1600"), + Map.of(INBOUND, new BigDecimal("720.00"), 30002L, new BigDecimal("880.00")))); + + service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-1"); + + ArgumentCaptor fees = ArgumentCaptor.forClass(InboundFeeEntity.class); + verify(repository, times(2)).insertFee(fees.capture()); + assertThat(fees.getAllValues().get(0).getActualWeightKg()).isEqualByComparingTo("90.000"); + assertThat(fees.getAllValues().get(0).getActualVolumeCbm()).isEqualByComparingTo("0.1000"); + assertThat(fees.getAllValues().get(0).getMeasurementId()).isEqualTo(301L); + assertThat(fees.getAllValues().get(0).getMeasurementSource()).isEqualTo("OUTER"); + assertThat(fees.getAllValues().get(0).getSubtotalAmount()).isEqualByComparingTo("720.00"); + assertThat(fees.getAllValues().get(1).getActualWeightKg()).isEqualByComparingTo("110.000"); + assertThat(fees.getAllValues().get(1).getMeasurementId()).isEqualTo(302L); + assertThat(fees.getAllValues().get(1).getMeasurementSource()).isEqualTo("ACTUAL"); + ArgumentCaptor requests = + ArgumentCaptor.forClass(com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest.class); + verify(priceBookEstimator, times(2)).estimate(eq(TENANT), requests.capture()); + assertThat(requests.getAllValues()).allSatisfy(request -> { + assertThat(request.actualWeightKg()).isEqualByComparingTo("200"); + assertThat(request.actualVolumeCbm()).isEqualByComparingTo(BigDecimal.ZERO); + }); + verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any()); + } + + @Test + @DisplayName("柜内已发送费用保持锁定且不阻断其他未锁定费用重算") + void keepsImmutableContainerFeeAndRecalculatesOnlyUnlockedMembers() { + readyRepository(); + InboundPricingEntity lockedInbound = inbound("100", "0.1"); + InboundPricingEntity unlockedInbound = inbound("80", "0.1"); + unlockedInbound.setId(30002L); unlockedInbound.setInboundCode("RK-002"); + InboundFeeEntity sent = savedFee("SENT"); + sent.setActualWeightKg(new BigDecimal("100")); sent.setActualVolumeCbm(new BigDecimal("0.1")); + InboundFeeEntity calculated = savedFee("CALCULATED"); + calculated.setId(502L); calculated.setInboundId(30002L); + calculated.setActualWeightKg(new BigDecimal("80")); calculated.setActualVolumeCbm(new BigDecimal("0.1")); + when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1"), memberRow(901L, 30002L, "80", "0.1") + )); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(lockedInbound); + when(repository.findInbound(TENANT, 30002L)).thenReturn(unlockedInbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent); + when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(calculated); + when(repository.findRate(201L)).thenReturn(storedRate(201L, PriceModel.REGION_WEIGHT_TIER)); + when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2); + when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1); + when(repository.supersedeCurrent(TENANT, 502L, "CALCULATED")).thenReturn(1); + when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(30002L), anyString(), + any(), any(), anyString(), any())) + .thenReturn(1); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("180", "8", "1440")); + when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn( + new ContainerTierAmountAllocator.Allocation(new BigDecimal("180"), new BigDecimal("1440"), + Map.of(INBOUND, new BigDecimal("800.00"), 30002L, new BigDecimal("640.00")))); + + var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-2"); + + assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.RECALCULATED); + assertThat(result.recalculatedCount()).isEqualTo(1); + ArgumentCaptor fee = ArgumentCaptor.forClass(InboundFeeEntity.class); + verify(repository).insertFee(fee.capture()); + assertThat(fee.getValue().getInboundId()).isEqualTo(30002L); + assertThat(fee.getValue().getSubtotalAmount()).isEqualByComparingTo("640.00"); + verify(repository, never()).supersedeCurrent(TENANT, 501L, "SENT"); + verify(priceBookEstimator).estimate(eq(TENANT), any()); + } + + @Test + @DisplayName("同柜混合固定仓价和区域阶梯时仅重算区域阶梯成员") + void recalculatesOnlyRegionalMembersWhenContainerUsesMixedPriceModels() { + readyRepository(); + InboundPricingEntity regionalInbound = inbound("90", "0.1"); + InboundPricingEntity fixedInbound = inbound("110", "0.1"); + fixedInbound.setId(30002L); fixedInbound.setInboundCode("RK-FIXED"); + InboundFeeEntity regional = savedFee("CALCULATED"); + regional.setActualWeightKg(new BigDecimal("90")); regional.setActualVolumeCbm(new BigDecimal("0.1")); + InboundFeeEntity fixed = savedFee("CALCULATED"); + fixed.setId(502L); fixed.setInboundId(30002L); fixed.setCarrierRateId(203L); + fixed.setActualWeightKg(new BigDecimal("110")); fixed.setActualVolumeCbm(new BigDecimal("0.1")); + when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( + memberRow(901L, INBOUND, "90", "0.1"), memberRow(901L, 30002L, "110", "0.1") + )); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(regionalInbound); + when(repository.findInbound(TENANT, 30002L)).thenReturn(fixedInbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(regional); + when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(fixed); + when(repository.findRate(201L)).thenReturn(storedRate(201L, PriceModel.REGION_WEIGHT_TIER)); + when(repository.findRate(203L)).thenReturn(storedRate(203L, PriceModel.FBA_WAREHOUSE_RATE)); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1); + when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1); + when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(INBOUND), anyString(), + any(), any(), anyString(), any())) + .thenReturn(1); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("90", "8", "720")); + when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn( + new ContainerTierAmountAllocator.Allocation(new BigDecimal("90"), new BigDecimal("720"), + Map.of(INBOUND, new BigDecimal("720.00")))); + + var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "mixed-models"); + + assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.RECALCULATED); + assertThat(result.recalculatedCount()).isEqualTo(1); + verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED"); + verify(repository, never()).supersedeCurrent(TENANT, 502L, "CALCULATED"); + ArgumentCaptor> members = ArgumentCaptor.forClass(List.class); + verify(containerTierAllocator).allocate(members.capture(), eq(new BigDecimal("8"))); + assertThat(members.getValue()).extracting(ContainerTierAmountAllocator.Member::inboundId) + .containsExactly(INBOUND); + } + + @Test + @DisplayName("柜内费用全部锁定时关系变更无需重算且不报错") + void skipsContainerRecalculationWhenEveryFeeIsImmutable() { + InboundPricingEntity inbound = inbound("100", "0.1"); + InboundFeeEntity confirmed = savedFee("CONFIRMED"); + when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1") + )); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(confirmed); + + var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-locked"); + + assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.NOT_APPLICABLE); + assertThat(result.recalculatedCount()).isZero(); + verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), anyString()); + verify(repository, never()).insertFee(any()); + verify(priceBookEstimator, never()).estimate(anyLong(), any()); + } + + @Test + @DisplayName("待审核或待测量成员无初算费用时保持占位并返回未就绪") + void keepsContainerRelationshipWhenAnyMemberFeeIsNotReady() { + readyRepository(); + InboundPricingEntity pricedInbound = inbound("100", "0.1"); + InboundPricingEntity pendingInbound = inbound("80", "0.1"); + pendingInbound.setId(30002L); pendingInbound.setInboundCode("RK-PENDING"); + InboundFeeEntity priced = savedFee("CALCULATED"); + priced.setActualWeightKg(new BigDecimal("100")); priced.setActualVolumeCbm(new BigDecimal("0.1")); + when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1"), memberRow(901L, 30002L, "80", "0.1") + )); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(pricedInbound); + when(repository.findInbound(TENANT, 30002L)).thenReturn(pendingInbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(priced); + when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(null); + + var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "pending-member"); + + assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY); + assertThat(result.recalculatedCount()).isZero(); + verify(repository, never()).insertFee(any()); + verify(priceBookEstimator, never()).estimate(anyLong(), any()); + } + + @Test + @DisplayName("相同柜次重算触发标识重放不重复创建费用版本") + void replaysCompletedContainerRecalculationTrigger() { + InboundPricingIdempotencyEntity completed = completedContainerRecalculation(901L, "same-trigger"); + when(repository.findIdempotency(TENANT, 0L, "CONTAINER_FEE_RECALCULATE", "CONTAINER:901:same-trigger")) + .thenReturn(completed); + + service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "same-trigger"); + + verify(repository, never()).containerTierMembersForUpdate(anyLong(), anyLong()); + verify(repository, never()).insertFee(any()); + } + + private void readyRepository() { + when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) + .when(repository).insertIdempotency(any()); + doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(501L); return null; }) + .when(repository).insertFee(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(1); + when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any(), any(), any(), any())) + .thenReturn(1); + when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); + } + + @Configuration + static class TestConfig { + @Bean + Clock clock() { + return Clock.fixed(Instant.parse("2026-08-03T02:00:00Z"), ZoneId.of("Asia/Shanghai")); + } + } + + private InboundPricingEntity inbound(String weight, String volume) { + InboundPricingEntity value = new InboundPricingEntity(); + value.setId(INBOUND); value.setTenantId(TENANT); value.setInboundCode("RK-001"); + value.setProcessStatus("WAIT_PRICING"); value.setMeasureReviewStatus("APPROVED"); + value.setFeeStatus("UNPRICED"); value.setCustomerId(11L); value.setCustomerName("客户A"); + value.setWarehouseId(21L); value.setWarehouseName("义乌仓"); value.setBusinessType("FBA_SEA"); + value.setChannelCode("MATSON_TRUCK"); value.setCarrierId(31L); value.setCarrierName("船司A"); + value.setTaxMode("BUY_DECLARATION"); value.setPricingTaxMode("TAX_INCLUDED"); + value.setDestinationType("FBA"); value.setDestinationCode("ONT8"); + value.setActualGrossWeightKg(new BigDecimal(weight).add(new BigDecimal("8.000"))); + value.setActualNetWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume)); + return value; + } + + private ActivePriceBookEstimator.Estimate estimate(String unit, String quantity, String price, + String amount, String additional) { + RateRow rate = new RateRow(); + rate.setId(201L); rate.setPriceBookVersionId(701L); rate.setRateCode("PB-7-1"); rate.setRateVersion(1); + rate.setOriginWarehouseId(21L); rate.setOriginWarehouseName("义乌仓"); rate.setChannelCode("MATSON_TRUCK"); + rate.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name()); rate.setDestinationType("FBA"); + rate.setDestinationCode("ONT8"); rate.setPricingTaxMode("TAX_INCLUDED"); rate.setChargeUnit(unit); + rate.setUnitPrice(new BigDecimal(price)); rate.setCurrency("USD"); + rate.setAdditionalFee(new BigDecimal(additional)); rate.setEffectiveFrom(NOW.minusDays(1)); + return new ActivePriceBookEstimator.Estimate(701L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE, + PricingTaxMode.TAX_INCLUDED, rate, new BigDecimal(quantity), unit, + new BigDecimal(price), new BigDecimal(amount), "USD", + "KG".equals(unit) ? new BigDecimal("167") : new BigDecimal("363"), 6, "HALF_UP"); + } + + private ContainerTierPricingMapper.MemberRow memberRow(long containerId, long inboundId, + String weight, String volume) { + return new ContainerTierPricingMapper.MemberRow(containerId, inboundId, + new BigDecimal(weight), new BigDecimal(volume)); + } + + private ActivePriceBookEstimator.Estimate regionEstimate(String quantity, String unitPrice, String amount) { + RateRow rate = new RateRow(); + rate.setId(202L); rate.setPriceBookVersionId(701L); rate.setRateCode("PB-TIER-1"); rate.setRateVersion(1); + rate.setOriginWarehouseId(21L); rate.setOriginWarehouseName("义乌仓"); rate.setChannelCode("MATSON_TRUCK"); + rate.setPriceModel(PriceModel.REGION_WEIGHT_TIER.name()); rate.setDestinationType("FBA"); + rate.setDestinationCode("ONT8"); rate.setPricingTaxMode("TAX_INCLUDED"); rate.setChargeUnit("KG"); + rate.setUnitPrice(new BigDecimal(unitPrice)); rate.setCurrency("USD"); rate.setAdditionalFee(BigDecimal.ZERO); + rate.setEffectiveFrom(NOW.minusDays(1)); + return new ActivePriceBookEstimator.Estimate(701L, "MATSON_TRUCK", PriceModel.REGION_WEIGHT_TIER, + PricingTaxMode.TAX_INCLUDED, rate, new BigDecimal(quantity), "KG", new BigDecimal(unitPrice), + new BigDecimal(amount), "USD", new BigDecimal("167"), 6, "HALF_UP"); + } + + private CarrierFbaRateEntity storedRate(long id, PriceModel model) { + CarrierFbaRateEntity rate = new CarrierFbaRateEntity(); + rate.setId(id); rate.setPriceModel(model.name()); + return rate; + } + + private InboundPricingIdempotencyEntity completed(String operation, long feeId) { + InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity(); + value.setId(900L); value.setOperation(operation); value.setStatus("COMPLETED"); + value.setResultResourceId(feeId); + // 试算重复用例的摘要由服务算法固定;其余用例在首次调用后才返回该记录。 + value.setRequestHash("FEE_SEND".equals(operation) + ? "3be2051b9bc4c06a163cb3099fd7b3f828b51307cc54d3cfc232c11353c61046" + : "1b874a4f469151f236aa6086835f3cac22cca5cb4a1b90444f08b27b76e8a3c4"); + return value; + } + + private InboundPricingIdempotencyEntity completedContainerRecalculation(long containerBookingId, String trigger) { + InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity(); + value.setId(900L); value.setOperation("CONTAINER_FEE_RECALCULATE"); value.setStatus("COMPLETED"); + value.setResultResourceId(containerBookingId); + try { + value.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(("CONTAINER_FEE_RECALCULATE:" + containerBookingId + ':' + trigger) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new AssertionError(exception); + } + return value; + } + + private InboundFeeEntity savedFee(String status) { + InboundFeeEntity value = new InboundFeeEntity(); + value.setId(501L); value.setTenantId(TENANT); value.setInboundId(INBOUND); value.setFeeNo("FBAF-30001-1"); + value.setFeeVersion(1); value.setFeeStatus(status); value.setChargeRuleId(101L); + value.setRuleCode("CR-001"); value.setChargeRuleVersion(1); value.setCarrierRateId(201L); + value.setRateCode("RT-001"); value.setCarrierRateVersion(1); value.setCustomerId(11L); + value.setCustomerName("客户A"); value.setOriginWarehouseId(21L); value.setOriginWarehouseName("义乌仓"); + value.setBusinessType("FBA_SEA"); value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); + value.setCarrierName("船司A"); value.setDestinationCode("ONT8"); value.setTaxMode("TAX_INCLUDED"); + value.setChargeMode("KG"); value.setConversionFactor(new BigDecimal("167")); value.setRoundingScale(3); + value.setRoundingMode("CEILING"); value.setActualWeightKg(new BigDecimal("10")); + value.setActualVolumeCbm(BigDecimal.ONE); value.setConvertedQuantity(new BigDecimal("167")); + value.setChargeableQuantity(new BigDecimal("167")); value.setChargeUnit("KG"); + value.setSubtotalAmount(new BigDecimal("334")); value.setAdditionalAmount(BigDecimal.ZERO); + value.setTotalAmount(new BigDecimal("334")); value.setCurrency("USD"); value.setCalculatedAt(NOW); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java new file mode 100644 index 000000000..a0fdc7b00 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java @@ -0,0 +1,520 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.FeeItemDtos; +import com.baiyue.firstleg.dto.InboundFeeDetailDtos; +import com.baiyue.firstleg.entity.FeeItemEntity; +import com.baiyue.firstleg.entity.InboundFeeDetailEntity; +import com.baiyue.firstleg.entity.InboundFeeEntity; +import com.baiyue.firstleg.entity.InboundFeeItemEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.FeeItemRepository; +import com.baiyue.firstleg.repository.FbaPricingRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +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.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; + +@DisplayName("费用项目和入库费用明细") +@SpringJUnitConfig({FeeItemService.class, InboundFeeDetailService.class}) +class FeeManagementServiceTest { + @MockBean + private FeeItemRepository repository; + @MockBean + private FbaPricingRepository pricingRepository; + @MockBean + private FinanceFeeSourceOutboxService financeOutbox; + @MockBean + private WarehouseSupplierSnapshotService suppliers; + @MockBean + private FeePaymentSnapshotValidator paymentSnapshots; + @Resource + private FeeItemService feeItems; + @Resource + private InboundFeeDetailService details; + + @BeforeEach + void paymentSnapshot() { + lenient().when(paymentSnapshots.validate(anyLong(), any(), any(), any(), any(), any(), any())) + .thenAnswer(invocation -> new FeePaymentSnapshotValidator.Snapshot( + invocation.getArgument(2), invocation.getArgument(3), invocation.getArgument(4), + invocation.getArgument(5), invocation.getArgument(6) == null ? List.of() : invocation.getArgument(6))); + } + + @Test + @DisplayName("客户B不能删除客户A的费用明细") + void rejectsCrossCustomerFeeDetailDelete() { + assertThatThrownBy(() -> details.delete(10002L, 21L, 31L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("费用明细不存在"); + + verify(repository).findDetail(10002L, 21L, 31L); + verify(repository, never()).deleteDetail(10002L, 21L, 31L); + } + + @Test + @DisplayName("待审核费用明细按客户和入库单作用域删除") + void deletesPendingFeeDetailInInboundScope() { + InboundFeeDetailEntity pending = new InboundFeeDetailEntity(); + pending.setReviewStatus("PENDING"); + when(repository.findDetail(10001L, 21L, 31L)).thenReturn(pending); + when(repository.deleteDetail(10001L, 21L, 31L)).thenReturn(1); + + details.delete(10001L, 21L, 31L); + + verify(repository).deleteDetail(10001L, 21L, 31L); + } + + @Test + @DisplayName("费用项目列表返回分页结果") + void pagesItems() { + PageResult expected = new PageResult<>(List.of(), 2, 20, 21, 2); + when(repository.listItems("仓储成本", "INTERNATIONAL", "人工", "ACTIVE", 2, 20)) + .thenReturn(expected); + + assertThat(feeItems.list("仓储成本", "INTERNATIONAL", "人工", "ACTIVE", 2, 20)) + .isSameAs(expected); + } + + @Test + @DisplayName("新增费用项目保存原型字段") + void createsItem() { + doAnswer(invocation -> { + ((FeeItemEntity) invocation.getArgument(0)).setId(11L); + return null; + }).when(repository).insertItem(any()); + + FeeItemEntity created = feeItems.create(9L, "admin", itemRequest()); + + assertThat(created.getId()).isEqualTo(11L); + assertThat(created.getAllocationFee()).isTrue(); + assertThat(created.getWarehouseReceivable()).isTrue(); + assertThat(created.getWarehousePayable()).isTrue(); + assertThat(created.getSupplierCategory()).isNull(); + assertThat(created.getFeeCode()).startsWith("MANUAL_"); + assertThat(created.getTenantId()).isZero(); + assertThat(created.getCreatedByName()).isEqualTo("admin"); + } + + @Test + @DisplayName("自定义费用编码统一转为大写并检查客户内唯一性") + void normalizesCustomFeeCode() { + FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", + "storage_fee", "仓库租赁费", false, false, true, true, true, "应付仓库租赁费"); + + FeeItemEntity created = feeItems.create(9L, "admin", request); + + verify(repository).itemCodeExists("STORAGE_FEE", null); + assertThat(created.getFeeCode()).isEqualTo("STORAGE_FEE"); + } + + @Test + @DisplayName("并发创建相同费用编码返回业务冲突") + void rejectsConcurrentDuplicateFeeCode() { + FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", + "STORAGE_FEE", "仓库租赁费", false, false, true, true, true, null); + doThrow(new DuplicateKeyException("duplicate fee code")).when(repository).insertItem(any()); + + assertThatThrownBy(() -> feeItems.create(9L, "admin", request)) + .isInstanceOf(BizException.class) + .hasMessageContaining("费用项目编码已存在"); + } + + @Test + @DisplayName("编辑时省略费用编码保留原稳定编码") + void preservesFeeCodeWhenUpdateOmitsIt() { + FeeItemEntity existing = item(true, true); + existing.setFeeCode("STORAGE_FEE"); + when(repository.findItem(11L)).thenReturn(existing); + when(repository.updateItem(any())).thenReturn(1); + + FeeItemEntity updated = feeItems.update(11L, 9L, "admin", itemRequest()); + + assertThat(updated.getFeeCode()).isEqualTo("STORAGE_FEE"); + verify(repository, never()).itemCodeExists("STORAGE_FEE", 11L); + } + + @Test + @DisplayName("任一客户引用公共费用项目后都禁止删除") + void rejectsDeletingItemBoundByAnyCustomer() { + when(repository.findItem(11L)).thenReturn(item(true, true)); + when(repository.countBindings(11L)).thenReturn(1L); + + assertThatThrownBy(() -> feeItems.delete(11L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("已被入库费用明细使用"); + + verify(repository, never()).deleteItem(11L); + } + + @Test + @DisplayName("入库费用绑定项目并生成订单快照") + void bindsInboundFee() { + InboundOrderEntity inbound = inbound(); + FeeItemEntity item = item(true, true); + when(repository.findInbound(10001L, 21L)).thenReturn(inbound); + when(repository.findItem(11L)).thenReturn(item); + doAnswer(invocation -> { + ((InboundFeeDetailEntity) invocation.getArgument(0)).setId(31L); + return null; + }).when(repository).insertDetail(any()); + + InboundFeeDetailEntity created = details.create(10001L, 21L, 9L, + detailRequest("RECEIVABLE")); + + assertThat(created.getId()).isEqualTo(31L); + assertThat(created.getInboundCode()).isEqualTo("RK-20260805-001"); + assertThat(created.getFeeTypeNameSnapshot()).isEqualTo("仓库租赁费"); + assertThat(created.getActualGrossWeightKg()).isEqualByComparingTo("10.5000"); + assertThat(created.getReviewStatus()).isEqualTo("PENDING"); + } + + @Test + @DisplayName("发布计费快照生成客户与后台共用的入库运费明细") + void publishesSharedFreightDetail() { + InboundOrderEntity inbound = inbound(); + inbound.setCustomerId(101L); + inbound.setCustomerName("客户A"); + inbound.setCompanyName("客户A有限公司"); + when(repository.findInbound(10001L, 21L)).thenReturn(inbound); + when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null); + FeeItemEntity freightItem = item(true, false); + freightItem.setId(11L); + freightItem.setFeeCode("FBA_HEAD_FREIGHT"); + when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(freightItem); + + InboundFeeEntity fee = new InboundFeeEntity(); + fee.setId(501L); + fee.setInboundId(21L); + fee.setFeeNo("FBAF-21-1"); + fee.setFeeVersion(1); + fee.setFeeStatus("SENT"); + fee.setActualWeightKg(new BigDecimal("100.0000")); + fee.setActualVolumeCbm(new BigDecimal("1.000000")); + fee.setChargeableQuantity(new BigDecimal("167.0000")); + fee.setChargeUnit("KG"); + fee.setCurrency("USD"); + fee.setTotalAmount(new BigDecimal("354.00")); + when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee); + InboundFeeItemEntity base = new InboundFeeItemEntity(); + base.setUnitPrice(new BigDecimal("2.0000")); + when(pricingRepository.findBaseItem(10001L, 501L)).thenReturn(base); + + LocalDateTime publishedAt = LocalDateTime.of(2026, 8, 5, 12, 0); + InboundFeeDetailEntity result = details.publishFreight( + 10001L, 21L, 501L, 9L, "admin", publishedAt); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundFeeDetailEntity.class); + verify(repository).insertDetail(captor.capture()); + InboundFeeDetailEntity saved = captor.getValue(); + assertThat(result).isSameAs(saved); + assertThat(saved.getFeeItemId()).isEqualTo(11L); + assertThat(saved.getSourceFeeId()).isEqualTo(501L); + assertThat(saved.getDirection()).isEqualTo("RECEIVABLE"); + assertThat(saved.getAmount()).isEqualByComparingTo("354.00"); + assertThat(saved.getUnitPrice()).isEqualByComparingTo("2.1198"); + assertThat(saved.getReviewStatus()).isEqualTo("APPROVED"); + assertThat(saved.getPublicationStatus()).isEqualTo("PUBLISHED"); + } + + @Test + @DisplayName("查询费用明细会幂等补齐历史初算缺失的派生行") + void materializesMissingLatestCalculatedFreightOnList() { + InboundOrderEntity inbound = inbound(); + inbound.setCustomerId(101L); + inbound.setCustomerName("客户A"); + when(repository.findInbound(10001L, 21L)).thenReturn(inbound); + when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null); + when(repository.listDetails(10001L, 21L, false)).thenReturn(List.of()); + FeeItemEntity freightItem = item(true, false); + freightItem.setFeeCode("FBA_HEAD_FREIGHT"); + when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(freightItem); + + InboundFeeEntity fee = new InboundFeeEntity(); + fee.setId(501L); fee.setInboundId(21L); fee.setFeeNo("FBAF-21-2"); + fee.setFeeVersion(2); fee.setFeeStatus("CALCULATED"); + fee.setActualWeightKg(new BigDecimal("100.0000")); + fee.setActualVolumeCbm(new BigDecimal("1.000000")); + fee.setChargeableQuantity(new BigDecimal("167.0000")); + fee.setChargeUnit("KG"); fee.setCurrency("USD"); + fee.setTotalAmount(new BigDecimal("354.00")); + fee.setCalculatedAt(LocalDateTime.of(2026, 8, 17, 12, 0)); + when(pricingRepository.findLatestFee(10001L, 21L)).thenReturn(fee); + when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee); + + details.list(10001L, 21L, false); + + verify(repository).insertDetail(any(InboundFeeDetailEntity.class)); + verify(repository).listDetails(10001L, 21L, false); + } + + @Test + @DisplayName("系统头程运费项目缺失时拒绝运行期补建") + void rejectsMissingSystemFreightItem() { + when(repository.findInbound(10001L, 21L)).thenReturn(inbound()); + when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null); + when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(null); + + InboundFeeEntity fee = new InboundFeeEntity(); + fee.setId(501L); + fee.setInboundId(21L); + fee.setFeeStatus("SENT"); + when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee); + + assertThatThrownBy(() -> details.publishFreight( + 10001L, 21L, 501L, 9L, "admin", LocalDateTime.of(2026, 8, 5, 12, 0))) + .isInstanceOf(BizException.class) + .hasMessageContaining("FBA头程运费未初始化"); + verify(repository, never()).insertItem(any()); + } + + @Test + @DisplayName("并发发布同一计费快照时返回已生成的运费明细") + void reusesConcurrentFreightDetail() { + InboundFeeDetailEntity concurrent = new InboundFeeDetailEntity(); + concurrent.setId(99L); + when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)) + .thenReturn(null, concurrent); + when(repository.findInbound(10001L, 21L)).thenReturn(inbound()); + + FeeItemEntity freightItem = item(true, false); + freightItem.setFeeCode("FBA_HEAD_FREIGHT"); + when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(freightItem); + + InboundFeeEntity fee = new InboundFeeEntity(); + fee.setId(501L); + fee.setInboundId(21L); + fee.setFeeNo("FBAF-21-1"); + fee.setFeeVersion(1); + fee.setFeeStatus("SENT"); + fee.setActualWeightKg(new BigDecimal("100.0000")); + fee.setActualVolumeCbm(new BigDecimal("1.000000")); + fee.setChargeableQuantity(new BigDecimal("1.0000")); + fee.setChargeUnit("CBM"); + fee.setCurrency("USD"); + fee.setTotalAmount(new BigDecimal("200.00")); + when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee); + doThrow(new DuplicateKeyException("duplicate source fee")) + .when(repository).insertDetail(any()); + + InboundFeeDetailEntity result = details.publishFreight( + 10001L, 21L, 501L, 9L, "admin", LocalDateTime.of(2026, 8, 5, 12, 0)); + + assertThat(result).isSameAs(concurrent); + } + + @Test + @DisplayName("拒绝绑定未启用仓储应收的项目") + void rejectsInapplicableItem() { + when(repository.findInbound(10001L, 21L)).thenReturn(inbound()); + when(repository.findItem(11L)).thenReturn(item(false, true)); + + assertThatThrownBy(() -> details.create(10001L, 21L, 9L, + detailRequest("RECEIVABLE"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("仓储应收/应付"); + } + + @Test + @DisplayName("私人仓尾程派送费只允许PRIVATE目的地") + void restrictsPrivateLastMileDeliveryFeeByDestinationType() { + InboundOrderEntity fbaInbound = inbound(); + fbaInbound.setDestinationType("FBA"); + FeeItemEntity privateDelivery = item(true, true); + privateDelivery.setFeeCode("PRIVATE_LAST_MILE_DELIVERY"); + when(repository.findInbound(10001L, 21L)).thenReturn(fbaInbound); + when(repository.findItem(11L)).thenReturn(privateDelivery); + + assertThatThrownBy(() -> details.create(10001L, 21L, 9L, + detailRequest("RECEIVABLE"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("目的地类型为PRIVATE"); + verify(repository, never()).insertDetail(any()); + + fbaInbound.setDestinationType("PRIVATE"); + doAnswer(invocation -> { ((InboundFeeDetailEntity) invocation.getArgument(0)).setId(32L); return null; }) + .when(repository).insertDetail(any()); + assertThat(details.create(10001L, 21L, 9L, detailRequest("RECEIVABLE")).getId()) + .isEqualTo(32L); + } + + @Test + @DisplayName("审核只更新待审核明细") + void approvesPendingDetail() { + InboundFeeDetailEntity pending = new InboundFeeDetailEntity(); + pending.setId(31L); + pending.setTenantId(10001L); + pending.setInboundId(21L); + pending.setFeeItemId(11L); + pending.setDirection("RECEIVABLE"); + pending.setSourceType("MANUAL"); + pending.setSourceFeeVersion(1); + when(repository.findDetail(10001L, 21L, 31L)).thenReturn(pending); + when(repository.findItem(11L)).thenReturn(item(true, true)); + when(repository.updateDetail(pending)).thenReturn(1); + when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1); + + details.approve(10001L, 21L, 31L, 9L, "admin"); + + verify(repository).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null); + verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED), + eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class)); + } + + @Test + @DisplayName("审核通过的人工费用自动写入Finance事件") + void publishesApprovedSettlementFee() { + InboundFeeDetailEntity approved = new InboundFeeDetailEntity(); + approved.setId(31L); + approved.setTenantId(10001L); + approved.setInboundId(21L); + approved.setFeeItemId(11L); + approved.setDirection("RECEIVABLE"); + approved.setSourceType("MANUAL"); + approved.setSourceFeeVersion(1); + when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved); + when(repository.findItem(11L)).thenReturn(item(true, true)); + when(repository.updateDetail(approved)).thenReturn(1); + when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1); + + details.approve(10001L, 21L, 31L, 9L, "admin"); + + verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED), + eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class)); + } + + @Test + @DisplayName("自动头程运费派生明细不能在费用明细中二次审核") + void rejectsReviewingDerivedFreight() { + InboundFeeDetailEntity derived = new InboundFeeDetailEntity(); + derived.setSourceType("FBA_PRICING"); + derived.setReviewStatus("PENDING"); + when(repository.findDetail(10001L, 21L, 31L)).thenReturn(derived); + + assertThatThrownBy(() -> details.approve(10001L, 21L, 31L, 9L, "admin")) + .isInstanceOf(BizException.class) + .hasMessageContaining("只读派生"); + + verify(repository, never()).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null); + } + + @Test + @DisplayName("已审核人工费用调整保留旧版本并创建待审核新版本") + void versionsApprovedManualAdjustment() { + InboundFeeDetailEntity approved = new InboundFeeDetailEntity(); + approved.setId(31L); + approved.setTenantId(10001L); + approved.setInboundId(21L); + approved.setSourceType("MANUAL"); + approved.setSourceFeeVersion(1); + approved.setReviewStatus("APPROVED"); + approved.setPublicationStatus("PUBLISHED"); + when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved); + when(repository.findInbound(10001L, 21L)).thenReturn(inbound()); + when(repository.findItem(11L)).thenReturn(item(true, true)); + when(repository.supersedeApprovedManual(eq(10001L), eq(21L), eq(31L), + eq(1), eq(9L), any(LocalDateTime.class))).thenReturn(1); + doAnswer(invocation -> { + ((InboundFeeDetailEntity) invocation.getArgument(0)).setId(32L); + return null; + }).when(repository).insertDetail(any()); + + InboundFeeDetailEntity next = details.adjust( + 10001L, 21L, 31L, 1, 9L, detailRequest("RECEIVABLE")); + + assertThat(next.getId()).isEqualTo(32L); + assertThat(next.getSourceFeeId()).isEqualTo(31L); + assertThat(next.getSourceFeeVersion()).isEqualTo(2); + assertThat(next.getReviewStatus()).isEqualTo("PENDING"); + assertThat(next.getPublicationStatus()).isEqualTo("DRAFT"); + verify(repository).insertDetail(next); + } + + @Test + @DisplayName("作废已入池人工费用引用当前版本并发布VOIDED") + void voidsPublishedSettlementFee() { + InboundFeeDetailEntity current = new InboundFeeDetailEntity(); + current.setId(31L); + current.setTenantId(10001L); + current.setInboundId(21L); + current.setFeeItemId(11L); + current.setSourceType("MANUAL"); + current.setSourceFeeVersion(1); + InboundFeeDetailEntity voided = new InboundFeeDetailEntity(); + voided.setId(31L); + voided.setTenantId(10001L); + voided.setInboundId(21L); + voided.setFeeItemId(11L); + voided.setSourceType("MANUAL"); + voided.setSourceFeeVersion(1); + when(repository.findDetail(10001L, 21L, 31L)).thenReturn(current, voided); + when(repository.voidApproved(10001L, 21L, 31L, 1, 9L)).thenReturn(1); + + details.voidApproved(10001L, 21L, 31L, 1, 9L); + + verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.VOIDED), + eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class)); + } + + private static FeeItemDtos.SaveRequest itemRequest() { + return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", "仓库租赁费", + false, false, true, true, true, "应付仓库租赁费"); + } + + private static InboundFeeDetailDtos.SaveRequest detailRequest(String direction) { + return new InboundFeeDetailDtos.SaveRequest(11L, direction, LocalDate.of(2026, 8, 5), + new BigDecimal("12.3456"), "CNY", new BigDecimal("123.46"), "联调费用"); + } + + private static FeeItemEntity item(boolean receivable, boolean payable) { + FeeItemEntity item = new FeeItemEntity(); + item.setId(11L); + item.setCategory("仓储成本"); + item.setTradeScope("INTERNATIONAL"); + item.setFeeTypeName("仓库租赁费"); + item.setWarehouseReceivable(receivable); + item.setWarehousePayable(payable); + item.setStatus("ACTIVE"); + return item; + } + + private static InboundOrderEntity inbound() { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(21L); + inbound.setInboundCode("RK-20260805-001"); + inbound.setChannelCode("SEA"); + inbound.setChannelName("海运"); + inbound.setDestinationCode("ONT8"); + inbound.setDestinationName("ONT8"); + inbound.setTotalPackageCount(10); + inbound.setActualGrossWeightKg(new BigDecimal("10.5000")); + inbound.setActualNetWeightKg(new BigDecimal("10.5000")); + inbound.setActualVolumeCbm(new BigDecimal("1.250000")); + inbound.setVolumeWeightKg(new BigDecimal("208.3333")); + inbound.setChargeableWeightKg(new BigDecimal("208.3333")); + inbound.setChargeableVolumeCbm(new BigDecimal("1.250000")); + return inbound; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java new file mode 100644 index 000000000..51664c9e3 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java @@ -0,0 +1,85 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.FeePaymentVoucherFileClient; +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; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FeePaymentSnapshotValidatorTest { + private final FeePaymentVoucherFileClient files = mock(FeePaymentVoucherFileClient.class); + private final FeePaymentSnapshotValidator validator = + new FeePaymentSnapshotValidator(files, "file-token"); + + @Test + void receivableClearsSettlementAndRejectsPaidFields() { + assertThat(validator.validate(9L, "RECEIVABLE", null, null, null, null, null)) + .isEqualTo(new FeePaymentSnapshotValidator.Snapshot(null, null, null, null, List.of())); + assertThatThrownBy(() -> validator.validate(9L, "RECEIVABLE", "ON_ACCOUNT", + null, null, null, null)).isInstanceOf(BizException.class) + .hasMessageContaining("应收费用不得"); + } + + @Test + void onAccountRejectsEveryPaidField() { + assertThat(validator.validate(9L, "PAYABLE", "ON_ACCOUNT", + null, null, null, null).settlementMode()).isEqualTo("ON_ACCOUNT"); + assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "ON_ACCOUNT", + LocalDateTime.now(), "CASH", "CASH-1", List.of(1L))) + .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); + assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "DIRECT_PAID", + paidAt, "BANK_TRANSFER", null, null)) + .isInstanceOf(BizException.class).hasMessageContaining("流水号或至少一个付款凭证"); + + assertThat(validator.validate(9L, "PAYABLE", "DIRECT_PAID", + paidAt, "BANK_TRANSFER", "BANK-001", null)) + .extracting(FeePaymentSnapshotValidator.Snapshot::paymentReferenceNo) + .isEqualTo("BANK-001"); + } + + @Test + void directPaidVoucherIsValidatedAgainstCurrentCustomerAndFileType() { + LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0); + when(files.metadata("file-token", 9L, 9L, 901L)).thenReturn(ApiResponse.success( + new FeePaymentVoucherFileClient.Metadata(901L, 9L, "FINANCE_VOUCHER", "9", + "voucher.pdf", "a".repeat(64), "application/pdf", 100L))); + + var result = validator.validate(9L, "PAYABLE", "DIRECT_PAID", + paidAt, "ONLINE", null, List.of(901L, 901L)); + + assertThat(result.paymentVoucherFileIds()).containsExactly(901L); + verify(files).metadata("file-token", 9L, 9L, 901L); + } + + @Test + void fileMismatchFailsClosed() { + when(files.metadata("file-token", 9L, 9L, 901L)).thenReturn(ApiResponse.success( + new FeePaymentVoucherFileClient.Metadata(901L, 10L, "FINANCE_VOUCHER", "10", + "voucher.pdf", "a".repeat(64), "application/pdf", 100L))); + + assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "DIRECT_PAID", + LocalDateTime.now(), "CASH", null, List.of(901L))) + .isInstanceOf(BizException.class).hasMessageContaining("不属于当前客户"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceCommandServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceCommandServiceTest.java new file mode 100644 index 000000000..109ac3c39 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceCommandServiceTest.java @@ -0,0 +1,160 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest; +import com.baiyue.firstleg.entity.ContainerFeeDetailEntity; +import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity; +import com.baiyue.firstleg.entity.InboundFeeDetailEntity; +import com.baiyue.firstleg.repository.ContainerFeeDetailRepository; +import com.baiyue.firstleg.repository.FbaPricingRepository; +import com.baiyue.firstleg.repository.FeeItemRepository; +import com.baiyue.firstleg.repository.FinanceFeeSourceCommandRepository; +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.dao.DuplicateKeyException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; + +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.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +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 FinanceFeeSourceCommandServiceTest { + @Mock FinanceFeeSourceCommandRepository commands; + @Mock FeeItemRepository inboundFees; + @Mock FbaPricingRepository pricing; + @Mock ContainerFeeDetailRepository containerFees; + @Mock ContainerFeeDetailService containerFeeService; + @Mock FinanceFeeSourceOutboxService outbox; + private ObjectMapper objectMapper; + private FinanceFeeSourceCommandService service; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper().findAndRegisterModules(); + service = new FinanceFeeSourceCommandService(commands, inboundFees, pricing, + containerFees, containerFeeService, outbox, objectMapper); + lenient().doAnswer(invocation -> { + FinanceFeeSourceCommandEntity value = invocation.getArgument(0); + value.setId(901L); + return null; + }).when(commands).insert(any()); + lenient().when(commands.complete(eq(901L), eq("VOIDED"), any())).thenReturn(1); + } + + @Test + void voidsManualInboundBusinessFactAndPublishesEvent() throws Exception { + VoidRequest request = inboundRequest(700L, 2); + InboundFeeDetailEntity detail = inboundDetail("MANUAL", 777L, 700L, 2, 9L); + when(inboundFees.findSourceVersion(100L, 700L, 2)).thenReturn(detail); + when(inboundFees.voidApproved(9L, 100L, 777L, 2, 71L)).thenReturn(1); + + var result = service.voidSource("void-key", hash(request), request); + + assertThat(result.status()).isEqualTo("VOIDED"); + assertThat(result.replayed()).isFalse(); + verify(outbox).publishInbound(org.mockito.ArgumentMatchers.eq(FinanceFeeSourceOutboxService.VOIDED), + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(100L), + org.mockito.ArgumentMatchers.eq(700L), org.mockito.ArgumentMatchers.eq(2), any()); + verify(pricing, never()).voidSentFee(anyLong(), anyLong(), anyLong(), anyInt(), any()); + } + + @Test + void voidsFbaPricingAndMaterializedInboundDetailAtomically() throws Exception { + VoidRequest request = inboundRequest(700L, 3); + when(inboundFees.findSourceVersion(100L, 700L, 3)) + .thenReturn(inboundDetail("FBA_PRICING", 778L, 700L, 3, 9L)); + when(pricing.voidSentFee(eq(9L), eq(100L), eq(700L), eq(3), any())).thenReturn(1); + when(pricing.markInboundFeeVoided(9L, 100L)).thenReturn(1); + when(inboundFees.voidApproved(9L, 100L, 778L, 3, 71L)).thenReturn(1); + + service.voidSource("void-fba", hash(request), request); + + verify(pricing).voidSentFee(eq(9L), eq(100L), eq(700L), eq(3), any()); + verify(pricing).markInboundFeeVoided(9L, 100L); + verify(outbox).publishInbound(org.mockito.ArgumentMatchers.eq(FinanceFeeSourceOutboxService.VOIDED), + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(100L), + org.mockito.ArgumentMatchers.eq(700L), org.mockito.ArgumentMatchers.eq(3), any()); + } + + @Test + void routesContainerVoidThroughExistingBusinessService() throws Exception { + VoidRequest request = new VoidRequest("CONTAINER_FEE", 800L, 4, 9L, + null, 61L, 71L, "finance", "柜次费用作废"); + ContainerFeeDetailEntity detail = new ContainerFeeDetailEntity(); + detail.setId(800L); detail.setTenantId(9L); detail.setLoadingPlanId(200L); + detail.setContainerBookingId(61L); + detail.setSourceVersion(4); detail.setSourceStatus("ACTIVE"); + when(containerFees.findByContainerBookingId(61L, 800L)).thenReturn(detail); + + service.voidSource("void-container", hash(request), request); + + verify(containerFeeService).voidApproved(9L, 200L, 800L, 4, 71L); + } + + @Test + void rejectsCrossCustomerSourceBeforeBusinessMutation() throws Exception { + VoidRequest request = inboundRequest(700L, 2); + when(inboundFees.findSourceVersion(100L, 700L, 2)) + .thenReturn(inboundDetail("MANUAL", 777L, 700L, 2, 10L)); + + assertThatThrownBy(() -> service.voidSource("void-cross", hash(request), request)) + .isInstanceOf(BizException.class).hasMessageContaining("指定客户"); + + verify(inboundFees, never()).voidApproved(anyLong(), anyLong(), anyLong(), anyInt(), anyLong()); + verify(outbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any()); + } + + @Test + void replaysCompletedCommandWithoutRepeatingBusinessMutation() throws Exception { + VoidRequest request = inboundRequest(700L, 2); + String requestHash = hash(request); + doThrow(new DuplicateKeyException("duplicate")).when(commands).insert(any()); + FinanceFeeSourceCommandEntity existing = new FinanceFeeSourceCommandEntity(); + existing.setRequestHash(requestHash); existing.setStatus("SUCCEEDED"); + existing.setResultStatus("VOIDED"); + when(commands.find(9L, 71L, "VOID", "void-replay")).thenReturn(existing); + + var result = service.voidSource("void-replay", requestHash, request); + + assertThat(result.replayed()).isTrue(); + verify(inboundFees, never()).findSourceVersion(anyLong(), anyLong(), anyInt()); + } + + private VoidRequest inboundRequest(long feeId, int version) { + return new VoidRequest("INBOUND_FEE", feeId, version, 9L, + 100L, null, 71L, "finance", "入库费用作废"); + } + + private static InboundFeeDetailEntity inboundDetail(String sourceType, long id, + long sourceFeeId, int version, + long tenantId) { + InboundFeeDetailEntity value = new InboundFeeDetailEntity(); + value.setId(id); value.setTenantId(tenantId); value.setInboundId(100L); + value.setSourceType(sourceType); value.setSourceFeeId(sourceFeeId); + value.setSourceFeeVersion(version); value.setReviewStatus("APPROVED"); + value.setPublicationStatus("PUBLISHED"); + return value; + } + + private String hash(VoidRequest request) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceOutboxRelayTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceOutboxRelayTest.java new file mode 100644 index 000000000..3ccc72e45 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceOutboxRelayTest.java @@ -0,0 +1,154 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.firstleg.client.FinanceFeeSourceEventClient; +import com.baiyue.firstleg.dto.FinanceFeeSourceEvent; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.repository.FinanceFeeSourceOutboxRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.env.MapPropertySource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FinanceFeeSourceOutboxRelayTest { + private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 11, 15, 0); + private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + private final FinanceFeeSourceOutboxRepository repository = mock(FinanceFeeSourceOutboxRepository.class); + private final FinanceFeeSourceEventClient client = mock(FinanceFeeSourceEventClient.class); + private final FinanceFeeSourceOutboxRelay relay = relay(); + + @Test + void springCreatesRelayWithItsAutowiredProductionConstructor() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.getEnvironment().getPropertySources().addFirst(new MapPropertySource( + "test", Map.of("baiyue.security.internal-service-token", "finance-token"))); + context.registerBean(FinanceFeeSourceOutboxRepository.class, () -> repository); + context.registerBean(FinanceFeeSourceEventClient.class, () -> client); + context.registerBean(ObjectMapper.class, () -> json); + context.register(FinanceFeeSourceOutboxRelay.class); + + context.refresh(); + + assertThat(context.getBean(FinanceFeeSourceOutboxRelay.class)).isNotNull(); + } + } + + @Test + void claimsThenPublishesOnlyAfterFinanceAcknowledgesSameEvent() throws Exception { + InboundOutboxEntity row = event(); + ready(row); + FinanceFeeSourceEvent envelope = json.readValue(row.getPayloadJson(), FinanceFeeSourceEvent.class); + when(client.receive("finance-token", envelope.idempotencyKey(), envelope)).thenReturn( + ApiResponse.success(new FinanceFeeSourceEventClient.Acknowledgement( + 901L))); + when(repository.published(row.getId(), "worker-1", NOW)).thenReturn(true); + + relay.relay(); + + verify(repository).recoverStale(NOW.minusMinutes(5), NOW.plusSeconds(10), 12); + verify(repository).claim(row.getId(), "worker-1", NOW); + verify(client).receive("finance-token", envelope.idempotencyKey(), envelope); + verify(repository).published(row.getId(), "worker-1", NOW); + verify(repository, never()).retry(any(Long.class), any(), any(), any(), eq(false)); + } + + @Test + void concurrentWorkerThatLosesClaimDoesNotDeliver() { + InboundOutboxEntity row = event(); + when(repository.pending(50, NOW)).thenReturn(List.of(row)); + when(repository.claim(row.getId(), "worker-1", NOW)).thenReturn(false); + + relay.relay(); + + verify(client, never()).receive(any(), any(), any()); + verify(repository, never()).published(any(Long.class), any(), any()); + } + + @Test + void transientFailureReturnsClaimToPendingWithBackoff() throws Exception { + InboundOutboxEntity row = event(); + ready(row); + FinanceFeeSourceEvent envelope = json.readValue(row.getPayloadJson(), FinanceFeeSourceEvent.class); + when(client.receive("finance-token", envelope.idempotencyKey(), envelope)) + .thenThrow(new IllegalStateException("finance unavailable")); + + relay.relay(); + + verify(repository).retry(row.getId(), "worker-1", NOW.plusSeconds(10), + "finance unavailable", false); + verify(repository, never()).published(any(Long.class), any(), any()); + } + + @Test + void invalidEventHashFailsPermanentlyWithoutCallingFinance() { + InboundOutboxEntity row = event(); + row.setPayloadHash("0".repeat(64)); + ready(row); + + relay.relay(); + + verify(repository).retry(eq(row.getId()), eq("worker-1"), eq(NOW.plusSeconds(10)), + any(), eq(true)); + verify(client, never()).receive(any(), any(), any()); + } + + @Test + void financeIdempotencyConflictFailsPermanently() throws Exception { + InboundOutboxEntity row = event(); + ready(row); + FinanceFeeSourceEvent envelope = json.readValue(row.getPayloadJson(), FinanceFeeSourceEvent.class); + when(client.receive("finance-token", envelope.idempotencyKey(), envelope)).thenReturn( + new ApiResponse<>("FIN-EVT-002", "同一事件ID对应不同请求内容", null, "r", "t", Instant.now())); + + relay.relay(); + + verify(repository).retry(eq(row.getId()), eq("worker-1"), eq(NOW.plusSeconds(10)), + any(), eq(true)); + verify(repository, never()).published(any(Long.class), any(), any()); + } + + private void ready(InboundOutboxEntity row) { + when(repository.pending(50, NOW)).thenReturn(List.of(row)); + when(repository.claim(row.getId(), "worker-1", NOW)).thenReturn(true); + when(repository.findClaimed(row.getId(), "worker-1")).thenReturn(row); + } + + private InboundOutboxEntity event() { + InboundOutboxMapper mapper = mock(InboundOutboxMapper.class); + FinanceFeeSourceOutboxService producer = new FinanceFeeSourceOutboxService(mapper, json); + producer.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED, + 10001L, 21L, 31L, 1, NOW); + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(mapper).insert(captor.capture()); + InboundOutboxEntity row = captor.getValue(); + row.setId(71L); + return row; + } + + private FinanceFeeSourceOutboxRelay relay() { + Clock clock = Clock.fixed(Instant.parse("2026-08-11T07:00:00Z"), ZoneId.of("Asia/Shanghai")); + FinanceFeeSourceOutboxRelay value = + new FinanceFeeSourceOutboxRelay(repository, client, json, clock, "worker-1"); + ReflectionTestUtils.setField(value, "internalToken", "finance-token"); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceOutboxServiceTest.java new file mode 100644 index 000000000..f9d3f259d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceOutboxServiceTest.java @@ -0,0 +1,66 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DuplicateKeyException; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class FinanceFeeSourceOutboxServiceTest { + private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class); + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + private final FinanceFeeSourceOutboxService service = + new FinanceFeeSourceOutboxService(mapper, objectMapper); + + @Test + void writesContractEnvelopeWithShortPayloadAndStableHash() { + LocalDateTime occurredAt = LocalDateTime.of(2026, 8, 11, 12, 0); + + service.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED, + 10001L, 21L, 31L, 1, occurredAt); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(mapper).insert(captor.capture()); + InboundOutboxEntity event = captor.getValue(); + assertThat(event.getEventType()).isEqualTo("FIRSTLEG_FEE_PUBLISHED"); + assertThat(event.getEventVersion()).isEqualTo(1); + assertThat(event.getAggregateSequence()).isEqualTo(1); + assertThat(event.getPayloadHash()).matches("[0-9a-f]{64}"); + assertThat(event.getPayloadJson()).contains("\"producer\":\"firstleg-service\"", + "\"inboundId\":\"21\"", "\"feeId\":\"31\"", "\"feeVersion\":1") + .doesNotContain("amount", "currency"); + } + + @Test + void treatsDuplicateEventKeyAsSuccessfulReplay() { + doThrow(new DuplicateKeyException("duplicate event_key")).when(mapper) + .insert((InboundOutboxEntity) org.mockito.ArgumentMatchers.any()); + + assertThatCode(() -> service.publishContainer(FinanceFeeSourceOutboxService.VOIDED, + 10001L, 41L, 51L, 2, LocalDateTime.now())).doesNotThrowAnyException(); + } + + @Test + void containerEventUsesTheRealContainerBookingIdAsAggregateAndPayloadIdentity() { + service.publishContainer(FinanceFeeSourceOutboxService.PUBLISHED, + 10001L, 61L, 51L, 2, LocalDateTime.of(2026, 8, 11, 12, 0)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(mapper).insert(captor.capture()); + InboundOutboxEntity event = captor.getValue(); + assertThat(event.getAggregateType()).isEqualTo("CONTAINER"); + assertThat(event.getAggregateId()).isEqualTo(61L); + assertThat(event.getPayloadJson()).contains("\"containerId\":\"61\"") + .doesNotContain("\"containerId\":\"41\""); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotServiceTest.java new file mode 100644 index 000000000..1c22f0a74 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotServiceTest.java @@ -0,0 +1,106 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot; +import com.baiyue.firstleg.entity.FeeItemEntity; +import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity; +import com.baiyue.firstleg.entity.InboundFeeDetailEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository; +import com.baiyue.firstleg.repository.FeeItemRepository; +import org.junit.jupiter.api.Test; + +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.mock; +import static org.mockito.Mockito.when; + +class FinanceFeeSourceSnapshotServiceTest { + private final FeeItemRepository inboundFees = mock(FeeItemRepository.class); + private final ContainerFeeSourceSnapshotRepository containerFees = mock(ContainerFeeSourceSnapshotRepository.class); + private final FinanceFeeSourceSnapshotService service = + new FinanceFeeSourceSnapshotService(inboundFees, containerFees); + + @Test + void returnsReviewedSettlementSnapshotWithStableSourceHash() { + InboundFeeDetailEntity detail = new InboundFeeDetailEntity(); + detail.setId(31L); + detail.setTenantId(10001L); + detail.setInboundId(21L); + detail.setFeeItemId(11L); + detail.setSourceType("MANUAL"); + detail.setSourceFeeVersion(2); + detail.setDirection("RECEIVABLE"); + detail.setAmount(new BigDecimal("100.00")); + detail.setCurrency("CNY"); + detail.setReviewStatus("APPROVED"); + detail.setPublicationStatus("PUBLISHED"); + when(inboundFees.findSourceVersion(21L, 31L, 2)).thenReturn(detail); + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(21L); + inbound.setOrderId(81L); + inbound.setInboundCode("RK-21"); + inbound.setCustomerName("客户A"); + when(inboundFees.findInbound(10001L, 21L)).thenReturn(inbound); + FeeItemEntity item = new FeeItemEntity(); + item.setId(11L); + item.setFeeCode("STORAGE"); + when(inboundFees.findItem(11L)).thenReturn(item); + + FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L, 2); + + assertThat(snapshot.customerId()).isEqualTo(10001L); + assertThat(snapshot.orderId()).isEqualTo(81L); + assertThat(snapshot.feeVersion()).isEqualTo(2); + assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}"); + detail.setPublicationStatus("VOIDED"); + assertThat(service.inbound(21L, 31L, 2).sourceHash()).isEqualTo(snapshot.sourceHash()); + } + + @Test + void rejectsFeeThatDoesNotBelongToInboundPath() { + InboundFeeDetailEntity detail = new InboundFeeDetailEntity(); + detail.setInboundId(22L); + when(inboundFees.findSourceVersion(21L, 31L, 1)).thenReturn(detail); + + assertThatThrownBy(() -> service.inbound(21L, 31L, 1)) + .isInstanceOf(BizException.class).hasMessageContaining("不存在"); + } + + @Test + void containerSnapshotPublishesPayableCounterpartyIdentity() { + ContainerFeeSourceSnapshotEntity detail = new ContainerFeeSourceSnapshotEntity(); + detail.setSourceFeeId(51L); + detail.setTenantId(10001L); + detail.setLoadingPlanId(41L); + detail.setContainerBookingId(61L); + detail.setContainerNo("CONT-41"); + detail.setFeeItemId(11L); + detail.setFeeItemCode("TRUCKING"); + detail.setFeeItemName("拖车费"); + detail.setDirection("PAYABLE"); + detail.setSupplierId(71L); + detail.setSupplierName("现有供应商"); + detail.setSupplierCategory("TRUCKING"); + detail.setSettlementMode("ON_ACCOUNT"); + detail.setBusinessDate(java.time.LocalDate.of(2026, 8, 12)); + detail.setAmount(new BigDecimal("300.00")); + detail.setCurrency("CNY"); + detail.setReviewStatus("APPROVED"); + detail.setSourceVersion(2); + when(containerFees.findByContainerBookingId(61L, 51L, 2)).thenReturn(detail); + + FinanceFeeSourceSnapshot snapshot = service.container(61L, 51L, 2); + + assertThat(snapshot.containerId()).isEqualTo(61L); + assertThat(snapshot.containerBookingId()).isEqualTo(61L); + assertThat(snapshot.supplierId()).isEqualTo(71L); + assertThat(snapshot.supplierName()).isEqualTo("现有供应商"); + assertThat(snapshot.supplierCategory()).isEqualTo("TRUCKING"); + assertThat(snapshot.settlementMode()).isEqualTo("ON_ACCOUNT"); + assertThat(snapshot.paidAt()).isNull(); + assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java new file mode 100644 index 000000000..911518b9c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java @@ -0,0 +1,61 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FirstlegDocumentNumberGeneratorTest { + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai")); + + @Test + void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.registerBean(JdbcTemplate.class, () -> jdbc); + context.register(FirstlegDocumentNumberGenerator.class); + context.refresh(); + + assertThat(context.getBean(FirstlegDocumentNumberGenerator.class)).isNotNull(); + } + } + + @Test + void formatsAllOperationalDocumentPrefixes() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)) + .thenReturn(1L, 1L, 1L, 1L, 1L); + FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK); + + assertThat(generator.next("OB")).isEqualTo("OB-260818-0001"); + assertThat(generator.next("TA")).isEqualTo("TA-260818-0001"); + assertThat(generator.next("CB")).isEqualTo("CB-260818-0001"); + assertThat(generator.next("CAB")).isEqualTo("CAB-260818-0001"); + assertThat(generator.next("LP")).isEqualTo("LP-260818-0001"); + + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"), + eq("OB:20260818")); + } + + @Test + void rejectsUnsupportedTypeAndDailyOverflow() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK); + assertThatThrownBy(() -> generator.next("MSCU")).isInstanceOf(IllegalArgumentException.class); + + when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L); + assertThatThrownBy(() -> generator.next("CAB")).isInstanceOf(BizException.class); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegScopeGuardTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegScopeGuardTest.java new file mode 100644 index 000000000..153137c19 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegScopeGuardTest.java @@ -0,0 +1,41 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FirstlegScopeGuardTest { + @Test + void platformRequiresZeroTenantAndPositiveTargetCustomer() { + AuthenticatedUser platform = user(0L, "PLATFORM"); + + assertThat(FirstlegScopeGuard.platform(platform, 10001L)).isEqualTo(10001L); + assertForbidden(() -> FirstlegScopeGuard.platform(user(10001L, "PLATFORM"), 10002L)); + assertForbidden(() -> FirstlegScopeGuard.platform(user(0L, "MERCHANT"), 10002L)); + } + + @Test + void merchantRequiresPositiveAuthenticatedCustomer() { + assertThat(FirstlegScopeGuard.merchant(user(10001L, "MERCHANT"))).isEqualTo(10001L); + assertForbidden(() -> FirstlegScopeGuard.merchant(user(0L, "MERCHANT"))); + assertForbidden(() -> FirstlegScopeGuard.merchant(user(10001L, "PLATFORM"))); + } + + private static AuthenticatedUser user(long tenantId, String appCode) { + return new AuthenticatedUser(9L, tenantId, tenantId, 1L, + "operator", appCode, "WEB", Set.of()); + } + + private static void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable call) { + assertThatThrownBy(call) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.FORBIDDEN); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskProvisioningServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskProvisioningServiceTest.java new file mode 100644 index 000000000..87b4d0b6e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskProvisioningServiceTest.java @@ -0,0 +1,84 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; +import com.baiyue.firstleg.entity.FulfillmentTaskEntity; +import com.baiyue.firstleg.repository.FulfillmentTaskRepository; +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.dao.DuplicateKeyException; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FulfillmentTaskProvisioningServiceTest { + private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 8, 10, 0); + @Mock private FulfillmentTaskRepository repository; + private FulfillmentTaskProvisioningService service; + + @BeforeEach + void setUp() { + service = new FulfillmentTaskProvisioningService(repository, new ObjectMapper().findAndRegisterModules()); + } + + @Test + void shouldCreatePendingTaskFromApprovedOrderSnapshot() { + FulfillmentTaskEntity created = service.provision(command(), NOW); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FulfillmentTaskEntity.class); + verify(repository).insert(captor.capture()); + assertThat(created).isSameAs(captor.getValue()); + assertThat(created.getTenantId()).isEqualTo(10001L); + assertThat(created.getOrderId()).isEqualTo(77L); + assertThat(created.getOrderNo()).isEqualTo("FL-77"); + assertThat(created.getStatus()).isEqualTo("PENDING_ASSIGNMENT"); + assertThat(created.getOrderSnapshotJson()).contains("\"tenantId\":10001", "\"orderId\":77", "\"inboundCode\":\"RK-77\""); + assertThat(created.getCreatedAt()).isEqualTo(NOW); + assertThat(created.getDeleted()).isFalse(); + assertThat(created.getVersion()).isZero(); + } + + @Test + void shouldReturnExistingTaskForIdempotentReplay() { + FulfillmentTaskEntity existing = task(700L); + when(repository.findByOrder(10001L, 77L)).thenReturn(existing); + + assertThat(service.provision(command(), NOW)).isSameAs(existing); + + verify(repository, never()).insert(any()); + } + + @Test + void shouldConvergeOnConcurrentTaskCreation() { + FulfillmentTaskEntity winner = task(701L); + doThrow(new DuplicateKeyException("concurrent insert")).when(repository).insert(any()); + when(repository.findByOrderForUpdate(10001L, 77L)).thenReturn(winner); + + assertThat(service.provision(command(), NOW)).isSameAs(winner); + } + + private static FulfillmentTaskEntity task(long id) { + FulfillmentTaskEntity task = new FulfillmentTaskEntity(); + task.setId(id); task.setTenantId(10001L); task.setOrderId(77L); task.setOrderNo("FL-77"); + return task; + } + + private static OrderApprovedInboundCommand command() { + return new OrderApprovedInboundCommand( + 10001L, 77L, "FL-77", "RK-77", 7L, "测试客户", 9L, "义乌仓", + null, null, null, "FBA", "AIR", "空运", null, null, + "BUY_DECLARATION", "TAX_EXCLUDED", null, null, "FBA", null, + "ONT8", "ONT8", "验收备注", null, null); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java new file mode 100644 index 000000000..5ed6c4772 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java @@ -0,0 +1,79 @@ +package com.baiyue.firstleg.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.firstleg.dto.FulfillmentTaskQuery; +import com.baiyue.firstleg.entity.FulfillmentTaskEntity; +import com.baiyue.firstleg.repository.FulfillmentTaskRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +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.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("履约任务服务") +@SpringJUnitConfig(FulfillmentTaskService.class) +class FulfillmentTaskServiceTest { + @MockBean private FulfillmentTaskRepository repository; + @Resource private FulfillmentTaskService service; + @Test + @DisplayName("列表查询保留租户和分页条件") + void shouldPreserveTenantAndPaginationInPageQuery() { + FulfillmentTaskQuery query = new FulfillmentTaskQuery(null, null, null, 1, 20); + PageResult expected = new PageResult<>(List.of(), 1, 20, 0, 0); + when(repository.page(10001L, query)).thenReturn(expected); + + assertThat(service.page(10001L, query)).isSameAs(expected); + verify(repository).page(10001L, query); + } + + @Test + @DisplayName("详情和按订单查询不存在时返回404") + void shouldReturnNotFoundForMissingTaskAndOrder() { + + assertThatThrownBy(() -> service.detail(10001L, 1L)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + assertThatThrownBy(() -> service.byOrder(10001L, 2L)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + } + + @Test + @DisplayName("按客户和订单查询返回审核后生成的履约任务") + void shouldReturnProvisionedTaskByTenantAndOrder() { + FulfillmentTaskEntity task = new FulfillmentTaskEntity(); + task.setId(700L); task.setTenantId(10001L); task.setOrderId(77L); + when(repository.findByOrder(10001L, 77L)).thenReturn(task); + + assertThat(service.byOrder(10001L, 77L)).isSameAs(task); + + verify(repository).findByOrder(10001L, 77L); + } + + @Test + @DisplayName("查询分配历史前先校验任务归属租户") + void shouldVerifyTaskTenantBeforeLoadingAssignmentHistory() { + FulfillmentTaskEntity task = new FulfillmentTaskEntity(); + when(repository.findOwned(10001L, 1L)).thenReturn(task); + PageResult expected = + new PageResult<>(List.of(), 1, 20, 0, 0); + when(repository.pageHistory(10001L, 1L, 1, 20)).thenReturn(expected); + + assertThat(service.assignmentHistory(10001L, 1L, 1, 20)).isSameAs(expected); + verify(repository).findOwned(10001L, 1L); + } + +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java new file mode 100644 index 000000000..e1d8457e9 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java @@ -0,0 +1,69 @@ +package com.baiyue.firstleg.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.IamCustomerSnapshotClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +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.verify; +import static org.mockito.Mockito.when; + +class IamCustomerSnapshotServiceTest { + @Mock IamCustomerSnapshotClient client; + private IamCustomerSnapshotService service; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new IamCustomerSnapshotService(); + ReflectionTestUtils.setField(service, "client", client); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + } + + @Test + void resolvesExactlyOneActiveCustomerAndUsesServerName() { + var customer = new IamCustomerSnapshotClient.Customer( + 7L, "C-7", "Canonical Customer", "Short Name", "ACTIVE", null, null); + when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1))); + + var result = service.active(7L, "Canonical Customer"); + + assertThat(result.tenantName()).isEqualTo("Canonical Customer"); + verify(client).page("internal-token", 1, 2, null, "ACTIVE", 7L); + } + + @Test + void rejectsMissingInactiveOrClientSuppliedDifferentName() { + when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 2, 0, 0))); + assertThatThrownBy(() -> service.active(7L, null)) + .isInstanceOf(BizException.class).hasMessageContaining("不存在或未启用"); + + var customer = new IamCustomerSnapshotClient.Customer( + 7L, "C-7", "Canonical Customer", "Short Name", "ACTIVE", null, null); + when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1))); + assertThatThrownBy(() -> service.active(7L, "Forged Customer")) + .isInstanceOf(BizException.class).hasMessageContaining("不一致"); + } + + @Test + void resolvesCustomerNicknameForPalletDocument() { + var customer = new IamCustomerSnapshotClient.Customer( + 7L, "C-7", "Canonical Customer", " Short Name ", "ACTIVE", null, null); + when(client.page("internal-token", 1, 2, null, null, 7L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1))); + + assertThat(service.customerNickname(7L)).isEqualTo("Short Name"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java new file mode 100644 index 000000000..43dfe4d4b --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java @@ -0,0 +1,263 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.PrintPdfClient; +import com.baiyue.firstleg.dto.InboundCartonLabels; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.SkuItem; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +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.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class InboundCartonLabelPrintServiceTest { + private final InboundCartonLabelService labels = mock(InboundCartonLabelService.class); + private final PrintPdfClient print = mock(PrintPdfClient.class); + private final DropshipPalletizationMapper palletization = mock(DropshipPalletizationMapper.class); + private final IamCustomerSnapshotService customerSnapshots = mock(IamCustomerSnapshotService.class); + private InboundCartonLabelPrintService service; + + @BeforeEach + void setUp() { + service = new InboundCartonLabelPrintService(labels, print, new ObjectMapper()); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + ReflectionTestUtils.setField(service, "palletizationMapper", palletization); + ReflectionTestUtils.setField(service, "customerSnapshots", customerSnapshots); + } + + @Test + void dropshipPalletLabelIsAvailableBeforeSeal() { + Pallet open = dropshipPallet("OPEN"); + stubPalletFacts(90001L); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.eq("request-open"), org.mockito.ArgumentMatchers.any())) + .thenReturn(new ResponseEntity<>(new byte[] {7, 8, 9}, headers, + org.springframework.http.HttpStatus.OK)); + + ResponseEntity response = service.generateDropshipPallet(open, "PCHK2608040174", "request-open"); + assertThat(response.getBody()).containsExactly(7, 8, 9); + + ArgumentCaptor request = + ArgumentCaptor.forClass(PrintPdfClient.DropshipPalletA4PdfRequest.class); + verify(print).generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.eq("request-open"), request.capture()); + assertThat(request.getValue().templateCode()).isEqualTo("DROPSHIP_PALLET_A4_V2"); + assertThat(request.getValue().pageSpec().widthMm()).isEqualByComparingTo("210"); + assertThat(request.getValue().pageSpec().heightMm()).isEqualByComparingTo("297"); + assertThat(request.getValue().documents()).singleElement().satisfies(document -> { + assertThat(document.palletNo()).isEqualTo("DP-260911-0001"); + assertThat(document.destinationWarehouse()).isEqualTo("NJDF"); + assertThat(document.inboundRows()).singleElement().satisfies(row -> { + assertThat(row.inboundCode()).isEqualTo("PCHK2608040174"); + assertThat(row.customerNickname()).isEqualTo("小虫科技"); + assertThat(row.cartonCount()).isEqualTo(1); + assertThat(row.itemQuantity()).isEqualTo(19); + }); + assertThat(document.skuRows()).singleElement().satisfies(row -> { + assertThat(row.skuCode()).isEqualTo("SKU-1"); + assertThat(row.cartonCount()).isEqualTo(1); + assertThat(row.itemQuantity()).isEqualTo(19); + assertThat(row.unitsPerCarton()).isEqualTo("19"); + }); + }); + } + + @Test + void multiplePreGeneratedPalletsAreReturnedInOnePdf() { + Pallet first = dropshipPallet("OPEN"); + Pallet second = new Pallet(90002L, "DP-260911-0002", "OPEN", 0, 0, 0, + BigDecimal.ZERO, BigDecimal.ZERO, 1, List.of(), List.of(), null); + stubPalletFacts(90001L); + stubPalletFacts(90002L); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.eq("request-all"), org.mockito.ArgumentMatchers.any())) + .thenReturn(new ResponseEntity<>(new byte[] {4, 5, 6}, headers, + org.springframework.http.HttpStatus.OK)); + + ResponseEntity response = service.generateDropshipPallets( + List.of(first, second), "PCHK2608040174", "request-all"); + + ArgumentCaptor request = + ArgumentCaptor.forClass(PrintPdfClient.DropshipPalletA4PdfRequest.class); + verify(print).generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.eq("request-all"), request.capture()); + assertThat(request.getValue().documents()).extracting("palletNo") + .containsExactly("DP-260911-0001", "DP-260911-0002"); + assertThat(request.getValue().downloadFileName()).contains("2托"); + assertThat(response.getBody()).containsExactly(4, 5, 6); + } + + @Test + void forwardsStableLabelsAndOnlySafePdfHeaders() { + when(labels.list(10001L, 70001L, List.of(1))).thenReturn(batch()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + headers.setContentDispositionFormData("attachment", "labels.pdf"); + headers.set("X-Print-Page-Count", "1"); + headers.set("X-Internal-Service-Token", "must-not-leak"); + when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any())) + .thenReturn(new ResponseEntity<>(new byte[] {1, 2, 3}, headers, + org.springframework.http.HttpStatus.OK)); + + ResponseEntity response = service.generate(10001L, 70001L, List.of(1), "request-1"); + + ArgumentCaptor request = + ArgumentCaptor.forClass(PrintPdfClient.InboundMarkPdfRequest.class); + verify(print).generateInboundMarks(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.eq("request-1"), request.capture()); + assertThat(request.getValue().downloadFileName()).isEqualTo("PCHK2608040174-19件-箱唛.pdf"); + assertThat(request.getValue().labels()).singleElement().satisfies(label -> { + assertThat(label.qrContent()).isEqualTo("PCHK2608040174#19-1"); + assertThat(label.cartonText()).isEqualTo("CTNS:19-1"); + }); + assertThat(response.getBody()).containsExactly(1, 2, 3); + assertThat(response.getHeaders().getFirst("X-Print-Page-Count")).isEqualTo("1"); + assertThat(response.getHeaders()).doesNotContainKey("X-Internal-Service-Token"); + } + + @Test + void convertsPrintJsonBusinessFailureInsteadOfReturningItAsPdf() throws Exception { + when(labels.list(10001L, 70001L, null)).thenReturn(batch()); + byte[] error = "{\"code\":\"A0422\",\"message\":\"标签字段超出模板限制\"," + .concat("\"data\":null,\"requestId\":\"req-2\",\"traceId\":\"trace-2\"}") + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(error)); + + assertThatThrownBy(() -> service.generate(10001L, 70001L, null, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("标签字段超出模板限制") + .extracting(value -> ((BizException) value).getResultCode()) + .isEqualTo(ResultCode.VALIDATION_FAILED); + } + + @Test + void convertsCircuitBreakerFailureToServiceUnavailable() { + when(labels.list(10001L, 70001L, null)).thenReturn(batch()); + when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any())) + .thenThrow(new NoFallbackAvailableException("print unavailable", + new IllegalStateException("down"))); + + assertThatThrownBy(() -> service.generate(10001L, 70001L, null, null)) + .isInstanceOf(BizException.class) + .hasMessage("打印服务暂不可用,请稍后重试") + .extracting(value -> ((BizException) value).getResultCode()) + .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + } + + @Test + void batchCombinesSuccessfulOrdersAndReportsIsolatedFailures() { + when(labels.requireInboundIdByOrder(10001L, 60001L)).thenReturn(70001L); + when(labels.requireInboundIdByOrder(10001L, 60002L)) + .thenThrow(new BizException(ResultCode.NOT_FOUND, "missing")); + when(labels.list(10001L, 70001L, null)).thenReturn(batch()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.eq("request-batch"), org.mockito.ArgumentMatchers.any())) + .thenReturn(new ResponseEntity<>(new byte[] {1, 2, 3}, headers, + org.springframework.http.HttpStatus.OK)); + + ResponseEntity response = service.generateByOrders( + 10001L, List.of(60001L, 60002L), "request-batch"); + + assertThat(response.getHeaders().getFirst("X-Carton-Label-Order-Count")).isEqualTo("2"); + assertThat(response.getHeaders().getFirst("X-Carton-Label-Failure-Count")).isEqualTo("1"); + assertThat(response.getHeaders().getFirst("X-Carton-Label-Failures")).contains("60002:A0404"); + verify(print).generateInboundMarks(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.eq("request-batch"), org.mockito.ArgumentMatchers.any()); + } + + @Test + void platformBatchCombinesLabelsAcrossCustomers() { + when(labels.list(10001L, 70001L, null)).thenReturn(batch()); + when(labels.list(10002L, 70002L, null)).thenReturn(batch()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.eq("platform-batch"), org.mockito.ArgumentMatchers.any())) + .thenReturn(new ResponseEntity<>(new byte[] {1, 2, 3}, headers, + org.springframework.http.HttpStatus.OK)); + + ResponseEntity response = service.generateForPlatformInbounds(List.of( + new InboundCartonLabels.PlatformBatchItem(10001L, 70001L), + new InboundCartonLabels.PlatformBatchItem(10002L, 70002L)), "platform-batch"); + + assertThat(response.getHeaders().getFirst("X-Carton-Label-Order-Count")).isEqualTo("2"); + assertThat(response.getBody()).containsExactly(1, 2, 3); + } + + @Test + void platformBatchFailsWhenAnySelectedInboundCannotGenerateLabels() { + when(labels.list(10001L, 70001L, null)).thenReturn(batch()); + when(labels.list(10002L, 70002L, null)) + .thenThrow(new BizException(ResultCode.NOT_FOUND, "箱唛入库单不存在")); + + assertThatThrownBy(() -> service.generateForPlatformInbounds(List.of( + new InboundCartonLabels.PlatformBatchItem(10001L, 70001L), + new InboundCartonLabels.PlatformBatchItem(10002L, 70002L)), "platform-batch")) + .isInstanceOf(BizException.class) + .hasMessage("箱唛入库单不存在"); + verifyNoInteractions(print); + } + + private InboundCartonLabels.Batch batch() { + InboundCartonLabels.Label label = new InboundCartonLabels.Label( + 1L, 2L, 1, "音箱", 19, "PCHK2608040174#19-1", 19, 1, + "50x40x30cm", new BigDecimal("50"), new BigDecimal("40"), new BigDecimal("30"), + "PCHK2608040174", "MCC1", "CTNS:19-1", "洛杉矶普船海卡"); + return new InboundCartonLabels.Batch(70001L, 80001L, "PCHK2608040174", 19, List.of(label)); + } + + private Pallet dropshipPallet(String status) { + SkuItem item = new SkuItem(70001L, "PCHK2608040174", 2L, + "SKU-1", "音箱", 1, 19, List.of("PCHK2608040174#19-1")); + return new Pallet(90001L, "DP-260911-0001", status, 1, 1, 0, + BigDecimal.ONE, BigDecimal.ONE, 1, List.of(item), List.of(), null); + } + + private void stubPalletFacts(long palletId) { + DropshipPalletizationMapper.PrintInboundRow inbound = + new DropshipPalletizationMapper.PrintInboundRow(); + inbound.setTenantId(10001L); + inbound.setInboundId(70001L); + inbound.setInboundCode("PCHK2608040174"); + inbound.setDestinationWarehouse("NJDF"); + inbound.setCartonCount(palletId == 90001L ? 1 : 0); + inbound.setItemQuantity(palletId == 90001L ? 19 : 0); + when(palletization.printInboundRows(palletId)).thenReturn(List.of(inbound)); + DropshipPalletizationMapper.PrintSkuRow sku = new DropshipPalletizationMapper.PrintSkuRow(); + sku.setSkuCode("SKU-1"); + sku.setCartonCount(1); + sku.setItemQuantity(19); + sku.setMinUnitsPerCarton(19); + sku.setMaxUnitsPerCarton(19); + when(palletization.printSkuRows(palletId)) + .thenReturn(palletId == 90001L ? List.of(sku) : List.of()); + when(customerSnapshots.customerNickname(10001L)).thenReturn("小虫科技"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java new file mode 100644 index 000000000..505f3dfb2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java @@ -0,0 +1,366 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.client.PrintableOrderInboundClient; +import com.baiyue.firstleg.entity.InboundCartonLabelEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.InboundCartonLabelRepository; +import com.baiyue.firstleg.mapper.InboundCartonLabelSkuMapper; +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.cloud.client.circuitbreaker.NoFallbackAvailableException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.ArrayList; +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.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 InboundCartonLabelServiceTest { + @Mock private InboundCartonLabelRepository repository; + @Mock private OrderCargoClient orderCargoClient; + @Mock private PrintableOrderInboundClient printableOrderInboundClient; + @Mock private InboundOrderCreationService inboundOrderCreationService; + @Mock private InboundCartonLabelSkuMapper labelSkus; + private InboundCartonLabelService service; + + @BeforeEach + void setUp() { + service = new InboundCartonLabelService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "orderCargoClient", orderCargoClient); + ReflectionTestUtils.setField(service, "printableOrderInboundClient", printableOrderInboundClient); + ReflectionTestUtils.setField(service, "inboundOrderCreationService", inboundOrderCreationService); + ReflectionTestUtils.setField(service, "labelSkus", labelSkus); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + } + + @Test + void firstAllocationUsesCargoOrderAndFreezesEveryPhysicalCarton() { + InboundOrderEntity inbound = inbound(); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(new ArrayList<>()); + when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>()); + var second = cargo(80002L, 2, 2, "50", "30", "20"); + var first = cargo(80001L, 1, 3, "60", "40", "30"); + when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(List.of(second, first))); + List inserted = new ArrayList<>(); + org.mockito.Mockito.doAnswer(invocation -> { + inserted.add(invocation.getArgument(0)); + return null; + }).when(repository).insert(any()); + + var batch = service.getOrCreate(10001L, 70001L); + + assertThat(batch.totalCount()).isEqualTo(5); + assertThat(batch.labels()).extracting(label -> label.boxCode()) + .containsExactly("PCHK2608140001#5-1", "PCHK2608140001#5-2", "PCHK2608140001#5-3", + "PCHK2608140001#5-4", "PCHK2608140001#5-5"); + assertThat(batch.labels()).extracting(label -> label.inboundDetailId()) + .containsExactly(80001L, 80001L, 80001L, 80002L, 80002L); + assertThat(batch.labels().get(0).boxSpecification()).isEqualTo("60x40x30cm"); + assertThat(batch.labels().get(0).cartonText()).isEqualTo("CTNS:5-1"); + assertThat(inbound.getTotalPackageCount()).isEqualTo(5); + assertThat(inserted).hasSize(5); + verify(repository).updatePackageCount(10001L, 70001L, 5); + } + + @Test + void mixedCartonGroupCreatesOnePhysicalBoxSeriesWithEverySkuLine() { + InboundOrderEntity inbound = inbound(); + inbound.setFulfillmentType("ONE_CLICK_DROPSHIP"); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(new ArrayList<>()); + when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>()); + var skuA = mixedCargo(80001L, 1, "SKU-A", 2, 3, "A"); + var skuB = mixedCargo(80002L, 2, "SKU-B", 5, 3, "A"); + when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(List.of(skuA, skuB))); + + var batch = service.getOrCreate(10001L, 70001L); + + assertThat(batch.totalCount()).isEqualTo(3); + verify(labelSkus, org.mockito.Mockito.times(6)).insert(any(Long.class), + org.mockito.ArgumentMatchers.eq(10001L), org.mockito.ArgumentMatchers.eq(70001L), any(Long.class), + any(Long.class), any(String.class), any(), any(Integer.class), any(Integer.class)); + } + + @Test + void mixedCartonSkuSummaryStaysWithinPrintContractLimit() { + InboundCartonLabelEntity label = new InboundCartonLabelEntity(); + label.setId(90001L); + List rows = List.of( + skuRow(90001L, "SKU-SMALL-ADMIN-API-20260911081504632"), + skuRow(90001L, "SKU-MEDIUM-ADMIN-API-20260911081504632"), + skuRow(90001L, "SKU-LARGE-ADMIN-API-20260911081504632"), + skuRow(90001L, "SKU-EXTRA-LARGE-ADMIN-API-20260911081504632")); + + String summary = ReflectionTestUtils.invokeMethod(service, "displaySkuCodes", label, + Map.of(90001L, rows)); + + assertThat(summary.codePointCount(0, summary.length())).isLessThanOrEqualTo(64); + assertThat(summary).startsWith("SKU-SMALL-").endsWith(" / +3 SKU"); + } + + @Test + void repeatedAndPartialPrintsReadFrozenMappingWithoutReallocation() { + InboundOrderEntity inbound = inbound(); + inbound.setTotalPackageCount(5); + List existing = labels(inbound, 5); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing); + when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 5, "60", "40", "30")))); + + var batch = service.list(10001L, 70001L, List.of(5, 2, 2)); + + assertThat(batch.totalCount()).isEqualTo(5); + assertThat(batch.labels()).extracting(label -> label.cartonIndex()).containsExactly(2, 5); + verify(repository).initializeLegacyFingerprint(org.mockito.ArgumentMatchers.eq(10001L), + org.mockito.ArgumentMatchers.eq(70001L), org.mockito.ArgumentMatchers.eq(1), any(String.class)); + verify(repository, never()).insert(any()); + } + + @Test + void changedCargoKeepsTheFirstFrozenBoxCodes() { + InboundOrderEntity inbound = inbound(); + inbound.setTotalPackageCount(4); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + List existing = labels(inbound, 5); + existing.forEach(label -> { label.setCargoFingerprint("old"); label.setBatchStatus("ACTIVE"); }); + when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing); + when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30")))); + var result = service.getOrCreate(10001L, 70001L); + assertThat(result.labels()).extracting(label -> label.boxCode()) + .containsExactly("PCHK2608140001#5-1", "PCHK2608140001#5-2", "PCHK2608140001#5-3", + "PCHK2608140001#5-4", "PCHK2608140001#5-5"); + verify(repository, never()).insert(any()); + } + + @Test + void draftCanGenerateLabelsBeforeSubmission() { + InboundOrderEntity inbound = inbound(); + inbound.setProcessStatus("PREPARING"); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(new ArrayList<>()); + when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>()); + when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30")))); + org.mockito.Mockito.doAnswer(invocation -> null).when(repository).insert(any()); + + var result = service.getOrCreate(10001L, 70001L); + + assertThat(result.totalCount()).isEqualTo(2); + assertThat(result.labels()).extracting(label -> label.boxCode()) + .containsExactly("PCHK2608140001#2-1", "PCHK2608140001#2-2"); + } + + @Test + void existingLabelsCanBeRepeatedAfterLoading() { + InboundOrderEntity inbound = inbound(); + inbound.setProcessStatus("LOADING"); + List existing = labels(inbound, 1); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing); + when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 1, "60", "40", "30")))); + var result = service.getOrCreate(10001L, 70001L); + assertThat(result.totalCount()).isEqualTo(1); + } + + @Test + void changedCargoAfterLoadingKeepsTheFirstFrozenBoxCodes() { + InboundOrderEntity inbound = inbound(); + inbound.setProcessStatus("LOADING"); + List existing = labels(inbound, 1); + existing.forEach(label -> { + label.setCargoFingerprint("old-fingerprint"); + label.setBatchStatus("ACTIVE"); + }); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing); + when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30")))); + var result = service.getOrCreate(10001L, 70001L); + + assertThat(result.labels()).extracting(label -> label.boxCode()) + .containsExactly("PCHK2608140001#1-1"); + verify(repository, never()).insert(any()); + } + + @Test + void resolvesBoxCodeInsideCustomerBoundary() { + InboundCartonLabelEntity label = labels(inbound(), 1).get(0); + when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label); + + var resolved = service.resolveByBoxCode(10001L, " PCHK2608140001#1-1 "); + + assertThat(resolved.inboundId()).isEqualTo(70001L); + assertThat(resolved.inboundDetailId()).isEqualTo(80001L); + assertThat(resolved.inboundCode()).isEqualTo("PCHK2608140001"); + assertThat(resolved.cartonIndex()).isEqualTo(1); + assertThat(resolved.totalCartons()).isEqualTo(1); + assertThat(resolved.boxSpecification()).isEqualTo("60x40x30cm"); + verify(repository).findByBoxCode(10001L, "PCHK2608140001#1-1"); + } + + @Test + void exposesDetailedResolutionForFirstlegConsumers() { + InboundCartonLabelEntity label = labels(inbound(), 1).get(0); + when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label); + + var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608140001#1-1"); + + assertThat(resolved.orderId()).isEqualTo(60001L); + assertThat(resolved.boxCode()).isEqualTo("PCHK2608140001#1-1"); + assertThat(resolved.cargoItemNo()).isEqualTo(1); + } + + @Test + void rejectsInactiveBoxCodeDuringScanResolution() { + InboundCartonLabelEntity label = labels(inbound(), 1).get(0); + label.setBatchStatus("VOID"); + when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label); + + assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608140001#1-1")) + .isInstanceOf(BizException.class) + .hasMessage("箱码已失效,请重新打印"); + } + + @Test + void refusesFirstAllocationAfterMeasurementSubmission() { + InboundOrderEntity inbound = inbound(); + inbound.setProcessStatus("MEASURE_PENDING"); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + + assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不允许首次生成箱号"); + verify(orderCargoClient, never()).listForCartonLabels(any(String.class), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); + } + + @Test + void resolvesTheRealInboundIdByTenantAndOrder() { + InboundOrderEntity inbound = inbound(); + when(repository.findByOrder(10001L, 60001L)).thenReturn(inbound); + + assertThat(service.requireInboundIdByOrder(10001L, 60001L)).isEqualTo(70001L); + verify(repository).findByOrder(10001L, 60001L); + } + + @Test + void rejectsAnOrderWithoutAnOwnedPreparingInbound() { + when(repository.findByOrder(10001L, 60001L)).thenReturn(null); + when(printableOrderInboundClient.reserve("internal-token", 10001L, 60001L)) + .thenThrow(org.mockito.Mockito.mock(feign.FeignException.class)); + + assertThatThrownBy(() -> service.requireInboundIdByOrder(10001L, 60001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("订单服务暂不可用"); + } + + @Test + void convertsOrderCargoCircuitBreakerFailureToActionableBusinessError() { + InboundOrderEntity inbound = inbound(); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(new ArrayList<>()); + when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>()); + when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) + .thenThrow(new NoFallbackAvailableException("order unavailable", + new IllegalStateException("downstream failure"))); + + assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L)) + .isInstanceOf(BizException.class) + .hasMessage("订单服务暂不可用,无法生成唛头"); + } + + @Test + void repairsMissingHistoricalInboundFromOwnedOrderSnapshot() { + var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand( + 10001L, 60001L, "FL60001", "PCHK2608140001", 1L, "Customer", + 41L, "Warehouse", null, null, null, "EXPORT_SEA", null, null, + null, null, null, null, null, false, "FBA", null, null, null, + null, null, null, 2); + InboundOrderEntity repaired = inbound(); + when(repository.findByOrder(10001L, 60001L)).thenReturn(null, repaired); + when(printableOrderInboundClient.reserve("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(snapshot)); + + assertThat(service.requireInboundIdByOrder(10001L, 60001L)).isEqualTo(70001L); + verify(inboundOrderCreationService).prepareFromDraft(snapshot); + } + + private InboundOrderEntity inbound() { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(70001L); + inbound.setTenantId(10001L); + inbound.setOrderId(60001L); + inbound.setInboundCode("PCHK2608140001"); + inbound.setProcessStatus("WAIT_MEASURE"); + inbound.setWarehouseName("MCC1"); + inbound.setChannelName("洛杉矶普船海卡"); + return inbound; + } + + private OrderCargoClient.CargoLabelBinding cargo(long id, int itemNo, int count, + String length, String width, String height) { + return new OrderCargoClient.CargoLabelBinding(id, 60001L, itemNo, "货物" + itemNo, count, + new BigDecimal(length), new BigDecimal(width), new BigDecimal(height)); + } + + private OrderCargoClient.CargoLabelBinding mixedCargo(long id, int itemNo, String sku, + int units, int count, String group) { + return new OrderCargoClient.CargoLabelBinding(id, 60001L, itemNo, "货物" + itemNo, sku, count, + units, new BigDecimal("60"), new BigDecimal("40"), new BigDecimal("30"), null, group); + } + + private InboundCartonLabelSkuMapper.SkuRow skuRow(long labelId, String skuCode) { + InboundCartonLabelSkuMapper.SkuRow row = new InboundCartonLabelSkuMapper.SkuRow(); + row.setCartonLabelId(labelId); + row.setSkuCode(skuCode); + return row; + } + + private List labels(InboundOrderEntity inbound, int total) { + List values = new ArrayList<>(); + for (int index = 1; index <= total; index++) { + InboundCartonLabelEntity label = new InboundCartonLabelEntity(); + label.setId((long) index); + label.setTenantId(inbound.getTenantId()); + label.setInboundId(inbound.getId()); + label.setOrderId(inbound.getOrderId()); + label.setInboundDetailId(80001L); + label.setCargoItemNo(1); + label.setCargoName("货物1"); + label.setDetailPackageCount(total); + label.setInboundCode(inbound.getInboundCode()); + label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index); + label.setTotalCartonCount(total); + label.setCartonIndex(index); + label.setLabelVersion(1); + label.setBatchStatus("ACTIVE"); + label.setBoxSpecification("60x40x30cm"); + label.setLengthCm(new BigDecimal("60")); + label.setWidthCm(new BigDecimal("40")); + label.setHeightCm(new BigDecimal("30")); + values.add(label); + } + return values; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldEvidenceServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldEvidenceServiceTest.java new file mode 100644 index 000000000..b08cbaf38 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldEvidenceServiceTest.java @@ -0,0 +1,106 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.InboundHoldFileClient; +import com.baiyue.firstleg.dto.InboundHoldDtos.Detail; +import com.baiyue.firstleg.mapper.InboundHoldRecordMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import java.util.List; + +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.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class InboundHoldEvidenceServiceTest { + private final InboundHoldService holds = mock(InboundHoldService.class); + private final InboundHoldRecordMapper history = mock(InboundHoldRecordMapper.class); + private final InboundHoldFileClient files = mock(InboundHoldFileClient.class); + private InboundHoldEvidenceService service; + + @BeforeEach + void setUp() { + service = new InboundHoldEvidenceService(holds, history, files, "internal-token"); + } + + @Test + void uploadsCustomerOwnedEvidenceForActionableInbound() { + when(holds.detail(7L, 11L)).thenReturn(detail(false, true, false)); + var file = new MockMultipartFile("file", "hold.pdf", "application/pdf", new byte[] {1, 2}); + when(files.upload("internal-token", 9L, 7L, 11L, file)).thenReturn(ApiResponse.success( + metadata(88L, 7L, 11L, "hold.pdf", "application/pdf", 2L))); + + var result = service.upload(7L, 11L, 9L, file); + + assertThat(result.fileId()).isEqualTo(88L); + assertThat(result.fileName()).isEqualTo("hold.pdf"); + verify(files).upload("internal-token", 9L, 7L, 11L, file); + } + + @Test + void rejectsUploadWhenPhysicalWorkBlocksBothActions() { + when(holds.detail(7L, 11L)).thenReturn(detail(false, false, false)); + + assertThatThrownBy(() -> service.upload(7L, 11L, 9L, + new MockMultipartFile("file", "x.pdf", "application/pdf", new byte[] {1}))) + .isInstanceOfSatisfying(BizException.class, + error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)); + } + + @Test + void rejectsEvidenceFromAnotherInboundOrCustomer() { + when(files.metadata("internal-token", 7L, 11L, 88L)).thenReturn(ApiResponse.success( + metadata(88L, 8L, 12L, "other.pdf", "application/pdf", 2L))); + + assertThatThrownBy(() -> service.validateReference(7L, 11L, 88L)) + .isInstanceOfSatisfying(BizException.class, + error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.BAD_REQUEST)); + } + + @Test + void downloadsOnlyEvidenceReferencedByImmutableHistory() { + when(holds.detail(7L, 11L)).thenReturn(detail(true, false, true)); + when(history.selectCount(any(Wrapper.class))).thenReturn(1L); + when(files.metadata("internal-token", 7L, 11L, 88L)).thenReturn(ApiResponse.success( + metadata(88L, 7L, 11L, "hold.pdf", "application/pdf", 2L))); + when(files.download("internal-token", 7L, 11L, 88L)) + .thenReturn(ResponseEntity.ok(new byte[] {1, 2})); + + var result = service.download(7L, 11L, 88L); + + assertThat(result.fileName()).isEqualTo("hold.pdf"); + assertThat(result.contentType()).isEqualTo(MediaType.APPLICATION_PDF); + assertThat(result.content()).containsExactly(1, 2); + } + + @Test + void refusesUnreferencedEvidenceDownload() { + when(holds.detail(7L, 11L)).thenReturn(detail(false, true, false)); + when(history.selectCount(any(Wrapper.class))).thenReturn(0L); + + assertThatThrownBy(() -> service.download(7L, 11L, 88L)) + .isInstanceOfSatisfying(BizException.class, + error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND)); + } + + private static Detail detail(boolean held, boolean canHold, boolean canRelease) { + return new Detail(11L, 7L, held, held ? "reason" : null, 3, canHold, canRelease, + canHold || canRelease ? null : "physical work", List.of()); + } + + private static InboundHoldFileClient.Metadata metadata(long fileId, long tenantId, long inboundId, + String name, String type, long size) { + return new InboundHoldFileClient.Metadata(fileId, "CUSTOMER", tenantId, "INBOUND_HOLD_EVIDENCE", + Long.toString(inboundId), name, "sha", type, size); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldGuardTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldGuardTest.java new file mode 100644 index 000000000..78ec7fa08 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldGuardTest.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.mapper.InboundHoldGateMapper; +import org.junit.jupiter.api.Test; + +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.Mockito.when; + +class InboundHoldGuardTest { + private final InboundHoldGateMapper mapper = mock(InboundHoldGateMapper.class); + private final InboundHoldGuard guard = new InboundHoldGuard(mapper); + + @Test + void blocksInboundPlanTaskAndContainerScopes() { + when(mapper.countHeldInbound(7L, 11L)).thenReturn(1); + when(mapper.countHeldByPlan(22L)).thenReturn(1); + when(mapper.countHeldByTask(33L)).thenReturn(1); + when(mapper.countHeldByContainer(44L)).thenReturn(1); + + for (Runnable action : new Runnable[] { + () -> guard.requireInboundNotHeld(7L, 11L), + () -> guard.requirePlanNotHeld(22L), + () -> guard.requireTaskNotHeld(33L), + () -> guard.requireContainerNotHeld(44L) + }) { + assertThatThrownBy(action::run).isInstanceOfSatisfying(BizException.class, + error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)); + } + } + + @Test + void treatsMissingPhysicalWorkProjectionAsFailClosed() { + when(mapper.hasPhysicalWork(7L, 11L)).thenReturn(null); + assertThat(guard.hasPhysicalWork(7L, 11L)).isTrue(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldServiceTest.java new file mode 100644 index 000000000..f2cd4f0bf --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldServiceTest.java @@ -0,0 +1,135 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest; +import com.baiyue.firstleg.entity.InboundHoldRecordEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.mapper.InboundHoldRecordMapper; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +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.time.LocalDateTime; +import java.util.List; + +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 InboundHoldServiceTest { + @Mock InboundOrderMapper inbounds; + @Mock InboundHoldRecordMapper history; + @Mock InboundIdempotencyService idempotency; + @Mock InboundHoldGuard guard; + @InjectMocks InboundHoldService service; + + @Test + void holdsOwnedInboundWithOptimisticVersionAndImmutableHistory() { + InboundOrderEntity before = inbound(false, null, 3); + InboundOrderEntity after = inbound(true, "海关抽查", 4); + when(idempotency.begin(7L, 9L, 11L, "INBOUND_HOLD", "hold-key", + "HOLD|7|11|3|海关抽查|88")).thenReturn(new InboundIdempotencyService.Context(7L, 101L, false)); + when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(before); + when(inbounds.updateManualHold(7L, 11L, 3, false, true, "海关抽查", 9L)).thenReturn(1); + when(inbounds.selectById(11L)).thenReturn(after); + when(history.selectList(any(Wrapper.class))).thenReturn(List.of()); + + var result = service.hold(7L, 11L, 9L, "平台人员", "hold-key", + new CommandRequest(3, " 海关抽查 ", 88L)); + + assertThat(result.holdFlag()).isTrue(); + assertThat(result.currentHoldReason()).isEqualTo("海关抽查"); + assertThat(result.version()).isEqualTo(4); + assertThat(result.canRelease()).isTrue(); + ArgumentCaptor record = ArgumentCaptor.forClass(InboundHoldRecordEntity.class); + verify(history).insert(record.capture()); + assertThat(record.getValue().getHoldAction()).isEqualTo("HOLD"); + assertThat(record.getValue().getReason()).isEqualTo("海关抽查"); + assertThat(record.getValue().getEvidenceFileId()).isEqualTo(88L); + verify(idempotency).complete(new InboundIdempotencyService.Context(7L, 101L, false), 11L); + } + + @Test + void replaysSameCompletedCommandWithoutWritingAgain() { + when(idempotency.begin(anyLong(), anyLong(), anyLong(), eq("INBOUND_HOLD"), anyString(), anyString())) + .thenReturn(new InboundIdempotencyService.Context(7L, 101L, true)); + when(inbounds.selectById(11L)).thenReturn(inbound(true, "检查", 4)); + when(history.selectList(any(Wrapper.class))).thenReturn(List.of()); + + assertThat(service.hold(7L, 11L, 9L, "平台人员", "hold-key", + new CommandRequest(3, "检查", null)).holdFlag()).isTrue(); + + verify(inbounds, never()).updateManualHold(anyLong(), anyLong(), anyInt(), anyBoolean(), + anyBoolean(), any(), anyLong()); + verify(history, never()).insert(any(InboundHoldRecordEntity.class)); + } + + @Test + void rejectsCrossCustomerInboundAsNotFound() { + when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) + .thenReturn(new InboundIdempotencyService.Context(7L, 101L, false)); + InboundOrderEntity other = inbound(false, null, 3); + other.setTenantId(8L); + when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(other); + + assertThatThrownBy(() -> service.hold(7L, 11L, 9L, "平台人员", "hold-key", + new CommandRequest(3, "检查", null))) + .isInstanceOfSatisfying(BizException.class, + error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND)); + } + + @Test + void rejectsManualActionAfterPhysicalPicking() { + when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) + .thenReturn(new InboundIdempotencyService.Context(7L, 101L, false)); + when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(inbound(false, null, 3)); + when(guard.hasPhysicalWork(7L, 11L)).thenReturn(true); + + assertThatThrownBy(() -> service.hold(7L, 11L, 9L, "平台人员", "hold-key", + new CommandRequest(3, "检查", null))) + .isInstanceOfSatisfying(BizException.class, + error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)); + verify(inbounds, never()).updateManualHold(anyLong(), anyLong(), anyInt(), anyBoolean(), + anyBoolean(), any(), anyLong()); + } + + @Test + void releasesAndClearsCurrentReasonButKeepsReleaseHistoryReason() { + when(idempotency.begin(anyLong(), anyLong(), anyLong(), eq("INBOUND_RELEASE"), anyString(), anyString())) + .thenReturn(new InboundIdempotencyService.Context(7L, 102L, false)); + when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(inbound(true, "旧原因", 4)); + when(inbounds.updateManualHold(7L, 11L, 4, true, false, null, 9L)).thenReturn(1); + when(inbounds.selectById(11L)).thenReturn(inbound(false, null, 5)); + when(history.selectList(any(Wrapper.class))).thenReturn(List.of()); + + var result = service.release(7L, 11L, 9L, "平台人员", "release-key", + new CommandRequest(4, "复核通过", null)); + + assertThat(result.holdFlag()).isFalse(); + assertThat(result.currentHoldReason()).isNull(); + ArgumentCaptor record = ArgumentCaptor.forClass(InboundHoldRecordEntity.class); + verify(history).insert(record.capture()); + assertThat(record.getValue().getHoldAction()).isEqualTo("RELEASE"); + assertThat(record.getValue().getReason()).isEqualTo("复核通过"); + } + + private static InboundOrderEntity inbound(boolean held, String reason, int version) { + InboundOrderEntity value = new InboundOrderEntity(); + value.setId(11L); + value.setTenantId(7L); + value.setHoldFlag(held); + value.setCurrentHoldReason(reason); + value.setVersion(version); + value.setDeleted(false); + value.setProcessStatus("WAIT_SPLIT"); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundIdempotencyServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundIdempotencyServiceTest.java new file mode 100644 index 000000000..3aad109e2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundIdempotencyServiceTest.java @@ -0,0 +1,82 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.entity.InboundIdempotencyEntity; +import com.baiyue.firstleg.repository.InboundWorkflowRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +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(InboundIdempotencyService.class) +class InboundIdempotencyServiceTest { + @MockBean + private InboundWorkflowRepository repository; + @Resource + private InboundIdempotencyService service; + @Test + @DisplayName("同键同请求成功记录可重放") + void shouldReplayCompletedRequest() { + doThrow(new DuplicateKeyException("duplicate")).when(repository).insertIdempotency(any()); + InboundIdempotencyEntity existing = existing("SUCCESS"); + when(repository.findIdempotency(10001L, "MEASURE_APPROVE", 9L, "key-1")) + .thenReturn(existing); + + InboundIdempotencyService.Context context = service.begin( + 10001L, 9L, 10L, "MEASURE_APPROVE", "key-1", "10|20|3|"); + + assertThat(context) + .extracting(InboundIdempotencyService.Context::tenantId, + InboundIdempotencyService.Context::id, + InboundIdempotencyService.Context::replayed) + .containsExactly(10001L, 1L, true); + } + + @Test + @DisplayName("同键不同请求返回冲突") + void shouldRejectDifferentRequestForSameKey() { + doThrow(new DuplicateKeyException("duplicate")).when(repository).insertIdempotency(any()); + InboundIdempotencyEntity existing = existing("SUCCESS"); + existing.setRequestHash("different-hash"); + when(repository.findIdempotency(10001L, "MEASURE_APPROVE", 9L, "key-1")) + .thenReturn(existing); + + assertThatThrownBy(() -> service.begin( + 10001L, 9L, 10L, "MEASURE_APPROVE", "key-1", "10|20|4|")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + } + + @Test + @DisplayName("新请求完成时只允许PROCESSING条件更新一次") + void shouldCompleteProcessingRecordOnce() { + when(repository.completeIdempotency(10001L, 1L, "{\"inboundId\":\"10\"}")) + .thenReturn(1); + + service.complete(new InboundIdempotencyService.Context(10001L, 1L, false), 10L); + + verify(repository).completeIdempotency(10001L, 1L, "{\"inboundId\":\"10\"}"); + } + + private InboundIdempotencyEntity existing(String status) { + InboundIdempotencyEntity record = new InboundIdempotencyEntity(); + record.setId(1L); + record.setStatus(status); + // 由被测服务对规范化请求计算的 SHA-256。 + record.setRequestHash("8482715c52a4611811574b1ca2dcbf031c765de767e052d4bb6d62139c10ee34"); + return record; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java new file mode 100644 index 000000000..0a30da8b1 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java @@ -0,0 +1,240 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.firstleg.client.MeasurementImageClient; +import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse; +import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.InboundOrderLookupEntity; +import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity; +import com.baiyue.firstleg.repository.InboundMeasurementOpenApiRepository; +import com.baiyue.firstleg.security.AuthenticatedMeasurementApp; +import com.baiyue.firstleg.security.MeasurementRequestAuthenticator; +import com.baiyue.firstleg.security.MeasurementRequestHeaders; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import jakarta.validation.Validator; +import jakarta.validation.Validation; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.time.OffsetDateTime; +import java.math.BigDecimal; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doThrow; +import org.mockito.ArgumentCaptor; + +@DisplayName("第三方测量开放接口服务") +@SpringJUnitConfig(classes = {InboundMeasurementOpenApiService.class, + InboundMeasurementOpenApiServiceTest.TestConfig.class}) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class InboundMeasurementOpenApiServiceTest { + private static final String BODY = """ + {"eventNo":"M202608010001","warehouseCode":"CN-YW-01","inboundCode":"RK202608010001", + "externalDetailNo":"BOX-001","inboundDetailId":"80001","deviceCode":"DEVICE-01", + "measuredAt":"2026-08-01T10:30:00+08:00","lengthCm":50.20,"widthCm":40.10, + "heightCm":30.00,"packageCount":5, + "boxSpecification":"50.2x40.1x30cm"} + """; + private static final byte[] IMAGE = "image-content".getBytes(java.nio.charset.StandardCharsets.UTF_8); + @MockBean private InboundMeasurementOpenApiRepository repository; + @MockBean private MeasurementRequestAuthenticator authenticator; + @MockBean private ThirdPartyCallLogService logs; + @MockBean private TransactionTemplate transactions; + @MockBean private OrderCargoClient orderCargo; + @MockBean private MeasurementImageClient images; + @Resource private ObjectMapper objectMapper; + @Resource private InboundMeasurementOpenApiService service; + + @Test + @DisplayName("第三方客户A不能访问只存在于客户B的入库单") + void shouldRejectInboundOwnedByAnotherCustomer() { + Fixture fixture = fixture(); + when(fixture.repository.findInbound(10002L, "RK202608010001")).thenReturn(inbound()); + + assertThatThrownBy(() -> fixture.service.receive(headers("nonce-cross"), BODY, image())) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessageContaining("入仓码不存在或不属于绑定仓库"); + + verify(fixture.repository).findInbound(10001L, "RK202608010001"); + verify(fixture.repository, never()).findInbound(10002L, "RK202608010001"); + verify(fixture.images, never()).upload(anyString(), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyLong(), any()); + } + + @Test + @DisplayName("回调只写批次明细和幂等结果,不存在入库主单update入口") + void shouldPersistMeasurementsWithoutUpdatingInboundOrder() { + Fixture fixture = fixture(); + InboundOrderLookupEntity inbound = inbound(); + when(fixture.repository.findInbound(10001L, "RK202608010001")).thenReturn(inbound); + + EventResponse response = fixture.service.receive(headers("nonce-1"), BODY, image()); + + assertThat(response.accepted()).isTrue(); + assertThat(response.status()).isEqualTo("RECEIVED"); + verify(fixture.repository).insertMeasurement(any(InboundMeasurementEntity.class)); + ArgumentCaptor itemCaptor = + ArgumentCaptor.forClass(InboundMeasurementItemEntity.class); + verify(fixture.repository).insertItem(itemCaptor.capture()); + assertThat(itemCaptor.getValue().getInboundDetailId()).isEqualTo(80001L); + assertThat(itemCaptor.getValue().getExternalDetailNo()).isEqualTo("BOX-001"); + assertThat(itemCaptor.getValue().getSourceImageFileId()).isEqualTo(99001L); + assertThat(itemCaptor.getValue().getItemNo()).isEqualTo("1"); + assertThat(itemCaptor.getValue().getTotalVolumeCbm()).isEqualByComparingTo("0.3020"); + verify(fixture.orderCargo).detail("internal-token", 10001L, 60001L, 80001L); + verify(fixture.images).upload(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.eq(90001L), org.mockito.ArgumentMatchers.eq(10001L), + org.mockito.ArgumentMatchers.eq(70001L), any()); + assertThatNoException().isThrownBy(() -> + InboundMeasurementOpenApiRepository.class.getDeclaredMethod("findInbound", long.class, String.class)); + assertThat(InboundMeasurementOpenApiRepository.class.getDeclaredMethods()) + .noneMatch(method -> method.getName().toLowerCase().contains("updateinbound")); + } + + @Test + @DisplayName("重复Idempotency-Key且报文一致时返回首次结果,不重复写测量") + void shouldReturnFirstResultForDuplicateCallback() throws Exception { + Fixture fixture = fixture(); + EventResponse first = new EventResponse("M202608010001", "RK202608010001", true, + "RECEIVED", OffsetDateTime.parse("2026-08-01T02:30:00Z")); + ThirdPartyCallLogEntity existing = new ThirdPartyCallLogEntity(); + existing.setRequestHash(requestHash()); + existing.setResultJson(objectMapper.writeValueAsString(first)); + existing.setProcessingStatus("ACCEPTED"); + when(fixture.logs.claimIdempotency(1L)).thenReturn(false); + when(fixture.logs.findIdempotencyOwner(10001L, "device-provider", "idem-1")).thenReturn(existing); + + EventResponse duplicate = fixture.service.receive(headers("nonce-2"), BODY, image()); + + assertThat(duplicate).isEqualTo(first); + verify(fixture.repository, never()).insertMeasurement(any()); + verify(fixture.repository, never()).insertItem(any()); + } + + @Test + @DisplayName("同一订单明细和第三方明细号已经绑定时在上传图片前拒绝") + void shouldRejectAlreadyBoundExternalDetailBeforeUploadingImage() { + Fixture fixture = fixture(); + when(fixture.repository.findInbound(10001L, "RK202608010001")).thenReturn(inbound()); + when(fixture.repository.findExternalDetail(10001L, 80001L, "BOX-001")) + .thenReturn(new InboundMeasurementItemEntity()); + + assertThatThrownBy(() -> fixture.service.receive(headers("nonce-3"), BODY, image())) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessageContaining("已绑定"); + + verify(fixture.images, never()).upload(anyString(), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyLong(), any()); + verify(fixture.repository, never()).insertMeasurement(any()); + } + + @Test + @DisplayName("图片已保存但测量落库失败时调用文件中心补偿清理") + void shouldCompensateImageWhenMeasurementPersistenceFails() { + Fixture fixture = fixture(); + when(fixture.repository.findInbound(10001L, "RK202608010001")).thenReturn(inbound()); + doThrow(new IllegalStateException("db failed")) + .when(fixture.repository).insertMeasurement(any(InboundMeasurementEntity.class)); + + assertThatThrownBy(() -> fixture.service.receive(headers("nonce-4"), BODY, image())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("db failed"); + + verify(fixture.images).delete("internal-token", 90001L, 10001L, 70001L, 99001L); + verify(fixture.repository, never()).insertItem(any()); + } + + private Fixture fixture() { + when(logs.start(anyString(), anyString(), any(), anyString(), anyString())).thenReturn(1L); + when(logs.claimIdempotency(1L)).thenReturn(true); + when(authenticator.authenticate(anyString(), anyString(), any(), anyString(), anyBoolean())) + .thenReturn(new AuthenticatedMeasurementApp("device-provider", 10001L, 90001L, + Map.of("CN-YW-01", 30001L))); + when(orderCargo.detail("internal-token", 10001L, 60001L, 80001L)).thenReturn(ApiResponse.success( + new OrderCargoClient.CargoBinding(80001L, 60001L, 1, "测试货物", 5, + new BigDecimal("62.500"), new BigDecimal("0.3010")))); + when(images.upload(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.eq(90001L), org.mockito.ArgumentMatchers.eq(10001L), + org.mockito.ArgumentMatchers.eq(70001L), any())).thenReturn(ApiResponse.success( + new MeasurementImageClient.UploadResult(99001L, "measure.jpg", "image/jpeg", 13L, + "NOT_REQUIRED", null, null, null, null))); + when(transactions.execute(any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(mock(TransactionStatus.class)); + }); + return new Fixture(repository, logs, orderCargo, images, service); + } + + @Configuration + static class TestConfig { + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper().findAndRegisterModules(); + } + + @Bean + Validator validator() { + return Validation.buildDefaultValidatorFactory().getValidator(); + } + } + + private InboundOrderLookupEntity inbound() { + InboundOrderLookupEntity inbound = new InboundOrderLookupEntity(); + inbound.setId(70001L); + inbound.setTenantId(10001L); + inbound.setInboundCode("RK202608010001"); + inbound.setOrderId(60001L); + inbound.setWarehouseId(30001L); + inbound.setProcessStatus("WAIT_MEASURE"); + inbound.setDeleted(0); + return inbound; + } + + private MeasurementRequestHeaders headers(String nonce) { + return new MeasurementRequestHeaders("device-provider", "1785551400000", nonce, "signature", "idem-1"); + } + + private MockMultipartFile image() { + return new MockMultipartFile("image", "measure.jpg", "image/jpeg", IMAGE); + } + + private String requestHash() throws Exception { + String imageHash = java.util.HexFormat.of().formatHex( + java.security.MessageDigest.getInstance("SHA-256").digest(IMAGE)); + return MeasurementRequestAuthenticator.sha256Hex(BODY + "\n" + imageHash); + } + + private record Fixture(InboundMeasurementOpenApiRepository repository, + ThirdPartyCallLogService logs, + OrderCargoClient orderCargo, + MeasurementImageClient images, + InboundMeasurementOpenApiService service) { + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java new file mode 100644 index 000000000..5daf6a6b5 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -0,0 +1,1479 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.client.MeasurementImageClient; +import com.baiyue.firstleg.client.WarehousePalletLocationClient; +import com.baiyue.firstleg.dto.InboundMeasurementRequests; +import com.baiyue.firstleg.dto.MeasurementBatchCommand; +import com.baiyue.firstleg.dto.InboundMeasurementResponses; +import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.InboundFeeEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; +import com.baiyue.firstleg.entity.InboundWeightRevisionEntity; +import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; +import com.baiyue.firstleg.repository.FbaPricingRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import feign.FeignException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@DisplayName("平台入库测量应用服务") +@SpringJUnitConfig(InboundMeasurementService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class InboundMeasurementServiceTest { + @MockBean private InboundMeasurementRepository repository; + @MockBean private MeasurementBatchService batchService; + @MockBean private FbaPricingRepository pricingRepository; + @MockBean private FbaMasterDataService fbaMasterDataService; + @MockBean private OrderCargoClient orderCargoClient; + @MockBean private MeasurementImageClient imageClient; + @MockBean private WarehousePalletLocationClient warehousePallets; + @MockBean private InboundPalletAllocationRepository palletAllocations; + @MockBean private MeasurementLocationOccupationService locationOccupation; + @Resource private InboundMeasurementService service; + + @BeforeEach + void stubWarehousePalletSnapshots() { + when(repository.updateInboundWeightSummary(any())).thenReturn(true); + when(warehousePallets.palletSnapshot("internal-token", 501L, 100L)).thenReturn( + ApiResponse.success(palletSnapshot())); + when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot( + "REF-50", new BigDecimal("2000"), new BigDecimal("20"), "123123"))); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding( + 80001L, 80L, 1, "123123", null, 3, new BigDecimal("40"), + new BigDecimal("30"), new BigDecimal("20"), "FBA-DECLARED-001")))); + } + + @Test + void customerCannotReadAnotherCustomersMeasurement() { + assertThatThrownBy(() -> service.detail(2L, 10L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("入库单不存在"); + + verify(repository).findInbound(2L, 10L); + verify(repository, never()).findInbound(1L, 10L); + verify(repository, never()).listWeights(anyLong(), anyLong()); + } + + @Test + void updatesSubmittedCalculationStrategyWithoutReplacingMeasurementBatch() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setTenantId(1L); + measurement.setInboundId(10L); + measurement.setStatus("SUBMITTED"); + measurement.setVersion(5); + measurement.setTotalVolumeCbm(new BigDecimal("2.5000")); + measurement.setVolumeWeightKg(new BigDecimal("417.500")); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(31L); + item.setTenantId(1L); + item.setMeasurementId(30L); + when(repository.lockInbound(1L, 10L)).thenReturn(inbound); + when(repository.lockMeasurement(1L, 10L, 30L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + when(repository.updateInbound(inbound)).thenReturn(true); + + var response = service.updateCalculationStrategy(1L, 10L, + new InboundMeasurementRequests.UpdateCalculationStrategyRequest(30L, 5, "ADD", null)); + + assertThat(response.status()).isEqualTo("SUBMITTED"); + verify(batchService).recalculateStrategy(1L, measurement, List.of(item), "ADD", null); + verify(repository, never()).replaceItems(anyLong(), anyLong(), any()); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("2.5000"); + } + + @Test + void locksCalculationStrategyOnlyAfterCustomerConfirmationAndLoadingCompletion() { + InboundOrderEntity inbound = inbound("LOADED"); + InboundFeeEntity fee = new InboundFeeEntity(); + fee.setCustomerConfirmedAt(LocalDateTime.now()); + when(repository.lockInbound(1L, 10L)).thenReturn(inbound); + when(pricingRepository.findLatestFeeForUpdate(1L, 10L)).thenReturn(fee); + + assertThatThrownBy(() -> service.updateCalculationStrategy(1L, 10L, + new InboundMeasurementRequests.UpdateCalculationStrategyRequest(30L, 5, "ACTUAL", null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("客户已确认且该票已装柜完成"); + + verify(repository, never()).lockMeasurement(anyLong(), anyLong(), anyLong()); + } + + @Test + void updatesSubmittedWarehouseOuterDimensionsWithoutReplacingMeasurementBatch() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setTenantId(1L); + measurement.setInboundId(10L); + measurement.setStatus("SUBMITTED"); + measurement.setVersion(5); + measurement.setTotalVolumeCbm(new BigDecimal("1.8000")); + measurement.setVolumeWeightKg(new BigDecimal("300.600")); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(31L); + item.setTenantId(1L); + item.setMeasurementId(30L); + when(repository.lockInbound(1L, 10L)).thenReturn(inbound); + when(repository.lockMeasurement(1L, 10L, 30L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + when(repository.updateInbound(inbound)).thenReturn(true); + + var response = service.updateOuterDimensions(1L, 10L, 31L, + new InboundMeasurementRequests.UpdateOuterDimensionsRequest( + 30L, 5, new BigDecimal("30"), new BigDecimal("20"), new BigDecimal("20"))); + + assertThat(response.status()).isEqualTo("SUBMITTED"); + assertThat(response.itemId()).isEqualTo(31L); + verify(batchService).recalculateOuterDimensions(1L, measurement, List.of(item), 31L, + new BigDecimal("30"), new BigDecimal("20"), new BigDecimal("20")); + verify(repository, never()).replaceItems(anyLong(), anyLong(), any()); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.8000"); + } + + @Test + void correctingWeightKeepsOcrOriginalAndWritesImmutableRevision() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "PENDING_CONFIRM", 4); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + + service.correctWeightResult(1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.CorrectWeightResultRequest( + 4, new BigDecimal("13.200"), "kg", "秤面显示修正")); + + assertThat(weight.getOcrWeightValue()).isEqualByComparingTo("12.500"); + assertThat(weight.getOcrWeightUnit()).isEqualTo("KG"); + assertThat(weight.getAdjustedWeightValue()).isEqualByComparingTo("13.200"); + ArgumentCaptor revision = + ArgumentCaptor.forClass(InboundWeightRevisionEntity.class); + verify(repository).insertRevision(revision.capture()); + assertThat(revision.getValue().getRevisionNo()).isEqualTo(5); + assertThat(revision.getValue().getBeforeWeightValue()).isEqualByComparingTo("12.500"); + assertThat(revision.getValue().getAfterWeightValue()).isEqualByComparingTo("13.200"); + } + + @Test + void correctingConfirmedWeightImmediatelyRemovesItFromInboundTotal() { + InboundOrderEntity inbound = inbound("MEASURING"); + inbound.setActualGrossWeightKg(new BigDecimal("12.500")); + inbound.setActualNetWeightKg(new BigDecimal("12.500")); + InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "CONFIRMED", 4); + weight.setGrossWeightKg(new BigDecimal("12.500")); + weight.setNetWeightKg(new BigDecimal("12.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + service.correctWeightResult(1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.CorrectWeightResultRequest( + 4, new BigDecimal("13.200"), "KG", "秤面显示修正")); + + assertThat(weight.getRecognitionStatus()).isEqualTo("PENDING_CONFIRM"); + assertThat(weight.getGrossWeightKg()).isNull(); + assertThat(weight.getNetWeightKg()).isNull(); + assertThat(inbound.getActualGrossWeightKg()).isNull(); + assertThat(inbound.getActualNetWeightKg()).isNull(); + assertThat(inbound.getPalletCount()).isZero(); + verify(repository).updateInboundWeightSummary(inbound); + } + + @Test + void detailReturnsStandardPalletRecommendationWithoutAllocatingPallet() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + inbound.setVersion(7); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setVersion(4); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.recommendedPalletType()).isEqualTo("STANDARD"); + assertThat(result.inboundVersion()).isEqualTo(7); + assertThat(result.version()).isEqualTo(4); + assertThat(result.originalDocumentNo()).isEqualTo("REF-50"); + assertThat(result.originalGrossWeightKg()).isEqualByComparingTo("2000"); + assertThat(result.originalVolumeCbm()).isEqualByComparingTo("20"); + assertThat(result.actualGoodsName()).isEqualTo("123123"); + verify(repository, never()).updateInbound(any()); + } + + @Test + void detailReportsOrderContractNotReadyInsteadOfExposingDownstreamBadRequest() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); + FeignException downstreamBadRequest = mock(FeignException.class); + when(downstreamBadRequest.status()).thenReturn(400); + when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)) + .thenThrow(downstreamBadRequest); + + assertThatThrownBy(() -> service.detail(1L, 10L)) + .isInstanceOfSatisfying(BizException.class, exception -> { + assertThat(exception.getResultCode().code()).isEqualTo("S0503"); + assertThat(exception).hasMessageContaining("申报快照契约未就绪") + .hasMessageContaining("order-service") + .hasMessageContaining("firstleg-service"); + }); + } + + @Test + void detailKeepsDimensionAndWeightEvidenceOnTheirStableBusinessRecords() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + inbound.setActualGrossWeightKg(new BigDecimal("30.000")); + inbound.setActualNetWeightKg(new BigDecimal("11.500")); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setVersion(4); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(40L); + item.setTenantId(1L); + item.setMeasurementId(30L); + item.setInboundDetailId(80001L); + item.setSourceImageFileId(140L); + item.setScannedShipmentId("FBA-SCANNED-001"); + item.setOuterLengthCm(new BigDecimal("64.000")); + item.setOuterWidthCm(new BigDecimal("54.000")); + item.setOuterHeightCm(new BigDecimal("54.000")); + item.setCalculationRuleVersion("CUSTOMER_DIMENSION_EXTRA_LARGE_V1"); + InboundWeightResultEntity weight = weight(50L, "30.000", "KG", "CONFIRMED", 2); + weight.setGrossWeightKg(new BigDecimal("30.000")); + weight.setPalletTareWeightKg(new BigDecimal("18.500")); + weight.setNetWeightKg(new BigDecimal("11.500")); + weight.setConfirmedAt(LocalDateTime.of(2026, 8, 17, 10, 30)); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + InboundPalletAllocationEntity bound = locationSlot(1, 801L, "A01", "BOUND", 50L); + InboundPalletAllocationEntity secondBound = locationSlot(2, 802L, "A02", "BOUND", 51L); + InboundPalletAllocationEntity empty = locationSlot(3, 803L, "A03", "RESERVED_EMPTY", null); + when(palletAllocations.list(1L, 10L)).thenReturn(List.of(empty, secondBound, bound)); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.inboundCode()).isEqualTo("RK-001"); + assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(result.actualNetWeightKg()).isEqualByComparingTo("11.500"); + assertThat(result.items()).singleElement().satisfies(dimension -> { + assertThat(dimension.id()).isEqualTo(40L); + assertThat(dimension.inboundCode()).isEqualTo("RK-001"); + assertThat(dimension.intelligentImageFileId()).isEqualTo(140L); + assertThat(dimension.fbaShipmentNo()).isEqualTo("FBA-DECLARED-001"); + assertThat(dimension.scannedShipmentId()).isEqualTo("FBA-SCANNED-001"); + assertThat(dimension.outerLengthCm()).isNull(); + assertThat(dimension.outerWidthCm()).isNull(); + assertThat(dimension.outerHeightCm()).isNull(); + }); + assertThat(result.weightResults()).singleElement().satisfies(confirmedWeight -> { + assertThat(confirmedWeight.id()).isEqualTo(50L); + assertThat(confirmedWeight.fileId()).isEqualTo(150L); + assertThat(confirmedWeight.recognitionStatus()).isEqualTo("CONFIRMED"); + assertThat(confirmedWeight.grossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(confirmedWeight.netWeightKg()).isEqualByComparingTo("11.500"); + assertThat(confirmedWeight.confirmedAt()).isNotNull(); + assertThat(confirmedWeight.locationCode()).isEqualTo("A01"); + }); + assertThat(result.palletLocationGuide()).satisfies(guide -> { + assertThat(guide.blockId()).isEqualTo(700L); + assertThat(guide.reservedSlotCount()).isEqualTo(3); + assertThat(guide.actualPalletCount()).isEqualTo(2); + assertThat(guide.reservedEmptySlotCount()).isEqualTo(1); + assertThat(guide.slots()).extracting(InboundMeasurementResponses.PalletLocationSlot::locationCode) + .containsExactly("A01", "A02", "A03"); + assertThat(guide.slots().get(2).boundWeightResultId()).isNull(); + }); + } + + @Test + void detailMapsMultipleDeclaredShipmentIdsByInboundDetailWithoutFallingBackToScannedValue() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + InboundMeasurementItemEntity first = new InboundMeasurementItemEntity(); + first.setId(41L); + first.setInboundDetailId(80001L); + first.setScannedShipmentId("SCAN-DIFFERENT-001"); + InboundMeasurementItemEntity second = new InboundMeasurementItemEntity(); + second.setId(42L); + second.setInboundDetailId(80002L); + InboundMeasurementItemEntity unmatched = new InboundMeasurementItemEntity(); + unmatched.setId(43L); + unmatched.setInboundDetailId(89999L); + unmatched.setScannedShipmentId("SCAN-ONLY-999"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(first, second, unmatched)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of( + new OrderCargoClient.CargoLabelBinding(80001L, 80L, 1, "货物1", null, + 1, null, null, null, "FBA-DECLARED-001"), + new OrderCargoClient.CargoLabelBinding(80002L, 80L, 2, "货物2", null, + 1, null, null, null, "FBA-DECLARED-002")))); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.inboundCode()).isEqualTo("RK-001"); + assertThat(result.items()) + .extracting(InboundMeasurementResponses.Item::inboundDetailId, + InboundMeasurementResponses.Item::inboundCode, + InboundMeasurementResponses.Item::fbaShipmentNo, + InboundMeasurementResponses.Item::scannedShipmentId) + .containsExactly( + tuple(80001L, "RK-001", "FBA-DECLARED-001", "SCAN-DIFFERENT-001"), + tuple(80002L, "RK-001", "FBA-DECLARED-002", null), + tuple(89999L, "RK-001", null, "SCAN-ONLY-999")); + verify(orderCargoClient, times(1)).listForCartonLabels("internal-token", 1L, 80L); + } + + @Test + void detailFallsBackToCustomerNameWhenLegacyCompanyNameIsEmpty() { + InboundOrderEntity inbound = inbound("MEASURING"); + inbound.setCustomerName("柏悦测试客户"); + inbound.setCompanyName(null); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.companyName()).isEqualTo("柏悦测试客户"); + } + + @Test + void detailRejectsCargoBatchWhoseOrderOwnershipDoesNotMatchInbound() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding( + 80001L, 81L, 1, "其他订单货物", null, 1, null, null, null, + "FBA-OTHER-001")))); + + assertThatThrownBy(() -> service.detail(1L, 10L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("货物明细归属不一致"); + } + + @Test + void detailReturnsWarehouseOuterDimensionsOnlyAfterManualOverrideWasSaved() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(40L); + item.setOuterLengthCm(new BigDecimal("65.000")); + item.setOuterWidthCm(new BigDecimal("55.000")); + item.setOuterHeightCm(new BigDecimal("55.000")); + item.setManualOuterLengthCm(new BigDecimal("65.000")); + item.setManualOuterWidthCm(new BigDecimal("55.000")); + item.setManualOuterHeightCm(new BigDecimal("55.000")); + item.setCalculationRuleVersion("WAREHOUSE_OUTER_OVERRIDE_V1"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.items()).singleElement().satisfies(dimension -> { + assertThat(dimension.outerLengthCm()).isEqualByComparingTo("65.000"); + assertThat(dimension.outerWidthCm()).isEqualByComparingTo("55.000"); + assertThat(dimension.outerHeightCm()).isEqualByComparingTo("55.000"); + }); + } + + @Test + void detailReturnsOnlyTheIndividuallyOverriddenOuterDimension() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(40L); + item.setOuterLengthCm(new BigDecimal("65.000")); + item.setOuterWidthCm(new BigDecimal("54.000")); + item.setOuterHeightCm(new BigDecimal("54.000")); + item.setManualOuterLengthCm(new BigDecimal("65.000")); + item.setCalculationRuleVersion("WAREHOUSE_OUTER_OVERRIDE_L_V1"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.items()).singleElement().satisfies(dimension -> { + assertThat(dimension.outerLengthCm()).isEqualByComparingTo("65.000"); + assertThat(dimension.outerWidthCm()).isNull(); + assertThat(dimension.outerHeightCm()).isNull(); + }); + } + + @Test + void detailComparesLatestDeclarationWithoutMutatingFrozenMeasurementFacts() { + InboundOrderEntity inbound = inbound("WAIT_SPLIT"); + inbound.setMeasureReviewStatus("APPROVED"); + inbound.setActualGrossWeightKg(new BigDecimal("30.000")); + inbound.setActualNetWeightKg(new BigDecimal("11.500")); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setStatus("APPROVED"); + measurement.setOriginalDocumentNo("OLD-REF"); + measurement.setOriginalGrossWeightKg(new BigDecimal("25.000")); + measurement.setOriginalVolumeCbm(new BigDecimal("0.5000")); + measurement.setTotalPackageCount(2); + measurement.setTotalVolumeCbm(new BigDecimal("0.6000")); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(40L); + item.setTenantId(1L); + item.setMeasurementId(30L); + item.setInboundDetailId(80001L); + item.setBoxCode("RK-001#2-1"); + item.setPackageCount(2); + item.setLengthCm(new BigDecimal("45")); + item.setWidthCm(new BigDecimal("35")); + item.setHeightCm(new BigDecimal("25")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot( + "NEW-REF", new BigDecimal("40.000"), new BigDecimal("0.8000"), "最新申报品名"))); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding( + 80001L, 80L, 1, "最新申报品名", null, 4, new BigDecimal("50"), + new BigDecimal("40"), new BigDecimal("30"), "FBA-LATEST-001")))); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.originalDocumentNo()).isEqualTo("OLD-REF"); + assertThat(result.originalGrossWeightKg()).isEqualByComparingTo("25.000"); + assertThat(result.originalVolumeCbm()).isEqualByComparingTo("0.5000"); + assertThat(result.actualPackageCount()).isEqualTo(2); + assertThat(result.latestDeclaration()).satisfies(latest -> { + assertThat(latest.referenceId()).isEqualTo("NEW-REF"); + assertThat(latest.totalPackageCount()).isEqualTo(4); + assertThat(latest.grossWeightKg()).isEqualByComparingTo("40.000"); + assertThat(latest.volumeCbm()).isEqualByComparingTo("0.8000"); + assertThat(latest.items()).singleElement().satisfies(declared -> { + assertThat(declared.inboundDetailId()).isEqualTo(80001L); + assertThat(declared.packageCount()).isEqualTo(4); + assertThat(declared.lengthCm()).isEqualByComparingTo("50"); + }); + }); + assertThat(result.items()).singleElement().satisfies(measured -> { + assertThat(measured.id()).isEqualTo(40L); + assertThat(measured.boxCode()).isEqualTo("RK-001#2-1"); + assertThat(measured.fbaShipmentNo()).isEqualTo("FBA-LATEST-001"); + assertThat(measured.packageCount()).isEqualTo(2); + assertThat(measured.lengthCm()).isEqualByComparingTo("45"); + }); + verify(repository, never()).updateInbound(any()); + verify(repository, never()).updateMeasurement(any()); + verify(repository, never()).updateItem(any()); + verify(repository, never()).deleteWeight(anyLong(), anyLong(), anyLong(), anyInt()); + } + + @Test + void detailCountsSameCartonMultiSkuGroupOnlyOnceForDropship() { + InboundOrderEntity inbound = inbound("MEASURING"); + inbound.setFulfillmentType("ONE_CLICK_DROPSHIP"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of( + new OrderCargoClient.CargoLabelBinding( + 80001L, 80L, 1, "SKU A", "SKU-A", 20, 5, + new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"), null, "MIX-1"), + new OrderCargoClient.CargoLabelBinding( + 80002L, 80L, 2, "SKU B", "SKU-B", 20, 3, + new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"), null, "MIX-1")))); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.latestDeclaration().totalPackageCount()).isEqualTo(20); + assertThat(result.latestDeclaration().items()).hasSize(2); + } + + @Test + void creatingWeightResultNormalizesProviderConfidenceAtBusinessBoundary() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + InboundMeasurementResponses.WeightResult result = service.createWeightResult( + 1L, 10L, 9L, + new InboundMeasurementRequests.CreateWeightResultRequest( + 120L, 501L, "FILE_WEIGHT_OCR", "285.5", new BigDecimal("285.500"), "KG", + new BigDecimal("0.5097191334"))); + + ArgumentCaptor saved = + ArgumentCaptor.forClass(InboundWeightResultEntity.class); + verify(repository).insertWeight(saved.capture()); + assertThat(saved.getValue().getConfidence()).isEqualByComparingTo("0.50972"); + assertThat(saved.getValue().getPalletId()).isEqualTo(501L); + assertThat(saved.getValue().getPalletCode()).isEqualTo("PLT-501"); + assertThat(saved.getValue().getPalletTareWeightKg()).isEqualByComparingTo("18.500"); + assertThat(result.confidence()).isEqualByComparingTo("0.50972"); + } + + @Test + void ocrFailureCreatesManualWeightThatCanBeAdjustedAndConfirmed() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + InboundMeasurementResponses.WeightResult created = service.createWeightResult( + 1L, 10L, 9L, + new InboundMeasurementRequests.CreateWeightResultRequest( + 120L, 501L, "FILE_WEIGHT_OCR", null, null, null, null)); + + assertThat(created.recognitionStatus()).isEqualTo("NEEDS_MANUAL"); + ArgumentCaptor saved = + ArgumentCaptor.forClass(InboundWeightResultEntity.class); + verify(repository).insertWeight(saved.capture()); + InboundWeightResultEntity weight = saved.getValue(); + weight.setId(20L); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult confirmed = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("30.000"), "KG", "OCR失败后人工确认")); + + assertThat(confirmed.recognitionStatus()).isEqualTo("CONFIRMED"); + assertThat(confirmed.grossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(confirmed.netWeightKg()).isEqualByComparingTo("11.500"); + verify(repository, org.mockito.Mockito.times(2)).insertRevision(any(InboundWeightRevisionEntity.class)); + } + + @Test + void weightPalletOptionsUsesInboundWarehouseWithoutCustomerScope() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5")) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(palletSnapshot()), 1, 20, 1000, 50))); + + PageResult result = + service.weightPalletOptions(1L, 10L, 1L, 20L, "TP-5"); + + assertThat(result.records()).singleElement().satisfies(snapshot -> { + assertThat(snapshot.palletId()).isEqualTo(501L); + assertThat(snapshot.tareWeightKg()).isEqualByComparingTo("18.500"); + }); + assertThat(result.total()).isEqualTo(1000); + assertThat(result.records()).hasSize(1); + verify(warehousePallets).palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5"); + } + + @Test + void weightPalletOptionsFailsClosedOnWarehouseNon2xxResponse() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5")) + .thenThrow(mock(feign.FeignException.class)); + + assertThatThrownBy(() -> service.weightPalletOptions(1L, 10L, 1L, 20L, "TP-5")) + .isInstanceOf(BizException.class) + .hasMessageContaining("仓库服务暂不可用"); + } + + @Test + void weightPalletOptionsFailsClosedOnWarehouseNetworkError() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, null)) + .thenThrow(mock(feign.RetryableException.class)); + + assertThatThrownBy(() -> service.weightPalletOptions(1L, 10L, 1L, 20L, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("仓库服务暂不可用"); + } + + @Test + void creatingWeightResultRejectsPalletOutsideTheInboundWarehouse() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(warehousePallets.palletSnapshot("internal-token", 999L, 100L)).thenReturn( + ApiResponse.failure(com.baiyue.common.core.api.ResultCode.NOT_FOUND, + "所选托盘不属于当前入库仓库或当前不可用", null, null)); + + assertThatThrownBy(() -> service.createWeightResult(1L, 10L, 9L, + new InboundMeasurementRequests.CreateWeightResultRequest( + 120L, 999L, "FILE_WEIGHT_OCR", "20.0 KG", new BigDecimal("20.000"), + "KG", new BigDecimal("0.99")))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不属于当前入库仓库"); + + verify(repository, never()).insertWeight(any()); + } + + @Test + void detailUsesStoredPalletSnapshotAfterCandidateBecomesUnavailable() { + InboundOrderEntity inbound = inbound("WAIT_SPLIT"); + InboundWeightResultEntity historical = weight(50L, "30.000", "KG", "CONFIRMED", 2); + historical.setPalletCode("HISTORICAL-501"); + historical.setPalletType("LEGACY-STANDARD"); + historical.setPalletTareWeightKg(new BigDecimal("18.500")); + historical.setGrossWeightKg(new BigDecimal("30.000")); + historical.setNetWeightKg(new BigDecimal("11.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(historical)); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.weightResults()).singleElement().satisfies(weight -> { + assertThat(weight.palletCode()).isEqualTo("HISTORICAL-501"); + assertThat(weight.palletType()).isEqualTo("LEGACY-STANDARD"); + assertThat(weight.palletTareWeightKg()).isEqualByComparingTo("18.500"); + }); + verify(warehousePallets, never()).palletSnapshot(any(), anyLong(), anyLong()); + verify(warehousePallets, never()).palletSnapshots(any(), anyLong(), anyLong(), anyLong(), any()); + } + + @Test + void confirmingLbWeightConvertsToKgAndRecalculatesOnlyConfirmedImages() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity current = weight(20L, "10.000", "LB", "PENDING_CONFIRM", 0); + InboundWeightResultEntity previous = weight(21L, "2.000", "KG", "CONFIRMED", 1); + previous.setGrossWeightKg(new BigDecimal("2.000")); + previous.setNetWeightKg(new BigDecimal("2.000")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(current); + when(repository.updateWeight(current)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(current, previous)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); + + assertThat(response.grossWeightKg()).isEqualByComparingTo("4.536"); + assertThat(response.netWeightKg()).isEqualByComparingTo("4.536"); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("6.536"); + assertThat(inbound.getPalletCount()).isEqualTo(2); + assertThat(inbound.getProcessStatus()).isEqualTo("MEASURING"); + verify(repository).insertRevision(any(InboundWeightRevisionEntity.class)); + } + + @Test + void confirmingWeightAfterCartonMeasurementReplacesFallbackWithNetWeight() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + inbound.setActualGrossWeightKg(new BigDecimal("54.400")); + InboundWeightResultEntity weight = weight(20L, "30.000", "KG", "PENDING_CONFIRM", 0); + weight.setPalletTareWeightKg(new BigDecimal("18.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); + + assertThat(response.grossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(response.netWeightKg()).isEqualByComparingTo("11.500"); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("11.500"); + ArgumentCaptor revision = + ArgumentCaptor.forClass(InboundWeightRevisionEntity.class); + verify(repository).insertRevision(revision.capture()); + assertThat(revision.getValue().getGrossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(revision.getValue().getNetWeightKg()).isEqualByComparingTo("11.500"); + } + + @Test + void confirmingAWeightReturnsTheAuthoritativeSlotBoundByTheLocationOrchestrator() { + InboundOrderEntity inbound = inbound("MEASURE_REJECTED"); + InboundWeightResultEntity weight = weight(20L, "30.000", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(palletAllocations.list(1L, 10L)).thenReturn(List.of( + locationSlot(1, 801L, "A01", "RESERVED_EMPTY", null))); + when(palletAllocations.findByWeightResult(1L, 10L, 20L)).thenReturn( + locationSlot(1, 801L, "A01", "BOUND", 20L)); + + InboundMeasurementResponses.WeightResult result = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); + + verify(locationOccupation).ensureForConfirmedWeight(1L, 10L, 9L, 20L, 1); + assertThat(result.blockId()).isEqualTo(700L); + assertThat(result.slotNo()).isEqualTo(1); + assertThat(result.locationCode()).isEqualTo("A01"); + assertThat(result.slotStatus()).isEqualTo("BOUND"); + } + + @Test + void locationAllocationFailureDoesNotBlockWeightConfirmation() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "30.000", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + doThrow(new IllegalStateException("warehouse unavailable")) + .when(locationOccupation).ensureForConfirmedWeight(1L, 10L, 9L, 20L, 1); + + InboundMeasurementResponses.WeightResult result = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); + + assertThat(result.recognitionStatus()).isEqualTo("CONFIRMED"); + assertThat(result.grossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(result.locationCode()).isNull(); + verify(repository).insertRevision(any(InboundWeightRevisionEntity.class)); + verify(repository).updateInboundWeightSummary(inbound); + } + + @Test + void confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity current = weight(20L, "357.500", "KG", "PENDING_CONFIRM", 0); + current.setPalletTareWeightKg(new BigDecimal("8.000")); + List weights = new java.util.ArrayList<>(); + weights.add(current); + for (long id = 21L; id <= 24L; id++) { + InboundWeightResultEntity confirmed = weight(id, "357.500", "KG", "CONFIRMED", 1); + confirmed.setGrossWeightKg(new BigDecimal("357.500")); + confirmed.setNetWeightKg(new BigDecimal("349.500")); + weights.add(confirmed); + } + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(current); + when(repository.updateWeight(current)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(weights); + + service.confirmWeightResult(1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); + + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("1787.500"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("1747.500"); + } + + @Test + void confirmingWeightRejectsGrossWeightNotAboveFrozenTare() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "18.500", "KG", "PENDING_CONFIRM", 0); + weight.setPalletTareWeightKg(new BigDecimal("18.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("毛重必须大于托盘皮重"); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + verify(repository, never()).updateInboundWeightSummary(any()); + } + + @ParameterizedTest + @CsvSource({ + "kg,2.000", + "KGS,2.000", + "lb,0.907", + "LBS,0.907", + "' lbs ',0.907" + }) + void confirmingAcceptsSupportedUnitAliases(String unit, String expectedKg) { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "1.000", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("2.000"), unit, "单位边界测试")); + + assertThat(response.adjustedWeightUnit()).isEqualTo( + unit.trim().toUpperCase().startsWith("LB") ? "LB" : "KG"); + assertThat(response.grossWeightKg()).isEqualByComparingTo(expectedKg); + assertThat(response.netWeightKg()).isEqualByComparingTo(expectedKg); + assertThat(response.version()).isEqualTo(2); + } + + @Test + void confirmingAcceptsSmallestRepresentableKgWeight() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "0.001", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); + + assertThat(response.netWeightKg()).isEqualByComparingTo("0.001"); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("0.001"); + } + + @Test + void confirmingRejectsLbWeightThatRoundsToZeroKgBeforePersisting() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "1.000", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("0.001"), "LB", "极小重量边界测试"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("换算后重量不能小于 0.001 KG"); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + verify(repository, never()).updateInboundWeightSummary(any()); + } + + @Test + void confirmingRejectsOcrLbWeightThatRoundsToZeroKg() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "0.001", "LB", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("换算后重量不能小于 0.001 KG"); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + verify(repository, never()).updateInboundWeightSummary(any()); + } + + @Test + void confirmingRejectsUnsupportedUnitBeforePersisting() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("286.125"), "G", "错误单位测试"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("重量单位仅支持 KG 或 LB"); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + } + + @Test + void confirmingAdjustedWeightWritesCorrectionAndConfirmationInOneTransaction() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("286.125"), "kg", "OCR 原值 285.500 KG,人工确认 286.125 KG")); + + assertThat(response.adjustedWeightValue()).isEqualByComparingTo("286.125"); + assertThat(response.grossWeightKg()).isEqualByComparingTo("286.125"); + assertThat(response.netWeightKg()).isEqualByComparingTo("286.125"); + assertThat(response.recognitionStatus()).isEqualTo("CONFIRMED"); + assertThat(response.version()).isEqualTo(2); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("286.125"); + verify(repository, times(2)).updateWeight(weight); + ArgumentCaptor revisions = + ArgumentCaptor.forClass(InboundWeightRevisionEntity.class); + verify(repository, times(2)).insertRevision(revisions.capture()); + assertThat(revisions.getAllValues()).extracting( + InboundWeightRevisionEntity::getRevisionNo, + InboundWeightRevisionEntity::getAction) + .containsExactly(tuple(1, "CORRECT"), tuple(2, "CONFIRM")); + assertThat(revisions.getAllValues().get(0).getReason()) + .isEqualTo("OCR 原值 285.500 KG,人工确认 286.125 KG"); + } + + @Test + void confirmingRejectsPartialAdjustmentBeforePersisting() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("286.125"), null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("有效重量、单位和修改原因"); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + } + + @Test + void confirmingRejectsEveryPartialAdjustmentCombinationBeforePersisting() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + List partialRequests = List.of( + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, "KG", null), + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, "人工修正"), + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("286.125"), " ", " ") + ); + + partialRequests.forEach(request -> assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", request)) + .isInstanceOf(BizException.class) + .hasMessageContaining("有效重量、单位和修改原因")); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + verify(repository, never()).updateInboundWeightSummary(any()); + } + + @Test + void confirmingAnAlreadyConfirmedWeightWithCurrentVersionIsIdempotent() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2); + weight.setGrossWeightKg(new BigDecimal("285.500")); + weight.setNetWeightKg(new BigDecimal("285.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(2, null, null, null)); + + assertThat(response.recognitionStatus()).isEqualTo("CONFIRMED"); + assertThat(response.version()).isEqualTo(2); + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + verify(repository, never()).updateInboundWeightSummary(any()); + } + + @Test + void confirmingWithStaleVersionDoesNotTreatRequestAsIdempotent() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2); + weight.setGrossWeightKg(new BigDecimal("285.500")); + weight.setNetWeightKg(new BigDecimal("285.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(1, null, null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("版本已变化"); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + } + + @Test + void confirmingAdjustedWeightStopsBeforeRecalculationWhenConfirmUpdateConflicts() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true, false); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("286.125"), "KG", "并发冲突测试"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("版本已变化"); + + verify(repository, times(2)).updateWeight(weight); + verify(repository, times(1)).insertRevision(any(InboundWeightRevisionEntity.class)); + verify(repository, never()).listWeights(anyLong(), anyLong()); + verify(repository, never()).updateInboundWeightSummary(any()); + } + + @Test + void deletingPendingWeightRemovesResultAndOwnedImage() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 1); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.deleteWeight(1L, 10L, 20L, 1)).thenReturn(true); + when(imageClient.delete("internal-token", 9L, 1L, 10L, 120L)) + .thenReturn(ApiResponse.success(null)); + + service.deleteWeightResult(1L, 10L, 20L, 1, 9L); + + verify(repository).deleteWeight(1L, 10L, 20L, 1); + verify(imageClient).delete("internal-token", 9L, 1L, 10L, 120L); + verify(repository).updateInboundWeightSummary(inbound); + } + + @Test + void deletingBoundWeightUnbindsTheSlotBeforeDeletingTheResultAndFailsOnFileError() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.deleteWeight(1L, 10L, 20L, 1)).thenReturn(true); + when(imageClient.delete("internal-token", 9L, 1L, 10L, 120L)) + .thenReturn(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, + "file unavailable", null, null)); + + assertThatThrownBy(() -> service.deleteWeightResult(1L, 10L, 20L, 1, 9L)) + .isInstanceOf(BizException.class).hasMessageContaining("file unavailable"); + + org.mockito.InOrder order = org.mockito.Mockito.inOrder(palletAllocations, repository, imageClient); + order.verify(palletAllocations).unbindWeight(1L, 10L, 20L); + order.verify(repository).deleteWeight(1L, 10L, 20L, 1); + order.verify(imageClient).delete("internal-token", 9L, 1L, 10L, 120L); + } + + @Test + void deletingLastConfirmedWeightClearsInboundTotal() { + InboundOrderEntity inbound = inbound("MEASURING"); + inbound.setActualGrossWeightKg(new BigDecimal("285.500")); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2); + weight.setGrossWeightKg(new BigDecimal("285.500")); + weight.setNetWeightKg(new BigDecimal("285.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.deleteWeight(1L, 10L, 20L, 2)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + when(imageClient.delete("internal-token", 9L, 1L, 10L, 120L)) + .thenReturn(ApiResponse.success(null)); + + service.deleteWeightResult(1L, 10L, 20L, 2, 9L); + + assertThat(inbound.getActualGrossWeightKg()).isNull(); + assertThat(inbound.getActualNetWeightKg()).isNull(); + assertThat(inbound.getPalletCount()).isZero(); + verify(repository).updateInboundWeightSummary(inbound); + } + + @Test + void deletingConfirmedWeightRecalculatesTotalFromRemainingHistory() { + InboundOrderEntity inbound = inbound("MEASURING"); + inbound.setActualGrossWeightKg(new BigDecimal("15.000")); + InboundWeightResultEntity deleted = weight(20L, "10.000", "KG", "CONFIRMED", 2); + deleted.setGrossWeightKg(new BigDecimal("10.000")); + deleted.setNetWeightKg(new BigDecimal("10.000")); + InboundWeightResultEntity remaining = weight(21L, "5.000", "KG", "CONFIRMED", 1); + remaining.setGrossWeightKg(new BigDecimal("5.000")); + remaining.setNetWeightKg(new BigDecimal("5.000")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(deleted); + when(repository.deleteWeight(1L, 10L, 20L, 2)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(remaining)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + when(imageClient.delete("internal-token", 9L, 1L, 10L, 120L)) + .thenReturn(ApiResponse.success(null)); + + service.deleteWeightResult(1L, 10L, 20L, 2, 9L); + + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("5.000"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("5.000"); + assertThat(inbound.getPalletCount()).isEqualTo(1); + verify(repository).updateInboundWeightSummary(inbound); + } + + @Test + void deletingWeightRejectsStaleVersionBeforeCallingFileService() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 2); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.deleteWeightResult(1L, 10L, 20L, 1, 9L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("版本已变化"); + + verify(repository, never()).deleteWeight(anyLong(), anyLong(), anyLong(), anyInt()); + verifyNoInteractions(imageClient); + } + + @Test + void submitAdvancesOnlyToMeasurePendingAfterAllWeightsConfirmed() { + InboundOrderEntity inbound = spy(inbound("MEASURING")); + clearInvocations(inbound); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setTenantId(1L); + measurement.setInboundId(10L); + measurement.setStatus("DRAFT"); + measurement.setVersion(2); + measurement.setTotalPackageCount(3); + measurement.setTotalVolumeCbm(new BigDecimal("1.2345")); + measurement.setVolumeWeightKg(new BigDecimal("206.162")); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(3); + item.setSourceImageFileId(500L); + InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1); + weight.setGrossWeightKg(new BigDecimal("20.000")); + weight.setNetWeightKg(new BigDecimal("20.000")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + when(repository.updateInbound(inbound)).thenReturn(true); + + InboundMeasurementResponses.MeasurementSubmitted result = service.submit( + 1L, 10L, 9L, new InboundMeasurementRequests.SubmitRequest(30L, 2)); + + assertThat(result.processStatus()).isEqualTo("MEASURE_PENDING"); + assertThat(result.recommendedPalletType()).isEqualTo("STANDARD"); + assertThat(inbound.getMeasureReviewStatus()).isEqualTo("PENDING"); + assertThat(measurement.getStatus()).isEqualTo("SUBMITTED"); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("20.000"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("20.000"); + assertThat(inbound.getPalletCount()).isEqualTo(1); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.2345"); + assertThat(inbound.getTotalPackageCount()).isEqualTo(3); + verify(inbound).setTotalPackageCount(3); + } + + @Test + void submitUsesLatestOrderDeclarationAndRefreshesStaleInboundPackageTotal() { + InboundOrderEntity inbound = inbound("MEASURING"); + inbound.setTotalPackageCount(20); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setStatus("DRAFT"); + measurement.setVersion(2); + measurement.setTotalPackageCount(18); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(18); + InboundWeightResultEntity weight = weight(20L, "197.000", "KG", "CONFIRMED", 1); + weight.setGrossWeightKg(new BigDecimal("197.000")); + weight.setNetWeightKg(new BigDecimal("181.000")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + when(repository.updateInbound(inbound)).thenReturn(true); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding( + 80001L, 80L, 1, "测试货物", null, 18, new BigDecimal("60"), + new BigDecimal("50"), new BigDecimal("50"), "SHIP-001")))); + + service.submit(1L, 10L, 9L, + new InboundMeasurementRequests.SubmitRequest(30L, 2)); + + assertThat(inbound.getTotalPackageCount()).isEqualTo(18); + assertThat(inbound.getProcessStatus()).isEqualTo("MEASURE_PENDING"); + } + + @Test + void submitValidatesDropshipMultiSkuCartonsByPhysicalCartonCount() { + InboundOrderEntity inbound = inbound("MEASURING"); + inbound.setFulfillmentType("ONE_CLICK_DROPSHIP"); + inbound.setPalletCount(1); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setStatus("RECEIVED"); + measurement.setVersion(6); + measurement.setTotalPackageCount(20); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(20); + InboundWeightResultEntity weight = weight(20L, "197.000", "KG", "CONFIRMED", 1); + weight.setGrossWeightKg(new BigDecimal("197.000")); + weight.setNetWeightKg(new BigDecimal("181.000")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + when(repository.updateInbound(inbound)).thenReturn(true); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of( + new OrderCargoClient.CargoLabelBinding( + 80001L, 80L, 1, "SKU A", "SKU-A", 20, 5, + new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"), null, "MIX-1"), + new OrderCargoClient.CargoLabelBinding( + 80002L, 80L, 2, "SKU B", "SKU-B", 20, 3, + new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"), null, "MIX-1")))); + + service.submit(1L, 10L, 9L, + new InboundMeasurementRequests.SubmitRequest(30L, 6)); + + assertThat(inbound.getTotalPackageCount()).isEqualTo(20); + assertThat(inbound.getProcessStatus()).isEqualTo("MEASURE_PENDING"); + assertThat(measurement.getStatus()).isEqualTo("SUBMITTED"); + } + + @Test + void submitRejectsWhenNoWeightImageHasBeenConfirmed() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setStatus("DRAFT"); + measurement.setVersion(0); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(3); + item.setSourceImageFileId(500L); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of( + weight(20L, "20", "KG", "PENDING_CONFIRM", 0))); + assertThatThrownBy(() -> service.submit(1L, 10L, 9L, + new InboundMeasurementRequests.SubmitRequest(30L, 0))) + .isInstanceOf(BizException.class) + .hasMessageContaining("即使只有一箱") + .hasMessageContaining("上传并确认至少一张称重图片"); + + verify(repository, never()).updateMeasurement(any()); + verify(repository, never()).updateInbound(any()); + } + + @Test + void submitRejectsWhenThereIsNoWeightImage() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setStatus("DRAFT"); + measurement.setVersion(0); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(1); + item.setSourceImageFileId(500L); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + + assertThatThrownBy(() -> service.submit(1L, 10L, 9L, + new InboundMeasurementRequests.SubmitRequest(30L, 0))) + .isInstanceOf(BizException.class) + .hasMessageContaining("即使只有一箱") + .hasMessageContaining("上传并确认至少一张称重图片"); + + verify(repository, never()).updateMeasurement(any()); + verify(repository, never()).updateInbound(any()); + } + + @Test + void approvedMeasurementCannotBeResubmittedOrMutateFrozenTotals() { + InboundOrderEntity inbound = inbound("WAIT_SPLIT"); + inbound.setMeasureReviewStatus("APPROVED"); + inbound.setActualGrossWeightKg(new BigDecimal("40.000")); + inbound.setActualNetWeightKg(new BigDecimal("30.000")); + inbound.setActualVolumeCbm(new BigDecimal("2.0000")); + inbound.setVolumeWeightKg(new BigDecimal("334.000")); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setStatus("APPROVED"); + measurement.setVersion(2); + measurement.setTotalPackageCount(4); + measurement.setTotalVolumeCbm(new BigDecimal("2.0000")); + measurement.setVolumeWeightKg(new BigDecimal("334.000")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of()); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + when(repository.updateInbound(inbound)).thenReturn(true); + + assertThatThrownBy(() -> service.submit(1L, 10L, 9L, + new InboundMeasurementRequests.SubmitRequest(30L, 2))) + .isInstanceOf(BizException.class) + .hasMessageContaining("审核完成"); + + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("40.000"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("30.000"); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("2.0000"); + assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("334.000"); + verify(repository, never()).updateMeasurement(any()); + verify(repository, never()).updateInbound(any()); + } + + @Test + void savingDraftValidatesCargoAndPersistsInboundDetailBinding() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + inbound.setOrderId(80L); + InboundMeasurementEntity saved = new InboundMeasurementEntity(); + saved.setId(30L); + saved.setVersion(0); + saved.setStatus("DRAFT"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(orderCargoClient.detail(eq("internal-token"), eq(1L), eq(80L), eq(80001L))).thenReturn( + ApiResponse.success(new OrderCargoClient.CargoBinding(80001L, 80L, 1, "Test cargo", + 2, new BigDecimal("6.500"), new BigDecimal("0.0480")))); + when(batchService.saveBatch(any())).thenReturn(saved); + + InboundMeasurementResponses.DraftSaved result = service.saveDraft(1L, 10L, 9L, + new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, + List.of(new InboundMeasurementRequests.ItemRequest(80001L, "001", "A", 2, + new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"), + null, null, null, null, null, null)))); + + assertThat(result.measurementId()).isEqualTo(30L); + ArgumentCaptor command = ArgumentCaptor.forClass(MeasurementBatchCommand.class); + verify(batchService).saveBatch(command.capture()); + assertThat(command.getValue().items().get(0).inboundDetailId()).isEqualTo(80001L); + assertThat(command.getValue().originalDocumentNo()).isEqualTo("REF-50"); + assertThat(command.getValue().originalGrossWeightKg()).isEqualByComparingTo("2000"); + assertThat(command.getValue().originalVolumeCbm()).isEqualByComparingTo("20"); + assertThat(command.getValue().actualGoodsName()).isEqualTo("123123"); + verify(orderCargoClient).detail("internal-token", 1L, 80L, 80001L); + } + + @Test + void savingDraftCannotReopenSubmittedMeasurementWhileReviewIsPending() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + inbound.setMeasureReviewStatus("PENDING"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + assertThatThrownBy(() -> service.saveDraft(1L, 10L, 9L, + new InboundMeasurementRequests.SaveDraftRequest(30L, 10, null, List.of()))) + .isInstanceOf(BizException.class) + .hasMessageContaining("已提交待审核"); + + verifyNoInteractions(batchService); + verify(orderCargoClient, never()).detail(any(), anyLong(), anyLong(), anyLong()); + } + + @Test + void savingDraftRejectsOverlongDefaultGoodsNameBeforeDatabaseWrite() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundMeasurementRequests.ItemRequest item = new InboundMeasurementRequests.ItemRequest( + 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), + new BigDecimal("20"), null, null, null, null, null, null); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(orderCargoClient.detail("internal-token", 1L, 80L, 80001L)).thenReturn( + ApiResponse.success(new OrderCargoClient.CargoBinding( + 80001L, 80L, 1, "Test", 2, new BigDecimal("6.5"), new BigDecimal("0.1")))); + when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot( + "REF", new BigDecimal("6.5"), new BigDecimal("0.1"), "A".repeat(256)))); + + assertThatThrownBy(() -> service.saveDraft(1L, 10L, 9L, + new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, List.of(item)))) + .isInstanceOf(BizException.class).hasMessageContaining("超过255字符"); + verify(batchService, never()).saveBatch(any()); + } + + private static InboundPalletAllocationEntity locationSlot( + int slotNo, long locationId, String locationCode, String slotStatus, Long weightResultId) { + InboundPalletAllocationEntity value = new InboundPalletAllocationEntity(); + value.setBlockId(700L); + value.setSlotNo(slotNo); + value.setReservationId(900L + slotNo); + value.setLocationId(locationId); + value.setLocationCode(locationCode); + value.setSharedOverflow(false); + value.setSlotStatus(slotStatus); + value.setWeightResultId(weightResultId); + value.setPalletSequence(weightResultId == null ? null : slotNo); + return value; + } + + private InboundOrderEntity inbound(String status) { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(10L); + inbound.setTenantId(1L); + inbound.setOrderId(80L); + inbound.setWarehouseId(100L); + inbound.setInboundCode("RK-001"); + inbound.setProcessStatus(status); + inbound.setTotalPackageCount(3); + inbound.setVersion(0); + return inbound; + } + + private InboundWeightResultEntity weight(long id, String value, String unit, + String status, int version) { + InboundWeightResultEntity weight = new InboundWeightResultEntity(); + weight.setId(id); + weight.setTenantId(1L); + weight.setInboundId(10L); + weight.setFileId(id + 100L); + weight.setPalletId(501L); + weight.setPalletCode("PLT-501"); + weight.setPalletType("STANDARD"); + weight.setPalletCapacityCbm(new BigDecimal("2.0000")); + weight.setPalletTareWeightKg(BigDecimal.ZERO.setScale(3)); + weight.setOcrWeightValue(new BigDecimal(value)); + weight.setOcrWeightUnit(unit); + weight.setRecognitionStatus(status); + weight.setVersion(version); + return weight; + } + + private WarehousePalletLocationClient.PalletSnapshot palletSnapshot() { + return new WarehousePalletLocationClient.PalletSnapshot( + 501L, "PLT-501", "STANDARD", new BigDecimal("2.0000"), + new BigDecimal("18.500")); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java new file mode 100644 index 000000000..98e4b2f4f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -0,0 +1,208 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.entity.FulfillmentTaskEntity; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundOrderCreationServiceTest { + @BeforeAll + static void initializeMetadata() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class); + } + + @Mock + private InboundOrderMapper orders; + @Mock + private InboundStatusHistoryMapper histories; + @Mock + private InboundPickupSnapshotMapper pickupSnapshots; + @Mock + private FulfillmentTaskProvisioningService fulfillmentTasks; + @InjectMocks + private InboundOrderCreationService service; + + @Test + void shouldUseSubmittedInboundCodeAndKeepReplayIdempotentWithoutCarrier() { + OrderApprovedInboundCommand command = approvedCommand("PCHK2608140001"); + InboundOrderEntity existing = new InboundOrderEntity(); + existing.setId(900L); + existing.setTenantId(10001L); + existing.setOrderId(77L); + existing.setInboundCode("PCHK2608140001"); + existing.setTaskId(700L); + when(orders.selectOne(any(Wrapper.class))).thenReturn(null, existing); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); + + long createdId = service.createFromApprovedOrder(command); + long replayedId = service.createFromApprovedOrder(command); + + ArgumentCaptor inboundCaptor = ArgumentCaptor.forClass(InboundOrderEntity.class); + verify(orders, times(1)).insert(inboundCaptor.capture()); + verify(histories, times(1)).insert(any(InboundStatusHistoryEntity.class)); + assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("PCHK2608140001"); + assertThat(inboundCaptor.getValue().getTaskId()).isEqualTo(700L); + 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", base.lxCode()); + 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); + when(orders.selectOne(any(Wrapper.class))).thenReturn(existing); + + assertThatThrownBy(() -> service.createFromApprovedOrder(approvedCommand("RK-NEW"))) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(orders, never()).insert(any(InboundOrderEntity.class)); + } + + @Test + void shouldConvergeOnConcurrentDuplicateInsertWinner() { + InboundOrderEntity winner = inbound(901L, "PCHK2608140001", "UNPRICED", null, null); + winner.setTaskId(700L); + when(orders.selectOne(any(Wrapper.class))).thenReturn(null, winner); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); + doThrow(new org.springframework.dao.DuplicateKeyException("concurrent insert")) + .when(orders).insert(any(InboundOrderEntity.class)); + + assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(901L); + verify(histories, never()).insert(any(InboundStatusHistoryEntity.class)); + } + + @Test + void shouldBackfillTaskLinkForInboundCreatedBeforeTaskProvisioning() { + InboundOrderEntity existing = inbound(900L, "PCHK2608140001", "UNPRICED", null, null); + when(orders.selectOne(any(Wrapper.class))).thenReturn(existing); + when(orders.update(any(), any(Wrapper.class))).thenReturn(1); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); + + assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(900L); + + assertThat(existing.getTaskId()).isEqualTo(700L); + verify(orders).update(any(), any(Wrapper.class)); + verify(orders, never()).insert(any(InboundOrderEntity.class)); + } + + @Test + void shouldPrepareDraftIdempotentlyAndUpgradeSameInboundOnApproval() { + InboundOrderEntity prepared = inbound(900L, "PCHK2608140001", "UNPRICED", null, null); + prepared.setProcessStatus("PREPARING"); + when(orders.selectOne(any(Wrapper.class))).thenReturn(null, prepared, prepared); + when(orders.update(any(), any(Wrapper.class))).thenReturn(1); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); + + long preparedId = service.prepareFromDraft(approvedCommand("PCHK2608140001")); + long approvedId = service.createFromApprovedOrder(approvedCommand("PCHK2608140001")); + + assertThat(preparedId).isNotNull(); + assertThat(approvedId).isEqualTo(900L); + verify(orders, times(1)).insert(any(InboundOrderEntity.class)); + verify(orders, times(2)).update(any(), any(Wrapper.class)); + } + + @Test + void approvalKeepsMeasurementAlreadyCapturedBeforeOrderReview() { + InboundOrderEntity prepared = inbound(900L, "PCHK2608140001", "UNPRICED", null, null); + prepared.setProcessStatus("MEASURING"); + prepared.setOrderStatusSnapshot("SUBMITTED"); + when(orders.selectOne(any(Wrapper.class))).thenReturn(prepared); + when(orders.update(any(), any(Wrapper.class))).thenReturn(1); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); + + assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(900L); + + verify(orders, times(2)).update(any(), any(Wrapper.class)); + ArgumentCaptor historyCaptor = + ArgumentCaptor.forClass(InboundStatusHistoryEntity.class); + verify(histories).insert(historyCaptor.capture()); + assertThat(historyCaptor.getValue().getFromStatus()).isEqualTo("MEASURING"); + assertThat(historyCaptor.getValue().getToStatus()).isEqualTo("MEASURING"); + assertThat(historyCaptor.getValue().getAction()).isEqualTo("ORDER_APPROVED"); + } + + private static InboundOrderEntity inbound(long id, String code, String feeStatus, + Long carrierId, String carrierName) { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(id); + inbound.setTenantId(10001L); + inbound.setOrderId(77L); + inbound.setInboundCode(code); + inbound.setFeeStatus(feeStatus); + inbound.setCarrierId(carrierId); + inbound.setCarrierName(carrierName); + return inbound; + } + + private static FulfillmentTaskEntity task(long id) { + FulfillmentTaskEntity task = new FulfillmentTaskEntity(); + task.setId(id); + task.setTenantId(10001L); + task.setOrderId(77L); + task.setOrderNo("FL-77"); + return task; + } + + private static OrderApprovedInboundCommand approvedCommand(String inboundCode) { + return new OrderApprovedInboundCommand( + 10001L, 77L, "FL-77", inboundCode, 7L, "测试客户", 9L, "义乌仓", + null, null, null, "FBA", "AIR", "空运", null, null, + "BUY_DECLARATION", "TAX_EXCLUDED", null, null, null, null, null, null, null, null, null, 4); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java new file mode 100644 index 000000000..b23203dea --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java @@ -0,0 +1,112 @@ +package com.baiyue.firstleg.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.firstleg.domain.InboundListTab; +import com.baiyue.firstleg.dto.InboundOrderDetail; +import com.baiyue.firstleg.dto.InboundOrderQuery; +import com.baiyue.firstleg.dto.InboundOrderSummary; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.InboundOrderRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.time.LocalDateTime; +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.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(InboundOrderQueryService.class) +class InboundOrderQueryServiceTest { + @MockBean + private InboundOrderRepository repository; + @MockBean + private DropshipPalletizationService dropshipPalletization; + @Resource + private InboundOrderQueryService service; + + @Test + void shouldQueryAllPlatformTenantsWhenTenantFilterIsMissing() { + InboundOrderQuery query = query(null, null, null, null); + PageResult expected = new PageResult<>(List.of(), 1, 20, 0, 0); + when(repository.page(null, query)).thenReturn(expected); + + assertThat(service.page(null, query)).isSameAs(expected); + verify(repository).page(null, query); + } + + @Test + void shouldAcceptInboundedAsThePhase2TerminalStatusFilter() { + InboundOrderQuery query = new InboundOrderQuery(null, null, "WAIT_SPLIT", null, + "APPROVED", "CONFIRMED", null, null, null, null, null, null, null, + null, null, null, null, 1, 200); + PageResult expected = new PageResult<>(List.of(), 1, 200, 0, 0); + when(repository.page(10001L, query)).thenReturn(expected); + + assertThat(service.page(10001L, query)).isSameAs(expected); + verify(repository).page(10001L, query); + } + + @Test + void shouldKeepV2TabMappingWithoutCustomerConfirmation() { + assertThat(InboundListTab.WAIT_MEASURE.processStatuses()) + .containsExactly("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); + assertThat(InboundListTab.WAIT_REVIEW.processStatuses()).containsExactly("MEASURE_PENDING"); + assertThat(InboundListTab.WAIT_FEE_SEND.processStatuses()) + .containsExactly("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING"); + assertThat(InboundListTab.WAIT_SPLIT.processStatuses()).containsExactly("WAIT_SPLIT"); + assertThat(List.of(InboundListTab.values())).extracting(Enum::name) + .doesNotContain("WAIT_CUSTOMER_CONFIRM"); + } + + @Test + void shouldRejectConflictingTabAndStatus() { + InboundOrderQuery query = query("WAIT_MEASURE", InboundListTab.WAIT_MEASURE, null, null); + assertThatThrownBy(() -> service.page(10001L, query)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.BAD_REQUEST); + } + + @Test + void shouldRejectReversedArrivalRange() { + LocalDateTime now = LocalDateTime.now(); + assertThatThrownBy(() -> service.page( + 10001L, query(null, null, now, now.minusDays(1)))) + .isInstanceOf(BizException.class); + } + + @Test + void shouldLoadDetailOnlyAfterTenantOwnedOrderExists() { + InboundOrderEntity order = new InboundOrderEntity(); + order.setId(9L); + InboundOrderDetail detail = new InboundOrderDetail(null, null, List.of(), List.of(), List.of(), List.of()); + when(repository.findOwned(10001L, 9L)).thenReturn(order); + when(repository.detail(10001L, order)).thenReturn(detail); + + assertThat(service.detail(10001L, 9L)).isSameAs(detail); + verify(repository).findOwned(10001L, 9L); + verify(repository).detail(10001L, order); + } + + @Test + void shouldReturnBusinessNotFoundForCrossTenantOrMissingOrder() { + assertThatThrownBy(() -> service.detail(10002L, 9L)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + } + + private static InboundOrderQuery query(String status, InboundListTab tab, + LocalDateTime from, LocalDateTime to) { + return new InboundOrderQuery(null, null, status, null, null, null, null, tab, + from, to, null, null, null, null, null, null, null, 1, 20); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InitialInboundPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InitialInboundPricingServiceTest.java new file mode 100644 index 000000000..24353fb70 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InitialInboundPricingServiceTest.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class InitialInboundPricingServiceTest { + private final FbaPricingService pricing = mock(FbaPricingService.class); + private final InitialInboundPricingService service = new InitialInboundPricingService(); + + InitialInboundPricingServiceTest() { + ReflectionTestUtils.setField(service, "pricing", pricing); + } + + @Test + void returnsCalculatedDraftWithoutChangingWaitSplit() { + FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class); + when(fee.feeStatus()).thenReturn("CALCULATED"); + when(pricing.calculate(10001L, 9L, 88L, null, "initial-88-30-5")).thenReturn(fee); + + var result = service.tryCalculate(10001L, 9L, 88L, 30L, 5); + + assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(result.feeStatus()).isEqualTo("CALCULATED"); + assertThat(result.initialPricingSucceeded()).isTrue(); + assertThat(result.initialFee()).isSameAs(fee); + } + + @Test + void keepsWaitSplitWhenNoPriceMatches() { + when(pricing.calculate(10001L, 9L, 88L, null, "initial-88-30-5")) + .thenThrow(new BizException("未匹配有效价格")); + + var result = service.tryCalculate(10001L, 9L, 88L, 30L, 5); + + assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(result.feeStatus()).isEqualTo("UNPRICED"); + assertThat(result.initialPricingSucceeded()).isFalse(); + assertThat(result.initialPricingMessage()).contains("未匹配有效价格"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingAssociationQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingAssociationQueryServiceTest.java new file mode 100644 index 000000000..c31cc3df2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingAssociationQueryServiceTest.java @@ -0,0 +1,97 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.mapper.LoadingAssociationMapper; +import com.baiyue.firstleg.mapper.LoadingAssociationMapper.Row; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +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.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(LoadingAssociationQueryService.class) +class LoadingAssociationQueryServiceTest { + @MockBean + private LoadingAssociationMapper mapper; + @Resource + private LoadingAssociationQueryService service; + + @Test + void returnsBusinessIdentifiersAndPreservesEveryTechnicalItemReference() { + Row first = row(800L); + Row second = row(801L); + when(mapper.byInboundCode("ZBYHK2608300001-2")).thenReturn(List.of(first, second)); + + var result = service.summary(null, " ZBYHK2608300001-2 "); + + assertThat(result.inboundCode()).isEqualTo("ZBYHK2608300001-2"); + assertThat(result.orderNo()).isEqualTo("FO-260830-0001"); + assertThat(result.inboundId()).isEqualTo("100"); + assertThat(result.loadingPlanId()).isEqualTo("200"); + assertThat(result.loadingPlanNo()).isEqualTo("LP-260830-0001"); + assertThat(result.cabinetNo()).isEqualTo("CAB-260830-0001"); + assertThat(result.containerNo()).isEqualTo("MSCU1234567"); + assertThat(result.loadingStatus()).isEqualTo("LOADING"); + assertThat(result.pdaTaskStatus()).isEqualTo("AWAITING_REVIEW"); + assertThat(result.coLoadGroupNo()).isEqualTo("CLG-260830-0001"); + assertThat(result.items()).extracting(item -> item.itemId()).containsExactly("800", "801"); + verify(mapper).byInboundCode("ZBYHK2608300001-2"); + } + + @Test + void returnsFragmentIdentityWhenItHasNoLoadingAssociation() { + Row row = new Row(); + row.setInboundId(100L); + row.setInboundCode("ZBYHK2608300002"); + when(mapper.byInboundId(100L)).thenReturn(List.of(row)); + + var result = service.summary(100L, null); + + assertThat(result.inboundCode()).isEqualTo("ZBYHK2608300002"); + assertThat(result.loadingPlanId()).isNull(); + assertThat(result.pdaTaskStatus()).isNull(); + assertThat(result.items()).isEmpty(); + } + + @Test + void selectorMustBeExclusiveAndExactCodeCannotResolveMultipleFragments() { + assertThatThrownBy(() -> service.summary(null, null)) + .isInstanceOf(BizException.class).hasMessageContaining("必须且只能"); + assertThatThrownBy(() -> service.summary(100L, "ZBYHK2608300001")) + .isInstanceOf(BizException.class).hasMessageContaining("必须且只能"); + Row first = row(800L); + Row other = row(801L); + other.setInboundId(101L); + when(mapper.byInboundCode("DUPLICATE")).thenReturn(List.of(first, other)); + + assertThatThrownBy(() -> service.summary(null, "DUPLICATE")) + .isInstanceOfSatisfying(BizException.class, error -> + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)); + } + + private static Row row(long itemId) { + Row row = new Row(); + row.setInboundId(100L); + row.setInboundCode("ZBYHK2608300001-2"); + row.setOrderNo("FO-260830-0001"); + row.setItemId(itemId); + row.setLoadingPlanId(200L); + row.setLoadingPlanNo("LP-260830-0001"); + row.setContainerBookingId(300L); + row.setCabinetNo("CAB-260830-0001"); + row.setContainerNo("MSCU1234567"); + row.setLoadingStatus("LOADING"); + row.setTaskId(400L); + row.setPdaTaskStatus("AWAITING_REVIEW"); + row.setCoLoadGroupId(500L); + row.setCoLoadGroupNo("CLG-260830-0001"); + return row; + } +} 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 new file mode 100644 index 000000000..139c3101e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -0,0 +1,528 @@ +package com.baiyue.firstleg.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.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.baiyue.firstleg.mapper.LoadingPalletLabelMapper; +import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper; +import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.apache.ibatis.annotations.Select; +import org.springframework.test.util.ReflectionTestUtils; + +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 LoadingPalletLabelMapper palletLabels = mock(LoadingPalletLabelMapper.class); + private final LoadingPalletLabelService palletLabelService = mock(LoadingPalletLabelService.class); + private final CabinetLoadingEvidenceMapper cabinetEvidence = mock(CabinetLoadingEvidenceMapper.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,"palletLabelMapper",palletLabels); + ReflectionTestUtils.setField(service,"palletLabelService",palletLabelService); + ReflectionTestUtils.setField(service,"cabinetEvidenceMapper",cabinetEvidence); + ReflectionTestUtils.setField(service,"outboxMapper",outbox); + ReflectionTestUtils.setField(service,"objectMapper",mapper); + when(palletLabels.labels(anyLong())).thenReturn(List.of()); + when(cabinetEvidence.byContainer(anyLong())).thenReturn(List.of( + evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2), evidence("PHOTO_STEP_3", 3), + evidence("PHOTO_STEP_4", 4), evidence("SEAL", 5))); + 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"))); + } + + private static CabinetLoadingEvidenceEntity evidence(String type, long fileId) { + CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity(); + value.setEvidenceType(type); value.setFileId(fileId); value.setFileName(type + ".jpg"); + value.setStatus("ACTIVE"); value.setActiveFlag(1); + return value; + } + + @Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception { + when(snapshots.rows(90L)).thenReturn(List.of(row(22,300,302),row(11,200,202),row(11,100,102),row(11,100,101))); + when(snapshots.palletRows(90L)).thenReturn(List.of( + pallet(11,100,1,"PLT-01",1,101,"SKU101",1,10,"BOX-101"), + pallet(11,100,1,"PLT-01",1,102,"SKU102",1,10,"BOX-102"), + pallet(11,100,2,"PLT-02",1,102,"SKU102",1,10,"BOX-102"))); + service.publish(90L); + ArgumentCaptor captor=ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox,org.mockito.Mockito.times(2)).insert(captor.capture()); + List rows=captor.getAllValues(); + assertThat(rows).extracting(InboundOutboxEntity::getTenantId).containsExactly(11L,22L); + EventEnvelope first=mapper.readValue(rows.get(0).getPayloadJson(),EventEnvelope.class); + assertThat(first.eventType()).isEqualTo(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED); + assertThat(first.tenantId()).isEqualTo("11"); + 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(first.payload().at("/orders/0/customerName").asText()).isEqualTo("客户-11"); + assertThat(first.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); + assertThat(first.payload().at("/orders/0/packageCount").asInt()).isEqualTo(2); + assertThat(first.payload().at("/orders/0/productQuantity").asInt()).isEqualTo(20); + assertThat(first.payload().at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2"); + assertThat(first.payload().at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("4"); + assertThat(first.payload().at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00"); + assertThat(first.payload().at("/actualPortArrivalAt").asText()).isEqualTo("2026-10-01T08:30"); + assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00"); + assertThat(first.payload().at("/sourceContainerBookingId").asText()).isEqualTo("91"); + assertThat(first.payload().get("loadingEvidence")).hasSize(5); + assertThat(first.payload().at("/loadingEvidence/0/evidenceType").asText()).isEqualTo("PHOTO_STEP_1"); + assertThat(first.payload().at("/loadingEvidence/4/evidenceType").asText()).isEqualTo("SEAL"); + assertThat(first.payload().has("containerBookingId")).isFalse(); + assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:3"); + assertThat(first.eventVersion()).isEqualTo(3); + } + + @Test void requiresFrozenPhysicalContainerNumberBeforePublishing() { + Row value = row(11, 100, 101); value.setContainerNo(" "); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + assertThatThrownBy(() -> service.publish(90L)) + .hasMessageContaining("真实集装箱号"); + verify(outbox, org.mockito.Mockito.never()).insert( + org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); + } + + @Test void rejectsNonCanonicalContainerNumberAndDuplicateEventKeyPayloadDrift() { + Row value = row(11, 100, 101); value.setContainerNo(" zcsu7881970 "); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("真实集装箱号"); + + value.setContainerNo("ZCSU7881970"); + InboundOutboxEntity existing = new InboundOutboxEntity(); existing.setPayloadHash("different"); + org.mockito.Mockito.when(outbox.insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class))) + .thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate")); + org.mockito.Mockito.when(outbox.selectByEventKey(org.mockito.ArgumentMatchers.anyString())) + .thenReturn(existing); + assertThatThrownBy(() -> service.publish(90L)) + .hasMessageContaining("eventKey").hasMessageContaining("漂移"); + } + + @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 rejectsMissingTrustedPlanItemCustomerName() { + Row value = row(11, 100, 101); + value.setCustomerName(" "); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("缺少客户名称快照"); + } + + @Test void publishesFrozenPlanItemCustomerSnapshotWithoutDependingOnBookingOwner() throws Exception { + Row value = row(11, 100, 101); + value.setCustomerName("南玻万"); + 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.tenantId()).isEqualTo("11"); + assertThat(event.payload().at("/orders/0/customerName").asText()).isEqualTo("南玻万"); + } + + @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 immutableCustomerNameParticipatesInPayloadHash() { + Row value = row(11, 100, 101); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + service.publish(90L); + value.setCustomerName("客户甲-更名快照"); + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox, org.mockito.Mockito.times(2)).insert(captor.capture()); + assertThat(captor.getAllValues().get(0).getPayloadHash()) + .isNotEqualTo(captor.getAllValues().get(1).getPayloadHash()); + } + + @Test void keepsMissingPalletAndMeasurementFactsNullInsteadOfInventingZero() throws Exception { + Row value = row(11, 100, 101); + value.setProductQuantity(null); value.setVolumeCbm(null); value.setDimensionalWeightKg(null); + value.setEstimatedPortArrivalAt(null); value.setActualPortArrivalAt(null); + value.setEstimatedWarehouseArrivalAt(null); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + when(snapshots.palletRows(90L)).thenReturn(List.of()); + + 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/palletCount").isNull()).isTrue(); + assertThat(event.payload().at("/orders/0/productQuantity").isNull()).isTrue(); + assertThat(event.payload().at("/orders/0/volumeCbm").isNull()).isTrue(); + assertThat(event.payload().at("/orders/0/volumeWeightKg").isNull()).isTrue(); + assertThat(event.payload().at("/estimatedPortArrivalAt").isNull()).isTrue(); + assertThat(event.payload().at("/actualPortArrivalAt").isNull()).isTrue(); + assertThat(event.payload().at("/estimatedWarehouseArrivalAt").isNull()).isTrue(); + } + + @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 snapshotSupportsReadOnlyPreflightBeforeFinalStatusMutation() throws Exception { + 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+", " "); + String actualPalletsSql = String.join(" ", LoadingCompletedEventMapper.class + .getMethod("actualPalletRows", long.class).getAnnotation(Select.class).value()) + .replaceAll("\\s+", " "); + String workPalletCountSql = String.join(" ", LoadingCompletedEventMapper.class + .getMethod("workPalletCount", long.class).getAnnotation(Select.class).value()) + .replaceAll("\\s+", " "); + String openWorkPalletCountSql = String.join(" ", LoadingCompletedEventMapper.class + .getMethod("openWorkPalletCount", long.class).getAnnotation(Select.class).value()) + .replaceAll("\\s+", " "); + + assertThat(rowsSql).doesNotContain("p.plan_status = 'COMPLETED'") + .doesNotContain("o.process_status = 'LOADED'") + .contains("i.customer_name") + .doesNotContain("b.customer_id AS booking_customer_id") + .doesNotContain("b.customer_name") + .contains("b.estimated_port_arrival_at", "b.actual_port_arrival_at") + .contains("b.estimated_warehouse_arrival_at"); + 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("a.pallet_id", "a.reservation_id") + .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"); + assertThat(actualPalletsSql) + .contains("pallet.pallet_no AS pallet_code") + .contains("item.pallet_id=pallet.id") + .contains("LEFT JOIN fl_dropship_pallet_carton_sku carton_sku") + .contains("COALESCE(carton_sku.inbound_detail_id,carton.inbound_detail_id)") + .doesNotContain("pallet.pallet_code"); + assertThat(workPalletCountSql).contains("i.pallet_id=p.id"); + assertThat(openWorkPalletCountSql).contains("i.pallet_id=p.id"); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {true, false}) + void freezesActualSkuCartonsAndRejectsInferredPalletSources(boolean actualBinding) throws Exception { + Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP"); + first.setSkuCode("SAME-SKU"); first.setProductQuantity(10); + Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP"); + second.setSkuCode("SAME-SKU"); second.setProductQuantity(20); + when(snapshots.rows(90L)).thenReturn(List.of(second, first)); + if (actualBinding) { + when(snapshots.workPalletCount(90L)).thenReturn(1); + when(snapshots.actualPalletRows(90L)).thenReturn(List.of( + actualPallet(11,100,"DP-260909-0001",102,"SAME-SKU","BOX-102",20), + actualPallet(11,100,"DP-260909-0001",101,"SAME-SKU","BOX-101",10))); + } else { + when(snapshots.palletRows(90L)).thenReturn(List.of( + pallet(11,100,1,"DP-260909-0001",2,102,"SAME-SKU",1,20,"BOX-102"), + pallet(11,100,1,"DP-260909-0001",2,101,"SAME-SKU",1,10,"BOX-101"))); + assertThatThrownBy(() -> service.publish(90L)).isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessageContaining("真实逐箱实收"); + verify(outbox, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); + return; + } + service.publish(90L); + service.publish(90L); + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox, org.mockito.Mockito.times(2)).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getAllValues().get(0).getPayloadJson(), EventEnvelope.class); + var frozen = event.payload().at("/orders/0/pallets/0/skuPackages"); + assertThat(frozen).hasSize(2); + for (var sku : frozen) { + String detail = sku.path("sourceInboundDetailId").asText(); + assertThat(detail).isIn("101", "102"); + assertThat(sku.path("skuCode").asText()).isEqualTo("SAME-SKU"); + assertThat(sku.path("cartonMarks")).hasSize(1); + assertThat(sku.path("cartonMarks").get(0).asText()).isEqualTo("BOX-" + detail); + assertThat(sku.path("productQuantity").asInt()).isEqualTo("101".equals(detail) ? 10 : 20); + assertThat(sku.path("cartonQuantities").path("BOX-" + detail).asInt()).isEqualTo("101".equals(detail) ? 10 : 20); + } + assertThat(frozen.get(0).path("sourceInboundDetailId")) + .isNotEqualTo(frozen.get(1).path("sourceInboundDetailId")); + assertThat(captor.getAllValues().get(0).getPayloadHash()).isEqualTo(captor.getAllValues().get(1).getPayloadHash()); + } + + @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.workPalletCount(90L)).thenReturn(2); + when(snapshots.actualPalletRows(90L)).thenReturn(List.of( + actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-001",7), + actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-002",13), + actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-003",5))); + + 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/pallets/0/skuPackages/0/cartonQuantities/BOX-001").asInt()).isEqualTo(7); + assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonQuantities/BOX-002").asInt()).isEqualTo(13); + assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); + } + + @Test + void multiSkuCartonsKeepSkuSnapshotsButCountPhysicalPackagesOnce() 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(2); second.setProductQuantity(10); second.setSkuCode("SKU-B"); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + when(snapshots.workPalletCount(90L)).thenReturn(1); + when(snapshots.actualPalletRows(90L)).thenReturn(List.of( + actualPallet(11,100,"DP-260916-0009",101,"SKU-A","BOX-001",10), + actualPallet(11,100,"DP-260916-0009",102,"SKU-B","BOX-001",5), + actualPallet(11,100,"DP-260916-0009",101,"SKU-A","BOX-002",10), + actualPallet(11,100,"DP-260916-0009",102,"SKU-B","BOX-002",5))); + + 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/packageCount").asInt()).isEqualTo(2); + assertThat(event.payload().at("/orders/0/items")).hasSize(2); + assertThat(event.payload().at("/orders/0/pallets/0/manifestPackageCount").asInt()).isEqualTo(2); + assertThat(event.payload().at("/orders/0/pallets/0/skuPackages")).hasSize(2); + assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); + assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/1/cartonMarks")).hasSize(2); + } + + @Test void publishesExactPdaCartonToPalletBindingsWithoutSequentialRedistribution() 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(2); second.setProductQuantity(10); second.setSkuCode("SKU-B"); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + when(snapshots.workPalletCount(90L)).thenReturn(2); + when(snapshots.actualPalletRows(90L)).thenReturn(List.of( + actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-A2",10), + actualPallet(11,100,"PLT-01",102,"SKU-B","BOX-B1",5), + actualPallet(11,100,"PLT-02",101,"SKU-A","BOX-A1",10), + actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-B2",5))); + + 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/pallets/0/skuPackages/0/cartonMarks/0").asText()) + .isEqualTo("BOX-A2"); + assertThat(event.payload().at("/orders/0/pallets/1/skuPackages/0/cartonMarks/0").asText()) + .isEqualTo("BOX-A1"); + assertThat(event.payload().at("/orders/0/pallets/0/manifestHash").asText()) + .matches("[0-9a-f]{64}") + .doesNotStartWith("sha256:"); + } + + @Test void mergesOneDropshipSourceDetailSplitAcrossTwoPalletsBeforePublishing() throws Exception { + Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP"); + Row second = row(11, 100, 101); second.setFulfillmentType("ONE_CLICK_DROPSHIP"); + second.setLoadingPlanItemId(102L); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + when(snapshots.workPalletCount(90L)).thenReturn(2); + when(snapshots.actualPalletRows(90L)).thenReturn(List.of( + actualPallet(11,100,"DP-260914-0021",101,"SKU101","BOX-001",10), + actualPallet(11,100,"DP-260914-0022",101,"SKU101","BOX-002",10))); + + 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/items")).hasSize(1); + assertThat(event.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101"); + assertThat(event.payload().at("/orders/0/items/0/packageCount").asInt()).isEqualTo(2); + assertThat(event.payload().at("/orders/0/items/0/productQuantity").asInt()).isEqualTo(20); + assertThat(event.payload().at("/orders/0/pallets")).hasSize(2); + assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/sourceInboundDetailId").asText()) + .isEqualTo("101"); + assertThat(event.payload().at("/orders/0/pallets/1/skuPackages/0/sourceInboundDetailId").asText()) + .isEqualTo("101"); + } + + @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) { + Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L); + r.setAggregateVersion(8); r.setContainerNo("ZCSU7881970"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); + r.setWarehouseName("WH"); r.setCustomerName("客户-" + account); + r.setEstimatedPortArrivalAt(java.time.LocalDateTime.of(2026, 9, 30, 12, 0)); + r.setActualPortArrivalAt(java.time.LocalDateTime.of(2026, 10, 1, 8, 30)); + r.setEstimatedWarehouseArrivalAt(java.time.LocalDateTime.of(2026, 10, 3, 9, 0)); + 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.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.setDimensionalWeightKg(new BigDecimal("2")); + 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.setPalletId(inbound * 100 + allocation); row.setReservationId(inbound * 1000 + allocation); + 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 PalletRow actualPallet(long account,long inbound,String palletNo,long detail, + String sku,String boxCode,int actualSkuQuantity) { + PalletRow row = pallet(account,inbound,0,palletNo,1,detail,sku,1,1,boxCode); + row.setActualBinding(true); + row.setPalletId(Integer.toUnsignedLong(palletNo.hashCode()) + 1L); + row.setActualSkuQuantity(actualSkuQuantity); + 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/LoadingCompletionWorkerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java new file mode 100644 index 000000000..519c17fc1 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java @@ -0,0 +1,92 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity; +import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository; +import com.baiyue.firstleg.repository.LoadingPickRepository; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(LoadingCompletionWorker.class) +class LoadingCompletionWorkerTest { + @MockBean private LoadingCompletionCommandRepository commands; + @MockBean private LoadingPickRepository picks; + @MockBean private LoadingPickService loadingPicks; + @jakarta.annotation.Resource private LoadingCompletionWorker worker; + + @Test + void transientFailureIsReleasedForBackoffRetry() { + LoadingCompletionCommandEntity command = command(1); + when(commands.due(10)).thenReturn(List.of(command)); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + doThrow(new LoadingPickService.CompletionProcessingException("WAREHOUSE_SETTLEMENT", + new BizException(ResultCode.SERVICE_UNAVAILABLE, "warehouse timeout"))) + .when(loadingPicks).processCompletion(eq(command), any(String.class)); + + when(commands.fail(eq(900L), any(String.class), eq("RETRYABLE"), any(LocalDateTime.class), + eq("WAREHOUSE_SETTLEMENT"), eq(ResultCode.SERVICE_UNAVAILABLE.code()), eq(null), eq(null))) + .thenReturn(true); + + worker.dispatch(); + + verify(commands).fail(eq(900L), any(String.class), eq("RETRYABLE"), any(LocalDateTime.class), + eq("WAREHOUSE_SETTLEMENT"), eq(ResultCode.SERVICE_UNAVAILABLE.code()), eq(null), eq(null)); + } + + @Test + void fifthFailureBecomesTerminalAndMarksTaskFailed() { + LoadingCompletionCommandEntity command = command(4); + when(commands.due(10)).thenReturn(List.of(command)); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + doThrow(new LoadingPickService.CompletionProcessingException("PLAN_COMPLETE", + new BizException(ResultCode.CONFLICT, "plan conflict"))) + .when(loadingPicks).processCompletion(eq(command), any(String.class)); + + when(commands.fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class), + eq("PLAN_COMPLETE"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null))).thenReturn(true); + + worker.dispatch(); + + verify(commands).fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class), + eq("PLAN_COMPLETE"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null)); + verify(picks).failCompletion(20L); + } + + @Test + void completedEventBusinessFailureIsTerminalOnFirstAttempt() { + LoadingCompletionCommandEntity command = command(0); + when(commands.due(10)).thenReturn(List.of(command)); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + doThrow(new LoadingPickService.CompletionProcessingException("COMPLETED_EVENT", + new BizException(ResultCode.CONFLICT, "customer snapshot conflict"))) + .when(loadingPicks).processCompletion(eq(command), any(String.class)); + when(commands.fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class), + eq("COMPLETED_EVENT"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null))).thenReturn(true); + + worker.dispatch(); + + verify(commands).fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class), + eq("COMPLETED_EVENT"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null)); + verify(picks).failCompletion(20L); + } + + private static LoadingCompletionCommandEntity command(int attemptCount) { + LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity(); + command.setId(900L); + command.setPickTaskId(20L); + command.setOperationId("complete-1"); + command.setAttemptCount(attemptCount); + return command; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java new file mode 100644 index 000000000..1302c4097 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -0,0 +1,999 @@ +package com.baiyue.firstleg.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.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadReadiness; +import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; +import com.baiyue.firstleg.client.LoadingDocumentFileClient; +import com.baiyue.firstleg.entity.ContainerDocumentEntity; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; +import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.repository.LoadingPickRepository; +import com.baiyue.firstleg.repository.ContainerRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +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.anyInt; +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.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@DisplayName("预装与装柜清单状态服务") +@SpringJUnitConfig(classes = {LoadingListService.class, LoadingListServiceTest.Config.class}) +class LoadingListServiceTest { + @MockBean + private LoadingListRepository repository; + @MockBean + private LoadingPickRepository pickRepository; + @MockBean + private ContainerRepository containerRepository; + @MockBean + private LoadingPickService loadingPickService; + @MockBean + private LoadingDocumentFileClient documentFiles; + @MockBean + private LoadingCompletedOutboxService loadingCompletedOutboxService; + @MockBean + private FbaPricingService fbaPricingService; + @MockBean + private InboundHoldGuard inboundHoldGuard; + @Resource + private LoadingListService service; + + @Test + void heldPlanBlocksDirectPdaFinalizationBeforeAnyPlanMutation() { + doThrow(new BizException(ResultCode.CONFLICT, "入库单已扣货")) + .when(inboundHoldGuard).requirePlanNotHeld(10L); + + assertThatThrownBy(() -> service.finishFromPda(10L, 21L, 9L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("扣货"); + + verifyNoInteractions(repository, pickRepository); + } + + @TestConfiguration + static class Config { + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + } + + @Test + void shouldAcceptEditingStatusFromPreloadList() { + PreloadQuery query = new PreloadQuery(null, "EDITING", 200L, 100L, null, null, 1, 20); + + service.preloadPage(query); + + verify(repository).preloadPage(query); + } + + @Test + void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() { + AggregateRow before = row("EDITING", 3); + before.setContainerNo("MSCU6639870"); + before.setVoyageNo("001E"); + AggregateRow after = row("EDITING", 4); + after.setContainerNo("CSQU3054383"); + after.setVoyageNo("023E"); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.updateTransport(10L, 3, "CSQU3054383", "023E", 9L, "operator")) + .thenReturn(1); + when(repository.preloadDetail(after)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + + service.updateTransport(10L, 100L, 9L, "operator", "key-1", + new TransportUpdateRequest(" cs qu 305438 3 ", "023E", 3, "纠错箱号")); + + verify(repository).updateTransport(10L, 3, "CSQU3054383", "023E", 9L, "operator"); + verify(loadingPickService).beginIfReady(30L, 9L, "operator"); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"), + eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L), + eq("operator"), eq("纠错箱号")); + } + + @Test + void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() { + AggregateRow before = row("EDITING", 3); + before.setContainerNo("MSCU6639870"); + before.setVoyageNo("023E"); + when(repository.aggregate(10L)).thenReturn(before); + when(repository.preloadDetail(before)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + + service.updateTransport(10L, 100L, 9L, "operator", "noop-key", + new TransportUpdateRequest(" ms cu 663987 0 ", " 023E ", 3, "重复保存")); + + verify(repository, never()).updateTransport(anyLong(), org.mockito.ArgumentMatchers.anyInt(), + any(), any(), anyLong(), any()); + verify(repository, never()).appendHistory(anyLong(), anyString(), anyString(), any(), any(), + any(), any(), anyLong(), any(), any()); + verifyNoInteractions(loadingPickService); + } + + @Test + void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() { + AggregateRow before = row("EDITING", 3); + before.setContainerNo("MSCU6639870"); + AggregateRow after = row("EDITING", 4); + after.setContainerNo("CSQU3054383"); + when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")).thenReturn(1); + when(repository.preloadDetail(after)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + + service.updateTransport(10L, 100L, 9L, "operator", "editing-key", + new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号")); + + verify(loadingPickService).beginIfReady(30L, 9L, "operator"); + } + + @Test + void editingPlanCanCorrectTransportWithoutPushStep() { + AggregateRow before = row("EDITING", 3); + before.setContainerNo("MSCU6639870"); + AggregateRow after = row("EDITING", 4); + after.setContainerNo("CSQU3054383"); + when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")).thenReturn(1); + + service.updateTransport(10L, 100L, 9L, "operator", "editing-key", + new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号")); + + verify(repository).updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator"); + } + + @Test + void shouldRejectTransportUpdateAfterConfirmation() { + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5)); + + assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1", + new TransportUpdateRequest("CSQU3054383", "023E", 5, "完成后纠错"))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).updateTransport(eq(10L), eq(5), anyString(), anyString(), eq(9L), anyString()); + } + + @Test + void shouldRejectBlankContainerNumberInsteadOfClearingAuthorityField() { + AggregateRow before = row("EDITING", 3); + before.setContainerNo("MSCU6639870"); + when(repository.aggregate(10L)).thenReturn(before); + + assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1", + new TransportUpdateRequest(" ", "023E", 3, "清空测试"))) + .isInstanceOf(BizException.class).hasMessageContaining("ISO 6346"); + verify(repository, never()).updateTransport(eq(10L), eq(3), + org.mockito.ArgumentMatchers.nullable(String.class), + org.mockito.ArgumentMatchers.nullable(String.class), eq(9L), + org.mockito.ArgumentMatchers.nullable(String.class)); + } + + @Test + void unclaimedPdaTaskStillAllowsCorrectionButClaimOrPhysicalWorkRejectsIt() { + AggregateRow before = row("EDITING", 3); + before.setContainerNo("MSCU6639870"); + when(repository.aggregate(10L)).thenReturn(before); + when(repository.hasPdaWorkStarted(10L)).thenReturn(true); + + assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "claimed-key", + new TransportUpdateRequest("CSQU3054383", null, 3, "领取后纠错"))) + .isInstanceOf(BizException.class).hasMessageContaining("已领取"); + + verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any()); + } + + @Test + void loadingPlanRejectsTransportUpdateButPushedEditingPlanAllowsFirstContainerFill() { + AggregateRow loading = row("LOADING", 3); + loading.setContainerNo("MSCU6639870"); + AggregateRow missing = row("EDITING", 3); + AggregateRow afterFill = row("EDITING", 4); + afterFill.setContainerNo("CSQU3054383"); + when(repository.aggregate(10L)).thenReturn(loading, missing, afterFill); + when(repository.fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator")) + .thenReturn(1); + when(repository.preloadDetail(afterFill)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + + assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "loading-key", + new TransportUpdateRequest("CSQU3054383", null, 3, "作业后纠错"))) + .isInstanceOf(BizException.class).hasMessageContaining("PDA尚未开始"); + service.updateTransport(10L, 100L, 9L, "operator", "missing-key", + new TransportUpdateRequest("CSQU3054383", null, 3, null)); + + verify(repository).fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator"); + verify(loadingPickService).beginIfReady(30L, 9L, "operator"); + } + + @Test + void loadingPlanWithClaimedAndPickedPdaTaskStillAllowsInitialContainerFill() { + AggregateRow before = row("LOADING", 7); + AggregateRow after = row("LOADING", 8); + after.setContainerNo("CSQU3054383"); + when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.hasPdaWorkStarted(10L)).thenReturn(true); + when(repository.fillInitialContainerNo(10L, 7, "CSQU3054383", "023E", 9L, "operator")) + .thenReturn(1); + when(repository.preloadDetail(after)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + + service.updateTransport(10L, 100L, 9L, "operator", "late-fill-key", + new TransportUpdateRequest("CSQU3054383", "023E", 7, null)); + + verify(repository).fillInitialContainerNo(10L, 7, "CSQU3054383", "023E", 9L, "operator"); + verify(repository, never()).hasPdaWorkStarted(10L); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"), + eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), eq("operator"), + org.mockito.ArgumentMatchers.isNull()); + } + + @Test + void correctingExistingContainerNumberStillRequiresReason() { + AggregateRow before = row("EDITING", 3); + before.setContainerNo("MSCU6639870"); + when(repository.aggregate(10L)).thenReturn(before); + + assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "reason-key", + new TransportUpdateRequest("CSQU3054383", null, 3, null))) + .isInstanceOf(BizException.class).hasMessageContaining("纠错原因"); + + verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any()); + } + + @Test + void rejectsGlobalUniqueConflictIncludingConcurrentConstraintRace() { + AggregateRow before = row("EDITING", 3); + before.setContainerNo("MSCU6639870"); + when(repository.aggregate(10L)).thenReturn(before); + when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(true); + + assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "unique-key", + new TransportUpdateRequest("CSQU3054383", null, 3, "唯一冲突"))) + .isInstanceOf(BizException.class).hasMessageContaining("已存在"); + + when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(false); + when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")) + .thenThrow(new org.springframework.dao.DuplicateKeyException("uk_container_booking_container_no")); + assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "race-key", + new TransportUpdateRequest("CSQU3054383", null, 3, "并发唯一冲突"))) + .isInstanceOf(BizException.class).hasMessageContaining("已存在"); + } + + @Test + void shouldCompleteLoadingAfterAllPdaItemsPicked() { + AggregateRow before = row("LOADING", 2); + when(pickRepository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before); + when(repository.advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator")) + .thenReturn(1); + + service.finishFromPda(10L, 21L, 9L, "operator"); + + verify(repository).advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator"); + verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"), + eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(), + eq(9L), eq("operator"), eq("PDA已人工确认装柜完成")); + verify(loadingCompletedOutboxService, never()).publish(10L); + + service.publishCompletedEvent(10L); + + verify(loadingCompletedOutboxService).publish(10L); + } + + @Test + void completedEventReadinessMustAllowPalletLabelFreeze() throws Exception { + Transactional transaction = LoadingListService.class + .getDeclaredMethod("requireCompletedEventReady", long.class) + .getAnnotation(Transactional.class); + + assertThat(transaction).isNotNull(); + assertThat(transaction.readOnly()).isFalse(); + } + + @Test + void shouldRejectAutomaticCompletionWhenPdaItemsRemain() { + when(pickRepository.allPlanItemsPicked(10L)).thenReturn(false); + + assertThatThrownBy(() -> service.finishFromPda(10L, 21L, 9L, "operator")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).advance(eq(10L), anyString(), anyString(), + org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString()); + verify(loadingCompletedOutboxService, never()).publish(10L); + } + + @Test + void shouldReplaySameTransportRequestWithoutSecondMutation() { + AggregateRow before = row("EDITING", 3); + before.setContainerNo("MSCU6639870"); + AggregateRow after = row("EDITING", 4); + after.setContainerNo("CSQU3054383"); + after.setVoyageNo("023E"); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.updateTransport(10L, 3, "CSQU3054383", "023E", 9L, "operator")) + .thenReturn(1); + when(repository.preloadDetail(after)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + ArgumentCaptor snapshot = ArgumentCaptor.forClass(String.class); + + TransportUpdateRequest request = new TransportUpdateRequest("CSQU3054383", "023E", 3, "纠错"); + service.updateTransport(10L, 100L, 9L, "operator", "key-1", request); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"), + eq("EDITING"), eq("EDITING"), anyString(), snapshot.capture(), eq(9L), + eq("operator"), eq("纠错")); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot(snapshot.getValue()); + when(repository.history(anyString())).thenReturn(replay); + when(repository.aggregate(10L)).thenReturn(after); + + service.updateTransport(10L, 100L, 9L, "operator", "key-1", request); + + verify(repository, times(1)).updateTransport(10L, 3, "CSQU3054383", "023E", 9L, "operator"); + } + + @Test + void reusedTransportKeyWithDifferentPayloadFailsBeforeMutation() { + AggregateRow before = row("EDITING", 3); + before.setContainerNo("MSCU6639870"); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot("{\"requestHash\":\"different\"}"); + when(repository.aggregate(10L)).thenReturn(before); + when(repository.history(anyString())).thenReturn(replay); + + assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "reused-key", + new TransportUpdateRequest("CSQU3054383", "023E", 3, "纠错"))) + .isInstanceOf(BizException.class).hasMessageContaining("不同请求"); + + verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any()); + } + + @Test + void confirmedPageAlwaysUsesRepositoryConfirmedProjection() { + ConfirmedQuery query = new ConfirmedQuery(" plan ", 200L, 100L, null, null, 1, 20); + when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn( + new PageResult<>(List.of(), 1, 20, 0, 0)); + + service.confirmedPage(query); + + verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value -> + "plan".equals(value.keyword()) && Long.valueOf(200L).equals(value.customerId()))); + } + + @Test + void listQueriesAllowAllWarehouses() { + when(repository.preloadPage(org.mockito.ArgumentMatchers.any())).thenReturn( + new PageResult<>(List.of(), 1, 20, 0, 0)); + when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn( + new PageResult<>(List.of(), 1, 20, 0, 0)); + + service.preloadPage(new PreloadQuery(null, "LOADING", null, null, null, null, 1, 20)); + service.confirmedPage(new ConfirmedQuery(null, null, null, null, null, 1, 20)); + + verify(repository).preloadPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null)); + verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null)); + } + + @Test + void rejectsPlanOutsideRequestedWarehouse() { + when(repository.aggregate(10L)).thenReturn(row("LOADING", 1)); + + assertThatThrownBy(() -> service.preloadDetail(10L, 200L)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.FORBIDDEN); + } + + @Test + void resolvesStablePlanIdToPreloadOrCompletedLoadingListWithoutGuessingFrom404() { + when(repository.aggregate(10L)).thenReturn(row("LOADING", 2), row("COMPLETED", 3)); + + var preload = service.lifecycle(10L, 100L); + var completed = service.lifecycle(10L, 100L); + + assertThat(preload.planId()).isEqualTo(10L); + assertThat(preload.planStatus()).isEqualTo("LOADING"); + assertThat(preload.detailResource()).isEqualTo("PRELOAD"); + assertThat(preload.requiredPermission()).isEqualTo("platform:firstleg:preload:list"); + assertThat(completed.planId()).isEqualTo(10L); + assertThat(completed.planStatus()).isEqualTo("COMPLETED"); + assertThat(completed.detailResource()).isEqualTo("LOADING_LIST"); + assertThat(completed.requiredPermission()).isEqualTo("platform:firstleg:loading-list:list"); + } + + @Test + void stageMismatchIsConflictAndOnlyMissingPlanIsNotFound() { + when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 3)); + assertThatThrownBy(() -> service.preloadDetail(10L, 100L)) + .isInstanceOfSatisfying(BizException.class, error -> + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)); + + when(repository.aggregate(11L)).thenReturn(row("LOADING", 2)); + assertThatThrownBy(() -> service.confirmedDetail(11L, 100L)) + .isInstanceOfSatisfying(BizException.class, error -> + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)); + + when(repository.aggregate(12L)).thenReturn(null); + assertThatThrownBy(() -> service.lifecycle(12L, 100L)) + .isInstanceOfSatisfying(BizException.class, error -> + assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND)); + } + + @Test + void attachesRealContainerClearanceFileAndWritesImmutableHistory() { + AggregateRow plan = row("LOADING", 2); + plan.setContainerBookingId(30L); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)) + .thenReturn(null, document(40L, LoadingListService.DESTINATION_CLEARANCE, 0, false)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq("30"), eq(99L))).thenReturn(ApiResponse.success(new LoadingDocumentFileClient.Metadata( + 99L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "30", "clearance.pdf", hash(), "application/pdf", 20L))); + when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1); + + var result = service.attachContainerClearance(30L, 9L, "operator", "key-clearance", + new DocumentAttachRequest(99L, 0, "上传清关资料")); + + assertThat(result.fileName()).isEqualTo("clearance.pdf"); + verify(repository).appendHistory(eq(10L), + eq("PL-" + sha256("ATTACH_DESTINATION_CLEARANCE|30|key-clearance")), + eq("ATTACH_DESTINATION_CLEARANCE"), + eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), eq("operator"), + eq("上传清关资料")); + } + + @Test + void rejectsFileMetadataFromAnotherBusinessResource() { + AggregateRow plan = row("LOADING", 2); + plan.setContainerBookingId(30L); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(null); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq("30"), eq(99L))).thenReturn(ApiResponse.success(new LoadingDocumentFileClient.Metadata( + 99L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "different-booking", "clearance.pdf", hash(), + "application/pdf", 20L))); + + assertThatThrownBy(() -> service.attachContainerClearance(30L, 9L, "operator", "key", + new DocumentAttachRequest(99L, 0, null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.BAD_REQUEST); + verify(repository, never()).createDocument(org.mockito.ArgumentMatchers.any()); + } + + @Test + void rejectsCompleteWithoutUploadedFile() { + AggregateRow plan = row("LOADING", 2); + plan.setContainerBookingId(30L); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(null); + + assertThatThrownBy(() -> service.completeContainerClearance(30L, 9L, "operator", "key", + new DocumentCompleteRequest(0, null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).completeDocument(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString()); + } + + @Test + void repeatedCompleteWithCurrentVersionIsNaturallyIdempotent() { + AggregateRow plan = row("LOADING", 2); + plan.setContainerBookingId(30L); + ContainerDocumentEntity completed = document(40L, LoadingListService.DESTINATION_CLEARANCE, 4, true); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(completed); + + var result = service.completeContainerClearance(30L, 9L, "operator", "new-key", + new DocumentCompleteRequest(4, null)); + + assertThat(result.completedAt()).isNotNull(); + verify(repository, never()).completeDocument(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString()); + } + + @Test + void uploadsContainerClearanceThroughInternalFileClientAndAssociatesMetadata() { + AggregateRow plan = row("LOADING", 2); + plan.setContainerBookingId(30L); + byte[] content = "clearance-document".getBytes(java.nio.charset.StandardCharsets.UTF_8); + String contentHash = sha256Bytes(content); + var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", content); + var metadata = new LoadingDocumentFileClient.Metadata(99L, 0L, + "CONTAINER_CLEARANCE_DOCUMENT", "30", "clearance.pdf", contentHash, "application/pdf", + (long) content.length); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + ContainerDocumentEntity saved = document(40L, LoadingListService.DESTINATION_CLEARANCE, 0, false); + saved.setFileHash(contentHash); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(null, saved); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq("30"), eq(99L))).thenReturn(ApiResponse.success(metadata)); + when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1); + + var result = service.uploadContainerClearance( + 30L, 9L, "operator", "upload-key", 0, "直接上传", file); + + assertThat(result.fileHash()).isEqualTo(contentHash); + verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq("30"), eq(file)); + verify(repository).appendHistory(eq(10L), + eq("PL-" + sha256("UPLOAD_DESTINATION_CLEARANCE|30|upload-key")), + eq("UPLOAD_DESTINATION_CLEARANCE"), eq("LOADING"), eq("LOADING"), anyString(), + anyString(), eq(9L), eq("operator"), eq("直接上传")); + } + + @Test + void multipartReplayDoesNotUploadFileAgain() { + AggregateRow plan = row("LOADING", 2); + plan.setContainerBookingId(30L); + byte[] content = "clearance-document".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", content); + String requestHash = sha256("UPLOAD|DESTINATION_CLEARANCE|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.DESTINATION_CLEARANCE)) + .thenReturn(document(40L, LoadingListService.DESTINATION_CLEARANCE, 0, false)); + + service.uploadContainerClearance(30L, 9L, "operator", "upload-key", 0, null, file); + + verify(documentFiles, never()).upload(anyString(), eq(9L), eq(0L), anyString(), anyString(), eq(file)); + } + + @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); + plan.setContainerBookingId(30L); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot("{\"requestHash\":\"different\"}"); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(replay); + + assertThatThrownBy(() -> service.attachContainerClearance(30L, 9L, "operator", "reused-key", + new DocumentAttachRequest(100L, 0, null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).createDocument(org.mockito.ArgumentMatchers.any()); + } + + @Test + void genericContainerDocumentsOnlyAcceptDeliveryOrders() { + assertThatThrownBy(() -> service.containerLogisticsDocument(30L, + LoadingListService.DESTINATION_CLEARANCE)).isInstanceOf(BizException.class) + .hasMessageContaining("DELIVERY_ORDER"); + assertThatThrownBy(() -> service.containerLogisticsDocument(30L, + "UNKNOWN_DOCUMENT")).isInstanceOf(BizException.class) + .hasMessageContaining("DELIVERY_ORDER"); + 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 seaRailContainerCannotUploadDeliveryOrder() { + AggregateRow plan = row("COMPLETED", 4); + plan.setChannelCode("OA_CHICAGO_RAIL"); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes()); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-rail", 0, null, file)) + .isInstanceOf(BizException.class) + .hasMessageContaining("海铁渠道不适用DO资料"); + verifyNoInteractions(documentFiles); + } + + @Test + void domesticStageContainerCannotUploadDeliveryOrder() { + AggregateRow plan = row("COMPLETED", 4); + plan.setOutboundConfirmedAt(null); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes()); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-domestic", 0, null, file)) + .isInstanceOf(BizException.class) + .hasMessageContaining("进入海外履约后"); + verifyNoInteractions(documentFiles); + } + + @Test + void returnedContainerCannotUploadDeliveryOrder() { + AggregateRow plan = row("COMPLETED", 4); + plan.setActualEmptyReportedAt(java.time.LocalDateTime.now()); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes()); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-returned", 0, null, file)) + .isInstanceOf(BizException.class) + .hasMessageContaining("已还柜"); + verifyNoInteractions(documentFiles); + } + + @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); + AggregateRow after = row("EDITING", 4); + List links = List.of( + new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L, + "MSCU-001", "EDITING", 3, 4, null), + new Link(2L, 102L, 202L, 302L, "CONFIRMING", 5, 10L, 30L, + "MSCU-001", "EDITING", 3, 6, null)); + PreloadReadiness readiness = readiness("CONFIRMED", false, true, 80L); + var detail = org.mockito.Mockito.mock( + com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail.class); + var summary = org.mockito.Mockito.mock( + com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary.class); + when(detail.summary()).thenReturn(summary); + when(summary.readiness()).thenReturn(readiness); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before, after); + when(loadingPickService.requirePreloadConfirmationReady(10L)).thenReturn(links); + when(fbaPricingService.recalculateUnlockedContainerCustomerFees(eq(30L), anyLong(), anyString())) + .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( + FbaPricingService.ContainerFeeRecalculationStatus.RECALCULATED, 2)); + when(repository.touchForPreloadConfirmation(10L, 3, 9L, "operator")).thenReturn(1); + when(repository.confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator")).thenReturn(1); + when(repository.confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator")).thenReturn(1); + when(repository.preloadDetail(after)).thenReturn(detail); + + PreloadConfirmationResponse response = service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3)); + + assertThat(response.confirmationStatus()).isEqualTo("CONFIRMED"); + assertThat(response.version()).isEqualTo(4); + assertThat(response.readiness().pdaTaskId()).isEqualTo(80L); + verify(repository).confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator"); + verify(repository).confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator"); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(30L), eq(101L), anyString()); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(30L), eq(102L), anyString()); + verify(loadingPickService, times(1)).beginIfReady(30L, 9L, "operator"); + verify(repository).appendHistory(eq(10L), anyString(), eq("CONFIRM_PRELOAD"), + eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L), + eq("operator"), org.mockito.ArgumentMatchers.isNull()); + } + + @Test + void refusesConfirmationWhenAContainerCustomerFeeIsNotReadyWithoutTouchingPlan() { + AggregateRow before = row("EDITING", 3); + List links = List.of(new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L, + "MSCU-001", "EDITING", 3, 4, null)); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before); + when(loadingPickService.requirePreloadConfirmationReady(10L)).thenReturn(links); + when(fbaPricingService.recalculateUnlockedContainerCustomerFees(eq(30L), eq(101L), anyString())) + .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( + FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0)); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3))) + .isInstanceOf(BizException.class).hasMessageContaining("初始费用"); + + verify(repository, never()).touchForPreloadConfirmation(anyLong(), anyInt(), anyLong(), anyString()); + verify(repository, never()).confirmLinkedPreload(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + + @Test + void exactConfirmationReplayReturnsFrozenResponseWithoutTouchingPlan() throws Exception { + PreloadConfirmationResponse expected = new PreloadConfirmationResponse( + 10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L)); + String requestHash = sha256("CONFIRM_PRELOAD|10|3"); + HistoryRow replay = new HistoryRow(); + ObjectMapper mapper = new ObjectMapper(); + replay.setAfterSnapshot(mapper.writerWithDefaultPrettyPrinter().writeValueAsString( + java.util.Map.of("response", expected, "requestHash", requestHash))); + when(repository.history(anyString())).thenReturn(replay); + + PreloadConfirmationResponse actual = service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3)); + + assertThat(actual).isEqualTo(expected); + verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong()); + verify(repository, never()).touchForPreloadConfirmation(anyLong(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(repository, never()).appendHistory(anyLong(), anyString(), anyString(), + anyString(), anyString(), anyString(), anyString(), anyLong(), anyString(), + org.mockito.ArgumentMatchers.isNull()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + + @Test + void rejectsConfirmationReplayWhenExpectedVersionDiffers() throws Exception { + PreloadConfirmationResponse expected = new PreloadConfirmationResponse( + 10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L)); + String originalHash = sha256("CONFIRM_PRELOAD|10|3"); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot(new ObjectMapper().writeValueAsString( + java.util.Map.of("requestHash", originalHash, "response", expected))); + when(repository.history(anyString())).thenReturn(replay); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + + verify(repository, never()).touchForPreloadConfirmation(anyLong(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong()); + } + + @Test + void rejectsNewConfirmationKeyAfterPlanWasAlreadyConfirmed() { + AggregateRow confirmed = row("EDITING", 4); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(confirmed); + when(loadingPickService.requirePreloadConfirmationReady(10L)) + .thenThrow(new BizException(ResultCode.CONFLICT, "预装计划已确认")); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "new-confirm-key", new ConfirmPreloadRequest(4))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + + verify(repository, never()).touchForPreloadConfirmation(anyLong(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(repository, never()).appendHistory(anyLong(), anyString(), anyString(), + anyString(), anyString(), anyString(), anyString(), anyLong(), anyString(), + org.mockito.ArgumentMatchers.isNull()); + } + + @Test + void confirmationGateFailureLeavesPlanAndEveryPreloadUntouched() { + AggregateRow before = row("EDITING", 3); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before); + when(loadingPickService.requirePreloadConfirmationReady(10L)) + .thenThrow(new BizException(ResultCode.CONFLICT, "第二票订舱尚未放舱")); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3))) + .isInstanceOf(BizException.class).hasMessageContaining("第二票"); + + verify(repository, never()).touchForPreloadConfirmation(anyLong(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(repository, never()).confirmLinkedPreload(anyLong(), anyString(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + + @Test + void reusedConfirmationKeyWithDifferentVersionConflictsBeforeReadiness() { + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot("{\"requestHash\":\"different\"}"); + when(repository.history(anyString())).thenReturn(replay); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4))) + .isInstanceOf(BizException.class).hasMessageContaining("不同请求"); + + verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong()); + } + + private static PreloadReadiness readiness(String confirmationStatus, boolean canConfirm, + boolean readyForPda, Long taskId) { + return new PreloadReadiness(true, 2, 2, 90L, "RELEASED", taskId, + taskId == null ? null : "LOADING", confirmationStatus, canConfirm, + readyForPda, List.of()); + } + + private static AggregateRow row(String status, int version) { + AggregateRow row = new AggregateRow(); + row.setPlanId(10L); + row.setPlanNo("PL-10"); + row.setContainerBookingId(30L); + row.setPlanStatus(status); + row.setWarehouseId(100L); + row.setChannelCode("OA_LA_TRUCK"); + row.setOutboundConfirmedAt(java.time.LocalDateTime.now()); + row.setVersion(version); + return row; + } + + private static ContainerDocumentEntity document(long id, String type, int version, boolean completed) { + ContainerDocumentEntity value = new ContainerDocumentEntity(); + value.setId(id); value.setContainerBookingId(30L); value.setDocumentType(type); + value.setFileId(99L); value.setFileName("clearance.pdf"); value.setFileHash(hash()); + value.setUploadedBy(9L); value.setUploadedByName("operator"); value.setVersion(version); + value.setCreatedAt(java.time.LocalDateTime.now()); + if (completed) value.setCompletedAt(java.time.LocalDateTime.now()); + return value; + } + + private static String hash() { return "a".repeat(64); } + + private static String sha256Bytes(byte[] value) { + try { + return java.util.HexFormat.of().formatHex( + java.security.MessageDigest.getInstance("SHA-256").digest(value)); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private static String sha256(String value) { + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java new file mode 100644 index 000000000..56569c323 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java @@ -0,0 +1,93 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; +import com.baiyue.common.core.api.ApiResponse; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LoadingPalletLabelServiceTest { + @Test + void printsAllAndWarehouseSubsetThroughSharedFbaRendererWithoutRegeneratingCodes() { + var service = org.mockito.Mockito.spy(new LoadingPalletLabelService()); + var print = mock(com.baiyue.firstleg.client.PrintPdfClient.class); + ReflectionTestUtils.setField(service, "printClient", print); + ReflectionTestUtils.setField(service, "internalToken", "test-token"); + var label1 = new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel(1L, 1L, 1L, 1L, + "IN-1", "MSCU6639870", "SCK8", "141", "FBA destination", 1, "MSCU6639870-141-001"); + var label2 = new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel(2L, 1L, 1L, 1L, + "IN-1", "MSCU6639870", "SCK8", "141", "FBA destination", 2, "MSCU6639870-141-002"); + var manifest = new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest(10L, "LP-1", "MSCU6639870", 2, + List.of(new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelGroup("SCK8", "141", "FBA destination", 2, List.of(label1, label2)))); + org.mockito.Mockito.doReturn(manifest).when(service).prepare(10L, 20L); + when(print.generateFbaPalletLabels(any(), any(), any())).thenReturn(org.springframework.http.ResponseEntity.ok() + .contentType(org.springframework.http.MediaType.APPLICATION_PDF).body("%PDF-test".getBytes())); + service.print(10L, 20L, null, "request-1"); + service.print(10L, 20L, new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelPrintRequest("SCK8", 1), "request-2"); + var captor = org.mockito.ArgumentCaptor.forClass(com.baiyue.firstleg.client.PrintPdfClient.FbaPalletRequest.class); + org.mockito.Mockito.verify(print, org.mockito.Mockito.times(2)).generateFbaPalletLabels(any(), any(), captor.capture()); + var requests = captor.getAllValues(); + assertThat(requests.get(0).templateCode()).isEqualTo("FBA_PALLET_A4_LANDSCAPE_V1"); + assertThat(requests.get(0).pageSpec().widthMm()).isEqualByComparingTo("297"); + assertThat(requests.get(0).pageSpec().heightMm()).isEqualByComparingTo("210"); + assertThat(requests.get(0).labels()).extracting(com.baiyue.firstleg.client.PrintPdfClient.FbaPalletLabel::palletCode) + .containsExactly("MSCU6639870-141-001", "MSCU6639870-141-002"); + assertThat(requests.get(1).labels()).singleElement().satisfies(label -> { + assertThat(label.fbaWarehouseCode()).isEqualTo("SCK8"); + assertThat(label.palletCode()).isEqualTo("MSCU6639870-141-001"); + }); + org.mockito.Mockito.verify(print, org.mockito.Mockito.never()).generateInboundMarks(any(), any(), any()); + } + + @Test + void generatesStableIndexesPerContainerAndFbaAndSkipsDropship() { + LoadingPalletLabelMapper mapper = mock(LoadingPalletLabelMapper.class); + LoadingPalletLabelService service = new LoadingPalletLabelService(); + ReflectionTestUtils.setField(service, "mapper", mapper); + WarehouseMasterDataClient warehouseClient = mock(WarehouseMasterDataClient.class); + ReflectionTestUtils.setField(service, "warehouseClient", warehouseClient); + ReflectionTestUtils.setField(service, "internalToken", "test-token"); + List saved = new ArrayList<>(); + when(mapper.lockSources(10L)).thenReturn(List.of(source(3, "LAX9", 1, "STANDARD"), + source(1, "ONT8", 2, "STANDARD"), source(2, "ONT8", 1, "STANDARD"), + source(4, "ONT8", 4, "ONE_CLICK_DROPSHIP"))); + when(mapper.labels(10L)).thenAnswer(ignored -> List.copyOf(saved)); + when(mapper.insert(any())).thenAnswer(invocation -> { saved.add(invocation.getArgument(0)); return 1; }); + when(warehouseClient.active("test-token", 0L, 101L)).thenReturn(warehouse("LAX9", "154")); + when(warehouseClient.active("test-token", 0L, 102L)).thenReturn(warehouse("ONT8", "CADF")); + + var manifest = service.prepare(10L, 20L); + + assertThat(manifest.totalCount()).isEqualTo(4); + assertThat(manifest.groups()).extracting(group -> group.destinationWarehouseCode() + ":" + group.totalCount()) + .containsExactly("LAX9:1", "ONT8:3"); + assertThat(manifest.groups()).extracting(group -> group.destinationWarehouseInternalCode()) + .containsExactly("154", "CADF"); + assertThat(saved).extracting(LoadingPalletLabelMapper.LabelRow::getPalletCode) + .containsExactly("MSCU6639870-154-001", "MSCU6639870-CADF-001", + "MSCU6639870-CADF-002", "MSCU6639870-CADF-003"); + } + + private LoadingPalletLabelMapper.SourceRow source(long itemId, String destination, int pallets, String fulfillment) { + var row = new LoadingPalletLabelMapper.SourceRow(); row.setPlanId(10L); row.setPlanNo("LP-1"); + row.setPlanStatus("LOADING"); row.setContainerNo("MSCU6639870"); row.setWarehouseId(20L); + row.setItemId(itemId); row.setAccountId(100L); row.setInboundId(itemId + 1000); + row.setInboundCode("RK-" + itemId); row.setDestinationWarehouseCode(destination); + row.setDestinationWarehouseId("LAX9".equals(destination) ? 101L : 102L); + row.setDestinationWarehouseName(destination); row.setPalletCount(pallets); + row.setFulfillmentType(fulfillment); row.setDestinationType("FBA"); return row; + } + + private ApiResponse warehouse(String code, String internalCode) { + return ApiResponse.success(new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse( + "LAX9".equals(code) ? 101L : 102L, 0L, code, internalCode, code, "FBA", "ACTIVE"))); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java new file mode 100644 index 000000000..f21014288 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -0,0 +1,1700 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.Task; +import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; +import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; +import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; +import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import com.baiyue.firstleg.entity.LoadingPickOperationEntity; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; +import com.baiyue.firstleg.loading.service.LoadingDeliveryService; +import com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; +import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; +import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; +import com.baiyue.firstleg.mapper.LoadingPickItemMapper; +import com.baiyue.firstleg.repository.LoadingPickRepository; +import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; +import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository; +import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InOrder; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.time.LocalDateTime; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.clearInvocations; +import static org.assertj.core.api.Assertions.assertThat; + +@SpringJUnitConfig(LoadingPickService.class) +class LoadingPickServiceTest { + @MockBean + private LoadingPickRepository repository; + @MockBean + private LoadingCompletionCommandRepository completionCommands; + @MockBean + private TransactionTemplate completionTransaction; + @MockBean + private SmartLoadingRepository smartLoadingRepository; + @MockBean + private OceanBookingRepository oceanBookingRepository; + @MockBean + private LoadingModelBridgeService loadingModelBridge; + @MockBean + private LoadingListService loadingListService; + @MockBean + private LoadingDeliveryService loadingDeliveryService; + @MockBean + private CabinetLoadingEvidenceService cabinetEvidenceService; + @MockBean + private ColoadGroupAssignmentPort coloadGroups; + @MockBean + private InboundStatusHistoryMapper inboundHistories; + @MockBean + private PdaPickSettlementService settlementService; + @MockBean + private InboundPalletAllocationRepository palletAllocations; + @MockBean + private InboundHoldGuard inboundHoldGuard; + @MockBean + private DropshipPalletizationMapper dropshipPallets; + @Resource + private LoadingPickService service; + + @Test + void heldPlanBlocksDirectPdaItemSynchronizationBeforeTaskMutation() { + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单已扣货")) + .when(inboundHoldGuard).requirePlanNotHeld(10L); + + assertThatThrownBy(() -> service.sync(10L, new LoadingPlanItemEntity())) + .isInstanceOf(BizException.class).hasMessageContaining("扣货"); + + verify(repository, never()).taskByPlan(anyLong()); + } + + @Test + void heldTaskStopsDurableCompletionWorkerBeforeAnyFulfillmentReadOrWrite() { + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单已扣货")) + .when(inboundHoldGuard).requireTaskNotHeld(20L); + + assertThatThrownBy(() -> service.processCompletion( + completionCommand(999L, "held-worker"), "worker-held")) + .isInstanceOf(BizException.class).hasMessageContaining("扣货"); + + verify(repository, never()).task(anyLong()); + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + } + + @BeforeEach + void activeOperationalContainer() { + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + Consumer action = invocation.getArgument(0); + action.accept(mock(TransactionStatus.class)); + return null; + }).when(completionTransaction).executeWithoutResult(any()); + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setId(99L); + booking.setContainerNo("MSCU6639870"); + booking.setWarehouseId(101L); + booking.setSourceType("SIMULATION"); + booking.setRecordStatus("ACTIVE"); + when(repository.activeBookingByTask(20L)).thenReturn(booking); + when(completionCommands.insert(any())).thenReturn(1); + when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true); + when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1); + when(repository.inboundOwners(anyLong())).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(repository.inbound(anyLong(), anyLong())).thenAnswer(invocation -> + new LoadingInboundMapper.Snapshot(invocation.getArgument(1), invocation.getArgument(0), "LOADING", 1)); + when(dropshipPallets.physicalMetricsByPalletIds(any())).thenReturn(List.of( + new DropshipPalletizationMapper.PalletMetrics(801L, 2, + java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO))); + } + + @Test + void doesNotOpenTaskWithoutRealContainerNumber() { + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + + service.beginIfReady(40L, 7L, "admin"); + + verify(repository, never()).insert(any(LoadingPickTaskEntity.class)); + verify(smartLoadingRepository, never()).planByBooking(40L); + } + + @Test + void doesNotOpenTaskBeforeOceanRelease() { + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); + plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); + var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); + var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("CONFIRMED"); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); + when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of( + new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null))); + when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); + when(oceanBookingRepository.find(90L)).thenReturn(ocean); + + service.beginIfReady(40L, 7L, "admin"); + + verify(repository, never()).insert(any(LoadingPickTaskEntity.class)); + } + + @Test + void doesNotOpenTaskBeforeEveryLinkedPreloadIsConfirmed() { + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); + plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); + when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of( + new Link(1L, 1L, 50L, 2L, "PENDING", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null))); + + service.beginIfReady(40L, 7L, "admin"); + + verify(repository, never()).insert(any(LoadingPickTaskEntity.class)); + verify(oceanBookingRepository, never()).allocationByContainer(40L); + } + + @Test + void planConfirmationGateRequiresReleasedBookingAndRealContainerNumber() { + Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1, + 10L, 40L, "MSCU-001", "EDITING", 3, 2, null); + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var allocation = new OceanBookingContainerAllocationEntity(); + allocation.setOceanBookingId(90L); + var ocean = new OceanBookingEntity(); + ocean.setId(90L); ocean.setStatus("RELEASED"); + when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link)); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); + when(oceanBookingRepository.find(90L)).thenReturn(ocean); + + assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link); + } + + @Test + void planConfirmationGateRejectsMissingRealContainerNumber() { + Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1, + 10L, 40L, null, "EDITING", 3, 2, null); + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); + when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link)); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + + assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L)) + .isInstanceOf(BizException.class).hasMessageContaining("真实集装箱号"); + verify(oceanBookingRepository, never()).allocationByContainer(40L); + } + + @Test + void newKeyCannotConfirmAnAlreadyConfirmedPlanAgain() { + Link confirmed = new Link(1L, 1L, 50L, 2L, "CONFIRMED", 2, + 10L, 40L, "MSCU-001", "EDITING", 4, 2, null); + when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)) + .thenReturn(List.of(confirmed)); + + assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L)) + .isInstanceOf(BizException.class).hasMessageContaining("已确认"); + verify(smartLoadingRepository, never()).booking(anyLong()); + } + + @Test + void readinessCreatesAtMostOneTask() { + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); + plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); + var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); + var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED"); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); + when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of( + new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null))); + when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); + when(oceanBookingRepository.find(90L)).thenReturn(ocean); + when(repository.taskByPlan(10L)).thenReturn(new LoadingPickTaskEntity()); + + service.beginIfReady(40L, 7L, "admin"); + + verify(repository, never()).insert(any(LoadingPickTaskEntity.class)); + verify(smartLoadingRepository, never()).startPicking( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void stableCabinetAndPlanQueriesReturnSameTaskWithCurrentContainerNumber() { + LoadingPickTaskEntity task = task(); + task.setContainerBookingId(40L); task.setCabinetNo("CAB-001"); + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-NEW"); + booking.setWarehouseId(101L); + booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE"); + when(repository.taskByCabinet("CAB-001", 101L)).thenReturn(task); + when(repository.taskByPlan(10L, 101L)).thenReturn(task); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(repository.activeBookingByTask(20L)).thenReturn(booking); + when(repository.items(20L, 101L)).thenReturn(List.of()); + when(repository.cartonLabels(20L)).thenReturn(List.of()); + + var byCabinet = service.task("CAB-001", null, 101L); + var byPlan = service.task(null, 10L, 101L); + + assertThat(byCabinet.id()).isEqualTo(20L); + assertThat(byPlan.id()).isEqualTo(20L); + assertThat(byCabinet.currentContainerNo()).isEqualTo("MSCU-NEW"); + assertThat(byCabinet.sourceType()).isEqualTo("SIMULATION"); + assertThat(byCabinet.recordStatus()).isEqualTo("ACTIVE"); + assertThat(byCabinet.mergedIntoContainerBookingId()).isNull(); + } + + @Test + void pendingTasksExposeServerFilteredContainerLifecycle() { + var row = new com.baiyue.firstleg.mapper.LoadingPickTaskMapper.PendingTaskRow(); + row.setTaskId(20L); + row.setLoadingPlanId(10L); + row.setContainerBookingId(99L); + row.setCabinetNo("CAB-001"); + row.setContainerNo("MSCU-001"); + row.setSourceType("SIMULATION"); + row.setRecordStatus("ACTIVE"); + row.setMergedIntoContainerBookingId(null); + row.setWarehouseId(101L); + row.setTaskStatus("LOADING"); + row.setWorkflowStatus("AWAITING_REVIEW"); + row.setVersion(0); + row.setAssignmentVersion(0); + row.setItemCount(1); + row.setPendingItemCount(0); + when(repository.pendingTasks(9L, 200001L, null)).thenReturn(List.of(row)); + + var result = service.pendingTasks(9L, 200001L, null).get(0); + + assertThat(result.sourceType()).isEqualTo("SIMULATION"); + assertThat(result.recordStatus()).isEqualTo("ACTIVE"); + assertThat(result.mergedIntoContainerBookingId()).isNull(); + assertThat(result.workflowStatus()).isEqualTo("AWAITING_REVIEW"); + assertThat(result.pendingItemCount()).isZero(); + } + + @Test + void taskDetailRejectsMergedContainerSource() { + LoadingPickTaskEntity task = task(); + when(repository.task(20L)).thenReturn(task); + when(repository.activeBookingByTask(20L)).thenReturn(null); + when(repository.items(20L, 101L)).thenReturn(List.of()); + when(repository.cartonLabels(20L)).thenReturn(List.of()); + + assertThatThrownBy(() -> service.task(20L, 101L)) + .isInstanceOf(BizException.class).hasMessageContaining("已非有效存续记录"); + } + + @Test + void rejectsMissingOrConflictingCompatibilitySelectors() { + assertThatThrownBy(() -> service.task(null, null, 101L)) + .isInstanceOf(BizException.class).hasMessageContaining("必须且只能提供一个"); + assertThatThrownBy(() -> service.task("CAB", 10L, 101L)) + .isInstanceOf(BizException.class).hasMessageContaining("必须且只能提供一个"); + } + + @Test + void keepsPlanLoadingWhenItemsRemain() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + preparePick(task, item, "pick-1"); + when(repository.allTaskItemsResolved(20L)).thenReturn(false); + + service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-1"), 9L, "operator"); + + verify(repository).touchTask(20L, 0); + verify(repository).syncInboundProjections(20L, 9L, "operator"); + verify(settlementService, never()).settle(20L, 9L, "operator"); + verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator"); + } + + @Test + void keepsResourcesLockedAfterLastPickedItemUntilManualCompletion() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + preparePick(task, item, "pick-2"); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + + service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-2"), 9L, "operator"); + + verify(repository).touchTask(20L, 0); + verify(settlementService, never()).settle(20L, 9L, "operator"); + verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator"); + verify(repository).syncInboundProjections(20L, 9L, "operator"); + verify(repository).insert(any(LoadingPickHistoryEntity.class)); + } + + @Test + void keepsPickAsStartLoadingFallbackForOlderClients() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + preparePick(task, item, "pick-start-loading"); + when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1); + + service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-start-loading"), + 9L, "operator"); + + verify(smartLoadingRepository).startPicking(10L, 9L, "operator"); + verify(smartLoadingRepository).insert(any(com.baiyue.firstleg.entity.LoadingPlanHistoryEntity.class)); + } + + @Test + void startsInboundLoadingEvenWhenPlanWasAlreadyStarted() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + preparePick(task, item, "pick-start-inbound"); + when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(0); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(repository.inbound(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "WAIT_SPLIT", 4)); + when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(1); + + service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-start-inbound"), + 9L, "operator"); + + verify(repository).beginInboundLoading(101L, 50L, 9L, 4); + verify(inboundHistories).insert(argThat((InboundStatusHistoryEntity history) -> + Long.valueOf(101L).equals(history.getTenantId()) + && Long.valueOf(50L).equals(history.getInboundId()) + && "WAIT_SPLIT".equals(history.getFromStatus()) + && "LOADING".equals(history.getToStatus()) + && "PDA_START_LOADING".equals(history.getAction()))); + verify(smartLoadingRepository, never()).insert(any( + com.baiyue.firstleg.entity.LoadingPlanHistoryEntity.class)); + } + + @Test + void acceptsConcurrentInboundTransitionWithoutDuplicateHistory() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + preparePick(task, item, "pick-concurrent-inbound"); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(repository.inbound(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "WAIT_SPLIT", 4), + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 5)); + when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(0); + + service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-concurrent-inbound"), + 9L, "operator"); + + verify(repository).beginInboundLoading(101L, 50L, 9L, 4); + verify(inboundHistories, never()).insert(any(InboundStatusHistoryEntity.class)); + } + + @Test + void taskDetailReturnsServerInboundAndLoadingVersions() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity sealedPallet = item(); + sealedPallet.setUnitType("SEALED_PALLET"); + sealedPallet.setPalletId(801L); + sealedPallet.setPalletNo("DP-260909-0001"); + sealedPallet.setIndivisible(true); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L, 101L)).thenReturn(List.of(sealedPallet)); + when(repository.cartonLabels(20L)).thenReturn(List.of()); + when(repository.assignment(20L)).thenReturn(null); + when(repository.review(20L)).thenReturn(null); + when(repository.allTaskItemsResolved(20L)).thenReturn(false); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(palletAllocations.list(101L, 50L)).thenReturn(List.of(allocation(50L, 2))); + when(repository.inbound(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + var result = service.task(20L, 101L); + + assertThat(result.inboundId()).isEqualTo(50L); + assertThat(result.inboundVersion()).isEqualTo(7); + assertThat(result.loadingVersion()).isEqualTo(7); + assertThat(result.loadingPlanId()).isEqualTo(10L); + assertThat(result.containerBookingId()).isEqualTo(99L); + assertThat(result.sourceType()).isEqualTo("SIMULATION"); + assertThat(result.recordStatus()).isEqualTo("ACTIVE"); + assertThat(result.mergedIntoContainerBookingId()).isNull(); + assertThat(result.items().get(0)).extracting("unitType", "palletId", "palletNo", "indivisible") + .containsExactly("SEALED_PALLET", 801L, "DP-260909-0001", true); + assertThat(result.items().get(0).inventoryResources()).singleElement().satisfies(resource -> { + assertThat(resource.blockId()).isEqualTo(71L); + assertThat(resource.slotNo()).isEqualTo(1); + assertThat(resource.reservationId()).isEqualTo(72L); + assertThat(resource.locationId()).isEqualTo(74L); + assertThat(resource.availablePackageCount()).isEqualTo(2); + }); + verify(palletAllocations).list(101L, 50L); + verify(completionCommands, never()).latest(20L); + } + + @Test + void taskDetailCollapsesMultiSkuRowsIntoOnePhysicalPalletCard() { + LoadingPickItemEntity first = item(); + LoadingPickItemEntity second = item(); + second.setId(31L); + second.setLoadingPlanItemId(41L); + second.setInboundDetailId(61L); + for (LoadingPickItemEntity item : List.of(first, second)) { + item.setPlannedPackageCount(11); + item.setUnitType("SEALED_PALLET"); + item.setPalletId(801L); + item.setPalletNo("DP-260916-0009"); + item.setIndivisible(true); + } + when(dropshipPallets.physicalMetricsByPalletIds(any())).thenReturn(List.of( + new DropshipPalletizationMapper.PalletMetrics(801L, 11, + java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO))); + when(repository.task(20L)).thenReturn(task()); + when(repository.items(20L, 101L)).thenReturn(List.of(first, second)); + when(repository.cartonLabels(20L)).thenReturn(List.of()); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(palletAllocations.list(101L, 50L)).thenReturn(List.of(allocation(50L, 20))); + + Task result = service.task(20L, 101L); + + assertThat(result.items()).singleElement().satisfies(item -> { + assertThat(item.palletNo()).isEqualTo("DP-260916-0009"); + assertThat(item.plannedPackageCount()).isEqualTo(11); + assertThat(item.pickedPackageCount()).isZero(); + }); + } + + @Test + void loadingTaskDetailDoesNotDependOnCompletionCommandStorage() { + LoadingPickTaskEntity task = task(); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L, 101L)).thenReturn(List.of(item())); + when(repository.cartonLabels(20L)).thenReturn(List.of()); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(palletAllocations.list(101L, 50L)).thenReturn(List.of()); + doThrow(new IllegalStateException("completion command schema unavailable")) + .when(completionCommands).latest(20L); + + var result = service.task(20L, 101L); + + assertThat(result.taskStatus()).isEqualTo("LOADING"); + assertThat(result.completionCommand()).isNull(); + verify(completionCommands, never()).latest(20L); + } + + @Test + void taskDetailDoesNotRecheckWarehouseOwnership() { + LoadingPickTaskEntity task = task(); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L)).thenReturn(List.of(item())); + when(repository.items(20L, 999L)).thenReturn(List.of(item())); + when(repository.cartonLabels(20L)).thenReturn(List.of()); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(palletAllocations.list(101L, 50L)).thenReturn(List.of(allocation(50L, 2, 202L))); + + var result = service.task(20L, 999L); + + assertThat(result.id()).isEqualTo(20L); + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).inventoryResources()).singleElement() + .extracting("availablePackageCount").isEqualTo(2); + } + + @Test + void claimStartsLoadingAndSameOperatorReentryDoesNotDuplicateStartHistory() { + LoadingPickTaskEntity task = task(); + LoadingPickAssignmentEntity assignment = assignment(); + AtomicReference operation = new AtomicReference<>(); + when(repository.operation("claim-1")).thenAnswer(invocation -> operation.get()); + when(repository.operation("claim-2")).thenReturn(null); + when(repository.task(20L)).thenReturn(task); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(null, assignment); + when(repository.createAssignment(20L, 9L, "operator", 9L, "operator", "CLAIMED")).thenReturn(1); + when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1, 0); + doAnswer(invocation -> { + LoadingPickOperationEntity value = invocation.getArgument(0); + if ("claim-1".equals(value.getOperationId())) operation.set(value); + return null; + }).when(repository).insert(any(LoadingPickOperationEntity.class)); + + service.claim(20L, new ClaimRequest(0, "claim-1"), 9L, "operator"); + service.claim(20L, new ClaimRequest(0, "claim-1"), 9L, "operator"); + service.claim(20L, new ClaimRequest(0, "claim-2"), 9L, "operator"); + + verify(smartLoadingRepository, times(2)).startPicking(10L, 9L, "operator"); + verify(smartLoadingRepository, times(1)).insert(argThat( + (com.baiyue.firstleg.entity.LoadingPlanHistoryEntity history) -> + "START_LOADING".equals(history.getActionType()) + && "EDITING".equals(history.getFromStatus()) + && "LOADING".equals(history.getToStatus()) + && "PDA-START-20".equals(history.getOperationId()))); + verify(repository, times(1)).createAssignment(20L, 9L, "operator", 9L, "operator", "CLAIMED"); + } + + @Test + void claimRechecksSameOperationAfterWaitingForTaskLock() { + LoadingPickTaskEntity task = task(); + LoadingPickOperationEntity completed = taskOperation("claim-race", "CLAIM"); + when(repository.operation("claim-race")).thenReturn(null); + when(repository.operationForUpdate("claim-race")).thenReturn(completed); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L)).thenReturn(List.of()); + + Task replay = service.claim(20L, new ClaimRequest(0, "claim-race"), 9L, "operator"); + + assertThat(replay.id()).isEqualTo(20L); + verify(repository, never()).createAssignment(anyLong(), anyLong(), anyString(), anyLong(), + anyString(), anyString()); + verify(smartLoadingRepository, never()).startPicking(anyLong(), anyLong(), anyString()); + } + + @Test + void assignRechecksSameOperationAfterWaitingForTaskLock() { + LoadingPickTaskEntity task = task(); + LoadingPickOperationEntity completed = taskOperation("assign-race", "ASSIGN"); + when(repository.operation("assign-race")).thenReturn(null); + when(repository.operationForUpdate("assign-race")).thenReturn(completed); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L)).thenReturn(List.of()); + + Task replay = service.assign(20L, new AssignRequest(11L, "picker", 0, "assign-race"), + 9L, "operator"); + + assertThat(replay.id()).isEqualTo(20L); + verify(repository, never()).createAssignment(anyLong(), anyLong(), anyString(), anyLong(), + anyString(), anyString()); + verify(repository, never()).reassign(anyLong(), anyLong(), anyString(), anyLong(), + anyString(), anyString(), anyInt()); + } + + @Test + void pickRechecksSameHistoryAfterWaitingForTaskLock() { + LoadingPickTaskEntity task = task(); + LoadingPickHistoryEntity completed = itemHistory("pick-race", "PICK", + "20|30|101|2"); + when(repository.history("pick-race")).thenReturn(null); + when(repository.historyForUpdate("pick-race")).thenReturn(completed); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L, 101L)).thenReturn(List.of(item())); + + Task replay = service.pick(20L, 30L, + new PickRequest(101L, 2, 0, "pick-race"), 9L, "operator"); + + assertThat(replay.id()).isEqualTo(20L); + verify(repository, never()).pick(anyLong(), anyInt(), anyInt(), anyLong()); + verify(repository, never()).insert(any(LoadingPickHistoryEntity.class)); + verify(repository, never()).touchTask(anyLong(), anyInt()); + } + + @Test + void partialRechecksSameHistoryAfterWaitingForTaskLock() { + LoadingPickTaskEntity task = task(); + LoadingPickHistoryEntity completed = itemHistory("partial-race", "PARTIAL", + "PARTIAL|20|30|101|1"); + when(repository.history("partial-race")).thenReturn(null); + when(repository.historyForUpdate("partial-race")).thenReturn(completed); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L, 101L)).thenReturn(List.of(item())); + + Task replay = service.partial(20L, 30L, + new PartialPickRequest(101L, 1, 0, "partial-race"), 9L, "operator"); + + assertThat(replay.id()).isEqualTo(20L); + verify(repository, never()).partial(anyLong(), anyInt(), anyInt(), anyLong()); + verify(repository, never()).insert(any(LoadingPickHistoryEntity.class)); + verify(repository, never()).touchTask(anyLong(), anyInt()); + } + + @Test + void skipRechecksSameHistoryAfterWaitingForTaskLock() { + LoadingPickTaskEntity task = task(); + LoadingPickHistoryEntity completed = itemHistory("skip-race", "SKIP", + "SKIP|20|30|101|damaged"); + when(repository.history("skip-race")).thenReturn(null); + when(repository.historyForUpdate("skip-race")).thenReturn(completed); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L, 101L)).thenReturn(List.of(item())); + + Task replay = service.skip(20L, 30L, + new SkipRequest(101L, 0, "skip-race", "damaged"), 9L, "operator"); + + assertThat(replay.id()).isEqualTo(20L); + verify(repository, never()).skip(anyLong(), anyInt(), anyLong()); + verify(repository, never()).insert(any(LoadingPickHistoryEntity.class)); + verify(repository, never()).touchTask(anyLong(), anyInt()); + } + + @Test + void keepsPartialPickLockedUntilManualCompletion() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + preparePartial(task, item, "partial-1"); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + + service.partial(20L, 30L, new PartialPickRequest(101L, 1, 0, "partial-1"), + 9L, "operator"); + + verify(repository).touchTask(20L, 0); + verify(settlementService, never()).settle(20L, 9L, "operator"); + verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator"); + verify(repository).syncInboundProjections(20L, 9L, "operator"); + } + + @Test + void rejectsSkipWhenInboundAlreadyHasPickedItem() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity skipped = item(); + LoadingPickItemEntity picked = item(); + picked.setId(31L); + picked.setItemStatus("PICKED"); + picked.setPickedPackageCount(2); + when(repository.history("skip-1")).thenReturn(null); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.item(20L, 30L, 101L)).thenReturn(skipped); + when(repository.hasPickedOrPartialItem(20L, 50L)).thenReturn(true); + + assertThatThrownBy(() -> service.skip(20L, 30L, + new SkipRequest(101L, 0, "skip-1", "fixture unavailable"), 9L, "operator")) + .isInstanceOf(BizException.class); + + verify(repository, never()).skip(30L, 0, 101L); + verify(settlementService, never()).settle(20L, 9L, "operator"); + } + + @Test + @org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract") + void manualCompletionSettlesResolvedItemsCompletesPlanAndReplaysSameOperation() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + AtomicReference operation = new AtomicReference<>(); + item.setPlannedPackageCount(5); + item.setPickedPackageCount(3); + item.setItemStatus("PARTIAL"); + when(repository.operation("complete-1")).thenAnswer(invocation -> operation.get()); + doAnswer(invocation -> { + operation.set(invocation.getArgument(0)); + return null; + }).when(repository).insert(any(LoadingPickOperationEntity.class)); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L)).thenReturn(List.of(item)); + when(repository.items(20L, 101L)).thenReturn(List.of(item)); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); + 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"); + + verify(repository).insert(any(LoadingPickOperationEntity.class)); + 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, 60L); + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + "PDA-complete-1-50", "checked"); + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 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", + "PDA-complete-1-50", "checked"); + finalization.verify(loadingListService).publishCompletedEvent(10L); + } + + @Test + @org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract") + void doesNotPublishCompletedEventWhenInboundFinalizationFails() { + LoadingPickTaskEntity task = task(); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + 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)); + when(repository.inboundForUpdate(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, + "入库单并发修改")) + .when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + "PDA-complete-finalize-fails-50", "checked"); + + assertThatThrownBy(() -> service.complete(20L, + new CompleteLoadingRequest(0, "complete-finalize-fails", "checked"), 9L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("并发修改"); + + verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(loadingListService, never()).publishCompletedEvent(anyLong()); + } + + @Test + @org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract") + void translatesUnexpectedSettlementFailureIntoActionableStageMessage() { + LoadingPickTaskEntity task = task(); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); + when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); + when(repository.inboundForUpdate(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + doThrow(new IllegalStateException("unexpected database failure")) + .when(settlementService).settle(20L, 9L, "operator"); + + assertThatThrownBy(() -> service.complete(20L, + new CompleteLoadingRequest(0, "complete-stage-error", "checked"), 9L, "operator")) + .isInstanceOf(BizException.class) + .hasMessageContaining("拆单与库位结算") + .hasMessageContaining("请求编号"); + + verify(repository, never()).approve(anyLong(), anyLong(), anyString(), anyString()); + verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); + } + + @Test + @org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract") + void warehouseSettlementConflictLeavesCompletionClaimAndDownstreamFinalizationUnwritten() { + LoadingPickTaskEntity task = task(); + task.setVersion(1); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); + when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); + when(repository.inboundForUpdate(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, + "释放库位资源时发生并发变更")) + .when(settlementService).settle(20L, 9L, "operator"); + + assertThatThrownBy(() -> service.complete(20L, + new CompleteLoadingRequest(1, "complete-real-a0409", "PDA确认装柜完成"), 9L, "operator")) + .isInstanceOf(BizException.class) + .hasMessageContaining("释放库位资源时发生并发变更"); + + verify(repository, never()).approve(anyLong(), anyLong(), anyString(), anyString()); + verify(repository, never()).insert(any(LoadingPickOperationEntity.class)); + verify(repository, never()).syncInboundProjections(anyLong(), anyLong(), anyString()); + verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingListService, never()).publishCompletedEvent(anyLong()); + verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), + anyString(), anyString(), anyString()); + } + + @Test + @org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract") + void currentCompleteRequestFinishesAfterUniqueResourceSettlementSucceeds() { + LoadingPickTaskEntity task = task(); + task.setVersion(1); + LoadingPickItemEntity item = item(); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L)).thenReturn(List.of(item)); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.approve(20L, 9L, "operator", "PDA确认装柜完成")).thenReturn(1); + var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); + when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); + when(repository.inboundForUpdate(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + + Task result = service.complete(20L, new CompleteLoadingRequest(1, + "complete-1000001-2092817774453780481-2092787765815078913--45b8eb0887ff317c", + "PDA确认装柜完成"), 9L, "operator"); + + assertThat(result.id()).isEqualTo(20L); + verify(settlementService).settle(20L, 9L, "operator"); + verify(repository).insert(org.mockito.ArgumentMatchers.argThat( + (LoadingPickOperationEntity operation) -> "COMPLETE_LOADING".equals(operation.getActionType()))); + verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(loadingListService).publishCompletedEvent(10L); + } + + @Test + void sameOperationIdReplayReturnsProcessingWithoutAnotherTaskForUpdate() { + LoadingPickTaskEntity loading = task(); + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + completing.setVersion(1); + AtomicReference accepted = new AtomicReference<>(); + when(completionCommands.operation("async-complete-1")).thenAnswer(invocation -> accepted.get()); + doAnswer(invocation -> { + accepted.set(invocation.getArgument(0)); + return 1; + }).when(completionCommands).insert(any()); + when(repository.taskForUpdate(20L)).thenReturn(loading); + when(repository.task(20L)).thenReturn(completing); + when(repository.items(20L)).thenReturn(List.of(item())); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L))); + + Task first = service.complete(20L, + new CompleteLoadingRequest(0, "async-complete-1", "checked"), 9L, "operator"); + verify(repository).taskForUpdate(20L); + clearInvocations(repository); + Task replay = org.junit.jupiter.api.Assertions.assertTimeout(java.time.Duration.ofMillis(200), () -> + service.complete(20L, + new CompleteLoadingRequest(0, "async-complete-1", "checked"), 9L, "operator")); + + assertThat(first.taskStatus()).isEqualTo("COMPLETING"); + assertThat(first.workflowStatus()).isEqualTo("PROCESSING"); + assertThat(replay.taskStatus()).isEqualTo("COMPLETING"); + verify(repository, never()).taskForUpdate(20L); + verify(completionCommands, times(1)).insert(any()); + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); + } + + @Test + void completionRejectsMissingRealContainerNumberBeforeCreatingCommand() { + LoadingPickTaskEntity loading = task(); + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setId(99L); + booking.setWarehouseId(101L); + booking.setRecordStatus("ACTIVE"); + when(repository.taskForUpdate(20L)).thenReturn(loading); + when(repository.activeBookingByTask(20L)).thenReturn(booking); + + assertThatThrownBy(() -> service.complete(20L, + new CompleteLoadingRequest(0, "complete-missing-container", "checked"), 9L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("确认装柜完成前"); + + verify(repository, never()).allTaskItemsResolved(20L); + verify(completionCommands, never()).insert(any()); + verify(repository, never()).acceptCompletion(anyLong(), anyInt()); + } + + @Test + void differentOperationIdIsRejectedImmediatelyWithConflict() { + // different operationId is a canonical A0409 CONFLICT while the durable command is active. + LoadingPickTaskEntity loading = task(); + when(repository.taskForUpdate(20L)).thenReturn(loading); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L))); + var active = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity(); + active.setOperationId("already-processing"); + when(completionCommands.active(20L)).thenReturn(active); + + assertThatThrownBy(() -> service.complete(20L, + new CompleteLoadingRequest(0, "different-operation", "checked"), 9L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("其他装柜完成命令"); + + verify(completionCommands, never()).insert(any()); + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + } + + @Test + void failedSameOperationIdCanResumeWithoutCreatingAnotherCommand() { + assertNoTruckCoupling(); + LoadingPickTaskEntity failed = task(); + failed.setTaskStatus("COMPLETION_FAILED"); + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + var command = completionCommand(902L, "resume-same-operation"); + command.setRequestHash("48ea6be16079a761f4e77abcf58efaafebc2ab629250ce06118315232e9f3070"); + command.setCommandStatus("FAILED"); + command.setExpectedTaskVersion(0); + when(completionCommands.operation("resume-same-operation")).thenReturn(command); + when(completionCommands.resume(902L)).thenReturn(true); + when(repository.taskForUpdate(20L)).thenReturn(failed); + when(repository.resumeCompletion(20L)).thenReturn(1); + when(repository.task(20L)).thenReturn(completing); + when(repository.items(20L)).thenReturn(List.of(item())); + when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L))); + + Task result = service.complete(20L, + new CompleteLoadingRequest(0, "resume-same-operation", "checked"), 9L, "operator"); + + assertThat(result.workflowStatus()).isEqualTo("PROCESSING"); + verify(completionCommands).resume(902L); + verify(repository).resumeCompletion(20L); + verify(completionCommands, never()).insert(any()); + } + + @Test + void shortPickedColoadMemberCannotCreateCompletionCommandOrRemainderChild() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity shortPicked = item(); + shortPicked.setInboundId(50L); + shortPicked.setPlannedPackageCount(5); + shortPicked.setPickedPackageCount(4); + shortPicked.setItemStatus("PARTIAL"); + LoadingPickItemEntity complete = item(); + complete.setId(31L); + complete.setInboundId(60L); + complete.setPlannedPackageCount(5); + complete.setPickedPackageCount(5); + complete.setItemStatus("PICKED"); + GroupSnapshot group = coloadGroup("LOCKED", 10L, + new Member(101L, 301L, 50L, "IN-50", 5), new Member(101L, 301L, 60L, "IN-60", 5)); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L), + new LoadingPickItemMapper.InboundOwner(101L, 60L))); + when(repository.items(20L)).thenReturn(List.of(shortPicked, complete)); + when(coloadGroups.findByInbound(101L, 50L)).thenReturn(group); + when(coloadGroups.findByInbound(101L, 60L)).thenReturn(group); + + assertThatThrownBy(() -> service.complete(20L, + new CompleteLoadingRequest(0, "complete-short-coload", "短拣异常"), 9L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("全组箱数守恒"); + + verify(completionCommands, never()).insert(any()); + verify(repository, never()).acceptCompletion(anyLong(), anyInt()); + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + } + + @Test + void workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce() { + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + List owners = java.util.stream.IntStream.range(0, 15) + .mapToObj(index -> new LoadingPickItemMapper.InboundOwner(101L, 1014L - index)).toList(); + when(repository.inboundOwners(20L)).thenReturn(owners); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.review(20L)).thenReturn(null); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1); + when(completionCommands.complete(900L, "worker-1")).thenReturn(true); + var command = completionCommand(900L, "async-15"); + + service.processCompletion(command, "worker-1"); + + ArgumentCaptor inboundIds = ArgumentCaptor.forClass(Long.class); + verify(settlementService, times(15)).settleOwner(eq(20L), inboundIds.capture(), eq(9L), eq("operator")); + assertThat(inboundIds.getAllValues()).containsExactly( + 1000L, 1001L, 1002L, 1003L, 1004L, 1005L, 1006L, 1007L, + 1008L, 1009L, 1010L, 1011L, 1012L, 1013L, 1014L); + verify(loadingListService).publishCompletedEvent(10L); + verify(repository).completeAccepted(20L, 9L, "operator"); + verify(completionCommands).complete(900L, "worker-1"); + } + + @ParameterizedTest(name = "truck state {0} never participates in PDA completion") + @ValueSource(strings = {"NO_APPOINTMENT", "DRAFT", "IN_PROGRESS", "COMPLETED"}) + void completionWorkerIsIndependentOfEveryTruckAppointmentState(String truckState) { + assertNoTruckCoupling(); + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.review(20L)).thenReturn(null); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1); + long commandId = 920L + Math.abs(truckState.hashCode() % 20); + when(completionCommands.complete(commandId, "worker-truck-independent")).thenReturn(true); + + service.processCompletion(completionCommand(commandId, + "async-truck-independent-" + truckState.toLowerCase()), "worker-truck-independent"); + + verify(cabinetEvidenceService).requireComplete(99L); + verify(settlementService).settleOwner(20L, 50L, 9L, "operator"); + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + "PDA-async-truck-independent-" + truckState.toLowerCase() + "-50", "checked"); + verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(loadingListService).publishCompletedEvent(10L); + verify(repository).completeAccepted(20L, 9L, "operator"); + verify(completionCommands).complete(commandId, "worker-truck-independent"); + } + + @Test + void workerUsesCabinetEvidenceOnceWithReversedTwoCustomerOwnerOrder() { + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(202L, 80L), + new LoadingPickItemMapper.InboundOwner(101L, 60L), + new LoadingPickItemMapper.InboundOwner(202L, 70L), + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1); + when(completionCommands.complete(903L, "worker-multi")).thenReturn(true); + GroupSnapshot group = coloadGroup("LOCKED", 10L, + new Member(101L, 301L, 50L, "IN-50", 1), new Member(101L, 302L, 60L, "IN-60", 1), + new Member(202L, 303L, 70L, "IN-70", 1), new Member(202L, 304L, 80L, "IN-80", 1)); + when(coloadGroups.findByInbound(anyLong(), anyLong())).thenReturn(group); + + service.processCompletion(completionCommand(903L, "async-multi"), "worker-multi"); + + verify(cabinetEvidenceService, times(1)).requireComplete(99L); + verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L); + verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L); + verify(loadingDeliveryService).requirePdaCompletionReady(202L, 70L); + verify(loadingDeliveryService).requirePdaCompletionReady(202L, 80L); + verify(loadingDeliveryService).completeAfterPdaSettlement( + 202L, 70L, 9L, "operator", "PDA-async-multi-70", "checked"); + verify(settlementService, times(4)).settleOwner(eq(20L), anyLong(), eq(9L), eq("operator")); + verify(repository, times(1)).insert(argThat((LoadingPickOperationEntity operation) -> + "COMPLETE_LOADING".equals(operation.getActionType()) + && "async-multi".equals(operation.getOperationId()))); + verify(loadingListService, times(1)).publishCompletedEvent(10L); + verify(coloadGroups).consumeWholeGroup(eq(700L), eq(10L), + org.mockito.ArgumentMatchers.startsWith("PDA-CONSUME-"), eq(9L), eq("operator")); + verify(completionCommands).complete(903L, "worker-multi"); + } + + @Test + void secondCustomerOwnerReadyFailureLeavesEveryOwnerUnsettledAndUncompleted() { + assertNoTruckCoupling(); + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L), + new LoadingPickItemMapper.InboundOwner(202L, 70L))); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "第二客户门禁未就绪")) + .when(loadingDeliveryService).requirePdaCompletionReady(202L, 70L); + + assertThatThrownBy(() -> service.processCompletion( + completionCommand(904L, "async-owner-ready-fails"), "worker-failure")) + .isInstanceOf(LoadingPickService.CompletionProcessingException.class) + .satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException) error).stage()) + .isEqualTo("OWNER_READY")); + + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), + anyString(), anyString(), anyString()); + verify(completionCommands).checkpoint(904L, 101L, 50L, 1, "READY"); + verify(completionCommands, never()).checkpoint(eq(904L), anyLong(), anyLong(), anyInt(), + eq("SETTLED")); + verify(repository, never()).insert(any(LoadingPickOperationEntity.class)); + verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingListService, never()).publishCompletedEvent(anyLong()); + } + + @Test + void unpublishableCrossRegionSnapshotFailsBeforeAnyOwnerSettlement() { + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L), + new LoadingPickItemMapper.InboundOwner(101L, 60L))); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, + "一件代发现场托盘箱唛数与实装箱数不一致")) + .when(loadingListService).requireCompletedEventReady(10L); + + assertThatThrownBy(() -> service.processCompletion( + completionCommand(905L, "async-unpublishable"), "worker-unpublishable")) + .isInstanceOf(LoadingPickService.CompletionProcessingException.class) + .satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException) error).stage()) + .isEqualTo("COMPLETED_EVENT_READY")); + + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), + anyString(), anyString(), anyString()); + verify(completionCommands, never()).checkpoint(anyLong(), anyLong(), anyLong(), anyInt(), anyString()); + verify(repository, never()).insert(any(LoadingPickOperationEntity.class)); + verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingListService, never()).publishCompletedEvent(anyLong()); + } + + @Test + void workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements() { + assertNoTruckCoupling(); + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L), + new LoadingPickItemMapper.InboundOwner(202L, 60L))); + when(completionCommands.checkpoint(901L, 101L, 50L)).thenReturn("INBOUND_COMPLETED"); + when(completionCommands.checkpoint(901L, 202L, 60L)).thenReturn("SETTLED"); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1); + when(completionCommands.complete(901L, "worker-2")).thenReturn(true); + + service.processCompletion(completionCommand(901L, "async-resume"), "worker-2"); + + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingDeliveryService, never()).completeAfterPdaSettlement(eq(101L), eq(50L), anyLong(), + anyString(), anyString(), anyString()); + verify(loadingDeliveryService).completeAfterPdaSettlement(202L, 60L, 9L, "operator", + "PDA-async-resume-60", "checked"); + verify(loadingListService).publishCompletedEvent(10L); + verify(repository).insert(org.mockito.ArgumentMatchers.argThat( + operation -> "COMPLETE_LOADING".equals(operation.getActionType()))); + } + + @Test + void atomicallySubmitsCompleteBatchAndReplaysSameResult() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity picked = item(); + LoadingPickItemEntity skipped = item(); + skipped.setId(31L); + skipped.setLoadingPlanItemId(41L); + skipped.setInboundId(51L); + skipped.setInboundDetailId(61L); + List items = List.of(picked, skipped); + AtomicReference operation = new AtomicReference<>(); + when(repository.history(any(String.class))).thenReturn(null); + when(repository.operation("batch-1")).thenAnswer(invocation -> operation.get()); + doAnswer(invocation -> { + Object value = invocation.getArgument(0); + if (value instanceof LoadingPickOperationEntity entity && "BATCH".equals(entity.getActionType())) { + operation.set(entity); + } + return null; + }).when(repository).insert(any(LoadingPickOperationEntity.class)); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L)).thenReturn(items); + when(repository.items(20L, 101L)).thenReturn(items); + when(repository.pick(30L, 2, 0, 101L)).thenReturn(1); + when(repository.skip(31L, 0, 101L)).thenReturn(1); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.touchTask(20L, 0)).thenReturn(1); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(1L, 50L), + new LoadingPickItemMapper.InboundOwner(1L, 51L))); + when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation -> + List.of(allocation(invocation.getArgument(1), 2))); + var snapshot = service.inventorySnapshot(20L, 101L); + assertThat(snapshot.units()).hasSize(2); + picked.setUnitType("SEALED_PALLET"); + picked.setPalletId(801L); + picked.setPalletNo("DP-260909-0001"); + picked.setIndivisible(true); + var palletSnapshot = service.inventorySnapshot(20L, 101L); + assertThat(palletSnapshot.units().get(0)).extracting("pickItemId", "unitType", "palletId", "palletNo", "indivisible") + .containsExactly(30L, "SEALED_PALLET", 801L, "DP-260909-0001", true); + assertThat(palletSnapshot.snapshotToken()).isNotEqualTo(snapshot.snapshotToken()); + picked.setUnitType(null); + picked.setPalletId(null); + picked.setPalletNo(null); + picked.setIndivisible(null); + String snapshotToken = snapshot.snapshotToken(); + assertThat(snapshot.inbounds()).hasSize(2); + assertThat(snapshot.inbounds().get(0).resources().get(0)) + .extracting("blockId", "slotNo", "reservationId", "locationId", "availablePackageCount") + .containsExactly(71L, 1, 72L, 74L, 2); + BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of( + new BatchItemRequest(30L, "PICK", 2, null, 0, null, null), + new BatchItemRequest(31L, "SKIP", null, null, 0, "damaged carton", null))); + + service.batch(20L, "batch-1", request, 9L, "operator"); + service.batch(20L, "batch-1", request, 9L, "operator"); + + verify(repository, times(1)).pick(30L, 2, 0, 101L); + verify(repository, times(1)).skip(31L, 0, 101L); + verify(repository, times(1)).touchTask(20L, 0); + verify(settlementService, never()).settle(20L, 9L, "operator"); + verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator"); + verify(repository, times(1)).syncInboundProjections(20L, 9L, "operator"); + } + + @Test + void batchRecoversPlanResetToEditingWithoutDuplicatingExistingStartHistory() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + item.setPlannedPackageCount(100); + LoadingPlanHistoryEntity existingStart = new LoadingPlanHistoryEntity(); + existingStart.setLoadingPlanId(10L); + existingStart.setOperationId("PDA-START-20"); + existingStart.setActionType("START_LOADING"); + existingStart.setFromStatus("EDITING"); + existingStart.setToStatus("LOADING"); + when(repository.history(any(String.class))).thenReturn(null); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L)).thenReturn(List.of(item)); + when(repository.items(20L, 101L)).thenReturn(List.of(item)); + when(repository.pick(30L, 100, 0, 101L)).thenReturn(1); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.touchTask(20L, 0)).thenReturn(1); + when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1); + when(smartLoadingRepository.operation("PDA-START-20")).thenReturn(existingStart); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(1L, 50L))); + when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation -> + List.of(allocation(invocation.getArgument(1), 100))); + String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); + BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of( + new BatchItemRequest(30L, "PICK", 100, null, 0, null, null))); + + service.batch(20L, "batch-reset-plan", request, 9L, "operator"); + + verify(repository).pick(30L, 100, 0, 101L); + verify(smartLoadingRepository).startPicking(10L, 9L, "operator"); + verify(smartLoadingRepository, never()).insert(any(LoadingPlanHistoryEntity.class)); + verify(repository).touchTask(20L, 0); + } + + @Test + void rejectsWholeBatchBeforeWritingWhenAnyLineIsInvalid() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity first = item(); + LoadingPickItemEntity invalid = item(); + invalid.setId(31L); + invalid.setLoadingPlanItemId(41L); + invalid.setInboundId(51L); + invalid.setInboundDetailId(61L); + invalid.setPlannedPackageCount(4); + List items = List.of(first, invalid); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L)).thenReturn(items); + when(repository.items(20L, 101L)).thenReturn(items); + BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "stale-token", List.of( + new BatchItemRequest(30L, "PICK", 2, null, 0, null, null), + new BatchItemRequest(31L, "PARTIAL", null, 4, 0, null, null))); + + assertThatThrownBy(() -> service.batch(20L, "batch-invalid", request, 9L, "operator")) + .isInstanceOf(BizException.class); + + verify(repository, never()).pick(any(Long.class), any(Integer.class), any(Integer.class), any(Long.class)); + verify(repository, never()).partial(any(Long.class), any(Integer.class), any(Integer.class), any(Long.class)); + verify(repository, never()).touchTask(any(Long.class), any(Integer.class)); + verify(settlementService, never()).settle(20L, 9L, "operator"); + } + + private List prepareWholePalletBatch() { + var first = item(); + var second = item(); + second.setId(31L); + second.setLoadingPlanItemId(41L); + second.setInboundDetailId(61L); + var items = List.of(first, second); + for (var item : items) { + item.setUnitType("SEALED_PALLET"); item.setPalletId(801L); item.setPalletNo("DP-260909-0001"); item.setIndivisible(true); + } + when(repository.task(20L)).thenReturn(task()); + when(repository.taskForUpdate(20L)).thenReturn(task()); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L)).thenReturn(items); + when(repository.items(20L, 101L)).thenReturn(items); + when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(1L, 50L))); + when(palletAllocations.list(1L, 50L)).thenReturn(List.of(allocation(50L, 4))); + when(dropshipPallets.sealedPalletLines(801L)).thenReturn(items.stream().map(item -> { + var line = new DropshipPalletizationMapper.PalletLine(); + line.setPalletId(801L); line.setPalletNo("DP-260909-0001"); line.setPalletStatus("SEALED"); + line.setWarehouseId(101L); line.setInboundId(50L); line.setInboundDetailId(item.getInboundDetailId()); line.setCartonCount(2); + return line; + }).toList()); + return items; + } + + @Test + void batchPicksEveryLineOfSealedPalletOnceAndReplaysWithoutSecondWrite() { + prepareWholePalletBatch(); + AtomicReference operation = new AtomicReference<>(); + when(repository.operation("batch-pallet")).thenAnswer(invocation -> operation.get()); + doAnswer(invocation -> { operation.set(invocation.getArgument(0)); return null; }) + .when(repository).insert(any(LoadingPickOperationEntity.class)); + when(repository.pick(anyLong(), eq(2), eq(0), eq(101L))).thenReturn(1); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.touchTask(20L, 0)).thenReturn(1); + var snapshot = service.inventorySnapshot(20L, 101L); + assertThat(snapshot.units()).hasSize(2).allSatisfy(unit -> assertThat(unit.palletNo()).isEqualTo("DP-260909-0001")); + var request = new BatchRequest(101L, 10L, 99L, 0, snapshot.snapshotToken(), List.of( + new BatchItemRequest(30L, "PICK_PALLET", null, null, 0, null, "DP-260909-0001"))); + service.batch(20L, "batch-pallet", request, 9L, "operator"); + service.batch(20L, "batch-pallet", request, 9L, "operator"); + verify(repository, times(1)).pick(30L, 2, 0, 101L); + verify(repository, times(1)).pick(31L, 2, 0, 101L); + verify(repository, times(1)).touchTask(20L, 0); + verify(repository, times(1)).syncInboundProjections(20L, 9L, "operator"); + assertThatThrownBy(() -> service.batch(20L, "batch-pallet", new BatchRequest(101L, 10L, 99L, 0, + snapshot.snapshotToken(), List.of(new BatchItemRequest(30L, "PICK_PALLET", null, null, 0, null, "DP-260909-0999"))), 9L, "operator")) + .isInstanceOf(BizException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"missing-unit", "duplicate-pallet", "ordinary", "partial", "skip", "quantity", "wrong-code", "unsealed", "wrong-warehouse", "physical-short", "stale-item", "stale-task", "stale-snapshot"}) + void rejectsInvalidWholePalletBatchBeforeAnyWrite(String scenario) { + var items = prepareWholePalletBatch(); + String snapshot = service.inventorySnapshot(20L, 101L).snapshotToken(); + var lines = new java.util.ArrayList<>(List.of( + new BatchItemRequest(30L, "PICK_PALLET", null, null, 0, null, "DP-260909-0001"))); + switch (scenario) { + case "missing-unit" -> lines.clear(); + case "duplicate-pallet" -> lines.add(new BatchItemRequest(31L, "PICK_PALLET", null, null, 0, null, "DP-260909-0001")); + case "ordinary" -> lines.set(0, new BatchItemRequest(30L, "PICK", 2, null, 0, null, null)); + case "partial" -> lines.set(0, new BatchItemRequest(30L, "PARTIAL", null, 1, 0, null, null)); + case "skip" -> lines.set(0, new BatchItemRequest(30L, "SKIP", null, null, 0, "skip", null)); + case "quantity" -> lines.set(0, new BatchItemRequest(30L, "PICK_PALLET", 2, null, 0, null, "DP-260909-0001")); + case "wrong-code" -> lines.set(0, new BatchItemRequest(30L, "PICK_PALLET", null, null, 0, null, "DP-260909-0999")); + case "unsealed" -> dropshipPallets.sealedPalletLines(801L).get(0).setPalletStatus("OPEN"); + case "wrong-warehouse" -> dropshipPallets.sealedPalletLines(801L).get(0).setWarehouseId(999L); + case "physical-short" -> dropshipPallets.sealedPalletLines(801L).get(0).setCartonCount(1); + case "stale-item" -> items.get(1).setVersion(9); + case "stale-snapshot" -> snapshot = "0".repeat(64); + } + var request = new BatchRequest(101L, 10L, 99L, "stale-task".equals(scenario) ? 9 : 0, snapshot, lines); + assertThatThrownBy(() -> service.batch(20L, "invalid-pallet", request, 9L, "operator")).isInstanceOf(BizException.class); + verify(repository, never()).pick(anyLong(), anyInt(), anyInt(), anyLong()); + verify(repository, never()).partial(anyLong(), anyInt(), anyInt(), anyLong()); + verify(repository, never()).skip(anyLong(), anyInt(), anyLong()); + verify(repository, never()).touchTask(anyLong(), anyInt()); + } + + @Test + void rejectsChangedInventorySnapshotBeforeWritingAnyBatchLine() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity picked = item(); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L)).thenReturn(List.of(picked)); + when(repository.items(20L, 101L)).thenReturn(List.of(picked)); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(1L, 50L))); + when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation -> + List.of(allocation(invocation.getArgument(1), 2))); + BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "0".repeat(64), List.of( + new BatchItemRequest(30L, "PICK", 2, null, 0, null, null))); + + assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator")) + .isInstanceOf(BizException.class) + .hasMessageContaining("库存已变化"); + + verify(repository, never()).pick(any(Long.class), any(Integer.class), + any(Integer.class), any(Long.class)); + verify(repository, never()).touchTask(any(Long.class), any(Integer.class)); + verify(settlementService, never()).settle(20L, 9L, "operator"); + } + + @Test + void batchUsesConfirmedInboundInventoryWhenContainerLoadsAcrossWarehouses() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + item.setPlannedPackageCount(250); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L)).thenReturn(List.of(item)); + when(repository.items(20L, 101L)).thenReturn(List.of(item)); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(1L, 50L))); + when(palletAllocations.list(1L, 50L)).thenReturn(List.of(allocation(50L, 250, 202L))); + when(repository.pick(30L, 250, 0, 101L)).thenReturn(1); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.touchTask(20L, 0)).thenReturn(1); + + var snapshot = service.inventorySnapshot(20L, 101L); + assertThat(snapshot.warehouseId()).isEqualTo(101L); + assertThat(snapshot.inbounds()).singleElement().satisfies(inbound -> { + assertThat(inbound.availablePackageCount()).isEqualTo(250); + assertThat(inbound.resources()).singleElement() + .extracting("availablePackageCount") + .isEqualTo(250); + }); + + service.batch(20L, "batch-cross-warehouse", new BatchRequest( + 101L, 10L, 99L, 0, snapshot.snapshotToken(), List.of( + new BatchItemRequest(30L, "PICK", 250, null, 0, null, null))), 9L, "operator"); + + verify(repository).pick(30L, 250, 0, 101L); + verify(repository).touchTask(20L, 0); + } + + @Test + void rejectsBatchTaskVersionConflictBeforeWriting() { + LoadingPickTaskEntity task = task(); + task.setVersion(3); + when(repository.taskForUpdate(20L)).thenReturn(task); + BatchRequest request = new BatchRequest(101L, 10L, 99L, 2, "stale-token", List.of( + new BatchItemRequest(30L, "PICK", 2, null, 0, null, null))); + + assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator")) + .isInstanceOf(BizException.class); + + verify(repository, never()).items(20L); + verify(repository, never()).touchTask(any(Long.class), any(Integer.class)); + } + + @Test + void staleTaskVersionDoesNotDriftItemAndFollowingValidBatchSucceeds() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L)).thenReturn(List.of(item)); + when(repository.items(20L, 101L)).thenReturn(List.of(item)); + when(repository.pick(30L, 2, 0, 101L)).thenReturn(1); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.touchTask(20L, 0)).thenReturn(1); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(1L, 50L))); + when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation -> + List.of(allocation(invocation.getArgument(1), 2))); + String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); + BatchItemRequest line = new BatchItemRequest(30L, "PICK", 2, null, 0, null, null); + + assertThatThrownBy(() -> service.batch(20L, "batch-stale", new BatchRequest( + 101L, 10L, 99L, 1, snapshotToken, List.of(line)), 9L, "operator")) + .isInstanceOf(BizException.class) + .hasMessageContaining("任务版本已变化"); + + assertThat(item.getVersion()).isZero(); + assertThat(item.getPickedPackageCount()).isZero(); + service.batch(20L, "batch-valid", new BatchRequest( + 101L, 10L, 99L, 0, snapshotToken, List.of(line)), 9L, "operator"); + + verify(repository, times(1)).pick(30L, 2, 0, 101L); + verify(repository, times(1)).touchTask(20L, 0); + } + + @Test + void repeatedTaskBeginDoesNotChangePendingItemBeforeRejectedStaleBatch() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity pending = item(); + var planItem = new LoadingPlanItemEntity(); + planItem.setId(40L); + planItem.setInboundId(50L); + planItem.setInboundDetailId(60L); + planItem.setPackageCount(2); + when(repository.taskByPlan(10L)).thenReturn(task); + when(repository.itemByPlanItem(40L)).thenReturn(pending); + when(repository.taskForUpdate(20L)).thenReturn(task); + + service.begin(10L, "CSLU1234567", 9L, "operator", List.of(planItem)); + + BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, 1, + "stale-token", List.of( + new BatchItemRequest(30L, "PICK", 2, null, 0, null, null))); + assertThatThrownBy(() -> service.batch(20L, "batch-stale-after-resync", staleRequest, + 9L, "operator")).isInstanceOf(BizException.class); + + assertThat(pending.getVersion()).isZero(); + assertThat(pending.getPickedPackageCount()).isZero(); + assertThat(pending.getItemStatus()).isEqualTo("PENDING"); + verify(repository, never()).update(any(LoadingPickItemEntity.class)); + verify(repository, never()).pick(any(Long.class), any(Integer.class), + any(Integer.class), any(Long.class)); + verify(repository, never()).touchTask(any(Long.class), any(Integer.class)); + } + + private static void assertNoTruckCoupling() { + assertThat(LoadingPickService.class.getDeclaredFields()) + .noneMatch(field -> field.getType().equals(TruckAppointmentService.class)); + assertThat(LoadingPickService.class.getDeclaredMethods()) + .noneMatch(method -> method.getName().equals("completeTruckAfterCommit") + || method.getName().equals("completeLoadingFromPda")); + } + + private void preparePick(LoadingPickTaskEntity task, LoadingPickItemEntity item, String operationId) { + when(repository.history(operationId)).thenReturn(null); + when(repository.task(20L)).thenReturn(task); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.item(20L, 30L, 101L)).thenReturn(item); + when(repository.pick(30L, 2, 0, 101L)).thenReturn(1); + when(repository.touchTask(20L, 0)).thenReturn(1); + when(repository.items(20L, 101L)).thenReturn(List.of(item)); + } + + private void preparePartial(LoadingPickTaskEntity task, LoadingPickItemEntity item, String operationId) { + when(repository.history(operationId)).thenReturn(null); + when(repository.task(20L)).thenReturn(task); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.item(20L, 30L, 101L)).thenReturn(item); + when(repository.partial(30L, 1, 0, 101L)).thenReturn(1); + when(repository.touchTask(20L, 0)).thenReturn(1); + when(repository.items(20L, 101L)).thenReturn(List.of(item)); + } + + private static com.baiyue.firstleg.entity.LoadingCompletionCommandEntity completionCommand( + long id, String operationId) { + var command = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity(); + command.setId(id); + command.setPickTaskId(20L); + command.setOperationId(operationId); + command.setRequestHash("hash"); + command.setCommandStatus("PROCESSING"); + command.setOperatorId(9L); + command.setOperatorName("operator"); + command.setRemark("checked"); + command.setAttemptCount(1); + return command; + } + + private static LoadingPickTaskEntity task() { + LoadingPickTaskEntity task = new LoadingPickTaskEntity(); + task.setId(20L); + task.setLoadingPlanId(10L); + task.setContainerBookingId(99L); + task.setContainerNo("CSLU1234567"); + task.setTaskStatus("LOADING"); + task.setVersion(0); + return task; + } + + private static LoadingPickItemEntity item() { + LoadingPickItemEntity item = new LoadingPickItemEntity(); + item.setId(30L); + item.setPickTaskId(20L); + item.setLoadingPlanItemId(40L); + item.setInboundId(50L); + item.setInboundDetailId(60L); + item.setPlannedPackageCount(2); + item.setPickedPackageCount(0); + item.setItemStatus("PENDING"); + item.setVersion(0); + return item; + } + + private static InboundPalletAllocationEntity allocation(long inboundId, int packageCount) { + return allocation(inboundId, packageCount, 101L); + } + + private static InboundPalletAllocationEntity allocation(long inboundId, int packageCount, + long warehouseId) { + InboundPalletAllocationEntity allocation = new InboundPalletAllocationEntity(); + allocation.setId(72L); + allocation.setTenantId(1L); + allocation.setInboundId(inboundId); + allocation.setBlockId(71L); + allocation.setSlotNo(1); + allocation.setAllocationNo(1); + allocation.setWarehouseId(warehouseId); + allocation.setPalletId(73L); + allocation.setLocationId(74L); + allocation.setLocationCode("A-01"); + allocation.setSharedOverflow(false); + allocation.setReservationId(72L); + allocation.setSlotStatus("BOUND"); + allocation.setAllocationStatus("CONFIRMED"); + allocation.setAllocatedPackageCount(packageCount); + allocation.setPickedPackageCount(0); + return allocation; + } + + private static LoadingPickAssignmentEntity assignment() { + LoadingPickAssignmentEntity assignment = new LoadingPickAssignmentEntity(); + assignment.setPickTaskId(20L); + assignment.setAssigneeId(9L); + assignment.setAssigneeName("operator"); + assignment.setVersion(0); + return assignment; + } + + private static LoadingPickOperationEntity taskOperation(String operationId, String action) { + LoadingPickOperationEntity operation = new LoadingPickOperationEntity(); + operation.setPickTaskId(20L); + operation.setOperationId(operationId); + operation.setRequestHash(hashForTest(action + "|20|0")); + if ("ASSIGN".equals(action)) { + operation.setRequestHash(hashForTest("ASSIGN|20|11|picker|0")); + } + operation.setActionType(action); + operation.setOperatorId(9L); + operation.setOperatorName("operator"); + return operation; + } + + private static LoadingPickHistoryEntity itemHistory(String operationId, String action, String canonical) { + LoadingPickHistoryEntity history = new LoadingPickHistoryEntity(); + history.setPickTaskId(20L); + history.setPickItemId(30L); + history.setOperationId(operationId); + history.setRequestHash(hashForTest(canonical)); + history.setActionType(action); + history.setOperatorId(9L); + history.setOperatorName("operator"); + return history; + } + + private static String hashForTest(String value) { + try { + return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private static GroupSnapshot coloadGroup(String status, Long lockedPlanId, Member... members) { + GroupSnapshot group = mock(GroupSnapshot.class); + when(group.groupId()).thenReturn(700L); + when(group.status()).thenReturn(status); + when(group.lockedPlanId()).thenReturn(lockedPlanId); + when(group.members()).thenReturn(List.of(members)); + when(group.fullPackageCount()).thenReturn(List.of(members).stream() + .mapToInt(Member::fullPackageCount).sum()); + return group; + } + +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java new file mode 100644 index 000000000..34c2f2a2a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -0,0 +1,560 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.MeasurementBatchCommand; +import com.baiyue.firstleg.domain.CustomerDimensionBoxType; +import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; +import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; +import com.baiyue.firstleg.repository.FbaPricingRepository; +import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +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.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("测量批次共享领域服务") +@SpringJUnitConfig({MeasurementBatchService.class, CustomerDimensionWeightingService.class}) +class MeasurementBatchServiceTest { + @MockBean private InboundMeasurementRepository repository; + @MockBean private CustomerDimensionConfigRepository dimensionConfigs; + @MockBean private FbaPricingRepository pricingRepository; + @Resource private MeasurementBatchService service; + + @BeforeEach + void setUp() { + when(repository.findInbound(1L, 10L)).thenReturn(inbound()); + when(dimensionConfigs.findAll(1L)).thenReturn(dimensionRules(1L)); + org.mockito.Mockito.doAnswer(invocation -> { + InboundMeasurementEntity entity = invocation.getArgument(0); + entity.setId(100L); + return null; + }).when(repository).insertMeasurement(any()); + } + + @Test + void manualAndExternalUseSameTablesAndExternalSaveNeverAdvancesInbound() { + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "THIRD_PARTY", "DEVICE_VENDOR", "EVENT-001", + "D-01", "YW-01", LocalDateTime.now(), "{}", null, null, null, + null, 0L, List.of(item(null))); + + InboundMeasurementEntity saved = service.saveBatch(command); + + assertThat(saved.getSourceType()).isEqualTo("THIRD_PARTY"); + assertThat(saved.getStatus()).isEqualTo("RECEIVED"); + assertThat(saved.getTotalPackageCount()).isEqualTo(2); + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + assertThat(items.getValue().get(0).getUnitVolumeCbm()).isEqualByComparingTo("0.0267"); + assertThat(items.getValue().get(0).getTotalVolumeCbm()).isEqualByComparingTo("0.0534"); + assertThat(items.getValue().get(0).getOuterLengthCm()).isEqualByComparingTo("41.000"); + assertThat(items.getValue().get(0).getOuterWidthCm()).isEqualByComparingTo("31.000"); + assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("21.000"); + assertThat(items.getValue().get(0).getCalculationRuleVersion()) + .isEqualTo("WAREHOUSE_OUTER_OVERRIDE_V1"); + assertThat(items.getValue().get(0).getCoefficientMode()).isNull(); + assertThat(items.getValue().get(0).getChargeRuleId()).isNull(); + assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("145.000"); + verify(pricingRepository).findDefaultRule(1L, 1L); + verify(repository, never()).updateInbound(any()); + } + + @Test + void manualDraftCalculatesVolumeAndVolumeWeightOnServer() { + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, "CKW-1", new BigDecimal("20"), + new BigDecimal("0.1"), "服装", 9L, List.of(item(new BigDecimal("3.25")))); + + InboundMeasurementEntity saved = service.saveBatch(command); + + assertThat(saved.getStatus()).isEqualTo("DRAFT"); + assertThat(saved.getTotalVolumeCbm()).isEqualByComparingTo("0.0534"); + assertThat(saved.getVolumeWeightKg()).isEqualByComparingTo("8.918"); + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L); + } + + @Test + void recalculatesSubmittedItemsInPlaceWithoutChangingTheirIdentityOrStatus() { + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(100L); + measurement.setTenantId(1L); + measurement.setInboundId(10L); + measurement.setSourceType("MANUAL"); + measurement.setStatus("SUBMITTED"); + measurement.setVersion(4); + measurement.setCreatedBy(9L); + InboundMeasurementItemEntity existing = new InboundMeasurementItemEntity(); + existing.setId(200L); + existing.setTenantId(1L); + existing.setMeasurementId(100L); + existing.setInboundDetailId(80001L); + existing.setBoxCode("RK-001#1"); + existing.setItemNo("001"); + existing.setBoxSpecification("40x30x20cm"); + existing.setPackageCount(2); + existing.setLengthCm(new BigDecimal("40")); + existing.setWidthCm(new BigDecimal("30")); + existing.setHeightCm(new BigDecimal("20")); + when(repository.updateItem(existing)).thenReturn(true); + + service.recalculateStrategy(1L, measurement, List.of(existing), "ACTUAL", null); + + assertThat(existing.getId()).isEqualTo(200L); + assertThat(existing.getBoxCode()).isEqualTo("RK-001#1"); + assertThat(existing.getOuterLengthCm()).isEqualByComparingTo("40.000"); + assertThat(measurement.getStatus()).isEqualTo("SUBMITTED"); + assertThat(measurement.getTotalVolumeCbm()).isEqualByComparingTo("0.0480"); + verify(repository).updateItem(existing); + verify(repository, never()).replaceItems(anyLong(), anyLong(), any()); + } + + @Test + void recalculatesOneSubmittedWarehouseOuterDimensionRowInPlace() { + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(100L); + measurement.setTenantId(1L); + measurement.setInboundId(10L); + measurement.setSourceType("MANUAL"); + measurement.setStatus("SUBMITTED"); + measurement.setVersion(4); + measurement.setCreatedBy(9L); + InboundMeasurementItemEntity existing = new InboundMeasurementItemEntity(); + existing.setId(200L); + existing.setTenantId(1L); + existing.setMeasurementId(100L); + existing.setInboundDetailId(80001L); + existing.setBoxCode("RK-001#1"); + existing.setItemNo("001"); + existing.setBoxSpecification("40x30x20cm"); + existing.setPackageCount(2); + existing.setLengthCm(new BigDecimal("40")); + existing.setWidthCm(new BigDecimal("30")); + existing.setHeightCm(new BigDecimal("20")); + when(repository.updateItem(existing)).thenReturn(true); + + service.recalculateOuterDimensions(1L, measurement, List.of(existing), 200L, + new BigDecimal("30"), new BigDecimal("20"), new BigDecimal("20")); + + assertThat(existing.getId()).isEqualTo(200L); + assertThat(existing.getBoxCode()).isEqualTo("RK-001#1"); + assertThat(existing.getManualOuterLengthCm()).isEqualByComparingTo("30"); + assertThat(existing.getManualOuterWidthCm()).isEqualByComparingTo("20"); + assertThat(existing.getManualOuterHeightCm()).isEqualByComparingTo("20"); + assertThat(existing.getOuterLengthCm()).isEqualByComparingTo("30.000"); + assertThat(existing.getUnitVolumeCbm()).isEqualByComparingTo("0.0120"); + assertThat(measurement.getStatus()).isEqualTo("SUBMITTED"); + assertThat(measurement.getTotalVolumeCbm()).isEqualByComparingTo("0.0240"); + verify(repository).updateItem(existing); + verify(repository, never()).replaceItems(anyLong(), anyLong(), any()); + } + + @Test + void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() { + CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); + rule.setId(77L); + rule.setTenantId(1L); + rule.setCustomerId(1L); + rule.setRuleCode("DEFAULT"); + rule.setRuleVersion(2); + rule.setStatus("ACTIVE"); + rule.setDefaultRule(true); + rule.setLengthFactor(new BigDecimal("1.1")); + rule.setWidthFactor(new BigDecimal("1.1")); + rule.setHeightFactor(new BigDecimal("1.1")); + when(pricingRepository.findDefaultRule(1L, 1L)).thenReturn(rule); + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), + new BigDecimal("20"), 500L, null, null, null, null, null, + null, "备注"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)); + + service.saveBatch(command); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity calculated = items.getValue().get(0); + assertThat(calculated.getChargeRuleId()).isEqualTo(77L); + assertThat(calculated.getCoefficientMode()).isEqualTo("MULTIPLY"); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("44.000"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("33.000"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("22.000"); + assertThat(calculated.getGirthCm()).isEqualByComparingTo("154.000"); + assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0320"); + assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.0640"); + } + + @Test + void usesOneMatchedTierCoefficientForAllDimensionsOfEachSpecification() { + CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); + rule.setId(78L); + rule.setTenantId(1L); + rule.setCustomerId(1L); + rule.setRuleCode("TIERED"); + rule.setRuleVersion(3); + rule.setStatus("ACTIVE"); + rule.setDefaultRule(true); + rule.setLengthFactor(new BigDecimal("1.05")); + rule.setWidthFactor(new BigDecimal("1.25")); + rule.setHeightFactor(new BigDecimal("1.35")); + rule.setRemark("SMALL:30,20,20;系数:1.1;小箱:—;MEDIUM:40,30,30;系数:1.2;中箱:—;" + + "LARGE:50,40,40;系数:1.3;大箱:—;EXTRA_LARGE:60,50,50;系数:1.4;特大箱:—"); + when(pricingRepository.findDefaultRule(1L, 1L)).thenReturn(rule); + MeasurementBatchCommand.Item small = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 1, new BigDecimal("10"), new BigDecimal("10"), + new BigDecimal("10"), 500L, null, null, null, null, null, null, "低于最小档"); + MeasurementBatchCommand.Item medium = new MeasurementBatchCommand.Item( + 80002L, "002", "B", 1, new BigDecimal("31"), new BigDecimal("20"), + new BigDecimal("20"), 501L, null, null, null, null, null, null, "中箱"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(small, medium)); + + service.saveBatch(command); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + assertThat(items.getValue().get(0).getOuterLengthCm()).isEqualByComparingTo("11.000"); + assertThat(items.getValue().get(0).getOuterWidthCm()).isEqualByComparingTo("11.000"); + assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("11.000"); + assertThat(items.getValue().get(1).getOuterLengthCm()).isEqualByComparingTo("37.200"); + assertThat(items.getValue().get(1).getOuterWidthCm()).isEqualByComparingTo("24.000"); + assertThat(items.getValue().get(1).getOuterHeightCm()).isEqualByComparingTo("24.000"); + } + + @Test + void keepsActualMeasurementUnchangedWhenCustomerHasNoDefaultRule() { + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), + new BigDecimal("20"), 500L, null, null, null, null, null, null, "备注"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)); + + service.saveBatch(command); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity calculated = items.getValue().get(0); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("40.000"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("30.000"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("20.000"); + assertThat(calculated.getChargeRuleId()).isNull(); + assertThat(calculated.getCoefficientMode()).isNull(); + assertThat(calculated.getCalculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1"); + } + + @Test + void addsMatchedEdgeValueToMeasuredDimensionsInsteadOfRuleBoundary() { + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 2, new BigDecimal("59.5"), new BigDecimal("41.2"), + new BigDecimal("31.3"), 500L, null, null, null, null, null, "ADD", "备注"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)); + + service.saveBatch(command); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity calculated = items.getValue().get(0); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("63.500"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("45.200"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("35.300"); + assertThat(calculated.getGirthCm()).isEqualByComparingTo("224.500"); + assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.101"); + assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.2020"); + } + + @Test + void dimensionsAboveLargestTierAddLargestTierValueToMeasuredDimensions() { + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 2, new BigDecimal("60.001"), new BigDecimal("50"), + new BigDecimal("50"), 500L, null, null, null, null, null, "ADD", "超过最大箱规"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)); + + service.saveBatch(command); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity calculated = items.getValue().get(0); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("64.001"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("54.000"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("54.000"); + assertThat(calculated.getGirthCm()).isEqualByComparingTo("280.001"); + assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.187"); + assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.3740"); + assertThat(calculated.getCalculationRuleVersion()) + .isEqualTo("CUSTOMER_DIMENSION_EXTRA_LARGE_V0"); + } + + @Test + void warehouseOuterDimensionsBecomeBaseValuesBeforeAddStrategy() { + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), + new BigDecimal("20"), 500L, null, new BigDecimal("50"), + new BigDecimal("40"), new BigDecimal("30"), null, "ADD", "仓库修正"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)); + + service.saveBatch(command); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity calculated = items.getValue().get(0); + assertThat(calculated.getManualOuterLengthCm()).isEqualByComparingTo("50"); + assertThat(calculated.getManualOuterWidthCm()).isEqualByComparingTo("40"); + assertThat(calculated.getManualOuterHeightCm()).isEqualByComparingTo("30"); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("54.000"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("44.000"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("34.000"); + assertThat(calculated.getGirthCm()).isEqualByComparingTo("210.000"); + assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0808"); + assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.1616"); + assertThat(calculated.getVolumeWeightKg()).isEqualByComparingTo("26.988"); + assertThat(calculated.getCalculationRuleVersion()).isEqualTo("WAREHOUSE_OUTER_OVERRIDE_V1"); + } + + @Test + void warehouseCanOverrideOnlyOneOuterDimension() { + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), + new BigDecimal("20"), 500L, null, new BigDecimal("50"), + null, null, null, "ADD", "仅修改长度"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)); + + service.saveBatch(command); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity calculated = items.getValue().get(0); + assertThat(calculated.getManualOuterLengthCm()).isEqualByComparingTo("50"); + assertThat(calculated.getManualOuterWidthCm()).isNull(); + assertThat(calculated.getManualOuterHeightCm()).isNull(); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("54.000"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("34.000"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("24.000"); + assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0441"); + assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.0882"); + assertThat(calculated.getGirthCm()).isEqualByComparingTo("170.000"); + assertThat(calculated.getCalculationRuleVersion()).isEqualTo("WAREHOUSE_OUTER_OVERRIDE_L_V1"); + } + + @Test + void actualStrategyUsesEachWarehouseValueAndFillsBlankDimensionsFromMeasurement() { + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 1, new BigDecimal("61.1"), new BigDecimal("41.2"), + new BigDecimal("31.3"), 500L, null, new BigDecimal("11"), + null, new BigDecimal("11"), null, "ACTUAL", "宽未填写"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)); + + service.saveBatch(command); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity calculated = items.getValue().get(0); + assertThat(calculated.getManualOuterLengthCm()).isEqualByComparingTo("11"); + assertThat(calculated.getManualOuterWidthCm()).isNull(); + assertThat(calculated.getManualOuterHeightCm()).isEqualByComparingTo("11"); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("11.000"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("41.200"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("11.000"); + assertThat(calculated.getCoefficientMode()).isEqualTo("ACTUAL"); + assertThat(calculated.getCalculationRuleVersion()).isEqualTo("WAREHOUSE_OUTER_OVERRIDE_LH_V1"); + } + + @Test + void warehouseOverrideUsesTheTierMatchedByItsBaseDimensionsBeforeMultiplyStrategy() { + CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); + rule.setId(79L); + rule.setTenantId(1L); + rule.setCustomerId(1L); + rule.setRuleCode("TIERED-OVERRIDE"); + rule.setRuleVersion(1); + rule.setStatus("ACTIVE"); + rule.setDefaultRule(true); + rule.setLengthFactor(new BigDecimal("1.1")); + rule.setWidthFactor(new BigDecimal("1.1")); + rule.setHeightFactor(new BigDecimal("1.1")); + rule.setRemark("SMALL:30,20,20;系数:1.1;小箱:—;MEDIUM:40,30,30;系数:1.2;中箱:—"); + when(pricingRepository.findDefaultRule(1L, 1L)).thenReturn(rule); + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 1, new BigDecimal("30"), new BigDecimal("20"), + new BigDecimal("20"), 500L, null, new BigDecimal("31"), + null, null, null, "MULTIPLY", "仅修改长度"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)); + + service.saveBatch(command); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity calculated = items.getValue().get(0); + assertThat(calculated.getManualOuterLengthCm()).isEqualByComparingTo("31"); + assertThat(calculated.getManualOuterWidthCm()).isNull(); + assertThat(calculated.getManualOuterHeightCm()).isNull(); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("37.200"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("24.000"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("24.000"); + assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0215"); + assertThat(calculated.getGirthCm()).isEqualByComparingTo("133.200"); + assertThat(calculated.getChargeRuleId()).isEqualTo(79L); + assertThat(calculated.getCalculationRuleVersion()).isEqualTo("WAREHOUSE_OUTER_OVERRIDE_L_V1"); + } + + @Test + void staleDraftVersionIsRejectedBeforeReplacingItems() { + InboundMeasurementEntity existing = new InboundMeasurementEntity(); + existing.setId(100L); + existing.setTenantId(1L); + existing.setInboundId(10L); + existing.setSourceType("MANUAL"); + existing.setStatus("DRAFT"); + existing.setVersion(3); + when(repository.findMeasurement(1L, 10L, 100L)).thenReturn(existing); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, 100L, 2, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, + List.of(item(BigDecimal.ONE))); + + assertThatThrownBy(() -> service.saveBatch(command)) + .isInstanceOf(BizException.class) + .hasMessageContaining("其他人修改"); + verify(repository, never()).replaceItems( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any()); + } + + @Test + void submittedMeasurementCannotBeResetToDraftWhileReviewIsPending() { + InboundMeasurementEntity existing = new InboundMeasurementEntity(); + existing.setId(100L); + existing.setTenantId(1L); + existing.setInboundId(10L); + existing.setSourceType("MANUAL"); + existing.setStatus("SUBMITTED"); + existing.setVersion(3); + InboundOrderEntity inbound = inbound(); + inbound.setProcessStatus("MEASURE_PENDING"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findMeasurement(1L, 10L, 100L)).thenReturn(existing); + + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, 100L, 3, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, "CKW-2", new BigDecimal("21"), + new BigDecimal("0.2"), "服装", 9L, List.of(item(BigDecimal.ONE))); + + assertThatThrownBy(() -> service.saveBatch(command)) + .isInstanceOf(BizException.class) + .hasMessageContaining("已提交批次必须保持冻结"); + assertThat(existing.getStatus()).isEqualTo("SUBMITTED"); + verify(repository, never()).updateMeasurement(any()); + verify(repository, never()).replaceItems( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any()); + } + + @Test + void manualDraftKeepsExactScannedCartonTraceability() { + InboundMeasurementEntity existing = new InboundMeasurementEntity(); + existing.setId(100L); + existing.setTenantId(1L); + existing.setInboundId(10L); + existing.setStatus("RECEIVED"); + existing.setVersion(3); + InboundMeasurementItemEntity scanned = new InboundMeasurementItemEntity(); + scanned.setInboundDetailId(80001L); + scanned.setItemNo("001"); + scanned.setExternalDetailNo("INB-001#1-1"); + scanned.setBoxCode("INB-001#1-1"); + scanned.setScannedShipmentId("FBA-001"); + scanned.setMachineNo("MJ-01"); + scanned.setDeviceMeasuredAt(LocalDateTime.of(2026, 9, 2, 10, 0)); + when(repository.findMeasurement(1L, 10L, 100L)).thenReturn(existing); + when(repository.updateMeasurement(existing)).thenReturn(true); + when(repository.listItems(1L, 100L)).thenReturn(List.of(scanned)); + + service.saveBatch(new MeasurementBatchCommand( + 1L, 10L, 100L, 3, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(item(BigDecimal.ONE)))); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity saved = items.getValue().get(0); + assertThat(saved.getBoxCode()).isEqualTo("INB-001#1-1"); + assertThat(saved.getExternalDetailNo()).isEqualTo("INB-001#1-1"); + assertThat(saved.getScannedShipmentId()).isEqualTo("FBA-001"); + assertThat(saved.getMachineNo()).isEqualTo("MJ-01"); + assertThat(saved.getDeviceMeasuredAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0)); + } + + private MeasurementBatchCommand.Item item(BigDecimal unitWeight) { + return new MeasurementBatchCommand.Item( + 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), + new BigDecimal("20"), 500L, null, + new BigDecimal("41"), new BigDecimal("31"), new BigDecimal("21"), "备注"); + } + + private InboundOrderEntity inbound() { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(10L); + inbound.setTenantId(1L); + inbound.setCustomerId(null); + inbound.setProcessStatus("WAIT_MEASURE"); + inbound.setVersion(0); + return inbound; + } + + private List dimensionRules(long tenantId) { + return java.util.Arrays.stream(CustomerDimensionBoxType.values()).map(type -> { + CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity(); + rule.setTenantId(tenantId); rule.setBoxType(type); rule.setVersion(0); + rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm()); + rule.setInnerHeightCm(type.defaultHeightCm()); + rule.setEdgeAdditionCm(new BigDecimal("2.000")); + return rule; + }).toList(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java new file mode 100644 index 000000000..e9932f854 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java @@ -0,0 +1,448 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.client.WarehousePalletLocationClient; +import com.baiyue.firstleg.config.InboundProperties; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; +import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; +import com.baiyue.firstleg.repository.InboundWorkflowRepository; +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.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +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.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(classes = {MeasurementLocationOccupationService.class, + MeasurementLocationOccupationServiceTest.TestConfig.class}) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class MeasurementLocationOccupationServiceTest { + private static final long BUSINESS_TENANT = 10001L; + private static final long PLATFORM_RESOURCE_TENANT = 0L; + private static final long WAREHOUSE = 200L; + private static final long INBOUND = 88L; + private static final long OPERATOR = 9L; + private static final long MEASUREMENT = 30L; + + @MockBean private WarehousePalletLocationClient warehouse; + @MockBean private InboundPalletAllocationRepository allocations; + @MockBean private InboundWorkflowRepository workflow; + @MockBean private InboundMeasurementRepository measurements; + @MockBean private OrderCargoClient orderCargo; + @Resource private InboundProperties properties; + @Resource private MeasurementLocationOccupationService service; + + @Test + void platformWarehouseResourcesUseZeroWhileSnapshotsRemainInBusinessTenant() { + properties.setWarehouseResourceTenantId(90001L); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights()); + when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3))); + when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3))); + + service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3); + + ArgumentCaptor reserve = + ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReserveRequest.class); + verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture()); + assertThat(reserve.getValue().businessId()).isEqualTo(INBOUND); + assertThat(reserve.getValue().idempotencyKey()).startsWith("measure-block-88-30-3-"); + assertThat(reserve.getValue().requiredSlotCount()).isEqualTo(3); + + ArgumentCaptor confirm = + ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockConfirmRequest.class); + verify(warehouse).confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture()); + assertThat(confirm.getValue().businessId()).isEqualTo(INBOUND); + assertThat(confirm.getValue().blockId()).isEqualTo(500L); + assertThat(confirm.getValue().idempotencyKey()) + .isEqualTo(reserve.getValue().idempotencyKey() + "-confirm"); + + verify(allocations).replaceBlock( + org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT), + org.mockito.ArgumentMatchers.eq(INBOUND), + org.mockito.ArgumentMatchers.eq(WAREHOUSE), + org.mockito.ArgumentMatchers.eq(20), + org.mockito.ArgumentMatchers.eq(new BigDecimal("1.0000")), + org.mockito.ArgumentMatchers.any()); + verify(allocations).bindConfirmedWeights( + org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT), + org.mockito.ArgumentMatchers.eq(INBOUND), org.mockito.ArgumentMatchers.anyList()); + } + + @Test + void defaultResourceTenantRemainsPlatformZero() { + properties.setWarehouseResourceTenantId(0L); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights()); + when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3))); + when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3))); + + service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3); + + ArgumentCaptor reserve = + ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReserveRequest.class); + verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture()); + assertThat(reserve.getValue().warehouseId()).isEqualTo(WAREHOUSE); + + ArgumentCaptor confirm = + ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockConfirmRequest.class); + verify(warehouse).confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture()); + assertThat(confirm.getValue().businessId()).isEqualTo(INBOUND); + + verify(allocations).replaceBlock( + org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT), + org.mockito.ArgumentMatchers.eq(INBOUND), + org.mockito.ArgumentMatchers.eq(WAREHOUSE), + org.mockito.ArgumentMatchers.eq(20), + org.mockito.ArgumentMatchers.eq(new BigDecimal("1.0000")), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void reservesThreeSlotsForVolumeWhileBindingOnlyTwoConfirmedWeightImages() { + InboundOrderEntity inbound = inbound(); + inbound.setActualVolumeCbm(new BigDecimal("2.5000")); + List twoWeights = confirmedWeights().subList(0, 2); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(twoWeights); + when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3))); + when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3))); + + service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3); + + verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.argThat(request -> request.requiredSlotCount() == 3)); + verify(allocations).bindConfirmedWeights(BUSINESS_TENANT, INBOUND, twoWeights); + } + + @Test + void reservesByCustomerDeclaredVolumeBeforeMeasuredVolumeIsFrozen() { + InboundOrderEntity inbound = inbound(); + inbound.setActualVolumeCbm(null); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setTotalVolumeCbm(new BigDecimal("1.0000")); + measurement.setOriginalVolumeCbm(new BigDecimal("2.5000")); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound); + when(measurements.findLatestMeasurement(BUSINESS_TENANT, INBOUND)).thenReturn(measurement); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)) + .thenReturn(confirmedWeights().subList(0, 2)); + when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3))); + when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3))); + + service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3); + + verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.argThat(request -> request.requiredSlotCount() == 3)); + verify(allocations).replaceBlock(BUSINESS_TENANT, INBOUND, WAREHOUSE, + 20, new BigDecimal("2.5000"), block("OCCUPIED", 3)); + } + + @Test + void reservesByCurrentMeasuredVolumeBeforeFrozenDeclaredVolume() { + InboundOrderEntity inbound = inbound(); + inbound.setActualVolumeCbm(null); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setTotalVolumeCbm(new BigDecimal("2.5000")); + measurement.setOriginalVolumeCbm(new BigDecimal("1.0000")); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound); + when(measurements.findLatestMeasurement(BUSINESS_TENANT, INBOUND)).thenReturn(measurement); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)) + .thenReturn(confirmedWeights().subList(0, 1)); + when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3))); + when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3))); + + service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3); + + verify(orderCargo, never()).measurementDeclaration(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); + verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.argThat(request -> request.requiredSlotCount() == 3)); + verify(allocations).replaceBlock(BUSINESS_TENANT, INBOUND, WAREHOUSE, + 20, new BigDecimal("2.5000"), block("OCCUPIED", 3)); + } + + @Test + void fallsBackToLatestOrderDeclarationWhenNoMeasurementBatchExists() { + InboundOrderEntity inbound = inbound(); + inbound.setOrderId(77L); + inbound.setActualVolumeCbm(null); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound); + when(measurements.findLatestMeasurement(BUSINESS_TENANT, INBOUND)).thenReturn(null); + when(orderCargo.measurementDeclaration("internal-token", BUSINESS_TENANT, inbound.getOrderId())) + .thenReturn(ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot( + "REF-88", new BigDecimal("100"), new BigDecimal("6.5000"), "货物"))); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)) + .thenReturn(confirmedWeights().subList(0, 1)); + when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 6))); + when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 6))); + + service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 1); + + verify(orderCargo).measurementDeclaration("internal-token", BUSINESS_TENANT, inbound.getOrderId()); + verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.argThat(request -> request.requiredSlotCount() == 6)); + verify(allocations).replaceBlock(BUSINESS_TENANT, INBOUND, WAREHOUSE, + 20, new BigDecimal("6.5000"), block("OCCUPIED", 6)); + } + + @Test + void rejectsConfirmedWeightWithoutConfirmationTimeInsteadOfDroppingIt() { + InboundWeightResultEntity weight = confirmedWeights().get(0); + weight.setConfirmedAt(null); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight)); + + assertThatThrownBy(() -> service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3)) + .isInstanceOf(BizException.class) + .hasMessageContaining("缺少确认时间"); + + verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + } + + @Test + void rejectsConfirmedWeightWithoutNetWeightInsteadOfDroppingIt() { + InboundWeightResultEntity weight = confirmedWeights().get(0); + weight.setNetWeightKg(null); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight)); + + assertThatThrownBy(() -> service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3)) + .isInstanceOf(BizException.class) + .hasMessageContaining("缺少有效净重"); + + verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + } + + @Test + void rejectsConfirmedWeightWithoutPalletInsteadOfDroppingIt() { + InboundWeightResultEntity weight = confirmedWeights().get(0); + weight.setPalletId(null); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight)); + + assertThatThrownBy(() -> service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3)) + .isInstanceOf(BizException.class) + .hasMessageContaining("缺少有效托盘"); + + verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + } + + @Test + void confirmFailureReleasesReservationInResourceTenantAndKeepsOriginalFailure() { + properties.setWarehouseResourceTenantId(90001L); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights()); + when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3))); + IllegalStateException failure = new IllegalStateException("confirm failed"); + when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenThrow(failure); + when(warehouse.releaseBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RELEASED", 3))); + + assertThatThrownBy(() -> service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3)) + .isSameAs(failure); + + ArgumentCaptor release = + ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReleaseRequest.class); + verify(warehouse).releaseBlock(org.mockito.ArgumentMatchers.eq("internal-token"), release.capture()); + assertThat(release.getValue().businessId()).isEqualTo(INBOUND); + verify(allocations, never()).replaceBlock(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void expandsOccupiedBlockWithoutChangingExistingSlotsWhenConfirmedCountGrows() { + InboundOrderEntity inbound = inbound(); + List weights = confirmedWeights(); + List current = + java.util.stream.IntStream.rangeClosed(1, 2).mapToObj(MeasurementLocationOccupationServiceTest::slot) + .toList(); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(weights); + when(allocations.list(BUSINESS_TENANT, INBOUND)).thenReturn(current); + when(warehouse.expandBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3))); + + service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, 803L, 1); + + verify(warehouse).expandBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.argThat(request -> request.blockId() == 500L + && request.businessId() == INBOUND && request.requiredSlotCount() == 3 + && request.idempotencyKey().equals("measure-block-expand-88-803-1"))); + verify(allocations).replaceBlock(BUSINESS_TENANT, INBOUND, WAREHOUSE, + 20, new BigDecimal("1.0000"), block("OCCUPIED", 3)); + verify(allocations).bindConfirmedWeights(BUSINESS_TENANT, INBOUND, weights); + } + + @Test + void submitValidationReusesExistingBoundBlockWithoutWarehouseMutation() { + List weights = confirmedWeights().subList(0, 2); + var first = slot(1); + var second = slot(2); + first.setWeightResultId(weights.get(0).getId()); + second.setWeightResultId(weights.get(1).getId()); + first.setSlotStatus("BOUND"); + second.setSlotStatus("BOUND"); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound()); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(weights); + when(allocations.list(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(first, second)); + + service.ensureForSubmission(BUSINESS_TENANT, INBOUND, OPERATOR); + + verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any()); + verify(warehouse, never()).confirmBlock(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any()); + verify(warehouse, never()).expandBlock(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void submissionReconcilesMissingBlockFromLatestConfirmedWeight() { + InboundOrderEntity inbound = inbound(); + inbound.setActualVolumeCbm(new BigDecimal("2.5000")); + List weights = confirmedWeights().subList(0, 1); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(weights); + when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3))); + when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3))); + when(allocations.list(BUSINESS_TENANT, INBOUND)) + .thenReturn(List.of(), blockSlotsBoundTo(weights)); + + service.ensureForSubmission(BUSINESS_TENANT, INBOUND, OPERATOR); + + verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.argThat(request -> request.requiredSlotCount() == 3)); + verify(allocations).bindConfirmedWeights(BUSINESS_TENANT, INBOUND, weights); + } + + @Test + void submissionExpandsUndersizedBlockWhenMeasuredVolumeGrows() { + InboundOrderEntity inbound = inbound(); + inbound.setActualVolumeCbm(new BigDecimal("2.5000")); + List weights = confirmedWeights().subList(0, 1); + var current = slot(1); + current.setWeightResultId(weights.get(0).getId()); + current.setSlotStatus("BOUND"); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(weights); + when(allocations.list(BUSINESS_TENANT, INBOUND)) + .thenReturn(List.of(current), blockSlotsBoundTo(weights)); + when(warehouse.expandBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3))); + + service.ensureForSubmission(BUSINESS_TENANT, INBOUND, OPERATOR); + + verify(warehouse).expandBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.argThat(request -> request.blockId() == 500L + && request.requiredSlotCount() == 3)); + verify(allocations).replaceBlock(BUSINESS_TENANT, INBOUND, WAREHOUSE, + 20, new BigDecimal("2.5000"), block("OCCUPIED", 3)); + } + + private static InboundProperties properties() { + InboundProperties value = new InboundProperties(); + value.setDefaultPalletCapacityCbm(new BigDecimal("1.200000")); + return value; + } + + @Configuration + static class TestConfig { + @Bean + InboundProperties inboundProperties() { + return properties(); + } + } + + private static InboundOrderEntity inbound() { + InboundOrderEntity value = new InboundOrderEntity(); + value.setTenantId(BUSINESS_TENANT); + value.setId(INBOUND); + value.setWarehouseId(WAREHOUSE); + value.setInboundCode("RK-001"); + value.setActualVolumeCbm(new BigDecimal("1.0000")); + value.setTotalPackageCount(20); + return value; + } + + private static WarehousePalletLocationClient.BlockResult block(String state, int count) { + return new WarehousePalletLocationClient.BlockResult(500L, state, + java.util.stream.IntStream.rangeClosed(1, count) + .mapToObj(index -> new WarehousePalletLocationClient.BlockResource( + 500L, 600L + index, 700L + index, "A-00" + index, index, false, state)) + .toList()); + } + + private static com.baiyue.firstleg.entity.InboundPalletAllocationEntity slot(int slotNo) { + var value = new com.baiyue.firstleg.entity.InboundPalletAllocationEntity(); + value.setBlockId(500L); + value.setSlotNo(slotNo); + value.setWarehouseId(WAREHOUSE); + value.setReservationId(600L + slotNo); + value.setLocationId(700L + slotNo); + value.setLocationCode("A-00" + slotNo); + value.setSharedOverflow(false); + value.setAllocationStatus("CONFIRMED"); + value.setSlotStatus("RESERVED_EMPTY"); + return value; + } + + private static List blockSlotsBoundTo( + List weights) { + return java.util.stream.IntStream.rangeClosed(1, 3).mapToObj(slotNo -> { + var slot = slot(slotNo); + if (slotNo <= weights.size()) { + slot.setWeightResultId(weights.get(slotNo - 1).getId()); + slot.setSlotStatus("BOUND"); + } + return slot; + }).toList(); + } + + private static List confirmedWeights() { + return java.util.stream.LongStream.rangeClosed(1, 3).mapToObj(index -> { + InboundWeightResultEntity weight = new InboundWeightResultEntity(); + weight.setId(800L + index); + weight.setPalletId(601L); + weight.setRecognitionStatus("CONFIRMED"); + weight.setConfirmedAt(java.time.LocalDateTime.now()); + weight.setNetWeightKg(new BigDecimal("10")); + return weight; + }).toList(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java new file mode 100644 index 000000000..291dc1e8f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -0,0 +1,348 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.config.InboundProperties; +import com.baiyue.firstleg.dto.ExceptionReleaseRequest; +import com.baiyue.firstleg.dto.MeasurementReviewRequest; +import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; +import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import com.baiyue.firstleg.repository.InboundWorkflowRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; +import java.math.BigDecimal; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; + +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.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("入库测量审核服务") +@SpringJUnitConfig(MeasurementReviewService.class) +class MeasurementReviewServiceTest { + @MockBean private InboundWorkflowRepository repository; + @MockBean private InboundMeasurementRepository measurements; + @MockBean private InboundIdempotencyService idempotency; + @MockBean private InboundProperties properties; + @MockBean private DropshipPalletizationService dropshipPalletization; + @Resource private MeasurementReviewService service; + + @Test + @DisplayName("客户B不能审核客户A的测量") + void shouldRejectCrossCustomerReview() { + when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) + .thenReturn(new InboundIdempotencyService.Context(10002L, 1L, false)); + + assertThatThrownBy(() -> service.approve(10002L, 9L, "审核员", 10L, "cross-customer", + new MeasurementReviewRequest(20L, 3, null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + + verify(repository).findOwned(10002L, 10L); + verify(repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); + } + + @Test + @DisplayName("订单待审核时保留测量数据但不能审核测量") + void shouldBlockMeasurementReviewUntilOrderIsApproved() { + Fixture fixture = fixture(false); + fixture.order.setOrderStatusSnapshot("SUBMITTED"); + + assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L, + "approve-before-order", new MeasurementReviewRequest(20L, 3, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("订单尚未审核通过"); + + verify(fixture.repository, never()).transitionReview( + anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); + } + + @Test + @DisplayName("审核通过是测量到计费的唯一正常推进关口") + void shouldApproveOnlyPendingSubmittedMeasurement() { + Fixture fixture = fixture(false); + when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1); + when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1); + + fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-1", + new MeasurementReviewRequest(20L, 3, null)); + + verify(fixture.repository).transitionReview(10001L, 10L, 9L, 3, true, 20L); + verify(fixture.repository).transitionMeasurement(10001L, 10L, 20L, "APPROVED"); + verify(fixture.repository).insertReview(any()); + ArgumentCaptor history = + ArgumentCaptor.forClass(InboundStatusHistoryEntity.class); + verify(fixture.repository).insertHistory(history.capture()); + assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING"); + assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT"); + } + + @Test + @DisplayName("审核通过使用订单权威货物总重量,不依赖箱规重量") + void shouldApproveWithActualGrossWeightWhenCartonWeightIsMissing() { + Fixture fixture = fixture(false); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(5); + item.setTotalVolumeCbm(new BigDecimal("0.7480")); + item.setVolumeWeightKg(new BigDecimal("124.916")); + when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item)); + when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1); + when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1); + + fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-authoritative-weight", + new MeasurementReviewRequest(20L, 3, null)); + + verify(fixture.repository).transitionReview(10001L, 10L, 9L, 3, true, 20L); + } + + @Test + @DisplayName("确认箱数合计不等于订单总箱数时不能审核通过") + void shouldRejectApprovalWhenConfirmedPackageCountDoesNotMatchOrder() { + Fixture fixture = fixture(false); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(4); + when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item)); + + assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-mismatch", + new MeasurementReviewRequest(20L, 3, null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.VALIDATION_FAILED); + + verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); + } + + @Test + @DisplayName("历史异常测量已提交但没有称重图片时提示退回补录") + void shouldExplainHowToRecoverSubmittedMeasurementWithoutWeightImage() { + Fixture fixture = fixture(false); + when(measurements.listWeights(10001L, 10L)).thenReturn(List.of()); + + assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L, + "approve-without-weight", new MeasurementReviewRequest(20L, 3, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("已提交但没有称重图片") + .hasMessageContaining("先退回测量") + .hasMessageContaining("即使只有一箱也必须称重"); + + verify(fixture.repository, never()).transitionReview( + anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); + } + + @Test + @DisplayName("已上传但未确认的称重图片提示在审核弹框修正") + void shouldExplainHowToConfirmInvalidWeightImageDuringReview() { + Fixture fixture = fixture(false); + InboundWeightResultEntity pending = confirmedWeight("357.500", "349.500"); + pending.setRecognitionStatus("PENDING_CONFIRM"); + when(measurements.listWeights(10001L, 10L)).thenReturn(List.of(pending)); + + assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L, + "approve-pending-weight", new MeasurementReviewRequest(20L, 3, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("未确认或重量无效") + .hasMessageContaining("在审核弹框逐张修正并确认"); + + verify(fixture.repository, never()).transitionReview( + anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); + } + + @Test + @DisplayName("多SKU测量行的箱数、重量、体积和体积重守恒后才能进入WAIT_SPLIT") + void shouldApproveMultipleCargoLinesOnlyWhenAllFrozenTotalsMatch() { + Fixture fixture = fixture(false); + InboundMeasurementItemEntity first = measurementItem(2, "0.3000", "50.100"); + InboundMeasurementItemEntity second = measurementItem(3, "0.4480", "74.816"); + when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(first, second)); + when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1); + when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1); + + fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-multi-sku", + new MeasurementReviewRequest(20L, 3, null)); + + ArgumentCaptor history = + ArgumentCaptor.forClass(InboundStatusHistoryEntity.class); + verify(fixture.repository).insertHistory(history.capture()); + assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT"); + } + + @Test + @DisplayName("体积或体积重任一层汇总不一致时审核零写入") + void shouldRejectApprovalWhenVolumeSnapshotDoesNotConserve() { + Fixture fixture = fixture(false); + fixture.order.setActualVolumeCbm(new BigDecimal("0.7479")); + + assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L, + "approve-volume-mismatch", new MeasurementReviewRequest(20L, 3, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("体积和体积重"); + + verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); + verify(fixture.repository, never()).transitionMeasurement(anyLong(), anyLong(), anyLong(), anyString()); + } + + @Test + @DisplayName("驳回原因必填且状态推进为测量驳回") + void shouldRequireRejectReason() { + Fixture fixture = fixture(false); + assertThatThrownBy(() -> fixture.service.reject(10001L, 9L, "审核员", 10L, "reject-1", + new MeasurementReviewRequest(20L, 3, " "))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.VALIDATION_FAILED); + verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); + } + + @Test + @DisplayName("重复审核使用原幂等键时直接返回首次结果") + void shouldReplaySameReviewRequest() { + Fixture fixture = fixture(false); + when(fixture.idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) + .thenReturn(new InboundIdempotencyService.Context(10001L, 1L, true)); + + fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-1", + new MeasurementReviewRequest(20L, 3, null)); + + verify(fixture.repository, never()).findOwned(anyLong(), anyLong()); + verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); + } + + @Test + @DisplayName("开关关闭时驳回单不能异常放行") + void shouldBlockExceptionReleaseByDefault() { + Fixture fixture = fixture(false); + assertThatThrownBy(() -> fixture.service.exceptionRelease(10001L, 9L, "主管", 10L, + "release-1", new ExceptionReleaseRequest(20L, 3, "客户确认紧急入库"))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(fixture.repository, never()).releaseRejected(anyLong(), anyLong(), anyLong(), anyInt()); + } + + @Test + @DisplayName("开关开启时仅放行已驳回单并保留驳回事实") + void shouldReleaseRejectedMeasurementWithHistory() { + Fixture fixture = fixture(true); + fixture.order.setProcessStatus("MEASURE_REJECTED"); + fixture.order.setMeasureReviewStatus("REJECTED"); + fixture.measurement.setStatus("REJECTED"); + when(fixture.repository.releaseRejected(10001L, 10L, 9L, 3)).thenReturn(1); + + fixture.service.exceptionRelease(10001L, 9L, "主管", 10L, "release-1", + new ExceptionReleaseRequest(20L, 3, "紧急放行")); + + verify(fixture.repository).releaseRejected(10001L, 10L, 9L, 3); + verify(fixture.repository, never()).transitionMeasurement(anyLong(), anyLong(), anyLong(), anyString()); + verify(fixture.repository).insertReview(any()); + } + + @Test + @DisplayName("并发审核只有一个条件更新成功") + void shouldAllowOnlyOneConcurrentReview() throws Exception { + Fixture fixture = fixture(false); + AtomicInteger attempts = new AtomicInteger(); + CountDownLatch bothArrived = new CountDownLatch(2); + when(fixture.repository.transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong())) + .thenAnswer(invocation -> { + bothArrived.countDown(); + bothArrived.await(); + return attempts.getAndIncrement() == 0 ? 1 : 0; + }); + when(fixture.repository.transitionMeasurement(anyLong(), anyLong(), anyLong(), anyString())).thenReturn(1); + var executor = Executors.newFixedThreadPool(2); + try { + Callable call = () -> { + try { + fixture.service.approve(10001L, 9L, "审核员", 10L, + "approve-" + Thread.currentThread().getId(), + new MeasurementReviewRequest(20L, 3, null)); + return "SUCCESS"; + } catch (BizException error) { + return error.getResultCode().name(); + } + }; + List> results = executor.invokeAll(List.of(call, call)); + assertThat(results.get(0).get() + "," + results.get(1).get()) + .contains("SUCCESS").contains("CONFLICT"); + assertThat(attempts).hasValue(2); + } finally { + executor.shutdownNow(); + } + } + + private Fixture fixture(boolean allowRelease) { + when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) + .thenReturn(new InboundIdempotencyService.Context(10001L, 1L, false)); + when(properties.isAllowRejectedMeasurement()).thenReturn(allowRelease); + InboundOrderEntity order = new InboundOrderEntity(); + order.setProcessStatus("MEASURE_PENDING"); + order.setOrderStatusSnapshot("APPROVED"); + order.setMeasureReviewStatus("PENDING"); + order.setTotalPackageCount(5); + order.setActualGrossWeightKg(new BigDecimal("1787.500")); + order.setActualNetWeightKg(new BigDecimal("1747.500")); + order.setActualVolumeCbm(new BigDecimal("0.7480")); + order.setVolumeWeightKg(new BigDecimal("124.916")); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(20L); + measurement.setStatus("SUBMITTED"); + measurement.setTotalPackageCount(5); + measurement.setTotalVolumeCbm(new BigDecimal("0.7480")); + measurement.setVolumeWeightKg(new BigDecimal("124.916")); + InboundMeasurementItemEntity item = measurementItem(5, "0.7480", "124.916"); + when(repository.findOwned(10001L, 10L)).thenReturn(order); + when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement); + when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item)); + when(measurements.listWeights(10001L, 10L)).thenReturn(List.of( + confirmedWeight("357.500", "349.500"), + confirmedWeight("357.500", "349.500"), + confirmedWeight("357.500", "349.500"), + confirmedWeight("357.500", "349.500"), + confirmedWeight("357.500", "349.500"))); + return new Fixture(service, repository, idempotency, order, measurement); + } + + private InboundMeasurementItemEntity measurementItem(int packages, String volume, String volumeWeight) { + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(packages); + item.setTotalVolumeCbm(new BigDecimal(volume)); + item.setVolumeWeightKg(new BigDecimal(volumeWeight)); + return item; + } + + private InboundWeightResultEntity confirmedWeight(String grossWeightKg, String netWeightKg) { + InboundWeightResultEntity weight = new InboundWeightResultEntity(); + weight.setRecognitionStatus("CONFIRMED"); + weight.setGrossWeightKg(new BigDecimal(grossWeightKg)); + weight.setNetWeightKg(new BigDecimal(netWeightKg)); + return weight; + } + + private record Fixture(MeasurementReviewService service, InboundWorkflowRepository repository, + InboundIdempotencyService idempotency, InboundOrderEntity order, + InboundMeasurementEntity measurement) { + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java new file mode 100644 index 000000000..d79d94af7 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java @@ -0,0 +1,54 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.InboundMeasurementRequests; +import com.baiyue.firstleg.dto.InboundMeasurementResponses; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(MeasurementSubmissionService.class) +class MeasurementSubmissionServiceTest { + @MockBean private InboundMeasurementService measurements; + @MockBean private MeasurementLocationOccupationService locations; + @Resource private MeasurementSubmissionService service; + + @Test + void shouldHandleCase01() throws Exception { + assertThat(MeasurementSubmissionService.class + .getDeclaredMethod("submit", long.class, long.class, long.class, + InboundMeasurementRequests.SubmitRequest.class) + .getAnnotation(Transactional.class)).isNotNull(); + } + + @Test + void shouldHandleCase02() { + var request = new InboundMeasurementRequests.SubmitRequest(30L, 2); + var submitted = new InboundMeasurementResponses.MeasurementSubmitted( + 30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null); + when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted); + + assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted); + + verify(locations).ensureForSubmission(10001L, 88L, 9L); + } + + @Test + void shouldHandleCase03() { + var request = new InboundMeasurementRequests.SubmitRequest(30L, 2); + var submitted = new InboundMeasurementResponses.MeasurementSubmitted( + 30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null); + when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted); + doThrow(new IllegalStateException("warehouse unavailable")) + .when(locations).ensureForSubmission(10001L, 88L, 9L); + + assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted); + verify(locations).ensureForSubmission(10001L, 88L, 9L); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java new file mode 100644 index 000000000..11bbbc6b8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java @@ -0,0 +1,245 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; +import com.baiyue.firstleg.dto.InboundOrderDetail; +import com.baiyue.firstleg.dto.InboundMeasurementResponses; +import com.baiyue.firstleg.client.MeasurementImageClient; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundFeeDetailEntity; +import com.baiyue.firstleg.repository.InboundOrderRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(MerchantInboundBillingService.class) +@TestPropertySource(properties = "baiyue.internal.token=internal-token") +class MerchantInboundBillingServiceTest { + @MockBean private InboundOrderRepository orders; + @MockBean private FbaPricingService pricing; + @MockBean private InboundMeasurementService measurements; + @MockBean private MeasurementImageClient imageClient; + @MockBean private InboundFeeDetailService feeDetails; + @Resource private MerchantInboundBillingService service; + + @ParameterizedTest + @ValueSource(strings = {"WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"}) + void shouldKeepPublishedBillingVisibleAfterFulfillmentProgresses(String processStatus) { + InboundOrderEntity inbound = inbound(processStatus, "APPROVED", "SENT"); + InboundOrderDetail detail = new InboundOrderDetail(mock(com.baiyue.firstleg.dto.InboundOrderSummary.class), + "客户备注", List.of(), List.of( + new InboundOrderDetail.FileSummary(1L, "RELATED", "public.pdf", "PUBLIC", 1, 1L, null), + new InboundOrderDetail.FileSummary(2L, "INTERNAL", "internal.pdf", "INTERNAL", 1, 1L, null)), + List.of(new InboundOrderDetail.InternalNoteSummary(1L, "内部备注", 1L, "客服", null)), + List.of(new InboundOrderDetail.StatusHistorySummary(null, "WAIT_SPLIT", "COMPLETE", + "内部原因", 1L, "客服", null))); + InboundMeasurementResponses.Detail measurement = mock(InboundMeasurementResponses.Detail.class); + FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class); + when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); + when(orders.detail(10001L, inbound)).thenReturn(detail); + when(measurements.detail(10001L, 88L)).thenReturn(measurement); + when(measurement.items()).thenReturn(List.of()); + when(measurement.weightResults()).thenReturn(List.of()); + when(pricing.latest(10001L, 88L)).thenReturn(fee); + InboundFeeDetailEntity published = new InboundFeeDetailEntity(); + published.setId(601L); + published.setInboundId(88L); + published.setSourceFeeId(501L); + published.setSourceFeeVersion(1); + published.setFeeTypeNameSnapshot("FBA头程运费"); + published.setAmount(new BigDecimal("334.00")); + published.setPublicationStatus("PUBLISHED"); + when(feeDetails.publishedFreight(10001L, 88L)).thenReturn(published); + when(fee.chargeMode()).thenReturn("KG"); + when(fee.chargeUnit()).thenReturn("KG"); + when(fee.actualWeightKg()).thenReturn(new BigDecimal("20")); + when(fee.actualVolumeCbm()).thenReturn(new BigDecimal("0.24")); + when(fee.convertedQuantity()).thenReturn(new BigDecimal("40.08")); + when(fee.chargeableQuantity()).thenReturn(new BigDecimal("40.08")); + + var result = service.find(10001L, 77L); + + assertThat(result.inbound().files()).extracting(InboundOrderDetail.FileSummary::fileId) + .containsExactly(1L); + assertThat(result.inbound().internalNotes()).isEmpty(); + assertThat(result.inbound().statusHistory()).isEmpty(); + assertThat(result.billingBasis().chargeableBasis()).isEqualTo("VOLUME_WEIGHT"); + assertThat(result.latestFee()).isSameAs(fee); + assertThat(result.publishedFeeDetail().id()).isEqualTo(601L); + assertThat(result.publishedFeeDetail().amount()).isEqualByComparingTo("334.00"); + verify(orders).findByOrderId(10001L, 77L); + } + + @Test + void shouldHandleCase02() { + InboundOrderEntity inbound = inbound("WAIT_SPLIT", "PENDING", "SENT"); + when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); + + assertThatThrownBy(() -> service.find(10001L, 77L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("尚未由客服审核并发布"); + verify(pricing, never()).latest(10001L, 88L); + } + + @Test + void shouldHandleCase03() { + when(orders.findByOrderId(20002L, 77L)).thenReturn(null); + + assertThatThrownBy(() -> service.find(20002L, 77L)).isInstanceOf(BizException.class); + + verify(orders).findByOrderId(20002L, 77L); + verify(pricing, never()).latest(20002L, 88L); + } + + @Test + void shouldHandleCase04() { + InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "SENT"); + InboundMeasurementResponses.Detail measurement = mock(InboundMeasurementResponses.Detail.class); + InboundMeasurementResponses.Item item = mock(InboundMeasurementResponses.Item.class); + when(item.intelligentImageFileId()).thenReturn(41L); + when(measurement.items()).thenReturn(List.of(item)); + when(measurement.weightResults()).thenReturn(List.of()); + when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); + when(measurements.detail(10001L, 88L)).thenReturn(measurement); + when(imageClient.download(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.eq(10001L), org.mockito.ArgumentMatchers.eq(88L), + org.mockito.ArgumentMatchers.eq(41L))).thenReturn( + org.springframework.http.ResponseEntity.ok() + .contentType(org.springframework.http.MediaType.IMAGE_PNG) + .body(new byte[]{1, 2, 3})); + + var image = service.downloadImage(10001L, 77L, 41L); + + assertThat(image.contentType()).isEqualTo("image/png"); + assertThat(image.content()).containsExactly(1, 2, 3); + } + + @Test + void shouldHandleCase05() { + InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "SENT"); + InboundMeasurementResponses.Detail measurement = mock(InboundMeasurementResponses.Detail.class); + when(measurement.items()).thenReturn(List.of()); + when(measurement.weightResults()).thenReturn(List.of()); + when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); + when(measurements.detail(10001L, 88L)).thenReturn(measurement); + + assertThatThrownBy(() -> service.downloadImage(10001L, 77L, 99L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不存在该测量图片"); + verify(imageClient, never()).download("internal-token", 10001L, 88L, 99L); + } + + @Test + void shouldRejectConfirmedFeeBeforeItIsSentAndPublished() { + InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "CONFIRMED"); + when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); + + assertThatThrownBy(() -> service.find(10001L, 77L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("尚未由客服审核并发布"); + verify(pricing, never()).latest(10001L, 88L); + } + + @Test + void shouldPublishSavedOuterDimensionsAndOuterTotalsInsteadOfMeasuredDimensions() { + InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "SENT"); + InboundOrderDetail detail = new InboundOrderDetail( + mock(com.baiyue.firstleg.dto.InboundOrderSummary.class), null, + List.of(), List.of(), List.of(), List.of()); + InboundMeasurementResponses.Detail measurement = mock(InboundMeasurementResponses.Detail.class); + InboundMeasurementResponses.Item item = mock(InboundMeasurementResponses.Item.class); + FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class); + InboundFeeDetailEntity published = new InboundFeeDetailEntity(); + published.setId(601L); + when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); + when(orders.detail(10001L, inbound)).thenReturn(detail); + when(measurements.detail(10001L, 88L)).thenReturn(measurement); + when(measurement.items()).thenReturn(List.of(item)); + when(measurement.weightResults()).thenReturn(List.of()); + when(measurement.actualGrossWeightKg()).thenReturn(new BigDecimal("62.5")); + when(item.itemNo()).thenReturn("1"); + when(item.packageCount()).thenReturn(2); + when(item.lengthCm()).thenReturn(new BigDecimal("30")); + when(item.widthCm()).thenReturn(new BigDecimal("20")); + when(item.heightCm()).thenReturn(new BigDecimal("60")); + when(item.calculatedOuterLengthCm()).thenReturn(new BigDecimal("34")); + when(item.calculatedOuterWidthCm()).thenReturn(new BigDecimal("24")); + when(item.calculatedOuterHeightCm()).thenReturn(new BigDecimal("64")); + when(item.totalVolumeCbm()).thenReturn(new BigDecimal("0.1046")); + when(item.volumeWeightKg()).thenReturn(new BigDecimal("17.469")); + when(item.coefficientMode()).thenReturn("ADD"); + when(item.calculationRuleVersion()).thenReturn("CUSTOMER_DIMENSION_SMALL_V3"); + when(pricing.latest(10001L, 88L)).thenReturn(fee); + when(feeDetails.publishedFreight(10001L, 88L)).thenReturn(published); + when(fee.chargeMode()).thenReturn("CBM"); + when(fee.measurementId()).thenReturn(301L); + when(fee.chargeUnit()).thenReturn("CBM"); + when(fee.actualVolumeCbm()).thenReturn(new BigDecimal("0.1046")); + when(fee.convertedQuantity()).thenReturn(new BigDecimal("0.1000")); + when(measurements.detail(10001L, 88L, 301L)).thenReturn(measurement); + + var result = service.find(10001L, 77L); + + assertThat(result.measurementItems()).singleElement().satisfies(value -> { + assertThat(value.lengthCm()).isEqualByComparingTo("34"); + assertThat(value.widthCm()).isEqualByComparingTo("24"); + assertThat(value.heightCm()).isEqualByComparingTo("64"); + assertThat(value.calculationRuleVersion()).isEqualTo("CUSTOMER_DIMENSION_SMALL_V3"); + }); + assertThat(result.measurementSummary().actualPackageCount()).isEqualTo(2); + assertThat(result.measurementSummary().actualVolumeCbm()).isEqualByComparingTo("0.1046"); + assertThat(result.measurementSummary().volumeWeightKg()).isEqualByComparingTo("17.469"); + assertThat(result.measurementSummary().calculationSource()).isEqualTo("OUTER"); + verify(measurements).detail(10001L, 88L, 301L); + } + + @ParameterizedTest + @ValueSource(strings = {"FEE_CONFIRMING", "VOIDED"}) + void shouldRejectProcessStatusesOutsidePublishedFulfillmentLifecycle(String processStatus) { + InboundOrderEntity inbound = inbound(processStatus, "APPROVED", "SENT"); + when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); + + assertThatThrownBy(() -> service.find(10001L, 77L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("尚未由客服审核并发布"); + verify(feeDetails, never()).publishedFreight(10001L, 88L); + } + + @Test + void shouldRequirePublishedFreightDetailAfterLoadingCompletes() { + InboundOrderEntity inbound = inbound("LOADED", "APPROVED", "SENT"); + when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); + when(feeDetails.publishedFreight(10001L, 88L)) + .thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND, + "入库运费明细尚未发布")); + + assertThatThrownBy(() -> service.find(10001L, 77L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("运费明细尚未发布"); + verify(orders, never()).detail(10001L, inbound); + verify(pricing, never()).latest(10001L, 88L); + } + + private static InboundOrderEntity inbound(String process, String reviewStatus, String feeStatus) { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(88L); + inbound.setProcessStatus(process); + inbound.setMeasureReviewStatus(reviewStatus); + inbound.setFeeStatus(feeStatus); + return inbound; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java new file mode 100644 index 000000000..b660e0a4e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -0,0 +1,336 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.PriceBookWarehouseClient; +import com.baiyue.firstleg.domain.PriceModel; +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.pricing.ActivePriceBookEstimator; +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.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; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +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.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig({MerchantPriceBookService.class, ActivePriceBookEstimator.class, + FbaWarehouseRateEstimator.class, RegionWeightTierEstimator.class, PriceRegionResolver.class, + PriceBookWarehouseRegionResolver.class}) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class MerchantPriceBookServiceTest { + @MockBean private PriceBookQueryUseCase repository; + @MockBean private PriceBookWarehouseClient warehouses; + @Resource private MerchantPriceBookService service; + + @Test + void shouldResolveNormalAndOverseasInboundCodePrefixesFromChannelCatalog() { + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); + when(repository.channelAvailable(7L, 10L, "MATSON_CLX_TRUCK")).thenReturn(true); + when(repository.channels(7L, 10L)).thenReturn(List.of( + channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); + when(repository.channelPrefix("MATSON_CLX_TRUCK")) + .thenReturn(new ChannelPrefixRow("MATSON_CLX_TRUCK", "ZBYHK", "ZBYHWK")); + + assertThat(service.requireAvailableChannel(10001L, 10L, "matson_clx_truck", "FBA", true) + .inboundCodePrefix()).isEqualTo("ZBYHK"); + assertThat(service.requireAvailableChannel(10001L, 10L, "MATSON_CLX_TRUCK", "OVERSEAS", true) + .inboundCodePrefix()).isEqualTo("ZBYHWK"); + } + + @Test + void shouldFallBackToNormalPrefixWhenChannelHasNoOverseasVariant() { + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); + when(repository.channelAvailable(7L, 10L, "OA_HOUSTON_TRUCK")).thenReturn(true); + when(repository.channels(7L, 10L)).thenReturn(List.of( + channel(7L, "OA_HOUSTON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); + when(repository.channelPrefix("OA_HOUSTON_TRUCK")) + .thenReturn(new ChannelPrefixRow("OA_HOUSTON_TRUCK", "HPHK", null)); + + assertThat(service.requireAvailableChannel(10001L, 10L, "OA_HOUSTON_TRUCK", "OVERSEAS", true) + .inboundCodePrefix()).isEqualTo("HPHK"); + } + + @Test + void shouldValidateLegacyChannelWithoutRequiringAnInboundCodePrefix() { + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); + when(repository.channelAvailable(7L, 10L, "LEGACY_CHANNEL")).thenReturn(true); + var response = service.requireAvailableChannel( + 10001L, 10L, "LEGACY_CHANNEL", "FBA", false); + + assertThat(response.channelCode()).isEqualTo("LEGACY_CHANNEL"); + assertThat(response.inboundCodePrefix()).isNull(); + verify(repository, never()).channelPrefix("LEGACY_CHANNEL"); + } + + @Test + void shouldReturnEmptyChannelsWithoutActiveVersion() { + when(repository.effectiveVersionId(anyLong())).thenReturn(null); + + assertThat(service.channels(10001L, 10L)).isEmpty(); + verify(repository, never()).channels(1L, 10L); + verify(repository, never()).channelTaxModes(1L, 10L); + } + + @Test + void shouldExposeOnlyChannelsFromLatestActiveVersionAfterSwitch() { + when(repository.effectiveVersionId(anyLong())).thenReturn(1L, 2L); + when(repository.channels(1L, 10L)).thenReturn(List.of(channel(1L, "OLD", PriceModel.FBA_WAREHOUSE_RATE))); + when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "NEW", PriceModel.FBA_WAREHOUSE_RATE))); + + assertThat(service.channels(10001L, 10L)).extracting(value -> value.channelCode()) + .containsExactly("OLD"); + assertThat(service.channels(10001L, 10L)).extracting(value -> value.channelCode()) + .containsExactly("NEW"); + } + + @Test + void shouldExposeDistinctPricingTaxModesForActiveOriginChannels() { + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); + when(repository.channels(7L, 10L)).thenReturn(List.of( + channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE), + channel(7L, "OA_PARCEL", PriceModel.REGION_WEIGHT_TIER))); + when(repository.channelTaxModes(7L, 10L)).thenReturn(List.of( + taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_EXCLUDED), + taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_INCLUDED), + taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_INCLUDED), + taxMode("OA_PARCEL", PricingTaxMode.TAX_INCLUDED))); + + var channels = service.channels(10001L, 10L); + + assertThat(channels.get(0).pricingTaxModes()) + .containsExactly(PricingTaxMode.TAX_INCLUDED, PricingTaxMode.TAX_EXCLUDED); + assertThat(channels.get(1).pricingTaxModes()).containsExactly(PricingTaxMode.TAX_INCLUDED); + } + + @Test + void shouldKeepActiveChannelWhenItsTaxModeProjectionIsEmpty() { + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); + when(repository.channels(7L, 10L)).thenReturn(List.of( + channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); + when(repository.channelTaxModes(7L, 10L)).thenReturn(List.of()); + + assertThat(service.channels(10001L, 10L)).singleElement() + .extracting(value -> value.pricingTaxModes()) + .isEqualTo(List.of()); + } + + @Test + void shouldRejectForgedOrRemovedChannel() { + when(repository.effectiveVersionId(anyLong())).thenReturn(2L); + when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "ACTIVE_CHANNEL", + PriceModel.FBA_WAREHOUSE_RATE))); + + assertThatThrownBy(() -> service.estimate(10001L, + request("FORGED", PricingDestinationType.FBA, "ONT8", null, null, + PricingTaxMode.TAX_INCLUDED, "10", "0.1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("已下线"); + } + + @Test + void shouldEstimateFbaDestinationWithIndependentPricingTaxMode() { + stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE); + when(repository.fbaRates(7L, 10L, "MATSON_TRUCK", "ONT8", "TAX_INCLUDED")) + .thenReturn(List.of(rate(PriceModel.FBA_WAREHOUSE_RATE, "KG", null, "12.50"))); + + var result = service.estimate(10001L, + request(" matson_truck ", PricingDestinationType.FBA, "ont8", null, null, + PricingTaxMode.TAX_INCLUDED, "20", "0.2")); + + assertThat(result.priceBookVersionId()).isEqualTo(7L); + assertThat(result.pricingTaxMode()).isEqualTo(PricingTaxMode.TAX_INCLUDED); + assertThat(result.chargeableQuantity()).isEqualByComparingTo("33.400000"); + assertThat(result.estimatedAmount()).isEqualByComparingTo("417.50"); + assertThat(result.disclaimer()).isEqualTo(MerchantPriceBookService.DISCLAIMER); + } + + @Test + void shouldEstimateBoundOverseasWarehouse() { + stubChannel(7L, "EXX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE); + when(repository.overseasRates(7L, 10L, "EXX_TRUCK", 901L, "TAX_EXCLUDED")) + .thenReturn(List.of(rate(PriceModel.FBA_WAREHOUSE_RATE, "CBM", null, "880"))); + + var result = service.estimate(10001L, + request("EXX_TRUCK", PricingDestinationType.OVERSEAS, null, 901L, null, + PricingTaxMode.TAX_EXCLUDED, "363", "0.5")); + + assertThat(result.chargeUnit()).isEqualTo("CBM"); + assertThat(result.chargeableQuantity()).isEqualByComparingTo("1.000000"); + assertThat(result.estimatedAmount()).isEqualByComparingTo("880.00"); + } + + @Test + void shouldUseExplicitRegionForTierEstimate() { + stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER); + when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED")) + .thenReturn(tiers()); + + var result = service.estimate(10001L, + request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST, + PricingTaxMode.TAX_INCLUDED, "51", null)); + + assertThat(result.priceModel()).isEqualTo(PriceModel.REGION_WEIGHT_TIER); + assertThat(result.unitPrice()).isEqualByComparingTo("13"); + assertThat(result.estimatedAmount()).isEqualByComparingTo("663.00"); + } + + @Test + void shouldUseIndependentPricingTaxModeForTierEstimateWhenConfigured() { + stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER); + when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED")) + .thenReturn(tiers()); + + var result = service.estimate(10001L, + request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST, + PricingTaxMode.TAX_INCLUDED, "51", null)); + + assertThat(result.pricingTaxMode()).isEqualTo(PricingTaxMode.TAX_INCLUDED); + verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED"); + } + + @Test + void shouldResolveTierRegionFromPlatformFbaWarehousePostalCode() { + stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER); + when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED")) + .thenReturn(tiers()); + var warehouse = new PriceBookWarehouseClient.Warehouse(501L, 0L, "FBA-NJ", "NJ FBA", + "FBA", "US", "ACTIVE"); + var address = new PriceBookWarehouseClient.Address("US", "NJ", "Newark", null, + "07114", "address", null); + when(warehouses.active("internal-token", 0L, 501L)) + .thenReturn(ApiResponse.success(new PriceBookWarehouseClient.Detail(warehouse, List.of(address)))); + + var result = service.estimate(10001L, + request("MATSON_PARCEL", PricingDestinationType.FBA, null, 501L, null, + PricingTaxMode.TAX_INCLUDED, "12", null)); + + assertThat(result.unitPrice()).isEqualByComparingTo("15"); + verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED"); + } + + @Test + void shouldResolveTierRegionFromPlatformOverseasWarehousePostalCode() { + stubChannel(8L, "MATSON_MAX_PARCEL", PriceModel.REGION_WEIGHT_TIER); + when(repository.regionTiers(8L, 10L, "MATSON_MAX_PARCEL", "WEST", "TAX_INCLUDED")) + .thenReturn(tiers()); + var warehouse = new PriceBookWarehouseClient.Warehouse(901L, 0L, "US-LAX-01", "洛杉矶仓", + "OVERSEAS", "US", "ACTIVE"); + var address = new PriceBookWarehouseClient.Address("US", "CA", "Los Angeles", null, + "90001", "address", null); + when(warehouses.active("internal-token", 0L, 901L)) + .thenReturn(ApiResponse.success(new PriceBookWarehouseClient.Detail(warehouse, List.of(address)))); + + var result = service.estimate(10001L, + request("MATSON_MAX_PARCEL", PricingDestinationType.OVERSEAS, null, 901L, null, + PricingTaxMode.TAX_INCLUDED, "51", null)); + + assertThat(result.unitPrice()).isEqualByComparingTo("13"); + verify(repository).regionTiers(8L, 10L, "MATSON_MAX_PARCEL", "WEST", "TAX_INCLUDED"); + } + + @Test + void shouldRejectMissingRequiredEstimateFields() { + stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE); + + assertThatThrownBy(() -> service.estimate(10001L, + request("MATSON_TRUCK", PricingDestinationType.FBA, "ONT8", null, null, + null, "10", null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("包税或不包税"); + } + + @Test + void shouldNotGuessRegionWithoutExplicitRegionOrFbaWarehouse() { + stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER); + + assertThatThrownBy(() -> service.estimate(10001L, + request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, null, + PricingTaxMode.TAX_INCLUDED, "51", null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("目的仓区域"); + } + + @Test + void shouldRejectTierEstimateWithoutPricingTaxMode() { + stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER); + + assertThatThrownBy(() -> service.estimate(10001L, + request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST, + null, "51", null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("包税或不包税"); + } + + private void stubChannel(long versionId, String code, PriceModel model) { + when(repository.effectiveVersionId(anyLong())).thenReturn(versionId); + when(repository.channels(versionId, 10L)).thenReturn(List.of(channel(versionId, code, model))); + } + + private static EstimateRequest request(String channelCode, PricingDestinationType destinationType, + String destinationCode, Long destinationWarehouseId, + PriceRegion destinationRegion, PricingTaxMode pricingTaxMode, + String weight, String volume) { + return new EstimateRequest(10L, channelCode, destinationCode, destinationType, + destinationWarehouseId, null, destinationRegion, pricingTaxMode, + pricingTaxMode == PricingTaxMode.TAX_INCLUDED ? "KG" : "CBM", + decimal(weight), decimal(volume)); + } + + private static ChannelRow channel(long versionId, String code, PriceModel model) { + ChannelRow row = new ChannelRow(); + row.setPriceBookVersionId(versionId); + row.setChannelCode(code); + row.setChannelName(code); + row.setPriceModel(model.name()); + return row; + } + + private static ChannelTaxModeRow taxMode(String channelCode, PricingTaxMode pricingTaxMode) { + ChannelTaxModeRow row = new ChannelTaxModeRow(); + row.setChannelCode(channelCode); + row.setPricingTaxMode(pricingTaxMode.name()); + return row; + } + + private static List tiers() { + return List.of( + rate(PriceModel.REGION_WEIGHT_TIER, "KG", "12", "15"), + rate(PriceModel.REGION_WEIGHT_TIER, "KG", "51", "13"), + rate(PriceModel.REGION_WEIGHT_TIER, "KG", "101", "11")); + } + + private static RateRow rate(PriceModel model, String unit, String from, String price) { + RateRow row = new RateRow(); + row.setPriceModel(model.name()); + row.setChargeUnit(unit); + row.setWeightFromKg(decimal(from)); + row.setUnitPrice(new BigDecimal(price)); + row.setCurrency("CNY"); + return row; + } + + private static BigDecimal decimal(String value) { + return value == null ? null : new BigDecimal(value); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java new file mode 100644 index 000000000..3c3415160 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java @@ -0,0 +1,125 @@ +package com.baiyue.firstleg.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.firstleg.client.MeasurementImageClient; +import com.baiyue.firstleg.dto.CartonMeasurementCommand; +import com.baiyue.firstleg.dto.CartonMeasurementResult; +import com.baiyue.firstleg.dto.MojiaMeasurementDtos.UploadRequest; +import com.baiyue.firstleg.dto.MojiaMeasurementDtos.UploadResponse; +import com.baiyue.firstleg.entity.MojiaMeasurementReceiptEntity; +import com.baiyue.firstleg.repository.MojiaMeasurementReceiptRepository; +import com.baiyue.firstleg.security.AuthenticatedMeasurementApp; +import com.baiyue.firstleg.security.MeasurementRequestAuthenticator; +import com.baiyue.firstleg.security.MeasurementRequestHeaders; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MojiaMeasurementOpenApiServiceTest { + @Test + void shipmentIdIsOptionalAndCamelCaseAliasIsAccepted() throws Exception { + ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + var validator = Validation.buildDefaultValidatorFactory().getValidator(); + + UploadRequest camelCase = mapper.readValue( + json().replace("\"shipment_id\"", "\"shipmentId\""), UploadRequest.class); + UploadRequest missing = mapper.readValue( + json().replace("\"shipment_id\":\"FBA19L5KMY2CU000002\",", ""), UploadRequest.class); + UploadRequest blank = mapper.readValue( + json().replace("FBA19L5KMY2CU000002", ""), UploadRequest.class); + + assertThat(camelCase.shipmentId()).isEqualTo("FBA19L5KMY2CU000002"); + assertThat(validator.validate(camelCase)).isEmpty(); + assertThat(validator.validate(missing)).isEmpty(); + assertThat(validator.validate(blank)).isEmpty(); + } + + @Test + void generatedDailyInboundCartonCodeFlowsThroughMojiaMeasurement() throws Exception { + MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class); + CartonMeasurementService cartons = mock(CartonMeasurementService.class); + MeasurementImageClient images = mock(MeasurementImageClient.class); + MojiaMeasurementReceiptRepository receipts = mock(MojiaMeasurementReceiptRepository.class); + var service = new MojiaMeasurementOpenApiService(authenticator, cartons, + new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(), + images, receipts); + MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null); + when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH, + headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp("mojia", 10001L, 90001L, Map.of())); + when(cartons.upsert(eq(10001L), eq(90001L), any())).thenReturn(new CartonMeasurementResult( + 1L, 1, 2L, "PCHK2608140001#5-1", "FBA19L5KMY2CU000002", 1, + new BigDecimal("60.1"), new BigDecimal("40.2"), + new BigDecimal("30.3"), 3L, "MJ-01", LocalDateTime.now(), true)); + + UploadResponse result = service.receive(headers, json()); + + assertThat(result.matched()).isTrue(); + assertThat(result.boxCode()).isEqualTo("PCHK2608140001#5-1"); + ArgumentCaptor command = ArgumentCaptor.forClass(CartonMeasurementCommand.class); + verify(cartons).upsert(eq(10001L), eq(90001L), command.capture()); + assertThat(command.getValue().boxCode()).isEqualTo("PCHK2608140001#5-1"); + assertThat(command.getValue().scannedShipmentId()).isEqualTo("FBA19L5KMY2CU000002"); + assertThat(command.getValue().lengthCm()).isEqualByComparingTo("60.1"); + assertThat(command.getValue().image().getContentType()).isEqualTo("image/jpeg"); + assertThat(command.getValue().image().getBytes()).containsExactly(0xff, 0xd8, 0xff, 0xd9); + } + + @Test + void unmatchedSimulationIsAcceptedWithFieldsAndImageSavedForInvestigation() { + MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class); + CartonMeasurementService cartons = mock(CartonMeasurementService.class); + MeasurementImageClient images = mock(MeasurementImageClient.class); + MojiaMeasurementReceiptRepository receipts = mock(MojiaMeasurementReceiptRepository.class); + var service = new MojiaMeasurementOpenApiService(authenticator, cartons, + new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(), + images, receipts); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null); + String requestJson = json().replace("\"shipment_id\":\"FBA19L5KMY2CU000002\",", ""); + when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH, + headers, requestJson, false)).thenReturn(new AuthenticatedMeasurementApp("mojia", 10001L, 90001L, Map.of())); + when(cartons.upsert(eq(10001L), eq(90001L), any())) + .thenThrow(new BizException(ResultCode.NOT_FOUND, "箱号不存在或不属于当前客户")); + when(images.uploadReceipt(eq("internal-token"), eq(90001L), eq(10001L), any(Long.TYPE), any())) + .thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult( + 3L, "mojia.jpg", "image/jpeg", 4L, "NOT_REQUIRED", null, null, null, null))); + + UploadResponse result = service.receive(headers, requestJson); + + assertThat(result.accepted()).isTrue(); + assertThat(result.matched()).isFalse(); + assertThat(result.receiptId()).isNotNull(); + assertThat(result.imageFileId()).isEqualTo(3L); + ArgumentCaptor receipt = + ArgumentCaptor.forClass(MojiaMeasurementReceiptEntity.class); + verify(receipts).insert(receipt.capture()); + assertThat(receipt.getValue().getBoxCode()).isEqualTo("PCHK2608140001#5-1"); + assertThat(receipt.getValue().getScannedShipmentId()).isNull(); + assertThat(receipt.getValue().getLengthCm()).isEqualByComparingTo("60.1"); + assertThat(receipt.getValue().getImageFileId()).isEqualTo(3L); + assertThat(receipt.getValue().getStatus()).isEqualTo("UNMATCHED"); + } + + private String json() { + return """ + {"boxCode":"PCHK2608140001#5-1","shipment_id":"FBA19L5KMY2CU000002", + "length":60.1,"width":40.2,"height":30.3, + "machineNo":"MJ-01","picture":"data:image/jpeg;base64,/9j/2Q=="} + """; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java new file mode 100644 index 000000000..ef8b72bd3 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java @@ -0,0 +1,181 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.OrderChannelChangePricing.Request; +import com.baiyue.firstleg.entity.InboundFeeEntity; +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.FeeItemRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; + +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.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(OrderChannelChangePricingService.class) +class OrderChannelChangePricingServiceTest { + @MockBean private FbaPricingRepository repository; + @MockBean private FeeItemRepository feeItems; + @MockBean private MerchantPriceBookService priceBooks; + @MockBean private ActivePriceBookEstimator estimator; + @Resource private OrderChannelChangePricingService service; + + @Test + void rejectsConfirmedFeeBeforeChannelCanChange() { + InboundPricingEntity inbound = inbound("CONFIRMED"); + InboundFeeEntity fee = fee(91L, "CONFIRMED"); + when(repository.findInboundByOrder(10001L, 77L)).thenReturn(inbound); + when(repository.findLatestFee(10001L, 88L)).thenReturn(fee); + + assertThatThrownBy(() -> service.validate(request(4))) + .isInstanceOf(BizException.class) + .hasMessageContaining("费用已确认或已发布"); + verify(estimator, never()).estimate(any()); + } + + @Test + void validatesExactPriceAndMarksCalculatedEstimateForInvalidation() { + InboundPricingEntity inbound = inbound("CALCULATED"); + when(repository.findInboundByOrder(10001L, 77L)).thenReturn(inbound); + when(repository.findLatestFee(10001L, 88L)).thenReturn(fee(91L, "CALCULATED")); + when(estimator.estimate(anyLong(), any())).thenReturn(estimate()); + + var response = service.validate(request(4)); + + assertThat(response.priceBookVersionId()).isEqualTo(12L); + assertThat(response.calculatedFeeWillBeInvalidated()).isTrue(); + assertThat(response.feeStatus()).isEqualTo("CALCULATED"); + verify(priceBooks).requireAvailableChannel(10001L, 9L, "MATSON_NEW", "FBA", false); + } + + @Test + void appliesByOrderVersionAndOnlyInvalidatesCalculatedFacts() { + InboundPricingEntity inbound = inbound("CALCULATED"); + InboundFeeEntity fee = fee(91L, "CALCULATED"); + when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); + when(repository.findLatestFeeForUpdate(10001L, 88L)).thenReturn(fee); + when(estimator.estimate(anyLong(), any())).thenReturn(estimate()); + when(repository.supersedeCurrent(10001L, 91L, "CALCULATED")).thenReturn(1); + when(repository.applyOrderChannelChange(inbound, "MATSON_NEW", "新渠道")).thenReturn(1); + when(repository.completeIdempotency(any(), org.mockito.ArgumentMatchers.eq(4L), any())).thenReturn(1); + + var response = service.apply(request(4)); + + assertThat(response.feeStatus()).isEqualTo("UNPRICED"); + assertThat(response.invalidatedFeeId()).isEqualTo(91L); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("37"); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.102"); + verify(feeItems).supersedeFreight( + org.mockito.ArgumentMatchers.eq(10001L), org.mockito.ArgumentMatchers.eq(88L), + org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(0L), any()); + verify(repository).insertIdempotency(any()); + } + + @Test + void sameOrderVersionReplaysWithoutInvalidatingAgain() { + InboundPricingEntity inbound = inbound("UNPRICED"); + InboundPricingIdempotencyEntity token = token(4L, "COMPLETED"); + token.setRequestHash(requestHash(request(4))); + when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); + when(repository.findIdempotency(10001L, 0L, "ORDER_CHANNEL_CHANGE", "77:4")).thenReturn(token); + when(estimator.estimate(anyLong(), any())).thenReturn(estimate()); + + assertThat(service.apply(request(4)).orderVersion()).isEqualTo(4L); + + verify(repository, never()).supersedeCurrent(any(Long.class), any(Long.class), any()); + verify(repository, never()).applyOrderChannelChange(any(), any(), any()); + } + + @Test + void sameOrderVersionRejectsDifferentChannelNamePayload() { + InboundPricingEntity inbound = inbound("UNPRICED"); + InboundPricingIdempotencyEntity token = token(4L, "COMPLETED"); + token.setRequestHash(requestHash(request(4))); + when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); + when(repository.findIdempotency(10001L, 0L, "ORDER_CHANNEL_CHANGE", "77:4")).thenReturn(token); + Request changedName = new Request(10001L, 77L, 4L, 9L, "MATSON_NEW", "伪造名称", + "FBA", 501L, "ONT8", "TAX_INCLUDED"); + + assertThatThrownBy(() -> service.apply(changedName)) + .isInstanceOf(BizException.class) + .hasMessageContaining("同一订单版本已用于不同渠道变更"); + verify(estimator, never()).estimate(any()); + } + + @Test + void rejectsOlderOrderVersionAfterNewerVersionWasApplied() { + InboundPricingEntity inbound = inbound("UNPRICED"); + when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); + when(repository.findLatestCompletedOrderChannelChange(10001L, 88L)) + .thenReturn(token(5L, "COMPLETED")); + + assertThatThrownBy(() -> service.apply(request(4))) + .isInstanceOf(BizException.class) + .hasMessageContaining("订单渠道版本已推进"); + verify(estimator, never()).estimate(any()); + } + + private static Request request(long orderVersion) { + return new Request(10001L, 77L, orderVersion, 9L, "MATSON_NEW", "新渠道", + "FBA", 501L, "ONT8", "TAX_INCLUDED"); + } + + private static InboundPricingEntity inbound(String feeStatus) { + InboundPricingEntity value = new InboundPricingEntity(); + value.setId(88L); + value.setTenantId(10001L); + value.setOrderId(77L); + value.setWarehouseId(9L); + value.setDestinationType("FBA"); + value.setDestinationWarehouseId(501L); + value.setDestinationCode("ONT8"); + value.setDestinationName("ONT8"); + value.setPricingTaxMode("TAX_INCLUDED"); + value.setProcessStatus("WAIT_SPLIT"); + value.setFeeStatus(feeStatus); + value.setActualNetWeightKg(new BigDecimal("37")); + value.setActualVolumeCbm(new BigDecimal("0.102")); + return value; + } + + private static InboundFeeEntity fee(long id, String status) { + InboundFeeEntity value = new InboundFeeEntity(); + value.setId(id); + value.setInboundId(88L); + value.setFeeStatus(status); + return value; + } + + private static ActivePriceBookEstimator.Estimate estimate() { + return new ActivePriceBookEstimator.Estimate(12L, "MATSON_NEW", null, null, null, + BigDecimal.ONE, "KG", BigDecimal.ONE, BigDecimal.ONE, "CNY", BigDecimal.ONE, 6, "HALF_UP"); + } + + private static InboundPricingIdempotencyEntity token(long version, String status) { + InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity(); + value.setResultResourceId(version); + value.setStatus(status); + return value; + } + + private static String requestHash(Request request) { + try { + var method = OrderChannelChangePricingService.class.getDeclaredMethod("hash", Request.class); + method.setAccessible(true); + return (String) method.invoke(null, request); + } catch (ReflectiveOperationException exception) { + throw new AssertionError(exception); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderDeclarationSyncServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderDeclarationSyncServiceTest.java new file mode 100644 index 000000000..98445bcd8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderDeclarationSyncServiceTest.java @@ -0,0 +1,137 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.OrderDeclarationSync; +import com.baiyue.firstleg.mapper.OrderDeclarationSyncMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +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.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.List; + +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.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig({OrderDeclarationSyncService.class, OrderDeclarationSyncServiceTest.JsonConfig.class}) +class OrderDeclarationSyncServiceTest { + @Configuration + static class JsonConfig { + @Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); } + } + + @MockBean private OrderDeclarationSyncMapper mapper; + @Resource private OrderDeclarationSyncService service; + @Resource private ObjectMapper json; + + @Test + void allowsOnlyCompatibleUnallocatedFulfillment() { + when(mapper.gate(10001L, 77L, "MATSON_NEW")).thenReturn( + row("WAIT_SPLIT", 0, 0, 0, 0)); + + var response = service.validateChannelChange( + new OrderDeclarationSync.ChannelChangeValidationRequest(10001L, 77L, 4L, "matson_new")); + + assertThat(response.channelCode()).isEqualTo("MATSON_NEW"); + assertThat(response.mainStatus()).isEqualTo("WAIT_SPLIT"); + } + + @Test + void rejectsPdaFactWithoutMutatingAnything() { + when(mapper.gate(10001L, 77L, "MATSON_NEW")).thenReturn( + row("WAIT_SPLIT", 0, 0, 1, 1)); + + assertThatThrownBy(() -> service.validateChannelChange( + new OrderDeclarationSync.ChannelChangeValidationRequest(10001L, 77L, 4L, "MATSON_NEW"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("PDA装柜任务"); + verify(mapper, never()).updateAllowedSnapshot(anyLong(), anyLong(), anyLong(), anyString(), anyString(), + any(), any(), anyString(), anyString(), anyLong()); + } + + @Test + void persistsVersionedDeclarationAndUpdatesOnlyAllowedInboundProjection() { + var event = event(4, "event-4"); + when(mapper.lockInbound(10001L, 77L)).thenReturn(inboundRow("WAIT_MEASURE")); + when(mapper.insertSnapshot(anyLong(), anyLong(), anyString(), anyInt(), anyString(), any(), any(), + anyString(), anyString(), anyString(), anyString(), any())).thenReturn(1); + when(mapper.insertConsumedEvent(anyLong(), anyString(), anyLong(), anyInt(), anyString())).thenReturn(1); + when(mapper.updateAllowedSnapshot(10001L, 88L, 77L, "FL-77", "RK-77", "客户新备注", + event.expectedArrivalAt(), "MATSON_NEW", "新渠道", 9L)).thenReturn(1); + + service.consume(event); + + verify(mapper).updateAllowedSnapshot(10001L, 88L, 77L, "FL-77", "RK-77", "客户新备注", + event.expectedArrivalAt(), "MATSON_NEW", "新渠道", 9L); + } + + @Test + void ignoresOlderVersionWithoutOverwritingCurrentProjection() { + var event = event(4, "event-4"); + when(mapper.lockInbound(10001L, 77L)).thenReturn(inboundRow("WAIT_MEASURE")); + when(mapper.lockSnapshot(10001L, 77L)).thenReturn( + new OrderDeclarationSyncMapper.SnapshotRow(10001L, 77L, "event-5", 5, "hash")); + when(mapper.insertConsumedEvent(anyLong(), anyString(), anyLong(), anyInt(), anyString())).thenReturn(1); + + service.consume(event); + + verify(mapper, never()).insertSnapshot(anyLong(), anyLong(), anyString(), anyInt(), anyString(), any(), + any(), anyString(), anyString(), anyString(), anyString(), any()); + verify(mapper, never()).updateAllowedSnapshot(anyLong(), anyLong(), anyLong(), anyString(), anyString(), + any(), any(), anyString(), anyString(), anyLong()); + verify(mapper).insertConsumedEvent(anyLong(), anyString(), anyLong(), anyInt(), anyString()); + } + + @Test + void sameEventAndPayloadIsIdempotent() throws Exception { + var event = event(4, "event-4"); + String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(json.writeValueAsBytes(event))); + when(mapper.lockInbound(10001L, 77L)).thenReturn(inboundRow("WAIT_MEASURE")); + when(mapper.byEvent(10001L, "event-4")).thenReturn( + new OrderDeclarationSyncMapper.SnapshotRow(10001L, 77L, "event-4", 4, hash)); + + service.consume(event); + + verify(mapper, never()).lockSnapshot(anyLong(), anyLong()); + verify(mapper, never()).updateAllowedSnapshot(anyLong(), anyLong(), anyLong(), anyString(), anyString(), + any(), any(), anyString(), anyString(), anyLong()); + } + + private static OrderDeclarationSyncMapper.GateRow row(String status, Integer group, Integer split, + Integer preload, Integer pda) { + return new OrderDeclarationSyncMapper.GateRow(88L, 77L, "FL-77", "RK-77", status, + "MATSON_OLD", group, split, preload, pda); + } + + private static OrderDeclarationSyncMapper.InboundRow inboundRow(String status) { + return new OrderDeclarationSyncMapper.InboundRow( + 88L, 77L, "FL-77", "RK-77", status, "MATSON_OLD"); + } + + private static OrderDeclarationSync.UpdatedEvent event(int version, String eventId) { + var cargo = new OrderDeclarationSync.Cargo(1, "衣服", "clothes", "SKU-1", null, 2, 20, + new BigDecimal("10"), new BigDecimal("20"), new BigDecimal("30"), new BigDecimal("37"), + new BigDecimal("0.102"), null, null, null, null, null, null, "CNY", + null, null, null, null); + return new OrderDeclarationSync.UpdatedEvent(1, eventId, 10001L, 77L, "FL-77", "RK-77", + version, 9L, LocalDateTime.of(2026, 9, 2, 10, 0), "客户新备注", + LocalDateTime.of(2026, 9, 3, 10, 0), "MATSON_NEW", "新渠道", true, + List.of(cargo), List.of(new OrderDeclarationSync.FieldChange(null, "channelCode", + "MATSON_OLD", "MATSON_NEW"))); + } +} 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..c4242c9fc --- /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 derivesEstimatedEmptyReportedAtFromUnloadCompletionAndPersistsInbox() { + 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", "ZCSU7881970", 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-23T03:00: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", "ZCSU7881970", + 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", "ZCSU7881970", 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("ZCSU7881970"); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java new file mode 100644 index 000000000..8fa0a26ba --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java @@ -0,0 +1,86 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.*; +import com.baiyue.firstleg.client.OrderFulfillmentCompletionClient; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.mapper.ContainerEventInboxMapper; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class OverseasDeliveryCompletedConsumerTest { + @Test + void completesOnlyEventSourceInboundAndOrder() { + InboundOrderMapper inbounds = mock(InboundOrderMapper.class); + InboundStatusHistoryMapper histories = mock(InboundStatusHistoryMapper.class); + ContainerEventInboxMapper inbox = mock(ContainerEventInboxMapper.class); + OrderFulfillmentCompletionClient orders = mock(OrderFulfillmentCompletionClient.class); + ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + OverseasDeliveryCompletedConsumer consumer = new OverseasDeliveryCompletedConsumer( + inbounds, histories, inbox, orders, json); + ReflectionTestUtils.setField(consumer, "internalToken", "token"); + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(101L); inbound.setTenantId(88L); inbound.setOrderId(201L); + inbound.setOrderNo("FL-201"); inbound.setInboundCode("IN-1"); inbound.setDestinationCode("ONT8"); + inbound.setProcessStatus("LOADED"); + when(inbounds.selectActiveByIdForUpdate(101L)).thenReturn(inbound); + when(inbounds.completeOverseasDelivery(eq(88L), eq(101L), any())).thenReturn(1); + when(inbounds.countNotDeliveredByOrder(88L, 201L)).thenReturn(0); + when(orders.complete(eq("token"), any())).thenReturn(ApiResponse.success( + new OrderFulfillmentCompletionClient.Result(201L, "COMPLETED", false))); + + consumer.consume(event(json)); + + verify(inbounds).completeOverseasDelivery(eq(88L), eq(101L), any()); + verify(orders).complete(eq("token"), argThat(request -> + "201".equals(request.orderId()) && "IN-1".equals(request.inboundCode()) + && "DL-ONT8".equals(request.deliveryNo()))); + verify(inbounds).markOrderSnapshotCompleted(eq(88L), eq(201L), any()); + verify(inbounds, never()).completeOverseasDelivery(eq(88L), eq(102L), any()); + } + + @Test + void leavesOrderOpenWhileAnotherSourceInboundIsNotDelivered() { + InboundOrderMapper inbounds = mock(InboundOrderMapper.class); + InboundStatusHistoryMapper histories = mock(InboundStatusHistoryMapper.class); + ContainerEventInboxMapper inbox = mock(ContainerEventInboxMapper.class); + OrderFulfillmentCompletionClient orders = mock(OrderFulfillmentCompletionClient.class); + ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + OverseasDeliveryCompletedConsumer consumer = new OverseasDeliveryCompletedConsumer( + inbounds, histories, inbox, orders, json); + ReflectionTestUtils.setField(consumer, "internalToken", "token"); + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(101L); inbound.setTenantId(88L); inbound.setOrderId(201L); + inbound.setOrderNo("FL-201"); inbound.setInboundCode("IN-1"); inbound.setDestinationCode("ONT8"); + inbound.setProcessStatus("LOADED"); + when(inbounds.selectActiveByIdForUpdate(101L)).thenReturn(inbound); + when(inbounds.completeOverseasDelivery(eq(88L), eq(101L), any())).thenReturn(1); + when(inbounds.countNotDeliveredByOrder(88L, 201L)).thenReturn(1); + + consumer.consume(event(json)); + + verifyNoInteractions(orders); + verify(inbounds, never()).markOrderSnapshotCompleted(anyLong(), anyLong(), any()); + } + + private EventEnvelope event(ObjectMapper json) { + Instant time = Instant.parse("2026-09-15T08:00:00Z"); + var payload = new OverseasDeliveryCompletedV1("1", "DL-ONT8", "11", "LM-11", + "101", "IN-1", "201", "FL-201", "201", "ONT8", time); + var node = json.valueToTree(payload); + return new EventEnvelope("00000000-0000-4000-8000-000000000001", + EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time, RegionCode.OVERSEAS, RegionCode.CN, + "88", "INBOUND_ORDER", "101", 2L, 1L, + "00000000-0000-4000-8000-000000000099", "delivery-101", null, node, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java new file mode 100644 index 000000000..f065a93ec --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java @@ -0,0 +1,88 @@ +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.OverseasInboundOrderDetainedChangedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.entity.InboundHoldRecordEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.ContainerEventInboxEntity; +import com.baiyue.firstleg.mapper.ContainerEventInboxMapper; +import com.baiyue.firstleg.mapper.InboundHoldRecordMapper; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OverseasInboundOrderDetainedConsumerTest { + @Mock InboundOrderMapper inboundOrders; + @Mock InboundHoldRecordMapper holdRecords; + @Mock ContainerEventInboxMapper inbox; + ObjectMapper objectMapper; + OverseasInboundOrderDetainedConsumer consumer; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + consumer = new OverseasInboundOrderDetainedConsumer(inboundOrders, holdRecords, inbox, objectMapper); + } + + @Test + void appliesHoldBySourceInboundIdAndWritesHistory() { + InboundOrderEntity inbound = inbound(null); + when(inboundOrders.selectActiveByIdForUpdate(1001L)).thenReturn(inbound); + when(inboundOrders.applyOverseasHold(88L, 1001L, true, "等待查验", 7L, 66L)).thenReturn(1); + + consumer.consume(event(7, true, "等待查验")); + + verify(inboundOrders).applyOverseasHold(88L, 1001L, true, "等待查验", 7L, 66L); + ArgumentCaptor history = ArgumentCaptor.forClass(InboundHoldRecordEntity.class); + verify(holdRecords).insert(history.capture()); + assertThat(history.getValue().getInboundId()).isEqualTo(1001L); + assertThat(history.getValue().getHoldAction()).isEqualTo("HOLD"); + verify(inbox).updateById(argThat((ContainerEventInboxEntity row) -> "PROCESSED".equals(row.getStatus()))); + } + + @Test + void staleReleaseCannotOverwriteNewerHoldState() { + when(inboundOrders.selectActiveByIdForUpdate(1001L)).thenReturn(inbound(9L)); + + consumer.consume(event(7, false, "解除")); + + verify(inboundOrders, never()).applyOverseasHold(anyLong(), anyLong(), anyBoolean(), any(), anyLong(), anyLong()); + verifyNoInteractions(holdRecords); + verify(inbox).updateById(argThat((ContainerEventInboxEntity row) -> "PROCESSED".equals(row.getStatus()))); + } + + private InboundOrderEntity inbound(Long sourceVersion) { + InboundOrderEntity value = new InboundOrderEntity(); + value.setId(1001L); value.setTenantId(88L); value.setInboundCode("IN-1001"); + value.setLastOverseasHoldVersion(sourceVersion); + return value; + } + + private EventEnvelope event(long version, boolean detained, String reason) { + var payload = new OverseasInboundOrderDetainedChangedV1("1001", "IN-1001", "9001", + "LM-9001", detained, reason, "66", "海外操作员", Instant.parse("2026-08-31T06:00:00Z")); + var node = objectMapper.valueToTree(payload); + return new EventEnvelope(String.format("00000000-0000-4000-8000-%012d", version), + EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, payload.changedAt(), + RegionCode.OVERSEAS, RegionCode.CN, "88", "INBOUND_ORDER", "1001", version, 1L, + "00000000-0000-4000-8000-000000000099", "hold-1001-" + version, null, node, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementPlanTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementPlanTest.java new file mode 100644 index 000000000..a781ccfc7 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementPlanTest.java @@ -0,0 +1,53 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PdaPickSettlementPlanTest { + @Test + void groupsPartialItemsByInbound() { + var resolution = PdaPickSettlementPlan.resolve(List.of( + item(1L, 11L, 101L, 1001L, 10, 6, "PARTIAL"), + item(2L, 12L, 101L, 1002L, 8, 3, "PARTIAL"), + item(3L, 13L, 102L, 2001L, 4, 0, "SKIPPED"), + item(4L, 14L, 103L, 3001L, 5, 5, "PICKED"))); + + assertThat(resolution.partialByInbound()).containsOnlyKeys(101L); + assertThat(resolution.partialByInbound().get(101L)) + .extracting(PdaPickSettlementPlan.SplitLine::remainingPackageCount) + .containsExactlyInAnyOrder(4, 5); + assertThat(resolution.skippedPlanItemIds()).containsExactly(13L); + } + + @Test + void rejectsUnfinishedItem() { + assertThatThrownBy(() -> PdaPickSettlementPlan.resolve(List.of( + item(1L, 11L, 101L, 1001L, 10, 2, "PICKING")))) + .hasMessageContaining("未结束"); + } + + @Test + void rejectsInvalidPartialCount() { + assertThatThrownBy(() -> PdaPickSettlementPlan.resolve(List.of( + item(1L, 11L, 101L, 1001L, 10, 10, "PARTIAL")))) + .hasMessageContaining("不完整或箱数不合法"); + } + + private static LoadingPickItemEntity item(long id, long planItemId, long inboundId, long detailId, + int planned, int picked, String status) { + LoadingPickItemEntity item = new LoadingPickItemEntity(); + item.setId(id); + item.setLoadingPlanItemId(planItemId); + item.setInboundId(inboundId); + item.setInboundDetailId(detailId); + item.setPlannedPackageCount(planned); + item.setPickedPackageCount(picked); + item.setItemStatus(status); + return item; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java new file mode 100644 index 000000000..f3fb214c7 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java @@ -0,0 +1,534 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.firstleg.client.WarehousePalletLocationClient; +import com.baiyue.firstleg.client.OrderFulfillmentSplitClient; +import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; +import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper; +import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.MeasurementItemRow; +import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.ParentRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; +import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; +import com.baiyue.firstleg.repository.LoadingPickRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; + +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.anyInt; +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.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(PdaPickSettlementService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class PdaPickSettlementServiceTest { + @MockBean private LoadingPickRepository picks; + @MockBean private InboundSplitSettlementMapper splits; + @MockBean private InboundPalletAllocationRepository allocations; + @MockBean private WarehousePalletLocationClient warehouse; + @MockBean private OrderFulfillmentSplitClient orderSplits; + @MockBean private InboundHoldGuard inboundHoldGuard; + @MockBean private DropshipPalletizationMapper dropshipPallets; + @Resource private PdaPickSettlementService service; + + @Test + void heldTaskBlocksDirectSettlementAndOwnerSettlementBeforeResourceMutation() { + doThrow(new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单已扣货")) + .when(inboundHoldGuard).requireTaskNotHeld(1L); + + assertThatThrownBy(() -> service.settle(1L, 9L, "operator")) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("扣货"); + assertThatThrownBy(() -> service.settleOwner(1L, 31L, 9L, "operator")) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("扣货"); + + verifyNoInteractions(picks, splits, allocations, warehouse, orderSplits); + } + + @Test + void createsChildForAllUnloadedPackagesAndTransfersRemainingAllocations() { + LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 8, "PARTIAL"); + resolved(List.of(item)); + when(splits.lockParent(31L)).thenReturn(parent(20)); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); + when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation -> + List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10))); + writes(12, 8); + + service.settle(1L, 9L, "operator"); + + 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).transferBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> + request.blockId() == 800L && request.sourceBusinessId() == 31L + && request.targetBusinessId() > 0 + && request.idempotencyKey().equals("PDA-BLOCK-1-31"))); + verify(warehouse, never()).releaseBlock(anyString(), any()); + verify(allocations).copyBlockToChild(eq(10001L), anyLong(), any()); + verify(allocations).markBlockTransferred(10001L, 31L, 800L); + verify(splits).convergePartialPlanItem(21L, 9L, "operator"); + } + + @Test + void distributesOnePickedCartonAcrossRepeatedMeasurementRowsOfTheSameInboundDetail() { + LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 1, 1, "PICKED"); + resolved(List.of(item)); + when(splits.lockParent(31L)).thenReturn(parent(2)); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)).thenReturn(List.of( + new MeasurementItemRow(51L, 61L, 41L, 1), + new MeasurementItemRow(52L, 61L, 41L, 1))); + when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation -> + List.of(allocation(1L, 1, 501L, 2))); + writes(1, 1); + when(splits.insertSplitItem(anyLong(), eq(10001L), anyLong(), eq(31L), anyLong(), anyInt(), + eq(41L), eq(61L), eq(52L), eq(1))).thenReturn(1); + when(splits.cloneMeasurementItem(eq(10001L), eq(61L), eq(52L), anyLong(), + anyLong(), anyLong(), anyInt(), eq(1))).thenReturn(1); + when(splits.updateMeasurementItemForLoaded(10001L, 52L, 0)).thenReturn(1); + + service.settle(1L, 9L, "operator"); + + verify(splits).updateMeasurementItemForLoaded(10001L, 51L, 1); + verify(splits).updateMeasurementItemForLoaded(10001L, 52L, 0); + verify(splits).cloneMeasurementItem(eq(10001L), eq(61L), eq(52L), anyLong(), + anyLong(), anyLong(), anyInt(), eq(1)); + verify(orderSplits).split(eq("internal-token"), eq(9L), + org.mockito.ArgumentMatchers.argThat(request -> request.lines().size() == 1 + && request.lines().get(0).sourceDetailId() == 41L + && request.lines().get(0).packageCount() == 2)); + } + + @Test + void transfersTheWholeBlockToTheGeneratedRemainderChild() { + LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 3, 1, "PARTIAL"); + resolved(List.of(item)); + when(splits.lockParent(31L)).thenReturn(parent(3)); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 3))); + when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation -> + List.of(allocation(1L, 1, 501L, 3))); + writes(2, 1); + + service.settle(1L, 9L, "operator"); + + verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), + eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-1"), eq(2), + eq(1), eq("REMAINDER"), eq(9L), eq("operator")); + verify(warehouse).transferBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> + request.blockId() == 800L && request.sourceBusinessId() == 31L + && request.targetBusinessId() > 0)); + } + + @Test + void repeatedSettleWithExistingChildDoesNotConsumeOrTransferAgain() { + LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 6, "PARTIAL"); + resolved(List.of(item)); + when(splits.lockParent(31L)).thenReturn(parent(10)); + when(splits.appliedBatch(10001L, "PDA-SPLIT-1-31")).thenReturn(71L); + + service.settle(1L, 9L, "operator"); + + verify(warehouse, never()).transferBlock(anyString(), any()); + verify(warehouse, never()).releaseBlock(anyString(), any()); + verify(splits, never()).convergePartialPlanItem(anyLong(), anyLong(), anyString()); + } + + @Test + void partialPickTransfersTheWholeBlockWithoutReleasingPickedEmptySlots() { + LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 10, "PICKED"); + resolved(List.of(item)); + when(splits.lockParent(31L)).thenReturn(parent(20)); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); + when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation -> + List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10))); + writes(10, 10); + + service.settle(1L, 9L, "operator"); + + verify(warehouse).transferBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> + request.blockId() == 800L && request.sourceBusinessId() == 31L)); + verify(warehouse, never()).releaseBlock(anyString(), any()); + } + + @Test + void fullyPickedInboundKeepsSourceAsLoadedSliceAndReleasesAllResources() { + LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 20, 20, "PICKED"); + resolved(List.of(item)); + when(splits.lockParent(31L)).thenReturn(parent(20)); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); + InboundPalletAllocationEntity first = allocation(1L, 1, 501L, 10); + InboundPalletAllocationEntity second = allocation(2L, 2, 501L, 10); + first.setPalletId(801L); + second.setPalletId(801L); + when(allocations.lockConfirmed(10001L, 31L)).thenReturn(List.of(first, second)); + writes(0, 20); + + service.settle(1L, 9L, "operator"); + + verify(allocations).consume(10001L, 31L, 1L, 10); + verify(allocations).consume(10001L, 31L, 2L, 10); + verify(splits, never()).cloneChildWithPurpose(anyLong(), anyLong(), anyLong(), anyLong(), + anyLong(), anyString(), anyString(), anyInt(), anyInt(), anyString(), anyLong(), anyString()); + verify(splits).markSourceLoaded(10001L, 31L, 20, 9L, 3); + verify(warehouse).releaseBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> + request.blockId() == 800L && request.businessId() == 31L + && request.reason().equals("ALL_PACKAGES_PICKED"))); + verify(warehouse, never()).transferBlock(anyString(), any()); + } + + @Test + void settlesWholeDropshipUsingUnifiedPhysicalCartonMeasurement() { + List items = List.of( + item(11L, 21L, 31L, 41L, 1, 1, "PICKED"), + item(12L, 22L, 31L, 42L, 1, 1, "PICKED"), + item(13L, 23L, 31L, 43L, 1, 1, "PICKED"), + item(14L, 24L, 31L, 44L, 1, 1, "PICKED")); + resolved(items); + when(splits.lockParent(31L)).thenReturn(parent(4)); + when(splits.fulfillmentType(10001L, 31L)).thenReturn("ONE_CLICK_DROPSHIP"); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 4))); + when(allocations.lockConfirmed(10001L, 31L)) + .thenReturn(List.of(allocation(1L, 1, 501L, 4))); + writes(0, 4); + + service.settle(1L, 9L, "operator"); + + verify(orderSplits).split(eq("internal-token"), eq(9L), + org.mockito.ArgumentMatchers.argThat(request -> request.lines().size() == 1 + && request.lines().get(0).sourceDetailId() == 41L + && request.lines().get(0).packageCount() == 4)); + verify(splits).updateMeasurementItemForLoaded(10001L, 51L, 4); + verify(splits).markSourceLoaded(10001L, 31L, 4, 9L, 3); + } + + @Test + void multiSkuCartonsCountOnceWhenSettlingWholeDropshipPallet() { + LoadingPickItemEntity first = item(11L, 21L, 31L, 41L, 20, 20, "PICKED"); + LoadingPickItemEntity second = item(12L, 22L, 31L, 42L, 20, 20, "PICKED"); + for (LoadingPickItemEntity item : List.of(first, second)) { + item.setUnitType("SEALED_PALLET"); + item.setPalletId(801L); + item.setPalletNo("DP-260916-0009"); + item.setIndivisible(true); + } + resolved(List.of(first, second)); + DropshipPalletizationMapper.PlanPalletMemberRow physical = + new DropshipPalletizationMapper.PlanPalletMemberRow(); + physical.setPalletId(801L); + physical.setAccountId(10001L); + physical.setInboundId(31L); + physical.setAvailablePackageCount(20); + when(dropshipPallets.palletInboundMembersByPalletIds(any())).thenReturn(List.of(physical)); + when(splits.lockParent(31L)).thenReturn(parent(20)); + when(splits.fulfillmentType(10001L, 31L)).thenReturn("ONE_CLICK_DROPSHIP"); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); + when(allocations.lockConfirmed(10001L, 31L)) + .thenReturn(List.of(allocation(1L, 1, 501L, 20))); + writes(0, 20); + + service.settle(1L, 9L, "operator"); + + verify(splits).markSourceLoaded(10001L, 31L, 20, 9L, 3); + verify(orderSplits).split(eq("internal-token"), eq(9L), + org.mockito.ArgumentMatchers.argThat(request -> request.loadedPackageCount() == 20 + && request.lines().size() == 1 && request.lines().get(0).packageCount() == 20)); + } + + @Test + void onlyPureSkippedInboundRestoresCandidate() { + LoadingPickItemEntity item = item(12L, 22L, 32L, 42L, 8, 0, "SKIPPED"); + resolved(List.of(item)); + when(splits.removeSkippedPlanItem(22L, 9L, "operator")).thenReturn(1); + + service.settle(1L, 9L, "operator"); + + verify(splits).removeSkippedPlanItem(22L, 9L, "operator"); + } + + @Test + void usesTotalPackageCountWhenUnsplitMeasuredInboundHasZeroAvailableColumn() { + LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 20, 20, "PICKED"); + resolved(List.of(item)); + when(splits.lockParent(31L)).thenReturn(parent(20, 0, "WAIT_SPLIT", "NOT_SPLIT")); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); + when(allocations.lockConfirmed(10001L, 31L)) + .thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10))); + writes(0, 20); + + service.settle(1L, 9L, "operator"); + + verify(splits).markSourceLoaded(10001L, 31L, 20, 9L, 3); + verify(splits, never()).cloneChildWithPurpose(anyLong(), anyLong(), anyLong(), anyLong(), + anyLong(), anyString(), anyString(), anyInt(), anyInt(), anyString(), anyLong(), anyString()); + } + + @Test + void allowsPdaSettlementAfterPreloadMovesMasterToLoading() { + LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 20, 20, "PICKED"); + resolved(List.of(item)); + when(splits.lockParent(31L)).thenReturn(parent(20, 20, "LOADING", "NOT_SPLIT")); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); + when(allocations.lockConfirmed(10001L, 31L)) + .thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10))); + writes(0, 20); + + service.settle(1L, 9L, "operator"); + + verify(splits).markSourceLoaded(10001L, 31L, 20, 9L, 3); + } + + @Test + void mixedSkippedInboundIsNotRestored() { + LoadingPickItemEntity picked = item(11L, 21L, 31L, 41L, 8, 8, "PICKED"); + LoadingPickItemEntity skipped = item(12L, 22L, 31L, 42L, 8, 0, "SKIPPED"); + + PdaPickSettlementPlan.Resolution resolution = PdaPickSettlementPlan.resolve(List.of(picked, skipped)); + + assertThat(resolution.skippedPlanItemIds()).isEmpty(); + } + + @Test + void childInboundCodesUseRootCodeAndGlobalSplitSequence() { + assertThat(PdaPickSettlementService.childInboundCode("RK-001", 1)).isEqualTo("RK-001-1"); + assertThat(PdaPickSettlementService.childInboundCode("RK-001", 4)).isEqualTo("RK-001-4"); + } + + @Test + void orderSplitFailureStopsLocalChildCreationAndCanBeRetriedWithSameKey() { + LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 20, 20, "PICKED"); + resolved(List.of(item)); + when(splits.lockParent(31L)).thenReturn(parent(20)); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); + when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation -> + List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10))); + when(orderSplits.split(eq("internal-token"), eq(9L), any())).thenReturn( + ApiResponse.failure(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, + "order unavailable", null, null)); + + assertThatThrownBy(() -> service.settle(1L, 9L, "operator")) + .hasMessageContaining("order unavailable"); + + verify(orderSplits).split(eq("internal-token"), eq(9L), + org.mockito.ArgumentMatchers.argThat(request -> + request.idempotencyKey().equals("pda-task-1") + && request.operationId().equals("PDA-SPLIT-1-31"))); + verify(splits, never()).cloneChildWithPurpose(anyLong(), anyLong(), anyLong(), anyLong(), + anyLong(), anyString(), anyString(), anyInt(), anyInt(), anyString(), anyLong(), anyString()); + + stubOrderSplitSuccess(); + writes(0, 20); + + service.settle(1L, 9L, "operator"); + + verify(orderSplits, times(2)).split(eq("internal-token"), eq(9L), + org.mockito.ArgumentMatchers.argThat(request -> request.idempotencyKey().equals("pda-task-1"))); + verify(splits, never()).cloneChildWithPurpose(anyLong(), anyLong(), anyLong(), anyLong(), + anyLong(), anyString(), anyString(), anyInt(), anyInt(), anyString(), anyLong(), anyString()); + } + + @Test + void consumesSecondSliceBeforeCreatingSingleNextRemainderFromRootSequence() { + LoadingPickItemEntity item = item(12L, 22L, 32L, 42L, 12, 5, "PARTIAL"); + resolvedChild(item, 32L, 42L, 62L, 52L, 12, 2); + when(splits.nextSplitSequence(10001L, 31L)).thenReturn(2); + childWrites(32L, 42L, 62L, 52L, 5, 2); + + service.settle(1L, 9L, "operator"); + + InOrder order = inOrder(splits); + order.verify(splits).markSourceLoaded(10001L, 32L, 5, 9L, 2); + order.verify(splits).touchRootSplit(10001L, 31L, 9L, 4); + order.verify(splits).cloneChildWithPurpose(eq(10001L), eq(32L), eq(31L), anyLong(), + eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-2"), eq(7), eq(2), + eq("REMAINDER"), eq(9L), eq("operator")); + verify(splits).insertBatchWithChildren(anyLong(), eq(10001L), eq(31L), eq("PDA-SPLIT-1-32"), + eq(32L), eq("pda-task-1"), eq(4), eq(12), + org.mockito.ArgumentMatchers.isNull(), anyLong(), eq(5), eq(7), eq(701L), eq(801L), + anyString(), eq(9L), eq("operator")); + } + + @Test + void retryAfterRemoteOrderSuccessCanFinishLocalThirdSliceWithoutNestedCode() { + LoadingPickItemEntity item = item(13L, 23L, 33L, 43L, 9, 4, "PARTIAL"); + resolvedChild(item, 33L, 43L, 63L, 53L, 9, 3); + when(splits.nextSplitSequence(10001L, 31L)).thenReturn(3); + childWrites(33L, 43L, 63L, 53L, 4, 3); + when(splits.markSourceLoaded(10001L, 33L, 4, 9L, 3)).thenReturn(0, 1); + + assertThatThrownBy(() -> service.settle(1L, 9L, "operator")) + .hasMessageContaining("来源分片状态已被并发修改"); + service.settle(1L, 9L, "operator"); + + verify(orderSplits, times(2)).split(eq("internal-token"), eq(9L), + org.mockito.ArgumentMatchers.argThat(request -> request.idempotencyKey().equals("pda-task-1") + && request.operationId().equals("PDA-SPLIT-1-33"))); + verify(splits).cloneChildWithPurpose(eq(10001L), eq(33L), eq(31L), anyLong(), + eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-3"), eq(5), eq(3), + eq("REMAINDER"), eq(9L), eq("operator")); + } + + private void resolved(List items) { + when(picks.allTaskItemsResolved(1L)).thenReturn(true); + when(picks.items(1L)).thenReturn(items); + when(splits.lineage(31L)).thenReturn(new InboundSplitSettlementMapper.Lineage( + 31L, 10001L, "MASTER", 31L, "MASTER", "NOT_SPLIT")); + LoadingPickTaskEntity task = new LoadingPickTaskEntity(); + task.setId(1L); task.setLoadingPlanId(701L); + when(picks.task(1L)).thenReturn(task); + when(splits.containerBookingId(701L)).thenReturn(801L); + when(warehouse.transferBlock(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(block("OCCUPIED"))); + when(warehouse.releaseBlock(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(block("RELEASED"))); + stubOrderSplitSuccess(); + } + + private void resolvedChild(LoadingPickItemEntity item, long inboundId, long detailId, + long measurementId, long measurementItemId, + int packageCount, int sourceVersion) { + resolved(List.of(item)); + when(splits.lineage(inboundId)).thenReturn(new InboundSplitSettlementMapper.Lineage( + inboundId, 10001L, "CHILD", 31L, "REMAINDER", "NOT_SPLIT")); + when(splits.lockParent(31L)).thenReturn(new ParentRow(31L, 10001L, 901L, "ORDER-001", + "IN-001", "MASTER", 31L, "LOADING", 5, 5, "FULLY_SPLIT", 4)); + when(splits.lockParent(inboundId)).thenReturn(new ParentRow(inboundId, 10001L, 902L, + "ORDER-REMAINDER", "IN-001-" + (sourceVersion - 1), "CHILD", 31L, + "WAIT_SPLIT", packageCount, packageCount, "NOT_SPLIT", sourceVersion)); + when(splits.lockLatestApprovedItemsForInbound(10001L, inboundId)) + .thenReturn(List.of(new MeasurementItemRow(measurementItemId, measurementId, detailId, packageCount))); + when(allocations.lockConfirmed(10001L, inboundId)).thenAnswer(invocation -> + List.of(allocation(1L, 1, 501L, packageCount))); + } + + private void childWrites(long inboundId, long detailId, long measurementId, + long measurementItemId, int pickedPackageCount, int sourceVersion) { + when(splits.cloneChildWithPurpose(eq(10001L), eq(inboundId), eq(31L), anyLong(), anyLong(), + anyString(), anyString(), anyInt(), anyInt(), eq("REMAINDER"), eq(9L), eq("operator"))).thenReturn(1); + when(splits.insertBatchWithChildren(anyLong(), eq(10001L), eq(31L), anyString(), eq(inboundId), + anyString(), eq(4), anyInt(), org.mockito.ArgumentMatchers.isNull(), anyLong(), anyInt(), anyInt(), + eq(701L), eq(801L), anyString(), eq(9L), eq("operator"))).thenReturn(1); + when(splits.cloneMeasurement(eq(10001L), eq(inboundId), eq(measurementId), anyLong(), anyLong(), + anyLong(), anyInt(), eq(9L))).thenReturn(1); + when(splits.insertSplitItem(anyLong(), eq(10001L), anyLong(), eq(31L), anyLong(), anyInt(), + eq(detailId), eq(measurementId), eq(measurementItemId), anyInt())).thenReturn(1); + when(splits.cloneMeasurementItem(eq(10001L), eq(measurementId), eq(measurementItemId), anyLong(), + anyLong(), anyLong(), anyInt(), anyInt())).thenReturn(1); + when(splits.markSourceLoaded(10001L, inboundId, pickedPackageCount, 9L, sourceVersion)).thenReturn(1); + when(splits.touchRootSplit(10001L, 31L, 9L, 4)).thenReturn(1); + when(splits.applyBatch(eq(10001L), anyLong())).thenReturn(1); + when(splits.updateMeasurementItemForLoaded(eq(10001L), eq(measurementItemId), anyInt())).thenReturn(1); + when(splits.updateMeasurementForLoaded(eq(10001L), eq(inboundId), eq(measurementId), anyInt())).thenReturn(1); + when(splits.normalizeChildMetrics(eq(10001L), anyLong(), anyLong())).thenReturn(1); + when(splits.insertHistory(anyLong(), eq(10001L), anyLong(), anyString(), anyString(), eq(9L), + eq("operator"), anyString())).thenReturn(1); + when(splits.convergePartialPlanItem(anyLong(), eq(9L), eq("operator"))).thenReturn(1); + } + + private void stubOrderSplitSuccess() { + when(orderSplits.split(eq("internal-token"), eq(9L), any())).thenAnswer(invocation -> { + OrderFulfillmentSplitClient.CreateRequest request = invocation.getArgument(2); + OrderFulfillmentSplitClient.Child loaded = new OrderFulfillmentSplitClient.Child( + request.sourceOrderId(), "ORDER-001", null, "LOADED", request.loadedPackageCount(), + request.sourceOrderId(), request.loadingPlanId(), request.containerBookingId()); + OrderFulfillmentSplitClient.Child remainder = request.remainingPackageCount() == 0 ? null + : new OrderFulfillmentSplitClient.Child(1002L, "ORDER-REMAINDER", 1, "REMAINDER", + request.remainingPackageCount(), request.sourceOrderId(), request.loadingPlanId(), + request.containerBookingId()); + return ApiResponse.success(new OrderFulfillmentSplitClient.Response( + request.parentOrderId(), "ORDER-001", 2, loaded, remainder, + request.sourcePackageCount(), request.loadedPackageCount(), request.remainingPackageCount(), + request.loadingPlanId(), request.containerBookingId(), request.idempotencyKey())); + }); + } + + private void writes(int childCount, int pickedCount) { + when(splits.nextSplitSequence(10001L, 31L)).thenReturn(1); + when(splits.cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyLong(), anyString(), anyString(), anyInt(), + anyInt(), anyString(), eq(9L), eq("operator"))).thenReturn(1); + when(splits.insertBatchWithChildren(anyLong(), eq(10001L), eq(31L), anyString(), eq(31L), anyString(), eq(3), + anyInt(), org.mockito.ArgumentMatchers.isNull(), any(), anyInt(), anyInt(), eq(701L), eq(801L), + anyString(), eq(9L), eq("operator"))).thenReturn(1); + when(splits.cloneMeasurement(eq(10001L), eq(31L), eq(61L), anyLong(), anyLong(), anyLong(), + anyInt(), eq(9L))).thenReturn(1); + when(splits.insertSplitItem(anyLong(), eq(10001L), anyLong(), eq(31L), anyLong(), anyInt(), + eq(41L), eq(61L), eq(51L), anyInt())).thenReturn(1); + when(splits.cloneMeasurementItem(eq(10001L), eq(61L), eq(51L), anyLong(), anyLong(), anyLong(), + anyInt(), anyInt())).thenReturn(1); + when(splits.updateMeasurementItemForLoaded(eq(10001L), eq(51L), anyInt())).thenReturn(1); + when(splits.updateMeasurementForLoaded(eq(10001L), eq(31L), eq(61L), anyInt())).thenReturn(1); + when(splits.normalizeChildMetrics(eq(10001L), anyLong(), anyLong())).thenReturn(1); + when(splits.markSourceLoaded(eq(10001L), eq(31L), anyInt(), eq(9L), eq(3))).thenReturn(1); + when(splits.applyBatch(eq(10001L), anyLong())).thenReturn(1); + when(splits.insertHistory(anyLong(), eq(10001L), anyLong(), anyString(), anyString(), eq(9L), + eq("operator"), anyString())).thenReturn(1); + when(splits.convergePartialPlanItem(21L, 9L, "operator")).thenReturn(1); + } + + private static ParentRow parent(int packageCount) { + return parent(packageCount, packageCount, "WAIT_SPLIT", "NOT_SPLIT"); + } + + private static ParentRow parent(int totalPackageCount, int packageCount, String processStatus, String splitStatus) { + return new ParentRow(31L, 10001L, 901L, "ORDER-001", "IN-001", "MASTER", 31L, + processStatus, totalPackageCount, packageCount, splitStatus, 3); + } + + private static InboundPalletAllocationEntity allocation(long id, int no, long locationId, int allocated) { + InboundPalletAllocationEntity value = new InboundPalletAllocationEntity(); + value.setId(id); value.setAllocationNo(no); value.setLocationId(locationId); value.setWarehouseId(701L); + value.setBlockId(800L); value.setSlotNo(no); value.setAllocationStatus("CONFIRMED"); + value.setReservationId(90L + no); + value.setAllocatedPackageCount(allocated); value.setPickedPackageCount(0); + return value; + } + + private static WarehousePalletLocationClient.BlockResult block(String status) { + return new WarehousePalletLocationClient.BlockResult(800L, status, List.of( + new WarehousePalletLocationClient.BlockResource( + 800L, 91L, 501L, "L-1", 1, false, status))); + } + + private static LoadingPickItemEntity item(long id, long planItemId, long inboundId, long detailId, + int planned, int picked, String status) { + LoadingPickItemEntity value = new LoadingPickItemEntity(); + value.setId(id); value.setLoadingPlanItemId(planItemId); value.setInboundId(inboundId); + value.setInboundDetailId(detailId); value.setPlannedPackageCount(planned); + value.setPickedPackageCount(picked); value.setItemStatus(status); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaWarehouseCatalogServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaWarehouseCatalogServiceTest.java new file mode 100644 index 000000000..521bb9f12 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaWarehouseCatalogServiceTest.java @@ -0,0 +1,39 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +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.Mockito.when; + +class PdaWarehouseCatalogServiceTest { + @Test + void returnsDomesticWarehouseMasterDataWithoutHardcodedOptions() { + WarehouseMasterDataClient client = mock(WarehouseMasterDataClient.class); + PdaWarehouseCatalogService service = new PdaWarehouseCatalogService(); + ReflectionTestUtils.setField(service, "warehouses", client); + ReflectionTestUtils.setField(service, "internalToken", "token"); + var option = new WarehouseMasterDataClient.WarehouseOption(200001L, "CN-DONGGUAN-01", "东莞仓"); + when(client.domestic("token")).thenReturn(ApiResponse.success(List.of(option))); + + assertThat(service.activeDomesticWarehouses()).containsExactly(option); + } + + @Test + void failsClosedWhenWarehouseDirectoryIsUnavailable() { + WarehouseMasterDataClient client = mock(WarehouseMasterDataClient.class); + PdaWarehouseCatalogService service = new PdaWarehouseCatalogService(); + ReflectionTestUtils.setField(service, "warehouses", client); + ReflectionTestUtils.setField(service, "internalToken", "token"); + when(client.domestic("token")).thenThrow(new IllegalStateException("down")); + + assertThatThrownBy(service::activeDomesticWarehouses).isInstanceOf(BizException.class); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java new file mode 100644 index 000000000..1e6ff9f5a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java @@ -0,0 +1,400 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.PriceBookFileClient; +import com.baiyue.firstleg.client.PriceBookWarehouseClient; +import com.baiyue.firstleg.domain.PriceBookStatus; +import com.baiyue.firstleg.domain.PriceModel; +import com.baiyue.firstleg.domain.PricingDestinationType; +import com.baiyue.firstleg.domain.PricingTaxMode; +import com.baiyue.firstleg.dto.PriceBookConfirmationRequest; +import com.baiyue.firstleg.dto.PriceBookDtos; +import com.baiyue.firstleg.entity.CarrierFbaRateEntity; +import com.baiyue.firstleg.entity.PriceBookChannelEntity; +import com.baiyue.firstleg.entity.PriceBookVersionEntity; +import com.baiyue.firstleg.entity.PriceChannelCatalogEntity; +import com.baiyue.firstleg.entity.PriceWarehouseMappingEntity; +import com.baiyue.firstleg.repository.PriceBookRepository; +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.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; + +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.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PlatformPriceBookServiceTest { + @Mock private PriceBookRepository repository; + @Mock private PriceBookFileClient files; + @Mock private PriceBookWarehouseClient warehouses; + @Mock private TransactionTemplate transactions; + @Mock private TransactionStatus transactionStatus; + private PlatformPriceBookService service; + + @BeforeEach + void setUp() { + service = new PlatformPriceBookService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "files", files); + ReflectionTestUtils.setField(service, "warehouses", warehouses); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(service, "transactions", transactions); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + } + + @Test + void listsCustomerSpecificVersionsWithinRequestedScope() { + PriceBookVersionEntity customerVersion = version(21L, PriceBookStatus.ACTIVE); + customerVersion.setTenantId(10001L); + when(repository.listVersions(10001L, PriceBookStatus.ACTIVE.name())) + .thenReturn(List.of(customerVersion)); + + var result = service.list(10001L, PriceBookStatus.ACTIVE); + + assertThat(result).singleElement().satisfies(summary -> { + assertThat(summary.id()).isEqualTo(21L); + assertThat(summary.customerId()).isEqualTo(10001L); + }); + verify(repository).listVersions(10001L, PriceBookStatus.ACTIVE.name()); + } + + @Test + void fileServiceFailureMarksAllocatedVersionFailed() { + executeTransactionsImmediately(); + org.mockito.Mockito.doAnswer(invocation -> { + PriceBookVersionEntity value = invocation.getArgument(0); + value.setId(9007199254740993L); + return 1; + }).when(repository).insertVersion(any()); + when(repository.nextVersionNo()).thenReturn(1); + when(files.upload(any(), any(Long.class), any(Long.class), any())).thenReturn(null); + var upload = new MockMultipartFile("file", "price.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1}); + + assertThatThrownBy(() -> service.upload(7L, upload)) + .isInstanceOf(BizException.class) + .hasMessageContaining("原文件保存失败"); + + ArgumentCaptor saved = ArgumentCaptor.forClass(PriceBookVersionEntity.class); + verify(repository).updateVersion(saved.capture()); + assertThat(saved.getValue().getId()).isEqualTo(9007199254740993L); + assertThat(saved.getValue().getStatus()).isEqualTo(PriceBookStatus.FAILED.name()); + assertThat(saved.getValue().getBlockingErrorCount()).isEqualTo(1); + } + + @Test + void parseFailureKeepsImmutableSourceFileMetadataOnFailedVersion() { + executeTransactionsImmediately(); + org.mockito.Mockito.doAnswer(invocation -> { + PriceBookVersionEntity value = invocation.getArgument(0); + value.setId(88L); + return 1; + }).when(repository).insertVersion(any()); + when(repository.nextVersionNo()).thenReturn(2); + when(files.upload(any(), any(Long.class), any(Long.class), any())).thenReturn(ApiResponse.success( + new PriceBookFileClient.FileMetadata(501L, 88L, "price.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 3L, "abc123", "FBA_PRICE_BOOK_SOURCE"))); + when(files.download(any(), any(Long.class), any(Long.class), any(Long.class))) + .thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3})); + var invalid = new MockMultipartFile("file", "price.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1, 2, 3}); + + assertThatThrownBy(() -> service.upload(7L, invalid)) + .isInstanceOf(BizException.class); + + ArgumentCaptor saved = ArgumentCaptor.forClass(PriceBookVersionEntity.class); + verify(repository).updateVersion(saved.capture()); + assertThat(saved.getValue().getStatus()).isEqualTo(PriceBookStatus.FAILED.name()); + assertThat(saved.getValue().getSourceFileId()).isEqualTo(501L); + assertThat(saved.getValue().getSourceSha256()).isEqualTo("abc123"); + assertThat(saved.getValue().getParseIssuesJson()).contains("UPLOAD_OR_PARSE_FAILED"); + } + + @Test + void exposesOnlyUploadConfirmationAndReadOperations() { + assertThat(Arrays.stream(PlatformPriceBookService.class.getDeclaredMethods()) + .filter(method -> java.lang.reflect.Modifier.isPublic(method.getModifiers())) + .map(java.lang.reflect.Method::getName).toList()) + .containsOnly("list", "upload", "detail", "publish", "voidPending", "source"); + } + + @Test + void voidsPendingVersionAndRetainsReasonForAudit() { + PriceBookVersionEntity pending = version(41L, PriceBookStatus.PENDING_CONFIRMATION); + when(repository.findVersion(41L)).thenReturn(pending); + org.mockito.Mockito.doAnswer(invocation -> { + PriceBookVersionEntity value = invocation.getArgument(0); + value.setStatus(PriceBookStatus.VOIDED.name()); + value.setVoidedBy(7L); + value.setVoidReason(invocation.getArgument(3)); + return null; + }).when(repository).voidPending(any(), any(), any(Long.class), any()); + when(repository.listChannels(41L)).thenReturn(List.of()); + when(repository.listRates(41L)).thenReturn(List.of()); + + var result = service.voidPending(41L, 7L, new PriceBookDtos.PriceBookVoidRequest(" 上传错误 ")); + + assertThat(result.version().status()).isEqualTo(PriceBookStatus.VOIDED); + assertThat(result.version().voidedBy()).isEqualTo(7L); + assertThat(result.version().voidReason()).isEqualTo("上传错误"); + verify(repository).lockPlatformVersions(); + verify(repository).voidPending(any(), any(), any(Long.class), org.mockito.ArgumentMatchers.eq("上传错误")); + } + + @Test + void pendingDetailWarnsThatMissingActiveChannelWillBeRemoved() { + PriceBookVersionEntity pendingVersion = version(13L, PriceBookStatus.PENDING_CONFIRMATION); + PriceBookVersionEntity active = version(12L, PriceBookStatus.ACTIVE); + when(repository.findVersion(13L)).thenReturn(pendingVersion); + when(repository.findActive()).thenReturn(active); + when(repository.listChannels(13L)).thenReturn(List.of()); + when(repository.listChannels(12L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK"))); + when(repository.listRates(13L)).thenReturn(List.of()); + + var detail = service.detail(13L); + + assertThat(detail.version().status()).isEqualTo(PriceBookStatus.PENDING_CONFIRMATION); + assertThat(detail.issues()).extracting(PriceBookDtos.ParseIssue::issueCode) + .containsExactly("CHANNEL_MISSING_FROM_PENDING_UPLOAD"); + } + + @Test + void sourceDownloadIsProxiedWithSafeAttachmentHeaders() { + PriceBookVersionEntity version = version(14L, PriceBookStatus.SUPERSEDED); + version.setSourceFileId(601L); + version.setOriginalFileName("历史价格.xlsx"); + when(repository.findVersion(14L)).thenReturn(version); + when(files.download("internal-token", 7L, 14L, 601L)) + .thenReturn(ResponseEntity.ok().header(HttpHeaders.CONTENT_TYPE, "application/octet-stream") + .body(new byte[]{9, 8, 7})); + + ResponseEntity response = service.source(14L, 7L); + + assertThat(response.getBody()).containsExactly(9, 8, 7); + assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION)) + .startsWith("attachment;").contains("filename*="); + assertThat(response.getHeaders().getFirst("X-Content-Type-Options")).isEqualTo("nosniff"); + } + + @Test + void confirmationMapsWarehousesBeforeReplacingActiveVersion() { + PriceBookVersionEntity pendingVersion = version(21L, PriceBookStatus.PENDING_CONFIRMATION); + PriceBookVersionEntity active = version(20L, PriceBookStatus.ACTIVE); + when(repository.findVersion(21L)).thenReturn(pendingVersion); + when(repository.listChannels(21L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK"))); + when(repository.listRates(21L)).thenReturn(List.of(rate(77L))); + when(repository.activeCatalog()).thenReturn(List.of(catalog("MATSON_CLX_TRUCK"))); + when(repository.listActiveForUpdate()).thenReturn(List.of(active)); + when(warehouses.active("internal-token", 0L, 501L)).thenReturn(ApiResponse.success( + new PriceBookWarehouseClient.Detail(new PriceBookWarehouseClient.Warehouse(501L, 0L, + "CN-YIWU-01", "义乌仓", "DOMESTIC", "CN", "ACTIVE"), List.of()))); + org.mockito.Mockito.doAnswer(invocation -> { + PriceBookVersionEntity value = invocation.getArgument(0); + value.setStatus(PriceBookStatus.ACTIVE.name()); + return 1; + }).when(repository).activate(any(), any(), any(Long.class)); + + service.publish(21L, 7L, confirmation("CN-YIWU-01", 501L)); + + InOrder order = inOrder(repository); + order.verify(repository).lockPlatformVersions(); + order.verify(repository).findVersion(21L); + verify(repository).deletePendingChildren(21L); + verify(repository).supersede(any(PriceBookVersionEntity.class), any(), any(Long.class)); + verify(repository).activate(any(PriceBookVersionEntity.class), any(), any(Long.class)); + ArgumentCaptor pendingRate = ArgumentCaptor.forClass(CarrierFbaRateEntity.class); + verify(repository).insertRate(pendingRate.capture()); + assertThat(pendingRate.getValue().getStatus()).isEqualTo(PriceBookStatus.PENDING_CONFIRMATION.name()); + } + + @Test + void missingWarehouseMappingCannotPublish() { + PriceBookVersionEntity pendingVersion = version(22L, PriceBookStatus.PENDING_CONFIRMATION); + when(repository.findVersion(22L)).thenReturn(pendingVersion); + when(repository.listChannels(22L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK"))); + when(repository.listRates(22L)).thenReturn(List.of(rate(78L))); + when(repository.activeCatalog()).thenReturn(List.of(catalog("MATSON_CLX_TRUCK"))); + + assertThatThrownBy(() -> service.publish(22L, 7L, + new PriceBookConfirmationRequest(List.of(), List.of()))) + .isInstanceOf(BizException.class) + .hasMessageContaining("阻断错误"); + verify(repository, never()).activate(any(), any(), any(Long.class)); + verify(repository, never()).deletePendingChildren(22L); + } + + @Test + void previewDoesNotTreatDifferentUnmappedOriginsAsDuplicateRates() { + CarrierFbaRateEntity yiwu = rate(79L); + CarrierFbaRateEntity dongguan = rate(80L); + dongguan.setOriginWarehouseCode("CN-DONGGUAN-01"); + dongguan.setOriginWarehouseName("东莞仓"); + when(repository.activeCatalog()).thenReturn(List.of(catalog("MATSON_CLX_TRUCK"))); + PriceBookDtos.ParsedChannel parsedChannel = ReflectionTestUtils.invokeMethod( + service, "channelDto", channel("MATSON_CLX_TRUCK")); + PriceBookDtos.ParsedRate parsedYiwu = ReflectionTestUtils.invokeMethod(service, "rateDto", yiwu); + PriceBookDtos.ParsedRate parsedDongguan = ReflectionTestUtils.invokeMethod(service, "rateDto", dongguan); + + @SuppressWarnings("unchecked") + List issues = ReflectionTestUtils.invokeMethod( + service, + "validate", + List.of(parsedChannel), + List.of(parsedYiwu, parsedDongguan) + ); + + assertThat(issues).extracting(PriceBookDtos.ParseIssue::issueCode) + .contains("ORIGIN_MAPPING_REQUIRED") + .doesNotContain("DUPLICATE_RATE"); + } + + @Test + void initializedWarehouseMappingsBindFixedDomesticAndOverseasWarehouses() { + CarrierFbaRateEntity overseasRate = rate(81L); + overseasRate.setDestinationType(PricingDestinationType.OVERSEAS.name()); + overseasRate.setDestinationCode(null); + overseasRate.setDestinationName("洛杉矶仓"); + PriceBookDtos.ParsedRate parsed = ReflectionTestUtils.invokeMethod(service, "rateDto", overseasRate); + when(repository.activeWarehouseMappings()).thenReturn(List.of( + warehouseMapping("ORIGIN_CODE", "CN-YIWU-01", 193000000000200001L, "CN-YIWU-01", "DOMESTIC"), + warehouseMapping("OVERSEAS_NAME", "洛杉矶仓", 193000000000200003L, "US-LAX-01", "OVERSEAS") + )); + + @SuppressWarnings("unchecked") + List mapped = ReflectionTestUtils.invokeMethod( + service, "applyInitializedWarehouseMappings", List.of(parsed)); + + assertThat(mapped).singleElement().satisfies(rate -> { + assertThat(rate.originWarehouseId()).isEqualTo(193000000000200001L); + assertThat(rate.destinationWarehouseId()).isEqualTo(193000000000200003L); + assertThat(rate.destinationCode()).isEqualTo("US-LAX-01"); + }); + } + + @Test + void duplicatePublishIsRejectedAfterPublishLock() { + when(repository.findVersion(31L)).thenReturn(version(31L, PriceBookStatus.ACTIVE)); + + assertThatThrownBy(() -> service.publish(31L, 7L, confirmation("CN-YIWU-01", 501L))) + .isInstanceOf(BizException.class) + .hasMessageContaining("待确认上传记录"); + + InOrder order = inOrder(repository); + order.verify(repository).lockPlatformVersions(); + order.verify(repository).findVersion(31L); + verify(repository, never()).activate(any(), any(), any(Long.class)); + } + + @Test + void olderPendingVersionCannotReplaceNewerActiveVersion() { + PriceBookVersionEntity pending = version(5L, PriceBookStatus.PENDING_CONFIRMATION); + PriceBookVersionEntity active = version(8L, PriceBookStatus.ACTIVE); + when(repository.findVersion(5L)).thenReturn(pending); + when(repository.listActiveForUpdate()).thenReturn(List.of(active)); + + assertThatThrownBy(() -> service.publish(5L, 7L, confirmation("CN-YIWU-01", 501L))) + .isInstanceOf(BizException.class) + .hasMessageContaining("早于当前生效版本"); + + verify(repository).lockPlatformVersions(); + verify(repository, never()).activate(any(), any(), any(Long.class)); + } + + private static PriceBookVersionEntity version(long id, PriceBookStatus status) { + PriceBookVersionEntity value = new PriceBookVersionEntity(); + value.setId(id); + value.setTenantId(0L); + value.setVersionNo((int) id); + value.setOriginalFileName("price.xlsx"); + value.setStatus(status.name()); + value.setChannelCount(0); + value.setRateCount(0); + value.setBlockingErrorCount(0); + value.setWarningCount(0); + value.setDeleted(false); + return value; + } + + private static PriceBookChannelEntity channel(String code) { + PriceBookChannelEntity value = new PriceBookChannelEntity(); + value.setChannelCode(code); + value.setChannelName(code); + value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name()); + value.setSourceSheetName(code); + return value; + } + + private static PriceChannelCatalogEntity catalog(String code) { + PriceChannelCatalogEntity value = new PriceChannelCatalogEntity(); + value.setChannelCode(code); + value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name()); + value.setStatus("ACTIVE"); + return value; + } + + private static CarrierFbaRateEntity rate(long id) { + CarrierFbaRateEntity value = new CarrierFbaRateEntity(); + value.setId(id); + value.setChannelCode("MATSON_CLX_TRUCK"); + value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name()); + value.setOriginWarehouseCode("CN-YIWU-01"); + value.setOriginWarehouseName("义乌仓"); + value.setDestinationType(PricingDestinationType.FBA.name()); + value.setDestinationCode("ONT8"); + value.setPricingTaxMode(PricingTaxMode.TAX_INCLUDED.name()); + value.setChargeUnit("KG"); + value.setUnitPrice(new BigDecimal("10")); + value.setCurrency("CNY"); + value.setSourceSheetName("美森正班海卡"); + value.setSourceRowNumber(5); + value.setSourceDestinationText("ONT8"); + return value; + } + + private static PriceBookConfirmationRequest confirmation(String code, long id) { + return new PriceBookConfirmationRequest( + List.of(new PriceBookConfirmationRequest.OriginMapping(code, id)), List.of()); + } + + private static PriceWarehouseMappingEntity warehouseMapping(String sourceType, String sourceValue, long warehouseId, + String warehouseCode, String warehouseType) { + PriceWarehouseMappingEntity value = new PriceWarehouseMappingEntity(); + value.setSourceType(sourceType); + value.setSourceValue(sourceValue); + value.setWarehouseId(warehouseId); + value.setWarehouseCode(warehouseCode); + value.setWarehouseType(warehouseType); + value.setStatus("ACTIVE"); + return value; + } + + private void executeTransactionsImmediately() { + when(transactions.execute(any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(transactionStatus); + }); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingProvisionalAdjustmentTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingProvisionalAdjustmentTest.java new file mode 100644 index 000000000..b897bf500 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingProvisionalAdjustmentTest.java @@ -0,0 +1,165 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.client.PendingReviewOrderClient; +import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; +import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.LoadingPlanEntity; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; +import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; +import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.split.service.SplitPreloadService; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataAccessResourceFailureException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SmartLoadingProvisionalAdjustmentTest { + @Mock private SmartLoadingRepository repository; + @Mock private OrderCargoClient orderCargoClient; + @Mock private PendingReviewOrderClient pendingReviewOrders; + @Mock private InboundOrderCreationService inboundOrderCreationService; + @Mock private LoadingPickService loadingPickService; + @Mock private InboundHoldGuard inboundHoldGuard; + @Mock private LoadingModelBridgeService loadingModelBridge; + @Mock private SplitPreloadService splitPreloadService; + @Mock private ColoadGroupAssignmentPort coloadGroups; + @Mock private PriceBookQueryUseCase priceBooks; + @Mock private FirstlegDocumentNumberGenerator documentNumbers; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); + @InjectMocks private SmartLoadingService service; + + private LoadingPlanItemEntity item; + private InboundOrderEntity inbound; + + @BeforeEach + void setUp() { + item = new LoadingPlanItemEntity(); + item.setId(8L); + item.setLoadingPlanId(1L); + item.setAccountId(9L); + item.setInboundId(10L); + item.setInboundDetailId(20L); + item.setOrderId(30L); + item.setInboundCode("I10"); + item.setPackageCount(20); + item.setVersion(0); + + inbound = new InboundOrderEntity(); + inbound.setTenantId(9L); + inbound.setId(10L); + inbound.setProcessStatus("PREPARING"); + + when(loadingPickService.mutationSnapshot(anyLong())).thenReturn( + new LoadingPickService.MutationSnapshot(null, null, null, Map.of())); + } + + @Test + void adjustsProvisionalInboundFromTwentyToFiftyWithinDeclaredHundredBoxes() { + LoadingPlanEntity plan = plan(103); + ContainerBookingEntity booking = booking(); + OrderCargoClient.CargoBinding cargo = new OrderCargoClient.CargoBinding( + 20L, 30L, 1, "货物", 100, new BigDecimal("200"), new BigDecimal("50")); + OrderApprovedInboundCommand snapshot = new OrderApprovedInboundCommand( + 9L, 30L, "O30", "I10", 9L, "客户", 100L, "仓库", + null, null, null, "FIRST_LEG", "FBA", "FBA渠道", null, null, null, + null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 100); + + when(repository.plan(1L)).thenReturn(plan, plan(104)); + when(repository.item(1L, 8L)).thenReturn(item); + when(repository.booking(99L)).thenReturn(booking); + when(repository.inbound(9L, 10L)).thenReturn(inbound); + when(orderCargoClient.detail(nullable(String.class), eq(9L), eq(30L), eq(20L))) + .thenReturn(ApiResponse.success(cargo)); + when(orderCargoClient.inboundSnapshot(nullable(String.class), eq(9L), eq(30L))) + .thenReturn(ApiResponse.success(snapshot)); + when(repository.items(1L)).thenReturn(List.of(item)); + when(repository.touch(1L, 103, 7L, "u")).thenReturn(1); + when(repository.update(item)).thenReturn(1); + + Plan result = service.adjust(1L, 8L, + new AdjustRequest(50, 103, 0, "adjust-provisional"), 7L, "u"); + + assertThat(item.getPackageCount()).isEqualTo(50); + assertThat(result.items()).singleElement().satisfies(updated -> { + assertThat(updated.getTotalPackageCount()).isEqualTo(100); + assertThat(updated.getRemainingPackageCount()).isEqualTo(50); + }); + } + + @Test + void planReadFallsBackToAssignedCountWhenQuantityEnrichmentFails() { + LoadingPlanEntity plan = plan(103); + when(repository.planByBooking(99L)).thenReturn(plan); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(item)); + when(repository.inbound(9L, 10L)) + .thenThrow(new DataAccessResourceFailureException("read query failed")); + + Plan result = service.draftByBooking(99L); + + assertThat(result.items()).singleElement().satisfies(updated -> { + assertThat(updated.getTotalPackageCount()).isEqualTo(20); + assertThat(updated.getRemainingPackageCount()).isZero(); + }); + } + + @Test + void planReadUsesApprovedMeasurementWithoutCallingTheLockedAdjustmentQuery() { + inbound.setProcessStatus("WAIT_SPLIT"); + when(repository.planByBooking(99L)).thenReturn(plan(103)); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(item)); + when(repository.inbound(9L, 10L)).thenReturn(inbound); + when(repository.measuredPackageCount(9L, 10L, 20L)).thenReturn(100); + + Plan result = service.draftByBooking(99L); + + assertThat(result.items()).singleElement().satisfies(updated -> { + assertThat(updated.getTotalPackageCount()).isEqualTo(100); + assertThat(updated.getRemainingPackageCount()).isEqualTo(80); + }); + } + + private static LoadingPlanEntity plan(int version) { + LoadingPlanEntity value = new LoadingPlanEntity(); + value.setId(1L); + value.setContainerBookingId(99L); + value.setPlanStatus("EDITING"); + value.setVersion(version); + return value; + } + + private static ContainerBookingEntity booking() { + ContainerBookingEntity value = new ContainerBookingEntity(); + value.setId(99L); + value.setWarehouseId(100L); + value.setChannelCode("FBA"); + value.setDestinationWarehouseCode("FBA1"); + value.setCapacityCbm(new BigDecimal("60")); + value.setMaxWeightKg(new BigDecimal("20000")); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java new file mode 100644 index 000000000..11fb0cca6 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -0,0 +1,2078 @@ +package com.baiyue.firstleg.service; + +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.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.client.PendingReviewOrderClient; +import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignInboundRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignPendingReviewRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignPalletRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; +import com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus; +import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.GroupMemberCandidate; +import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery; +import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidate; +import com.baiyue.firstleg.dto.SmartLoadingDtos.PalletCandidate; +import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer; +import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery; +import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan; +import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.RemarksRequest; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.LoadingPlanEntity; +import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; +import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; +import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; +import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit; +import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.PlanColoadMemberRow; +import com.baiyue.firstleg.split.service.SplitPreloadService; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.SQLException; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.concurrent.atomic.AtomicInteger; + +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; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; + +@SpringJUnitConfig(SmartLoadingServiceTest.Config.class) +class SmartLoadingServiceTest { + @MockBean private SmartLoadingRepository repository; + @MockBean private OrderCargoClient cargoClient; + @MockBean private PendingReviewOrderClient pendingReviewOrders; + @MockBean private InboundOrderCreationService inboundOrderCreationService; + @MockBean private LoadingPickService loadingPickService; + @MockBean private InboundHoldGuard inboundHoldGuard; + @MockBean private LoadingModelBridgeService loadingModelBridge; + @MockBean private SplitPreloadService splitPreloadService; + @MockBean private ColoadGroupAssignmentPort coloadGroups; + @MockBean private FbaPricingService fbaPricingService; + @MockBean private PriceBookQueryUseCase priceBooks; + @MockBean private FirstlegDocumentNumberGenerator documentNumbers; + @MockBean private DropshipPalletizationMapper dropshipPallets; + @Resource private SmartLoadingService service; + + @TestConfiguration + static class Config { + @Bean SmartLoadingService smartLoadingService() { return new SmartLoadingService(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + } + + @BeforeEach + void setUp() { + when(documentNumbers.next("LP")).thenReturn("LP-260818-0001"); + when(loadingPickService.mutationSnapshot(anyLong())).thenReturn( + new LoadingPickService.MutationSnapshot(null, null, null, Map.of())); + when(repository.occupiedOrderIds(any(), anySet())).thenReturn(List.of()); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn( + ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5, + new BigDecimal("10"), new BigDecimal("2")))); + when(fbaPricingService.recalculateUnlockedContainerCustomerFees(anyLong(), anyLong(), anyString())) + .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( + FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0)); + when(dropshipPallets.physicalMetricsByPalletIds(any())).thenAnswer(invocation -> { + Collection palletIds = invocation.getArgument(0); + return palletIds.stream().map(palletId -> new DropshipPalletizationMapper.PalletMetrics( + palletId, 10, new BigDecimal("20"), new BigDecimal("15"), new BigDecimal("5"))).toList(); + }); + } + + @Test + void restoresDrafts() { + LoadingPlanEntity plan = plan("EDITING", 2); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.planByBooking(99L)).thenReturn(plan); + when(repository.plan(1L)).thenReturn(plan); + when(repository.items(1L)).thenReturn(List.of(item(8, 9, 20, 1))); + + assertThat(service.draftByBooking(99L).id()).isEqualTo(1L); + assertThat(service.detail(1L).bookingId()).isEqualTo(99L); + } + + @Test + void returnsPlanColoadGroupsWithOneBatchLookup() { + LoadingPlanEntity plan = plan("EDITING", 2); + when(repository.plan(1L)).thenReturn(plan); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of()); + when(repository.planColoadMembers(1L)).thenReturn(List.of( + new PlanColoadMemberRow(700L,"CLG-700",1L,30L,10L,4), + new PlanColoadMemberRow(700L,"CLG-700",1L,30L,11L,6), + new PlanColoadMemberRow(700L,"CLG-700",1L,31L,12L,5))); + + Plan result=service.detail(1L); + + assertThat(result.coLoadGroups()).singleElement().satisfies(group->{ + assertThat(group.groupId()).isEqualTo(700L); + assertThat(group.groupNo()).isEqualTo("CLG-700"); + assertThat(group.lockedPlanId()).isEqualTo(1L); + assertThat(group.memberOrderCount()).isEqualTo(2); + assertThat(group.inboundFragmentCount()).isEqualTo(3); + assertThat(group.fullPackageCount()).isEqualTo(15); + assertThat(group.memberInboundIds()).containsExactly(10L,11L,12L); + }); + verify(repository).planColoadMembers(1L); + verify(coloadGroups,never()).findByInbound(anyLong(),anyLong()); + } + + @Test + void returnsEveryInboundCompositionWhenAPlanCollapsesASealedPalletToOneRow() { + LoadingPlanEntity plan = plan("EDITING", 2); + when(repository.plan(1L)).thenReturn(plan); + when(repository.booking(99L)).thenReturn(booking()); + LoadingPlanItemEntity first=item(101L,1L,201L,6); + first.setInboundId(180L); first.setOrderId(280L); first.setOrderNo("O180"); first.setInboundCode("I180"); + first.setCustomerName("客户"); first.setPalletId(801L); first.setPalletNo("DP-0025"); first.setUnitType("SEALED_PALLET"); + LoadingPlanItemEntity second=item(102L,1L,202L,5); + second.setInboundId(181L); second.setOrderId(281L); second.setOrderNo("O181"); second.setInboundCode("I181"); + second.setCustomerName("客户"); second.setPalletId(801L); second.setPalletNo("DP-0025"); second.setUnitType("SEALED_PALLET"); + when(repository.items(1L)).thenReturn(List.of(first,second)); + var firstMember=new com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PlanPalletMemberRow(); + firstMember.setPalletId(801L); firstMember.setAccountId(1L); firstMember.setInboundId(180L); + firstMember.setOrderId(280L); firstMember.setOrderNo("O180"); firstMember.setInboundCode("I180"); + firstMember.setCustomerName("客户"); firstMember.setAvailablePackageCount(6); + var secondMember=new com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PlanPalletMemberRow(); + secondMember.setPalletId(801L); secondMember.setAccountId(1L); secondMember.setInboundId(181L); + secondMember.setOrderId(281L); secondMember.setOrderNo("O181"); secondMember.setInboundCode("I181"); + secondMember.setCustomerName("客户"); secondMember.setAvailablePackageCount(5); + when(dropshipPallets.palletInboundMembersByPalletIds(List.of(801L))) + .thenReturn(List.of(firstMember,secondMember)); + doReturn(List.of(new DropshipPalletizationMapper.PalletMetrics( + 801L, 11, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO))) + .when(dropshipPallets).physicalMetricsByPalletIds(any()); + + Plan result=service.detail(1L); + + assertThat(result.items()).singleElement().satisfies(row->assertThat(row.getPackageCount()).isEqualTo(11)); + assertThat(result.palletMembers()).singleElement().satisfies(pallet->{ + assertThat(pallet.palletId()).isEqualTo(801L); + assertThat(pallet.members()).extracting(GroupMemberCandidate::inboundCode).containsExactly("I180","I181"); + assertThat(pallet.members()).extracting(GroupMemberCandidate::availablePackageCount).containsExactly(6,5); + }); + verify(dropshipPallets).palletInboundMembersByPalletIds(List.of(801L)); + } + + @Test + void restoresLoadingPlanForContinuedAssignment() { + LoadingPlanEntity plan = plan("LOADING", 3); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.planByBooking(99L)).thenReturn(plan); + when(repository.items(1L)).thenReturn(List.of()); + + assertThat(service.draftByBooking(99L).status()).isEqualTo("LOADING"); + } + + @Test + void groupsCandidateSpecificationsByInboundCodeForQuantityOnlyPlanning() { + Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, + "MATSON_CLX_TRUCK", "美森正班海卡", 200L, "FBA1", "FBA仓", "客户", "服装", 3, + new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"), + null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00")); + Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L, + "MATSON_CLX_TRUCK", "美森正班海卡", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7, + new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"), + null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00")); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.candidates(100L)).thenReturn(List.of(first, second)); + + var result = service.inboundCandidates(99L, 100L); + + assertThat(result).singleElement().satisfies(candidate -> { + assertThat(candidate.inboundCode()).isEqualTo("I"); + assertThat(candidate.goodsSummary()).isEqualTo("服装、鞋类"); + assertThat(candidate.availablePackageCount()).isEqualTo(10); + assertThat(candidate.averageUnitVolumeCbm()).isEqualByComparingTo("0.410000"); + assertThat(candidate.availableVolumeCbm()).isEqualByComparingTo("4.1000"); + assertThat(candidate.unitGrossWeightKg()).isEqualByComparingTo("2"); + assertThat(candidate.orderStatusSnapshot()).isEqualTo("WAIT_SPLIT"); + assertThat(candidate.expectedArrivalAt()).isEqualTo(LocalDateTime.parse("2026-09-10T09:30:00")); + }); + } + + @Test + void pagesGlobalCandidatesWithoutBindingAContainer() { + Candidate clx = withChannel(candidate(9L, 10L, 20L, 30L, 4), + "MATSON_CLX_TRUCK", "美森正班海卡"); + Candidate max = withChannel(candidate(19L, 11L, 21L, 31L, 6), + "MATSON_MAX_TRUCK", "美森加班海卡"); + InboundCandidateQuery query = new InboundCandidateQuery( + List.of("MATSON_CLX"), null, null, null, null, null, + null, null, + List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 20); + CandidateUnit unit = new CandidateUnit(null, 9L, 10L, null, 10L); + when(repository.globalCandidateUnits(eq(query), anySet())) + .thenReturn(new PageResult<>(List.of(unit), 1, 20, 1, 1)); + when(repository.globalCandidatesByUnits(List.of(unit))).thenReturn(List.of(clx)); + + var result = service.inboundCandidates(query); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.pages()).isEqualTo(1); + assertThat(result.records()).singleElement().satisfies(candidate -> { + assertThat(candidate.inboundId()).isEqualTo(10L); + assertThat(candidate.channelCode()).isEqualTo("MATSON_CLX_TRUCK"); + }); + verify(repository).globalCandidateUnits(eq(query), anySet()); + verify(repository).globalCandidatesByUnits(List.of(unit)); + verify(coloadGroups, never()).findByInbound(anyLong(), anyLong()); + verify(repository, never()).booking(anyLong()); + } + + @Test + void pagesEachAvailableDropshipPalletAsAnIndependentCandidate() { + Candidate dropship = withStatus(withChannel(candidate(9L, 10L, 20L, 30L, 12), + "EXX_TRUCK", "EXX海卡"), "LOADING", true); + InboundCandidateQuery query = new InboundCandidateQuery( + List.of("EXX"), null, null, "EXXHWK2609140003", null, null, + null, null, List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 20); + List units = List.of( + new CandidateUnit(null, 9L, 10L, 801L, null, 10L), + new CandidateUnit(null, 9L, 10L, 802L, null, 10L)); + when(repository.globalCandidateUnits(eq(query), anySet())) + .thenReturn(new PageResult<>(units, 1, 20, 2, 1)); + when(repository.globalCandidatesByUnits(units)).thenReturn(List.of(dropship)); + when(dropshipPallets.palletCandidates(10L)).thenReturn(List.of( + new PalletCandidate(801L, "DP-260914-0004", "SEALED_PALLET", true, + 8, 1, new BigDecimal("79.2"), new BigDecimal("4.4")), + new PalletCandidate(802L, "DP-260914-0005", "SEALED_PALLET", true, + 4, 1, new BigDecimal("39.6"), new BigDecimal("2.2")))); + + var result = service.inboundCandidates(query); + + assertThat(result.total()).isEqualTo(2); + assertThat(result.records()).extracting(InboundCandidate::palletId, + InboundCandidate::palletNo, InboundCandidate::availablePackageCount) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(801L, "DP-260914-0004", 8), + org.assertj.core.groups.Tuple.tuple(802L, "DP-260914-0005", 4)); + assertThat(result.records()).allSatisfy(candidate -> { + assertThat(candidate.unitType()).isEqualTo("SEALED_PALLET"); + assertThat(candidate.indivisible()).isTrue(); + assertThat(candidate.locked()).isFalse(); + assertThat(candidate.canAssign()).isTrue(); + assertThat(candidate.blockReason()).isNull(); + }); + } + + @Test + void sharedPalletIsOneUnitWithExactInboundComposition() { + Candidate dropship = withStatus(withChannel(candidate(9L, 10L, 20L, 100L, 20), "EXX_TRUCK", "EXX海卡"), "WAIT_SPLIT", false); + // Searching a non-representative member must still return the whole pallet. + InboundCandidateQuery query = new InboundCandidateQuery(List.of("EXX"), null, null, "I11", + null, null, null, null, List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 1); + var unit = new CandidateUnit(null, 9L, 10L, 801L, null, 10L); + when(repository.globalCandidateUnits(eq(query), anySet())).thenReturn(new PageResult<>(List.of(unit), 1, 1, 1, 1)); + when(repository.globalCandidatesByUnits(List.of(unit))).thenReturn(List.of(dropship)); + when(dropshipPallets.palletCandidates(10L)).thenReturn(List.of( + new PalletCandidate(801L, "DP-0025", "SEALED_PALLET", true, 13, 1, new BigDecimal("130"), new BigDecimal("1.3")))); + var members = List.of( + new com.baiyue.firstleg.dto.SmartLoadingDtos.GroupMemberCandidate(9L, 10L, 100L, "O10", "I10", "客户", 3, "WAIT_SPLIT", null), + new com.baiyue.firstleg.dto.SmartLoadingDtos.GroupMemberCandidate(9L, 11L, 101L, "O11", "I11", "客户", 10, "WAIT_SPLIT", null)); + when(dropshipPallets.palletInboundMembers(801L)).thenReturn(members); + var result = service.inboundCandidates(query); + assertThat(result.total()).isEqualTo(1); + assertThat(result.records()).singleElement().satisfies(row -> { + assertThat(row.availablePackageCount()).isEqualTo(13); + assertThat(row.groupMembers()).containsExactlyElementsOf(members); + assertThat(row.unitGrossWeightKg()).isEqualByComparingTo("10"); + }); + } + + @Test + void combinesAllUnfinishedStatusesBeforeReturningTheRequestedPage() { + InboundCandidateQuery query = new InboundCandidateQuery( + List.of("EXX"), null, null, null, null, null, null, null, List.of(), 1, 20); + var pending = new PendingReviewOrderClient.Row(31L, "O31", "I31", 9L, "客户", + 100L, "EXX_TRUCK", "EXX海卡", 3, LocalDateTime.parse("2026-09-01T09:00:00"), + LocalDateTime.parse("2026-09-08T09:00:00"), "PENDING_REVIEW"); + when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull(), anyList(), anyLong(), anyLong())).thenAnswer(invocation -> { + long page = invocation.getArgument(10); + long size = invocation.getArgument(11); + return ApiResponse.success(new PageResult<>(page == 1 ? List.of(pending) : List.of(), + page, size, 1, 1)); + }); + Candidate measured = withStatus(candidate(9L, 10L, 20L, 30L, 4), "WAIT_SPLIT", false); + CandidateUnit unit = new CandidateUnit(null, 9L, 10L, null, 10L); + when(repository.globalCandidateUnits(any(), anySet())).thenAnswer(invocation -> { + InboundCandidateQuery requested = invocation.getArgument(0); + return new PageResult<>(requested.page() == 1 ? List.of(unit) : List.of(), + requested.page(), requested.size(), 1, 1); + }); + when(repository.globalCandidatesByUnits(List.of(unit))).thenReturn(List.of(measured)); + + var result = service.inboundCandidates(query); + + assertThat(result.total()).isEqualTo(2); + assertThat(result.records()).extracting(InboundCandidate::orderStatusSnapshot) + .containsExactly("PENDING_REVIEW", "WAIT_SPLIT"); + assertThat(result.records()).extracting(InboundCandidate::canAssign) + .containsExactly(true, true); + ArgumentCaptor firstlegQueries = + ArgumentCaptor.forClass(InboundCandidateQuery.class); + verify(repository).globalCandidateUnits(firstlegQueries.capture(), anySet()); + assertThat(firstlegQueries.getValue().page()).isEqualTo(1); + assertThat(firstlegQueries.getValue().size()).isEqualTo(19); + assertThat(firstlegQueries.getAllValues()).allSatisfy(requested -> + assertThat(requested.orderStatuses()).containsExactly( + CandidateOrderStatus.WAIT_MEASURE, CandidateOrderStatus.WAIT_SPLIT, + CandidateOrderStatus.LOADING)); + verify(pendingReviewOrders).page(anyString(), anyList(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull(), anyList(), eq(1L), eq(20L)); + } + + @Test + void statusDoesNotBlockAssignmentButAnActiveContainerOccupationDoes() { + InboundCandidateQuery query = new InboundCandidateQuery(List.of("EXX"), null, null, null, + null, null, null, null, + List.of(CandidateOrderStatus.WAIT_MEASURE, CandidateOrderStatus.LOADING), 1, 20); + Candidate waiting = withStatus(candidate(9L, 10L, 20L, 30L, 4), "WAIT_MEASURE", false); + Candidate loading = withStatus(candidate(19L, 11L, 21L, 31L, 0), "LOADING", true); + List units = List.of(new CandidateUnit(null, 9L, 10L, null, 10L), + new CandidateUnit(null, 19L, 11L, null, 11L)); + when(repository.globalCandidateUnits(eq(query), anySet())) + .thenReturn(new PageResult<>(units, 1, 20, 2, 1)); + when(repository.globalCandidatesByUnits(units)).thenReturn(List.of(waiting, loading)); + + var result = service.inboundCandidates(query); + + assertThat(result.records()).filteredOn(candidate -> "WAIT_MEASURE".equals(candidate.orderStatusSnapshot())) + .singleElement().satisfies(candidate -> assertThat(candidate.canAssign()).isTrue()); + assertThat(result.records()).filteredOn(candidate -> "LOADING".equals(candidate.orderStatusSnapshot())) + .singleElement().satisfies(candidate -> { + assertThat(candidate.canAssign()).isFalse(); + assertThat(candidate.blockReason()).isEqualTo("该入仓码已被有效柜次占用"); + }); + } + + @Test + void keepsAnOccupiedLoadingGroupAsOneNonAssignablePagingUnit() { + InboundCandidateQuery query = new InboundCandidateQuery(List.of("EXX"), null, null, null, + null, null, null, null, List.of(CandidateOrderStatus.LOADING), 1, 20); + Candidate first = withStatus(withGroup(candidate(9L, 10L, 20L, 30L, 0), 2, 10), + "LOADING", true); + Candidate second = withStatus(withGroup(candidate(19L, 11L, 21L, 31L, 0), 2, 10), + "LOADING", true); + CandidateUnit unit = new CandidateUnit(700L, null, null, null, 10L); + when(repository.globalCandidateUnits(eq(query), anySet())) + .thenReturn(new PageResult<>(List.of(unit), 1, 20, 1, 1)); + when(repository.globalCandidatesByUnits(List.of(unit))).thenReturn(List.of(first, second)); + + var result = service.inboundCandidates(query); + + assertThat(result.records()).singleElement().satisfies(candidate -> { + assertThat(candidate.coLoadGroupId()).isEqualTo(700L); + assertThat(candidate.groupMembers()).hasSize(2); + assertThat(candidate.canAssign()).isFalse(); + assertThat(candidate.orderStatusSnapshot()).isEqualTo("LOADING"); + }); + } + + @Test + void pagesPendingReviewOrdersFromTheOrderOwnedReadModelAsAssignableRows() { + InboundCandidateQuery query = new InboundCandidateQuery( + List.of("EXX"), 193000000000200002L, null, "EXXHK2609020361", + LocalDate.parse("2026-09-02"), LocalDate.parse("2026-09-02"), + LocalDate.parse("2026-09-08"), LocalDate.parse("2026-09-09"), + List.of(CandidateOrderStatus.PENDING_REVIEW), 1, 20); + var row = new PendingReviewOrderClient.Row(2095052002489335810L, "FL-361", + "EXXHK2609020361", 2092797167699816450L, "客户", 193000000000200002L, + "EXX_TRUCK", "EXX海卡", 100, LocalDateTime.parse("2026-09-02T15:31:36"), + LocalDateTime.parse("2026-09-08T10:00:00"), "PENDING_REVIEW"); + when(repository.occupiedOrderIds(eq(query), anySet())).thenReturn(List.of(998L)); + when(pendingReviewOrders.page(anyString(), anyList(), eq(193000000000200002L), + isNull(), eq("EXXHK2609020361"), eq("2026-09-02"), + eq("2026-09-02"), eq("2026-09-08"), eq("2026-09-09"), + eq(List.of(998L)), eq(1L), eq(20L))) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(row), 1, 20, 1, 1))); + + var result = service.inboundCandidates(query); + + assertThat(result.records()).singleElement().satisfies(candidate -> { + assertThat(candidate.orderId()).isEqualTo(2095052002489335810L); + assertThat(candidate.inboundId()).isNull(); + assertThat(candidate.inboundCode()).isEqualTo("EXXHK2609020361"); + assertThat(candidate.orderStatusSnapshot()).isEqualTo("PENDING_REVIEW"); + assertThat(candidate.canAssign()).isTrue(); + assertThat(candidate.blockReason()).isNull(); + assertThat(candidate.availablePackageCount()).isEqualTo(100); + assertThat(candidate.expectedArrivalAt()).isEqualTo(LocalDateTime.parse("2026-09-08T10:00:00")); + }); + verify(repository, never()).globalCandidateUnits(any(), anySet()); + verify(repository, never()).globalCandidatesByUnits(anyList()); + verify(repository).occupiedOrderIds(eq(query), anySet()); + } + + @Test + void manuallyAssignsCrossChannelPendingReviewOrderAsAProvisionalContainerAllocation() { + long customerId = 2092797167699816450L; + long orderId = 2095052002489335810L; + String inboundCode = "EXXHK2609020361"; + var pending = new PendingReviewOrderClient.Row(orderId, "FL-361", inboundCode, + customerId, "客户", 100L, "SLOW", "慢船", 5, + LocalDateTime.parse("2026-09-02T15:31:36"), null, "PENDING_REVIEW"); + var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand( + customerId, orderId, "FL-361", inboundCode, customerId, "客户", 100L, "义乌仓", + null, null, null, "FIRST_LEG", "SLOW", "慢船", null, null, null, + null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 5); + var label = new OrderCargoClient.CargoLabelBinding(20L, orderId, 1, "货物", 5, + null, null, null); + var cargo = new OrderCargoClient.CargoBinding(20L, orderId, 1, "货物", 5, + new BigDecimal("50"), new BigDecimal("2.5")); + when(pendingReviewOrders.page(anyString(), isNull(), isNull(), eq(customerId), + eq(inboundCode), isNull(), isNull(), isNull(), isNull(), isNull(), eq(1L), eq(2L))) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(pending), 1, 2, 1, 1))); + when(cargoClient.inboundSnapshot(anyString(), eq(customerId), eq(orderId))) + .thenReturn(ApiResponse.success(snapshot)); + when(inboundOrderCreationService.prepareFromDraft(snapshot)).thenReturn(10L); + when(cargoClient.listForCartonLabels(anyString(), eq(customerId), eq(orderId))) + .thenReturn(ApiResponse.success(List.of(label))); + when(cargoClient.detail(anyString(), eq(customerId), eq(orderId), eq(20L))) + .thenReturn(ApiResponse.success(cargo)); + when(repository.plan(1L)).thenReturn(plan("EDITING", 0), plan("EDITING", 1)); + when(repository.booking(99L)).thenReturn(booking()); + InboundOrderEntity lockedInbound = new InboundOrderEntity(); + lockedInbound.setProcessStatus("PREPARING"); + when(repository.inboundForUpdate(customerId, 10L)).thenReturn(lockedInbound); + when(repository.items(1L)).thenReturn(List.of()); + when(repository.touch(1L, 0, 7L, "u")).thenReturn(1); + + Plan result = service.assignPendingReview(1L, + new AssignPendingReviewRequest(customerId, orderId, inboundCode, 5, 0, "pending-1"), + 7L, "u"); + + assertThat(result.version()).isEqualTo(1); + ArgumentCaptor item = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).insert(item.capture()); + assertThat(item.getValue().getInboundId()).isEqualTo(10L); + assertThat(item.getValue().getInboundDetailId()).isEqualTo(20L); + assertThat(item.getValue().getPackageCount()).isEqualTo(5); + assertThat(item.getValue().getGrossWeightKg()).isEqualByComparingTo("50"); + verify(splitPreloadService).ensurePendingForPreAllocation(customerId, 10L, 7L, "u"); + verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, 10L, 7L, "u", "pending-1"); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(customerId), anyString()); + } + + @Test + void manuallyAssignsCrossChannelMeasurePendingInboundUsingDeclaredFacts() { + long customerId = 9L; + long inboundId = 10L; + long orderId = 30L; + InboundOrderEntity projection = new InboundOrderEntity(); + projection.setId(inboundId); + projection.setTenantId(customerId); + projection.setOrderId(orderId); + projection.setInboundCode("I10"); + projection.setWarehouseId(100L); + projection.setChannelCode("SLOW"); + projection.setProcessStatus("MEASURE_PENDING"); + var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand( + customerId, orderId, "O30", "I10", customerId, "客户", 100L, "义乌仓", + null, null, null, "FIRST_LEG", "SLOW", "慢船", null, null, null, + null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 5); + var label = new OrderCargoClient.CargoLabelBinding(20L, orderId, 1, "货物", 5, + null, null, null); + var cargo = new OrderCargoClient.CargoBinding(20L, orderId, 1, "货物", 5, + new BigDecimal("50"), new BigDecimal("2.5")); + when(repository.inbound(inboundId)).thenReturn(projection); + when(repository.inboundForUpdate(customerId, inboundId)).thenReturn(projection); + when(repository.plan(1L)).thenReturn(plan("EDITING", 0), plan("EDITING", 1)); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of()); + when(repository.touch(1L, 0, 7L, "u")).thenReturn(1); + when(cargoClient.inboundSnapshot(anyString(), eq(customerId), eq(orderId))) + .thenReturn(ApiResponse.success(snapshot)); + when(cargoClient.listForCartonLabels(anyString(), eq(customerId), eq(orderId))) + .thenReturn(ApiResponse.success(List.of(label))); + when(cargoClient.detail(anyString(), eq(customerId), eq(orderId), eq(20L))) + .thenReturn(ApiResponse.success(cargo)); + + Plan result = service.assignInbound(1L, + new AssignInboundRequest(inboundId, 5, 0, "wait-measure-1"), 7L, "u"); + + assertThat(result.version()).isEqualTo(1); + verify(repository).insert(any(LoadingPlanItemEntity.class)); + verify(splitPreloadService).ensurePendingForPreAllocation(customerId, inboundId, 7L, "u"); + verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, inboundId, + 7L, "u", "wait-measure-1"); + } + + @Test + void assignsWaitSplitInboundToCabinetFromAnotherDomesticWarehouse() { + Candidate crossWarehouse = withWarehouse(candidate(1), 101L); + InboundOrderEntity projection = new InboundOrderEntity(); + projection.setId(10L); + projection.setTenantId(9L); + projection.setWarehouseId(101L); + projection.setProcessStatus("WAIT_SPLIT"); + AtomicInteger version = new AtomicInteger(); + List saved = new ArrayList<>(); + when(repository.inbound(10L)).thenReturn(projection); + when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get())); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.candidates(null)).thenReturn(List.of(crossWarehouse)); + when(repository.candidate(1L, 10L, 20L)).thenReturn(crossWarehouse); + when(repository.activeItemsByInbound(1L, 9L, 10L)).thenReturn(List.of()); + when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); + when(repository.items(1L)).thenAnswer(invocation -> List.copyOf(saved)); + when(repository.touch(eq(1L), anyInt(), eq(7L), eq("u"))).thenAnswer(invocation -> { + int expected = invocation.getArgument(1); + if (expected != version.get()) return 0; + version.incrementAndGet(); + return 1; + }); + doAnswer(invocation -> { + LoadingPlanItemEntity item = invocation.getArgument(0); + item.setId(800L); + saved.add(item); + return null; + }).when(repository).insert(any(LoadingPlanItemEntity.class)); + + Plan result = service.assignInbound(1L, + new AssignInboundRequest(10L, 1, 0, "assign-inbound-cross-warehouse"), 7L, "u"); + + assertThat(result.version()).isEqualTo(1); + assertThat(saved).singleElement().satisfies(item -> { + assertThat(item.getInboundId()).isEqualTo(10L); + assertThat(item.getPackageCount()).isEqualTo(1); + }); + verify(repository).candidates(null); + } + + @Test + void failsClosedWhenPendingReviewOrderSourceIsUnavailable() { + InboundCandidateQuery query = new InboundCandidateQuery( + List.of("EXX"), null, null, null, null, null, + null, null, + List.of(CandidateOrderStatus.PENDING_REVIEW), 1, 20); + when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull(), anyList(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline")); + + assertThatThrownBy(() -> service.inboundCandidates(query)) + .isInstanceOf(BizException.class) + .hasMessage("待审核订单服务暂时不可用"); + } + + @Test + void rejectsReversedExpectedArrivalRangeBeforeQueryingCandidates() { + InboundCandidateQuery query = new InboundCandidateQuery( + List.of("EXX"), null, null, null, null, null, + LocalDate.parse("2026-09-10"), LocalDate.parse("2026-09-09"), + List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 20); + + assertThatThrownBy(() -> service.inboundCandidates(query)) + .isInstanceOf(BizException.class) + .hasMessage("预计到货开始日期不能晚于结束日期"); + verify(repository, never()).globalCandidateUnits(any(), anySet()); + } + + @Test + void pagesWorkbenchContainersWithTheSameGlobalFilters() { + WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null, + 100L, "义乌仓", "MATSON_CLX", null, null, "40HQ", + new BigDecimal("68"), new BigDecimal("19500"), + 1L, "LP-1", "EDITING", 2); + WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L, + 9L, "IN-10", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30"), + 1, 20); + when(repository.workbenchContainers(eq(query), any())).thenReturn( + new com.baiyue.common.core.api.PageResult<>(List.of(container), 1, 20, 1, 1)); + + var result = service.containers(query); + + assertThat(result.records()).containsExactly(container); + @SuppressWarnings("unchecked") ArgumentCaptor> channels = + ArgumentCaptor.forClass(java.util.Set.class); + verify(repository).workbenchContainers(eq(query), channels.capture()); + assertThat(channels.getValue()).containsExactly("MATSON_CLX"); + } + + @Test + void treatsWholeColoadGroupAsOneStablePagingUnit() { + Candidate first = withGroup(withArrival(withChannel(candidate(9L, 10L, 20L, 30L, 4), + "MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-01T09:00:00"), 2, 10); + Candidate second = withGroup(withArrival(withChannel(candidate(19L, 11L, 21L, 31L, 6), + "MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-10T09:00:00"), 2, 10); + InboundCandidateQuery query = new InboundCandidateQuery( + List.of("MATSON_CLX"), 100L, null, null, LocalDate.parse("2026-09-01"), + LocalDate.parse("2026-09-01"), null, null, + List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 1); + CandidateUnit unit = new CandidateUnit(700L, null, null, + LocalDateTime.parse("2026-09-01T09:00:00"), 10L); + when(repository.globalCandidateUnits(eq(query), anySet())) + .thenReturn(new PageResult<>(List.of(unit), 1, 1, 1, 1)); + when(repository.globalCandidatesByUnits(List.of(unit))).thenReturn(List.of(first, second)); + + var result = service.inboundCandidates(query); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.records()).singleElement().satisfies(candidate -> { + assertThat(candidate.coLoadGroupId()).isEqualTo(700L); + assertThat(candidate.availablePackageCount()).isEqualTo(10); + assertThat(candidate.groupMembers()).extracting(GroupMemberCandidate::inboundId) + .containsExactly(10L, 11L); + }); + } + + @Test + void excludesIncompleteColoadGroupInsteadOfPublishingPartialMember() { + InboundCandidateQuery query = new InboundCandidateQuery( + List.of("MATSON_CLX"), 100L, null, null, null, null, + null, null, + List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 20); + when(repository.globalCandidateUnits(eq(query), anySet())) + .thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + var result = service.inboundCandidates(query); + + assertThat(result.records()).isEmpty(); + assertThat(result.total()).isZero(); + verify(repository, never()).globalCandidatesByUnits(anyList()); + } + + @Test + void channelDirectoryKeepsActiveZeroStatChannelAndAggregatesCandidateAndContainerFacts() { + ChannelRow maxTruck = channel("MATSON_MAX_TRUCK", "美森加班海卡"); + ChannelRow clxTruck = channel("MATSON_CLX_TRUCK", "美森正班海卡"); + Candidate first = candidate(9L, 10L, 20L, 30L, 4); + Candidate secondDetail = candidate(9L, 10L, 21L, 30L, 6); + Candidate secondInbound = candidate(19L, 11L, 22L, 31L, 5); + first = withChannel(first, "MATSON_MAX_TRUCK", "旧渠道快照名称"); + secondDetail = withChannel(secondDetail, "MATSON_MAX_TRUCK", "旧渠道快照名称"); + secondInbound = withChannel(secondInbound, "MATSON_MAX_TRUCK", "旧渠道快照名称"); + ChannelContainerCountRow count = new ChannelContainerCountRow(); + count.setChannelCode("MATSON_MAX_TRUCK"); count.setAvailableContainerCount(2L); + when(priceBooks.activeVersionId()).thenReturn(88L); + when(priceBooks.channels(88L, 100L)).thenReturn(List.of(maxTruck, clxTruck)); + when(repository.candidates(100L)).thenReturn(List.of(first, secondDetail, secondInbound)); + when(repository.channelContainerCounts(100L)).thenReturn(List.of(count)); + + var result = service.channels(100L); + + assertThat(result).hasSize(2); + assertThat(result.get(0)).satisfies(channel -> { + assertThat(channel.channelName()).isEqualTo("美森加班海卡"); + assertThat(channel.candidateInboundCount()).isEqualTo(2); + assertThat(channel.availablePackageCount()).isEqualTo(15); + assertThat(channel.availableContainerCount()).isEqualTo(2); + }); + assertThat(result.get(1)).satisfies(channel -> { + assertThat(channel.channelCode()).isEqualTo("MATSON_CLX_TRUCK"); + assertThat(channel.candidateInboundCount()).isZero(); + assertThat(channel.availablePackageCount()).isZero(); + assertThat(channel.availableContainerCount()).isZero(); + }); + } + + @Test + void exposesWholeColoadGroupFactsOnEveryMemberCandidate() { + Candidate first = candidate(9L, 10L, 20L, 30L, 4); + Candidate second = candidate(19L, 11L, 21L, 31L, 6); + GroupSnapshot group = group("ACTIVE", null, + new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6)); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.candidates(100L)).thenReturn(List.of(first, second)); + when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group); + when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group); + + var result = service.inboundCandidates(99L, 100L); + + assertThat(result).hasSize(2).allSatisfy(candidate -> { + assertThat(candidate.coLoadGroupId()).isEqualTo(700L); + assertThat(candidate.groupNo()).isEqualTo("CLG-700"); + assertThat(candidate.memberCount()).isEqualTo(2); + assertThat(candidate.groupTotalBoxes()).isEqualTo(10); + assertThat(candidate.locked()).isFalse(); + assertThat(candidate.blockReason()).isNull(); + }); + } + + @Test + void rejectsIndividualAssignmentForColoadMemberWithoutWriting() { + stubAssignment(); + GroupSnapshot group = group("ACTIVE", null, + new Member(9L, 30L, 10L, "IN-10", 10), new Member(19L, 31L, 11L, "IN-11", 5)); + when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group); + + assertThatThrownBy(() -> service.assign(1L, + new AssignRequest(10L, 20L, 10, 0, "individual-group-member"), 7L, "u")) + .isInstanceOf(BizException.class).hasMessageContaining("整组分配"); + + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); + verify(splitPreloadService, never()).ensurePendingForLoadingAllocation( + anyLong(), anyLong(), anyLong(), anyString()); + } + + @Test + void atomicallyAssignsEveryInboundFragmentOfSameOrderAcrossDifferentDestinations() { + Candidate first = withChannel(candidate(9L, 10L, 20L, 30L, 4), "SLOW", "慢船"); + Candidate second = withChannel(candidate(9L, 11L, 21L, 30L, 6, "FBA2"), "SLOW", "慢船"); + GroupSnapshot active = group("ACTIVE", null, + new Member(9L, 30L, 10L, "IN-10", 4), new Member(9L, 30L, 11L, "IN-11", 6)); + GroupSnapshot locked = group("LOCKED", 1L, + new Member(9L, 30L, 10L, "IN-10", 4), new Member(9L, 30L, 11L, "IN-11", 6)); + AtomicInteger version = new AtomicInteger(); + List saved = new ArrayList<>(); + when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get())); + when(repository.planByBooking(99L)).thenAnswer(invocation -> plan("EDITING", version.get())); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.inbound(10L)).thenReturn(inbound(9L, 10L)); + when(repository.candidates(null)).thenReturn(List.of(first, second)); + when(repository.candidate(1L, 10L, 20L)).thenReturn(first); + when(repository.candidate(1L, 11L, 21L)).thenReturn(second); + when(repository.items(1L)).thenAnswer(invocation -> List.copyOf(saved)); + when(repository.activeItemsByInbound(anyLong(), anyLong())).thenReturn(List.of()); + when(repository.activeItemsByInbound(anyLong(), anyLong(), anyLong())).thenReturn(List.of()); + when(repository.activeItem(anyLong(), anyLong(), anyLong())).thenReturn(null); + when(repository.touch(eq(1L), anyInt(), eq(7L), eq("u"))).thenAnswer(invocation -> { + int expected = invocation.getArgument(1); + if (expected != version.get()) return 0; + version.incrementAndGet(); + return 1; + }); + doAnswer(invocation -> { + LoadingPlanItemEntity item = invocation.getArgument(0); + item.setId(800L + saved.size()); + saved.add(item); + return null; + }).when(repository).insert(any(LoadingPlanItemEntity.class)); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenAnswer(invocation -> { + long orderId = invocation.getArgument(2); + long detailId = invocation.getArgument(3); + return ApiResponse.success(new OrderCargoClient.CargoBinding(detailId, orderId, 1, + "货物", detailId == 20L ? 4 : 6, BigDecimal.TEN, BigDecimal.ONE)); + }); + when(coloadGroups.findByInbound(9L, 10L)).thenReturn(active); + when(coloadGroups.findByInbound(9L, 11L)).thenReturn(active); + when(coloadGroups.lockWholeGroupForAssignment(eq(9L), eq(10L), eq(1L), + anyString(), eq(7L), eq("u"))).thenReturn(locked); + + Plan result = service.assignGroup(1L, + new com.baiyue.firstleg.dto.SmartLoadingDtos.AssignGroupRequest( + 10L, 0, "assign-whole-group"), 7L, "u"); + + assertThat(result.version()).isEqualTo(2); + assertThat(saved).extracting(LoadingPlanItemEntity::getInboundId, + LoadingPlanItemEntity::getPackageCount).containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple(10L, 4), + org.assertj.core.groups.Tuple.tuple(11L, 6)); + verify(coloadGroups).lockWholeGroupForAssignment(eq(9L), eq(10L), eq(1L), + org.mockito.ArgumentMatchers.startsWith("LOCK-"), eq(7L), eq("u")); + verify(loadingModelBridge, times(2)).bindIncrementalAllocation(eq(1L), anyLong(), anyLong(), + eq(7L), eq("u"), anyString()); + verify(repository, times(1)).candidates(null); + } + + @Test + void recommendSkipsWholeThreeMemberGroupWhenCombinedCapacityDoesNotFit() { + Candidate first = candidate(9L, 10L, 20L, 30L, 5); + Candidate second = candidate(19L, 11L, 21L, 31L, 5); + Candidate third = candidate(29L, 12L, 22L, 32L, 5); + GroupSnapshot group = group("ACTIVE", null, + new Member(9L, 30L, 10L, "IN-10", 5), new Member(19L, 31L, 11L, "IN-11", 5), + new Member(29L, 32L, 12L, "IN-12", 5)); + ContainerBookingEntity small = booking(); + small.setCapacityCbm(new BigDecimal("4.9")); + LoadingPlanEntity plan = plan("EDITING", 0); + when(repository.plan(1L)).thenReturn(plan); + when(repository.planByBooking(99L)).thenReturn(plan); + when(repository.booking(99L)).thenReturn(small); + when(repository.items(1L)).thenReturn(List.of()); + when(repository.candidates(null)).thenReturn(List.of(first, second, third)); + when(coloadGroups.findByInbound(anyLong(), anyLong())).thenReturn(group); + + Plan result = service.recommend(1L, new RecommendRequest(0, "recommend-group-too-large", null, null), 7L, "u"); + + assertThat(result.items()).isEmpty(); + verify(coloadGroups, never()).lockWholeGroupForAssignment(anyLong(), anyLong(), anyLong(), + anyString(), anyLong(), anyString()); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); + } + + @Test + void recommendAssignsMultipleDropshipCandidatesThroughWholePalletCommands() { + Candidate dropship = candidate(9L, 10L, 20L, 30L, 20); + Candidate anotherCustomer = candidate(19L, 11L, 21L, 31L, 10); + AtomicInteger version = new AtomicInteger(); + List saved = new ArrayList<>(); + InboundOrderEntity inbound = inbound(9L, 10L); + inbound.setFulfillmentType("ONE_CLICK_DROPSHIP"); + when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get())); + when(repository.planByBooking(99L)).thenAnswer(invocation -> plan("EDITING", version.get())); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenAnswer(invocation -> List.copyOf(saved)); + when(repository.candidates(null)).thenReturn(List.of(dropship, anotherCustomer)); + when(repository.inbound(9L, 10L)).thenReturn(inbound); + when(repository.activeItemsByPallet(anyLong())).thenReturn(List.of()); + when(repository.activePalletItem(eq(1L), anyLong(), eq(20L))).thenReturn(null); + when(dropshipPallets.palletCandidates(10L)).thenReturn(List.of( + new PalletCandidate(801L, "DP-260914-0021", "SEALED_PALLET", true, + 10, 1, new BigDecimal("20"), new BigDecimal("5")), + new PalletCandidate(802L, "DP-260914-0022", "SEALED_PALLET", true, + 10, 1, new BigDecimal("20"), new BigDecimal("5")))); + when(dropshipPallets.sealedPalletLines(801L)).thenReturn(List.of( + palletLine(801L, "DP-260914-0021", 10))); + when(dropshipPallets.sealedPalletLines(802L)).thenReturn(List.of( + palletLine(802L, "DP-260914-0022", 10))); + when(repository.touch(eq(1L), anyInt(), eq(7L), eq("u"))).thenAnswer(invocation -> { + int expected = invocation.getArgument(1); + if (expected != version.get()) return 0; + version.incrementAndGet(); + return 1; + }); + doAnswer(invocation -> { + LoadingPlanItemEntity item = invocation.getArgument(0); + item.setId(800L + saved.size()); + saved.add(item); + return null; + }).when(repository).insert(any(LoadingPlanItemEntity.class)); + + Plan result = service.recommend(1L, + new RecommendRequest(0, "recommend-dropship-pallet", 9L, "I10"), 7L, "u"); + + assertThat(result.version()).isEqualTo(2); + assertThat(saved).hasSize(2).allSatisfy(item -> { + assertThat(item.getUnitType()).isEqualTo("SEALED_PALLET"); + assertThat(item.getPackageCount()).isEqualTo(10); + assertThat(item.getIndivisible()).isTrue(); + }); + assertThat(saved).extracting(LoadingPlanItemEntity::getPalletId, + LoadingPlanItemEntity::getPalletNo).containsExactly( + org.assertj.core.groups.Tuple.tuple(801L, "DP-260914-0021"), + org.assertj.core.groups.Tuple.tuple(802L, "DP-260914-0022")); + verify(repository, never()).candidate(anyLong(), anyLong(), anyLong()); + verify(dropshipPallets, never()).palletCandidates(11L); + verify(fbaPricingService, times(1)).recalculateUnlockedContainerCustomerFees( + eq(99L), eq(9L), anyString()); + } + + @Test + void multiSkuPalletKeepsSkuLinesButCountsPhysicalCartonsAndVolumeOnce() { + AtomicInteger version = new AtomicInteger(); + List saved = new ArrayList<>(); + DropshipPalletizationMapper.PalletLine first = palletLine(801L, "DP-260916-0009", 20); + DropshipPalletizationMapper.PalletLine second = palletLine(801L, "DP-260916-0009", 20); + second.setInboundDetailId(21L); + when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get())); + when(repository.planByBooking(99L)).thenAnswer(invocation -> plan("EDITING", version.get())); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenAnswer(invocation -> List.copyOf(saved)); + when(repository.activeItemsByPallet(801L)).thenReturn(List.of()); + when(repository.activePalletItem(eq(1L), eq(801L), anyLong())).thenReturn(null); + when(repository.inbound(9L, 10L)).thenReturn(inbound(9L, 10L)); + when(dropshipPallets.sealedPalletLines(801L)).thenReturn(List.of(first, second)); + when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(21L))).thenReturn( + ApiResponse.success(new OrderCargoClient.CargoBinding(21L, 30L, 1, "货物 2", 5, + new BigDecimal("10"), new BigDecimal("2")))); + doReturn(List.of( + new DropshipPalletizationMapper.PalletMetrics(801L, 20, new BigDecimal("197"), + new BigDecimal("180"), new BigDecimal("3.365")))) + .when(dropshipPallets).physicalMetricsByPalletIds(any()); + when(repository.touch(eq(1L), anyInt(), eq(7L), eq("u"))).thenAnswer(invocation -> { + int expected = invocation.getArgument(1); + if (expected != version.get()) return 0; + version.incrementAndGet(); + return 1; + }); + doAnswer(invocation -> { + LoadingPlanItemEntity item = invocation.getArgument(0); + item.setId(800L + saved.size()); + saved.add(item); + return null; + }).when(repository).insert(any(LoadingPlanItemEntity.class)); + + Plan result = service.assignPallet(1L, + new AssignPalletRequest(801L, 0, "assign-multi-sku-pallet"), 7L, "u"); + + assertThat(saved).hasSize(2); + assertThat(saved).extracting(LoadingPlanItemEntity::getInboundDetailId) + .containsExactly(20L, 21L); + assertThat(saved.get(0).getVolumeCbm()).isEqualByComparingTo("3.365"); + assertThat(saved.get(1).getVolumeCbm()).isEqualByComparingTo("0"); + assertThat(result.items()).singleElement().satisfies(item -> { + assertThat(item.getPackageCount()).isEqualTo(20); + assertThat(item.getGrossWeightKg()).isEqualByComparingTo("197"); + assertThat(item.getVolumeCbm()).isEqualByComparingTo("3.365"); + }); + } + + @Test + void recommendSkipsStalePalletCandidateInsteadOfFailingTheWholeRecommendation() { + Candidate dropship = candidate(9L, 10L, 20L, 30L, 10); + when(repository.plan(1L)).thenReturn(plan("EDITING", 0)); + when(repository.planByBooking(99L)).thenReturn(plan("EDITING", 0)); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of()); + when(repository.candidates(null)).thenReturn(List.of(dropship)); + when(dropshipPallets.palletCandidates(10L)).thenReturn(List.of( + new PalletCandidate(801L, "DP-STALE", "SEALED_PALLET", true, + 10, 1, new BigDecimal("20"), new BigDecimal("5")))); + when(dropshipPallets.sealedPalletLines(801L)).thenReturn(List.of()); + + Plan result = service.recommend(1L, + new RecommendRequest(0, "recommend-stale-pallet", null, null), 7L, "u"); + + assertThat(result.items()).isEmpty(); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); + } + + @Test + void recommendRechecksCurrentCapacitySkipsStaleGroupAndContinuesWithLaterCandidate() { + Candidate ordinary = candidate(9L, 10L, 20L, 30L, 4); + Candidate groupFirst = candidate(9L, 11L, 21L, 31L, 4); + Candidate groupSecond = candidate(9L, 12L, 22L, 32L, 4); + Candidate later = candidate(9L, 13L, 23L, 33L, 1); + GroupSnapshot group = group("ACTIVE", null, + new Member(9L, 31L, 11L, "IN-11", 4), new Member(9L, 32L, 12L, "IN-12", 4)); + ContainerBookingEntity small = booking(); + small.setCapacityCbm(new BigDecimal("3.9")); + AtomicInteger version = new AtomicInteger(); + List saved = new ArrayList<>(); + when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get())); + when(repository.planByBooking(99L)).thenAnswer(invocation -> plan("EDITING", version.get())); + when(repository.booking(99L)).thenReturn(small); + when(repository.items(1L)).thenAnswer(invocation -> List.copyOf(saved)); + when(repository.candidates(null)).thenReturn(List.of(ordinary, groupFirst, groupSecond, later)); + when(repository.candidate(1L, 10L, 20L)).thenReturn(ordinary); + when(repository.candidate(1L, 13L, 23L)).thenReturn(later); + when(repository.activeItemsByInbound(anyLong(), anyLong(), anyLong())).thenReturn(List.of()); + when(repository.activeItem(anyLong(), anyLong(), anyLong())).thenReturn(null); + when(repository.touch(eq(1L), anyInt(), eq(7L), eq("u"))).thenAnswer(invocation -> { + int expected = invocation.getArgument(1); + if (expected != version.get()) return 0; + version.incrementAndGet(); + return 1; + }); + doAnswer(invocation -> { + LoadingPlanItemEntity item = invocation.getArgument(0); + item.setId(800L + saved.size()); + saved.add(item); + return null; + }).when(repository).insert(any(LoadingPlanItemEntity.class)); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenAnswer(invocation -> { + long orderId = invocation.getArgument(2); + long detailId = invocation.getArgument(3); + return ApiResponse.success(new OrderCargoClient.CargoBinding(detailId, orderId, 1, + "货物", detailId == 23L ? 1 : 4, BigDecimal.TEN, BigDecimal.ONE)); + }); + when(coloadGroups.findByInbound(9L, 11L)).thenReturn(group); + when(coloadGroups.findByInbound(9L, 12L)).thenReturn(group); + + Plan result = service.recommend(1L, + new RecommendRequest(0, "recommend-refresh-group-capacity", null, null), 7L, "u"); + + assertThat(result.version()).isEqualTo(2); + assertThat(saved).extracting(LoadingPlanItemEntity::getInboundId, + LoadingPlanItemEntity::getPackageCount).containsExactly( + org.assertj.core.groups.Tuple.tuple(10L, 4), + org.assertj.core.groups.Tuple.tuple(13L, 1)); + verify(coloadGroups, never()).lockWholeGroupForAssignment(anyLong(), anyLong(), anyLong(), + anyString(), anyLong(), anyString()); + } + + @Test + void manualGroupAssignmentOverCurrentCapacityStillReturnsA0422() { + Candidate first = candidate(9L, 11L, 21L, 31L, 4); + Candidate second = candidate(9L, 12L, 22L, 32L, 4); + GroupSnapshot group = group("ACTIVE", null, + new Member(9L, 31L, 11L, "IN-11", 4), new Member(9L, 32L, 12L, "IN-12", 4)); + ContainerBookingEntity small = booking(); + small.setCapacityCbm(new BigDecimal("2.6")); + when(repository.plan(1L)).thenReturn(plan("EDITING", 0)); + when(repository.planByBooking(99L)).thenReturn(plan("EDITING", 0)); + when(repository.booking(99L)).thenReturn(small); + when(repository.inbound(11L)).thenReturn(inbound(9L, 11L)); + when(repository.items(1L)).thenReturn(List.of()); + when(repository.candidates(null)).thenReturn(List.of(first, second)); + when(repository.activeItemsByInbound(anyLong(), anyLong())).thenReturn(List.of()); + when(coloadGroups.findByInbound(9L, 11L)).thenReturn(group); + when(coloadGroups.findByInbound(9L, 12L)).thenReturn(group); + + BizException error = catchThrowableOfType(() -> service.assignGroup(1L, + new com.baiyue.firstleg.dto.SmartLoadingDtos.AssignGroupRequest( + 11L, 0, "manual-group-too-large"), 7L, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + assertThat(error).hasMessageContaining("150%上限"); + verify(coloadGroups, never()).lockWholeGroupForAssignment(anyLong(), anyLong(), anyLong(), + anyString(), anyLong(), anyString()); + } + + @Test + void adjustAndIndividualDeleteRejectColoadMembersBeforeAnyWrite() { + LoadingPlanEntity plan = plan("EDITING", 0); + LoadingPlanItemEntity item = item(8L, 9L, 20L, 4); + GroupSnapshot group = group("LOCKED", 1L, + new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6)); + when(repository.plan(1L)).thenReturn(plan); + when(repository.item(1L, 8L)).thenReturn(item); + when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group); + + assertThatThrownBy(() -> service.adjust(1L, 8L, + new AdjustRequest(4, 0, 0, "adjust-group-member"), 7L, "u")) + .isInstanceOf(BizException.class).hasMessageContaining("整组分配"); + assertThatThrownBy(() -> service.delete(1L, 8L, + new DeleteRequest(0, 0, "delete-group-member"), 7L, "u")) + .isInstanceOf(BizException.class).hasMessageContaining("整组分配"); + + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); + verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).remove(anyLong()); + } + + @Test + void removesEveryGroupMemberAndUnlocksOnlyAfterAllRowsAreRemoved() { + LoadingPlanEntity plan = plan("EDITING", 0); + LoadingPlanEntity after = plan("EDITING", 1); + LoadingPlanItemEntity first = item(8L, 9L, 20L, 4); + LoadingPlanItemEntity second = item(9L, 19L, 21L, 6); + second.setInboundId(11L); + GroupSnapshot group = group("LOCKED", 1L, + new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6)); + when(repository.plan(1L)).thenReturn(plan, after); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(first, second), List.of()); + when(repository.activeItemsByInbound(9L, 10L)).thenReturn(List.of(first)); + when(repository.activeItemsByInbound(19L, 11L)).thenReturn(List.of(second)); + when(repository.touch(1L, 0, 7L, "u")).thenReturn(1); + when(repository.remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"))).thenReturn(1); + when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group); + when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group); + GroupSnapshot unlocked = group("ACTIVE", null, + new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6)); + when(coloadGroups.unlockWholeGroupAfterRemoval(eq(700L), eq(1L), + anyString(), eq(7L), eq("u"))).thenReturn(unlocked); + + Plan result = service.removeGroup(1L, 700L, + new com.baiyue.firstleg.dto.SmartLoadingDtos.RemoveGroupRequest( + 0, "remove-whole-group"), 7L, "u"); + + assertThat(result.items()).isEmpty(); + verify(repository, times(2)).remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u")); + verify(coloadGroups).unlockWholeGroupAfterRemoval(eq(700L), eq(1L), + org.mockito.ArgumentMatchers.startsWith("UNLOCK-"), eq(7L), eq("u")); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(9L), anyString()); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(19L), anyString()); + } + + @Test + void removingSharedPalletChecksEveryInboundOnceAfterAllLinesAreRemoved() { + LoadingPlanItemEntity first = item(8L, 9L, 20L, 4); + LoadingPlanItemEntity sameInbound = item(9L, 9L, 21L, 2); + LoadingPlanItemEntity secondInbound = item(10L, 9L, 22L, 3); + secondInbound.setInboundId(11L); + LoadingPlanItemEntity otherCustomer = item(11L, 19L, 23L, 1); + List members = List.of(first, sameInbound, secondInbound, otherCustomer); + members.forEach(value -> value.setPalletId(801L)); + when(repository.plan(1L)).thenReturn(plan("EDITING", 2), plan("EDITING", 3)); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.activeItemsByPallet(801L)).thenReturn(members); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"))).thenReturn(1); + + Plan result = service.removePallet(1L, 801L, + new com.baiyue.firstleg.dto.SmartLoadingDtos.RemovePalletRequest(2, "remove-shared-pallet"), + 7L, "u"); + + assertThat(result.items()).isEmpty(); + var ordered = inOrder(repository, loadingModelBridge); + for (LoadingPlanItemEntity member : members) { + ordered.verify(repository).remove(1L, member.getId(), 0, 7L, "u"); + } + ordered.verify(loadingModelBridge).removeIfUnallocated(1L, 9L, 10L); + ordered.verify(loadingModelBridge).removeIfUnallocated(1L, 9L, 11L); + ordered.verify(loadingModelBridge).removeIfUnallocated(1L, 19L, 10L); + verify(loadingModelBridge, times(3)).removeIfUnallocated(anyLong(), anyLong(), anyLong()); + verify(repository).touch(1L, 2, 7L, "u"); + } + + @Test + void sharedPalletWithAnyPickedMemberCannotRemoveLinesOrLinks() { + LoadingPlanItemEntity first = item(8L, 9L, 20L, 4); + LoadingPlanItemEntity second = item(9L, 9L, 21L, 3); + second.setInboundId(11L); + when(repository.plan(1L)).thenReturn(plan("LOADING", 2)); + when(repository.activeItemsByPallet(801L)).thenReturn(List.of(first, second)); + when(loadingPickService.mutationSnapshot(1L)).thenReturn( + new LoadingPickService.MutationSnapshot(77L, "LOADING", 5, Map.of(9L, 3))); + + assertThatThrownBy(() -> service.removePallet(1L, 801L, + new com.baiyue.firstleg.dto.SmartLoadingDtos.RemovePalletRequest(2, "keep-picked-pallet"), + 7L, "u")).isInstanceOf(BizException.class).hasMessageContaining("已发生实物拣货"); + + verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).remove(anyLong()); + verify(loadingModelBridge, never()).removeIfUnallocated(anyLong(), anyLong(), anyLong()); + } + + @Test + void removesAllUnpickedItemsWithOnePlanVersionChange() { + LoadingPlanEntity initial = plan("EDITING", 2); + LoadingPlanEntity after = plan("EDITING", 3); + LoadingPlanItemEntity first = item(8L, 9L, 20L, 4); + LoadingPlanItemEntity second = item(9L, 19L, 21L, 6); + second.setInboundId(11L); + when(repository.plan(1L)).thenReturn(initial, after); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(first, second), List.of()); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"))).thenReturn(1); + + Plan result = service.removeUnpicked(1L, + new com.baiyue.firstleg.dto.SmartLoadingDtos.RemoveUnpickedRequest(2, "remove-unpicked"), + 7L, "u"); + + assertThat(result.items()).isEmpty(); + verify(repository).touch(1L, 2, 7L, "u"); + verify(repository, times(2)).remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u")); + verify(loadingPickService, times(2)).remove(anyLong()); + } + + @Test + void bulkRemovalKeepsWholeGroupWhenAnyMemberWasPicked() { + LoadingPlanEntity initial = plan("LOADING", 2); + LoadingPlanEntity after = plan("LOADING", 3); + LoadingPlanItemEntity first = item(8L, 9L, 20L, 4); + LoadingPlanItemEntity second = item(9L, 19L, 21L, 6); + second.setInboundId(11L); + LoadingPlanItemEntity standalone = item(10L, 29L, 22L, 3); + standalone.setInboundId(12L); + GroupSnapshot group = group("LOCKED", 1L, + new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6)); + when(repository.plan(1L)).thenReturn(initial, after); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(first, second, standalone), List.of(first, second)); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.remove(1L, 10L, 0, 7L, "u")).thenReturn(1); + when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group); + when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group); + when(loadingPickService.mutationSnapshot(1L)).thenReturn( + new LoadingPickService.MutationSnapshot(77L, "LOADING", 5, Map.of(8L, 1))); + + Plan result = service.removeUnpicked(1L, + new com.baiyue.firstleg.dto.SmartLoadingDtos.RemoveUnpickedRequest(2, "keep-picked-group"), + 7L, "u"); + + assertThat(result.items()).extracting(LoadingPlanItemEntity::getId).containsExactly(8L, 9L); + verify(repository).remove(1L, 10L, 0, 7L, "u"); + verify(repository, never()).remove(eq(1L), eq(8L), anyInt(), anyLong(), anyString()); + verify(repository, never()).remove(eq(1L), eq(9L), anyInt(), anyLong(), anyString()); + verify(coloadGroups, never()).unlockWholeGroupAfterRemoval(anyLong(), anyLong(), anyString(), anyLong(), anyString()); + } + + @Test + void bulkRemovalRemovesAndUnlocksACompletelyUnpickedGroupOnce() { + LoadingPlanEntity initial = plan("EDITING", 2); + LoadingPlanEntity after = plan("EDITING", 3); + LoadingPlanItemEntity first = item(8L, 9L, 20L, 4); + LoadingPlanItemEntity second = item(9L, 19L, 21L, 6); + second.setInboundId(11L); + GroupSnapshot group = group("LOCKED", 1L, + new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6)); + when(repository.plan(1L)).thenReturn(initial, after); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(first, second), List.of()); + when(repository.activeItemsByInbound(9L, 10L)).thenReturn(List.of(first)); + when(repository.activeItemsByInbound(19L, 11L)).thenReturn(List.of(second)); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"))).thenReturn(1); + when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group); + when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group); + GroupSnapshot unlocked = group("ACTIVE", null, + new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6)); + when(coloadGroups.unlockWholeGroupAfterRemoval(eq(700L), eq(1L), anyString(), eq(7L), eq("u"))) + .thenReturn(unlocked); + + Plan result = service.removeUnpicked(1L, + new com.baiyue.firstleg.dto.SmartLoadingDtos.RemoveUnpickedRequest(2, "remove-unpicked-group"), + 7L, "u"); + + assertThat(result.items()).isEmpty(); + verify(repository, times(2)).remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u")); + verify(coloadGroups).unlockWholeGroupAfterRemoval(eq(700L), eq(1L), + org.mockito.ArgumentMatchers.startsWith("UNLOCK-"), eq(7L), eq("u")); + } + + @Test + void proportionallyDistributesInboundQuantityWithoutLosingCartons() { + Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, + "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "A", 30, + new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"), + null, null, null, null, null, null, null, null); + Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L, + "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "B", 40, + new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"), + null, null, null, null, null, null, null, null); + Candidate third = new Candidate(9L, 10L, 22L, 30L, "O", "I", 100L, + "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "C", 30, + new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.8"), + null, null, null, null, null, null, null, null); + + var result = service.proportionalAllocations(List.of(first, second, third), 60); + + assertThat(result).extracting(SmartLoadingService.InboundAllocation::packageCount) + .containsExactly(18, 24, 18); + assertThat(result).extracting(SmartLoadingService.InboundAllocation::candidate) + .extracting(Candidate::inboundDetailId).containsExactly(20L, 21L, 22L); + } + + @Test + void exposesPickedMinimumAndItemCapabilitiesForRunningTask() { + LoadingPlanEntity plan = plan("LOADING", 3); + LoadingPlanItemEntity line = item(8, 9, 20, 3); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.plan(1L)).thenReturn(plan); + when(repository.items(1L)).thenReturn(List.of(line)); + when(loadingPickService.mutationSnapshot(1L)).thenReturn( + new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of(8L, 2))); + + Plan result = service.detail(1L); + + assertThat(result.canAssign()).isTrue(); + assertThat(result.canEdit()).isTrue(); + assertThat(result.finalCompletionStatus()).isEqualTo("IN_PROGRESS"); + assertThat(result.pdaTaskId()).isEqualTo(77L); + assertThat(result.pdaTaskVersion()).isEqualTo(6); + assertThat(result.items()).singleElement().satisfies(item -> { + assertThat(item.getPickedPackageCount()).isEqualTo(2); + assertThat(item.getMinPackageCount()).isEqualTo(2); + assertThat(item.getCanAdjust()).isTrue(); + assertThat(item.getCanRemove()).isFalse(); + }); + } + + @Test + void completedTaskDisablesEveryIncrementalCapability() { + LoadingPlanEntity plan = plan("COMPLETED", 4); + LoadingPlanItemEntity line = item(8, 9, 20, 3); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.plan(1L)).thenReturn(plan); + when(repository.items(1L)).thenReturn(List.of(line)); + when(loadingPickService.mutationSnapshot(1L)).thenReturn( + new LoadingPickService.MutationSnapshot(77L, "COMPLETED", 7, Map.of(8L, 3))); + + Plan result = service.detail(1L); + + assertThat(result.canAssign()).isFalse(); + assertThat(result.canEdit()).isFalse(); + assertThat(result.blockedReason()).contains("最终完成"); + assertThat(result.finalCompletionStatus()).isEqualTo("COMPLETED"); + assertThat(result.items()).singleElement().satisfies(item -> { + assertThat(item.getCanAdjust()).isFalse(); + assertThat(item.getCanRemove()).isFalse(); + }); + } + + @Test + void doesNotRestoreCompletedPlanInTheLoadingWorkbench() { + when(repository.booking(99L)).thenReturn(booking()); + when(repository.planByBooking(99L)).thenReturn(plan("COMPLETED", 4)); + + BizException error = catchThrowableOfType( + () -> service.draftByBooking(99L), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).items(anyLong()); + } + + @Test + void returnsExistingDraftOnCreate() { + LoadingPlanEntity plan = plan("EDITING", 2); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.planByBooking(99L)).thenReturn(plan); + when(repository.items(1L)).thenReturn(List.of()); + + Plan result = service.createForContainer(99L, 7L, "u"); + + assertThat(result.id()).isEqualTo(1L); + verify(repository, never()).insert(any(LoadingPlanEntity.class)); + } + + @Test + void createsPlanWithShortStableNumber() { + when(repository.booking(99L)).thenReturn(booking()); + when(repository.planByBooking(99L)).thenReturn(null); + doAnswer(invocation -> { + LoadingPlanEntity value = invocation.getArgument(0); + value.setId(1L); + return 1; + }).when(repository).insert(any(LoadingPlanEntity.class)); + when(repository.items(1L)).thenReturn(List.of()); + + Plan result = service.createForContainer(99L, 7L, "u"); + + assertThat(result.planNo()).isEqualTo("LP-260818-0001"); + } + + @Test + void rejectsExcessBoxes() { + LoadingPlanEntity plan = plan("EDITING", 0); + when(repository.plan(1)).thenReturn(plan); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.candidate(1, 10, 20)).thenReturn(candidate(5)); + when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4)); + + BizException error = catchThrowableOfType(() -> service.assign(1, + new AssignRequest(10L, 20L, 6, 0, "assign-1"), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); + } + + @Test + void rejectsManualAssignmentOutsideLinkedBookingCustomerScope() { + LoadingPlanEntity plan = plan("EDITING", 0); + ContainerBookingEntity linked = booking(); + linked.setCustomerId(88L); + when(repository.plan(1L)).thenReturn(plan); + when(repository.booking(99L)).thenReturn(linked); + when(repository.candidate(1L, 10L, 20L)).thenReturn(candidate(5)); + + BizException error = catchThrowableOfType(() -> service.assign(1L, + new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); + } + + @Test + void manuallyAssignsCargoFromAnotherDomesticWarehouse() { + stubAssignment(); + when(repository.candidate(1L, 10L, 20L)).thenReturn( + withWarehouse(candidate(10), 101L)); + + Plan result = service.assign(1L, + new AssignRequest(10L, 20L, 1, 0, "assign-cross-warehouse"), 7L, "u"); + + assertThat(result.version()).isEqualTo(1); + verify(repository).insert(any(LoadingPlanItemEntity.class)); + verify(repository).touch(1L, 0, 7L, "u"); + } + + @Test + void allowsVolumeAndWeightWithinOneHundredFiftyPercent() { + stubAssignment(); + ContainerBookingEntity tolerant = booking(); + tolerant.setCapacityCbm(new BigDecimal("1")); + tolerant.setMaxWeightKg(new BigDecimal("4")); + when(repository.booking(99L)).thenReturn(tolerant); + + service.assign(1, new AssignRequest(10L, 20L, 3, 0, "assign-150-ok"), 7, "u"); + + verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u"); + verify(repository).touch(1, 0, 7, "u"); + } + + @Test + void rejectsVolumeOrWeightAboveOneHundredFiftyPercent() { + stubAssignment(); + ContainerBookingEntity exceeded = booking(); + exceeded.setCapacityCbm(new BigDecimal("0.9999")); + exceeded.setMaxWeightKg(new BigDecimal("3.9999")); + when(repository.booking(99L)).thenReturn(exceeded); + + BizException error = catchThrowableOfType(() -> service.assign(1, + new AssignRequest(10L, 20L, 3, 0, "assign-150-exceeded"), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + assertThat(error.getMessage()).contains("150%"); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); + } + + @Test + void snapshotsCargoFields() { + stubAssignment(); + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(10L); inbound.setTenantId(9L); inbound.setTotalPackageCount(10); inbound.setPalletCount(4); + when(repository.inbound(9L, 10L)).thenReturn(inbound); + when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(20L))).thenReturn(ApiResponse.success( + new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10, + new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name", + 4, 100, new BigDecimal("10"), "货物备注", "客户备注", "最新内部备注", "仓库备注", "客服备注"))); + doAnswer(invocation -> { + Object value = invocation.getArgument(0); + if (value instanceof LoadingPlanItemEntity item) item.setId(88L); + return null; + }).when(repository).insert(any(LoadingPlanItemEntity.class)); + + service.assign(1, new AssignRequest(10L, 20L, 5, 0, "assign-snapshot"), 7, "u"); + + ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).insert(itemCaptor.capture()); + LoadingPlanItemEntity saved = itemCaptor.getValue(); + assertThat(saved.getDestinationWarehouseId()).isEqualTo(200L); + assertThat(saved.getSkuCode()).isEqualTo("SKU-1"); + assertThat(saved.getProductNameEn()).isEqualTo("English name"); + assertThat(saved.getUnitsPerCarton()).isEqualTo(4); + assertThat(saved.getProductQuantity()).isEqualTo(20); + assertThat(saved.getPalletCount()).isEqualTo(2); + assertThat(saved.getNetWeightKg()).isEqualByComparingTo("7.5"); + assertThat(saved.getCustomerRemark()).isEqualTo("客户备注"); + assertThat(saved.getInternalRemark()).isEqualTo("最新内部备注"); + assertThat(saved.getWarehouseRemark()).isEqualTo("仓库备注"); + assertThat(saved.getCustomerServiceRemark()).isEqualTo("客服备注"); + assertThat(saved.getInternalRemark()).isNotEqualTo("货物备注"); + + ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class); + verify(repository).insert(historyCaptor.capture()); + assertThat(historyCaptor.getValue().getLoadingPlanItemId()).isEqualTo(88L); + } + + @Test + void roundsQuantityWithoutLoss() { + stubAssignment(); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success( + new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 3, + BigDecimal.ONE, BigDecimal.ONE, null, null, null, 100, null, null))); + + service.assign(1, new AssignRequest(10L, 20L, 2, 0, "assign-round"), 7, "u"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).insert(captor.capture()); + assertThat(captor.getValue().getProductQuantity()).isEqualTo(67); + } + + @Test + void addsItemToLoadingPlanWithoutChangingStableTaskOrPlanStatus() { + LoadingPlanEntity loading = plan("LOADING", 5); + LoadingPlanEntity after = plan("LOADING", 6); + when(repository.plan(1)).thenReturn(loading, after); + 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(1, 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())); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success( + new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 1, + BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, null))); + + Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-loading"), 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(repository).candidate(1L, 10L, 20L); + var order = inOrder(loadingPickService, splitPreloadService, repository, loadingModelBridge); + order.verify(loadingPickService).lockMutable(1L); + order.verify(splitPreloadService).ensureForRunningLoadingAllocation(9L, 10L, 7L, "u"); + order.verify(repository).touch(1, 5, 7, "u"); + order.verify(loadingModelBridge).bindIncrementalAllocation(1L, 9L, 10L, + 7L, "u", "assign-loading"); + order.verify(loadingPickService).sync(eq(1L), any(LoadingPlanItemEntity.class)); + verify(splitPreloadService, never()).ensurePendingForLoadingAllocation( + anyLong(), anyLong(), anyLong(), anyString()); + } + + @Test + void reusesConfirmingPreloadWhenAddingToRunningPlan() { + LoadingPlanEntity loading = plan("LOADING", 5); + LoadingPlanEntity after = plan("LOADING", 6); + when(repository.plan(1)).thenReturn(loading, after); + 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(1, 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())); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success( + new OrderCargoClient.CargoBinding(20L, 30L, 1, "goods", 1, + BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, null))); + + Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-confirming"), 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(splitPreloadService).ensureForRunningLoadingAllocation(9L, 10L, 7L, "u"); + verify(loadingModelBridge).bindIncrementalAllocation(1L, 9L, 10L, + 7L, "u", "assign-confirming"); + } + + @Test + void replaysBeforeVersionCheck() { + AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "assign-replay"); + LoadingPlanHistoryEntity replay = history(1, null, "ADD_ITEM", + sha256("ASSIGN|1|10|20|2|0")); + LoadingPlanEntity frozen = plan("LOADING", 9); + when(repository.operation("assign-replay")).thenReturn(replay); + when(repository.plan(1)).thenReturn(frozen); + when(repository.booking(99)).thenReturn(booking()); + when(repository.items(1)).thenReturn(List.of()); + + Plan result = service.assign(1, request, 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(repository, never()).candidate(anyLong(), anyLong(), anyLong()); + verify(cargoClient, never()).detail(anyString(), anyLong(), anyLong(), anyLong()); + } + + @Test + void rejectsReusedOperation() { + AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "shared-op"); + when(repository.operation("shared-op")).thenReturn(history(2, null, "ADD_ITEM", + sha256("ASSIGN|1|10|20|2|0"))); + + BizException error = catchThrowableOfType(() -> service.assign(1, request, 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + } + + @Test + void replaysRecommendation() { + RecommendRequest request = new RecommendRequest(0, "recommend-replay", null, null); + when(repository.operation("recommend-replay")).thenReturn(history(1, null, + "SMART_RECOMMEND", sha256("RECOMMEND|1|0|null|null"))); + when(repository.plan(1)).thenReturn(plan("LOADING", 8)); + when(repository.booking(99)).thenReturn(booking()); + when(repository.items(1)).thenReturn(List.of()); + + Plan result = service.recommend(1, request, 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(repository, never()).candidates(anyLong()); + } + + @Test + void replaysDeleteBeforeVersionCheck() { + DeleteRequest request = new DeleteRequest(2, 0, "delete-replay"); + when(repository.operation("delete-replay")).thenReturn(history(1, 8L, + "REMOVE_ITEM", sha256("DELETE|1|8|2|0"))); + when(repository.plan(1)).thenReturn(plan("LOADING", 7)); + when(repository.booking(99)).thenReturn(booking()); + when(repository.items(1)).thenReturn(List.of()); + + Plan result = service.delete(1, 8, request, 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(repository, never()).item(anyLong(), anyLong()); + verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), any()); + } + + @Test + void deleteTouchesPlan() { + LoadingPlanEntity plan = plan("EDITING", 2); + LoadingPlanItemEntity item = item(8, 9, 20, 1); + when(repository.plan(1)).thenReturn(plan, plan("EDITING", 3)); + when(repository.item(1, 8)).thenReturn(item); + when(repository.touch(1, 2, 7, "u")).thenReturn(1); + when(repository.remove(1, 8, 0, 7, "u")).thenReturn(1); + when(repository.booking(99)).thenReturn(booking()); + when(repository.items(1)).thenReturn(List.of()); + + Plan result = service.delete(1, 8, new DeleteRequest(2, 0, "delete-1"), 7, "u"); + + assertThat(result.version()).isEqualTo(3); + verify(repository).touch(1, 2, 7, "u"); + } + + @Test + void updatesAndClearsBlankWarehouseRemarkWithBothVersions() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 1); + line.setWarehouseRemark("旧仓库备注"); + when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3)); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(line)); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.updateRemarks(1L, 8L, 3, 0, null, null, null, 7L, "u")).thenReturn(1); + + Plan result = service.updateRemarks(1L, 8L, + new RemarksRequest(null, " ", null, 2, 0, "warehouse-clear"), 7L, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(loadingPickService).lockMutable(1L); + var order = inOrder(loadingPickService, repository); + order.verify(loadingPickService).lockMutable(1L); + order.verify(repository).touch(1L, 2, 7L, "u"); + order.verify(repository).updateRemarks(1L, 8L, 3, 0, null, null, null, 7L, "u"); + ArgumentCaptor history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class); + verify(repository).insert(history.capture()); + assertThat(history.getValue().getActionType()).isEqualTo("REMARKS"); + assertThat(history.getValue().getBeforeSnapshot()).contains("旧仓库备注"); + } + + @Test + void updatesWarehouseRemarkWhilePlanIsEditing() { + LoadingPlanEntity plan = plan("EDITING", 1); + LoadingPlanItemEntity line = item(8, 9, 20, 1); + when(repository.plan(1L)).thenReturn(plan, plan("EDITING", 2)); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(line)); + when(repository.touch(1L, 1, 7L, "u")).thenReturn(1); + when(repository.updateRemarks(1L, 8L, 2, 0, null, "待核对外箱", null, 7L, "u")).thenReturn(1); + + Plan result = service.updateRemarks(1L, 8L, + new RemarksRequest(null, "待核对外箱", null, 1, 0, "warehouse-editing"), 7L, "u"); + + assertThat(result.status()).isEqualTo("EDITING"); + assertThat(result.version()).isEqualTo(2); + verify(repository).updateRemarks(1L, 8L, 2, 0, null, "待核对外箱", null, 7L, "u"); + } + + @Test + void unchangedWarehouseRemarkDoesNotTouchPlanItemOrHistory() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 1); + line.setWarehouseRemark("待核对外箱"); + when(repository.plan(1L)).thenReturn(plan); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(line)); + + Plan result = service.updateRemarks(1L, 8L, + new RemarksRequest(null, " 待核对外箱 ", null, 2, 0, "warehouse-noop"), 7L, "u"); + + assertThat(result.version()).isEqualTo(2); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); + verify(repository, never()).updateRemarks(anyLong(), anyLong(), anyInt(), anyInt(), + any(), any(), any(), anyLong(), anyString()); + verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class)); + } + + @Test + void warehouseRemarkConflictProducesNoHistory() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 1); + when(repository.plan(1L)).thenReturn(plan); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.updateRemarks(1L, 8L, 3, 0, null, "新备注", null, 7L, "u")).thenReturn(0); + + BizException error = catchThrowableOfType(() -> service.updateRemarks(1L, 8L, + new RemarksRequest(null, "新备注", null, 2, 0, "warehouse-conflict"), 7L, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(repository).touch(1L, 2, 7L, "u"); + verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class)); + } + + @Test + void warehouseRemarkMapsTriggerSqlStateToConflict() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 1); + when(repository.plan(1L)).thenReturn(plan); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.updateRemarks(1L, 8L, 3, 0, null, "新备注", null, 7L, "u")) + .thenThrow(new DataIntegrityViolationException("trigger", + new SQLException("状态冲突", "45000"))); + + BizException error = catchThrowableOfType(() -> service.updateRemarks(1L, 8L, + new RemarksRequest(null, "新备注", null, 2, 0, "warehouse-trigger"), 7L, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + assertThat(error.getMessage()).contains("备注未保存").contains("刷新后重试"); + verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class)); + } + + @Test + void stalePlanVersionRejectsWarehouseRemarkBeforeItemWrite() { + when(repository.plan(1L)).thenReturn(plan("LOADING", 3)); + + BizException error = catchThrowableOfType(() -> service.updateRemarks(1L, 8L, + new RemarksRequest(null, "新备注", null, 2, 0, "warehouse-stale-plan"), 7L, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); + verify(repository, never()).updateRemarks(anyLong(), anyLong(), anyInt(), anyInt(), + any(), any(), any(), anyLong(), anyString()); + } + + @Test + void completedPlanRejectsWarehouseRemarkBeforeAnyWrite() { + when(repository.plan(1L)).thenReturn(plan("COMPLETED", 4)); + + BizException error = catchThrowableOfType(() -> service.updateRemarks(1L, 8L, + new RemarksRequest(null, "新备注", null, 4, 0, "warehouse-completed"), 7L, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(loadingPickService, never()).lockMutable(anyLong()); + verify(repository, never()).item(anyLong(), anyLong()); + verify(repository, never()).updateRemarks(anyLong(), anyLong(), anyInt(), anyInt(), + any(), any(), any(), anyLong(), anyString()); + verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class)); + } + + @Test + void exactWarehouseRemarkReplayIsReadOnly() { + String requestHash = sha256("REMARKS|1|8||备注||2|0"); + when(repository.operation("warehouse-replay")) + .thenReturn(history(1L, 8L, "REMARKS", requestHash)); + when(repository.plan(1L)).thenReturn(plan("LOADING", 3)); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(item(8, 9, 20, 1))); + + Plan result = service.updateRemarks(1L, 8L, + new RemarksRequest(null, "备注", null, 2, 0, "warehouse-replay"), 7L, "u"); + + assertThat(result.version()).isEqualTo(3); + verify(loadingPickService, never()).lockMutable(anyLong()); + verify(repository, never()).updateRemarks(anyLong(), anyLong(), anyInt(), anyInt(), + any(), any(), any(), anyLong(), anyString()); + } + + @Test + void quantityAdjustmentPreservesWarehouseRemark() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 1); + line.setWarehouseRemark("装柜时注意外箱"); + 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(1L, 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); + + service.adjust(1L, 8L, new AdjustRequest(2, 2, 0, "adjust-remark"), 7L, "u"); + + ArgumentCaptor updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).update(updated.capture()); + assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱"); + } + + @Test + void quantityAdjustmentCanDecreaseWhenCalculatedRemainingQuantityIsNegative() { + LoadingPlanEntity plan = plan("LOADING", 93); + LoadingPlanItemEntity line = item(8, 9, 20, 50); + when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 94)); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.candidate(1L, 10L, 20L)).thenReturn(candidate(-25)); + when(repository.items(1L)).thenReturn(List.of(line)); + when(repository.touch(1L, 93, 7L, "u")).thenReturn(1); + when(repository.update(line)).thenReturn(1); + + service.adjust(1L, 8L, new AdjustRequest(30, 93, 0, "adjust-down"), 7L, "u"); + + ArgumentCaptor updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).update(updated.capture()); + assertThat(updated.getValue().getPackageCount()).isEqualTo(30); + } + + @Test + void quantityAdjustmentOnlyAppliesRemainingQuantityLimitToTheIncrease() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 50); + 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(1L, 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); + + service.adjust(1L, 8L, new AdjustRequest(60, 2, 0, "adjust-up"), 7L, "u"); + + ArgumentCaptor updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).update(updated.capture()); + assertThat(updated.getValue().getPackageCount()).isEqualTo(60); + } + + @Test + void unchangedQuantityAndCargoSnapshotDoNotWriteAdjustmentHistory() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 2); + line.setGrossWeightKg(new BigDecimal("4.00")); + line.setNetWeightKg(new BigDecimal("3.0")); + line.setVolumeCbm(new BigDecimal("1.000")); + line.setPalletCount(0); + when(repository.plan(1L)).thenReturn(plan); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.candidate(1L, 10L, 20L)).thenReturn(candidate(10)); + when(repository.items(1L)).thenReturn(List.of(line)); + + Plan result = service.adjust(1L, 8L, + new AdjustRequest(2, 2, 0, "adjust-noop"), 7L, "u"); + + assertThat(result.version()).isEqualTo(2); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); + verify(repository, never()).update(any(LoadingPlanItemEntity.class)); + verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class)); + verify(loadingPickService, never()).sync(anyLong(), any(LoadingPlanItemEntity.class)); + } + + @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()); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); + } + + @Test + void preservesDownstreamCode() { + stubAssignment(); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn( + ApiResponse.failure(ResultCode.NOT_FOUND, "货物不存在", null, null)); + + BizException error = catchThrowableOfType(() -> service.assign(1, + new AssignRequest(10L, 20L, 2, 0, "assign-not-found"), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + } + + @Test + void mapsFeignFailure() { + stubAssignment(); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())) + .thenThrow(mock(FeignException.class)); + + BizException error = catchThrowableOfType(() -> service.assign(1, + new AssignRequest(10L, 20L, 2, 0, "assign-down"), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + } + + @Test + void rejectsCargoMismatch() { + stubAssignment(); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success( + new OrderCargoClient.CargoBinding(99L, 30L, 1, "货物", 5, BigDecimal.ONE, BigDecimal.ONE))); + + BizException error = catchThrowableOfType(() -> service.assign(1, + new AssignRequest(10L, 20L, 2, 0, "assign-mismatch"), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + } + + @Test + void deletesCustomerItemInThePlanWarehouse() { + LoadingPlanEntity plan = plan("EDITING", 2); + when(repository.plan(1)).thenReturn(plan, plan("EDITING", 3)); + when(repository.item(1, 8)).thenReturn(item(8, 22, 20, 1)); + when(repository.touch(1, 2, 7, "u")).thenReturn(1); + when(repository.remove(1, 8, 0, 7, "u")).thenReturn(1); + when(repository.booking(99)).thenReturn(booking()); + when(repository.items(1)).thenReturn(List.of()); + + Plan result = service.delete(1, 8, + new DeleteRequest(2, 0, "delete-owner"), 7, "u"); + + assertThat(result.version()).isEqualTo(3); + verify(repository).remove(1, 8, 0, 7, "u"); + } + + private void stubAssignment() { + LoadingPlanEntity plan = plan("EDITING", 0); + 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(1, 10, 20)).thenReturn(candidate(10)); + when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); + when(repository.touch(1, 0, 7, "u")).thenReturn(1); + } + + private static LoadingPlanHistoryEntity history(long planId, Long itemId, String action, String hash) { + LoadingPlanHistoryEntity value = new LoadingPlanHistoryEntity(); + value.setLoadingPlanId(planId); + value.setLoadingPlanItemId(itemId); + value.setActionType(action); + // MySQL JSON columns normalize a space after the colon; replay must parse structurally. + value.setAfterSnapshot("{\"requestHash\": \"" + hash + "\"}"); + return 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); + } + } + + private static LoadingPlanEntity plan(String status, int version) { + LoadingPlanEntity value = new LoadingPlanEntity(); + value.setId(1L); value.setContainerBookingId(99L); value.setPlanNo("LP1"); + value.setPlanStatus(status); value.setVersion(version); + return value; + } + + private static LoadingPlanItemEntity item(long id, long account, long detail, int boxes) { + LoadingPlanItemEntity value = new LoadingPlanItemEntity(); + value.setId(id); value.setLoadingPlanId(1L); value.setAccountId(account); + value.setInboundId(10L); value.setInboundDetailId(detail); value.setPackageCount(boxes); + value.setVersion(0); value.setDeleted(false); + return value; + } + + private static Candidate candidate(int boxes) { + return candidate(9L, 10L, 20L, 30L, boxes); + } + + private static InboundOrderEntity inbound(long tenantId, long inboundId) { + InboundOrderEntity value = new InboundOrderEntity(); + value.setTenantId(tenantId); + value.setId(inboundId); + return value; + } + + private static Candidate candidate(long tenantId, long inboundId, long detailId, long orderId, int boxes) { + return candidate(tenantId, inboundId, detailId, orderId, boxes, "FBA1"); + } + + private static Candidate candidate(long tenantId, long inboundId, long detailId, long orderId, int boxes, + String destinationCode) { + return new Candidate(tenantId, inboundId, detailId, orderId, "O" + orderId, "I" + inboundId, + 100L, "MATSON_CLX_TRUCK", "美森正班海卡", 200L, destinationCode, "FBA仓", "客户", "货物", boxes, + new BigDecimal("2"), new BigDecimal("1.5"), + new BigDecimal("0.5"), null, null, null, null, null, null, null, null); + } + + private static Candidate withChannel(Candidate value, String channelCode, String channelName) { + return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(), + value.orderNo(), value.inboundCode(), value.warehouseId(), channelCode, channelName, + value.destinationWarehouseId(), value.destinationWarehouseCode(), value.destinationWarehouseName(), + value.customerName(), value.goodsName(), value.availablePackageCount(), value.unitGrossWeightKg(), + value.unitNetWeightKg(), value.unitVolumeCbm(), value.coLoadGroupId(), value.groupNo(), + value.memberCount(), value.groupTotalBoxes(), value.locked(), value.blockReason(), + value.orderStatusSnapshot(), value.expectedArrivalAt()); + } + + private static DropshipPalletizationMapper.PalletLine palletLine(long palletId, String palletNo, + int cartonCount) { + DropshipPalletizationMapper.PalletLine value = new DropshipPalletizationMapper.PalletLine(); + value.setPalletId(palletId); value.setPalletNo(palletNo); value.setPalletStatus("SEALED"); + value.setAccountId(9L); value.setInboundId(10L); value.setInboundDetailId(20L); value.setOrderId(30L); + value.setOrderNo("O30"); value.setInboundCode("I10"); value.setWarehouseId(100L); + value.setChannelCode("MATSON_CLX_TRUCK"); value.setChannelName("美森正班海卡"); + value.setDestinationWarehouseId(200L); value.setDestinationWarehouseCode("FBA1"); + value.setDestinationWarehouseName("FBA仓"); value.setCustomerName("客户"); value.setCargoName("货物"); + value.setCartonCount(cartonCount); value.setActualSkuQuantity(cartonCount); + value.setUnitGrossWeightKg(new BigDecimal("2")); value.setUnitNetWeightKg(new BigDecimal("1.5")); + value.setUnitVolumeCbm(new BigDecimal("0.5")); + return value; + } + + private static Candidate withWarehouse(Candidate value, long warehouseId) { + return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(), + value.orderNo(), value.inboundCode(), warehouseId, value.channelCode(), value.channelName(), + value.destinationWarehouseId(), value.destinationWarehouseCode(), value.destinationWarehouseName(), + value.customerName(), value.goodsName(), value.availablePackageCount(), value.unitGrossWeightKg(), + value.unitNetWeightKg(), value.unitVolumeCbm(), value.coLoadGroupId(), value.groupNo(), + value.memberCount(), value.groupTotalBoxes(), value.locked(), value.blockReason(), + value.orderStatusSnapshot(), value.expectedArrivalAt()); + } + + private static Candidate withArrival(Candidate value, String expectedArrivalAt) { + return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(), + value.orderNo(), value.inboundCode(), value.warehouseId(), value.channelCode(), value.channelName(), + value.destinationWarehouseId(), value.destinationWarehouseCode(), value.destinationWarehouseName(), + value.customerName(), value.goodsName(), value.availablePackageCount(), value.unitGrossWeightKg(), + value.unitNetWeightKg(), value.unitVolumeCbm(), value.coLoadGroupId(), value.groupNo(), + value.memberCount(), value.groupTotalBoxes(), value.locked(), value.blockReason(), + value.orderStatusSnapshot(), LocalDateTime.parse(expectedArrivalAt)); + } + + private static Candidate withGroup(Candidate value, int memberCount, int groupTotalBoxes) { + return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(), + value.orderNo(), value.inboundCode(), value.warehouseId(), value.channelCode(), value.channelName(), + value.destinationWarehouseId(), value.destinationWarehouseCode(), value.destinationWarehouseName(), + value.customerName(), value.goodsName(), value.availablePackageCount(), value.unitGrossWeightKg(), + value.unitNetWeightKg(), value.unitVolumeCbm(), 700L, "CLG-700", memberCount, groupTotalBoxes, + false, null, value.orderStatusSnapshot(), value.expectedArrivalAt()); + } + + private static Candidate withStatus(Candidate value, String status, boolean locked) { + return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(), + value.orderNo(), value.inboundCode(), value.warehouseId(), value.channelCode(), value.channelName(), + value.destinationWarehouseId(), value.destinationWarehouseCode(), value.destinationWarehouseName(), + value.customerName(), value.goodsName(), value.availablePackageCount(), value.unitGrossWeightKg(), + value.unitNetWeightKg(), value.unitVolumeCbm(), value.coLoadGroupId(), value.groupNo(), + value.memberCount(), value.groupTotalBoxes(), locked, value.blockReason(), status, + value.expectedArrivalAt()); + } + + private static ChannelRow channel(String channelCode, String channelName) { + ChannelRow value = new ChannelRow(); + value.setChannelCode(channelCode); value.setChannelName(channelName); + return value; + } + + private static GroupSnapshot group(String status, Long lockedPlanId, Member... members) { + GroupSnapshot group = mock(GroupSnapshot.class); + when(group.groupId()).thenReturn(700L); + when(group.groupNo()).thenReturn("CLG-700"); + when(group.status()).thenReturn(status); + when(group.warehouseId()).thenReturn(100L); + when(group.channelCode()).thenReturn("FBA"); + when(group.lockedPlanId()).thenReturn(lockedPlanId); + when(group.members()).thenReturn(List.of(members)); + when(group.fullPackageCount()).thenReturn(List.of(members).stream() + .mapToInt(Member::fullPackageCount).sum()); + return group; + } + + private static ContainerBookingEntity booking() { + ContainerBookingEntity value = new ContainerBookingEntity(); + value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1"); + value.setChannelGroupCode("MATSON_CLX"); value.setChannelCode(null); + value.setContainerNo("MSCU1234567"); + value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000")); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingStatusTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingStatusTest.java new file mode 100644 index 000000000..b30c8df28 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingStatusTest.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.domain.SmartLoadingStatus; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SmartLoadingStatusTest { + @Test + void statusCanOnlyAdvanceOneWay() { + assertThat(SmartLoadingStatus.WAIT_LOADING.canAdvanceTo(SmartLoadingStatus.EDITING)).isTrue(); + assertThat(SmartLoadingStatus.EDITING.canAdvanceTo(SmartLoadingStatus.LOADING)).isTrue(); + assertThat(SmartLoadingStatus.LOADING.canAdvanceTo(SmartLoadingStatus.COMPLETED)).isTrue(); + assertThat(SmartLoadingStatus.COMPLETED.canAdvanceTo(SmartLoadingStatus.LOADING)).isFalse(); + assertThat(SmartLoadingStatus.WAIT_LOADING.mutable()).isTrue(); + assertThat(SmartLoadingStatus.EDITING.mutable()).isTrue(); + assertThat(SmartLoadingStatus.LOADING.mutable()).isTrue(); + assertThat(SmartLoadingStatus.LOADING.allowsTransportUpdate()).isTrue(); + assertThat(SmartLoadingStatus.COMPLETED.allowsTransportUpdate()).isFalse(); + } +} 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..3a43c1dce --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -0,0 +1,156 @@ +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; +import static org.junit.jupiter.api.Assertions.assertNull; + +@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 + ":3"; + 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)); + assertEquals(5, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.loadingEvidence')) " + + "FROM fl_inbound_outbox WHERE event_key=?", Integer.class, eventKey)); + assertEquals("真实验收客户", jdbc.queryForObject( + "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].customerName')) " + + "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); + assertEquals("2026-09-30T12:00", jdbc.queryForObject( + "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.estimatedPortArrivalAt')) " + + "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); + assertNull(jdbc.queryForObject( + "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.actualPortArrivalAt')) " + + "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); + assertEquals(1, jdbc.queryForObject( + "SELECT JSON_EXTRACT(payload_json,'$.payload.orders[0].palletCount') " + + "FROM fl_inbound_outbox WHERE event_key=?", Integer.class, eventKey)); + assertEquals("35.0", jdbc.queryForObject( + "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].volumeWeightKg')) " + + "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); + } + + private void seedCompletedLoadingPlan() { + jdbc.update("INSERT IGNORE INTO fl_container_booking(id,booking_no,container_no,cabinet_no,warehouse_id," + + "warehouse_name,customer_id,customer_name,estimated_port_arrival_at,container_type,created_by,updated_by) " + + "VALUES(?,?,?,?,?,?,?,?,?,?,1,1)", + BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", + CUSTOMER, "真实验收客户", java.time.LocalDateTime.of(2026, 9, 30, 12, 0), "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); + seedAllocation(990022620L, FIRST_INBOUND, 990022720L, 990022820L, 3, "F"); + seedAllocation(990022621L, DROPSHIP_INBOUND, 990022721L, 990022821L, 2, "D"); + 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); + int evidenceIndex = 0; + for (String type : new String[] {"PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"}) { + long id = 990023000L + evidenceIndex; + jdbc.update("INSERT IGNORE INTO fl_loading_cabinet_evidence(id,container_booking_id,source_task_id," + + "evidence_type,file_id,file_name,status,active_flag,operation_key,request_hash,created_by,updated_by) " + + "VALUES(?,?,?,?,?,?,'ACTIVE',1,?,REPEAT('a',64),1,1)", + id, BOOKING, 990022901L, type, id + 1000, type + ".jpg", MARKER + "-EVIDENCE-" + type); + evidenceIndex++; + } + jdbc.update("UPDATE fl_loading_plan SET plan_status='COMPLETED',confirmed_at=NOW(3),version=version+1 WHERE id=?", PLAN); + } + + private void seedAllocation(long id, long inboundId, long palletId, long locationId, + int packages, String suffix) { + jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," + + "block_id,slot_no,weight_result_id,pallet_sequence,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," + + "shared_overflow,reservation_id,slot_status,allocation_status) " + + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'BOUND','CONFIRMED')", + id, CUSTOMER, inboundId, 1, id + 3000, 1, id + 2000, 1, 92L, 0L, + palletId, MARKER + "-PALLET-" + suffix, 1.2, 0.4, packages, packages, + locationId, "A-01", false, id + 1000); + } + + 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/service/TruckAppointmentCompletionServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentCompletionServiceTest.java new file mode 100644 index 000000000..0c1ce13e7 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentCompletionServiceTest.java @@ -0,0 +1,94 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.domain.TruckAppointmentStatus; +import com.baiyue.firstleg.entity.TruckAppointmentEntity; +import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity; +import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper; +import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper.DepartureRow; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; +import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.repository.TruckAppointmentRepository; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class TruckAppointmentCompletionServiceTest { + static final LocalDateTime DEPARTED = LocalDateTime.of(2026, 9, 14, 18, 41); + private final TruckAppointmentCompletionMapper mapper = mock(TruckAppointmentCompletionMapper.class); + private final OceanBookingRepository bookings = mock(OceanBookingRepository.class); + private final TruckAppointmentRepository appointments = mock(TruckAppointmentRepository.class); + private final TruckAppointmentFeeService fees = mock(TruckAppointmentFeeService.class); + private final TruckAppointmentCompletionService service = new TruckAppointmentCompletionService(mapper, bookings, appointments, fees); + + static DepartureRow row(long container, int quantity, String status, String volume) { + return new DepartureRow(9L, quantity, container, "CONT" + container, "ACTIVE", container * 10, + "COMPLETED", container * 100, container * 1000, container * 10000, "ORDER" + container, + container * 100000, "INBOUND" + container, new BigDecimal(volume), status, + "WAITING_FOR_LOADING".equals(status) ? DEPARTED : null); + } + + @Test void loadingCompletionAloneDoesNotCompleteTruck() { + assertThat(TruckAppointmentCompletionService.allDeparted(List.of(row(1, 1, "READY_FOR_DISPATCH", "2")))).isFalse(); + } + + @Test void allRequiredContainersAndEveryInboundMustHaveDeparted() { + var first = row(1, 2, "WAITING_FOR_LOADING", "2"); + assertThat(TruckAppointmentCompletionService.allDeparted(List.of(first))).isFalse(); + assertThat(TruckAppointmentCompletionService.allDeparted(List.of(first, row(2, 2, "READY_FOR_DISPATCH", "4")))).isFalse(); + assertThat(TruckAppointmentCompletionService.allDeparted(List.of(first, row(2, 2, "WAITING_FOR_LOADING", "4")))).isTrue(); + assertThat(TruckAppointmentCompletionService.allDeparted(List.of(row(1, 1, "WAITING_FOR_LOADING", "2"), row(1, 1, null, "4")))).isFalse(); + assertThat(TruckAppointmentCompletionService.allDeparted(List.of())).isFalse(); + } + + @Test void missingRequirementAllocationCannotBeHiddenByAnInnerJoin() { + var missing = new DepartureRow(10L, 1, null, null, null, null, null, null, null, null, null, null, null, null, null, null); + assertThat(TruckAppointmentCompletionService.allDeparted(List.of(row(1, 1, "WAITING_FOR_LOADING", "2"), missing))).isFalse(); + } + + @Test void completesDraftOrLateSubmittedTruckAndPublishesExactlyOnce() { + var appointment = new TruckAppointmentEntity(); + appointment.setId(11L); appointment.setVersion(1); appointment.setRevision(1); + appointment.setStatus(TruckAppointmentStatus.PENDING_DISPATCH); + var rows = List.of(row(1, 1, "WAITING_FOR_LOADING", "2")); + when(bookings.findForUpdate(99L)).thenReturn(new OceanBookingEntity()); + when(mapper.activeForUpdate(99L)).thenReturn(List.of(appointment), List.of()); + when(mapper.departuresForUpdate(99L)).thenReturn(rows); + when(mapper.complete(11L, 1, DEPARTED)).thenReturn(1); + + service.completeForBooking(99L); + service.completeForBooking(99L); + + verify(fees, times(1)).publish(appointment, rows, DEPARTED); + var history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class); + verify(appointments, times(1)).history(history.capture()); + assertThat(history.getValue().getAction()).isEqualTo("AUTO_COMPLETE"); + assertThat(history.getValue().getFromStatus()).isEqualTo("PENDING_DISPATCH"); + assertThat(history.getValue().getToStatus()).isEqualTo("COMPLETED"); + assertThat(history.getValue().getRevision()).isEqualTo(2); + var order = inOrder(bookings, mapper, fees, appointments); + order.verify(bookings).findForUpdate(99L); + order.verify(mapper).activeForUpdate(99L); + order.verify(mapper).departuresForUpdate(99L); + order.verify(fees).publish(appointment, rows, DEPARTED); + order.verify(mapper).complete(11L, 1, DEPARTED); + order.verify(appointments).history(any()); + } + + @Test void feeFailureDoesNotLeaveACompletedTruck() { + var appointment = new TruckAppointmentEntity(); + when(bookings.findForUpdate(99L)).thenReturn(new OceanBookingEntity()); + when(mapper.activeForUpdate(99L)).thenReturn(List.of(appointment)); + when(mapper.departuresForUpdate(99L)).thenReturn(List.of(row(1, 1, "WAITING_FOR_LOADING", "2"))); + doThrow(new IllegalStateException("fee unavailable")).when(fees).publish(any(), any(), any()); + assertThatThrownBy(() -> service.completeForBooking(99L)).hasMessage("fee unavailable"); + verify(mapper, never()).complete(anyLong(), anyInt(), any()); + verifyNoInteractions(appointments); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentFeeServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentFeeServiceTest.java new file mode 100644 index 000000000..6f377ca81 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentFeeServiceTest.java @@ -0,0 +1,79 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupRequest; +import com.baiyue.firstleg.entity.ContainerFeeDetailEntity; +import com.baiyue.firstleg.entity.FeeItemEntity; +import com.baiyue.firstleg.entity.TruckAppointmentEntity; +import com.baiyue.firstleg.repository.FeeItemRepository; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.math.BigDecimal; +import java.util.List; + +import static com.baiyue.firstleg.service.TruckAppointmentCompletionServiceTest.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class TruckAppointmentFeeServiceTest { + private final FeeItemRepository items = mock(FeeItemRepository.class); + private final ContainerFeeDetailService containers = mock(ContainerFeeDetailService.class); + private final WarehouseSupplierSnapshotService suppliers = mock(WarehouseSupplierSnapshotService.class); + private final TruckAppointmentFeeService service = new TruckAppointmentFeeService(items, containers, suppliers); + + @Test void allocatesAcrossCustomersAndContainersByVolumeWithExactCents() { + var shares = TruckAppointmentFeeService.allocate(new BigDecimal("100.00"), List.of( + row(3, 3, "WAITING_FOR_LOADING", "1"), row(1, 3, "WAITING_FOR_LOADING", "1"), row(2, 3, "WAITING_FOR_LOADING", "1"))); + assertThat(shares).extracting(TruckAppointmentFeeService.Share::amount) + .containsExactly(new BigDecimal("33.34"), new BigDecimal("33.33"), new BigDecimal("33.33")); + assertThat(shares.stream().map(TruckAppointmentFeeService.Share::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo("100.00"); + } + + @Test void tinyAmountsNeverPublishZeroOrNegativeSources() { + var shares = TruckAppointmentFeeService.allocate(new BigDecimal("0.01"), List.of( + row(1, 2, "WAITING_FOR_LOADING", "1"), row(2, 2, "WAITING_FOR_LOADING", "2"))); + assertThat(shares).hasSize(1); + assertThat(shares.get(0).owner().planId()).isEqualTo(20L); + assertThat(shares.get(0).amount()).isEqualByComparingTo("0.01"); + } + + @Test void missingVolumeRejectsInsteadOfInventingEqualCosts() { + assertThatThrownBy(() -> TruckAppointmentFeeService.allocate(new BigDecimal("100.00"), + List.of(row(1, 1, "WAITING_FOR_LOADING", "0")))).hasMessageContaining("实装体积"); + } + + @Test void publishesApprovedCnyOnAccountSourcesWithTruckAndSupplierReferences() { + var appointment = appointment(); + var item = new FeeItemEntity(); item.setId(108L); item.setStatus("ACTIVE"); item.setPickupPayable(true); + when(items.findItemByCode("FEE_TYPE_108")).thenReturn(item); + var created = new ContainerFeeDetailEntity(); created.setAllocationGroupId(500L); + when(containers.createAllocationGroup(anyLong(), eq(0L), any())).thenReturn(List.of(created)); + service.publish(appointment, List.of(row(1, 1, "WAITING_FOR_LOADING", "5")), DEPARTED); + var request = ArgumentCaptor.forClass(AllocationGroupRequest.class); + verify(containers).createAllocationGroup(eq(10L), eq(0L), request.capture()); + assertThat(request.getValue().fee().direction()).isEqualTo("PAYABLE"); + assertThat(request.getValue().fee().currency()).isEqualTo("CNY"); + assertThat(request.getValue().fee().settlementMode()).isEqualTo("ON_ACCOUNT"); + assertThat(request.getValue().fee().supplierId()).isEqualTo(8L); + assertThat(request.getValue().fee().remark()).contains("TA-260914-0111"); + assertThat(request.getValue().fee().businessDate()).isEqualTo(DEPARTED.toLocalDate()); + assertThat(request.getValue().allocations().get(0).amount()).isEqualByComparingTo("100"); + verify(suppliers).domesticTrucking(8L, null); + verify(containers).approveAllocationGroup(10L, 500L, 0L, "系统(拖车离仓完成)"); + } + + @Test void zeroFeeCreatesNoPayableAndUnsupportedCurrencyFailsClosed() { + var appointment = appointment(); appointment.setPayableAmount(BigDecimal.ZERO); + service.publish(appointment, List.of(), DEPARTED); + verifyNoInteractions(items, containers, suppliers); + appointment.setPayableAmount(BigDecimal.ONE); appointment.setCurrency("USD"); + assertThatThrownBy(() -> service.publish(appointment, List.of(), DEPARTED)).hasMessageContaining("人民币"); + } + + private TruckAppointmentEntity appointment() { + var appointment = new TruckAppointmentEntity(); appointment.setAppointmentNo("TA-260914-0111"); + appointment.setCarrierId(8L); appointment.setCurrency("RMB"); appointment.setPayableAmount(new BigDecimal("100.00")); + return appointment; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentPayableFlowTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentPayableFlowTest.java new file mode 100644 index 000000000..422bff155 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentPayableFlowTest.java @@ -0,0 +1,107 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.client.FeePaymentVoucherFileClient; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import com.baiyue.firstleg.entity.*; +import com.baiyue.firstleg.mapper.ContainerFeeSourceSnapshotMapper; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper.DepartureRow; +import com.baiyue.firstleg.repository.*; +import com.fasterxml.jackson.databind.json.JsonMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static com.baiyue.firstleg.service.TruckAppointmentCompletionServiceTest.DEPARTED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real business services from trucking through approval, immutable snapshot and Finance Outbox. */ +class TruckAppointmentPayableFlowTest { + @Test void truckTotalBecomesCustomerOwnedPublishedPayablesWithoutRegisteringPayment() throws Exception { + var repository = mock(ContainerFeeDetailRepository.class); + var loading = mock(SmartLoadingRepository.class); + var items = mock(FeeItemRepository.class); + var suppliers = mock(WarehouseSupplierSnapshotService.class); + var snapshotMapper = mock(ContainerFeeSourceSnapshotMapper.class); + var outboxMapper = mock(InboundOutboxMapper.class); + var mapper = JsonMapper.builder().findAndAddModules().build(); + var snapshots = new ContainerFeeSourceSnapshotRepository(snapshotMapper); + var outbox = new FinanceFeeSourceOutboxService(outboxMapper, mapper); + var payments = new FeePaymentSnapshotValidator(mock(FeePaymentVoucherFileClient.class), "test"); + var containerFees = new ContainerFeeDetailService(repository, loading, items, outbox, suppliers, snapshots, payments); + var trucking = new TruckAppointmentFeeService(items, containerFees, suppliers); + var snapshotQueries = new FinanceFeeSourceSnapshotService(items, snapshots); + + var item = new FeeItemEntity(); item.setId(108L); item.setFeeCode("FEE_TYPE_108"); + item.setFeeTypeName("拖车费"); item.setStatus("ACTIVE"); item.setPickupPayable(true); item.setSupplierCategory("TRUCKING"); + when(items.findItemByCode("FEE_TYPE_108")).thenReturn(item); + when(items.findItem(108L)).thenReturn(item); + var supplier = new WarehouseSupplierClient.Snapshot(8L, "测试车队", "TRUCKING", "ACTIVE", List.of(), "DOMESTIC"); + when(suppliers.domesticTrucking(8L, null)).thenReturn(supplier); + when(suppliers.fee(8L, "TRUCKING")).thenReturn(supplier); + var plan = new LoadingPlanEntity(); plan.setId(10L); plan.setContainerBookingId(1L); + var container = new ContainerBookingEntity(); container.setId(1L); container.setContainerNo("TEST1234567"); + when(loading.plan(10L)).thenReturn(plan); when(loading.booking(1L)).thenReturn(container); + var loaded = new ArrayList(); + var departures = new ArrayList(); + for (long customer = 1; customer <= 2; customer++) { + var load = new LoadingPlanItemEntity(); load.setAccountId(customer); load.setOrderId(customer * 100); + load.setOrderNo("ORDER-" + customer); load.setInboundId(customer * 1000); load.setInboundCode("IN-" + customer); + loaded.add(load); + departures.add(new DepartureRow(9L, 1, 1L, "TEST1234567", "ACTIVE", 10L, "COMPLETED", customer, + customer, load.getOrderId(), load.getOrderNo(), load.getInboundId(), load.getInboundCode(), + BigDecimal.valueOf(customer), "WAITING_FOR_LOADING", DEPARTED)); + } + when(loading.items(10L)).thenReturn(loaded); + Map saved = new LinkedHashMap<>(); + doAnswer(call -> { var value = (ContainerFeeDetailEntity) call.getArgument(0); saved.put(value.getId(), value); return null; }) + .when(repository).insert(any()); + when(repository.listGroup(eq(10L), anyLong())).thenAnswer(call -> List.copyOf(saved.values())); + when(repository.find(anyLong(), eq(10L), anyLong())).thenAnswer(call -> saved.get(call.getArgument(2))); + when(repository.updateSupplierSnapshot(anyLong(), eq(10L), anyLong(), eq(8L), eq("测试车队"), eq("TRUCKING"), eq(0L))).thenReturn(1); + when(repository.review(anyLong(), eq(10L), anyLong(), eq("APPROVED"), eq(0L), anyString(), isNull())) + .thenAnswer(call -> { var value = saved.get(call.getArgument(2)); value.setReviewStatus("APPROVED"); value.setReviewedAt(DEPARTED); return 1; }); + + var appointment = new TruckAppointmentEntity(); appointment.setAppointmentNo("TA-260914-0111"); + appointment.setCarrierId(8L); appointment.setCurrency("RMB"); appointment.setPayableAmount(new BigDecimal("100.00")); + trucking.publish(appointment, departures, DEPARTED); + + assertThat(saved.values()).hasSize(2).allSatisfy(value -> { + assertThat(value.getDirection()).isEqualTo("PAYABLE"); + assertThat(value.getReviewStatus()).isEqualTo("APPROVED"); + assertThat(value.getSettlementMode()).isEqualTo("ON_ACCOUNT"); + assertThat(value.getPaidAt()).isNull(); + assertThat(value.getRemark()).contains("TA-260914-0111"); + }); + assertThat(saved.values().stream().map(ContainerFeeDetailEntity::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add)) + .isEqualByComparingTo("100.00"); + var frozen = ArgumentCaptor.forClass(ContainerFeeSourceSnapshotEntity.class); + verify(snapshotMapper, times(2)).insert(frozen.capture()); + var events = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outboxMapper, times(2)).insert(events.capture()); + for (int i = 0; i < 2; i++) { + var source = frozen.getAllValues().get(i); + when(snapshotMapper.selectOne(any())).thenReturn(source); + var snapshot = snapshotQueries.container(1L, source.getSourceFeeId(), 1); + assertThat(snapshot.publicationStatus()).isEqualTo("PUBLISHED"); + assertThat(snapshot.currency()).isEqualTo("CNY"); + assertThat(snapshot.customerId()).isEqualTo(source.getTenantId()); + assertThat(snapshot.orderId()).isEqualTo(source.getAllocationOrderId()); + assertThat(snapshot.supplierId()).isEqualTo(8L); + assertThat(snapshot.sourceHash()).hasSize(64); + var event = mapper.readTree(events.getAllValues().get(i).getPayloadJson()); + assertThat(event.path("eventType").asText()).isEqualTo("FIRSTLEG_FEE_PUBLISHED"); + assertThat(event.path("aggregateType").asText()).isEqualTo("CONTAINER"); + assertThat(event.path("payload").path("feeId").asText()).isEqualTo(source.getSourceFeeId().toString()); + assertThat(event.path("tenantId").asText()).isEqualTo(source.getTenantId().toString()); + assertThat(events.getAllValues().get(i).getPublishStatus()).isEqualTo("PENDING"); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java new file mode 100644 index 000000000..4a4c0df8c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -0,0 +1,623 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.firstleg.domain.TruckAppointmentStatus; +import com.baiyue.firstleg.dto.TruckAppointmentDtos.ActionRequest; +import com.baiyue.firstleg.dto.TruckAppointmentDtos.AttachmentRequest; +import com.baiyue.firstleg.dto.TruckAppointmentDtos.Candidate; +import com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery; +import com.baiyue.firstleg.dto.TruckAppointmentDtos.SaveRequest; +import com.baiyue.firstleg.entity.TruckAppointmentEntity; +import com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity; +import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity; +import com.baiyue.firstleg.entity.TruckAppointmentOperationEntity; +import com.baiyue.firstleg.repository.TruckAppointmentRepository; +import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; +import com.baiyue.firstleg.oceanbooking.service.OceanBookingDocumentService; +import com.baiyue.firstleg.client.TruckAppointmentFileClient; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import com.baiyue.common.core.api.ApiResponse; +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.MockitoAnnotations; +import org.mockito.Spy; +import org.springframework.dao.DuplicateKeyException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; + +import java.lang.reflect.Constructor; +import java.time.LocalDateTime; +import java.util.List; + +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.*; + +class TruckAppointmentServiceTest { + @Mock TruckAppointmentRepository repository; + @Mock TruckAppointmentCompletionService completion; + @Mock OceanBookingRepository oceanBookings; + @Mock WarehouseSupplierSnapshotService suppliers; + @Mock TruckAppointmentFileClient files; + @Mock OceanBookingDocumentService oceanBookingDocuments; + @Mock FirstlegDocumentNumberGenerator documentNumbers; + @Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build(); + @InjectMocks TruckAppointmentService service; + + @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); + service.internalToken = "test-internal-token"; + lenient().doAnswer(invocation -> { + TruckAppointmentEntity inserted = invocation.getArgument(0); + lenient().when(repository.find(inserted.getId())).thenReturn(inserted); + return null; + }).when(repository).insert(any()); + lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001"); + when(suppliers.domesticTrucking(any(), any())).thenReturn( + new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of(), "DOMESTIC")); + lenient().when(repository.attachments(anyLong())).thenReturn(List.of()); + OceanBookingEntity booking = releasedBooking(); + lenient().when(oceanBookings.find(99L)).thenReturn(booking); + } + + @Test void detailResolvesStableNamesFromLinkedBooking() { + TruckAppointmentEntity appointment = new TruckAppointmentEntity(); + appointment.setId(51L); appointment.setOceanBookingId(61L); + when(repository.find(51L)).thenReturn(appointment); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(61L); booking.setBookingNo("OB61"); + booking.setWarehouseName("Shenzhen Transit"); booking.setShippingCompanyId(81L); + booking.setShippingCompanyName("Carrier Line"); booking.setBookingAgentId(91L); booking.setBookingAgentName("Agent Co"); + when(oceanBookings.find(61L)).thenReturn(booking); + TruckAppointmentEntity detail = service.detail(51L); + + assertThat(detail.getOceanBookingNo()).isEqualTo("OB61"); + assertThat(detail.getWarehouseName()).isEqualTo("Shenzhen Transit"); + assertThat(detail.getShippingCompanyName()).isEqualTo("Carrier Line"); + assertThat(detail.getBookingAgentName()).isEqualTo("Agent Co"); + assertThat(detail.getContainerBookingId()).isNull(); + assertThat(detail.getLoadingPlanId()).isNull(); + } + + @Test void createCandidatesDelegateOneBoundedAuthoritativeQuery() { + Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西", true); + when(repository.candidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); + + PageResult result = service.candidates(new CandidateQuery(" OB-1 ", 1, 20)); + + assertThat(result.records()).containsExactly(candidate); + verify(repository, times(1)).candidates(any(CandidateQuery.class)); + verifyNoInteractions(oceanBookings); + } + + @Test void createAfterDepartureReturnsCompletedVersion() { + doAnswer(invocation -> { + var inserted = org.mockito.Mockito.mockingDetails(repository).getInvocations().stream() + .filter(call -> call.getMethod().getName().equals("insert")) + .map(call -> (TruckAppointmentEntity) call.getArguments()[0]).findFirst().orElseThrow(); + inserted.setStatus(TruckAppointmentStatus.COMPLETED); inserted.setVersion(1); + return null; + }).when(completion).completeForBooking(99L); + var result = service.create(7L, "admin", validRequest()); + assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.COMPLETED); + assertThat(result.getVersion()).isEqualTo(1); + } + + @Test void completedBookingCannotCreateAnotherAutomaticCharge() { + var existing = entity(TruckAppointmentStatus.COMPLETED, 3); + existing.setAppointmentNo("TA-260914-0111"); + when(repository.firstActiveForUpdate(99L)).thenReturn(existing); + assertThatThrownBy(() -> service.create(7L, "admin", validRequest())).hasMessageContaining("TA-260914-0111"); + verify(repository, never()).insert(any()); + } + + @Test void lateSubmitReturnsAutomaticCompletionInsteadOfPendingDispatch() { + var before = entity(TruckAppointmentStatus.DRAFT, 0); + var submitted = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); + when(repository.find(1L)).thenReturn(before, submitted); + when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("late-submit"), anyString())).thenReturn(true); + when(repository.transition(1L, "DRAFT", "PENDING_DISPATCH", 0)).thenReturn(1); + when(repository.completeOperation(eq(1L), eq("SUBMIT"), eq("late-submit"), anyString())).thenReturn(1); + doAnswer(invocation -> { submitted.setStatus(TruckAppointmentStatus.COMPLETED); submitted.setVersion(2); return null; }) + .when(completion).completeForBooking(99L); + var result = service.action(1L, "SUBMIT", new ActionRequest(0, null), "late-submit", null, 7L, "admin"); + assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.COMPLETED); + assertThat(result.getVersion()).isEqualTo(2); + } + + @Test void reschedulingAnExceptionCannotDuplicateAnAlreadyCompletedBookingCharge() { + var failed = entity(TruckAppointmentStatus.EXCEPTION, 1); + var completed = entity(TruckAppointmentStatus.COMPLETED, 2); completed.setId(2L); + completed.setAppointmentNo("TA-260914-0111"); + when(repository.find(1L)).thenReturn(failed); + when(repository.firstActiveForUpdate(99L)).thenReturn(completed); + when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("reschedule-old"), anyString())).thenReturn(true); + assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(1, null), + "reschedule-old", null, 7L, "admin")).hasMessageContaining("已完成拖车单"); + verify(repository, never()).reschedule(anyLong(), any(), any(), anyInt()); + } + + @Test void manualCompleteAndDepartureCannotBypassActualDeparture() { + for (String action : List.of("COMPLETE", "DEPART")) { + when(repository.find(1L)).thenReturn(entity(TruckAppointmentStatus.LOADING, 4)); + when(repository.claimOperation(anyLong(), eq(1L), eq(action), eq(action), anyString())).thenReturn(true); + assertThatThrownBy(() -> service.action(1L, action, new ActionRequest(4, null), action, null, 7L, "admin")) + .isInstanceOf(BizException.class); + } + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + } + + @Test void createRequiresZeroVersionAndInitialisesDraft() { + SaveRequest invalid = request(1); + assertThatThrownBy(() -> service.create(7L, "admin", invalid)).isInstanceOf(BizException.class); + verifyNoInteractions(repository); + } + + @Test void createRejectsMissingBooking() { + SaveRequest request = mock(SaveRequest.class); + when(request.expectedVersion()).thenReturn(0); + when(request.carrierId()).thenReturn(8L); + when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); + when(request.oceanBookingId()).thenReturn(null); + when(request.warehouseId()).thenReturn(null); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("oceanBookingId"); + verify(repository, never()).insert(any()); + } + + @Test void createFreezesBookingBillAndRequiresBookingOwnedDocument() { + SaveRequest request = validRequest(); + + service.create(7L, "admin", request); + + ArgumentCaptor entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class); + verify(repository).insert(entity.capture()); + assertThat(entity.getValue().getBillOfLadingNo()).isEqualTo("BL-BOOKING-1"); + verify(oceanBookingDocuments).requireActiveValidDocument(any(OceanBookingEntity.class)); + verifyNoInteractions(files); + } + + @Test void createRejectsAnotherActiveAppointmentForTheSameBookingWithoutWriting() { + SaveRequest request = validRequest(); + TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 0); + existing.setAppointmentNo("TA-260901-0003"); + when(repository.firstActiveForUpdate(99L)).thenReturn(existing); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("该订舱已存在拖车预约 TA-260901-0003,请继续编辑"); + + verifyNoInteractions(oceanBookingDocuments); + verify(repository, never()).insert(any()); + verify(repository, never()).history(any()); + assertThat(existing.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT); + assertThat(existing.getVersion()).isZero(); + } + + @Test void concurrentCreateUniqueCollisionBecomesBusinessConflictWithoutHistory() { + SaveRequest request = validRequest(); + TruckAppointmentEntity concurrent = entity(TruckAppointmentStatus.DRAFT, 0); + concurrent.setAppointmentNo("TA-260901-0003"); + when(repository.firstActiveForUpdate(99L)).thenReturn(null, concurrent); + doThrow(new DuplicateKeyException("Duplicate entry for key 'uk_truck_active_ocean_booking'")) + .when(repository).insert(any()); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("该订舱已存在拖车预约 TA-260901-0003,请继续编辑"); + + verify(repository, times(2)).firstActiveForUpdate(99L); + verify(repository, never()).history(any()); + assertThat(concurrent.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT); + assertThat(concurrent.getVersion()).isZero(); + } + + @Test void createPreservesMaximumChineseRemarkAndBoundaryValues() { + SaveRequest request = validRequest(); + String remark = "报".repeat(1000); + when(request.remark()).thenReturn(remark); + when(request.packageCount()).thenReturn(0); + when(request.palletCount()).thenReturn(0); + when(request.grossWeightKg()).thenReturn(java.math.BigDecimal.ZERO); + when(request.volumeCbm()).thenReturn(java.math.BigDecimal.ZERO); + when(request.transitMinutes()).thenReturn(0); + + service.create(7L, "admin", request); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(TruckAppointmentEntity.class); + verify(repository).insert(inserted.capture()); + assertThat(inserted.getValue().getRemark()).hasSize(1000).isEqualTo(remark); + assertThat(inserted.getValue().getPackageCount()).isZero(); + assertThat(inserted.getValue().getPalletCount()).isZero(); + assertThat(inserted.getValue().getGrossWeightKg()).isZero(); + assertThat(inserted.getValue().getVolumeCbm()).isZero(); + assertThat(inserted.getValue().getCurrency()).isEqualTo("RMB"); + } + + @Test void createRejectsUsdBeforeWriting() { + SaveRequest request = validRequest(); + when(request.currency()).thenReturn("USD"); + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("人民币"); + verify(repository, never()).insert(any()); + } + + @Test void createRejectsUnsupportedCurrencyWithoutWriting() { + SaveRequest request = validRequest(); + when(request.currency()).thenReturn("CNY"); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("人民币"); + + verify(repository, never()).insert(any()); + verify(repository, never()).history(any()); + } + + @Test void platformOwnedAttachmentUsesZeroTenantWithoutCustomerSnapshot() { + TruckAppointmentEntity appointment = entity(TruckAppointmentStatus.DRAFT, 0); + when(repository.find(1L)).thenReturn(appointment); + when(files.metadata("test-internal-token", 0L, "TRUCK_APPOINTMENT_ATTACHMENT", "1", 77L)) + .thenReturn(ApiResponse.success(new TruckAppointmentFileClient.Metadata(77L, 0L, + "TRUCK_APPOINTMENT_ATTACHMENT", "1", "派车资料.pdf", "abc", "application/pdf", 12L))); + + TruckAppointmentAttachmentEntity result = service.attach(1L, + new AttachmentRequest(77L, "DISPATCH_ORDER", "ignored", "ignored"), 9L); + + assertThat(result.getFileId()).isEqualTo(77L); + assertThat(result.getVisibility()).isEqualTo("PLATFORM"); + verify(files).metadata("test-internal-token", 0L, "TRUCK_APPOINTMENT_ATTACHMENT", "1", 77L); + verify(repository).attachment(result); + } + + @Test void createFreezesReleasedBookingBillAndRejectsClientMismatch() { + SaveRequest request = validRequest(); + when(request.oceanBookingId()).thenReturn(99L); + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setId(99L); booking.setStatus("RELEASED"); + booking.setBillOfLadingNo(" BL-BOOKING-1 "); + when(oceanBookings.find(99L)).thenReturn(booking); + + service.create(7L, "admin", request); + + ArgumentCaptor entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class); + verify(repository).insert(entity.capture()); + assertThat(entity.getValue().getBillOfLadingNo()).isEqualTo("BL-BOOKING-1"); + + SaveRequest mismatch = validRequest(); + when(mismatch.oceanBookingId()).thenReturn(99L); + when(mismatch.billOfLadingNo()).thenReturn("BL-OTHER"); + assertThatThrownBy(() -> service.create(7L, "admin", mismatch)) + .isInstanceOf(BizException.class).hasMessageContaining("billOfLadingNo").hasMessageContaining("不一致"); + } + + @Test void createRejectsBookingThatIsNotReleased() { + SaveRequest request = mock(SaveRequest.class); + when(request.expectedVersion()).thenReturn(0); + when(request.oceanBookingId()).thenReturn(99L); + when(request.carrierId()).thenReturn(8L); + when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); + when(request.warehouseId()).thenReturn(null); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setWarehouseId(18L); + when(oceanBookings.find(99L)).thenReturn(booking); + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("已放舱"); + verify(repository, never()).insert(any()); + } + + @Test void createStillRequiresBookingDocumentWithoutAnyContainerAssociation() { + SaveRequest request = validRequest(); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, + "订舱缺少有效订舱确认或提单文件")) + .when(oceanBookingDocuments).requireActiveValidDocument(any()); + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件"); + verify(repository, never()).insert(any()); + } + + @Test void editCanSwitchToAnotherUnoccupiedReleasedBooking() { + TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 3); + existing.setOceanBookingId(99L); + when(repository.find(1L)).thenReturn(existing, existing); + SaveRequest request = validRequest(); + when(request.expectedVersion()).thenReturn(3); + when(request.oceanBookingId()).thenReturn(199L); + OceanBookingEntity booking = releasedBooking(); booking.setId(199L); booking.setBillOfLadingNo("BL-NEW"); + when(oceanBookings.find(199L)).thenReturn(booking); + when(repository.firstActiveForUpdate(199L)).thenReturn(null); + when(repository.updateEdit(any(), eq(3))).thenReturn(1); + + service.edit(1L, 7L, "admin", request); + + ArgumentCaptor updated = ArgumentCaptor.forClass(TruckAppointmentEntity.class); + verify(repository).updateEdit(updated.capture(), eq(3)); + assertThat(updated.getValue().getOceanBookingId()).isEqualTo(199L); + assertThat(updated.getValue().getContainerBookingId()).isNull(); + assertThat(updated.getValue().getLoadingPlanId()).isNull(); + assertThat(updated.getValue().getBillOfLadingNo()).isEqualTo("BL-NEW"); + } + + @Test void actionAdvancesMainLifecycleAndWritesIdempotentOperation() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.DRAFT, 0); + TruckAppointmentEntity after = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); + when(repository.find(1L)).thenReturn(before, after); + when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("idem-1"), anyString())).thenReturn(true); + when(repository.transition(1L, "DRAFT", "PENDING_DISPATCH", 0)).thenReturn(1); + when(repository.completeOperation(eq(1L), eq("SUBMIT"), eq("idem-1"), anyString())).thenReturn(1); + + var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", null, 7L, "admin"); + + assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH); + assertThat(result.getVersion()).isEqualTo(1); + assertThat(result.getRevision()).isEqualTo(1); + verify(repository).claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("idem-1"), anyString()); + verify(repository).completeOperation(eq(1L), eq("SUBMIT"), eq("idem-1"), anyString()); + verify(repository).history(any(TruckAppointmentHistoryEntity.class)); + } + + @Test void actionReplaysCompletedRequestWithoutApplyingTransitionAgain() throws Exception { + TruckAppointmentEntity completed = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); + TruckAppointmentOperationEntity operation = new TruckAppointmentOperationEntity(); + operation.setAppointmentId(1L); operation.setAction("SUBMIT"); operation.setIdempotencyKey("idem-1"); + String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交")); + operation.setRequestHash(hash); operation.setResponseSnapshot(objectMapper.writeValueAsString(completed)); + when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("idem-1"), eq(hash))).thenReturn(false); + when(repository.operation("idem-1")).thenReturn(operation); + + var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", hash, 7L, "admin"); + + assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + verify(repository, never()).history(any()); + } + + @Test void concurrentSameKeyBeforeFirstCompletionReturnsRetryableConflictWithoutDrift() throws Exception { + var request = new ActionRequest(0, "并发提交"); + String hash = requestHash("SUBMIT", 1L, request); + TruckAppointmentOperationEntity processing = new TruckAppointmentOperationEntity(); + processing.setAppointmentId(1L); processing.setAction("SUBMIT"); + processing.setIdempotencyKey("concurrent-submit"); processing.setRequestHash(hash); + when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("concurrent-submit"), eq(hash))) + .thenReturn(false); + when(repository.operation("concurrent-submit")).thenReturn(processing); + + assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, "concurrent-submit", hash, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("正在处理中"); + + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + verify(repository, never()).history(any()); + verify(repository, never()).completeOperation(anyLong(), anyString(), anyString(), anyString()); + } + + @Test void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutDrift() { + TruckAppointmentOperationEntity existing = new TruckAppointmentOperationEntity(); + existing.setAppointmentId(1L); existing.setAction("SUBMIT"); + existing.setIdempotencyKey("reused-key"); existing.setRequestHash("different-hash"); + when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("reused-key"), anyString())) + .thenReturn(false); + when(repository.operation("reused-key")).thenReturn(existing); + + assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A"), + "reused-key", null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("不同请求"); + + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + verify(repository, never()).history(any()); + } + + @Test void dispatchRequiresBookingBillAndActiveBookingOwnedDocument() { + TruckAppointmentEntity missingBill = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); + missingBill.setOceanBookingId(99L); missingBill.setContainerBookingId(88L); + OceanBookingEntity bookingWithoutBill = releasedBooking(); bookingWithoutBill.setBillOfLadingNo(null); + when(oceanBookings.find(99L)).thenReturn(bookingWithoutBill); + when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-bill"), anyString())).thenReturn(true); + when(repository.find(1L)).thenReturn(missingBill); + + assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null), + "dispatch-no-bill", null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("提单号"); + + TruckAppointmentEntity missingAttachment = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); + missingAttachment.setOceanBookingId(99L); missingAttachment.setContainerBookingId(88L); missingAttachment.setBillOfLadingNo("BL-BOOKING-1"); + when(oceanBookings.find(99L)).thenReturn(releasedBooking()); + when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-file"), anyString())).thenReturn(true); + when(repository.find(1L)).thenReturn(missingAttachment); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"订舱缺少有效订舱确认或提单文件")) + .when(oceanBookingDocuments).requireActiveValidDocument(any()); + + assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null), + "dispatch-no-file", null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件"); + verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt()); + } + + @Test void dispatchFreezesReleasedBookingBillWithoutRequiringRealContainerNumber() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); + before.setOceanBookingId(99L); before.setContainerBookingId(88L); before.setBillOfLadingNo(null); + TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2); + after.setOceanBookingId(99L); after.setContainerBookingId(88L); after.setBillOfLadingNo("BL-BOOKING-1"); + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setId(99L); booking.setStatus("RELEASED"); booking.setBillOfLadingNo("BL-BOOKING-1"); + when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(true); + when(repository.find(1L)).thenReturn(before, after); + when(oceanBookings.find(99L)).thenReturn(booking); + when(repository.dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1)).thenReturn(1); + when(repository.completeOperation(eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(1); + + TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null), + "dispatch-ok", null, 7L, "admin"); + + assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DISPATCHED); + assertThat(result.getBillOfLadingNo()).isEqualTo("BL-BOOKING-1"); + verify(repository).dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + verify(repository).history(any(TruckAppointmentHistoryEntity.class)); + } + + @Test void pickupWithoutContainerNumberStillAdvancesWithoutBinding() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2); + before.setOceanBookingId(99L); + TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3); + after.setOceanBookingId(99L); + when(repository.find(1L)).thenReturn(before, after); + when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-without-number"), anyString())).thenReturn(true); + when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1); + when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-without-number"), anyString())).thenReturn(1); + + TruckAppointmentEntity result = service.action(1L, "PICKUP", new ActionRequest(2, "提柜"), + "pickup-without-number", null, 7L, "admin"); + + assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS); + verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2); + verify(repository).history(any(TruckAppointmentHistoryEntity.class)); + } + + @Test void actionRejectsMissingKeyAndMismatchedClientHash() { + var request = new ActionRequest(0, "提交"); + assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, null, null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("Idempotency-Key"); + assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, "idem-1", "wrong", 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("X-Request-Hash"); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + } + + @Test void rejectsRescheduleThroughGenericActionAndCompletedReschedule() { + TruckAppointmentEntity e = entity(TruckAppointmentStatus.DRAFT, 0); + when(repository.find(1L)).thenReturn(e); + when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-action"), anyString())).thenReturn(true); + assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null), "idem-action", null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("专用接口"); + e.setStatus(TruckAppointmentStatus.COMPLETED); + when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(true); + assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约"), "idem-reschedule", null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("不可改约"); + verify(repository, never()).reschedule(anyLong(), any(), anyString(), anyInt()); + } + + @Test void rescheduleKeepsFromStatusAndIncrementsOptimisticFields() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2); + TruckAppointmentEntity after = entity(TruckAppointmentStatus.RESCHEDULED, 3); + when(repository.find(1L)).thenReturn(before, after); + when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(true); + when(repository.reschedule(eq(1L), any(), eq("改约"), eq(2))).thenReturn(1); + when(repository.completeOperation(eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(1); + var at = LocalDateTime.of(2026, 8, 12, 10, 0); + var result = service.reschedule(1L, at, new ActionRequest(2, "改约"), "idem-reschedule", null, 7L, "admin"); + assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.RESCHEDULED); + assertThat(result.getVersion()).isEqualTo(3); + ArgumentCaptor history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class); + verify(repository).history(history.capture()); + assertThat(history.getValue().getFromStatus()).isEqualTo("DISPATCHED"); + assertThat(history.getValue().getToStatus()).isEqualTo("RESCHEDULED"); + } + + @Test void createRejectsUnknownBookingWithoutWriting() { + SaveRequest request = validRequest(); + when(oceanBookings.find(99L)).thenReturn(null); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("关联订舱不存在"); + + verify(repository, never()).insert(any()); + verify(repository, never()).history(any()); + } + + @Test void createRejectsWarehouseMismatchWithoutWriting() { + SaveRequest request = validRequest(); + when(request.warehouseId()).thenReturn(19L); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("warehouseId与订舱不一致"); + + verify(repository, never()).insert(any()); + } + + @Test void createRejectsInvalidTruckingSupplierWithoutWriting() { + SaveRequest request = validRequest(); + when(suppliers.domesticTrucking(8L, null)).thenThrow( + new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "拖车服务商未启用")); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("拖车服务商未启用"); + + verify(repository, never()).insert(any()); + } + + @Test void editRejectsTargetBookingOwnedByAnotherActiveAppointment() { + TruckAppointmentEntity current = entity(TruckAppointmentStatus.DRAFT, 3); + current.setOceanBookingId(99L); + TruckAppointmentEntity occupied = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); + occupied.setId(2L); occupied.setOceanBookingId(199L); occupied.setAppointmentNo("TA-260901-0099"); + SaveRequest request = validRequest(); + when(request.expectedVersion()).thenReturn(3); + when(request.oceanBookingId()).thenReturn(199L); + OceanBookingEntity target = releasedBooking(); target.setId(199L); + when(oceanBookings.find(199L)).thenReturn(target); + when(repository.find(1L)).thenReturn(current); + when(repository.firstActiveForUpdate(199L)).thenReturn(occupied); + + assertThatThrownBy(() -> service.edit(1L, 7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("TA-260901-0099"); + + verify(repository, never()).updateEdit(any(), anyInt()); + } + + @Test void detailJsonContainsNoContainerProtocolFields() throws Exception { + TruckAppointmentEntity appointment = entity(TruckAppointmentStatus.DRAFT, 0); + appointment.setOceanBookingId(99L); appointment.setContainerBookingId(88L); + appointment.setLoadingPlanId(77L); appointment.setCabinetNo("CAB-OLD"); appointment.setContainerNo("MSCU6639870"); + when(repository.find(1L)).thenReturn(appointment); + + String json = objectMapper.writeValueAsString(service.detail(1L)); + + assertThat(json).doesNotContain("containerBookingId", "loadingPlanId", "cabinetNo", "containerNo", "containers"); + assertThat(json).contains("oceanBookingId"); + } + + @Test void deleteAttachmentKeepsAppointmentOwnershipGuard() { + when(repository.find(1L)).thenReturn(entity(TruckAppointmentStatus.DRAFT, 0)); + when(repository.deleteAttachment(1L, 77L)).thenReturn(1); + + service.deleteAttachment(1L, 77L); + + verify(repository).deleteAttachment(1L, 77L); + } + + private String requestHash(String action, long id, Object request) throws Exception { + String canonical = action + '|' + id + '|' + objectMapper.writeValueAsString(request); + return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(canonical.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } + + private static TruckAppointmentEntity entity(TruckAppointmentStatus status, int version) { + TruckAppointmentEntity e = new TruckAppointmentEntity(); e.setId(1L); e.setOceanBookingId(99L); e.setStatus(status); e.setVersion(version); e.setRevision(version); return e; + } + + private static SaveRequest request(int expectedVersion) { + try { + Constructor c = SaveRequest.class.getDeclaredConstructors()[0]; + Object[] args = new Object[c.getParameterCount()]; args[args.length - 1] = expectedVersion; + return (SaveRequest) c.newInstance(args); + } catch (ReflectiveOperationException ex) { throw new AssertionError(ex); } + } + + private static SaveRequest validRequest() { + SaveRequest request = mock(SaveRequest.class); + when(request.expectedVersion()).thenReturn(0); + when(request.carrierId()).thenReturn(8L); + when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); + when(request.oceanBookingId()).thenReturn(99L); + when(request.warehouseId()).thenReturn(null); + return request; + } + + private static OceanBookingEntity releasedBooking() { + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setId(99L); booking.setStatus("RELEASED"); + booking.setWarehouseId(18L); booking.setBillOfLadingNo("BL-BOOKING-1"); + return booking; + } + +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java new file mode 100644 index 000000000..6bcd6c210 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java @@ -0,0 +1,129 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.InboundOrderDetail; +import com.baiyue.firstleg.dto.UnifiedInboundDetail; +import com.baiyue.firstleg.dto.UnifiedInboundQuery; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.FbaPricingRepository; +import com.baiyue.firstleg.repository.InboundOrderRepository; +import com.baiyue.firstleg.repository.UnifiedInboundReadRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UnifiedInboundQueryServiceTest { + @Test + void mapsAllCompatibleInternalStatesToProductMainStatuses() { + assertThat(UnifiedInboundQueryService.mainStatus("WAIT_MEASURE")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("MEASURING")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_PENDING")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_REJECTED")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("WAIT_SPLIT"); + assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING"); + assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED"); + assertThat(UnifiedInboundQueryService.mainStatus("DELIVERED")).isEqualTo("DELIVERED"); + assertThat(UnifiedInboundQueryService.mainStatus("VOIDED")).isNull(); + } + + @Test + void nextActionReflectsExecutableMeasurementAndPreloadSubstates() { + assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURING", null)).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURE_PENDING", null)) + .isEqualTo("CONFIRM_MEASUREMENT"); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", null)) + .isEqualTo("SMART_SPLIT"); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "PENDING")) + .isEqualTo("CONFIRM_PRELOAD"); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "INBOUNDING", null)).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "CONFIRMED")).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null)).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull(); + } + + @Test + void waitSplitFilterUsesOnlyCanonicalStatus() throws Exception { + var method = UnifiedInboundQueryService.class.getDeclaredMethod("internalStatuses", String.class); + method.setAccessible(true); + + assertThat(method.invoke(new UnifiedInboundQueryService(), "WAIT_SPLIT")) + .isEqualTo(java.util.List.of("WAIT_SPLIT")); + } + + @Test + void phaseTwoMeasurementStatusesDoNotRequireUndeployedPhaseThreeTables() { + assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("WAIT_MEASURE")).isFalse(); + assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("WAIT_SPLIT")).isFalse(); + assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("LOADING")).isTrue(); + assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("LOADED")).isTrue(); + assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("DELIVERED")).isTrue(); + } + + @Test + void completedHistoricalInboundUsesConfirmedWeightCountWhenStoredPalletCountIsZero() throws Exception { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(88L); + inbound.setPalletCount(0); + var method = UnifiedInboundQueryService.class.getDeclaredMethod( + "applyConfirmedPalletCount", InboundOrderEntity.class, Map.class); + method.setAccessible(true); + + method.invoke(null, inbound, Map.of(88L, 2)); + + assertThat(inbound.getPalletCount()).isEqualTo(2); + } + + @ParameterizedTest + @ValueSource(strings = {"WAIT_MEASURE", "WAIT_SPLIT"}) + void phaseTwoDetailDoesNotQueryPhaseThreeTables(String processStatus) { + InboundOrderRepository inbounds = mock(InboundOrderRepository.class); + UnifiedInboundReadRepository operations = mock(UnifiedInboundReadRepository.class); + FbaPricingRepository pricing = mock(FbaPricingRepository.class); + UnifiedInboundQueryService service = new UnifiedInboundQueryService(); + ReflectionTestUtils.setField(service, "inbounds", inbounds); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "pricing", pricing); + + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(88L); + inbound.setOrderId(99L); + inbound.setTenantId(10001L); + inbound.setProcessStatus(processStatus); + InboundOrderDetail detail = new InboundOrderDetail(null, null, null, null, null, List.of()); + when(inbounds.findByOrderId(10001L, 99L)).thenReturn(inbound); + when(inbounds.detail(10001L, inbound)).thenReturn(detail); + when(pricing.customerFeeConfirmationRequiredMap(10001L, Map.of(88L, 10001L))) + .thenReturn(Map.of(88L, true)); + + UnifiedInboundDetail result = service.detail(10001L, 99L); + + assertThat(result).isNotNull(); + assertThat(result.summary().customerFeeConfirmationRequired()).isTrue(); + verifyNoInteractions(operations); + } + + @Test + void rejectsUnknownReviewStatusBeforeRepositoryQuery() { + UnifiedInboundQuery query = new UnifiedInboundQuery( + null, null, null, null, null, null, null, null, + null, null, "UNKNOWN", null, null, null, null, null, null, "INVALID", + null, null, null, 1, 20); + + assertThatThrownBy(() -> new UnifiedInboundQueryService().page(query)) + .isInstanceOf(BizException.class); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java new file mode 100644 index 000000000..5195483f2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java @@ -0,0 +1,92 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.assertThat; +import com.baiyue.common.core.api.ApiResponse; + +class WarehouseSupplierSnapshotServiceTest { + @Mock WarehouseSupplierClient client; + private WarehouseSupplierSnapshotService service; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new WarehouseSupplierSnapshotService(); + ReflectionTestUtils.setField(service, "client", client); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + } + + @Test + void rejectsFreeTextWithoutSupplierId() { + assertThatThrownBy(() -> service.domesticTrucking(null, "任意承运商")) + .isInstanceOf(BizException.class).hasMessageContaining("自由文本"); + verifyNoInteractions(client); + } + + @Test + void resolvesActiveBookingSupplierByCategoryWithoutScopeAndUsesServerName() { + when(client.get("internal-token", 7L, true, "BOOKING")) + .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(7L, "海运代理", "BOOKING", "ACTIVE", java.util.List.of("MATSON"), null))); + var snapshot = service.booking(7L, "海运代理"); + assertThat(snapshot.name()).isEqualTo("海运代理"); + verify(client).get("internal-token", 7L, true, "BOOKING"); + } + + @Test + void feeWithoutCategoryAcceptsAnyActiveWarehouseSupplier() { + when(client.get("internal-token", 8L, true, null)) + .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot( + 8L, "任意启用供应商", "CERTIFICATE", "ACTIVE", java.util.List.of(), null))); + + assertThat(service.fee(8L, null).name()).isEqualTo("任意启用供应商"); + verify(client).get("internal-token", 8L, true, null); + } + + @Test + void rejectsClientNameThatDiffersFromWarehouseSnapshot() { + when(client.get(anyString(), eq(9L), eq(true), eq("CUSTOMS"))) + .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(9L, "真实报关行", "CUSTOMS", "ACTIVE", java.util.List.of(), null))); + assertThatThrownBy(() -> service.customs(9L, "伪造名称")) + .isInstanceOf(BizException.class).hasMessageContaining("不一致"); + } + + @Test + void rejectsSupplierSnapshotWithWrongCategoryOrInactiveStatus() { + when(client.get("internal-token", 7L, true, "BOOKING")) + .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot( + 7L, "错误代理", "TRUCKING", "ACTIVE", java.util.List.of(), "DOMESTIC"))); + + assertThatThrownBy(() -> service.booking(7L, null)) + .isInstanceOf(BizException.class).hasMessageContaining("类别不匹配"); + } + + @Test void truckingScopesAreMutuallyExclusiveAndMissingScopeIsRejected() { + for (String scope : new String[] {"DOMESTIC", "OVERSEAS", null}) { + when(client.get("internal-token", 7L, true, "TRUCKING")) + .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot( + 7L, "车队", "TRUCKING", "ACTIVE", java.util.List.of(), scope))); + if ("DOMESTIC".equals(scope)) { + assertThat(service.domesticTrucking(7L, null).name()).isEqualTo("车队"); + } else { + assertThatThrownBy(() -> service.domesticTrucking(7L, null)) + .isInstanceOf(BizException.class).hasMessageContaining("国内拖车"); + } + if ("OVERSEAS".equals(scope)) { + assertThat(service.overseasTrucking(7L, null).name()).isEqualTo("车队"); + } else { + assertThatThrownBy(() -> service.overseasTrucking(7L, null)) + .isInstanceOf(BizException.class).hasMessageContaining("海外车队"); + } + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java new file mode 100644 index 000000000..f5deb15f0 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/controller/PlatformSplitPreloadControllerContractTest.java @@ -0,0 +1,34 @@ +package com.baiyue.firstleg.split.controller; + +import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformSplitPreloadControllerContractTest { + @Test + void shouldDeclareStableOperationsAndButtonPermissions() throws Exception { + assertAuthority("smart", "hasAuthority('firstleg:inbound:split')", SmartSplitRequest.class); + assertThat(PlatformSplitPreloadController.class + .getDeclaredMethod("detail", com.baiyue.common.security.AuthenticatedUser.class, + long.class, long.class).getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('firstleg:inbound:preload-view')"); + } + + @Test + void doesNotExposeInboundScopedPreloadConfirmation() { + assertThat(PlatformSplitPreloadController.class.getDeclaredMethods()) + .noneMatch(method -> method.getName().equals("confirm") + || method.getName().equals("confirming")); + } + + private void assertAuthority(String name, String expected, Class requestType) throws Exception { + Method method = PlatformSplitPreloadController.class.getDeclaredMethod(name, + com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class, + String.class, requestType); + assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected); + } +} 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 new file mode 100644 index 000000000..29294fc3f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -0,0 +1,182 @@ +package com.baiyue.firstleg.split.service; + +import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem; +import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; +import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse; +import com.baiyue.firstleg.split.entity.PreloadPlanEntity; +import com.baiyue.firstleg.split.mapper.SplitInboundMapper.InboundSnapshot; +import com.baiyue.firstleg.split.repository.SplitRepository; +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.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.List; +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.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class SplitPreloadServiceTest { + @Mock SplitRepository repository; + @InjectMocks SplitPreloadService service; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void objectMapper() throws Exception { + var field = SplitPreloadService.class.getDeclaredField("objectMapper"); + field.setAccessible(true); + field.set(service, objectMapper); + } + + @Test + void smartSplitCreatesEditablePendingPlan() { + when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + when(repository.findPlan(10, 20)).thenReturn(null); + SmartSplitRequest request = new SmartSplitRequest(0, + List.of(new PreloadItem("C1", "A", 2, BigDecimal.ONE, null)), "r"); + SplitActionResponse result = service.smartSplit(10, 20, 7, "admin", "k2", request); + assertThat(result.mainStatus()).isEqualTo("WAIT_SPLIT"); + verify(repository).createPlan(any(PreloadPlanEntity.class)); + } + + @Test + void loadingAllocationCreatesPendingPreloadLifecycle() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + when(repository.findPlan(10, 20)).thenReturn(null); + + service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(PreloadPlanEntity.class); + verify(repository).createPlan(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo("PENDING"); + assertThat(captor.getValue().getItemsJson()).isEqualTo("[]"); + assertThat(captor.getValue().getRemark()).isEqualTo("智能装柜分配自动建立"); + } + + @ParameterizedTest + @ValueSource(strings = {"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", + "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT"}) + void preAllocationCreatesAProvisionalPreloadLifecycleForEveryCandidateState(String processStatus) { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, processStatus, 0)); + when(repository.findPlan(10, 20)).thenReturn(null); + + service.ensurePendingForPreAllocation(10, 20, 7, "admin"); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(PreloadPlanEntity.class); + verify(repository).createPlan(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo("PENDING"); + assertThat(captor.getValue().getRemark()).isEqualTo("智能装柜提前分配自动建立"); + } + + @Test + void preAllocationRejectsPalletizingState() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_PALLETIZE", 0)); + + assertThatThrownBy(() -> service.ensurePendingForPreAllocation(10, 20, 7, "admin")) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessage("订单当前状态不能加入装柜清单"); + verify(repository, never()).createPlan(any()); + } + + @Test + void loadingAllocationMayReuseConfirmingAndConfirmedPreloads() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + PreloadPlanEntity confirming = new PreloadPlanEntity(); + confirming.setStatus("CONFIRMING"); + PreloadPlanEntity confirmed = new PreloadPlanEntity(); + confirmed.setStatus("CONFIRMED"); + when(repository.findPlan(10, 20)).thenReturn(confirming, confirmed); + + service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"); + service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"); + + verify(repository, never()).createPlan(any()); + } + + @Test + 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); + + service.ensureForRunningLoadingAllocation(10, 20, 7, "admin"); + + verify(repository, never()).createPlan(any()); + } + + @Test + 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 runningLoadingAllocationMayReuseConfirmingPreload() { + 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); + + service.ensureForRunningLoadingAllocation(10, 20, 7, "admin"); + + verify(repository, never()).createPlan(any()); + } + + @Test + void splitPreloadNeverCreatesInboundChildrenOrFulfillmentSlices() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java")); + assertThat(source).doesNotContain("cloneChild", "OrderFulfillmentSplitClient", + "PdaPickSettlementService", "REMAINDER"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java new file mode 100644 index 000000000..7c5354aa4 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java @@ -0,0 +1,85 @@ +package com.baiyue.firstleg.split.service; + +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 SplitPreloadSqlContractTest { + @Test + void supportsPartialPackageLock() throws Exception { + assertCurrentContract(moduleMigration()); + } + + @Test + void limitsNewChildrenToRootBasedRemainders() throws Exception { + String sql = moduleMigration(); + assertThat(sql) + .contains("parent_inbound_id") + .contains("inbound_role") + .contains("root_inbound_id") + .contains("split_status") + .contains("uk_inbound_parent_split_sequence") + .contains("fk_inbound_parent_order") + .contains("新建子入仓单只能作为余量分片") + .contains("uk_inbound_one_active_remainder") + .contains("入仓单主子归属创建后不可修改"); + } + + @Test + void remainsRepeatable() throws Exception { + String sql = moduleMigration(); + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history") + .contains("information_schema.columns") + .contains("information_schema.statistics") + .contains("information_schema.table_constraints") + .contains("DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert") + .contains("DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete") + .contains("CREATE PROCEDURE phase3_assert_inbound_parent_child_schema()") + .contains("主子入仓单拆分台账未完整创建") + .contains("主子入仓单数据库保护触发器未完整创建"); + } + + @org.junit.jupiter.api.Disabled("统一deploy SQL由集成部署任务同步,本模块契约不强制其同轮更新") + @Test + void syncsUnifiedMigration() throws Exception { + assertCurrentContract(unifiedMigration()); + } + + private static void assertCurrentContract(String sql) { + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history") + .contains("uk_inbound_parent_split_sequence") + .contains("uk_inbound_split_operation") + .contains("uk_inbound_split_idempotency") + .contains("uk_inbound_split_batch_line") + .contains("COUNT(DISTINCT item.child_inbound_id)") + .contains("child_order.package_count <> SUM(item.package_count)") + .contains("HAVING SUM(split_item.package_count) > source_item.package_count") + .contains("CREATE TRIGGER trg_inbound_split_item_before_update") + .contains("CREATE TRIGGER trg_inbound_split_item_before_delete"); + } + + private static String moduleMigration() throws Exception { + return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static String unifiedMigration() throws Exception { + Path working = Path.of("").toAbsolutePath(); + Path file = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(file)) { + file = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + return Files.readString(file, StandardCharsets.UTF_8); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java new file mode 100644 index 000000000..fdc975777 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java @@ -0,0 +1,77 @@ +package com.baiyue.firstleg.terminaldrayage.controller; + +import org.junit.jupiter.api.Test; +import org.apache.ibatis.annotations.Select; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class TerminalDrayageOpenApiContractTest { + @Test + void actualTimeRequestBindsRemarkFromPublicJson() throws Exception { + var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + var request = mapper.readValue(""" + {"expectedVersion":3,"actualAt":"2026-09-04T09:00:00","remark":"已还柜"} + """, com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest.class); + assertThat(request.remark()).isEqualTo("已还柜"); + assertThat(request.expectedVersion()).isEqualTo(3); + String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml")); + String schema = yaml.substring(yaml.indexOf(" ActualTimeRequest:"), yaml.indexOf(" Warning:")); + assertThat(schema).contains("remark:", "expectedVersion:", "actualAt:").doesNotContain("reason:"); + } + + @Test + void controllerUsesFrozenOverseasPermissionsWithoutGenericContainerFallback() throws Exception { + assertPermission("views", "platform:firstleg:terminal-drayage:list"); + assertPermission("detail", "platform:firstleg:terminal-drayage:list"); + assertPermission("history", "platform:firstleg:terminal-drayage:list"); + assertPermission("selectFleet", "platform:firstleg:terminal-drayage:fleet:edit"); + assertPermission("sendPickupEmail", "platform:firstleg:terminal-drayage:fleet:edit"); + assertPermission("retryEmail", "platform:firstleg:terminal-drayage:fleet:edit"); + assertPermission("emails", "platform:firstleg:terminal-drayage:list"); + assertPermission("schedulePickup", "platform:firstleg:terminal-drayage:pickup-appointment:edit"); + assertPermission("recordEmptyReturn", "platform:firstleg:terminal-drayage:empty-return:confirm"); + } + + @Test + void freezesActionsPermissionsAndServerWarningFields() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml")); + assertThat(yaml).contains( + "/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:", + "/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:", + "/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:", + "platform:firstleg:terminal-drayage:list", + "platform:firstleg:terminal-drayage:fleet:edit", + "platform:firstleg:terminal-drayage:pickup-appointment:edit", + "platform:firstleg:terminal-drayage:empty-return:confirm", + "expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt", + "emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays", + "船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情", + "允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件"); + assertThat(yaml).contains("description: 卸柜完成时间加72小时"); + assertThat(yaml).doesNotContain("/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:", + "platform:firstleg:terminal-drayage:pickup:confirm", "RECORD_PICKUP"); + } + + @Test + void terminalDrayageContextsUseConfirmedOutboundAsTheOverseasBoundary() throws Exception { + var mapper = com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class; + for (String methodName : java.util.List.of("context", "lockContext", "contexts")) { + var method = java.util.Arrays.stream(mapper.getDeclaredMethods()) + .filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow(); + String sql = String.join("\n", method.getAnnotation(Select.class).value()); + assertThat(sql).contains("FROM fl_outbound_dispatch", "d.confirmed_at IS NOT NULL", + "d.reversed_at IS NULL", "d.status='WAITING_FOR_LOADING'"); + } + } + + private static void assertPermission(String methodName, String permission) { + var method = java.util.Arrays.stream(PlatformTerminalDrayageController.class.getDeclaredMethods()) + .filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + permission + "')"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java new file mode 100644 index 000000000..52bc4bd58 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java @@ -0,0 +1,73 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +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 TerminalDrayageSqlContractTest { + @Test + void mailProjectionMatchesRecordAndPendingQueryBlocksEarlierUnsentMail() throws Exception { + var mapperType = com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class; + var fields = java.util.Arrays.stream(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.MailTask.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName).toList(); + for (String name : java.util.List.of("mails", "lastPickupMail", "pendingMails")) { + var method = java.util.Arrays.stream(mapperType.getMethods()).filter(value -> value.getName().equals(name)).findFirst().orElseThrow(); + String sql = String.join(" ", method.getAnnotation(org.apache.ibatis.annotations.Select.class).value()); + String projection = sql.substring(sql.indexOf("SELECT")+6, sql.indexOf("FROM")); + var selected = java.util.Arrays.stream(projection.split(",")).map(String::trim) + .map(value -> value.contains(" AS ") ? value.substring(value.indexOf(" AS ")+4) : value.replace("m.", "")).toList(); + assertThat(selected).isEqualTo(fields); + if (name.equals("pendingMails")) assertThat(sql).contains("earlier.container_booking_id=m.container_booking_id", "earlier.id'SENT'"); + } + for (Path path : java.util.List.of(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"), Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))) { + assertThat(Files.readString(path)).contains( + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (", + "CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))", + "('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')"); + } + } + + @Test + void bothSqlEntrypointsRemoveTheManualPickupTimestamp() throws Exception { + String baseline = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql")); + assertThat(baseline).doesNotContain("actual_pickup_at", "chk_terminal_drayage_times"); + String release = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertThat(release).contains("ALTER TABLE fl_terminal_drayage DROP COLUMN actual_pickup_at", + "DROP CHECK chk_terminal_drayage_history_action", + "ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN"); + assertThat(release).doesNotContain("chk_terminal_drayage_times"); + } + + @Test + void schemaKeepsOneAggregatePerContainerAndImmutableAudit() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql")); + String mapper = Files.readString(Path.of( + "src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java")); + assertThat(sql).contains( + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage (", + "fleet_supplier_id BIGINT UNSIGNED NULL", + "fleet_supplier_name VARCHAR(128) NULL", + "UNIQUE KEY uk_terminal_drayage_container(container_booking_id)", + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (", + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (", + "UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key)", + "服务端按卸柜完成时间加72小时计算的预计还柜时间"); + assertThat(sql).doesNotContain("ON DELETE CASCADE"); + assertThat(mapper).contains("WHERE b.id=#{containerBookingId}", "FOR UPDATE", + "WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}") + .doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact"); + } + + @Test + void unifiedMigrationRepairsColumnsAfterAPartialDdlRun() throws Exception { + String sql = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertThat(sql).contains( + "ALTER TABLE fl_terminal_drayage", + "ALTER TABLE fl_terminal_drayage_history", + "ALTER TABLE fl_terminal_drayage_operation", + "MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间'"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java new file mode 100644 index 000000000..74175e9c0 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java @@ -0,0 +1,262 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.firstleg.dto.ContainerDtos.Query; +import com.baiyue.firstleg.dto.ContainerDtos.WorkbenchScope; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.repository.ContainerRepository; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning; +import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +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.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ContainerWorkbenchServiceTest { + private ContainerRepository repository; + private CarrierScheduleQueryService carrierSchedules; + private TerminalDrayageService terminalDrayages; + private ContainerWorkbenchService service; + + @BeforeEach + void setUp() { + repository = mock(ContainerRepository.class); + carrierSchedules = mock(CarrierScheduleQueryService.class); + terminalDrayages = mock(TerminalDrayageService.class); + service = new ContainerWorkbenchService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "carrierSchedules", carrierSchedules); + ReflectionTestUtils.setField(service, "terminalDrayages", terminalDrayages); + } + + @Test + void domesticPageKeepsServerPaginationAndUsesOneActualFactBatch() { + ContainerBookingEntity container = container(); + container.setPlanStatus("COMPLETED"); + container.setCustomsDeclarationDocumentStatus("COMPLETED"); + when(repository.page(eq("DOMESTIC"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1)); + when(carrierSchedules.containerActualFacts(List.of(11L))).thenReturn(List.of()); + + var result = service.domesticPage(query(WorkbenchScope.ACTIVE), Set.of( + "platform:firstleg:customs:manage", "platform:firstleg:outbound-dispatch:confirm")); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.records().get(0).workbenchStage()).isEqualTo("WAIT_OUTBOUND"); + assertThat(result.records().get(0).actualFacts()).isNull(); + assertThat(result.records().get(0).allowedActions()) + .containsExactly("MANAGE_CUSTOMS", "CONFIRM_OUTBOUND"); + verify(carrierSchedules, times(1)).containerActualFacts(List.of(11L)); + verifyNoInteractions(terminalDrayages); + } + + @Test + void overseasPageConsumesTerminalAggregateOnceAndFiltersActionsByPermission() { + ContainerBookingEntity container = container(); + container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0)); + ContainerActualFacts facts = facts("PICKED_UP"); + View raw = new View(11L, "MSCU1234567", 21L, "UNLOADED", 3, 91L, "车队", + LocalDateTime.of(2026, 9, 4, 8, 0), LocalDateTime.of(2026, 9, 4, 9, 0), + LocalDateTime.of(2026, 9, 8, 8, 0), null, container.getUnloadingCompletedAt(), + new Warning("PENDING", LocalDateTime.of(2026, 9, 8, 8, 0), 0, 0), + List.of("RECORD_EMPTY_RETURN"), facts, LocalDateTime.of(2026, 9, 5, 9, 0)); + Set permissions = Set.of("platform:firstleg:terminal-drayage:empty-return:confirm"); + when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1)); + when(terminalDrayages.views(List.of(11L))).thenReturn(List.of(raw)); + when(terminalDrayages.authorize(List.of(raw), permissions)).thenReturn(List.of(raw)); + + var result = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions); + + assertThat(result.records().get(0).workbenchStage()).isEqualTo("UNLOADED"); + assertThat(result.records().get(0).actualFacts()).isSameAs(facts); + assertThat(result.records().get(0).allowedActions()).containsExactly("RECORD_EMPTY_RETURN"); + verify(terminalDrayages, times(1)).views(List.of(11L)); + verifyNoInteractions(carrierSchedules); + } + + @Test + void overseasDetailKeepsNewlyDepartedContainerBeforeCarrierTrackingExists() { + ContainerBookingEntity container = container(); + container.setOutboundConfirmedAt(LocalDateTime.of(2026, 9, 10, 10, 50)); + Set permissions = Set.of("platform:firstleg:terminal-drayage:list", + "platform:firstleg:terminal-drayage:document:edit"); + when(repository.find(11L)).thenReturn(container); + when(repository.departed(11L)).thenReturn(true); + var waiting = waitingDrayage(); + when(terminalDrayages.views(List.of(11L))).thenReturn(List.of(waiting)); + when(terminalDrayages.authorize(List.of(waiting), permissions)).thenReturn(List.of(waiting)); + + var result = service.overseasDetail(11L, permissions); + + assertThat(result.workbenchStage()).isEqualTo("DEPARTED_WAREHOUSE"); + assertThat(result.actualFacts()).isNull(); + assertThat(result.terminalDrayage()).isEqualTo(waiting); + assertThat(result.allowedActions()).containsExactly("MANAGE_DESTINATION_DOCUMENTS"); + verify(terminalDrayages, never()).detail(11L); + verifyNoInteractions(carrierSchedules); + } + + private static com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View waitingDrayage() { + return new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View( + 11L, "MSCU1234567", 21L, "WAITING_FOR_ARRIVAL", 0, null, null, null, + null, null, null, null, new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning( + "NOT_APPLICABLE", null, 0, 0), List.of(), null, null); + } + + @Test + void realOverseasListAndDetailProjectBothEmptyReturnActions() throws Exception { + verifyRealEmptyReturnProjection(true, true); + } + + @Test + void realOverseasListAndDetailRemoveBothActionsWithoutPermission() throws Exception { + verifyRealEmptyReturnProjection(false, true); + } + + @Test + void unloadedListWithoutDrayageAggregateOffersEmptyReturn() throws Exception { + verifyRealEmptyReturnProjection(true, false); + } + + private void verifyRealEmptyReturnProjection(boolean hasPermission, boolean hasAggregate) throws Exception { + var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class); + var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + var realDrayages = new TerminalDrayageService(mapper, + mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json, mock(TerminalDrayageMailService.class)); + ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages); + ContainerBookingEntity container = container(); + container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0)); + var context = new com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context( + 11L, "MSCU1234567", 21L, "ACTIVE", container.getUnloadingCompletedAt()); + var row = new com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity(); + row.setId(51L); + row.setContainerBookingId(11L); + row.setVersion(3); + when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1)); + when(repository.find(11L)).thenReturn(container); + when(repository.departed(11L)).thenReturn(true); + when(mapper.contexts(List.of(11L))).thenReturn(List.of(context)); + when(mapper.context(11L)).thenReturn(context); + when(mapper.findAll(List.of(11L))).thenReturn(hasAggregate ? List.of(row) : List.of()); + when(mapper.find(11L)).thenReturn(hasAggregate ? row : null); + when(carrierSchedules.containerActualFacts(List.of(11L))).thenReturn(List.of(facts("PICKED_UP"))); + Set permissions = hasPermission + ? Set.of("platform:firstleg:terminal-drayage:list", "platform:firstleg:terminal-drayage:empty-return:confirm") + : Set.of("platform:firstleg:terminal-drayage:list"); + + var page = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions); + var listed = page.records().get(0); + var detail = service.overseasDetail(11L, permissions); + List expected = hasPermission ? List.of("RECORD_EMPTY_RETURN") : List.of(); + assertThat(listed.workbenchStage()).isEqualTo("UNLOADED"); + assertThat(listed.terminalDrayage().status()).isEqualTo("UNLOADED"); + assertThat(listed.allowedActions()).isEqualTo(expected); + assertThat(listed.terminalDrayage().allowedActions()).isEqualTo(expected); + assertThat(detail.allowedActions()).isEqualTo(listed.allowedActions()); + assertThat(detail.terminalDrayage().allowedActions()).isEqualTo(listed.terminalDrayage().allowedActions()); + var serialized = json.readTree(json.writeValueAsString(com.baiyue.common.core.api.ApiResponse.success(page))); + assertThat(serialized.at("/data/records/0/allowedActions")).isEqualTo(json.valueToTree(expected)); + assertThat(serialized.at("/data/records/0/terminalDrayage/allowedActions")).isEqualTo(json.valueToTree(expected)); + verify(mapper, times(2)).findAll(List.of(11L)); + verify(mapper, times(2)).contexts(List.of(11L)); + } + + @Test + void unloadedWorkbenchWithoutPickupOffersEmptyReturn() { + var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class); + var realDrayages = new TerminalDrayageService(mapper, + mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, + new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class)); + ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages); + ContainerBookingEntity container = container(); + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 5, 8, 0); + container.setUnloadingCompletedAt(unloadedAt); + var context = new com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context( + 11L, container.getContainerNo(), 21L, "ACTIVE", unloadedAt); + LocalDateTime readyAt = unloadedAt.minusDays(1); + var actualFacts = new ContainerActualFacts(11L, 41L, 8, "READY", "PICKUP_READY", readyAt, + readyAt.minusDays(5), readyAt.minusDays(4), readyAt.minusDays(1), readyAt, null, null); + when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1)); + when(repository.find(11L)).thenReturn(container); + when(repository.departed(11L)).thenReturn(true); + when(mapper.contexts(List.of(11L))).thenReturn(List.of(context)); + when(mapper.context(11L)).thenReturn(context); + when(mapper.findAll(List.of(11L))).thenReturn(List.of()); + when(mapper.find(11L)).thenReturn(null); + when(carrierSchedules.containerActualFacts(List.of(11L))).thenReturn(List.of(actualFacts)); + Set permissions = Set.of("platform:firstleg:terminal-drayage:fleet:edit", + "platform:firstleg:terminal-drayage:empty-return:confirm"); + + var listed = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions).records().get(0); + var detail = service.overseasDetail(11L, permissions); + + for (var result : List.of(listed, detail)) { + assertThat(result.workbenchStage()).isEqualTo("UNLOADED"); + assertThat(result.terminalDrayage().status()).isEqualTo("UNLOADED"); + assertThat(result.terminalDrayage().version()).isZero(); + assertThat(result.terminalDrayage().actualPickupAt()).isEqualTo(unloadedAt); + assertThat(result.allowedActions()).containsExactly("RECORD_EMPTY_RETURN"); + assertThat(result.terminalDrayage().allowedActions()).containsExactly("RECORD_EMPTY_RETURN"); + } + verify(mapper, never()).insert(any(com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity.class)); + } + + @Test + void domesticHistoryDetailKeepsTheDepartedContainerAndItsUniqueLoadingPlan() { + ContainerBookingEntity container = container(); + container.setOutboundConfirmedAt(LocalDateTime.of(2026, 9, 8, 10, 0)); + when(repository.find(11L)).thenReturn(container); + when(repository.departed(11L)).thenReturn(true); + when(carrierSchedules.containerActualFacts(List.of(11L))).thenReturn(List.of(facts("DEPARTED_WAREHOUSE"))); + + var result = service.domesticDetail(11L, Set.of("platform:firstleg:container:list")); + + assertThat(result.workbenchStage()).isEqualTo("DEPARTED_WAREHOUSE"); + assertThat(result.container().getPlanId()).isEqualTo(31L); + assertThat(result.container().getPlanNo()).isEqualTo("LP-260908-0001"); + assertThat(result.allowedActions()).isEmpty(); + verify(repository, never()).departed(11L); + verifyNoInteractions(terminalDrayages); + } + + @Test + void domesticDetailStillRejectsContainersOutsideTheActiveMasterSet() { + when(repository.find(404L)).thenReturn(null); + + assertThatThrownBy(() -> service.domesticDetail(404L, Set.of("platform:firstleg:container:list"))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessage("货柜不存在"); + + verifyNoInteractions(carrierSchedules, terminalDrayages); + } + + private static Query query(WorkbenchScope scope) { + return new Query(null, null, null, null, null, null, null, null, null, null, null, + null, null, null, scope, 1, 20); + } + + private static ContainerBookingEntity container() { + ContainerBookingEntity value = new ContainerBookingEntity(); + value.setId(11L); value.setContainerNo("MSCU1234567"); value.setPlanId(31L); + value.setPlanNo("LP-260908-0001"); value.setPlanStatus("COMPLETED"); + value.setDestinationClearanceDocumentStatus("MISSING"); + value.setDeliveryOrderDocumentStatus("MISSING"); value.setOutboundDispatchStatus("NOT_CREATED"); + return value; + } + + private static ContainerActualFacts facts(String node) { + LocalDateTime now = LocalDateTime.of(2026, 9, 4, 9, 0); + return new ContainerActualFacts(11L, 41L, 3, "READY", node, now, + now.minusDays(5), now.minusDays(4), now.minusDays(1), now.minusHours(2), now, null); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcherTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcherTest.java new file mode 100644 index 000000000..a58ffa97f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcherTest.java @@ -0,0 +1,47 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper; +import org.junit.jupiter.api.Test; +import org.springframework.mail.MailSendException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class TerminalDrayageMailDispatcherTest { + TerminalDrayageMapper mapper = mock(TerminalDrayageMapper.class); + JavaMailSenderImpl sender = mock(JavaMailSenderImpl.class); + TerminalDrayageMailDispatcher worker = spy(new TerminalDrayageMailDispatcher(mapper)); + + void setup() { + ReflectionTestUtils.setField(worker,"username","operations@example.com"); + doReturn(sender).when(worker).sender(); + when(mapper.pendingMails()).thenReturn(List.of(new TerminalDrayageMapper.MailTask( + 1L,11L,2L,"Fleet","fleet@example.com","REQUEST_PICKUP","Pickup","Body","PENDING",0,null,null,null))); + when(mapper.claimMail(1L)).thenReturn(1); + when(mapper.completeMail(1L)).thenReturn(1); + } + + @Test void onlyClaimsOnceAndMarksSentAfterSmtpAccepted() { + setup(); worker.dispatch(); + var ordered = inOrder(mapper,sender); + var sent = org.mockito.ArgumentCaptor.forClass(SimpleMailMessage.class); + ordered.verify(mapper).claimMail(1L); + ordered.verify(sender).send(sent.capture()); + assertThat(sent.getValue().getFrom()).isEqualTo("operations@example.com"); + ordered.verify(mapper).completeMail(1L); + when(mapper.claimMail(1L)).thenReturn(0); + worker.dispatch(); + verify(sender,times(1)).send(any(SimpleMailMessage.class)); + } + + @Test void smtpFailureRequiresReviewInsteadOfReportingSuccess() { + setup(); doThrow(new MailSendException("uncertain")).when(sender).send(any(SimpleMailMessage.class)); + worker.dispatch(); + verify(mapper).failMail(eq(1L),contains("结果不确定")); + verify(mapper,never()).completeMail(anyLong()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailServiceTest.java new file mode 100644 index 000000000..156204c38 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailServiceTest.java @@ -0,0 +1,77 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class TerminalDrayageMailServiceTest { + TerminalDrayageMapper mapper = mock(TerminalDrayageMapper.class); + WarehouseSupplierClient suppliers = mock(WarehouseSupplierClient.class); + TerminalDrayageMailService service = new TerminalDrayageMailService(mapper, suppliers); + + @BeforeEach void setup() { + ReflectionTestUtils.setField(service, "internalToken", "test-token"); + when(suppliers.truckingContact("test-token", 1L, false)).thenReturn(ApiResponse.success( + new WarehouseSupplierClient.TruckingContact(1L, "Old Fleet", "old@example.com"))); + when(suppliers.truckingContact("test-token", 2L, true)).thenReturn(ApiResponse.success( + new WarehouseSupplierClient.TruckingContact(2L, "New Fleet", "new@example.com"))); + } + + @Test void replacementQueuesCancellationThenRequestWithSeparateFrozenRecipients() { + when(mapper.lastPickupMail(11L, 1L)).thenReturn(new TerminalDrayageMapper.MailTask( + 90L,11L,1L,"Old Fleet","old@example.com","REQUEST_PICKUP","subject","body","SENT",1,null,null,null)); + service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L); + var order = inOrder(mapper); + order.verify(mapper).insertMail(anyLong(), eq(11L), eq(1L), eq("Old Fleet"), eq("old@example.com"), + eq("CANCEL_PICKUP"), eq("Cancel port pickup - MSCU1234567"), contains("Please cancel"), eq(7L)); + order.verify(mapper).insertMail(anyLong(), eq(11L), eq(2L), eq("New Fleet"), eq("new@example.com"), + eq("REQUEST_PICKUP"), eq("Port pickup request - MSCU1234567"), contains("Please arrange"), eq(7L)); + } + + @Test void replacementWithoutPreviousPickupMailQueuesOnlyNewRequest() { + service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L); + + verify(mapper, never()).insertMail(anyLong(), eq(11L), eq(1L), anyString(), anyString(), + eq("CANCEL_PICKUP"), anyString(), anyString(), eq(7L)); + verify(mapper).insertMail(anyLong(), eq(11L), eq(2L), eq("New Fleet"), eq("new@example.com"), + eq("REQUEST_PICKUP"), eq("Port pickup request - MSCU1234567"), contains("Please arrange"), eq(7L)); + verify(suppliers, never()).truckingContact("test-token", 1L, false); + } + + @Test void cancellationUsesOriginalRecipientEvenIfSupplierEmailChanged() { + when(mapper.lastPickupMail(11L, 1L)).thenReturn(new TerminalDrayageMapper.MailTask( + 90L,11L,1L,"Frozen Fleet","original@example.com","REQUEST_PICKUP","subject","body","SENT",1,null,null,null)); + service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L); + verify(suppliers, never()).truckingContact("test-token", 1L, false); + verify(mapper).insertMail(anyLong(), eq(11L), eq(1L), eq("Frozen Fleet"), eq("original@example.com"), + eq("CANCEL_PICKUP"), anyString(), anyString(), eq(7L)); + } + + @Test void missingNewEmailQueuesNeitherMail() { + when(suppliers.truckingContact("test-token", 2L, true)).thenReturn(ApiResponse.success( + new WarehouseSupplierClient.TruckingContact(2L, "New Fleet", null))); + assertThatThrownBy(() -> service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L)).isInstanceOf(BizException.class); + verify(mapper, never()).insertMail(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyLong()); + } + + @ParameterizedTest + @ValueSource(strings={"", "not-an-email", "a@example.com,b@example.com", "a@example.com\r\nBcc: b@example.com", "Name "}) + void rejectsInvalidOrMultipleRecipients(String email) { + assertThatThrownBy(() -> TerminalDrayageMailService.validateAddress(email)).isInstanceOf(BizException.class); + } + + @Test void retriesOnlyFailedMailBelongingToContainer() { + assertThatThrownBy(() -> service.retry(11L, 55L, 7L)).isInstanceOf(BizException.class); + when(mapper.retryMail(11L,55L,7L)).thenReturn(1); + service.retry(11L,55L,7L); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java new file mode 100644 index 000000000..c25510484 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java @@ -0,0 +1,389 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest; +import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +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.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class TerminalDrayageServiceTest { + private static final LocalDateTime ARRIVED_AT = LocalDateTime.of(2026, 9, 1, 8, 0); + private TerminalDrayageMapper mapper; + private WarehouseSupplierSnapshotService suppliers; + private CarrierScheduleQueryService carrierSchedules; + private TerminalDrayageService service; + private TerminalDrayageMailService mails; + private Deque facts; + + @BeforeEach + void setUp() { + mapper = mock(TerminalDrayageMapper.class); + suppliers = mock(WarehouseSupplierSnapshotService.class); + carrierSchedules = mock(CarrierScheduleQueryService.class); + facts = new ArrayDeque<>(); + when(carrierSchedules.containerActualFacts(any())).thenAnswer(ignored -> { + if (facts.isEmpty()) return List.of(); + return List.of(facts.size() > 1 ? facts.removeFirst() : facts.peekFirst()); + }); + mails = mock(TerminalDrayageMailService.class); + service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mails); + service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC)); + } + + @Test + void rejectsFleetSelectionBeforeActualDestinationArrival() { + when(mapper.lockContext(11L)).thenReturn(context("IN_TRANSIT", null)); + + assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "fleet-1", + new FleetSelectionRequest(0, 91L, null, null))) + .isInstanceOf(BizException.class) + .hasMessage("货柜实际到达目的港后才能安排码头车队"); + verify(suppliers, never()).overseasTrucking(anyLong(), any()); + } + + @Test + void selectsActiveTruckingSupplierAndPersistsServerSnapshot() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.context(11L)).thenReturn(context); + when(mapper.lock(11L)).thenReturn(null); + when(mapper.operation(11L, "fleet-1")).thenReturn(null); + when(suppliers.overseasTrucking(91L, "用户提示名")).thenReturn( + new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of(), "OVERSEAS")); + AtomicReference stored = new AtomicReference<>(); + when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> { + TerminalDrayageEntity value = invocation.getArgument(0); + stored.set(value); + return 1; + }); + when(mapper.find(11L)).thenAnswer(ignored -> stored.get()); + + var result = service.selectFleet(11L, 7L, "admin", "fleet-1", + new FleetSelectionRequest(0, 91L, "用户提示名", "安排提柜")); + + assertThat(result.version()).isEqualTo(1); + assertThat(result.fleetSupplierId()).isEqualTo(91L); + assertThat(result.fleetSupplierName()).isEqualTo("服务端车队"); + assertThat(result.allowedActions()).containsExactlyInAnyOrder("SELECT_FLEET", "SCHEDULE_PICKUP", "SEND_PICKUP_EMAIL"); + verify(mapper).insertHistory(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(), + anyInt(), anyInt(), any(), anyLong(), anyString()); + verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()); + } + + @Test + void requiresOverseasUnloadBeforeEmptyReturn() { + Context context = context("PICKED_UP", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.operation(11L, "return-1")).thenReturn(null); + TerminalDrayageEntity row = row(3); + when(mapper.lock(11L)).thenReturn(row); + + assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1", + new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null))) + .isInstanceOf(BizException.class) + .hasMessage("海外卸柜收货完成后才能确认还柜"); + verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void computesSeventyTwoHourWarningAndClearsItAfterReturn() { + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 1, 0, 0); + Context context = context("PICKED_UP", unloadedAt); + TerminalDrayageEntity row = row(3); + when(mapper.context(11L)).thenReturn(context); + when(mapper.find(11L)).thenReturn(row); + + var overdue = service.detail(11L); + assertThat(overdue.estimatedEmptyReturnAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 0, 0)); + assertThat(overdue.emptyReturnWarning().status()).isEqualTo("OVERDUE"); + assertThat(overdue.emptyReturnWarning().overdueHours()).isEqualTo(26); + assertThat(overdue.emptyReturnWarning().overdueDays()).isEqualTo(1); + + row.setActualEmptyReturnAt(LocalDateTime.of(2026, 9, 4, 1, 0)); + assertThat(service.detail(11L).emptyReturnWarning().status()).isEqualTo("RESOLVED"); + assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isZero(); + } + + @Test + void offersEmptyReturnAfterUnloadWithoutDrayageAggregateOrPickupFact() { + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0); + Context context = context("ARRIVED_DESTINATION_PORT", unloadedAt); + when(mapper.context(11L)).thenReturn(context); + when(mapper.find(11L)).thenReturn(null); + + var result = service.detail(11L); + + assertThat(result.status()).isEqualTo("UNLOADED"); + assertThat(result.allowedActions()).contains("RECORD_EMPTY_RETURN"); + assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 3, 0, 0)); + assertThat(result.version()).isZero(); + } + + @Test + void createsReturnAggregateWithoutInventingPickupAndReplaysWithoutWritingAgain() { + Context context = context("ARRIVED_DESTINATION_PORT", LocalDateTime.of(2026, 9, 3, 0, 0)); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.context(11L)).thenReturn(context); + AtomicReference stored = new AtomicReference<>(); + AtomicReference operation = new AtomicReference<>(); + when(mapper.lock(11L)).thenAnswer(ignored -> stored.get()); + when(mapper.find(11L)).thenAnswer(ignored -> stored.get()); + when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> { + stored.set(invocation.getArgument(0)); + return 1; + }); + when(mapper.operation(11L, "return-new")).thenAnswer(ignored -> operation.get()); + when(mapper.insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong())) + .thenAnswer(invocation -> { + operation.set(new Operation(invocation.getArgument(2), invocation.getArgument(4), invocation.getArgument(5))); + return 1; + }); + ActualTimeRequest request = new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 4, 9, 0), "手动还柜"); + + var result = service.recordEmptyReturn(11L, 7L, "admin", "return-new", request); + assertThat(result.status()).isEqualTo("EMPTY_RETURNED"); + assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED"); + assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 3, 0, 0)); + assertThat(result.fleetSupplierId()).isNull(); + assertThat(result.version()).isEqualTo(1); + assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN"); + assertThat(service.recordEmptyReturn(11L, 7L, "admin", "return-new", request)).isEqualTo(result); + verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class)); + verify(carrierSchedules, times(1)).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void rejectsReturnBeforeUnloadWithoutAggregateOrPickup() { + when(mapper.lockContext(11L)).thenReturn(context("ARRIVED_DESTINATION_PORT", LocalDateTime.of(2026, 9, 3, 0, 0))); + assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-early", + new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 2, 23, 0), null))) + .isInstanceOf(BizException.class).hasMessage("实际还柜时间不能早于提柜或卸柜完成时间"); + verify(mapper, never()).insert(any(TerminalDrayageEntity.class)); + verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void unloadedContainerOffersEmptyReturnOnlyWithDedicatedPermission() { + Context context = context("PICKED_UP", LocalDateTime.of(2026, 9, 3, 0, 0)); + TerminalDrayageEntity row = row(3); + when(mapper.context(11L)).thenReturn(context); + when(mapper.find(11L)).thenReturn(row); + + var raw = service.detail(11L); + assertThat(raw.status()).isEqualTo("UNLOADED"); + assertThat(raw.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 8, 0)); + assertThat(service.authorize(raw, + Set.of("platform:firstleg:terminal-drayage:empty-return:confirm")).allowedActions()) + .containsExactly("RECORD_EMPTY_RETURN"); + assertThat(service.authorize(raw, + Set.of("platform:firstleg:terminal-drayage:list")).allowedActions()).isEmpty(); + } + + @Test + void readsDepartedContainerBeforeCarrierTrackingRecordExists() { + when(mapper.contexts(List.of(11L))).thenReturn(List.of( + new Context(11L, "MSCU1234567", 21L, "ACTIVE", null))); + when(mapper.findAll(List.of(11L))).thenReturn(List.of()); + when(mapper.context(11L)).thenReturn( + new Context(11L, "MSCU1234567", 21L, "ACTIVE", null)); + when(mapper.find(11L)).thenReturn(null); + + var bulk = service.views(List.of(11L)); + var detail = service.detail(11L); + + assertThat(bulk).singleElement().satisfies(view -> { + assertThat(view.status()).isEqualTo("WAITING_FOR_ARRIVAL"); + assertThat(view.actualFacts()).isNull(); + assertThat(view.allowedActions()).isEmpty(); + }); + assertThat(detail.status()).isEqualTo("WAITING_FOR_ARRIVAL"); + assertThat(detail.actualFacts()).isNull(); + assertThat(detail.allowedActions()).isEmpty(); + verify(carrierSchedules, times(2)).containerActualFacts(List.of(11L)); + } + + @Test + void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.operation(11L, "same-key")).thenReturn(new Operation("SELECT_FLEET", "different-hash", 1)); + + assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "same-key", + new FleetSelectionRequest(0, 91L, null, null))) + .isInstanceOf(BizException.class) + .hasMessage("Idempotency-Key已被不同的码头拖车操作占用"); + verify(mapper, never()).insert(any(TerminalDrayageEntity.class)); + verify(mapper, never()).updateAggregate(any(), anyInt(), anyLong(), anyString()); + } + + @Test + void sameIdempotencyKeyAndPayloadReplaysWithoutSecondWrite() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.context(11L)).thenReturn(context); + when(mapper.lock(11L)).thenReturn(null); + when(suppliers.overseasTrucking(91L, null)).thenReturn( + new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of(), "OVERSEAS")); + AtomicReference stored = new AtomicReference<>(); + AtomicReference operation = new AtomicReference<>(); + when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> { + stored.set(invocation.getArgument(0)); + return 1; + }); + when(mapper.find(11L)).thenAnswer(ignored -> stored.get()); + when(mapper.operation(11L, "fleet-replay")).thenAnswer(ignored -> operation.get()); + when(mapper.insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong())) + .thenAnswer(invocation -> { + operation.set(new Operation(invocation.getArgument(2), invocation.getArgument(4), invocation.getArgument(5))); + return 1; + }); + FleetSelectionRequest request = new FleetSelectionRequest(0, 91L, null, null); + + var first = service.selectFleet(11L, 7L, "admin", "fleet-replay", request); + var replay = service.selectFleet(11L, 7L, "admin", "fleet-replay", request); + + assertThat(replay).isEqualTo(first); + verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class)); + verify(mapper, times(1)).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()); + verify(suppliers, times(1)).overseasTrucking(91L, null); + } + + @Test + void recordsEmptyReturnAndResolvesWarning() { + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0); + Context before = context("PICKED_UP", unloadedAt); + Context after = context("EMPTY_RETURNED", unloadedAt); + when(mapper.lockContext(11L)).thenReturn(before); + when(mapper.context(11L)).thenReturn(after); + when(mapper.operation(11L, "return-2")).thenReturn(null); + TerminalDrayageEntity row = row(3); + row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); + when(mapper.lock(11L)).thenReturn(row); + when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> { + row.setVersion(4); + return 1; + }); + when(mapper.find(11L)).thenReturn(row); + + var result = service.recordEmptyReturn(11L, 7L, "admin", "return-2", + new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 9, 0), "已还柜")); + + assertThat(result.status()).isEqualTo("EMPTY_RETURNED"); + assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72)); + assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED"); + ArgumentCaptor node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class); + verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); + assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED"); + assertThat(node.getValue().reason()).isEqualTo("已还柜"); + assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0)); + assertThat(result.version()).isEqualTo(4); + } + + @Test + void selectingSameFleetDoesNotQueueMailOrChangeVersion() { + when(mapper.lockContext(11L)).thenReturn(context("ARRIVED_DESTINATION_PORT", null)); + when(mapper.lock(11L)).thenReturn(row(3)); + when(suppliers.overseasTrucking(91L, null)).thenReturn(new WarehouseSupplierClient.Snapshot(91L,"车队","TRUCKING","ACTIVE",List.of(), "OVERSEAS")); + service.selectFleet(11L,7L,"admin","same-fleet",new FleetSelectionRequest(3,91L,null,null)); + verifyNoInteractions(mails); + verify(mapper,never()).updateAggregate(any(),anyInt(),anyLong(),anyString()); + } + + @Test + void fleetReplacementQueuesBothEmailsBeforeSavingNewFleet() { + Context context = context("ARRIVED_DESTINATION_PORT",null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.context(11L)).thenReturn(context); + TerminalDrayageEntity row = row(3); + when(mapper.lock(11L)).thenReturn(row); + when(mapper.find(11L)).thenReturn(row); + when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenReturn(1); + when(suppliers.overseasTrucking(92L,null)).thenReturn(new WarehouseSupplierClient.Snapshot(92L,"新车队","TRUCKING","ACTIVE",List.of(), "OVERSEAS")); + service.selectFleet(11L,7L,"admin","change-fleet",new FleetSelectionRequest(3,92L,null,null)); + var ordered = org.mockito.Mockito.inOrder(mails,mapper); + ordered.verify(mails).replaceFleet(11L,"MSCU1234567",91L,92L,7L); + ordered.verify(mapper).updateAggregate(any(),org.mockito.ArgumentMatchers.eq(3),org.mockito.ArgumentMatchers.eq(7L),org.mockito.ArgumentMatchers.eq("admin")); + assertThat(row.getFleetSupplierId()).isEqualTo(92L); + } + + @Test + void manualPickupEmailRejectsStaleClickAndAllowsRepeatWithLatestVersion() { + Context context = context("ARRIVED_DESTINATION_PORT",null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.context(11L)).thenReturn(context); + TerminalDrayageEntity row = row(3); + when(mapper.lock(11L)).thenReturn(row); + when(mapper.find(11L)).thenReturn(row); + when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenAnswer(invocation -> { + row.setVersion(invocation.getArgument(1, Integer.class) + 1); + return 1; + }); + var request = new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest(3); + service.sendPickupEmail(11L,7L,"admin","mail-1",request); + assertThatThrownBy(() -> service.sendPickupEmail(11L,7L,"admin","mail-2",request)).isInstanceOf(BizException.class); + service.sendPickupEmail(11L,7L,"admin","mail-3", + new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest(4)); + verify(mails,times(2)).request(11L,"MSCU1234567",91L,7L); + } + + private Context context(String node, LocalDateTime unloadedAt) { + int sequenceVersion = switch (node) { + case "PICKED_UP" -> 5; + case "PICKUP_READY" -> 4; + case "ARRIVED_DESTINATION_PORT" -> 3; + default -> 2; + }; + facts.addLast(new ContainerActualFacts(11L, 31L, sequenceVersion, "READY", node, ARRIVED_AT, + ARRIVED_AT.minusDays(5), ARRIVED_AT.minusDays(4), + switch (node) { + case "IN_TRANSIT" -> null; + default -> ARRIVED_AT; + }, + "PICKUP_READY".equals(node) || "PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node) + ? ARRIVED_AT.plusHours(2) : null, + "PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(1) : null, + "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(3) : null)); + return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt); + } + + private TerminalDrayageEntity row(int version) { + TerminalDrayageEntity value = new TerminalDrayageEntity(); + value.setId(41L); value.setContainerBookingId(11L); value.setVersion(version); + value.setFleetSupplierId(91L); value.setFleetSupplierName("服务端车队"); + return value; + } +} 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..89298259e --- /dev/null +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -0,0 +1,96 @@ +{ + "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->shipping-nodes", + "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..94e97063a --- /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`、`filter/OverseasSecurityProjectionFilter.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..4135c50d0 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java @@ -0,0 +1,62 @@ +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.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"); + + @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 访问域不一致"); + } + 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..5adcb9b12 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java @@ -0,0 +1,50 @@ +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; +import java.util.LinkedHashSet; +import java.util.Set; + +@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), + strings(data.path("roles")), strings(data.path("authorities")))); + }); + } + + private static Set strings(JsonNode values) { + if (values == null || !values.isArray()) return Set.of(); + LinkedHashSet result = new LinkedHashSet<>(); + values.forEach(value -> { + String text = value.asText(""); + if (!text.isBlank()) result.add(text); + }); + return Set.copyOf(result); + } + + 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..4d2de7857 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java @@ -0,0 +1,90 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.GatewaySecurityConfig; +import com.baiyue.gateway.config.OverseasSecurityProjectionProperties; +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; +import java.util.Collection; +import java.util.stream.Collectors; + +@Component +public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered { + private final OverseasSecurityValidationClient client; + private final ObjectMapper objectMapper; + private final OverseasSecurityProjectionProperties properties; + + public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper, + OverseasSecurityProjectionProperties properties) { + this.client = client; + this.objectMapper = objectMapper; + this.properties = properties; + } + + @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()) { + var request = exchange.getRequest().mutate() + .header("X-Overseas-Roles", join(result.roles())) + .header("X-Overseas-Authorities", join(result.authorities())) + .header("X-Internal-Service-Token", properties.internalServiceToken()) + .build(); + return chain.filter(exchange.mutate().request(request).build()); + } + 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; } + } + + private static String join(Collection values) { + return values.stream().sorted().collect(Collectors.joining(",")); + } + + @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..a7ae9b3cd --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java @@ -0,0 +1,14 @@ +package com.baiyue.gateway.filter; + +import reactor.core.publisher.Mono; +import java.util.Set; + +public interface OverseasSecurityValidationClient { + Mono validate(long userId, long tenantId, long permissionVersion); + record Result(boolean valid, String failureReason, Set roles, Set authorities) { + public Result { + roles = roles == null ? Set.of() : Set.copyOf(roles); + authorities = authorities == null ? Set.of() : Set.copyOf(authorities); + } + } +} 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..f0c00dc91 --- /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", "X-Overseas-Roles", "X-Overseas-Authorities", + "X-Internal-Service-Token" + ); + + @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..fca38239b --- /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:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + pda-session-validation: + auth-url: ${AUTH_SESSION_VALIDATION_URL} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} + overseas-projection: + service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + 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..70757edf3 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java @@ -0,0 +1,57 @@ +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"); + } + } + + @Test + void lastlegPdaRouteCoversFrozenDispatchAndOutboundTaskPaths() 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("/api/pda/v1/last-leg/**"); + for (String path : new String[] { + "/api/pda/v1/last-leg/dispatches/DSP-1", + "/api/pda/v1/last-leg/dispatches/DSP-1/depart", + "/api/pda/v1/last-leg/outbound-tasks/PDA-1/complete" + }) { + assertThat(path).startsWith("/api/pda/v1/last-leg/"); + } + } + } +} 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..a5964dbe4 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java @@ -0,0 +1,84 @@ +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 { + private static final String HEADER_SIZE = + "max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}"; + + @Test + void gatewayLocalJwtDefaultsMatchAuthAndLastleg() throws Exception { + String gatewayApplication = read("gateway-overseas/src/main/resources/application.yml"); + String lastlegApplication = read("lastleg-service/src/main/resources/application.yml"); + String localGateway = read("config/nacos/local/gateway-overseas.yml"); + String localLastleg = read("config/nacos/local/lastleg-service.yml"); + String localWarehouse = read("config/nacos/local/warehouse-overseas-service.yml"); + String remoteGateway = read("config/nacos/overseas/gateway-overseas.yml"); + String remoteLastleg = read("config/nacos/overseas/lastleg-service.yml"); + String remoteWarehouse = read("config/nacos/overseas/warehouse-overseas-service.yml"); + + for (String source : new String[]{gatewayApplication, lastlegApplication, localGateway, localLastleg, localWarehouse}) { + assertThat(source) + .contains("${JWT_ISSUER:auth-service}") + .contains("${JWT_AUDIENCE:lastleg-api}") + .contains("${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}"); + } + assertThat(gatewayApplication).doesNotContain("auth.example.com"); + assertThat(localGateway) + .contains("${AUTH_SESSION_VALIDATION_URL:http://127.0.0.1:9100}") + .contains("${LASTLEG_SECURITY_SERVICE_URL:http://127.0.0.1:9303}"); + assertThat(localLastleg).contains("${WAREHOUSE_OVERSEAS_SERVICE_URL:http://127.0.0.1:9305}"); + assertThat(localWarehouse).contains("${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205}"); + assertThat(remoteGateway + remoteLastleg + remoteWarehouse) + .doesNotContain("127.0.0.1", "root", "123456"); + assertThat(remoteGateway).contains("${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303}"); + assertThat(remoteLastleg).contains("${LASTLEG_DB_URL}", "${LASTLEG_DB_USERNAME}", "${LASTLEG_DB_PASSWORD}"); + assertThat(remoteWarehouse).contains("${WAREHOUSE_DB_URL}", "${WAREHOUSE_DB_USERNAME}", "${WAREHOUSE_DB_PASSWORD}"); + } + + @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"); + } + + @Test + void overseasAuthorizationSnapshotHeadersFitEveryServletService() throws Exception { + assertThat(read("config/nacos/overseas/baiyue-common.yml")).contains(HEADER_SIZE); + assertThat(read("lastleg-service/src/main/resources/application.yml")).contains(HEADER_SIZE); + assertThat(read("warehouse-overseas-service/src/main/resources/application.yml")).contains(HEADER_SIZE); + assertThat(read("deploy/docker-compose.overseas.yml")) + .contains("SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB}"); + } + + 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..81b21ddb1 --- /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) + .build(); + assertThat(validator.validate(value).hasErrors()).isTrue(); + } + + @Test + void acceptsTokenWithoutAuthoritiesBecauseProjectionOwnsAuthorization() { + 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()).isFalse(); + } + + @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..4c1847ecb --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java @@ -0,0 +1,97 @@ +package com.baiyue.gateway.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.baiyue.gateway.config.OverseasSecurityProjectionProperties; +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.time.Duration; +import java.util.List; +import java.util.Set; +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 OverseasSecurityProjectionProperties properties = + new OverseasSecurityProjectionProperties("http://lastleg", + "gateway-internal-test-token-32-bytes", Duration.ofSeconds(2)); + private final OverseasSecurityProjectionFilter filter = + new OverseasSecurityProjectionFilter(client, new ObjectMapper(), properties); + + @Test void disabledUserFailsWithA0401() { + when(client.validate(42, 0, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(false, "USER_DISABLED", Set.of(), Set.of()))); + 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, Set.of(), Set.of()))); + 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, Set.of("PLATFORM_ADMIN"), + Set.of("platform:overseas-warehouse:last-mile-order:list")))); + AtomicBoolean routed = new AtomicBoolean(); + var accepted = exchange("/api/platform/v1/last-leg/orders"); + StepVerifier.create(filter.filter(accepted, routedExchange -> { + routed.set(true); + assertThat(routedExchange.getRequest().getHeaders().getFirst("X-Internal-Service-Token")) + .isEqualTo("gateway-secret"); + assertThat(routedExchange.getRequest().getHeaders().getFirst("X-Overseas-Authorities")) + .isEqualTo("platform:overseas-warehouse:last-mile-order:list"); + return Mono.empty(); + })).verifyComplete(); + assertThat(routed).isTrue(); + + var isolatedClient = mock(OverseasSecurityValidationClient.class); + var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper(), properties); + 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..73155dbd8 --- /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_OVERSEAS_WAREHOUSE_PDA_OPERATOR"))))) + .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/gateway/AGENTS.md b/gateway/AGENTS.md new file mode 100644 index 000000000..bc183918d --- /dev/null +++ b/gateway/AGENTS.md @@ -0,0 +1,20 @@ +# Gateway 模块指引 + +## 职责与边界 + +- 负责路由、CORS、JWT/Redis 会话校验、平台/客户访问域隔离、请求标识和 Swagger 聚合。 +- 只做入口认证,不做业务编排;下游服务仍必须校验客户归属、数据范围和权限。 + +## 模块入口与验证 + +- 应用:`BaiyueGatewayApplication.java` +- Security/访问域:`config/GatewaySecurityConfig.java`、`AppScopeAuthorizationManager.java` +- 会话/CORS/请求头:`config/RedisTokenSessionWebFilter.java`、`GatewayCorsConfiguration.java`、`filter/TrustedIdentityHeaderFilter.java`、`CorrelationIdWebFilter.java` +- 路由配置:`src/main/resources/application*.yml`、`../config/nacos/{local,cn}/gateway.yml` +- 测试:`src/test/java/com/baiyue/gateway/`;`mvn -pl gateway -am test` + +## 修改约束与分期 + +- 新增路由同步检查本地/Nacos、App Scope、CORS、公开路径和 Swagger;不得暴露内部令牌接口。 +- 订单与客户承载登录后 Admin/Client 入口;入库测量与计费接入到仓/测量/计费公开路由但保持内部测量接口隔离;国内货柜履约仅在 Controller、权限和公开访问域契约稳定后新增必要路由,内部 PDA、仓储和服务令牌接口继续不得经 Gateway 暴露。 +- 国内货柜履约先读 `../../docs/国内货柜履约/国内货柜履约开工门禁.md`;网关变更只服务已确认模块,不以路由提前暴露未完成能力。 diff --git a/gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java b/gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java index 5dae7304a..ccc0f3efa 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java @@ -26,7 +26,18 @@ final class AppScopeAuthorizationManager implements ReactiveAuthorizationManager } private boolean hasExpectedScope(Authentication authentication) { - return authentication instanceof JwtAuthenticationToken jwt - && expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code")); + if (!(authentication instanceof JwtAuthenticationToken jwt) + || !expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code"))) { + return false; + } + Object tenantIdClaim = jwt.getToken().getClaim("tenant_id"); + if (!(tenantIdClaim instanceof Number tenantId)) { + return false; + } + return switch (expectedAppCode) { + case "PLATFORM" -> tenantId.longValue() == 0L; + case "MERCHANT" -> tenantId.longValue() > 0L; + default -> false; + }; } } diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java index 0ffe1dbbd..0b0626c50 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -1,6 +1,7 @@ package com.baiyue.gateway.config; import com.fasterxml.jackson.databind.ObjectMapper; +import com.baiyue.gateway.filter.CorrelationIdWebFilter; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -35,9 +36,12 @@ public class GatewaySecurityConfig { CorsConfigurationSource corsConfigurationSource) { var exchanges = http.authorizeExchange(); exchanges.pathMatchers(HttpMethod.OPTIONS, "/**").permitAll(); + exchanges.pathMatchers("/api/iam/**").denyAll(); properties.permitPaths().forEach(path -> exchanges.pathMatchers(path).permitAll()); exchanges.pathMatchers("/api/merchant/**") .access(new AppScopeAuthorizationManager("MERCHANT")); + exchanges.pathMatchers("/api/client/**") + .access(new AppScopeAuthorizationManager("MERCHANT")); exchanges.pathMatchers("/api/platform/**") .access(new AppScopeAuthorizationManager("PLATFORM")); exchanges.anyExchange().authenticated(); @@ -68,9 +72,14 @@ public class GatewaySecurityConfig { 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.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", exchange.getRequest().getId()); body.put("timestamp", Instant.now().toString()); + 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))); diff --git a/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java b/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java index 82ceca7a1..96a594ea1 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java @@ -1,5 +1,7 @@ package com.baiyue.gateway.config; +import jakarta.annotation.Resource; + import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.data.redis.core.ReactiveStringRedisTemplate; import org.springframework.http.HttpStatus; @@ -15,17 +17,9 @@ import reactor.core.publisher.Mono; */ @Component public class RedisTokenSessionWebFilter implements WebFilter { - private final ReactiveStringRedisTemplate redisTemplate; - private final GatewaySecurityProperties properties; - private final ObjectMapper objectMapper; - - public RedisTokenSessionWebFilter(ReactiveStringRedisTemplate redisTemplate, - GatewaySecurityProperties properties, - ObjectMapper objectMapper) { - this.redisTemplate = redisTemplate; - this.properties = properties; - this.objectMapper = objectMapper; - } + @Resource private ReactiveStringRedisTemplate reactiveStringRedisTemplate; + @Resource private GatewaySecurityProperties properties; + @Resource private ObjectMapper objectMapper; @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { @@ -40,7 +34,7 @@ public class RedisTokenSessionWebFilter implements WebFilter { WebFilterChain chain) { String jwtId = jwt.getToken().getId(); if (jwtId == null || jwtId.isBlank()) return unauthorized(exchange); - return redisTemplate.hasKey(properties.accessTokenKey(jwtId)) + return reactiveStringRedisTemplate.hasKey(properties.accessTokenKey(jwtId)) .onErrorReturn(false) .flatMap(active -> Boolean.TRUE.equals(active) ? chain.filter(exchange) : unauthorized(exchange)); diff --git a/gateway/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java b/gateway/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java new file mode 100644 index 000000000..d29bf982f --- /dev/null +++ b/gateway/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java @@ -0,0 +1,54 @@ +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; + +/** 确保跨域预检和安全失败响应也携带可追踪的请求标识。 */ +@Component +public class CorrelationIdWebFilter implements WebFilter, Ordered { + public static final String HEADER = "X-Request-Id"; + public static final String TRACE_HEADER = "X-Trace-Id"; + public static final String REQUEST_ID_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".requestId"; + public static final String TRACE_ID_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".traceId"; + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + String requestId = ensureRequestId(exchange); + String traceId = ensureTraceId(exchange); + exchange.getResponse().getHeaders().set(HEADER, requestId); + exchange.getResponse().getHeaders().set(TRACE_HEADER, traceId); + return chain.filter(exchange); + } + + public static String ensureRequestId(ServerWebExchange exchange) { + return (String) exchange.getAttributes().computeIfAbsent(REQUEST_ID_ATTRIBUTE, key -> { + HttpHeaders headers = exchange.getRequest().getHeaders(); + return validRequestId(headers.getFirst(HEADER)) + ? headers.getFirst(HEADER) : UUID.randomUUID().toString(); + }); + } + + public static String ensureTraceId(ServerWebExchange exchange) { + return (String) exchange.getAttributes().computeIfAbsent(TRACE_ID_ATTRIBUTE, key -> { + String incoming = exchange.getRequest().getHeaders().getFirst(TRACE_HEADER); + return validRequestId(incoming) ? incoming : UUID.randomUUID().toString(); + }); + } + + private static boolean validRequestId(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/src/main/resources/application-cn.yml b/gateway/src/main/resources/application-cn.yml index 7c7928c0a..d958fc845 100644 --- a/gateway/src/main/resources/application-cn.yml +++ b/gateway/src/main/resources/application-cn.yml @@ -2,94 +2,9 @@ spring: config: activate: on-profile: cn - cloud: - gateway: - discovery: - locator: - enabled: false - routes: - - id: auth-service - uri: lb://auth-service - predicates: [Path=/api/auth/**] - - id: iam-service - uri: lb://iam-service - predicates: ["Path=/api/iam/**,/api/platform/v1/iam/**,/api/merchant/v1/iam/**"] - - id: order-service - uri: lb://order-service - predicates: ["Path=/api/merchant/v1/orders/**,/api/platform/v1/orders/**"] - - id: firstleg-service - uri: lb://firstleg-service - predicates: ["Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**"] - - id: warehouse-service - uri: lb://warehouse-service - predicates: ["Path=/api/merchant/v1/warehouses/**,/api/merchant/v1/partners/**,/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: auth-openapi - uri: lb://auth-service - predicates: [Path=/auth-service/v3/api-docs/**] - filters: [StripPrefix=1] - - id: iam-openapi - uri: lb://iam-service - predicates: [Path=/iam-service/v3/api-docs/**] - filters: [StripPrefix=1] - - id: order-openapi - uri: lb://order-service - predicates: [Path=/order-service/v3/api-docs/**] - filters: [StripPrefix=1] - - id: firstleg-openapi - uri: lb://firstleg-service - predicates: [Path=/firstleg-service/v3/api-docs/**] - filters: [StripPrefix=1] - - id: warehouse-openapi - uri: lb://warehouse-service - predicates: [Path=/warehouse-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:CN} - cell: ${DEPLOYMENT_CELL:prod-cn} + cell: ${DEPLOYMENT_CELL:cn} data-residency: ${DATA_RESIDENCY:CN} - -springdoc: - swagger-ui: - urls: - - { name: auth-platform, url: /auth-service/v3/api-docs/platform } - - { name: iam-platform, url: /iam-service/v3/api-docs/platform } - - { name: iam-merchant, url: /iam-service/v3/api-docs/merchant } - - { name: order-platform, url: /order-service/v3/api-docs/platform } - - { name: order-merchant, url: /order-service/v3/api-docs/merchant } - - { name: firstleg-platform, url: /firstleg-service/v3/api-docs/platform } - - { name: warehouse-merchant, url: /warehouse-service/v3/api-docs/merchant } - - { name: warehouse-platform, url: /warehouse-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: 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/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index 03e74896f..b6f008ce4 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -3,7 +3,10 @@ server: spring: profiles: - default: cn + active: ${SPRING_PROFILES_ACTIVE:dev} + group: + dev: cn + test: cn application: name: gateway autoconfigure: @@ -35,17 +38,26 @@ spring: gateway: discovery: locator: - enabled: true + # 使用显式路由,避免将注册中心中的内部服务自动暴露到公网。 + enabled: false lower-case-service-id: true routes: - id: auth-service uri: lb://auth-service predicates: - Path=/api/auth/** + - id: warehouse-customer-overseas-fees + uri: lb://warehouse-service + predicates: + - Path=/api/platform/v1/iam/tenants/{tenantId}/overseas-storage-fees,/api/platform/v1/iam/tenants/{tenantId}/overseas-storage-fees/** - id: iam-service uri: lb://iam-service predicates: - - Path=/api/iam/**,/api/platform/v1/iam/**,/api/merchant/v1/iam/** + - Path=/api/platform/v1/iam/**,/api/merchant/v1/iam/**,/api/platform/v1/notifications/**,/api/platform/v1/notification-templates/**,/api/platform/v1/notification-tasks/**,/api/merchant/v1/notifications/** + - id: audit-service + uri: lb://audit-service + predicates: + - Path=/api/platform/v1/audit/** - id: order-service uri: lb://order-service predicates: @@ -53,7 +65,7 @@ spring: - id: warehouse-service uri: lb://warehouse-service predicates: - - Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/** + - Path=/api/merchant/v1/warehouses/**,/api/merchant/v1/partners/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**,/api/platform/v1/partners/**,/api/platform/v1/express-companies/**,/api/platform/v1/pallets/**,/api/platform/v1/locations/**,/api/platform/v1/overseas-storage-fees/**,/api/platform/v1/suppliers/**,/api/platform/v1/domestic-containers/**,/api/platform/v1/iam/tenants/{tenantId}/overseas-storage-fees,/api/platform/v1/iam/tenants/{tenantId}/overseas-storage-fees/** - id: file-service uri: lb://file-service predicates: @@ -65,12 +77,83 @@ spring: - id: firstleg-service uri: lb://firstleg-service predicates: - - Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/** + - Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/container-shipping-nodes/**,/api/platform/v1/containers/**,/api/platform/v1/terminal-drayages/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** + - id: finance-service + uri: lb://finance-service + predicates: + - Path=/api/platform/finance/v1/**,/api/client/finance/v1/** + # Swagger 文档聚合路由。 + - id: auth-openapi + uri: lb://auth-service + predicates: + - Path=/auth-service/v3/api-docs/** + filters: + - StripPrefix=1 + - id: iam-openapi + uri: lb://iam-service + predicates: + - Path=/iam-service/v3/api-docs/** + filters: + - StripPrefix=1 + - id: order-openapi + uri: lb://order-service + predicates: + - Path=/order-service/v3/api-docs/** + filters: + - StripPrefix=1 + - id: firstleg-openapi + uri: lb://firstleg-service + predicates: + - Path=/firstleg-service/v3/api-docs/** + filters: + - StripPrefix=1 + - id: warehouse-openapi + uri: lb://warehouse-service + predicates: + - Path=/warehouse-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 + - id: finance-openapi + uri: lb://finance-service + predicates: + - Path=/finance-service/v3/api-docs/** + filters: + - StripPrefix=1 baiyue: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174} allowed-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS - allowed-headers: Authorization,Content-Type,Accept,X-Requested-With,X-Request-Id,Idempotency-Key + allowed-headers: Authorization,Content-Type,Accept,X-Requested-With,X-Request-Id,Idempotency-Key,X-App-Key,X-Timestamp,X-Nonce,X-Signature exposed-headers: X-Request-Id,X-Trace-Id,Location,Content-Disposition allow-credentials: ${CORS_ALLOW_CREDENTIALS:false} max-age: ${CORS_MAX_AGE:3600} @@ -81,6 +164,8 @@ baiyue: - /api/auth/login - /api/auth/register - /api/auth/refresh + - /api/auth/impersonations/exchange + - /openapi/v1/inbound-measurements/** - /actuator/health - /swagger-ui/** - /swagger-ui.html @@ -93,6 +178,10 @@ baiyue: - /file-service/v3/api-docs/** - /print-service/v3/api-docs/** - /firstleg-service/v3/api-docs/** + - /audit-service/v3/api-docs/** + - /integration-service/v3/api-docs/** + - /event-bridge-service/v3/api-docs/** + - /finance-service/v3/api-docs/** management: endpoints: @@ -131,3 +220,13 @@ springdoc: url: /print-service/v3/api-docs/platform - name: firstleg-platform url: /firstleg-service/v3/api-docs/platform + - name: firstleg-measurement-openapi + url: /firstleg-service/v3/api-docs/measurement-openapi + - 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 + - name: finance + url: /finance-service/v3/api-docs diff --git a/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java b/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java index 21e2371a7..0c6df4d36 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java @@ -1,5 +1,6 @@ package com.baiyue.gateway.config; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; @@ -9,11 +10,20 @@ import reactor.test.StepVerifier; import java.time.Instant; +@DisplayName("网关应用域授权功能") class AppScopeAuthorizationManagerTest { @Test void acceptsMatchingApplicationScope() { var manager = new AppScopeAuthorizationManager("MERCHANT"); - StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null)) + StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT", 10001L)), null)) + .expectNextMatches(decision -> decision.isGranted()) + .verifyComplete(); + } + + @Test + void acceptsPlatformOnlyInPlatformTenant() { + var manager = new AppScopeAuthorizationManager("PLATFORM"); + StepVerifier.create(manager.check(Mono.just(authentication("PLATFORM", 0L)), null)) .expectNextMatches(decision -> decision.isGranted()) .verifyComplete(); } @@ -21,7 +31,23 @@ class AppScopeAuthorizationManagerTest { @Test void rejectsDifferentApplicationScope() { var manager = new AppScopeAuthorizationManager("PLATFORM"); - StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null)) + StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT", 10001L)), null)) + .expectNextMatches(decision -> !decision.isGranted()) + .verifyComplete(); + } + + @Test + void rejectsPlatformApplicationWithCustomerTenant() { + var manager = new AppScopeAuthorizationManager("PLATFORM"); + StepVerifier.create(manager.check(Mono.just(authentication("PLATFORM", 10001L)), null)) + .expectNextMatches(decision -> !decision.isGranted()) + .verifyComplete(); + } + + @Test + void rejectsMerchantApplicationWithoutPositiveCustomerTenant() { + var manager = new AppScopeAuthorizationManager("MERCHANT"); + StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT", 0L)), null)) .expectNextMatches(decision -> !decision.isGranted()) .verifyComplete(); } @@ -41,13 +67,14 @@ class AppScopeAuthorizationManagerTest { .verifyComplete(); } - private JwtAuthenticationToken authentication(String appCode) { + private JwtAuthenticationToken authentication(String appCode, long tenantId) { Jwt jwt = Jwt.withTokenValue("token") .header("alg", "none") .subject("1") .issuedAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(60)) .claim("app_code", appCode) + .claim("tenant_id", tenantId) .build(); return new JwtAuthenticationToken(jwt, java.util.List.of(new SimpleGrantedAuthority("authenticated"))); diff --git a/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java new file mode 100644 index 000000000..a6ab80981 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java @@ -0,0 +1,34 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.config.GatewayProperties; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(properties = { + "spring.cloud.nacos.discovery.enabled=false", + "spring.cloud.nacos.discovery.register-enabled=false", + "spring.cloud.nacos.config.enabled=false", + "spring.cloud.gateway.discovery.locator.enabled=false", + "baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long" +}) +class CustomerTransitStatisticsRouteContractTest { + @Autowired private GatewayProperties gatewayProperties; + + @Test + void routesCustomerTransitSummaryToFirstlegService() { + var route = gatewayProperties.getRoutes().stream() + .filter(candidate -> "firstleg-service".equals(candidate.getId())) + .findFirst() + .orElseThrow(); + + assertThat(route.getUri().toString()).isEqualTo("lb://firstleg-service"); + assertThat(route.getPredicates()).singleElement().satisfies(predicate -> { + assertThat(predicate.getName()).isEqualTo("Path"); + assertThat(String.join(",", predicate.getArgs().values())) + .contains("/api/platform/v1/customers/{customerId}/container-transit-summary"); + }); + } +} diff --git a/gateway/src/test/java/com/baiyue/gateway/config/DeploymentHeaderSizeContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/DeploymentHeaderSizeContractTest.java new file mode 100644 index 000000000..38549c6f4 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/DeploymentHeaderSizeContractTest.java @@ -0,0 +1,31 @@ +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 DeploymentHeaderSizeContractTest { + private static final String PROPERTY = + "max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}"; + private static final String ENVIRONMENT = + "SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB}"; + + @Test + void allowsAuthorizationTokensAndTrustedIdentityHeadersAcrossAllServices() throws Exception { + assertThat(readRepositoryFile("deploy", "docker-compose.yml")).contains(ENVIRONMENT); + assertThat(readRepositoryFile("config", "nacos", "cn", "baiyue-common.yml")).contains(PROPERTY); + assertThat(readRepositoryFile("config", "nacos", "local", "baiyue-common.yml")).contains(PROPERTY); + } + + private static String readRepositoryFile(String first, String... more) throws Exception { + Path path = Path.of(first, more); + if (!Files.exists(path)) { + path = Path.of("..").resolve(path).normalize(); + } + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/gateway/src/test/java/com/baiyue/gateway/config/FinanceRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/FinanceRouteContractTest.java new file mode 100644 index 000000000..b7fcd10a4 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/FinanceRouteContractTest.java @@ -0,0 +1,33 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceRouteContractTest { + @Test + void routesOnlyTheConfirmedFinanceApiPrefixes() throws Exception { + String yaml = new ClassPathResource("application.yml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(yaml) + .contains("uri: lb://finance-service") + .contains("/api/platform/finance/v1/**") + .contains("/api/client/finance/v1/**") + .doesNotContain("/api/platform/v1/finance") + .doesNotContain("/api/merchant/v1/finance"); + } + + @Test + void exposesFinanceSwaggerAggregationRoute() throws Exception { + String yaml = new ClassPathResource("application.yml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(yaml) + .contains("/finance-service/v3/api-docs/**") + .contains("url: /finance-service/v3/api-docs"); + } +} diff --git a/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java new file mode 100644 index 000000000..28d0c14cd --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java @@ -0,0 +1,45 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class FirstlegContainerRouteContractTest { + @Test + void routesContainers() throws Exception { + String yaml = new ClassPathResource("application.yml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(yaml) + .contains("/api/platform/v1/containers/**") + .contains("/api/platform/v1/terminal-drayages/**") + .contains("/api/platform/v1/loading-plans/**"); + } + + @Test + void routesOceanBookingsAndTruckAppointments() throws Exception { + String yaml = new ClassPathResource("application.yml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(yaml) + .contains("/api/platform/v1/ocean-bookings/**") + .contains("/api/platform/v1/truck-appointments/**"); + } + + @Test + void routesPhase3CustomsPortSchedulesAndOutboundDispatch() throws Exception { + String yaml = new ClassPathResource("application.yml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(yaml) + .contains("/api/platform/v1/customs/**") + .contains("/api/merchant/v1/customs/**") + .contains("/api/platform/v1/port-schedules/**") + .contains("/api/platform/v1/carrier-schedule-queries/**") + .contains("/api/platform/v1/container-shipping-nodes/**") + .contains("/api/platform/v1/first-leg/**"); + } +} diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java index a4631a245..8b13163fd 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java @@ -1,5 +1,6 @@ package com.baiyue.gateway.config; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; @@ -8,15 +9,20 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.test.web.reactive.server.WebTestClient; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertThrows; + @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { "spring.cloud.nacos.discovery.enabled=false", "spring.cloud.nacos.discovery.register-enabled=false", "spring.cloud.nacos.config.enabled=false", "spring.cloud.gateway.discovery.locator.enabled=false", "baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long", - "baiyue.cors.allowed-origins=http://localhost:5173" + "baiyue.cors.allowed-origins=http://localhost:5173,http://localhost:5174" }) @AutoConfigureWebTestClient +@DisplayName("网关跨域配置功能") class GatewayCorsConfigurationTest { private static final String PROFILE_URL = "http://localhost/api/merchant/v1/iam/tenant/profile"; @@ -24,6 +30,30 @@ class GatewayCorsConfigurationTest { @Autowired private WebTestClient webTestClient; + @Test + void wildcardOriginIsRejected() { + assertThrows(IllegalArgumentException.class, () -> new GatewayCorsProperties( + List.of("*"), List.of("GET"), List.of("Content-Type"), List.of(), false, 3600)); + } + + @Test + void localhost5174CanPreflightLoginWithoutTokenAndKeepsRequestId() { + webTestClient.options() + .uri("http://localhost/api/auth/login") + .header(HttpHeaders.ORIGIN, "http://localhost:5174") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpMethod.POST.name()) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "content-type") + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "http://localhost:5174") + .expectHeader().value(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, + value -> org.assertj.core.api.Assertions.assertThat(value).contains("POST")) + .expectHeader().value(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, + value -> org.assertj.core.api.Assertions.assertThat(value.toLowerCase()).contains("content-type")) + .expectHeader().exists("X-Request-Id") + .expectHeader().doesNotExist(HttpHeaders.WWW_AUTHENTICATE); + } + @Test void allowedOriginCanCompletePutPreflightWithoutToken() { webTestClient.options() @@ -50,6 +80,7 @@ class GatewayCorsConfigurationTest { .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpMethod.PUT.name()) .exchange() .expectStatus().isForbidden() + .expectHeader().exists("X-Request-Id") .expectHeader().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN); } diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java new file mode 100644 index 000000000..c2778a083 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java @@ -0,0 +1,113 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.config.GatewayProperties; +import org.springframework.http.server.PathContainer; +import org.springframework.web.util.pattern.PathPatternParser; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(properties = { + "spring.cloud.nacos.discovery.enabled=false", + "spring.cloud.nacos.discovery.register-enabled=false", + "spring.cloud.nacos.config.enabled=false", + "spring.cloud.gateway.discovery.locator.enabled=false", + "baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long" +}) +class GatewayFileRouteConfigurationTest { + @Autowired + private GatewayProperties gatewayProperties; + + @Test + void qualificationRequestsRouteToRegisteredFileServiceName() { + var route = gatewayProperties.getRoutes().stream() + .filter(candidate -> "file-service".equals(candidate.getId())) + .findFirst() + .orElseThrow(); + + assertThat(route.getUri().toString()).isEqualTo("lb://file-service"); + assertThat(route.getPredicates()).singleElement().satisfies(predicate -> { + assertThat(predicate.getName()).isEqualTo("Path"); + String paths = String.join(",", predicate.getArgs().values()); + assertThat(paths) + .contains("/api/merchant/v1/files/**") + .contains("/api/platform/v1/files/**"); + }); + } + + @Test + void iamRouteDoesNotExposeUnscopedCompatibilityPath() { + var route = gatewayProperties.getRoutes().stream() + .filter(candidate -> "iam-service".equals(candidate.getId())) + .findFirst() + .orElseThrow(); + + assertThat(route.getPredicates()).singleElement().satisfies(predicate -> { + assertThat(predicate.getName()).isEqualTo("Path"); + assertThat(String.join(",", predicate.getArgs().values())) + .contains("/api/platform/v1/iam/**") + .contains("/api/merchant/v1/iam/**") + .contains("/api/platform/v1/notifications/**") + .contains("/api/platform/v1/notification-templates/**") + .contains("/api/platform/v1/notification-tasks/**") + .contains("/api/merchant/v1/notifications/**") + .doesNotContain("/api/iam/**"); + }); + } + + @Test + void warehouseResourceRequestsRouteToWarehouseService() { + var route = gatewayProperties.getRoutes().stream() + .filter(candidate -> "warehouse-service".equals(candidate.getId())) + .findFirst() + .orElseThrow(); + + assertThat(route.getUri().toString()).isEqualTo("lb://warehouse-service"); + assertThat(route.getPredicates()).singleElement().satisfies(predicate -> { + assertThat(predicate.getName()).isEqualTo("Path"); + String paths = String.join(",", predicate.getArgs().values()); + assertThat(paths) + .contains("/api/platform/v1/pallets/**") + .contains("/api/platform/v1/locations/**") + .contains("/api/platform/v1/overseas-storage-fees/**") + .contains("/api/platform/v1/suppliers/**") + .contains("/api/platform/v1/domestic-containers/**"); + }); + } + + @Test + void feeItemsRouteToFirstlegService() { + var route = gatewayProperties.getRoutes().stream() + .filter(candidate -> "firstleg-service".equals(candidate.getId())) + .findFirst() + .orElseThrow(); + + assertThat(route.getUri().toString()).isEqualTo("lb://firstleg-service"); + assertThat(route.getPredicates()).singleElement().satisfies(predicate -> { + assertThat(predicate.getName()).isEqualTo("Path"); + assertThat(String.join(",", predicate.getArgs().values())) + .contains("/api/platform/v1/fee-items/**") + .contains("/api/platform/v1/inbounds/**"); + }); + } + + @Test + void auditLogRequestsRouteToAuditService() { + var route = gatewayProperties.getRoutes().stream() + .filter(candidate -> "audit-service".equals(candidate.getId())) + .findFirst() + .orElseThrow(); + + assertThat(route.getUri().toString()).isEqualTo("lb://audit-service"); + assertThat(route.getPredicates()).singleElement().satisfies(predicate -> { + assertThat(predicate.getName()).isEqualTo("Path"); + String pattern = String.join(",", predicate.getArgs().values()); + assertThat(pattern).contains("/api/platform/v1/audit/**"); + var matcher = PathPatternParser.defaultInstance.parse("/api/platform/v1/audit/**"); + assertThat(matcher.matches(PathContainer.parsePath("/api/platform/v1/audit/logs"))).isTrue(); + assertThat(matcher.matches(PathContainer.parsePath("/api/platform/v1/audit/logs/8"))).isTrue(); + }); + } +} diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorResponseTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorResponseTest.java new file mode 100644 index 000000000..ab3324863 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorResponseTest.java @@ -0,0 +1,49 @@ +package com.baiyue.gateway.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class GatewaySecurityErrorResponseTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @ParameterizedTest + @CsvSource({ + "UNAUTHORIZED, A0401", + "FORBIDDEN, A0403" + }) + void securityErrorsExposeStableCompleteCorrelationIds(String statusName, String code) throws Exception { + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/platform/v1/orders").build()); + HttpStatus status = HttpStatus.valueOf(statusName); + + GatewaySecurityConfig.writeError(exchange, objectMapper, status, code, "安全响应").block(); + + DataBuffer buffer = DataBufferUtils.join(exchange.getResponse().getBody()).block(); + assertThat(buffer).isNotNull(); + byte[] bytes = new byte[buffer.readableByteCount()]; + buffer.read(bytes); + DataBufferUtils.release(buffer); + String body = new String(bytes, StandardCharsets.UTF_8); + JsonNode json = objectMapper.readTree(body); + String requestId = json.get("requestId").asText(); + String traceId = json.get("traceId").asText(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(status); + assertThat(json.get("code").asText()).isEqualTo(code); + assertThat(UUID.fromString(requestId)).isNotNull(); + assertThat(UUID.fromString(traceId)).isNotNull(); + assertThat(exchange.getResponse().getHeaders().getFirst("X-Request-Id")).isEqualTo(requestId); + assertThat(exchange.getResponse().getHeaders().getFirst("X-Trace-Id")).isEqualTo(traceId); + } +} diff --git a/gateway/src/test/java/com/baiyue/gateway/config/ImpersonationExchangeSecurityContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/ImpersonationExchangeSecurityContractTest.java new file mode 100644 index 000000000..79799e03e --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/ImpersonationExchangeSecurityContractTest.java @@ -0,0 +1,20 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class ImpersonationExchangeSecurityContractTest { + @Test + void permitsAnonymousImpersonationCodeExchangeAtTheGateway() throws Exception { + String yaml = new ClassPathResource("application.yml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(yaml) + .contains("permit-paths:") + .contains("- /api/auth/impersonations/exchange"); + } +} diff --git a/gateway/src/test/java/com/baiyue/gateway/config/Phase2InboundRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/Phase2InboundRouteContractTest.java new file mode 100644 index 000000000..5aaf44a83 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/Phase2InboundRouteContractTest.java @@ -0,0 +1,22 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class Phase2InboundRouteContractTest { + @Test + void shouldHandleCase01() throws Exception { + String yaml = new ClassPathResource("application.yml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(yaml) + .contains("/api/platform/v1/inbounds/**") + .contains("/api/platform/inbounds/**") + .contains("/api/platform/v1/fba-pricing/**") + .contains("/api/merchant/v1/first-leg/**") + .contains("/api/platform/v1/files/**"); + } +} diff --git a/gateway/src/test/java/com/baiyue/gateway/filter/CorrelationIdWebFilterTest.java b/gateway/src/test/java/com/baiyue/gateway/filter/CorrelationIdWebFilterTest.java new file mode 100644 index 000000000..1c95246a2 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/filter/CorrelationIdWebFilterTest.java @@ -0,0 +1,27 @@ +package com.baiyue.gateway.filter; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +class CorrelationIdWebFilterTest { + @Test + void replacesShortIncomingRequestIdAndCreatesTraceId() { + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/platform/v1/orders") + .header(CorrelationIdWebFilter.HEADER, "a7612a59-6211") + .build()); + + new CorrelationIdWebFilter().filter(exchange, ignored -> Mono.empty()).block(); + + String requestId = exchange.getResponse().getHeaders().getFirst(CorrelationIdWebFilter.HEADER); + String traceId = exchange.getResponse().getHeaders().getFirst(CorrelationIdWebFilter.TRACE_HEADER); + assertThat((String) requestId).matches("[0-9a-fA-F-]{36}"); + assertThat((String) traceId).matches("[0-9a-fA-F-]{36}"); + assertThat(requestId).isNotEqualTo("a7612a59-6211"); + assertThat((String) exchange.getAttribute(CorrelationIdWebFilter.REQUEST_ID_ATTRIBUTE)).isEqualTo(requestId); + assertThat((String) exchange.getAttribute(CorrelationIdWebFilter.TRACE_ID_ATTRIBUTE)).isEqualTo(traceId); + } +} diff --git a/iam/AGENTS.md b/iam/AGENTS.md new file mode 100644 index 000000000..e8e8b4c46 --- /dev/null +++ b/iam/AGENTS.md @@ -0,0 +1,40 @@ +# IAM Service 模块指引 + +## 职责与边界 + +- Mapper 只由 `BaiyueIamApplication.java` 对 `com.baiyue.iam` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 +- 负责客户主档、成员、企业资料/审核、平台用户、角色权限、数据范围和授权快照。 +- 账号凭据/Token 属于 `auth/`,文件内容属于 `file/`;资质登记只接收 File 的内部元数据调用。 +- 修改用户可见层级、菜单权限、列表/详情只读语义或权限资源前,先读 `../../docs/平台客户层级与权限契约.md`。 +- 当前仅“平台—客户”两级:`iam_tenant.id=0` 为平台作用域,`id>0` 为客户主档;不得新增真实租户层。 + +## 模块入口 + +| 目标 | 入口 | +|---|---| +| 客户资料/审核与企业联系信息 | `controller/PlatformTenantController.java`、`service/TenantService.java`;企业客户编号由 `CustomerCodeRepository` 在创建事务内从 `00001` 起分配固定五位数字,平台技术主体仍为 `PLATFORM`;公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一;审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 | +| 客户默认箱规与权重系数初始化、重试及补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults`,尺寸配置和默认权重系数规则均返回成功后才标记 `READY` | +| 商户成员、登录手机号与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java`、`client/AuthAccountClient.java`;成员手机号是可选登录标识,变更时同步 Auth、撤销该账号全部会话并审计,不与公司联系电话联动 | +| 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `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/` | +| SQL/OpenAPI | IAM 主契约 `src/main/resources/openapi/iam-v1.yaml`;消息契约 `src/main/resources/openapi/notification-v1.yaml`;消息脚本 `src/main/resources/db/notification-center.sql`,其余数据库脚本在同目录 | +| 测试/验证 | `src/test/java/com/baiyue/iam/`;`mvn -pl iam -am test` | + +## 修改约束 + +- 所有客户查询按 `tenant_id` 过滤;平台用户只能 `tenant_id=0`。必填资质审核通过才具备下单资格。 +- 成员/账号跨 IAM/Auth 保持幂等补偿;资质跨 IAM/File 不保存文件内容。 +- 注册创建时可用同一手机号初始化公司联系电话与首位管理员登录手机号,但此后两者独立:企业资料更新不得探测 Auth 唯一性或修改任何成员账号;成员登录手机号修改必须检查 Auth 唯一性、同步账号并撤销该账号会话。企业审核不得把法人身份证恢复为必填资质。 +- 客户建档只在 IAM 事务内落持久初始化意图,远端投递由带租约、退避和最大次数的 Relay 完成;只有状态为 `READY` 才能审核通过。注册补偿必须投递 `COMPENSATE`,不得在建档事务中同步依赖 Firstleg。 +- 权限资源 code/type 不可变;按钮隐藏不能替代后端权限校验。 +- 国内头程 PDA 使用 `CN_FIRSTLEG_PDA_OPERATOR`,海外仓 PDA 使用 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`;两个角色互斥且权限不得交叉。只有海外角色可保存 `overseasWarehouseId`,且必须为单个有效海外仓;国内角色及其他角色必须为空。仓绑定、账号状态、角色权限或客户状态变化要在同一业务事务内推进安全事件 Outbox,不得依赖海外服务回查国内 IAM。 +- `IamTenantStatusChanged` 是安全可用性快照,不暴露客户建档/审核流程状态:仅 IAM 业务状态 `ACTIVE` 发布 `ACTIVE`,`PROFILE_REQUIRED`、`PENDING`、`REJECTED`、`DISABLED`、`ARCHIVED` 及其他不可用状态统一发布 `DISABLED`。 + +## 全量验收导航 + +本模块承载客户建档审核、平台用户/RBAC、企业资料、消息中心及全流程作业角色权限;按本次验收链路读取对应业务域/专题文档和权限契约,核对授权快照、客户隔离、权限码和消息收件箱,不因历史规划边界排除协作场景。 diff --git a/iam/README-IAM.md b/iam/README-IAM.md index 6bb91ca65..4e76c84b1 100644 --- a/iam/README-IAM.md +++ b/iam/README-IAM.md @@ -1,10 +1,10 @@ -# IAM 一期交付说明 / IAM Phase 1 Delivery +# IAM 订单与客户交付说明 / IAM Phase 1 Delivery ## 数据库归属 / Database ownership -全部表位于 `baiyue_iam`,由 `iam-service` 独占连接。`auth-service` 只访问 `baiyue_auth.auth_account` 与 `auth_refresh_token`。`iam_user.auth_user_id` 是跨库逻辑 ID,禁止建立跨库外键。商户数据通过 `tenant_id` 强制隔离;平台租户使用 `tenant_id=0`,权限通过 `app_code=PLATFORM`,商户端使用 `app_code=MERCHANT`。 +全部表位于 `baiyue_iam`,由 `iam-service` 独占连接。`auth-service` 只访问 `baiyue_auth.auth_account` 与 `auth_refresh_token`。`iam_user.auth_user_id` 是跨库逻辑 ID,禁止建立跨库外键。当前产品只包含“平台—客户”两级:商户数据通过兼容字段 `tenant_id > 0` 按客户强制隔离;平台账号、平台 RBAC 和平台公共资源使用技术作用域 `tenant_id=0`,平台身份使用 `app_code=PLATFORM`,商户端使用 `app_code=MERCHANT`。`tenant` 相关表、类、路径和字段为历史兼容命名,不表示已经存在真实租户层。 -All tables belong to `baiyue_iam`, accessed only by `iam-service`. `auth-service` owns credentials and tokens in `baiyue_auth`. `iam_user.auth_user_id` is a logical cross-database reference; no cross-database foreign keys are used. Every merchant query must constrain `tenant_id`. Platform scope uses `tenant_id=0` and `app_code=PLATFORM`; merchant scope uses `app_code=MERCHANT`. +All tables belong to `baiyue_iam`, accessed only by `iam-service`. `auth-service` owns credentials and tokens in `baiyue_auth`. `iam_user.auth_user_id` is a logical cross-database reference; no cross-database foreign keys are used. The current product has only platform and customer levels. Every merchant query must constrain the compatibility field `tenant_id`, which currently stores the customer ID. Platform identities, RBAC, and public warehouse master data use technical scope `tenant_id=0`; platform identity also uses `app_code=PLATFORM`, while merchant scope uses `app_code=MERCHANT`. Historical tenant-named tables, types, paths, and fields do not establish a real tenant layer. ## 代码落位 / Code placement @@ -20,10 +20,29 @@ Merge the packages under `src/main/java` into `D:/baiyue/iam/src/main/java`; exe | GET | `/api/platform/v1/iam/reviews` | PLATFORM | List pending reviews / 待审核列表 | | POST | `/api/platform/v1/iam/reviews/{id}/approve` | PLATFORM | Approve registration / 审核通过 | | POST | `/api/platform/v1/iam/reviews/{id}/reject` | PLATFORM | Reject registration / 审核驳回 | +| GET/POST | `/api/platform/v1/iam/users` | PLATFORM | Paginated platform-internal user administration (`tenant_id=0`) / 平台内部用户分页管理 | +| GET | `/api/platform/v1/iam/users/options` | PLATFORM | Platform technical scope and PLATFORM role options / 平台技术作用域与平台角色选项 | +| PUT | `/api/platform/v1/iam/users/{membershipId}` | PLATFORM | Edit internal-user profile and roles / 编辑内部用户资料和角色 | +| PUT | `/api/platform/v1/iam/users/{membershipId}/status` | PLATFORM | Enable or disable user / 启停用户 | +| PUT | `/api/platform/v1/iam/users/{membershipId}/password` | PLATFORM | Reset password / 重置密码 | | GET/PUT | `/api/merchant/v1/iam/tenant/profile` | MERCHANT | View/update own profile / 企业资料 | | GET/POST | `/api/merchant/v1/iam/members` | MERCHANT | List/add members / 成员 | | GET/POST | `/api/{app}/v1/iam/roles` | PLATFORM/MERCHANT | Role management / 角色 | +| GET | `/api/{app}/v1/iam/authorization/permissions` | PLATFORM/MERCHANT | Current user's active permission codes / 当前用户有效权限编码 | | GET/POST | `/api/{app}/v1/iam/permissions` | PLATFORM/MERCHANT | Permission tree / 权限树 | +## 审核通过初始化扩展 + +企业审核通过后的默认数据统一通过 `TenantApprovalInitializer` 扩展链初始化,`TenantService` 不直接依赖具体初始化事项。客户端自主注册时,注册账号已经创建 OWNER 成员并绑定 `MERCHANT_OWNER`,审核通过后继续沿用该账号;只有 Admin 平台代建且没有 OWNER 的企业,才由 `TenantAdministratorProvisioningService` 创建管理员账号。后续规则、费用方案或其他默认角色应各自新增初始化器,并通过 `@Order` 声明依赖顺序。 + +初始化器及其补偿必须幂等。任一初始化器失败时,协调器按完成顺序的逆序执行补偿;IAM 本地事务最终回滚时也会触发同一补偿链。初始化器名称必须非空且全局唯一,初始密码等敏感结果只能通过审核响应一次性返回,不得持久化到 IAM 或写入日志。 + +管理员初始化会验证既有 OWNER 成员、IAM 用户及 Auth 账号均处于可用状态且租户、成员和应用范围一致。历史数据只有 OWNER/IAM 身份而缺少 Auth 账号时,复用原身份补建认证账号;停用或范围冲突的数据会阻止审核通过,避免激活后没有可登录管理员。部署前必须执行 `phase1-onboarding.sql`;测试环境统一执行 `deploy/sql/2026-07-31订单与客户回归测试环境增量迁移.sql`,确保全局 `MERCHANT_OWNER` 模板及权限完整。 + OpenAPI is synchronized in `openapi/iam-v1.yaml`. +## 商户成员角色字段级授权 + +- 新增、编辑成员基础资料分别需要 `merchant:member:create`、`merchant:member:edit`。 +- 新增请求的 `roleIds` 非空,或编辑请求的完整目标角色集合与当前集合不同时,还必须具有 `merchant:member:grant-role`。 +- `roleIds: []` 表示清空角色,不能绕过授权;编辑时角色集合未变化则不额外拒绝。成员详情和列表均实时返回当前客户下的 `roleIds`。 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 5fd239738..20770d35f 100644 --- a/iam/pom.xml +++ b/iam/pom.xml @@ -5,7 +5,9 @@ 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} com.baomidoumybatis-plus-spring-boot3-starter com.baiyue.sharedbaiyue-common-web${project.version} @@ -14,10 +16,11 @@ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config org.springframework.cloudspring-cloud-starter-openfeign org.springframework.cloudspring-cloud-starter-loadbalancer + com.belerwebpinyin4j2.5.1 org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime + com.h2databaseh2test org.springframework.bootspring-boot-starter-testtest org.springframework.bootspring-boot-maven-plugin - diff --git a/iam/src/main/java/com/baiyue/iam/BaiyueIamApplication.java b/iam/src/main/java/com/baiyue/iam/BaiyueIamApplication.java index 0a42654e5..482eb26dc 100644 --- a/iam/src/main/java/com/baiyue/iam/BaiyueIamApplication.java +++ b/iam/src/main/java/com/baiyue/iam/BaiyueIamApplication.java @@ -1,15 +1,18 @@ package com.baiyue.iam; +import org.apache.ibatis.annotations.Mapper; 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; /** * 身份与权限服务启动入口,负责装配租户、成员、双端 RBAC 和授权快照相关组件。 */ -@MapperScan("com.baiyue.iam.mapper") +@MapperScan(basePackages = "com.baiyue.iam", annotationClass = Mapper.class) @EnableFeignClients(basePackages = "com.baiyue.iam.client") +@EnableScheduling @SpringBootApplication(scanBasePackages = "com.baiyue") public class BaiyueIamApplication { public static void main(String[] args) { SpringApplication.run(BaiyueIamApplication.class, args); } diff --git a/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java b/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java index 19358f3aa..18abf74a6 100644 --- a/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java +++ b/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java @@ -4,24 +4,83 @@ import com.baiyue.common.core.api.ApiResponse; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; +import java.util.Set; + /** auth-service 内部账号管理客户端。 */ @FeignClient(name = "auth-service", contextId = "authAccountClient", url = "${baiyue.clients.auth.url:}", path = "/internal/auth/accounts") public interface AuthAccountClient { @PostMapping ApiResponse create(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody CreateAccountRequest request); + @GetMapping("/availability") ApiResponse availability(@RequestHeader("X-Internal-Service-Token") String token, + @RequestParam String username); @GetMapping("/{id}") ApiResponse summary(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long id); @PutMapping("/{id}/status") ApiResponse status(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long id, @RequestBody StatusRequest request); + @PutMapping("/{id}/profile") ApiResponse profile(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable Long id, @RequestBody ProfileRequest request); + @PutMapping("/{id}/login-identifiers") ApiResponse changeLoginIdentifiers( + @RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long id, + @RequestBody ChangeLoginIdentifiersRequest request); + @PutMapping("/{id}/phone") ApiResponse phone(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable Long id, @RequestBody PhoneRequest request); @PutMapping("/{id}/password") ApiResponse reset(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long id, @RequestBody PasswordRequest request); + /** Idempotently disables and revokes all accounts belonging to a customer. */ + @PutMapping("/tenant/{tenantId}/archive") ApiResponse archiveTenant( + @RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long tenantId); + @PostMapping("/impersonation-tickets") ApiResponse createImpersonationTicket( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody ImpersonationTicketRequest request); + @PutMapping("/{id}/scope") ApiResponse scope(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable Long id, @RequestBody ScopeRequest request); + @PostMapping("/sessions/revoke") ApiResponse revokeSessions( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody RevokeAccountSessionsRequest request); @DeleteMapping("/{id}/compensation") ApiResponse compensate(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long id); - record CreateAccountRequest(Long accountId, String username, String password, String displayName, - Long tenantId, Long membershipId) { } - record AccountSummary(Long accountId, String username, String displayName, String status) { } + record CreateAccountRequest(Long accountId, String username, String phone, String password, String displayName, + Long tenantId, Long membershipId, String appCode, String accountStatus) { + public CreateAccountRequest(Long accountId, String username, String password, String displayName, + Long tenantId, Long membershipId) { + this(accountId, username, null, password, displayName, tenantId, membershipId, "MERCHANT", "ACTIVE"); + } + public CreateAccountRequest(Long accountId, String username, String password, String displayName, + Long tenantId, Long membershipId, String appCode) { + this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, "ACTIVE"); + } + public CreateAccountRequest(Long accountId, String username, String password, String displayName, + Long tenantId, Long membershipId, String appCode, String accountStatus) { + this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, accountStatus); + } + } + record AccountSummary(Long accountId, String username, String phone, String displayName, String status, + LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) { + public AccountSummary(Long accountId, String username, String displayName, String status) { + this(accountId, username, null, displayName, status, null, null, null, null); + } + public AccountSummary(Long accountId, String username, String displayName, String status, + LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) { + this(accountId, username, null, displayName, status, lastLoginAt, tenantId, membershipId, appCode); + } + } record StatusRequest(String status) { } - record PasswordRequest(String newPassword) { } + record ProfileRequest(String displayName) { } + record ChangeLoginIdentifiersRequest(String username, String phone, String currentPassword) { } + record PhoneRequest(String phone) { } + record PasswordRequest(String newPassword, boolean forceChangePassword) { + public PasswordRequest(String newPassword) { this(newPassword, false); } + } + record ScopeRequest(Long tenantId, Long membershipId, String appCode) { } + record RevokeAccountSessionsRequest(Set accountIds) { + public RevokeAccountSessionsRequest { + accountIds = Set.copyOf(accountIds); + } + } + record ImpersonationTicketRequest(Long targetAuthUserId, Long tenantId, Long membershipId, + Long actorUserId, Long actorMembershipId, String reason) { } + record ImpersonationTicketResponse(String code, String ticketId, long expiresIn) { } } diff --git a/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java b/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java new file mode 100644 index 000000000..d2eb9c2b8 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java @@ -0,0 +1,31 @@ +package com.baiyue.iam.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.DeleteMapping; +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; + +/** Firstleg internal contract for atomically provisioning box-size and weight-coefficient defaults. */ +@FeignClient(name = "firstleg-service", contextId = "customerDimensionRuleClient", + url = "${baiyue.clients.firstleg.url:}", + path = "/internal/firstleg/v1/customer-dimension-configs") +public interface CustomerDimensionRuleClient { + @PostMapping("/defaults") + ApiResponse initialize( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody InitializationRequest request); + + @DeleteMapping("/defaults") + ApiResponse compensate( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestParam long tenantId, + @RequestParam String initializationKey); + + record InitializationRequest(long tenantId, long operatorId, String initializationKey, + String customerName) { } + record InitializationResponse(Long configId, Long chargeRuleId, + boolean createdByThisInitialization) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/client/OverseasStorageFeeClient.java b/iam/src/main/java/com/baiyue/iam/client/OverseasStorageFeeClient.java new file mode 100644 index 000000000..c0355ebf4 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/client/OverseasStorageFeeClient.java @@ -0,0 +1,27 @@ +package com.baiyue.iam.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +/** Internal Warehouse contract for a customer's default overseas storage fees. */ +@FeignClient(name = "warehouse-service", contextId = "overseasStorageFeeClient", + url = "${baiyue.clients.warehouse.url:}", + path = "/internal/warehouse/v1/customer-overseas-storage-fees") +public interface OverseasStorageFeeClient { + @PostMapping("/defaults") + ApiResponse initialize( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody InitializationRequest request); + + @DeleteMapping("/defaults") + ApiResponse compensate(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody CompensationRequest request); + + record InitializationRequest(long tenantId, long operatorId, String initializationKey, String customerName) { } + record InitializationResponse(Long configId, boolean createdByThisInitialization) { } + record CompensationRequest(long tenantId, String initializationKey) { } +} 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/AuthorizationController.java b/iam/src/main/java/com/baiyue/iam/controller/AuthorizationController.java new file mode 100644 index 000000000..6439cffd3 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/controller/AuthorizationController.java @@ -0,0 +1,54 @@ +package com.baiyue.iam.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.security.AuthenticatedUser; +import com.baiyue.iam.service.AuthorizationSnapshotService; +import com.baiyue.iam.service.TenantGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +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.RestController; + +import java.util.Set; + +/** + * 当前登录成员的有效权限查询接口。 + * + *

此接口只要求调用方已通过对应应用端的 JWT 鉴权;不能以角色管理权限作为 + * 初始化登录会话的前置条件。权限从 IAM 当前有效成员、角色和权限资源实时计算, + * 因此角色被停用或权限被回收后,下一次查询会立即生效。

+ */ +@RestController +@Tag(name = "当前用户授权", description = "返回当前成员的有效权限编码。") +public class AuthorizationController { + @Resource + private AuthorizationSnapshotService authorizationSnapshotService; + + @GetMapping("/api/{app}/v1/iam/authorization/permissions") + @Operation(summary = "查询当前用户有效权限", description = "不依赖角色管理权限。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "权限查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "应用权限域错误或身份已停用") + }) + public ApiResponse> permissions(@PathVariable String app, + @AuthenticationPrincipal AuthenticatedUser user) { + String appCode = appCode(app); + TenantGuard.tenant(user, appCode); + return ApiResponse.success(authorizationSnapshotService.currentPermissions(user, appCode)); + } + + private String appCode(String app) { + return switch (app) { + case "platform" -> "PLATFORM"; + case "merchant" -> "MERCHANT"; + default -> throw new BizException(ResultCode.BAD_REQUEST, "不支持的应用端"); + }; + } +} diff --git a/iam/src/main/java/com/baiyue/iam/controller/CustomerListExportProviderController.java b/iam/src/main/java/com/baiyue/iam/controller/CustomerListExportProviderController.java new file mode 100644 index 000000000..737a6f1db --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/controller/CustomerListExportProviderController.java @@ -0,0 +1,55 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.iam.service.CustomerListExportProviderService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** Internal provider endpoint consumed only by File Service. */ +@RestController +@RequestMapping("/internal/exports/v1/CUSTOMER_LIST") +public class CustomerListExportProviderController { + @Resource + private CustomerListExportProviderService service; + @Value("${baiyue.security.internal-service-token}") + private String internalTokenValue; + private byte[] internalToken; + + @PostConstruct + void initializeToken() { + byte[] configured = internalTokenValue == null + ? new byte[0] : internalTokenValue.getBytes(StandardCharsets.UTF_8); + if (configured.length < 32) { + throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + this.internalToken = configured; + } + + @PostMapping("/batches") + public ApiResponse batch( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody ExportBatchRequest request) { + verifyToken(token); + return ApiResponse.success(service.exportBatch(request)); + } + + private void verifyToken(String token) { + byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(internalToken, supplied)) { + throw new BizException(ResultCode.FORBIDDEN, "Invalid internal service token"); + } + } +} diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java index 461a3c26a..044f022c2 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java @@ -8,12 +8,23 @@ import com.baiyue.iam.dto.OnboardingDtos.MerchantRegistrationResponse; import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse; +import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest; +import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationResponse; +import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest; +import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageResponse; import com.baiyue.iam.service.MerchantOnboardingService; import com.baiyue.iam.service.ProfileResourceService; import com.baiyue.iam.service.TenantService; +import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.iam.dto.TenantDtos.FinanceCustomerSummary; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import org.springframework.validation.annotation.Validated; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; @@ -22,6 +33,7 @@ import java.security.MessageDigest; @RestController @RequestMapping("/internal/iam") +@Validated public class InternalTenantController { @Resource private MerchantOnboardingService onboardingService; @Resource private ProfileResourceService profileResourceService; @@ -30,7 +42,14 @@ public class InternalTenantController { private byte[] internalToken; @PostConstruct - void initializeToken() { internalToken = internalTokenValue.getBytes(StandardCharsets.UTF_8); } + void initializeToken() { + byte[] configured = internalTokenValue == null + ? new byte[0] : internalTokenValue.getBytes(StandardCharsets.UTF_8); + if (configured.length < 32) { + throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + internalToken = configured; + } @PostMapping("/merchant-registrations") public ApiResponse register( @@ -40,6 +59,23 @@ public class InternalTenantController { return ApiResponse.success(onboardingService.register(request)); } + @GetMapping("/merchant-registrations/{authUserId}") + public ApiResponse registration( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long authUserId) { + verify(token); + return ApiResponse.success(onboardingService.registration(authUserId)); + } + + @DeleteMapping("/merchant-registrations/{authUserId}/compensation") + public ApiResponse compensateRegistration( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long authUserId) { + verify(token); + onboardingService.compensateRegistration(authUserId); + return ApiResponse.success(null); + } + @PostMapping("/qualifications") public ApiResponse submitQualification( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @@ -48,6 +84,30 @@ public class InternalTenantController { return ApiResponse.success(profileResourceService.submitQualification(request)); } + @PostMapping("/platform-qualifications") + public ApiResponse registerPlatformQualification( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody PlatformQualificationRegistrationRequest request) { + verify(token); + return ApiResponse.success(profileResourceService.registerPlatformQualification(request)); + } + + @PostMapping("/required-qualification-packages") + public ApiResponse submitRequiredQualificationPackage( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody RequiredQualificationPackageRequest request) { + verify(token); + return ApiResponse.success(profileResourceService.submitRequiredQualificationPackage(request)); + } + + @PostMapping("/platform-required-qualification-packages") + public ApiResponse registerPlatformRequiredQualificationPackage( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody RequiredQualificationPackageRequest request) { + verify(token); + return ApiResponse.success(profileResourceService.registerPlatformRequiredQualificationPackage(request)); + } + @GetMapping("/tenants/{tenantId}/order-eligibility") public ApiResponse orderEligibility( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @@ -56,6 +116,30 @@ public class InternalTenantController { return ApiResponse.success(tenantService.orderEligibility(tenantId)); } + @GetMapping("/finance-customers") + public ApiResponse> financeCustomers( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam(defaultValue = "1") @Min(1) long page, + @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) @Pattern(regexp = "ACTIVE|DISABLED") String status, + @RequestParam(required = false) Long customerId) { + verify(token); + return ApiResponse.success(tenantService.pageFinanceCustomers(page, size, keyword, status, customerId)); + } + + @GetMapping("/exports/customers") + public ApiResponse exportCustomers( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "500") @Min(1) @Max(500) int limit, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) + @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status) { + verify(token); + return ApiResponse.success(tenantService.exportMerchants(cursor, limit, keyword, status)); + } + private void verify(String token) { byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); if (!MessageDigest.isEqual(internalToken, supplied)) { diff --git a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java index d54e08b41..886722e72 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java @@ -5,6 +5,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.dto.MemberDtos.*; import com.baiyue.iam.dto.ProfileDtos.PersonalProfileResponse; import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest; +import com.baiyue.iam.dto.ProfileDtos.ChangeLoginIdentifiersRequest; import com.baiyue.iam.dto.TenantDtos.TenantResponse; import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest; import com.baiyue.iam.service.MemberService; @@ -49,11 +50,25 @@ public class MerchantTenantController { public ApiResponse updatePersonalProfile( @Valid @RequestBody UpdatePersonalProfileRequest request, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.rejectImpersonated(user); long tenantId = TenantGuard.tenant(user, "MERCHANT"); return ApiResponse.success(personalProfileService.update(tenantId, user.userId(), user.username(), request)); } + @PutMapping("/profile/login-identifiers") + @PreAuthorize("isAuthenticated()") + @Operation(summary = "修改当前登录用户的用户名和手机号", + description = "必须使用当前密码重新认证;成功后吊销全部会话,客户端需重新登录。") + public ApiResponse changeLoginIdentifiers( + @Valid @RequestBody ChangeLoginIdentifiersRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.rejectImpersonated(user); + long tenantId = TenantGuard.tenant(user, "MERCHANT"); + return ApiResponse.success(personalProfileService.changeLoginIdentifiers(tenantId, user.userId(), request)); + } + @GetMapping("/tenant/profile") + @PreAuthorize("hasAuthority('merchant:profile:view')") @Operation(summary = "Get current enterprise profile / 查询当前企业资料", description = "Tenant ID is taken only from the authenticated merchant JWT. / 租户 ID 仅从已认证商户 JWT 获取。") @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Profile returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Tenant not found / 企业不存在")}) public ApiResponse profile(@AuthenticationPrincipal AuthenticatedUser user) { @@ -66,9 +81,17 @@ public class MerchantTenantController { @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Profile updated / 更新成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid profile / 资料不合法")}) public ApiResponse update(@Valid @RequestBody UpdateProfileRequest request, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.rejectImpersonated(user); return ApiResponse.success(tenantService.update(TenantGuard.tenant(user, "MERCHANT"), request)); } + @PostMapping("/tenant/qualifications/submit-review") + @PreAuthorize("hasAuthority('merchant:qualification:upload')") + @Operation(summary = "提交当前企业资质审核", description = "校验营业执照齐全后创建审核单;法人身份证、货代资质证书和银行开户许可证选填。上传或替换文件不会自动送审。") + public ApiResponse submitQualificationReview(@AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(tenantService.submitMerchantReview(TenantGuard.tenant(user, "MERCHANT"))); + } + @GetMapping("/members") @Operation(summary = "List enterprise members / 查询企业成员", description = "List members belonging to the current JWT tenant. / 查询属于当前 JWT 租户的成员。") @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Member list returned / 查询成功")}) @@ -91,7 +114,9 @@ public class MerchantTenantController { @PreAuthorize("hasAuthority('merchant:member:create')") public ApiResponse addMember(@Valid @RequestBody CreateMemberRequest request, @AuthenticationPrincipal AuthenticatedUser user) { - return ApiResponse.success(memberService.create(TenantGuard.tenant(user, "MERCHANT"), request)); + TenantGuard.rejectImpersonated(user); + return ApiResponse.success(memberService.create(TenantGuard.tenant(user, "MERCHANT"), request, + user.permissions())); } @PutMapping("/members/{membershipId:\\d+}") @@ -99,7 +124,9 @@ public class MerchantTenantController { @Operation(summary = "编辑企业成员及角色") public ApiResponse updateMember(@PathVariable Long membershipId, @Valid @RequestBody UpdateMemberRequest request, @AuthenticationPrincipal AuthenticatedUser user) { - return ApiResponse.success(memberService.update(TenantGuard.tenant(user, "MERCHANT"), membershipId, request)); + TenantGuard.rejectImpersonated(user); + return ApiResponse.success(memberService.update(TenantGuard.tenant(user, "MERCHANT"), membershipId, request, + user.permissions())); } @PutMapping("/members/{membershipId:\\d+}/status") @@ -107,6 +134,7 @@ public class MerchantTenantController { @Operation(summary = "启用或停用企业成员") public ApiResponse changeMemberStatus(@PathVariable Long membershipId, @Valid @RequestBody ChangeMemberStatusRequest request, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.rejectImpersonated(user); return ApiResponse.success(memberService.changeStatus(TenantGuard.tenant(user, "MERCHANT"), membershipId, request, user.membershipId())); } @@ -116,6 +144,7 @@ public class MerchantTenantController { @Operation(summary = "管理员重置成员密码") public ApiResponse resetMemberPassword(@PathVariable Long membershipId, @Valid @RequestBody ResetMemberPasswordRequest request, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.rejectImpersonated(user); memberService.resetPassword(TenantGuard.tenant(user, "MERCHANT"), membershipId, request); return ApiResponse.success(null); } 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 f53766baa..7378bee76 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -1,21 +1,36 @@ package com.baiyue.iam.controller; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.dto.TenantDtos.RegisterRequest; import com.baiyue.iam.dto.TenantDtos.ReviewRequest; import com.baiyue.iam.dto.TenantDtos.TenantResponse; -import com.baiyue.iam.entity.IamTenantReview; +import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; +import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; +import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest; +import com.baiyue.iam.dto.TenantDtos.ApprovalResponse; +import com.baiyue.iam.dto.TenantDtos.CreationResponse; +import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest; +import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse; +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; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; import jakarta.validation.Valid; 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.*; import java.util.List; @@ -25,51 +40,182 @@ import java.util.List; */ @RestController @RequestMapping("/api/platform/v1/iam") +@Validated @Tag(name = "Platform tenant administration / 平台企业管理", description = "Enterprise registration review and tenant activation for platform operators. / 平台运营人员进行企业注册审核和租户启用。") public class PlatformTenantController { @Resource private TenantService service; + @Resource + private MemberService memberService; + + @GetMapping("/tenants") + @PreAuthorize("hasAuthority('platform:customer:list')") + @Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持客户名称和状态筛选。客户编号、联系人等仅作展示信息,不作为搜索条件。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "企业列表查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "分页参数类型无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "422", description = "分页范围或状态无效") + }) + public ApiResponse> page( + @RequestParam(defaultValue = "1") @Min(1) long page, + @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) + @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.pageMerchants(page, size, keyword, status)); + } + + @GetMapping("/tenants/{tenantId:\\d+}") + @PreAuthorize("hasAuthority('platform:customer:list')") + @Operation(summary = "查询企业客户档案", description = "返回企业基础资料、销售负责人、账期、注册地址以及入驻和审批时间。") + public ApiResponse tenant(@PathVariable long tenantId, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + 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/administrator-phone-availability") + @PreAuthorize("hasAuthority('platform:customer:create')") + @Operation(summary = "校验首个管理员登录手机号唯一性", + description = "仅用于创建客户前校验 Auth 登录手机号;企业联系电话本身不要求唯一。") + public ApiResponse administratorPhoneAvailability( + @RequestParam @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.administratorPhoneAvailable(phone)); + } + + @GetMapping("/tenants/{tenantId:\\d+}/members") + @PreAuthorize("hasAuthority('platform:customer:list')") + @Operation(summary = "查询企业客户成员及账号", description = "平台只读查询指定企业的全部成员,包含 OWNER 企业管理员,并返回登录账号。") + public ApiResponse> members( + @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(memberService.platformList(tenantId)); + } @PostMapping("/tenants") @PreAuthorize("hasAuthority('platform:customer:create')") - @Operation(summary = "Register enterprise tenant / 提交企业注册", description = "Create a pending merchant tenant and its registration review ticket. Platform token required. / 创建待审核商户租户及注册审核单,需要平台端令牌。") + @Operation(summary = "Register enterprise tenant / 创建企业客户草稿", description = "Create a PROFILE_REQUIRED merchant draft, its OWNER account and one-time credentials. Saving the customer does not create a review ticket. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据,保存客户不会创建审核单。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Registration created / 注册已创建"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid enterprise data / 企业资料不合法"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Enterprise already exists / 企业已存在") }) - public ApiResponse register(@Valid @RequestBody RegisterRequest request, + public ApiResponse register(@Valid @RequestBody RegisterRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.register(request, user.userId())); + } + + @PutMapping("/tenants/{tenantId:\\d+}") + @PreAuthorize("hasAuthority('platform:customer:edit')") + @Operation(summary = "编辑企业客户档案", description = "维护一期客户档案字段;统一社会信用代码和生命周期状态不由该接口修改。") + public ApiResponse updateTenant(@PathVariable long tenantId, + @Valid @RequestBody UpdateTenantRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.updateMerchant(tenantId, request)); + } + + @PutMapping("/tenants/{tenantId:\\d+}/status") + @PreAuthorize("hasAuthority('platform:customer:status')") + @Operation(summary = "启停企业客户") + public ApiResponse changeTenantStatus(@PathVariable long tenantId, + @Valid @RequestBody ChangeTenantStatusRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.changeMerchantStatus(tenantId, request)); + } + + @PutMapping("/tenants/{tenantId:\\d+}/owner/password") + @PreAuthorize("hasAuthority('platform:customer:reset-password')") + @Operation(summary = "重置客户企业管理员密码") + public ApiResponse resetOwnerPassword(@PathVariable long tenantId, + @Valid @RequestBody ResetCustomerOwnerPasswordRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + service.resetOwnerPassword(tenantId, request); + return ApiResponse.success(null); + } + + @PostMapping("/tenants/{tenantId:\\d+}/archive") + @PreAuthorize("hasAuthority('platform:customer:delete')") + @Operation(summary = "逻辑归档客户", description = "停用客户并撤销其全部账号,保留业务历史数据") + public ApiResponse archive(@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); - return ApiResponse.success(service.register(request)); + return ApiResponse.success(service.archiveMerchant(tenantId)); + } + + @PostMapping("/tenants/{tenantId:\\d+}/impersonation") + @PreAuthorize("hasAuthority('platform:customer:impersonate')") + @Operation(summary = "创建客户代登录授权码", description = "创建受审计、60 秒有效且只能兑换一次的客户企业管理员授权码") + public ApiResponse createImpersonation( + @PathVariable long tenantId, + @Valid @RequestBody CustomerImpersonationRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.createImpersonationTicket(tenantId, request, user)); + } + + @PostMapping("/tenants/{tenantId:\\d+}/submit-review") + @PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')") + @Operation(summary = "提交企业客户审核", description = "校验客户基础资料与必填资质后创建审核单;保存客户或上传文件不会自动进入审核。") + public ApiResponse submitTenantReview(@PathVariable long tenantId, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.submitMerchantReview(tenantId)); } @GetMapping("/reviews") + @PreAuthorize("hasAuthority('platform:customer:review')") @Operation(summary = "List pending enterprise reviews / 查询待审核企业", description = "Return pending enterprise registration review tickets ordered by creation. / 按创建顺序返回待审核企业注册单。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Review list returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域") }) - public ApiResponse> reviews(@AuthenticationPrincipal AuthenticatedUser user) { + public ApiResponse> reviews(@AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); return ApiResponse.success(service.pendingReviews()); } @PostMapping("/reviews/{id}/approve") @PreAuthorize("hasAuthority('platform:customer:approve')") - @Operation(summary = "Approve enterprise registration / 审核通过企业注册", description = "Approve a pending review and activate the tenant. The operation is audited. / 审核通过并激活租户,操作写入审计日志。") + @Operation(summary = "Approve enterprise registration / 审核通过企业注册", description = "Approve and activate the tenant. Client registrations keep their registration account as owner; only platform-created tenants without an owner receive a generated administrator and one-time credentials. / 审核通过并激活租户;客户端注册账号继续作为企业管理员,仅平台代建且没有所有者的企业自动开户并返回一次性凭据。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Approved / 审核通过"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Review not found / 审核单不存在"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Review already completed / 审核已完成") }) - public ApiResponse approve(@Parameter(description = "Review ticket ID / 审核单 ID", required = true, example = "190000000000000001") @PathVariable long id, + public ApiResponse approve(@Parameter(description = "Review ticket ID / 审核单 ID", required = true, example = "190000000000000001") @PathVariable long id, @RequestBody ReviewRequest request, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); - service.review(id, user.userId(), true, request.comment()); - return ApiResponse.success(null); + return ApiResponse.success(service.review(id, user.userId(), true, request.comment())); } @PostMapping("/reviews/{id}/reject") diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java new file mode 100644 index 000000000..8ec2775ac --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java @@ -0,0 +1,90 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.iam.dto.PlatformUserManagementDtos.*; +import com.baiyue.iam.service.PlatformUserManagementService; +import com.baiyue.iam.service.TenantGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** Admin 内部用户管理入口,只允许维护平台租户账号。 */ +@RestController +@RequestMapping("/api/platform/v1/iam/users") +@Validated +@Tag(name = "Platform user administration / 平台用户管理") +public class PlatformUserManagementController { + @Resource + private PlatformUserManagementService service; + + @GetMapping + @PreAuthorize("hasAuthority('platform:user:list')") + @Operation(summary = "分页查询平台内部用户") + public ApiResponse> list( + @RequestParam(defaultValue = "1") @Min(1) long page, + @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) @Pattern(regexp = "ACTIVE|DISABLED") String status, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.page(page, size, keyword, status)); + } + + @GetMapping("/options") + @PreAuthorize("hasAuthority('platform:user:list')") + @Operation(summary = "查询平台内部用户可选平台角色") + public ApiResponse options(@AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.options()); + } + + @PostMapping + @PreAuthorize("hasAuthority('platform:user:create') and hasAuthority('platform:user:grant-role')") + @Operation(summary = "新增平台内部用户") + public ApiResponse create(@Valid @RequestBody CreateUserRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.create(request)); + } + + @PutMapping("/{membershipId:\\d+}") + @PreAuthorize("hasAuthority('platform:user:edit')") + @Operation(summary = "编辑平台内部用户资料和角色") + public ApiResponse update(@PathVariable Long membershipId, + @Valid @RequestBody UpdateUserRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.update(membershipId, request, user.permissions())); + } + + @PutMapping("/{membershipId:\\d+}/status") + @PreAuthorize("hasAuthority('platform:user:status')") + @Operation(summary = "启用或停用用户") + public ApiResponse status(@PathVariable Long membershipId, + @Valid @RequestBody ChangeStatusRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.changeStatus(membershipId, request, user.membershipId())); + } + + @PutMapping("/{membershipId:\\d+}/password") + @PreAuthorize("hasAuthority('platform:user:reset-password')") + @Operation(summary = "重置用户密码") + public ApiResponse password(@PathVariable Long membershipId, + @Valid @RequestBody ResetPasswordRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + service.resetPassword(membershipId, request); + return ApiResponse.success(null); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java index 470649548..9929e5734 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java @@ -3,12 +3,12 @@ package com.baiyue.iam.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.dto.ProfileDtos.AddressRequest; -import com.baiyue.iam.dto.ProfileDtos.CreateUserRequest; -import com.baiyue.iam.dto.ProfileDtos.QualificationRequest; +import com.baiyue.iam.dto.ProfileDtos.QualificationDeletionResponse; +import com.baiyue.iam.dto.ProfileDtos.QualificationResponse; +import com.baiyue.iam.dto.PlatformInternalUserDtos.*; import com.baiyue.iam.entity.IamTenantAddress; -import com.baiyue.iam.entity.IamTenantQualification; -import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.service.ProfileResourceService; +import com.baiyue.iam.service.PlatformInternalUserService; import com.baiyue.iam.service.TenantGuard; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -29,23 +29,67 @@ import java.util.List; public class ProfileResourceController { @Resource private ProfileResourceService service; + @Resource + private PlatformInternalUserService internalUserService; @GetMapping("/api/platform/v1/iam/internal-users") + @PreAuthorize("hasAuthority('platform:user:list')") @Operation(summary = "List platform internal users / 查询平台内部用户", description = "Platform scope is required. / 需要平台端权限域。") @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "User list returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域")}) - public ApiResponse> internalUsers(@AuthenticationPrincipal AuthenticatedUser user) { + public ApiResponse> internalUsers(@AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); - return ApiResponse.success(service.internalUsers()); + return ApiResponse.success(internalUserService.list()); } @PostMapping("/api/platform/v1/iam/internal-users") @PreAuthorize("hasAuthority('platform:user:create')") @Operation(summary = "Create platform internal user / 新增平台内部用户", description = "Create an IAM profile linked to an auth-service account. / 创建关联统一认证账号的 IAM 用户资料。") @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "User created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid user profile / 用户资料不合法")}) - public ApiResponse addUser(@Valid @RequestBody CreateUserRequest request, - @AuthenticationPrincipal AuthenticatedUser user) { + public ApiResponse addUser(@Valid @RequestBody CreateInternalUserRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); - return ApiResponse.success(service.addUser(request)); + return ApiResponse.success(internalUserService.create(request)); + } + + @PutMapping("/api/platform/v1/iam/internal-users/{membershipId:\\d+}") + @PreAuthorize("hasAuthority('platform:user:edit')") + @Operation(summary = "编辑平台内部用户") + public ApiResponse updateInternalUser( + @PathVariable Long membershipId, @Valid @RequestBody UpdateInternalUserRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(internalUserService.update(membershipId, request)); + } + + @PutMapping("/api/platform/v1/iam/internal-users/{membershipId:\\d+}/status") + @PreAuthorize("hasAuthority('platform:user:status')") + @Operation(summary = "启用或停用平台内部用户") + public ApiResponse changeInternalUserStatus( + @PathVariable Long membershipId, @Valid @RequestBody ChangeInternalUserStatusRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(internalUserService.changeStatus(membershipId, request, user.membershipId())); + } + + @PutMapping("/api/platform/v1/iam/internal-users/{membershipId:\\d+}/password") + @PreAuthorize("hasAuthority('platform:user:reset-password')") + @Operation(summary = "重置平台内部用户密码") + public ApiResponse resetInternalUserPassword( + @PathVariable Long membershipId, @Valid @RequestBody ResetInternalUserPasswordRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + internalUserService.resetPassword(membershipId, request); + return ApiResponse.success(null); + } + + @PutMapping("/api/platform/v1/iam/internal-users/{membershipId:\\d+}/roles") + @PreAuthorize("hasAuthority('platform:user:grant-role')") + @Operation(summary = "分配平台内部用户角色") + public ApiResponse assignInternalUserRoles( + @PathVariable Long membershipId, @Valid @RequestBody AssignInternalUserRolesRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(internalUserService.assignRoles(membershipId, request)); } @GetMapping("/api/merchant/v1/iam/tenant/addresses") @@ -65,26 +109,55 @@ public class ProfileResourceController { } @GetMapping("/api/merchant/v1/iam/tenant/qualifications") + @PreAuthorize("hasAuthority('merchant:profile:view')") @Operation(summary = "List enterprise qualifications / 查询企业资质", description = "List qualification files owned by the current tenant. / 查询当前租户的资质文件。") @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Qualification list returned / 查询成功")}) - public ApiResponse> qualifications(@AuthenticationPrincipal AuthenticatedUser user) { + public ApiResponse> qualifications(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.qualifications(TenantGuard.tenant(user, "MERCHANT"))); } - @GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications") - @PreAuthorize("hasAuthority('platform:customer:review')") - public ApiResponse> platformQualifications( - @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { - TenantGuard.tenant(user, "PLATFORM"); - return ApiResponse.success(service.qualifications(tenantId)); + @GetMapping("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}") + @PreAuthorize("hasAuthority('merchant:profile:view')") + @Operation(summary = "查询企业资质详情", description = "客户范围来自 JWT,跨客户资质按不存在处理。") + public ApiResponse qualification( + @PathVariable long qualificationId, @AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(service.qualification(TenantGuard.tenant(user, "MERCHANT"), qualificationId)); } - @PostMapping("/api/merchant/v1/iam/tenant/qualifications") + @DeleteMapping("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}") @PreAuthorize("hasAuthority('merchant:qualification:upload')") - @Operation(summary = "Submit enterprise qualification / 提交企业资质", description = "Create a pending qualification record; document content is stored externally. / 创建待审核资质记录,文件内容由外部对象存储保存。") - @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Qualification submitted / 提交成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid qualification / 资质信息不合法")}) - public ApiResponse addQualification(@Valid @RequestBody QualificationRequest request, - @AuthenticationPrincipal AuthenticatedUser user) { - return ApiResponse.success(service.addQualification(TenantGuard.tenant(user, "MERCHANT"), request)); + @Operation(summary = "删除送审前企业资质", description = "仅待完善或已驳回客户可删除;提交审核后服务端拒绝。重复删除幂等成功。") + public ApiResponse deleteQualification( + @PathVariable long qualificationId, @AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(service.deleteQualification(TenantGuard.tenant(user, "MERCHANT"), qualificationId)); } + + @GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications") + @PreAuthorize("hasAuthority('platform:customer:list')") + @Operation(summary = "查询企业客户资质", description = "只读查询企业客户资质元数据,不授予审核能力。") + public ApiResponse> platformQualifications( + @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.platformQualifications(tenantId)); + } + + @GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}") + @PreAuthorize("hasAuthority('platform:customer:list')") + @Operation(summary = "查询企业客户资质详情", description = "需要客户列表权限,且同时校验客户与资质归属。") + public ApiResponse platformQualification( + @PathVariable long tenantId, @PathVariable long qualificationId, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.platformQualification(tenantId, qualificationId)); + } + + @GetMapping("/api/platform/v1/iam/tenants/{tenantId}/addresses") + @PreAuthorize("hasAuthority('platform:customer:list')") + @Operation(summary = "查询企业客户地址", description = "只读查询企业客户地址,不开放平台端写入。") + public ApiResponse> platformAddresses( + @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.platformAddresses(tenantId)); + } + } diff --git a/iam/src/main/java/com/baiyue/iam/controller/RbacController.java b/iam/src/main/java/com/baiyue/iam/controller/RbacController.java index 29b315105..c17354d7a 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/RbacController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/RbacController.java @@ -4,7 +4,6 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.dto.RbacDtos.*; import com.baiyue.iam.entity.IamPermission; -import com.baiyue.iam.entity.IamRole; import com.baiyue.iam.service.RbacService; import com.baiyue.iam.service.TenantGuard; import io.swagger.v3.oas.annotations.Operation; @@ -45,10 +44,11 @@ public class RbacController { @PostMapping("/roles") @Operation(summary = "创建角色") @PreAuthorize("hasAuthority(#app + ':role:create')") - public ApiResponse createRole(@PathVariable String app, + public ApiResponse createRole(@PathVariable String app, @Valid @RequestBody CreateRoleRequest request, @AuthenticationPrincipal AuthenticatedUser user) { Scope scope = scope(app, user); + TenantGuard.rejectImpersonated(user); return ApiResponse.success(service.createRole(scope.tenantId(), scope.appCode(), request)); } @@ -59,6 +59,7 @@ public class RbacController { @Valid @RequestBody UpdateRoleRequest request, @AuthenticationPrincipal AuthenticatedUser user) { Scope scope = scope(app, user); + TenantGuard.rejectImpersonated(user); return ApiResponse.success(service.updateRole(scope.tenantId(), scope.appCode(), roleId, request)); } @@ -69,6 +70,7 @@ public class RbacController { @Valid @RequestBody ChangeRoleStatusRequest request, @AuthenticationPrincipal AuthenticatedUser user) { Scope scope = scope(app, user); + TenantGuard.rejectImpersonated(user); return ApiResponse.success(service.changeStatus(scope.tenantId(), scope.appCode(), roleId, request)); } @@ -78,6 +80,7 @@ public class RbacController { public ApiResponse deleteRole(@PathVariable String app, @PathVariable Long roleId, @AuthenticationPrincipal AuthenticatedUser user) { Scope scope = scope(app, user); + TenantGuard.rejectImpersonated(user); service.deleteRole(scope.tenantId(), scope.appCode(), roleId); return ApiResponse.success(null); } @@ -99,6 +102,7 @@ public class RbacController { @Valid @RequestBody AssignRolesRequest request, @AuthenticationPrincipal AuthenticatedUser user) { Scope scope = scope(app, user); + TenantGuard.rejectImpersonated(user); service.assignRoles(scope.tenantId(), scope.appCode(), request); return ApiResponse.success(null); } @@ -119,9 +123,43 @@ public class RbacController { @Valid @RequestBody CreatePermissionRequest request, @AuthenticationPrincipal AuthenticatedUser user) { Scope scope = scope(app, user); + TenantGuard.rejectImpersonated(user); return ApiResponse.success(service.createPermission(scope.tenantId(), scope.appCode(), request)); } + @PutMapping("/permissions/{permissionId}") + @Operation(summary = "编辑自定义权限资源") + @PreAuthorize("hasAuthority(#app + ':permission:edit')") + public ApiResponse updatePermission(@PathVariable String app, @PathVariable Long permissionId, + @Valid @RequestBody UpdatePermissionRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + TenantGuard.rejectImpersonated(user); + return ApiResponse.success(service.updatePermission(scope.tenantId(), scope.appCode(), permissionId, request)); + } + + @PutMapping("/permissions/{permissionId}/status") + @Operation(summary = "启停自定义权限资源") + @PreAuthorize("hasAuthority(#app + ':permission:status')") + public ApiResponse changePermissionStatus(@PathVariable String app, @PathVariable Long permissionId, + @Valid @RequestBody ChangePermissionStatusRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + TenantGuard.rejectImpersonated(user); + return ApiResponse.success(service.changePermissionStatus(scope.tenantId(), scope.appCode(), permissionId, request)); + } + + @DeleteMapping("/permissions/{permissionId}") + @Operation(summary = "删除未绑定的自定义权限资源") + @PreAuthorize("hasAuthority(#app + ':permission:delete')") + public ApiResponse deletePermission(@PathVariable String app, @PathVariable Long permissionId, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + TenantGuard.rejectImpersonated(user); + service.deletePermission(scope.tenantId(), scope.appCode(), permissionId); + return ApiResponse.success(null); + } + @PutMapping("/roles/permissions") @Operation(summary = "全量设置角色权限,空集合表示清空") @PreAuthorize("hasAuthority(#app + ':role:grant')") @@ -129,6 +167,7 @@ public class RbacController { @Valid @RequestBody GrantPermissionsRequest request, @AuthenticationPrincipal AuthenticatedUser user) { Scope scope = scope(app, user); + TenantGuard.rejectImpersonated(user); service.grantPermissions(scope.tenantId(), scope.appCode(), request); return ApiResponse.success(null); } 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/CustomerExportDtos.java b/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java new file mode 100644 index 000000000..5a6fbbfc4 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java @@ -0,0 +1,20 @@ +package com.baiyue.iam.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class CustomerExportDtos { + private CustomerExportDtos() { } + + public record CustomerExportBatch(List records, Long nextCursor, boolean hasMore) { } + + public record CustomerExportRow( + String id, String tenantCode, String tenantName, String customerNickname, String legalName, + String unifiedSocialCreditCode, String enterpriseType, LocalDate establishedDate, + BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress, + String contactName, String contactPhone, String contactEmail, String contactWechat, + String salesOwnerName, String servicePackage, Integer paymentTermDays, + String status, LocalDateTime createdAt, LocalDateTime approvedAt) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java b/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java index b7e8936e8..77b6fd492 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java @@ -1,11 +1,13 @@ package com.baiyue.iam.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; import java.util.List; /** 企业成员接口的数据契约。 */ @@ -13,11 +15,12 @@ public final class MemberDtos { private MemberDtos() { } public record CreateMemberRequest( - @NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username, + @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username, @NotBlank @Size(min = 8, max = 64) @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, @NotBlank @Size(max = 64) String displayName, - @Size(max = 32) String phone, + @Schema(description = "成员登录手机号;同步 Auth 且与公司联系电话独立") + @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, @Email @Size(max = 128) String email, @NotBlank String memberType, @Size(max = 64) String memberNo, @@ -29,7 +32,8 @@ public final class MemberDtos { public record UpdateMemberRequest( @NotBlank @Size(max = 64) String displayName, - @Size(max = 32) String phone, + @Schema(description = "成员登录手机号;变更时同步 Auth、撤销该账号全部会话并审计") + @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, @Email @Size(max = 128) String email, @NotBlank String memberType, @Size(max = 64) String memberNo, @@ -44,8 +48,9 @@ public final class MemberDtos { public record ResetMemberPasswordRequest(@NotBlank @Size(min = 8, max = 64) @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } - public record MemberResponse(Long membershipId, Long userId, Long authUserId, + public record MemberResponse(Long membershipId, Long userId, Long authUserId, String username, String displayName, String phone, String email, - String memberType, String memberNo, String status, List roleIds) { } + String memberType, String memberNo, String status, LocalDateTime lastLoginAt, + List roleIds) { } } 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 e5180c013..3b3d5fdb8 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java @@ -2,6 +2,10 @@ package com.baiyue.iam.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.time.LocalDate; public final class OnboardingDtos { private OnboardingDtos() { } @@ -10,23 +14,66 @@ public final class OnboardingDtos { @NotNull Long authUserId, @NotBlank String displayName, @NotBlank String companyName, - @NotBlank String legalPerson, - @NotBlank String legalPhone, + @Size(max = 64) String legalPerson, + @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String administratorPhone, String contactEmail, String countryCode, 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, - @NotBlank String qualificationType, + @NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType, String certificateNo, - @NotBlank String documentUrl + @NotBlank String documentUrl, + @NotBlank @Size(max = 255) String originalFileName, + LocalDate validTo, + boolean replace ) { } - public record QualificationSubmissionResponse(Long qualificationId, Long reviewId, String tenantStatus) { } + public record QualificationSubmissionResponse(Long qualificationId, Long reviewId, String tenantStatus, + String replacedDocumentUrl) { } + + public record PlatformQualificationRegistrationRequest( + @NotNull Long tenantId, + @NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType, + String certificateNo, + @NotBlank String documentUrl, + @NotBlank @Size(max = 255) String originalFileName, + LocalDate validTo, + boolean replace + ) { } + + public record PlatformQualificationRegistrationResponse( + Long qualificationId, + Long reviewId, + String tenantStatus, + String replacedDocumentUrl + ) { } + + public record RequiredQualificationPackageRequest( + @NotNull Long tenantId, + @NotBlank @Pattern(regexp = "[0-9A-Za-z]{18}") String unifiedSocialCreditCode, + @NotBlank String businessLicenseDocumentUrl, + @NotBlank @Size(max = 255) String businessLicenseOriginalFileName, + LocalDate businessLicenseValidTo, + boolean replaceBusinessLicense, + String legalPersonIdDocumentUrl, + @Size(max = 255) String legalPersonIdOriginalFileName, + LocalDate legalPersonIdValidTo, + Boolean replaceLegalPersonId + ) { } + + public record RequiredQualificationPackageResponse( + Long businessLicenseQualificationId, + Long legalPersonIdQualificationId, + String tenantStatus, + String replacedBusinessLicenseDocumentUrl, + String replacedLegalPersonIdDocumentUrl + ) { } public record OrderEligibilityResponse(boolean eligible, String tenantStatus, String reason) { } } diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformCustomerMemberDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformCustomerMemberDtos.java new file mode 100644 index 000000000..44b7c67af --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformCustomerMemberDtos.java @@ -0,0 +1,13 @@ +package com.baiyue.iam.dto; + +import java.util.List; + +/** 平台查看客户企业成员时使用的只读契约,补充认证账号信息。 */ +public final class PlatformCustomerMemberDtos { + private PlatformCustomerMemberDtos() { } + + public record MemberResponse(Long membershipId, Long userId, Long authUserId, + String username, String displayName, String phone, String email, + String memberType, String memberNo, String status, + List roleIds, List roleNames) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java new file mode 100644 index 000000000..ffae0e240 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java @@ -0,0 +1,62 @@ +package com.baiyue.iam.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** 平台内部用户公开接口的数据契约,不向前端暴露密码摘要等认证敏感字段。 */ +public final class PlatformInternalUserDtos { + private PlatformInternalUserDtos() { } + + /** + * 同时兼容历史的 authUserId 关联模式和完整账号创建模式。 + * authUserId 有值时关联已有认证账号;否则 username、password 必填并由 IAM 编排 Auth 创建账号。 + */ + public record CreateInternalUserRequest( + Long authUserId, + String userType, + @NotBlank @Size(max = 64) String displayName, + @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, + @Email @Size(max = 128) String email, + @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username, + @Size(min = 8, max = 64) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") + String password, + @Size(max = 64) String memberNo, + List roleIds) { } + + public record UpdateInternalUserRequest( + @NotBlank @Size(max = 64) String displayName, + @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, + @Email @Size(max = 128) String email, + @NotBlank @Size(max = 64) String memberNo, + @NotNull List roleIds) { } + + public record ChangeInternalUserStatusRequest( + @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } + + public record ResetInternalUserPasswordRequest( + @NotBlank @Size(min = 8, max = 64) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") + String newPassword) { } + + /** 全量替换平台成员角色;空集合表示清空全部自定义角色。 */ + public record AssignInternalUserRolesRequest(@NotNull List roleIds) { } + + public record InternalUserResponse( + Long membershipId, + Long userId, + Long authUserId, + String username, + String displayName, + String phone, + String email, + String memberType, + String memberNo, + String status, + List roleIds) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java new file mode 100644 index 000000000..a90f46028 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java @@ -0,0 +1,63 @@ +package com.baiyue.iam.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +/** Admin 平台内部用户管理契约;tenantId 保留用于兼容既有请求,但只能为 0。 */ +public final class PlatformUserManagementDtos { + private PlatformUserManagementDtos() { } + + public record CreateUserRequest( + @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username, + @NotBlank @Size(max = 64) String displayName, + @Schema(description = "平台账号登录手机号;与企业联系电话独立") + @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") String phone, + @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) { + 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, + @Schema(description = "平台账号登录手机号;变更时同步 Auth、撤销该账号全部会话并审计") + @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") String phone, + @Email @Size(max = 128) String email, + @NotNull Long tenantId, + @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) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") + String newPassword) { } + + public record UserResponse(Long membershipId, Long userId, Long authUserId, String username, + String displayName, String phone, String email, Long tenantId, + String tenantName, String memberType, String memberNo, String status, + List roleIds, List roleNames, LocalDateTime lastLoginAt, + boolean protectedAccount, Long overseasWarehouseId) { } + + public record TenantOption(Long id, String name, String status) { } + public record RoleOption(Long id, Long tenantId, String name, String status) { } + public record UserOptions(List tenants, List roles) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java b/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java index 059927156..626fadd0e 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java @@ -2,29 +2,37 @@ package com.baiyue.iam.dto; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import java.time.LocalDate; +import java.time.LocalDateTime; /** 用户档案、地址和资质的数据契约。 */ public final class ProfileDtos { private ProfileDtos() { } - public record CreateUserRequest(@NotNull Long authUserId, @NotBlank String userType, - @NotBlank String displayName, String phone, String email) { } - public record PersonalProfileResponse(Long userId, Long authUserId, String username, String displayName, String phone, String email) { } public record UpdatePersonalProfileRequest(@NotBlank @Size(max = 64) String displayName, - @Size(max = 32) String phone, @Email @Size(max = 128) String email) { } + public record ChangeLoginIdentifiersRequest( + @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username, + @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, + @NotBlank String currentPassword) { } + public record AddressRequest(@NotBlank String addressType, String countryCode, String province, String city, String district, @NotBlank String detail, String postalCode, Boolean isDefault) { } - public record QualificationRequest(@NotBlank String qualificationType, String certificateNo, - @NotBlank String documentUrl, LocalDate validFrom, LocalDate validTo) { } + public record QualificationResponse(Long id, Long tenantId, String qualificationType, + String certificateNo, String documentUrl, String originalFileName, + LocalDate validFrom, LocalDate validTo, String status, + String reviewComment, LocalDateTime createdAt, + LocalDateTime updatedAt) { } + + public record QualificationDeletionResponse(Long qualificationId, String deletedDocumentUrl) { } + } diff --git a/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java b/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java index a10dafd27..716f8950a 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java @@ -25,7 +25,7 @@ public final class RbacDtos { /** 角色回显模型,permissionIds 可直接用于全量授权保存。 */ public record RoleResponse(Long id, Long tenantId, String appCode, String roleCode, String roleName, String roleType, String status, - String description, Set permissionIds) { } + String description, long memberCount, Set permissionIds) { } public record MemberRolesResponse(Long membershipId, Set roleIds) { } @@ -38,6 +38,17 @@ public final class RbacDtos { String httpMethod, Integer sortNo) { } + /** 权限编码和类型参与服务端鉴权,编辑时不可变更。 */ + public record UpdatePermissionRequest( + @NotBlank String name, + Long parentId, + String routePath, + String httpMethod, + Integer sortNo) { } + + public record ChangePermissionStatusRequest( + @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } + public record AssignRolesRequest(@NotNull Long membershipId, @NotNull Set roleIds) { } 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 f424ac26a..ae732b1b1 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -2,8 +2,18 @@ package com.baiyue.iam.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; +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.PastOrPresent; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; /** * 租户生命周期数据契约集合,定义入驻、资料更新、审核请求和租户响应。 @@ -14,29 +24,128 @@ public final class TenantDtos { @Schema(name = "TenantRegisterRequest", description = "Enterprise tenant registration / 企业租户注册资料") public record RegisterRequest( @Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName, + @Schema(description = "Customer nickname; defaults to uppercase pinyin initials of Chinese characters in tenantName / 客户昵称,不填时默认取公司名称各中文字符的拼音大写首字母", example = "SZWL") @Size(max = 32) String customerNickname, @Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName, - @Schema(description = "18-character unified social credit code / 18 位统一社会信用代码", example = "91440300MA5EXAMPLE") @NotBlank @Pattern(regexp = "[0-9A-Z]{18}") String unifiedSocialCreditCode, + @Schema(description = "Legal representative (optional) / 法定代表人(选填)", example = "张三") @Size(max = 64) String legalRepresentative, @Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName, - @Schema(description = "Contact phone / 联系电话", example = "13800138000") @NotBlank String contactPhone, - @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail + @Schema(description = "Company contact phone; also initializes the first administrator login phone at creation, then maintained independently / 公司联系电话;创建时同时初始化首个管理员登录手机号,之后独立维护", example = "13800138000") + @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String contactPhone, + @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail, + @NotBlank @Size(max = 64) String servicePackage, + @Size(max = 64) String contactWechat, + @Size(max = 64) String salesOwnerName, + @Min(0) @Max(365) Integer paymentTermDays, + @Size(max = 64) String enterpriseType, + @PastOrPresent LocalDate establishedDate, + @DecimalMin("0.00") @Digits(integer = 16, fraction = 2) BigDecimal registeredCapital, + @Pattern(regexp = "[A-Z]{3}") String registeredCapitalCurrency, + @Size(max = 500) String registeredAddress, + @Size(max = 1000) String internalRemark ) { } @Schema(name = "TenantProfileUpdateRequest", description = "Enterprise profile update / 企业资料更新") public record UpdateProfileRequest( @Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName, @Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName, + @Schema(description = "Legal representative (optional) / 法定代表人(选填)", example = "张三") @Size(max = 64) String legalRepresentative, @Schema(description = "Primary contact / 联系人", example = "李四") @NotBlank String contactName, - @Schema(description = "Contact phone / 联系电话", example = "13900139000") @NotBlank String contactPhone, + @Schema(description = "Company contact phone, never used to resolve login identity / 公司联系电话,不作为登录标识", example = "0755-12345678") + @NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone, @Schema(description = "Contact email / 联系邮箱", example = "service@example.com") @Email String contactEmail ) { } + /** 平台端客户档案维护。统一社会信用代码是注册标识,不允许经由普通编辑接口修改。 */ + @Schema(name = "PlatformTenantUpdateRequest", description = "Platform maintained merchant tenant profile") + public record UpdateTenantRequest( + @NotBlank String tenantName, + @Schema(description = "Customer nickname / 客户昵称") @NotBlank @Size(max = 32) String customerNickname, + @NotBlank String legalName, + @Size(max = 64) String legalRepresentative, + @NotBlank String contactName, + @Schema(description = "Company contact phone, independent from every member login phone / 公司联系电话,与所有成员登录手机号独立") + @NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone, + @Email String contactEmail, + @NotBlank @Size(max = 64) String servicePackage, + @Size(max = 64) String contactWechat, + @Size(max = 64) String salesOwnerName, + @Min(0) @Max(365) Integer paymentTermDays, + @Size(max = 64) String enterpriseType, + @PastOrPresent LocalDate establishedDate, + @DecimalMin("0.00") @Digits(integer = 16, fraction = 2) BigDecimal registeredCapital, + @Pattern(regexp = "[A-Z]{3}") String registeredCapitalCurrency, + @Size(max = 500) String registeredAddress, + @Size(max = 1000) String internalRemark + ) { } + + @Schema(name = "TenantStatusChangeRequest", description = "Platform controlled merchant tenant status") + public record ChangeTenantStatusRequest( + @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status + ) { } + + @Schema(name = "ResetCustomerOwnerPasswordRequest", description = "Reset the enterprise owner's login password") + public record ResetCustomerOwnerPasswordRequest( + @NotBlank @Size(min = 8, max = 64) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") + String newPassword, + Boolean forceChangePassword + ) { + public boolean forceChange() { return forceChangePassword == null || forceChangePassword; } + } + + @Schema(name = "CustomerImpersonationRequest", description = "Reason for audited customer owner impersonation") + public record CustomerImpersonationRequest( + @NotBlank @Size(min = 5, max = 500) String reason + ) { } + + @Schema(name = "CustomerImpersonationResponse", description = "One-time short-lived impersonation authorization code") + public record CustomerImpersonationResponse(String code, String ticketId, long expiresIn) { } + @Schema(name = "TenantReviewRequest", description = "Enterprise review comment / 企业审核意见") public record ReviewRequest( @Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment ) { } + @Schema(name = "PendingEnterpriseReviewResponse", description = "Pending enterprise review with the customer summary required by platform reviewers / 待审核企业及客户摘要") + public record PendingReviewResponse( + @Schema(description = "Review ID / 审核单 ID", example = "190000000000000001") Long id, + @Schema(description = "Customer ID / 客户 ID", example = "190000000000000002") Long tenantId, + @Schema(description = "Review type / 审核类型", example = "REGISTER") String reviewType, + @Schema(description = "Review status / 审核状态", example = "PENDING") String status, + @Schema(description = "Customer name / 客户名称", example = "深圳虫虫出海科技有限公司") String tenantName, + @Schema(description = "Customer code / 五位客户编号", example = "00001") String tenantCode, + @Schema(description = "Primary contact / 联系人", example = "张三") String contactName, + @Schema(description = "Contact phone / 联系电话", example = "13800138000") String contactPhone, + @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") String contactEmail, + @Schema(description = "Review submission time / 提交审核时间") LocalDateTime submittedAt, + @Schema(description = "Review comment / 审核意见", nullable = true) String reviewComment + ) { } + + @Schema(name = "TenantApprovalResponse", description = "Enterprise approval result and one-time administrator credentials / 企业审核结果及一次性管理员凭据") + public record ApprovalResponse( + @Schema(description = "Tenant ID / 企业租户 ID", example = "190000000000000001") Long tenantId, + @Schema(description = "Whether this approval created the administrator / 本次审核是否新建管理员") boolean administratorCreated, + @Schema(description = "Generated first-administrator username; independent from company contact phone / 自动生成的首个管理员用户名,与公司联系电话独立", nullable = true) String administratorUsername, + @Schema(description = "One-time temporary password; only returned when created / 一次性初始密码,仅新建时返回", format = "password", nullable = true) String temporaryPassword + ) { } + + @Schema(name = "TenantCreationResponse", description = "Created enterprise draft and one-time administrator credentials / 新建企业草稿及一次性管理员凭据") + public record CreationResponse( + @Schema(description = "Created enterprise draft / 新建企业草稿") TenantResponse tenant, + @Schema(description = "Generated administrator username, independent from company contact phone / 自动生成的管理员用户名,与公司联系电话独立") String administratorUsername, + @Schema(description = "One-time temporary password / 一次性初始密码", format = "password") String temporaryPassword + ) { } + @Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要") - public record TenantResponse(Long id, String tenantCode, String tenantName, String status, - String legalName, String unifiedSocialCreditCode, String contactName, - String contactPhone, String contactEmail) { } + public record TenantResponse(Long id, String tenantCode, String tenantName, String customerNickname, 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, + LocalDate establishedDate, BigDecimal registeredCapital, + String registeredCapitalCurrency, String registeredAddress, + String internalRemark, LocalDateTime createdAt, LocalDateTime approvedAt) { } + + /** Full customer contact summary for trusted internal finance read models. */ + public record FinanceCustomerSummary(Long id, String tenantCode, String tenantName, String status, + String contactName, String contactPhone) { } } 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/IamPermission.java b/iam/src/main/java/com/baiyue/iam/entity/IamPermission.java index a52de5219..13a4f7571 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamPermission.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamPermission.java @@ -4,6 +4,7 @@ import lombok.Getter; import lombok.Setter; import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; /** @@ -25,6 +26,10 @@ public class IamPermission { private String httpMethod; private Integer sortNo; private String status; + /** 系统预置资源不得通过租户权限管理接口修改。 */ + private Boolean builtIn; + @TableLogic + private Integer deleted; 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 9a3d06472..b253094bc 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java @@ -6,6 +6,8 @@ import lombok.Setter; import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; /** @@ -18,13 +20,26 @@ public class IamTenant extends BaseEntity { @TableId private Long id; private String tenantCode; private String tenantName; + private String customerNickname; private String tenantType; private String status; + private String dimensionInitializationStatus; private String legalName; + private String legalRepresentative; private String unifiedSocialCreditCode; private String contactName; private String contactPhone; private String contactEmail; + private String servicePackage; + private String contactWechat; + private String salesOwnerName; + private Integer paymentTermDays; + private String enterpriseType; + private LocalDate establishedDate; + private BigDecimal registeredCapital; + private String registeredCapitalCurrency; + private String registeredAddress; + private String internalRemark; private LocalDateTime approvedAt; private Long approvedBy; private String rejectReason; diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java index eb5585fdb..af1a2bd5f 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java @@ -2,10 +2,10 @@ package com.baiyue.iam.entity; import lombok.Getter; import lombok.Setter; -import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDate; +import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDate; import java.time.LocalDateTime; /** * 租户资质实体,对应表 {@code iam_tenant_qualification};按租户保存营业执照等证照、有效期、文件位置和审核状态。 */ @Getter @Setter -@TableName("iam_tenant_qualification") public class IamTenantQualification { @TableId private Long id; private Long tenantId; private String qualificationType; private String certificateNo; private String documentUrl; private LocalDate validFrom; private LocalDate validTo; private String status; private String reviewComment; } +@TableName("iam_tenant_qualification") public class IamTenantQualification { @TableId private Long id; private Long tenantId; private String qualificationType; private String certificateNo; private String documentUrl; private String originalFileName; private LocalDate validFrom; private LocalDate validTo; private String status; private String reviewComment; private LocalDateTime createdAt; private LocalDateTime updatedAt; @TableLogic private Boolean deleted; } 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 d39c7807b..a403399f9 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -55,11 +55,54 @@ public interface AuthorizationQueryMapper { AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0 AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId}) - AND p.app_code = #{appCode} AND p.status = 'ACTIVE' + AND p.app_code = #{appCode} AND p.status = 'ACTIVE' AND p.deleted = 0 ORDER BY p.permission_code """) List findPermissions(@Param("userId") long userId, @Param("tenantId") long tenantId, @Param("membershipId") long membershipId, @Param("appCode") String appCode); + + @Select(""" + SELECT DISTINCT r.role_code + FROM iam_user u + JOIN iam_membership m ON m.user_id = u.id + JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id + JOIN iam_role r ON r.id = mr.role_id + WHERE u.auth_user_id = #{userId} AND u.status = 'ACTIVE' AND u.deleted = 0 + AND m.tenant_id = #{tenantId} AND m.status = 'ACTIVE' AND m.deleted = 0 + AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) + AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0 + ORDER BY r.role_code + """) + List findRolesByIdentity(@Param("userId") long userId, + @Param("tenantId") long tenantId, + @Param("appCode") String appCode); + + @Select(""" + SELECT DISTINCT p.permission_code + FROM iam_user u + JOIN iam_membership m ON m.user_id = u.id + JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id + JOIN iam_role r ON r.id = mr.role_id + JOIN iam_role_permission rp ON rp.role_id = r.id + JOIN iam_permission p ON p.id = rp.permission_id + WHERE u.auth_user_id = #{userId} AND u.status = 'ACTIVE' AND u.deleted = 0 + AND m.tenant_id = #{tenantId} AND m.status = 'ACTIVE' AND m.deleted = 0 + AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) + AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0 + AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId}) + AND p.app_code = #{appCode} AND p.status = 'ACTIVE' AND p.deleted = 0 + ORDER BY p.permission_code + """) + List findPermissionsByIdentity(@Param("userId") long userId, + @Param("tenantId") long tenantId, + @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/mapper/IamMembershipRoleMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java index 79637cd6d..d15438b9b 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java @@ -1,4 +1,29 @@ /** * 成员角色关联 MyBatis-Plus Mapper,负责 {@code iam_membership_role} 表的基础持久化操作。 */ -package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamMembershipRole; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamMembershipRoleMapper extends BaseMapper{} +package com.baiyue.iam.mapper; + +import com.baiyue.iam.entity.IamMembershipRole; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface IamMembershipRoleMapper extends BaseMapper { + @Select(""" + SELECT DISTINCT u.auth_user_id + FROM iam_membership_role mr + JOIN iam_membership m ON m.id = mr.membership_id AND m.tenant_id = mr.tenant_id + JOIN iam_user u ON u.id = m.user_id + WHERE mr.tenant_id = #{tenantId} + AND mr.role_id = #{roleId} + AND m.deleted = 0 + AND u.deleted = 0 + ORDER BY u.auth_user_id + """) + List findAssignedAuthUserIds(@Param("tenantId") long tenantId, + @Param("roleId") Long roleId); +} 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..0d20384bb --- /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 customerName, + String initializationKey, + String action, + String status, + int attemptCount, + int taskVersion) { +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/controller/NotificationInboxController.java b/iam/src/main/java/com/baiyue/iam/notification/controller/NotificationInboxController.java new file mode 100644 index 000000000..e27561143 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/controller/NotificationInboxController.java @@ -0,0 +1,39 @@ +package com.baiyue.iam.notification.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.iam.notification.dto.NotificationDtos.*; +import com.baiyue.iam.notification.service.NotificationService; +import com.baiyue.iam.service.TenantGuard; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController @Validated +@RequestMapping("/api/{app}/v1/notifications") +public class NotificationInboxController { + @Resource private NotificationService service; + + @GetMapping @PreAuthorize("hasAuthority(#app + ':notification:view')") + public ApiResponse> list(@PathVariable String app, + @RequestParam(defaultValue="1") @Min(1) long page,@RequestParam(defaultValue="20") @Min(1) @Max(200) long size, + @RequestParam(required=false) @Pattern(regexp="UNREAD|READ") String status,@RequestParam(required=false) String category, + @AuthenticationPrincipal AuthenticatedUser user){String code=guard(app,user);return ApiResponse.success(service.inbox(user.tenantId(),user.membershipId(),code,page,size,status,category));} + @GetMapping("/unread-count") @PreAuthorize("hasAuthority(#app + ':notification:view')") + public ApiResponse unread(@PathVariable String app,@AuthenticationPrincipal AuthenticatedUser user){String code=guard(app,user);return ApiResponse.success(service.unread(user.tenantId(),user.membershipId(),code));} + @GetMapping("/{id:\\d+}") @PreAuthorize("hasAuthority(#app + ':notification:view')") + public ApiResponse detail(@PathVariable String app,@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){String code=guard(app,user);return ApiResponse.success(service.detail(user.tenantId(),user.membershipId(),code,id));} + @PutMapping("/{id:\\d+}/read") @PreAuthorize("hasAuthority(#app + ':notification:read')") + public ApiResponse read(@PathVariable String app,@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){String code=guard(app,user);return ApiResponse.success(service.read(user.tenantId(),user.membershipId(),code,id));} + @PutMapping("/read-all") @PreAuthorize("hasAuthority(#app + ':notification:read-all')") + public ApiResponse readAll(@PathVariable String app,@AuthenticationPrincipal AuthenticatedUser user){String code=guard(app,user);return ApiResponse.success(service.readAll(user.tenantId(),user.membershipId(),code));} + private String guard(String app,AuthenticatedUser user){String code=switch(app){case"platform"->"PLATFORM";case"merchant"->"MERCHANT";default->throw new BizException(ResultCode.BAD_REQUEST,"不支持的应用端");};TenantGuard.tenant(user,code);return code;} +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java b/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java new file mode 100644 index 000000000..300cb1377 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java @@ -0,0 +1,39 @@ +package com.baiyue.iam.notification.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.iam.notification.dto.NotificationDtos.*; +import com.baiyue.iam.notification.service.NotificationService; +import com.baiyue.iam.service.TenantGuard; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController @Validated @RequestMapping("/api/platform/v1") +public class PlatformNotificationController { + @Resource private NotificationService service; + @PostMapping("/notifications") @PreAuthorize("hasAuthority('platform:notification:create')") + public ApiResponse create(@Valid @RequestBody CreateNotificationRequest request,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.create(request,user.membershipId()));} + @GetMapping("/notification-templates") @PreAuthorize("hasAuthority('platform:notification:template:list')") + public ApiResponse> templates(@RequestParam(defaultValue="1")@Min(1)long page,@RequestParam(defaultValue="20")@Min(1)@Max(200)long size,@RequestParam(required=false)String templateCode,@RequestParam(required=false)String category,@RequestParam(required=false)String title,@RequestParam(required=false)@Pattern(regexp="DRAFT|ACTIVE|DISABLED")String status,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.templates(page,size,templateCode,category,title,status));} + @PostMapping("/notification-templates") @PreAuthorize("hasAuthority('platform:notification:template:create')") + public ApiResponse createTemplate(@Valid@RequestBody CreateTemplateRequest request,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.createTemplate(request,user.membershipId()));} + @PutMapping("/notification-templates/{id:\\d+}") @PreAuthorize("hasAuthority('platform:notification:template:edit')") + public ApiResponse updateTemplate(@PathVariable Long id,@Valid@RequestBody UpdateTemplateRequest request,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.updateTemplate(id,request,user.membershipId()));} + @PutMapping("/notification-templates/{id:\\d+}/status") @PreAuthorize("hasAuthority('platform:notification:template:status')") + public ApiResponse status(@PathVariable Long id,@Valid@RequestBody ChangeTemplateStatusRequest request,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.changeTemplateStatus(id,request,user.membershipId()));} + @GetMapping("/notification-tasks") @PreAuthorize("hasAuthority('platform:notification:task:list')") + public ApiResponse> tasks(@RequestParam(defaultValue="1")@Min(1)long page,@RequestParam(defaultValue="20")@Min(1)@Max(200)long size,@RequestParam(required=false)String taskNo,@RequestParam(required=false)String category,@RequestParam(required=false)String title,@RequestParam(required=false)@Pattern(regexp="CREATED|SCHEDULED|DISPATCHING|RETRYING|SENT|DEAD|CANCELLED")String status,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.tasks(page,size,taskNo,category,title,status));} + @GetMapping("/notification-tasks/{id:\\d+}") @PreAuthorize("hasAuthority('platform:notification:task:list')") + public ApiResponse taskDetail(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.taskDetail(id));} + @PutMapping("/notification-tasks/{id:\\d+}/cancel") @PreAuthorize("hasAuthority('platform:notification:task:cancel')") public ApiResponse cancel(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.cancel(id));} + @PutMapping("/notification-tasks/{id:\\d+}/retry") @PreAuthorize("hasAuthority('platform:notification:task:retry')") public ApiResponse retry(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.retry(id));} + private void platform(AuthenticatedUser user){TenantGuard.tenant(user,"PLATFORM");} +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java b/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java new file mode 100644 index 000000000..5e138668e --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java @@ -0,0 +1,80 @@ +package com.baiyue.iam.notification.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Pattern; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +public final class NotificationDtos { + private NotificationDtos() { } + + public enum RecipientType { USER, MEMBERSHIP, TENANT_ROLE, TENANT_ALL, PLATFORM_ROLE } + + public record CreateNotificationRequest( + @NotNull Long tenantId, + @NotBlank @Size(max = 64) String templateCode, + @NotNull RecipientType recipientType, + @Size(max = 1000) List recipientIds, + @Size(max = 64) String roleCode, + @NotNull @Size(max = 32) Map variables, + @Future LocalDateTime scheduledAt, + @NotBlank @Size(min = 8, max = 128) String idempotencyKey, + @Size(max = 128) String sourceEventId, + @JsonIgnore @Size(max = 128) String sourceEventType) { } + + public record NotificationSummary(Long id, String category, String title, String summary, String status, + LocalDateTime sentAt, LocalDateTime readAt) { } + + public record NotificationDetail(Long id, String category, String title, String summary, String body, + String status, String sourceType, String sourceId, + LocalDateTime sentAt, LocalDateTime readAt) { } + + public record NotificationTaskResponse(Long id, String taskNo, String status, int recipientCount, + LocalDateTime scheduledAt, LocalDateTime sentAt, + boolean duplicated) { } + + public record UnreadCountResponse(long unreadCount) { } + public record ReadAllResponse(long updatedCount) { } + + public record CreateTemplateRequest( + @NotBlank @Pattern(regexp = "^[A-Z][A-Z0-9_]{0,63}$") String templateCode, + @NotBlank @Size(max = 128) String templateName, + @NotBlank @Size(max = 64) String category, + @NotBlank @Size(max = 256) String titleTemplate, + @NotBlank @Size(max = 10000) String bodyTemplate, + @NotNull @Size(max = 32) List<@Pattern(regexp = "^[a-z][a-z0-9_]{0,31}$") String> variableWhitelist) { } + + public record UpdateTemplateRequest( + @NotBlank @Size(max = 128) String templateName, + @NotBlank @Size(max = 64) String category, + @NotBlank @Size(max = 256) String titleTemplate, + @NotBlank @Size(max = 10000) String bodyTemplate, + @NotNull @Size(max = 32) List<@Pattern(regexp = "^[a-z][a-z0-9_]{0,31}$") String> variableWhitelist, + @NotNull Integer version) { } + + public record ChangeTemplateStatusRequest( + @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String targetStatus, + @NotNull Integer version) { } + + public record TemplateResponse(Long id, String templateCode, String templateName, String category, + String channel, String titleTemplate, String bodyTemplate, + List variableWhitelist, String status, Integer version) { } + + public record TaskListItem(Long id, String taskNo, String category, String title, String status, + int recipientCount, LocalDateTime scheduledAt, LocalDateTime sentAt, + boolean duplicated) { } + + public record TaskRecipientItem(Long id, String displayName, String appCode, String status, + String failureReason, LocalDateTime deliveredAt) { } + + public record TaskDetail(Long id, String taskNo, String category, String title, String status, + int recipientCount, int attemptCount, LocalDateTime scheduledAt, + LocalDateTime sentAt, String lastError, + List recipients) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationEventInbox.java b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationEventInbox.java new file mode 100644 index 000000000..5cc9ed414 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationEventInbox.java @@ -0,0 +1,12 @@ +package com.baiyue.iam.notification.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("iam_notification_event_inbox") +public class NotificationEventInbox { + @TableId private Long id; private String sourceEventId; private String eventType; private String payloadHash; + private Long taskId; private String status; private String lastError; private LocalDateTime processedAt; + private LocalDateTime createdAt; private LocalDateTime updatedAt; +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationInbox.java b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationInbox.java new file mode 100644 index 000000000..94959aa38 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationInbox.java @@ -0,0 +1,29 @@ +package com.baiyue.iam.notification.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("iam_notification_inbox") +public class NotificationInbox { + @TableId private Long id; + private Long taskId; + private Long recipientId; + private Long tenantId; + private Long membershipId; + private String appCode; + private String category; + private String title; + private String body; + private String sourceType; + private String sourceId; + private String status; + private LocalDateTime sentAt; + private LocalDateTime readAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationRecipient.java b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationRecipient.java new file mode 100644 index 000000000..83f508ccf --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationRecipient.java @@ -0,0 +1,26 @@ +package com.baiyue.iam.notification.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("iam_notification_recipient") +public class NotificationRecipient { + @TableId private Long id; + private Long taskId; + private Long tenantId; + private Long membershipId; + private Long userId; + private String displayName; + private String appCode; + private String channel; + private String status; + private String failureReason; + private LocalDateTime deliveredAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTask.java b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTask.java new file mode 100644 index 000000000..44488ff5a --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTask.java @@ -0,0 +1,35 @@ +package com.baiyue.iam.notification.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("iam_notification_task") +public class NotificationTask { + @TableId private Long id; + private String taskNo; + private Long tenantId; + private Long templateId; + private String templateCode; + private String category; + private String title; + private String body; + private String recipientType; + private String variablesJson; + private String idempotencyKey; + private String sourceEventId; + private String sourceEventType; + private String status; + private Integer recipientCount; + private Integer attemptCount; + private LocalDateTime scheduledAt; + private LocalDateTime sentAt; + private String lastError; + private Long createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTemplate.java b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTemplate.java new file mode 100644 index 000000000..e772f9a4e --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTemplate.java @@ -0,0 +1,28 @@ +package com.baiyue.iam.notification.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("iam_notification_template") +public class NotificationTemplate { + @TableId private Long id; + private Long tenantId; + private String templateCode; + private String templateName; + private String category; + private String channel; + private String titleTemplate; + private String bodyTemplate; + private String variableWhitelistJson; + private String status; + private Integer version; + private Long createdBy; + private Long updatedBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationEventInboxMapper.java b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationEventInboxMapper.java new file mode 100644 index 000000000..2e8fcffc2 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationEventInboxMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.iam.notification.mapper; +import com.baiyue.iam.notification.entity.NotificationEventInbox; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface NotificationEventInboxMapper extends BaseMapper { + @Insert(""" + INSERT IGNORE INTO iam_notification_event_inbox + (id, source_event_id, event_type, payload_hash, task_id, status, last_error, + processed_at, created_at, updated_at) + VALUES + (#{id}, #{sourceEventId}, #{eventType}, #{payloadHash}, #{taskId}, #{status}, #{lastError}, + #{processedAt}, #{createdAt}, #{updatedAt}) + """) + int insertIfAbsent(NotificationEventInbox value); +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationInboxMapper.java b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationInboxMapper.java new file mode 100644 index 000000000..7bc70d115 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationInboxMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.iam.notification.mapper; +import com.baiyue.iam.notification.entity.NotificationInbox; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface NotificationInboxMapper extends BaseMapper { } diff --git a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationRecipientMapper.java b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationRecipientMapper.java new file mode 100644 index 000000000..5b002edc2 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationRecipientMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.iam.notification.mapper; +import com.baiyue.iam.notification.entity.NotificationRecipient; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface NotificationRecipientMapper extends BaseMapper { } diff --git a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java new file mode 100644 index 000000000..427b67e18 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java @@ -0,0 +1,113 @@ +package com.baiyue.iam.notification.mapper; +import com.baiyue.iam.notification.entity.NotificationTask; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Update; +import org.apache.ibatis.annotations.Param; +import java.time.LocalDateTime; +@Mapper public interface NotificationTaskMapper extends BaseMapper { + @Update(""" + UPDATE iam_notification_task + SET status = 'DISPATCHING', + attempt_count = attempt_count + 1, + updated_at = #{now} + WHERE id = #{taskId} + AND status = 'SCHEDULED' + AND scheduled_at <= #{now} + AND attempt_count < #{maxAttempts} + """) + int claimScheduled(@Param("taskId") Long taskId, + @Param("maxAttempts") int maxAttempts, + @Param("now") LocalDateTime now); + + @Update(""" + UPDATE iam_notification_task + SET status = 'DISPATCHING', + attempt_count = attempt_count + 1, + updated_at = #{now} + WHERE id = #{taskId} + AND status = 'RETRYING' + AND updated_at <= #{dueBefore} + AND attempt_count < #{maxAttempts} + """) + int claimRetrying(@Param("taskId") Long taskId, + @Param("maxAttempts") int maxAttempts, + @Param("dueBefore") LocalDateTime dueBefore, + @Param("now") LocalDateTime now); + + @Update(""" + UPDATE iam_notification_task task + SET task.status = 'DEAD', + task.last_error = 'DISPATCH_TIMEOUT_WITH_DELIVERY_EVIDENCE', + task.updated_at = #{now} + WHERE task.status = 'DISPATCHING' + AND task.updated_at <= #{staleBefore} + AND task.sent_at IS NULL + AND ( + EXISTS ( + SELECT 1 FROM iam_notification_recipient recipient + WHERE recipient.task_id = task.id AND recipient.status = 'DELIVERED' + ) + OR EXISTS ( + SELECT 1 FROM iam_notification_inbox inbox WHERE inbox.task_id = task.id + ) + ) + """) + int deadTimedOutDispatchingWithDeliveryEvidence(@Param("staleBefore") LocalDateTime staleBefore, + @Param("now") LocalDateTime now); + + @Update(""" + UPDATE iam_notification_task task + SET task.status = 'DEAD', + task.last_error = 'DISPATCH_TIMEOUT_ATTEMPT_LIMIT', + task.updated_at = #{now} + WHERE task.status = 'DISPATCHING' + AND task.updated_at <= #{staleBefore} + AND task.sent_at IS NULL + AND task.attempt_count >= #{maxAttempts} + AND NOT EXISTS ( + SELECT 1 FROM iam_notification_recipient recipient + WHERE recipient.task_id = task.id AND recipient.status = 'DELIVERED' + ) + AND NOT EXISTS ( + SELECT 1 FROM iam_notification_inbox inbox WHERE inbox.task_id = task.id + ) + """) + int deadTimedOutDispatchingAtAttemptLimit(@Param("staleBefore") LocalDateTime staleBefore, + @Param("now") LocalDateTime now, + @Param("maxAttempts") int maxAttempts); + + @Update(""" + UPDATE iam_notification_task task + SET task.status = 'RETRYING', + task.last_error = 'DISPATCH_TIMEOUT_RECOVERED', + task.updated_at = #{now} + WHERE task.status = 'DISPATCHING' + AND task.updated_at <= #{staleBefore} + AND task.sent_at IS NULL + AND task.attempt_count < #{maxAttempts} + AND NOT EXISTS ( + SELECT 1 FROM iam_notification_recipient recipient + WHERE recipient.task_id = task.id AND recipient.status = 'DELIVERED' + ) + AND NOT EXISTS ( + SELECT 1 FROM iam_notification_inbox inbox WHERE inbox.task_id = task.id + ) + """) + int retryTimedOutDispatchingWithoutDeliveryEvidence(@Param("staleBefore") LocalDateTime staleBefore, + @Param("now") LocalDateTime now, + @Param("maxAttempts") int maxAttempts); + + @Update(""" + UPDATE iam_notification_task + SET status = CASE WHEN attempt_count >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRYING' END, + last_error = #{error}, + updated_at = #{now} + WHERE id = #{taskId} + AND status = 'DISPATCHING' + """) + int failDispatch(@Param("taskId") Long taskId, + @Param("maxAttempts") int maxAttempts, + @Param("error") String error, + @Param("now") LocalDateTime now); +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTemplateMapper.java b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTemplateMapper.java new file mode 100644 index 000000000..588e15664 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTemplateMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.iam.notification.mapper; +import com.baiyue.iam.notification.entity.NotificationTemplate; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface NotificationTemplateMapper extends BaseMapper { } diff --git a/iam/src/main/java/com/baiyue/iam/notification/mq/NotificationBusinessEventConsumer.java b/iam/src/main/java/com/baiyue/iam/notification/mq/NotificationBusinessEventConsumer.java new file mode 100644 index 000000000..7ce804b91 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/mq/NotificationBusinessEventConsumer.java @@ -0,0 +1,64 @@ +package com.baiyue.iam.notification.mq; + +import com.baiyue.iam.notification.dto.NotificationDtos.CreateNotificationRequest; +import com.baiyue.iam.notification.dto.NotificationDtos.RecipientType; +import com.baiyue.iam.notification.service.NotificationService; +import com.baiyue.iam.notification.service.NotificationService.EventClaim; +import com.baiyue.iam.notification.service.NotificationService.EventClaimStatus; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; +import org.apache.rocketmq.spring.core.RocketMQListener; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import java.util.List; +import java.util.HexFormat; +import java.util.Map; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import com.fasterxml.jackson.databind.SerializationFeature; + +@Component +@ConditionalOnProperty(name="baiyue.notification.mq-consumer-enabled",havingValue="true") +@RocketMQMessageListener(topic=NotificationMqContract.BUSINESS_EVENT_TOPIC, + selectorExpression=NotificationMqContract.BUSINESS_EVENT_TAGS, + consumerGroup="iam-notification-business-event-consumer") +public class NotificationBusinessEventConsumer implements RocketMQListener { + @Resource private ObjectMapper objectMapper; + @Resource private NotificationService service; + + @Override public void onMessage(String message){ + EventClaim claim = null; + try{ + NotificationMqContract.BusinessEvent event=objectMapper.readValue(message,NotificationMqContract.BusinessEvent.class); + if(event.sourceEventId()==null||event.sourceEventId().isBlank()||event.eventType()==null||event.eventType().isBlank()||event.tenantId()==null)throw new IllegalArgumentException("Missing event identity or customer scope"); + if(event.sourceEventId().length()>128||event.eventType().length()>128)throw new IllegalArgumentException("Event identity exceeds 128 characters"); + Map payload=event.payload(); + if(payload==null)throw new IllegalArgumentException("Missing payload"); + claim=service.beginBusinessEvent(event.sourceEventId(),event.eventType(),payloadHash(payload)); + if(claim.status()==EventClaimStatus.PROCESSED)return; + if(claim.status()==EventClaimStatus.PROCESSING)throw new IllegalStateException("NOTIFICATION_EVENT_PROCESSING"); + String template=required(payload,"templateCode");RecipientType type=RecipientType.valueOf(required(payload,"recipientType")); + List ids=objectMapper.convertValue(payload.getOrDefault("recipientIds",List.of()),new TypeReference<>(){}); + Map variables=objectMapper.convertValue(payload.getOrDefault("variables",Map.of()),new TypeReference<>(){}); + var task=service.create(new CreateNotificationRequest(event.tenantId(),template,type,ids,(String)payload.get("roleCode"),variables,null, + event.sourceEventId()+":"+event.eventType(),event.sourceEventId(),event.eventType()),0L); + service.completeBusinessEvent(claim,task.id()); + }catch(JsonProcessingException e){ + throw new IllegalArgumentException("Invalid notification business event",e); + }catch(RuntimeException e){ + if(claim!=null&&claim.status()==EventClaimStatus.ACQUIRED){ + try{service.failBusinessEvent(claim,e);}catch(RuntimeException auditFailure){e.addSuppressed(auditFailure);} + } + throw e; + } + } + private String required(Map payload,String key){Object value=payload.get(key);if(value==null||value.toString().isBlank())throw new IllegalArgumentException("Missing "+key);return value.toString();} + private String payloadHash(Map payload) throws JsonProcessingException { + byte[] canonical=objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(payload); + try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));} + catch(NoSuchAlgorithmException e){throw new IllegalStateException("SHA-256 unavailable",e);} + } +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/mq/NotificationMqContract.java b/iam/src/main/java/com/baiyue/iam/notification/mq/NotificationMqContract.java new file mode 100644 index 000000000..561576eda --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/mq/NotificationMqContract.java @@ -0,0 +1,10 @@ +package com.baiyue.iam.notification.mq; +import java.time.LocalDateTime; +import java.util.Map; +public final class NotificationMqContract { + public static final String NOTIFICATION_TOPIC="baiyue.iam.notification"; + public static final String BUSINESS_EVENT_TOPIC="baiyue.business.event"; + public static final String BUSINESS_EVENT_TAGS="order.status.changed||inbound.review.result||finance.fee.review.result||system.announcement"; + private NotificationMqContract(){} + public record BusinessEvent(String eventId,String sourceEventId,Long tenantId,String aggregateType,String aggregateId,String eventType,LocalDateTime occurredAt,Map payload){} +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java b/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java new file mode 100644 index 000000000..24431b598 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java @@ -0,0 +1,130 @@ +package com.baiyue.iam.notification.repository; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.iam.notification.entity.*; +import com.baiyue.iam.notification.mapper.*; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; +import java.util.List; + +@Repository +public class NotificationRepository { + @Resource private NotificationTemplateMapper templates; + @Resource private NotificationTaskMapper tasks; + @Resource private NotificationRecipientMapper recipients; + @Resource private NotificationInboxMapper inbox; + @Resource private NotificationEventInboxMapper eventInbox; + + public NotificationTemplate templateByCode(long tenantId, String code) { + return templates.selectOne(new LambdaQueryWrapper().eq(NotificationTemplate::getTenantId, tenantId) + .eq(NotificationTemplate::getTemplateCode, code).last("LIMIT 1")); + } + public NotificationTemplate template(Long id) { return templates.selectById(id); } + public void insertTemplate(NotificationTemplate value) { templates.insert(value); } + public void saveTemplate(NotificationTemplate value) { templates.updateById(value); } + public PageResult pageTemplates(long page,long size,String code,String category,String title,String status) { + var query=new LambdaQueryWrapper().eq(NotificationTemplate::getTenantId,0L) + .like(StringUtils.hasText(code),NotificationTemplate::getTemplateCode,code) + .eq(StringUtils.hasText(category),NotificationTemplate::getCategory,category) + .eq(StringUtils.hasText(status),NotificationTemplate::getStatus,status).orderByDesc(NotificationTemplate::getUpdatedAt); + if(StringUtils.hasText(title)) query.and(q->q.like(NotificationTemplate::getTemplateName,title).or().like(NotificationTemplate::getTitleTemplate,title)); + return PageResult.from(templates.selectPage(new Page<>(page,size),query)); + } + public NotificationTask taskByIdempotency(String key) { return tasks.selectOne(new LambdaQueryWrapper().eq(NotificationTask::getIdempotencyKey,key).last("LIMIT 1")); } + public NotificationTask task(Long id) { return tasks.selectById(id); } + public void insertTask(NotificationTask value) { tasks.insert(value); } + public void saveTask(NotificationTask value) { tasks.updateById(value); } + public PageResult pageTasks(long page,long size,String taskNo,String category,String title,String status) { + return PageResult.from(tasks.selectPage(new Page<>(page,size),new LambdaQueryWrapper() + .like(StringUtils.hasText(taskNo),NotificationTask::getTaskNo,taskNo) + .eq(StringUtils.hasText(category),NotificationTask::getCategory,category) + .like(StringUtils.hasText(title),NotificationTask::getTitle,title) + .eq(StringUtils.hasText(status),NotificationTask::getStatus,status) + .orderByDesc(NotificationTask::getCreatedAt))); + } + public void insertRecipient(NotificationRecipient value) { recipients.insert(value); } + public List recipients(Long taskId) { return recipients.selectList(new LambdaQueryWrapper().eq(NotificationRecipient::getTaskId,taskId)); } + public void saveRecipient(NotificationRecipient value) { recipients.updateById(value); } + public List dueTasks(java.time.LocalDateTime now) { return tasks.selectList(new LambdaQueryWrapper() + .eq(NotificationTask::getStatus,"SCHEDULED").le(NotificationTask::getScheduledAt,now).orderByAsc(NotificationTask::getScheduledAt).last("LIMIT 100")); } + public boolean claimScheduled(Long taskId,int maxAttempts,java.time.LocalDateTime now) { + return tasks.claimScheduled(taskId,maxAttempts,now)==1; + } + public int recoverTimedOutDispatching(java.time.LocalDateTime staleBefore,java.time.LocalDateTime now,int maxAttempts) { + int dead = tasks.deadTimedOutDispatchingWithDeliveryEvidence(staleBefore,now); + dead += tasks.deadTimedOutDispatchingAtAttemptLimit(staleBefore,now,maxAttempts); + return dead + tasks.retryTimedOutDispatchingWithoutDeliveryEvidence(staleBefore,now,maxAttempts); + } + public List retryingTasks(java.time.LocalDateTime dueBefore,int maxAttempts) { return tasks.selectList(new LambdaQueryWrapper() + .eq(NotificationTask::getStatus,"RETRYING").lt(NotificationTask::getAttemptCount,maxAttempts) + .le(NotificationTask::getUpdatedAt,dueBefore).orderByAsc(NotificationTask::getUpdatedAt).last("LIMIT 100")); } + public boolean claimRetrying(Long taskId,int maxAttempts,java.time.LocalDateTime dueBefore,java.time.LocalDateTime now) { + return tasks.claimRetrying(taskId,maxAttempts,dueBefore,now)==1; + } + public boolean completeDispatch(Long taskId,java.time.LocalDateTime now) { return tasks.update(null,new LambdaUpdateWrapper() + .eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"DISPATCHING") + .set(NotificationTask::getStatus,"SENT").set(NotificationTask::getSentAt,now) + .set(NotificationTask::getLastError,null).set(NotificationTask::getUpdatedAt,now))==1; } + public boolean failDispatch(Long taskId,int maxAttempts,String error,java.time.LocalDateTime now) { + return tasks.failDispatch(taskId,maxAttempts,error,now)==1; + } + public boolean requeueForManualRetry(Long taskId,java.time.LocalDateTime now) { return tasks.update(null,new LambdaUpdateWrapper() + .eq(NotificationTask::getId,taskId).in(NotificationTask::getStatus,"RETRYING","DEAD") + .set(NotificationTask::getStatus,"RETRYING").set(NotificationTask::getAttemptCount,0) + .set(NotificationTask::getLastError,null).set(NotificationTask::getSentAt,null) + .set(NotificationTask::getUpdatedAt,now))==1; } + public void insertInbox(NotificationInbox value) { inbox.insert(value); } + public boolean markRecipientDelivered(Long recipientId,java.time.LocalDateTime now) { return recipients.update(null,new LambdaUpdateWrapper() + .eq(NotificationRecipient::getId,recipientId).ne(NotificationRecipient::getStatus,"DELIVERED") + .set(NotificationRecipient::getStatus,"DELIVERED").set(NotificationRecipient::getFailureReason,null) + .set(NotificationRecipient::getDeliveredAt,now).set(NotificationRecipient::getUpdatedAt,now))==1; } + public int markUndeliveredRecipientsFailed(Long taskId,String error,java.time.LocalDateTime now) { return recipients.update(null,new LambdaUpdateWrapper() + .eq(NotificationRecipient::getTaskId,taskId).ne(NotificationRecipient::getStatus,"DELIVERED") + .set(NotificationRecipient::getStatus,"FAILED").set(NotificationRecipient::getFailureReason,error) + .set(NotificationRecipient::getUpdatedAt,now)); } + public PageResult pageInbox(long tenantId,long membershipId,String app,long page,long size,String status,String category) { + return PageResult.from(inbox.selectPage(new Page<>(page,size), new LambdaQueryWrapper() + .eq(NotificationInbox::getTenantId,tenantId).eq(NotificationInbox::getMembershipId,membershipId) + .eq(NotificationInbox::getAppCode,app).eq(StringUtils.hasText(status),NotificationInbox::getStatus,status) + .eq(StringUtils.hasText(category),NotificationInbox::getCategory,category).orderByDesc(NotificationInbox::getSentAt))); + } + public NotificationInbox inbox(long tenantId,long membershipId,String app,Long id) { return inbox.selectOne(new LambdaQueryWrapper() + .eq(NotificationInbox::getTenantId,tenantId).eq(NotificationInbox::getMembershipId,membershipId).eq(NotificationInbox::getAppCode,app).eq(NotificationInbox::getId,id).last("LIMIT 1")); } + public long unread(long tenantId,long membershipId,String app) { return inbox.selectCount(new LambdaQueryWrapper() + .eq(NotificationInbox::getTenantId,tenantId).eq(NotificationInbox::getMembershipId,membershipId).eq(NotificationInbox::getAppCode,app).eq(NotificationInbox::getStatus,"UNREAD")); } + public void saveInbox(NotificationInbox value) { inbox.updateById(value); } + public List unreadItems(long tenantId,long membershipId,String app) { return inbox.selectList(new LambdaQueryWrapper() + .eq(NotificationInbox::getTenantId,tenantId).eq(NotificationInbox::getMembershipId,membershipId).eq(NotificationInbox::getAppCode,app).eq(NotificationInbox::getStatus,"UNREAD")); } + public boolean insertEventIfAbsent(NotificationEventInbox value) { return eventInbox.insertIfAbsent(value) == 1; } + public NotificationEventInbox event(String sourceEventId,String eventType) { return eventInbox.selectOne(new LambdaQueryWrapper() + .eq(NotificationEventInbox::getSourceEventId,sourceEventId).eq(NotificationEventInbox::getEventType,eventType).last("LIMIT 1")); } + public boolean claimEvent(NotificationEventInbox value,java.time.LocalDateTime staleBefore,java.time.LocalDateTime claimedAt) { + boolean claimed = eventInbox.update(null,new LambdaUpdateWrapper() + .eq(NotificationEventInbox::getId,value.getId()).eq(NotificationEventInbox::getPayloadHash,value.getPayloadHash()) + .and(q -> q.eq(NotificationEventInbox::getStatus,"FAILED").or(nested -> nested + .eq(NotificationEventInbox::getStatus,"PROCESSING").le(NotificationEventInbox::getUpdatedAt,staleBefore))) + .set(NotificationEventInbox::getStatus,"PROCESSING").set(NotificationEventInbox::getLastError,null) + .set(NotificationEventInbox::getProcessedAt,null).set(NotificationEventInbox::getUpdatedAt,claimedAt)) == 1; + if (claimed) value.setUpdatedAt(claimedAt); + return claimed; + } + public boolean completeEvent(Long id,String payloadHash,java.time.LocalDateTime claimToken,Long taskId,java.time.LocalDateTime now) { + return eventInbox.update(null,new LambdaUpdateWrapper() + .eq(NotificationEventInbox::getId,id).eq(NotificationEventInbox::getPayloadHash,payloadHash) + .eq(NotificationEventInbox::getStatus,"PROCESSING").eq(NotificationEventInbox::getUpdatedAt,claimToken) + .set(NotificationEventInbox::getTaskId,taskId).set(NotificationEventInbox::getStatus,"PROCESSED") + .set(NotificationEventInbox::getLastError,null).set(NotificationEventInbox::getProcessedAt,now) + .set(NotificationEventInbox::getUpdatedAt,now)) == 1; + } + public boolean failEvent(Long id,String payloadHash,java.time.LocalDateTime claimToken,String error,java.time.LocalDateTime now) { + return eventInbox.update(null,new LambdaUpdateWrapper() + .eq(NotificationEventInbox::getId,id).eq(NotificationEventInbox::getPayloadHash,payloadHash) + .eq(NotificationEventInbox::getStatus,"PROCESSING").eq(NotificationEventInbox::getUpdatedAt,claimToken) + .set(NotificationEventInbox::getStatus,"FAILED").set(NotificationEventInbox::getLastError,error) + .set(NotificationEventInbox::getProcessedAt,now).set(NotificationEventInbox::getUpdatedAt,now)) == 1; + } +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationDispatchTransactionService.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationDispatchTransactionService.java new file mode 100644 index 000000000..3d8bc076d --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationDispatchTransactionService.java @@ -0,0 +1,74 @@ +package com.baiyue.iam.notification.service; + +import com.baiyue.iam.notification.entity.NotificationInbox; +import com.baiyue.iam.notification.entity.NotificationRecipient; +import com.baiyue.iam.notification.entity.NotificationTask; +import com.baiyue.iam.notification.repository.NotificationRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +public class NotificationDispatchTransactionService { + static final int MAX_DISPATCH_ATTEMPTS = 3; + static final long RETRY_DELAY_SECONDS = 30; + + @Resource private NotificationRepository repository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean claimScheduled(Long taskId) { + return repository.claimScheduled(taskId, MAX_DISPATCH_ATTEMPTS, LocalDateTime.now()); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean claimRetrying(Long taskId) { + LocalDateTime now = LocalDateTime.now(); + return repository.claimRetrying(taskId, MAX_DISPATCH_ATTEMPTS, + now.minusSeconds(RETRY_DELAY_SECONDS), now); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void deliverClaimed(Long taskId) { + NotificationTask task = repository.task(taskId); + if (task == null || !"DISPATCHING".equals(task.getStatus())) { + throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST"); + } + LocalDateTime now = LocalDateTime.now(); + for (NotificationRecipient recipient : repository.recipients(taskId)) { + if ("DELIVERED".equals(recipient.getStatus())) continue; + NotificationInbox item = new NotificationInbox(); + item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setRecipientId(recipient.getId()); + item.setTenantId(recipient.getTenantId()); item.setMembershipId(recipient.getMembershipId()); + item.setAppCode(recipient.getAppCode()); item.setCategory(task.getCategory()); item.setTitle(task.getTitle()); + item.setBody(task.getBody()); item.setSourceType(task.getSourceEventType()); item.setSourceId(task.getSourceEventId()); + item.setStatus("UNREAD"); item.setSentAt(now); item.setCreatedAt(now); item.setUpdatedAt(now); + repository.insertInbox(item); + if (!repository.markRecipientDelivered(recipient.getId(), now)) { + throw new IllegalStateException("NOTIFICATION_RECIPIENT_DELIVERY_STATE_LOST"); + } + } + if (!repository.completeDispatch(taskId, now)) { + throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST"); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordFailure(Long taskId, Throwable failure) { + LocalDateTime now = LocalDateTime.now(); + String error = boundedError(failure); + repository.markUndeliveredRecipientsFailed(taskId, error, now); + if (!repository.failDispatch(taskId, MAX_DISPATCH_ATTEMPTS, error, now)) { + throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST"); + } + } + + private String boundedError(Throwable failure) { + String message = failure.getMessage(); + String error = message == null || message.isBlank() ? failure.getClass().getSimpleName() : message; + return error.length() <= 1000 ? error : error.substring(0, 1000); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationRecipientResolver.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationRecipientResolver.java new file mode 100644 index 000000000..b38c2b733 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationRecipientResolver.java @@ -0,0 +1,79 @@ +package com.baiyue.iam.notification.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamMembershipMapper; +import com.baiyue.iam.mapper.IamMembershipRoleMapper; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.mapper.IamUserMapper; +import com.baiyue.iam.notification.dto.NotificationDtos.CreateNotificationRequest; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +public class NotificationRecipientResolver { + @Resource private IamMembershipMapper memberships; + @Resource private IamUserMapper users; + @Resource private IamRoleMapper roles; + @Resource private IamMembershipRoleMapper membershipRoles; + + public List resolve(CreateNotificationRequest request) { + validateSelector(request); + List selected = switch (request.recipientType()) { + case MEMBERSHIP -> memberships.selectList(new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, request.tenantId()).in(IamMembership::getId, request.recipientIds())); + case USER -> memberships.selectList(new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, request.tenantId()).in(IamMembership::getUserId, request.recipientIds())); + case TENANT_ALL -> memberships.selectList(new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, request.tenantId())); + case TENANT_ROLE, PLATFORM_ROLE -> byRole(request); + }; + Map unique = new LinkedHashMap<>(); + selected.stream().filter(item -> "ACTIVE".equals(item.getStatus())).forEach(item -> { + IamUser user = users.selectById(item.getUserId()); + if (user != null && "ACTIVE".equals(user.getStatus())) unique.put(item.getId(), new ResolvedRecipient( + item.getTenantId(), item.getId(), item.getUserId(), user.getDisplayName(), item.getTenantId() == 0 ? "PLATFORM" : "MERCHANT")); + }); + if (unique.isEmpty()) { + if (request.recipientType() == com.baiyue.iam.notification.dto.NotificationDtos.RecipientType.TENANT_ROLE + || request.recipientType() == com.baiyue.iam.notification.dto.NotificationDtos.RecipientType.PLATFORM_ROLE) { + fail("该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员"); + } + fail("未找到可接收消息的有效成员,请检查用户 ID 和目标企业"); + } + return List.copyOf(unique.values()); + } + + private List byRole(CreateNotificationRequest request) { + var role = roles.selectOne(new LambdaQueryWrapper() + .eq(com.baiyue.iam.entity.IamRole::getTenantId, request.tenantId()) + .eq(com.baiyue.iam.entity.IamRole::getRoleCode, request.roleCode()).eq(com.baiyue.iam.entity.IamRole::getStatus,"ACTIVE").last("LIMIT 1")); + if (role == null) fail("角色编码不存在,请检查角色编码是否正确"); + List ids = membershipRoles.selectList(new LambdaQueryWrapper() + .eq(com.baiyue.iam.entity.IamMembershipRole::getTenantId,request.tenantId()) + .eq(com.baiyue.iam.entity.IamMembershipRole::getRoleId,role.getId())).stream() + .map(com.baiyue.iam.entity.IamMembershipRole::getMembershipId).toList(); + return ids.isEmpty()?List.of():memberships.selectBatchIds(ids); + } + + private void validateSelector(CreateNotificationRequest request) { + boolean ids = request.recipientIds()!=null&&!request.recipientIds().isEmpty(); + boolean role = request.roleCode()!=null&&!request.roleCode().isBlank(); + switch(request.recipientType()) { + case USER, MEMBERSHIP -> { if(!ids||role) fail("NOTIFICATION_RECIPIENT_FORBIDDEN"); } + case TENANT_ROLE, PLATFORM_ROLE -> { if(ids||!role) fail("NOTIFICATION_RECIPIENT_FORBIDDEN"); } + case TENANT_ALL -> { if(ids||role) fail("NOTIFICATION_RECIPIENT_FORBIDDEN"); } + } + if(request.recipientType()==com.baiyue.iam.notification.dto.NotificationDtos.RecipientType.PLATFORM_ROLE&&request.tenantId()!=0) fail("NOTIFICATION_RECIPIENT_FORBIDDEN"); + if(request.recipientType()!=com.baiyue.iam.notification.dto.NotificationDtos.RecipientType.PLATFORM_ROLE&&request.tenantId()<0) fail("NOTIFICATION_RECIPIENT_FORBIDDEN"); + } + private void fail(String message){throw new BizException(ResultCode.VALIDATION_FAILED,message);} + public record ResolvedRecipient(long tenantId,long membershipId,long userId,String displayName,String appCode){} +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationScheduler.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationScheduler.java new file mode 100644 index 000000000..813cd81cd --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationScheduler.java @@ -0,0 +1,29 @@ +package com.baiyue.iam.notification.service; +import jakarta.annotation.Resource; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.List; +import java.util.function.Function; +@Component @ConditionalOnProperty(name="baiyue.notification.scheduler-enabled",havingValue="true") +public class NotificationScheduler { + private static final Logger log = LoggerFactory.getLogger(NotificationScheduler.class); + @Resource private NotificationService service; + @Value("${baiyue.notification.dispatch-timeout-seconds:300}") private long dispatchTimeoutSeconds; + @Scheduled(fixedDelayString="${baiyue.notification.scheduler-delay-ms:5000}") public void dispatch(){ + service.recoverTimedOutDispatching(dispatchTimeoutSeconds); + dispatchTasks("scheduled", service.dueScheduledTaskIds(), service::dispatchScheduled); + dispatchTasks("retrying", service.retryingTaskIds(), service::dispatchRetrying); + } + private void dispatchTasks(String source, List taskIds, Function dispatcher) { + for (Long taskId : taskIds) { + try { dispatcher.apply(taskId); } + catch (RuntimeException failure) { + log.error("Notification {} task dispatch failed; taskId={}", source, taskId, failure); + } + } + } +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java new file mode 100644 index 000000000..4a7285aac --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java @@ -0,0 +1,381 @@ +package com.baiyue.iam.notification.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.notification.dto.NotificationDtos.*; +import com.baiyue.iam.notification.entity.*; +import com.baiyue.iam.notification.repository.NotificationRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class NotificationService { + private static final long EVENT_PROCESSING_TIMEOUT_MINUTES = 5; + private static final int INBOX_SUMMARY_MAX_CODE_POINTS = 120; + + public enum EventClaimStatus { ACQUIRED, PROCESSED, PROCESSING } + public record EventClaim(NotificationEventInbox event, EventClaimStatus status) { } + + @Resource private NotificationRepository repository; + @Resource private NotificationTemplateRenderer renderer; + @Resource private NotificationRecipientResolver resolver; + @Resource private ObjectMapper objectMapper; + @Resource private NotificationDispatchTransactionService dispatchTransactions; + + @Transactional(readOnly = true) + public PageResult inbox(long tenantId, long membershipId, String appCode, + long page, long size, String status, String category) { + PageResult source = repository.pageInbox( + tenantId, membershipId, appCode, page, size, status, category); + return new PageResult<>(source.records().stream().map(this::summary).toList(), + source.page(), source.size(), source.total(), source.pages()); + } + + @Transactional(readOnly = true) + public NotificationDetail detail(long tenantId, long membershipId, String appCode, Long id) { + return detail(requiredInbox(tenantId, membershipId, appCode, id)); + } + + @Transactional(readOnly = true) + public UnreadCountResponse unread(long tenantId, long membershipId, String appCode) { + return new UnreadCountResponse(repository.unread(tenantId, membershipId, appCode)); + } + + @Transactional + @BusinessAudit(event = "NOTIFICATION_READ", resourceType = "NOTIFICATION", resourceId = "#id", + description = "读取站内信", captureBefore = false, captureAfter = false) + public NotificationDetail read(long tenantId, long membershipId, String appCode, Long id) { + NotificationInbox item = requiredInbox(tenantId, membershipId, appCode, id); + if ("UNREAD".equals(item.getStatus())) { + item.setStatus("READ"); + item.setReadAt(LocalDateTime.now()); + repository.saveInbox(item); + } + return detail(item); + } + + @Transactional + @BusinessAudit(event = "NOTIFICATION_READ_ALL", resourceType = "NOTIFICATION", + description = "全部标记已读", captureBefore = false, captureAfter = false) + public ReadAllResponse readAll(long tenantId, long membershipId, String appCode) { + List items = repository.unreadItems(tenantId, membershipId, appCode); + LocalDateTime now = LocalDateTime.now(); + items.forEach(item -> { + item.setStatus("READ"); + item.setReadAt(now); + repository.saveInbox(item); + }); + return new ReadAllResponse(items.size()); + } + + @Transactional + @BusinessAudit(event = "NOTIFICATION_TASK_CREATE", resourceType = "NOTIFICATION_TASK", + resourceId = "#request.idempotencyKey", description = "创建消息任务", captureAfter = false) + public TaskListItem create(CreateNotificationRequest request, long operator) { + NotificationTask existing = repository.taskByIdempotency(request.idempotencyKey()); + if (existing != null) return taskItem(existing, true); + NotificationTemplate template = repository.templateByCode(0, request.templateCode()); + if (template == null) fail(ResultCode.NOT_FOUND, "NOTIFICATION_TEMPLATE_NOT_FOUND"); + if (!"ACTIVE".equals(template.getStatus())) fail(ResultCode.CONFLICT, "NOTIFICATION_TEMPLATE_INACTIVE"); + Set whitelist = readWhitelist(template.getVariableWhitelistJson()); + String title = renderer.render(template.getTitleTemplate(), whitelist, request.variables()); + String body = renderer.render(template.getBodyTemplate(), whitelist, request.variables()); + List resolved = resolver.resolve(request); + LocalDateTime now = LocalDateTime.now(); + NotificationTask task = new NotificationTask(); + task.setId(IdWorker.getId()); task.setTaskNo("NT" + task.getId()); task.setTenantId(request.tenantId()); + task.setTemplateId(template.getId()); task.setTemplateCode(template.getTemplateCode()); + task.setCategory(template.getCategory()); task.setTitle(title); task.setBody(body); + task.setRecipientType(request.recipientType().name()); task.setVariablesJson(json(request.variables())); + task.setIdempotencyKey(request.idempotencyKey()); task.setSourceEventId(request.sourceEventId()); + task.setSourceEventType(request.sourceEventType()); + task.setStatus(request.scheduledAt() == null ? "DISPATCHING" : "SCHEDULED"); + task.setRecipientCount(resolved.size()); task.setAttemptCount(0); task.setScheduledAt(request.scheduledAt()); + task.setCreatedBy(operator); task.setCreatedAt(now); task.setUpdatedAt(now); + repository.insertTask(task); + snapshot(task, resolved, now); + if (request.scheduledAt() == null) deliver(task, now); + return taskItem(task, false); + } + + @Transactional + public EventClaim beginBusinessEvent(String sourceEventId, String eventType, String payloadHash) { + LocalDateTime now = eventNow(); + NotificationEventInbox created = new NotificationEventInbox(); + created.setId(IdWorker.getId()); created.setSourceEventId(sourceEventId); created.setEventType(eventType); + created.setPayloadHash(payloadHash); created.setStatus("PROCESSING"); + created.setCreatedAt(now); created.setUpdatedAt(now); + if (repository.insertEventIfAbsent(created)) return new EventClaim(created, EventClaimStatus.ACQUIRED); + + NotificationEventInbox existing = repository.event(sourceEventId, eventType); + if (existing == null) throw new IllegalStateException("NOTIFICATION_EVENT_REGISTRATION_RACE"); + if (!payloadHash.equals(existing.getPayloadHash())) + fail(ResultCode.CONFLICT, "NOTIFICATION_EVENT_PAYLOAD_CONFLICT"); + if ("PROCESSED".equals(existing.getStatus())) + return new EventClaim(existing, EventClaimStatus.PROCESSED); + LocalDateTime staleBefore = now.minusMinutes(EVENT_PROCESSING_TIMEOUT_MINUTES); + if (repository.claimEvent(existing, staleBefore, now)) + return new EventClaim(existing, EventClaimStatus.ACQUIRED); + return new EventClaim(existing, EventClaimStatus.PROCESSING); + } + + @Transactional + public void completeBusinessEvent(EventClaim claim, Long taskId) { + LocalDateTime now = eventNow(); + if (!repository.completeEvent(claim.event().getId(), claim.event().getPayloadHash(), + claim.event().getUpdatedAt(), taskId, now)) + throw new IllegalStateException("NOTIFICATION_EVENT_CLAIM_LOST"); + } + + @Transactional + public void failBusinessEvent(EventClaim claim, Throwable failure) { + String error = failure.getMessage() == null ? failure.getClass().getSimpleName() : failure.getMessage(); + if (error.length() > 1000) error = error.substring(0, 1000); + LocalDateTime now = eventNow(); + if (!repository.failEvent(claim.event().getId(), claim.event().getPayloadHash(), + claim.event().getUpdatedAt(), error, now)) + throw new IllegalStateException("NOTIFICATION_EVENT_CLAIM_LOST"); + } + + private LocalDateTime eventNow() { return LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS); } + + private void snapshot(NotificationTask task, List resolved, + LocalDateTime now) { + for (var value : resolved) { + NotificationRecipient recipient = new NotificationRecipient(); + recipient.setId(IdWorker.getId()); recipient.setTaskId(task.getId()); recipient.setTenantId(value.tenantId()); + recipient.setMembershipId(value.membershipId()); recipient.setUserId(value.userId()); + recipient.setDisplayName(value.displayName()); recipient.setAppCode(value.appCode()); + recipient.setChannel("IN_APP"); recipient.setStatus("RESOLVED"); + recipient.setCreatedAt(now); recipient.setUpdatedAt(now); + repository.insertRecipient(recipient); + } + } + + private void deliver(NotificationTask task, LocalDateTime now) { + for (NotificationRecipient recipient : repository.recipients(task.getId())) { + if ("DELIVERED".equals(recipient.getStatus())) continue; + NotificationInbox item = new NotificationInbox(); + item.setId(IdWorker.getId()); item.setTaskId(task.getId()); item.setRecipientId(recipient.getId()); + item.setTenantId(recipient.getTenantId()); item.setMembershipId(recipient.getMembershipId()); + item.setAppCode(recipient.getAppCode()); item.setCategory(task.getCategory()); item.setTitle(task.getTitle()); + item.setBody(task.getBody()); item.setSourceType(task.getSourceEventType()); item.setSourceId(task.getSourceEventId()); + item.setStatus("UNREAD"); item.setSentAt(now); item.setCreatedAt(now); item.setUpdatedAt(now); + repository.insertInbox(item); + recipient.setStatus("DELIVERED"); recipient.setDeliveredAt(now); repository.saveRecipient(recipient); + } + task.setStatus("SENT"); task.setSentAt(now); task.setUpdatedAt(now); repository.saveTask(task); + } + + @Transactional + public int recoverTimedOutDispatching(long timeoutSeconds) { + if (timeoutSeconds < 1) throw new IllegalArgumentException("Dispatch timeout must be positive"); + LocalDateTime now = eventNow(); + return repository.recoverTimedOutDispatching(now.minusSeconds(timeoutSeconds), now, + NotificationDispatchTransactionService.MAX_DISPATCH_ATTEMPTS); + } + + @Transactional(readOnly = true) + public List dueScheduledTaskIds() { + return repository.dueTasks(LocalDateTime.now()).stream().map(NotificationTask::getId).toList(); + } + + @Transactional(readOnly = true) + public List retryingTaskIds() { + LocalDateTime dueBefore = LocalDateTime.now() + .minusSeconds(NotificationDispatchTransactionService.RETRY_DELAY_SECONDS); + return repository.retryingTasks(dueBefore, NotificationDispatchTransactionService.MAX_DISPATCH_ATTEMPTS) + .stream().map(NotificationTask::getId).toList(); + } + + public boolean dispatchScheduled(Long taskId) { + return dispatch(taskId, dispatchTransactions.claimScheduled(taskId)); + } + + public boolean dispatchRetrying(Long taskId) { + return dispatch(taskId, dispatchTransactions.claimRetrying(taskId)); + } + + private boolean dispatch(Long taskId, boolean claimed) { + if (!claimed) return false; + try { + dispatchTransactions.deliverClaimed(taskId); + return true; + } catch (RuntimeException failure) { + try { + dispatchTransactions.recordFailure(taskId, failure); + } catch (RuntimeException persistenceFailure) { + failure.addSuppressed(persistenceFailure); + } + throw failure; + } + } + + @Transactional(readOnly = true) + public PageResult templates(long page, long size, String code, String category, + String title, String status) { + PageResult source = repository.pageTemplates(page, size, code, category, title, status); + return new PageResult<>(source.records().stream().map(this::template).toList(), + source.page(), source.size(), source.total(), source.pages()); + } + + @Transactional + @BusinessAudit(event = "NOTIFICATION_TEMPLATE_CREATE", resourceType = "NOTIFICATION_TEMPLATE", + resourceId = "#request.templateCode", description = "创建消息模板", captureAfter = false) + public TemplateResponse createTemplate(CreateTemplateRequest request, long operator) { + if (repository.templateByCode(0, request.templateCode()) != null) + fail(ResultCode.CONFLICT, "NOTIFICATION_TEMPLATE_CODE_EXISTS"); + Set whitelist = new LinkedHashSet<>(request.variableWhitelist()); + renderer.validateTemplate(request.titleTemplate(), request.bodyTemplate(), whitelist); + LocalDateTime now = LocalDateTime.now(); + NotificationTemplate template = new NotificationTemplate(); + template.setId(IdWorker.getId()); template.setTenantId(0L); template.setTemplateCode(request.templateCode()); + template.setTemplateName(request.templateName()); template.setCategory(request.category()); + template.setChannel("IN_APP"); template.setTitleTemplate(request.titleTemplate()); + template.setBodyTemplate(request.bodyTemplate()); template.setVariableWhitelistJson(json(whitelist)); + template.setStatus("DRAFT"); template.setVersion(1); template.setCreatedBy(operator); + template.setUpdatedBy(operator); template.setCreatedAt(now); template.setUpdatedAt(now); + repository.insertTemplate(template); + return template(template); + } + + @Transactional + @BusinessAudit(event = "NOTIFICATION_TEMPLATE_UPDATE", resourceType = "NOTIFICATION_TEMPLATE", + resourceId = "#id", description = "编辑消息模板", captureAfter = false) + public TemplateResponse updateTemplate(Long id, UpdateTemplateRequest request, long operator) { + NotificationTemplate template = requiredTemplate(id); + if (!"DRAFT".equals(template.getStatus()) || !template.getVersion().equals(request.version())) + fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE"); + Set whitelist = new LinkedHashSet<>(request.variableWhitelist()); + renderer.validateTemplate(request.titleTemplate(), request.bodyTemplate(), whitelist); + template.setTemplateName(request.templateName()); template.setCategory(request.category()); + template.setTitleTemplate(request.titleTemplate()); template.setBodyTemplate(request.bodyTemplate()); + template.setVariableWhitelistJson(json(whitelist)); template.setVersion(template.getVersion() + 1); + template.setUpdatedBy(operator); repository.saveTemplate(template); + return template(template); + } + + @Transactional + @BusinessAudit(event = "NOTIFICATION_TEMPLATE_STATUS", resourceType = "NOTIFICATION_TEMPLATE", + resourceId = "#id", description = "变更消息模板状态", captureAfter = false) + public TemplateResponse changeTemplateStatus(Long id, ChangeTemplateStatusRequest request, long operator) { + NotificationTemplate template = requiredTemplate(id); + boolean valid = "DRAFT".equals(template.getStatus()) && "ACTIVE".equals(request.targetStatus()) + || "ACTIVE".equals(template.getStatus()) && "DISABLED".equals(request.targetStatus()); + if (!valid || !template.getVersion().equals(request.version())) + fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE"); + template.setStatus(request.targetStatus()); template.setVersion(template.getVersion() + 1); + template.setUpdatedBy(operator); repository.saveTemplate(template); + return template(template); + } + + @Transactional(readOnly = true) + public PageResult tasks(long page, long size, String taskNo, String category, + String title, String status) { + PageResult source = repository.pageTasks(page, size, taskNo, category, title, status); + return new PageResult<>(source.records().stream().map(task -> taskItem(task, false)).toList(), + source.page(), source.size(), source.total(), source.pages()); + } + + @Transactional(readOnly = true) + public TaskDetail taskDetail(Long id) { + NotificationTask task = requiredTask(id); + List recipients = repository.recipients(id).stream() + .map(value -> new TaskRecipientItem(value.getId(), value.getDisplayName(), value.getAppCode(), + value.getStatus(), value.getFailureReason(), value.getDeliveredAt())) + .toList(); + return new TaskDetail(task.getId(), task.getTaskNo(), task.getCategory(), task.getTitle(), task.getStatus(), + task.getRecipientCount(), task.getAttemptCount() == null ? 0 : task.getAttemptCount(), + task.getScheduledAt(), task.getSentAt(), + task.getLastError(), recipients); + } + + @Transactional + @BusinessAudit(event = "NOTIFICATION_TASK_CANCEL", resourceType = "NOTIFICATION_TASK", + resourceId = "#id", description = "取消消息任务", captureAfter = false) + public TaskListItem cancel(Long id) { + NotificationTask task = requiredTask(id); + if (!Set.of("CREATED", "SCHEDULED").contains(task.getStatus())) + fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE"); + task.setStatus("CANCELLED"); repository.saveTask(task); + return taskItem(task, false); + } + + @Transactional + @BusinessAudit(event = "NOTIFICATION_TASK_RETRY", resourceType = "NOTIFICATION_TASK", + resourceId = "#id", description = "重试消息任务", captureAfter = false) + public TaskListItem retry(Long id) { + NotificationTask task = requiredTask(id); + if (!Set.of("RETRYING", "DEAD").contains(task.getStatus())) + fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE"); + LocalDateTime now = LocalDateTime.now(); + if (!repository.requeueForManualRetry(id, now)) fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE"); + task.setStatus("RETRYING"); task.setAttemptCount(0); task.setLastError(null); task.setUpdatedAt(now); + return taskItem(task, false); + } + + private NotificationInbox requiredInbox(long tenantId, long membershipId, String appCode, Long id) { + NotificationInbox value = repository.inbox(tenantId, membershipId, appCode, id); + if (value == null) fail(ResultCode.NOT_FOUND, "NOTIFICATION_NOT_FOUND"); + return value; + } + private NotificationTemplate requiredTemplate(Long id) { + NotificationTemplate value = repository.template(id); + if (value == null) fail(ResultCode.NOT_FOUND, "NOTIFICATION_TEMPLATE_NOT_FOUND"); + return value; + } + private NotificationTask requiredTask(Long id) { + NotificationTask value = repository.task(id); + if (value == null) fail(ResultCode.NOT_FOUND, "NOTIFICATION_NOT_FOUND"); + return value; + } + private NotificationSummary summary(NotificationInbox value) { + return new NotificationSummary(value.getId(), value.getCategory(), value.getTitle(), + boundedSummary(value.getBody()), value.getStatus(), value.getSentAt(), value.getReadAt()); + } + + private String boundedSummary(String body) { + if (body == null || body.isBlank()) return ""; + String compact = body.strip().replaceAll("\\s+", " "); + int count = compact.codePointCount(0, compact.length()); + if (count <= INBOX_SUMMARY_MAX_CODE_POINTS) return compact; + return compact.substring(0, compact.offsetByCodePoints(0, INBOX_SUMMARY_MAX_CODE_POINTS)); + } + private NotificationDetail detail(NotificationInbox value) { + return new NotificationDetail(value.getId(), value.getCategory(), value.getTitle(), + boundedSummary(value.getBody()), value.getBody(), value.getStatus(), value.getSourceType(), + value.getSourceId(), value.getSentAt(), value.getReadAt()); + } + private TemplateResponse template(NotificationTemplate value) { + return new TemplateResponse(value.getId(), value.getTemplateCode(), value.getTemplateName(), + value.getCategory(), value.getChannel(), value.getTitleTemplate(), value.getBodyTemplate(), + List.copyOf(readWhitelist(value.getVariableWhitelistJson())), value.getStatus(), value.getVersion()); + } + private TaskListItem taskItem(NotificationTask value, boolean duplicated) { + return new TaskListItem(value.getId(), value.getTaskNo(), value.getCategory(), value.getTitle(), + value.getStatus(), value.getRecipientCount(), value.getScheduledAt(), value.getSentAt(), duplicated); + } + private Set readWhitelist(String value) { + try { return objectMapper.readValue(value, new TypeReference>() { }); } + catch (JsonProcessingException exception) { throw new IllegalStateException(exception); } + } + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalArgumentException(exception); } + } + private void fail(ResultCode code, String message) { throw new BizException(code, message); } +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationTemplateRenderer.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationTemplateRenderer.java new file mode 100644 index 000000000..f05b8f7ea --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationTemplateRenderer.java @@ -0,0 +1,41 @@ +package com.baiyue.iam.notification.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class NotificationTemplateRenderer { + private static final Pattern VARIABLE = Pattern.compile("\\{\\{([a-z][a-z0-9_]{0,31})}}" ); + + public String render(String template, Set whitelist, Map variables) { + Set used = placeholders(template); + if (!whitelist.containsAll(used)) fail("NOTIFICATION_VARIABLE_NOT_ALLOWED"); + if (!whitelist.containsAll(variables.keySet())) fail("NOTIFICATION_VARIABLE_NOT_ALLOWED"); + if (!variables.keySet().containsAll(used)) fail("NOTIFICATION_VARIABLE_MISSING"); + String rendered = template; + for (String key : used) rendered = rendered.replace("{{" + key + "}}", variables.get(key)); + if (rendered.length() > 10_000) fail("NOTIFICATION_TEMPLATE_RENDER_TOO_LONG"); + return rendered; + } + + public void validateTemplate(String title, String body, Set whitelist) { + if (whitelist.size() > 32) fail("NOTIFICATION_VARIABLE_NOT_ALLOWED"); + Set used = new HashSet<>(placeholders(title)); used.addAll(placeholders(body)); + if (!whitelist.containsAll(used)) fail("NOTIFICATION_VARIABLE_NOT_ALLOWED"); + } + + private Set placeholders(String value) { + Set keys = new HashSet<>(); Matcher matcher = VARIABLE.matcher(value); + while (matcher.find()) keys.add(matcher.group(1)); + return keys; + } + + private void fail(String message) { throw new BizException(ResultCode.VALIDATION_FAILED, message); } +} diff --git a/iam/src/main/java/com/baiyue/iam/repository/CustomerCodeRepository.java b/iam/src/main/java/com/baiyue/iam/repository/CustomerCodeRepository.java new file mode 100644 index 000000000..8cca4e4d9 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/repository/CustomerCodeRepository.java @@ -0,0 +1,51 @@ +package com.baiyue.iam.repository; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.Resource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Locale; + +/** 在客户创建事务内串行分配固定五位客户编号。 */ +@Repository +public class CustomerCodeRepository { + private static final String SEQUENCE_KEY = "CUSTOMER_CODE"; + private static final long MAX_CUSTOMER_CODE = 99_999L; + + @Resource + private JdbcTemplate jdbc; + + @Transactional(propagation = Propagation.MANDATORY) + public String nextCode() { + jdbc.update("INSERT IGNORE INTO iam_number_sequence(sequence_key,next_value) VALUES (?,1)", + SEQUENCE_KEY); + Long value = jdbc.queryForObject( + "SELECT next_value FROM iam_number_sequence WHERE sequence_key=? FOR UPDATE", + Long.class, SEQUENCE_KEY); + if (value == null) { + throw new BizException(ResultCode.INTERNAL_ERROR, "客户编号序列状态异常"); + } + String code = format(value); + int updated = jdbc.update( + "UPDATE iam_number_sequence SET next_value=next_value+1 WHERE sequence_key=? AND next_value=?", + SEQUENCE_KEY, value); + if (updated != 1) { + throw new BizException(ResultCode.CONFLICT, "客户编号分配冲突,请重试"); + } + return code; + } + + static String format(long value) { + if (value < 1) { + throw new BizException(ResultCode.INTERNAL_ERROR, "客户编号序列状态异常"); + } + if (value > MAX_CUSTOMER_CODE) { + throw new BizException(ResultCode.CONFLICT, "五位客户编号已用完"); + } + return String.format(Locale.ROOT, "%05d", value); + } +} 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..358e728af --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java @@ -0,0 +1,126 @@ +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 task.tenant_id,task.operator_id,tenant.tenant_name," + + "task.initialization_key,task.action,task.status,task.attempt_count,task.task_version " + + "FROM iam_customer_dimension_initialization_task task " + + "JOIN iam_tenant tenant ON tenant.id=task.tenant_id " + + "WHERE task.tenant_id=? AND task.status='SENDING' AND task.lease_owner=?", + (rs, row) -> new CustomerDimensionInitializationTask( + rs.getLong("tenant_id"), rs.getLong("operator_id"), rs.getString("tenant_name"), + 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/repository/CustomerListExportProviderRepository.java b/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java new file mode 100644 index 000000000..ae4f2d17e --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java @@ -0,0 +1,51 @@ +package com.baiyue.iam.repository; + +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.mapper.IamTenantMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.List; + +/** Read-only customer queries used by the generic export provider. */ +@Repository +public class CustomerListExportProviderRepository { + @Resource + private IamTenantMapper tenantMapper; + + public long maximumId(String keyword, String status) { + LambdaQueryWrapper wrapper = filters(keyword, status) + .select(IamTenant::getId) + .orderByDesc(IamTenant::getId) + .last("LIMIT 1"); + IamTenant tenant = tenantMapper.selectOne(wrapper); + return tenant == null || tenant.getId() == null ? 0L : tenant.getId(); + } + + public long estimateRows(long maximumId, String keyword, String status) { + if (maximumId <= 0) return 0L; + return tenantMapper.selectCount(filters(keyword, status).le(IamTenant::getId, maximumId)); + } + + public List findBatch(Long cursor, long maximumId, int fetchSize, + String keyword, String status) { + if (maximumId <= 0) return List.of(); + return tenantMapper.selectList(filters(keyword, status) + .gt(cursor != null, IamTenant::getId, cursor) + .le(IamTenant::getId, maximumId) + .orderByAsc(IamTenant::getId) + .last("LIMIT " + fetchSize)); + } + + private LambdaQueryWrapper filters(String keyword, String status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(IamTenant::getTenantType, "MERCHANT") + .eq(StringUtils.hasText(status), IamTenant::getStatus, status); + if (StringUtils.hasText(keyword)) { + wrapper.like(IamTenant::getTenantName, keyword); + } + return wrapper; + } +} diff --git a/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java b/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java index 79b82b81b..2d64b692b 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java @@ -1,5 +1,6 @@ package com.baiyue.iam.repository; +import com.baiyue.common.core.api.PageResult; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamMembershipRole; import com.baiyue.iam.entity.IamUser; @@ -7,34 +8,61 @@ import com.baiyue.iam.mapper.IamMembershipMapper; import com.baiyue.iam.mapper.IamMembershipRoleMapper; import com.baiyue.iam.mapper.IamUserMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.Resource; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; import java.util.List; /** 企业成员仓储,所有成员查询均显式携带租户条件。 */ @Repository public class MemberRepository { - private final IamMembershipMapper membershipMapper; - private final IamUserMapper userMapper; - private final IamMembershipRoleMapper roleMapper; - - public MemberRepository(IamMembershipMapper membershipMapper, IamUserMapper userMapper, - IamMembershipRoleMapper roleMapper) { - this.membershipMapper = membershipMapper; - this.userMapper = userMapper; - this.roleMapper = roleMapper; - } + @Resource + private IamMembershipMapper membershipMapper; + @Resource + private IamUserMapper userMapper; + @Resource + private IamMembershipRoleMapper roleMapper; public List findByTenant(long tenantId) { return membershipMapper.selectList(new LambdaQueryWrapper() .eq(IamMembership::getTenantId, tenantId).orderByDesc(IamMembership::getJoinedAt)); } + public PageResult pageByTenant(long tenantId, long page, long size, + String keyword, String status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, tenantId) + .eq(StringUtils.hasText(status), IamMembership::getStatus, status) + .orderByDesc(IamMembership::getJoinedAt); + if (StringUtils.hasText(keyword)) { + String normalizedKeyword = keyword.trim(); + List matchingUserIds = userMapper.selectList(new LambdaQueryWrapper() + .select(IamUser::getId) + .in(IamUser::getUserType, "INTERNAL", "PLATFORM") + .and(query -> query.like(IamUser::getDisplayName, normalizedKeyword) + .or().like(IamUser::getPhone, normalizedKeyword) + .or().like(IamUser::getEmail, normalizedKeyword))) + .stream().map(IamUser::getId).toList(); + wrapper.and(query -> { + query.like(IamMembership::getMemberNo, normalizedKeyword); + if (!matchingUserIds.isEmpty()) query.or().in(IamMembership::getUserId, matchingUserIds); + }); + } + return PageResult.from(membershipMapper.selectPage(new Page<>(page, size), wrapper)); + } public IamMembership findById(Long id) { return membershipMapper.selectById(id); } public IamMembership findByTenantAndId(long tenantId, Long id) { return membershipMapper.selectOne(new LambdaQueryWrapper() .eq(IamMembership::getTenantId, tenantId).eq(IamMembership::getId, id).last("LIMIT 1")); } + public IamMembership findOwnerByTenant(long tenantId) { + return membershipMapper.selectOne(new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, tenantId) + .eq(IamMembership::getMemberType, "OWNER") + .last("LIMIT 1")); + } public IamMembership findByTenantAndAuthUser(long tenantId, Long authUserId) { IamUser user = findUserByAuthId(authUserId); if (user == null) return null; diff --git a/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java b/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java index e08d1cbb5..cf80bc93d 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java @@ -7,6 +7,7 @@ import com.baiyue.iam.mapper.IamTenantAddressMapper; import com.baiyue.iam.mapper.IamTenantQualificationMapper; import com.baiyue.iam.mapper.IamUserMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import java.util.List; @@ -16,26 +17,38 @@ import java.util.List; */ @Repository public class ProfileResourceRepository { - private final IamUserMapper userMapper; - private final IamTenantAddressMapper addressMapper; - private final IamTenantQualificationMapper qualificationMapper; + @Resource + private IamUserMapper userMapper; + @Resource + private IamTenantAddressMapper addressMapper; + @Resource + private IamTenantQualificationMapper qualificationMapper; - public ProfileResourceRepository(IamUserMapper userMapper, IamTenantAddressMapper addressMapper, - IamTenantQualificationMapper qualificationMapper) { - this.userMapper = userMapper; - this.addressMapper = addressMapper; - this.qualificationMapper = qualificationMapper; - } - - public void insertUser(IamUser user) { userMapper.insert(user); } public IamUser findUserByAuthId(Long authUserId) { return userMapper.selectOne(new LambdaQueryWrapper().eq(IamUser::getAuthUserId, authUserId).last("LIMIT 1")); } public void saveUser(IamUser user) { userMapper.updateById(user); } public void insertAddress(IamTenantAddress address) { addressMapper.insert(address); } public void insertQualification(IamTenantQualification qualification) { qualificationMapper.insert(qualification); } public void saveQualification(IamTenantQualification qualification) { qualificationMapper.updateById(qualification); } - public List findInternalUsers() { return userMapper.selectList(new LambdaQueryWrapper().eq(IamUser::getUserType, "INTERNAL")); } public List findAddresses(long tenantId) { return addressMapper.selectList(new LambdaQueryWrapper().eq(IamTenantAddress::getTenantId, tenantId)); } public List findQualifications(long tenantId) { return qualificationMapper.selectList(new LambdaQueryWrapper().eq(IamTenantQualification::getTenantId, tenantId)); } + public IamTenantQualification findQualification(long tenantId, long qualificationId) { + return qualificationMapper.selectOne(new LambdaQueryWrapper() + .eq(IamTenantQualification::getTenantId, tenantId) + .eq(IamTenantQualification::getId, qualificationId)); + } + public IamTenantQualification findQualificationForUpdate(long tenantId, long qualificationId) { + return qualificationMapper.selectOne(new LambdaQueryWrapper() + .eq(IamTenantQualification::getTenantId, tenantId) + .eq(IamTenantQualification::getId, qualificationId) + .last("FOR UPDATE")); + } + public void deleteQualification(IamTenantQualification qualification) { qualificationMapper.deleteById(qualification); } + public List findQualificationsForUpdate(long tenantId) { + return qualificationMapper.selectList(new LambdaQueryWrapper() + .eq(IamTenantQualification::getTenantId, tenantId) + .orderByAsc(IamTenantQualification::getId) + .last("FOR UPDATE")); + } public long countApprovedQualifications(long tenantId) { return qualificationMapper.selectCount(new LambdaQueryWrapper() .eq(IamTenantQualification::getTenantId, tenantId) diff --git a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java index 155d08a17..f1e524e2a 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java @@ -3,36 +3,33 @@ package com.baiyue.iam.repository; import com.baiyue.iam.entity.*; import com.baiyue.iam.mapper.*; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; import java.util.LinkedHashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Set; /** RBAC 仓储,所有业务查询均显式携带租户和应用域。 */ @Repository public class RbacRepository { - private final IamRoleMapper roleMapper; - private final IamPermissionMapper permissionMapper; - private final IamMembershipRoleMapper membershipRoleMapper; - private final IamRolePermissionMapper rolePermissionMapper; - private final IamDataScopeMapper dataScopeMapper; - - public RbacRepository(IamRoleMapper roleMapper, IamPermissionMapper permissionMapper, - IamMembershipRoleMapper membershipRoleMapper, - IamRolePermissionMapper rolePermissionMapper, - IamDataScopeMapper dataScopeMapper) { - this.roleMapper = roleMapper; - this.permissionMapper = permissionMapper; - this.membershipRoleMapper = membershipRoleMapper; - this.rolePermissionMapper = rolePermissionMapper; - this.dataScopeMapper = dataScopeMapper; - } + @Resource + private IamRoleMapper roleMapper; + @Resource + private IamPermissionMapper permissionMapper; + @Resource + private IamMembershipRoleMapper membershipRoleMapper; + @Resource + private IamRolePermissionMapper rolePermissionMapper; + @Resource + private IamDataScopeMapper dataScopeMapper; public IamRole findRole(Long id, long tenantId, String appCode) { return roleMapper.selectOne(new LambdaQueryWrapper() - .eq(IamRole::getId, id).eq(IamRole::getTenantId, tenantId) + .eq(IamRole::getId, id).in(IamRole::getTenantId, List.of(0L, tenantId)) .eq(IamRole::getAppCode, appCode)); } @@ -46,10 +43,44 @@ public class RbacRepository { public void insertRole(IamRole role) { roleMapper.insert(role); } public void updateRole(IamRole role) { roleMapper.updateById(role); } public void insertPermission(IamPermission permission) { permissionMapper.insert(permission); } + public void updatePermission(IamPermission permission) { permissionMapper.updateById(permission); } + + /** 写操作必须精确匹配所属权限域;全局模板可读,但商户不可修改。 */ + public IamPermission findOwnedPermission(Long id, long tenantId, String appCode) { + return permissionMapper.selectOne(new LambdaQueryWrapper() + .eq(IamPermission::getId, id) + .eq(IamPermission::getTenantId, tenantId) + .eq(IamPermission::getAppCode, appCode)); + } + + public boolean hasPermissionChildren(long tenantId, String appCode, Long permissionId) { + return permissionMapper.selectCount(new LambdaQueryWrapper() + .eq(IamPermission::getTenantId, tenantId) + .eq(IamPermission::getAppCode, appCode) + .eq(IamPermission::getParentId, permissionId)) > 0; + } + + public boolean isPermissionGranted(long tenantId, Long permissionId) { + return rolePermissionMapper.selectCount(new LambdaQueryWrapper() + .eq(IamRolePermission::getTenantId, tenantId) + .eq(IamRolePermission::getPermissionId, permissionId)) > 0; + } + + public Set findRoleIdsByPermission(long tenantId, Long permissionId) { + List rows = rolePermissionMapper.selectList( + new LambdaQueryWrapper() + .eq(IamRolePermission::getTenantId, tenantId) + .eq(IamRolePermission::getPermissionId, permissionId)); + Set result = new LinkedHashSet<>(); + rows.forEach(row -> result.add(row.getRoleId())); + return result; + } + + public void deletePermission(IamPermission permission) { permissionMapper.deleteById(permission.getId()); } public List findRoles(long tenantId, String appCode) { return roleMapper.selectList(new LambdaQueryWrapper() - .eq(IamRole::getTenantId, tenantId).eq(IamRole::getAppCode, appCode) + .in(IamRole::getTenantId, List.of(0L, tenantId)).eq(IamRole::getAppCode, appCode) .orderByAsc(IamRole::getRoleCode)); } @@ -78,7 +109,7 @@ public class RbacRepository { public Set findRolePermissionIds(long tenantId, Long roleId) { List rows = rolePermissionMapper.selectList( new LambdaQueryWrapper() - .eq(IamRolePermission::getTenantId, tenantId) + .in(IamRolePermission::getTenantId, List.of(0L, tenantId)) .eq(IamRolePermission::getRoleId, roleId) .orderByAsc(IamRolePermission::getPermissionId)); Set result = new LinkedHashSet<>(); @@ -103,6 +134,27 @@ public class RbacRepository { .eq(IamMembershipRole::getRoleId, roleId)) > 0; } + public long countRoleMembers(long tenantId, Long roleId) { + return membershipRoleMapper.selectCount(new LambdaQueryWrapper() + .eq(IamMembershipRole::getTenantId, tenantId) + .eq(IamMembershipRole::getRoleId, roleId)); + } + + public Map findRoleMemberCounts(long tenantId, List roleIds) { + if (roleIds.isEmpty()) return Map.of(); + List assignments = membershipRoleMapper.selectList( + new LambdaQueryWrapper() + .eq(IamMembershipRole::getTenantId, tenantId) + .in(IamMembershipRole::getRoleId, roleIds)); + Map counts = new LinkedHashMap<>(); + assignments.forEach(assignment -> counts.merge(assignment.getRoleId(), 1L, Long::sum)); + return counts; + } + + public List findAssignedAuthUserIds(long tenantId, Long roleId) { + return membershipRoleMapper.findAssignedAuthUserIds(tenantId, roleId); + } + public void deleteRole(long tenantId, String appCode, Long roleId) { rolePermissionMapper.delete(new LambdaQueryWrapper() .eq(IamRolePermission::getTenantId, tenantId) diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java index aa93883e2..ca8c1b9c4 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -1,34 +1,68 @@ package com.baiyue.iam.repository; +import com.baiyue.common.core.api.PageResult; import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.mapper.IamTenantMapper; import com.baiyue.iam.mapper.IamTenantReviewMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; import java.util.List; +import java.util.Collection; /** * 租户仓储,封装租户及审核单的唯一性校验、查询、创建、更新和待审列表操作。 */ @Repository public class TenantRepository { - private final IamTenantMapper tenantMapper; - private final IamTenantReviewMapper reviewMapper; - - public TenantRepository(IamTenantMapper tenantMapper, IamTenantReviewMapper reviewMapper) { - this.tenantMapper = tenantMapper; - this.reviewMapper = reviewMapper; - } + @Resource + private IamTenantMapper tenantMapper; + @Resource + private IamTenantReviewMapper reviewMapper; public boolean existsByCreditCode(String creditCode) { return tenantMapper.selectCount(new LambdaQueryWrapper() .eq(IamTenant::getUnifiedSocialCreditCode, creditCode)) > 0; } + public boolean existsByCreditCodeForOtherTenant(String creditCode, long tenantId) { + return tenantMapper.selectCount(new LambdaQueryWrapper() + .eq(IamTenant::getUnifiedSocialCreditCode, creditCode) + .ne(IamTenant::getId, tenantId)) > 0; + } + public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); } + public List findMerchantsByIds(Collection tenantIds) { + if (tenantIds == null || tenantIds.isEmpty()) return List.of(); + return tenantMapper.selectList(new LambdaQueryWrapper() + .in(IamTenant::getId, tenantIds) + .eq(IamTenant::getTenantType, "MERCHANT")); + } + /** + * Serializes the draft qualification workflow for one enterprise. The file service and the + * merchant submit action run in separate requests, so qualification-row locks alone cannot + * protect the empty-set case. + */ + public IamTenant findTenantForUpdate(Long tenantId) { + return tenantMapper.selectOne(new LambdaQueryWrapper() + .eq(IamTenant::getId, tenantId) + .last("FOR UPDATE")); + } + public List listMerchants() { + return tenantMapper.selectList(new LambdaQueryWrapper() + .eq(IamTenant::getTenantType, "MERCHANT") + .orderByAsc(IamTenant::getTenantName)); + } public IamTenantReview findReview(Long reviewId) { return reviewMapper.selectById(reviewId); } + public IamTenantReview findReviewForUpdate(Long reviewId) { + return reviewMapper.selectOne(new LambdaQueryWrapper() + .eq(IamTenantReview::getId, reviewId) + .last("FOR UPDATE")); + } public IamTenantReview findPendingReviewByTenant(Long tenantId) { return reviewMapper.selectOne(new LambdaQueryWrapper() .eq(IamTenantReview::getTenantId, tenantId) @@ -45,4 +79,57 @@ public class TenantRepository { .eq(IamTenantReview::getStatus, "PENDING") .orderByAsc(IamTenantReview::getId)); } + + public PageResult pageMerchants(long page, long size, String keyword, String status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(IamTenant::getTenantType, "MERCHANT") + .ne(IamTenant::getStatus, "ARCHIVED") + .eq(StringUtils.hasText(status), IamTenant::getStatus, status) + .orderByDesc(IamTenant::getCreatedAt); + if (StringUtils.hasText(keyword)) { + wrapper.like(IamTenant::getTenantName, keyword); + } + return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper)); + } + + public PageResult pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(IamTenant::getTenantType, "MERCHANT") + .eq(customerId != null, IamTenant::getId, customerId) + .in(!StringUtils.hasText(status), IamTenant::getStatus, List.of("ACTIVE", "DISABLED")) + .eq(StringUtils.hasText(status), IamTenant::getStatus, status) + .orderByDesc(IamTenant::getCreatedAt); + if (StringUtils.hasText(keyword)) { + wrapper.and(condition -> condition + .like(IamTenant::getTenantCode, keyword) + .or().like(IamTenant::getTenantName, keyword) + .or().like(IamTenant::getLegalName, keyword) + .or().like(IamTenant::getUnifiedSocialCreditCode, keyword) + .or().like(IamTenant::getContactName, keyword) + .or().like(IamTenant::getContactPhone, keyword) + .or().like(IamTenant::getSalesOwnerName, keyword)); + } + return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper)); + } + + public List exportMerchants(Long cursor, int limit, String keyword, String status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(IamTenant::getTenantType, "MERCHANT") + .ne(IamTenant::getStatus, "ARCHIVED") + .gt(cursor != null, IamTenant::getId, cursor) + .eq(StringUtils.hasText(status), IamTenant::getStatus, status) + .orderByAsc(IamTenant::getId) + .last("LIMIT " + (limit + 1)); + if (StringUtils.hasText(keyword)) { + wrapper.and(condition -> condition + .like(IamTenant::getTenantCode, keyword) + .or().like(IamTenant::getTenantName, keyword) + .or().like(IamTenant::getLegalName, keyword) + .or().like(IamTenant::getUnifiedSocialCreditCode, keyword) + .or().like(IamTenant::getContactName, keyword) + .or().like(IamTenant::getContactPhone, keyword) + .or().like(IamTenant::getSalesOwnerName, keyword)); + } + return tenantMapper.selectList(wrapper); + } } 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 e8931d5d0..df90f8b1e 100644 --- a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java +++ b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java @@ -6,10 +6,12 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest; import com.baiyue.iam.dto.AuthorizationDtos.SnapshotResponse; import com.baiyue.iam.mapper.AuthorizationQueryMapper; +import com.baiyue.common.security.AuthenticatedUser; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; /** @@ -24,6 +26,7 @@ public class AuthorizationSnapshotService { @Transactional(readOnly = true) public SnapshotResponse load(SnapshotRequest request) { + requireRequest(request); String appCode = request.getAppCode().trim().toUpperCase(java.util.Locale.ROOT); if (!APPS.contains(appCode)) { throw new BizException(ResultCode.FORBIDDEN, "未知的应用身份域"); @@ -38,11 +41,42 @@ public class AuthorizationSnapshotService { throw new BizException(ResultCode.FORBIDDEN, "IAM 用户或成员身份无效"); } - Set roles = new LinkedHashSet<>(mapper.findRoles( + Set roles = normalizedSet(mapper.findRoles( request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); - Set permissions = new LinkedHashSet<>(mapper.findPermissions( + 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())); + } + + /** + * 根据已认证请求主体重新计算有效权限,而不是复用 JWT 中可能已经过期的权限声明。 + */ + @Transactional(readOnly = true) + public Set currentPermissions(AuthenticatedUser user, String appCode) { + if (user == null || user.userId() == null || user.tenantId() == null || user.membershipId() == null) { + throw new BizException(ResultCode.UNAUTHORIZED, "Authentication identity is incomplete / 登录身份不完整"); + } + return load(new SnapshotRequest(user.userId(), user.tenantId(), user.membershipId(), appCode)) + .getPermissions(); + } + + private void requireRequest(SnapshotRequest request) { + if (request == null || request.getUserId() == null || request.getTenantId() == null + || request.getMembershipId() == null || request.getAppCode() == null + || request.getAppCode().isBlank()) { + throw new BizException(ResultCode.FORBIDDEN, "Authorization identity is incomplete / 授权身份不完整"); + } + } + + private Set normalizedSet(List values) { + if (values == null || values.isEmpty()) { + return Set.of(); + } + LinkedHashSet normalized = new LinkedHashSet<>(); + values.stream().filter(java.util.Objects::nonNull).forEach(normalized::add); + return Set.copyOf(normalized); } } 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..ea43f1686 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java @@ -0,0 +1,102 @@ +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(), task.customerName())); + requireSuccess(response, true); + if (response.data() == null || response.data().configId() == null + || response.data().chargeRuleId() == 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 new file mode 100644 index 000000000..c5aaca617 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java @@ -0,0 +1,50 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +/** Owns IAM's durable customer-dimension initialization intent; remote delivery is always background. */ +@Service +public class CustomerDimensionRuleInitializationService { + private static final String KEY_PREFIX = "iam-customer-create:"; + @Resource private CustomerDimensionInitializationRepository repository; + + public InitializationResult initialize(long tenantId, long operatorId) { + String initializationKey = initializationKey(tenantId); + repository.enqueue(tenantId, operatorId, initializationKey); + return new InitializationResult(false, initializationKey, "PENDING"); + } + + /** Used when Auth compensates a committed client registration. */ + public void compensateRegistration(long tenantId) { + repository.requestCompensation(tenantId, 0L, initializationKey(tenantId)); + } + + public void retry(long tenantId) { + if (!repository.manualRetry(tenantId)) { + throw new BizException(ResultCode.CONFLICT, "默认尺寸初始化当前不可人工重试"); + } + } + + 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)); + } + } + + private static String initializationKey(long tenantId) { + return KEY_PREFIX + tenantId; + } + + public record InitializationResult(boolean created, String initializationKey, String status) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java b/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java new file mode 100644 index 000000000..a6f640149 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java @@ -0,0 +1,271 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.common.core.export.ExportContracts.ExportColumn; +import com.baiyue.common.core.export.ExportContracts.ExportColumnType; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.repository.CustomerListExportProviderRepository; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Implements the generic {@code CUSTOMER_LIST} export contract without writing export files. */ +@Service +public class CustomerListExportProviderService { + static final int SCHEMA_VERSION = 1; + private static final int MAX_BATCH_SIZE = 500; + private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512; + private static final Set STATUSES = Set.of( + "PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED", "ARCHIVED"); + private static final Set PARAMETER_KEYS = Set.of("keyword", "status"); + private static final List COLUMNS = List.of( + column("id", "客户ID", ExportColumnType.STRING, 20), + column("tenantCode", "客户编码", ExportColumnType.STRING, 18), + column("tenantName", "公司名称", ExportColumnType.STRING, 28), + column("customerNickname", "客户昵称", ExportColumnType.STRING, 14), + column("legalName", "营业执照企业名称", ExportColumnType.STRING, 28), + column("unifiedSocialCreditCode", "统一社会信用代码", ExportColumnType.STRING, 24), + column("enterpriseType", "企业类型", ExportColumnType.STRING, 16), + column("establishedDate", "成立日期", ExportColumnType.DATE, 14), + column("registeredCapital", "注册资本", ExportColumnType.DECIMAL, 16), + column("registeredCapitalCurrency", "币种", ExportColumnType.STRING, 10), + column("registeredAddress", "注册地址", ExportColumnType.STRING, 32), + column("contactName", "联系人", ExportColumnType.STRING, 16), + column("contactPhone", "联系电话", ExportColumnType.STRING, 18), + column("contactEmail", "联系邮箱", ExportColumnType.STRING, 24), + column("contactWechat", "联系微信", ExportColumnType.STRING, 18), + column("salesOwnerName", "销售人员", ExportColumnType.STRING, 16), + column("servicePackage", "客户套餐", ExportColumnType.STRING, 16), + column("paymentTermDays", "账期(天)", ExportColumnType.INTEGER, 12), + column("status", "状态", ExportColumnType.STRING, 14), + column("createdAt", "入驻时间", ExportColumnType.DATETIME, 21), + column("approvedAt", "审批时间", ExportColumnType.DATETIME, 21)); + private static final String SCHEMA_HASH = schemaHash(COLUMNS); + + @Resource + private CustomerListExportProviderRepository repository; + @Value("${baiyue.security.internal-service-token}") + private String internalServiceToken; + private byte[] snapshotSigningKey; + + @PostConstruct + void initializeSigningKey() { + this.snapshotSigningKey = internalServiceToken.getBytes(StandardCharsets.UTF_8); + } + + public ExportBatchResponse exportBatch(ExportBatchRequest request) { + ValidatedRequest validated = validate(request); + boolean firstBatch = validated.snapshotToken() == null; + long maximumId = firstBatch + ? repository.maximumId(validated.keyword(), validated.status()) + : verifySnapshot(validated); + String snapshotToken = firstBatch ? signSnapshot(validated, maximumId) : validated.snapshotToken(); + long totalRows = firstBatch + ? repository.estimateRows(maximumId, validated.keyword(), validated.status()) + : 0L; + + List source = repository.findBatch(validated.cursor(), maximumId, + validated.limit() + 1, validated.keyword(), validated.status()); + boolean hasMore = source.size() > validated.limit(); + List page = hasMore ? source.subList(0, validated.limit()) : source; + String nextCursor = page.isEmpty() + ? validated.cursorText() + : String.valueOf(page.get(page.size() - 1).getId()); + + return new ExportBatchResponse(SCHEMA_VERSION, SCHEMA_HASH, snapshotToken, + firstBatch ? COLUMNS : List.of(), page.stream().map(this::row).toList(), + nextCursor, hasMore, firstBatch ? totalRows : null); + } + + private ValidatedRequest validate(ExportBatchRequest request) { + if (request == null) throw invalid("Export batch request is required"); + requirePositiveId(request.taskId(), "taskId"); + if (!"0".equals(request.tenantId())) { + throw invalid("CUSTOMER_LIST only supports platform tenant 0"); + } + if (!"PLATFORM".equals(request.appCode())) { + throw invalid("CUSTOMER_LIST only supports PLATFORM appCode"); + } + requirePositiveId(request.requestedBy(), "requestedBy"); + if (request.dispatchVersion() <= 0) throw invalid("dispatchVersion must be positive"); + if (request.limit() < 1 || request.limit() > MAX_BATCH_SIZE) { + throw invalid("limit must be between 1 and 500"); + } + + Map parameters = request.parameters() == null ? Map.of() : request.parameters(); + if (!PARAMETER_KEYS.containsAll(parameters.keySet())) { + throw invalid("CUSTOMER_LIST contains unsupported parameters"); + } + String keyword = optionalString(parameters.get("keyword"), "keyword"); + if (keyword != null && keyword.length() > 128) throw invalid("keyword must not exceed 128 characters"); + String status = optionalString(parameters.get("status"), "status"); + if (status != null && !STATUSES.contains(status)) throw invalid("CUSTOMER_LIST status is invalid"); + + String snapshotToken = normalize(request.snapshotToken()); + if (snapshotToken != null && snapshotToken.length() > MAX_SNAPSHOT_TOKEN_LENGTH) { + throw invalid("snapshotToken must not exceed 512 characters"); + } + Long cursor = parseCursor(request.cursor()); + if (snapshotToken == null && cursor != null) { + throw invalid("cursor requires a snapshotToken"); + } + return new ValidatedRequest(request, keyword, status, cursor, normalize(request.cursor()), snapshotToken); + } + + private long verifySnapshot(ValidatedRequest request) { + String[] parts = request.snapshotToken().split("\\.", -1); + if (parts.length != 3 || !"v1".equals(parts[0])) throw invalid("snapshotToken is invalid"); + long maximumId; + byte[] suppliedSignature; + try { + maximumId = Long.parseLong(parts[1]); + suppliedSignature = Base64.getUrlDecoder().decode(parts[2]); + } catch (IllegalArgumentException exception) { + throw invalid("snapshotToken is invalid"); + } + if (maximumId < 0) throw invalid("snapshotToken is invalid"); + byte[] expectedSignature = hmac(snapshotSubject(request, maximumId)); + if (!MessageDigest.isEqual(expectedSignature, suppliedSignature)) { + throw invalid("snapshotToken does not match this export task"); + } + if (request.cursor() != null && request.cursor() > maximumId) { + throw invalid("cursor exceeds the export snapshot"); + } + return maximumId; + } + + private String signSnapshot(ValidatedRequest request, long maximumId) { + String signature = Base64.getUrlEncoder().withoutPadding() + .encodeToString(hmac(snapshotSubject(request, maximumId))); + return "v1." + maximumId + "." + signature; + } + + private String snapshotSubject(ValidatedRequest request, long maximumId) { + ExportBatchRequest source = request.source(); + return String.join("\n", "CUSTOMER_LIST", source.taskId(), source.tenantId(), source.appCode(), + source.requestedBy(), String.valueOf(source.dispatchVersion()), + request.keyword() == null ? "" : request.keyword(), + request.status() == null ? "" : request.status(), String.valueOf(maximumId)); + } + + private byte[] hmac(String value) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(snapshotSigningKey, "HmacSHA256")); + return mac.doFinal(value.getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("HmacSHA256 is unavailable", exception); + } + } + + private Map row(IamTenant tenant) { + Map row = new LinkedHashMap<>(); + row.put("id", String.valueOf(tenant.getId())); + row.put("tenantCode", tenant.getTenantCode()); + row.put("tenantName", tenant.getTenantName()); + row.put("customerNickname", tenant.getCustomerNickname()); + row.put("legalName", tenant.getLegalName()); + row.put("unifiedSocialCreditCode", tenant.getUnifiedSocialCreditCode()); + row.put("enterpriseType", tenant.getEnterpriseType()); + row.put("establishedDate", tenant.getEstablishedDate()); + row.put("registeredCapital", tenant.getRegisteredCapital()); + row.put("registeredCapitalCurrency", tenant.getRegisteredCapitalCurrency()); + row.put("registeredAddress", tenant.getRegisteredAddress()); + row.put("contactName", tenant.getContactName()); + row.put("contactPhone", tenant.getContactPhone()); + row.put("contactEmail", tenant.getContactEmail()); + row.put("contactWechat", tenant.getContactWechat()); + row.put("salesOwnerName", tenant.getSalesOwnerName()); + row.put("servicePackage", tenant.getServicePackage()); + row.put("paymentTermDays", tenant.getPaymentTermDays()); + row.put("status", statusLabel(tenant.getStatus())); + row.put("createdAt", tenant.getCreatedAt()); + row.put("approvedAt", tenant.getApprovedAt()); + return row; + } + + private String statusLabel(String status) { + if (status == null) return null; + return switch (status) { + case "PROFILE_REQUIRED" -> "待完善"; + case "PENDING" -> "待审核"; + case "ACTIVE" -> "启用"; + case "REJECTED" -> "已驳回"; + case "DISABLED" -> "已停用"; + case "ARCHIVED" -> "已归档"; + default -> status; + }; + } + + private static ExportColumn column(String key, String title, ExportColumnType type, int width) { + return new ExportColumn(key, title, type, width); + } + + private static String schemaHash(List columns) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (ExportColumn column : columns) { + digest.update((column.key() + "\u001f" + column.title() + "\u001f" + column.type() + + "\u001f" + column.width() + "\n").getBytes(StandardCharsets.UTF_8)); + } + return "sha256:" + java.util.HexFormat.of().formatHex(digest.digest()); + } catch (GeneralSecurityException exception) { + throw new ExceptionInInitializerError(exception); + } + } + + private Long parseCursor(String value) { + String normalized = normalize(value); + if (normalized == null) return null; + try { + long parsed = Long.parseLong(normalized); + if (parsed < 0) throw invalid("cursor must not be negative"); + return parsed; + } catch (NumberFormatException exception) { + throw invalid("cursor must be a numeric ID"); + } + } + + private void requirePositiveId(String value, String field) { + try { + if (value == null || Long.parseLong(value) <= 0) throw invalid(field + " must be a positive ID"); + } catch (NumberFormatException exception) { + throw invalid(field + " must be a positive ID"); + } + } + + private String optionalString(Object value, String field) { + if (value == null) return null; + if (!(value instanceof String text)) throw invalid(field + " must be a string"); + return normalize(text); + } + + private String normalize(String value) { + if (value == null || value.isBlank()) return null; + return value.trim(); + } + + private BizException invalid(String message) { + return new BizException(ResultCode.VALIDATION_FAILED, message); + } + + private record ValidatedRequest(ExportBatchRequest source, String keyword, String status, + Long cursor, String cursorText, String snapshotToken) { + int limit() { return source.limit(); } + } +} 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 83e27c0bf..43f79000e 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -11,53 +11,90 @@ import com.baiyue.iam.entity.IamRole; import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; +import com.baiyue.iam.repository.TenantRepository; +import com.baiyue.iam.dto.PlatformCustomerMemberDtos; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import feign.FeignException; +import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** 企业成员闭环服务,账号凭据归 auth-service,人员档案与租户关系归 IAM。 */ @Service public class MemberService { + private static final String GRANT_ROLE_PERMISSION = "merchant:member:grant-role"; private static final Logger log = LoggerFactory.getLogger(MemberService.class); - private final MemberRepository repository; - private final IamRoleMapper roleMapper; - private final AuthAccountClient authClient; - private final String internalToken; - - public MemberService(MemberRepository repository, IamRoleMapper roleMapper, AuthAccountClient authClient, - @Value("${baiyue.security.internal-service-token}") String internalToken) { - this.repository = repository; - this.roleMapper = roleMapper; - this.authClient = authClient; - this.internalToken = internalToken; - } + @Resource + private MemberRepository repository; + @Resource + private TenantRepository tenantRepository; + @Resource + private IamRoleMapper roleMapper; + @Resource + private AuthAccountClient authClient; + @Resource + private SecurityEventOutboxService securityEvents; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; public List list(long tenantId) { return repository.findByTenant(tenantId).stream().map(m -> response(tenantId, m)).toList(); } + /** 平台只读查询客户成员;显式校验客户主档,并包含 OWNER 管理员。 */ + public List platformList(long tenantId) { + var tenant = tenantRepository.findTenant(tenantId); + if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "企业客户不存在"); + } + return repository.findByTenant(tenantId).stream().map(m -> platformResponse(tenantId, m)).toList(); + } + + private PlatformCustomerMemberDtos.MemberResponse platformResponse(long tenantId, IamMembership membership) { + IamUser user = requiredUser(membership.getUserId()); + AuthAccountClient.AccountSummary account = accountSummary(user.getAuthUserId()); + List roleIds = repository.findRoleIds(tenantId, membership.getId()); + List roleNames = roleIds.stream() + .map(roleMapper::selectById) + .filter(role -> role != null && "MERCHANT".equals(role.getAppCode()) + && (Long.valueOf(tenantId).equals(role.getTenantId()) || Long.valueOf(0L).equals(role.getTenantId()))) + .map(IamRole::getRoleName) + .toList(); + return new PlatformCustomerMemberDtos.MemberResponse(membership.getId(), user.getId(), + user.getAuthUserId(), account.username(), user.getDisplayName(), user.getPhone(), user.getEmail(), + membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), + roleIds, roleNames); + } + + private AuthAccountClient.AccountSummary accountSummary(Long authUserId) { + return data(call(() -> authClient.summary(internalToken, authUserId))); + } + public MemberResponse detail(long tenantId, Long membershipId) { return response(tenantId, required(tenantId, membershipId)); } @Transactional @BusinessAudit(event = "MEMBER_CREATE", resourceType = "MEMBERSHIP", resourceId = "#request.username", description = "新增企业成员") - public MemberResponse create(long tenantId, CreateMemberRequest request) { + public MemberResponse create(long tenantId, CreateMemberRequest request, Set operatorPermissions) { rejectOwnerMutation(request.memberType()); + requireGrantRoleIfChanged(List.of(), request.roleIds(), operatorPermissions); validateRoles(tenantId, request.roleIds()); validateMemberNo(tenantId, request.memberNo(), null); long authId = IdWorker.getId(); long userId = IdWorker.getId(); long membershipId = IdWorker.getId(); call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest( - authId, request.username(), request.password(), request.displayName(), tenantId, membershipId))); + authId, request.username(), request.phone(), request.password(), request.displayName(), + tenantId, membershipId, "MERCHANT", "ACTIVE"))); try { IamUser user = new IamUser(); user.setId(userId); user.setAuthUserId(authId); user.setUserType("ENTERPRISE"); @@ -69,6 +106,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 { @@ -84,19 +123,30 @@ public class MemberService { @Transactional @BusinessAudit(event = "MEMBER_UPDATE", resourceType = "MEMBERSHIP", resourceId = "#membershipId", description = "编辑企业成员") - public MemberResponse update(long tenantId, Long membershipId, UpdateMemberRequest request) { + public MemberResponse update(long tenantId, Long membershipId, UpdateMemberRequest request, + Set operatorPermissions) { rejectOwnerMutation(request.memberType()); + IamMembership membership = required(tenantId, membershipId); + List currentRoleIds = repository.findRoleIds(tenantId, membershipId); + requireGrantRoleIfChanged(currentRoleIds, request.roleIds(), operatorPermissions); validateRoles(tenantId, request.roleIds()); validateMemberNo(tenantId, request.memberNo(), membershipId); - IamMembership membership = required(tenantId, membershipId); if ("OWNER".equals(membership.getMemberType())) { throw new BizException(ResultCode.FORBIDDEN, "企业所有者不允许通过成员管理接口编辑"); } IamUser user = requiredUser(membership.getUserId()); + if (!java.util.Objects.equals(user.getPhone(), request.phone())) { + call(() -> authClient.phone(internalToken, user.getAuthUserId(), + new AuthAccountClient.PhoneRequest(request.phone()))); + } user.setDisplayName(request.displayName()); user.setPhone(request.phone()); user.setEmail(request.email()); membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo()); repository.saveUser(user); repository.save(membership); repository.replaceRoles(tenantId, membershipId, request.roleIds()); + if (!new HashSet<>(currentRoleIds).equals(new HashSet<>(request.roleIds()))) { + call(() -> authClient.revokeSessions(internalToken, + new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); + } return response(tenantId, membership); } @@ -119,6 +169,9 @@ 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()); + securityEvents.permissionChanged(user.getAuthUserId(), tenantId, "MERCHANT", + "MEMBER_STATUS_CHANGED"); } catch (RuntimeException localFailure) { try { authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldStatus)); @@ -132,7 +185,8 @@ public class MemberService { return response(tenantId, membership); } - @BusinessAudit(event = "MEMBER_PASSWORD_RESET", resourceType = "MEMBERSHIP", resourceId = "#membershipId", description = "管理员重置成员密码", captureAfter = false) + @BusinessAudit(event = "MEMBER_PASSWORD_RESET", resourceType = "MEMBERSHIP", resourceId = "#membershipId", + description = "管理员重置成员密码", captureBefore = false, captureAfter = false) public void resetPassword(long tenantId, Long membershipId, ResetMemberPasswordRequest request) { IamMembership membership = required(tenantId, membershipId); if ("OWNER".equals(membership.getMemberType())) { @@ -148,8 +202,9 @@ public class MemberService { return response(tenantId, membership, user); } private MemberResponse response(long tenantId, IamMembership m, IamUser u) { - return new MemberResponse(m.getId(), u.getId(), u.getAuthUserId(), u.getDisplayName(), - u.getPhone(), u.getEmail(), m.getMemberType(), m.getMemberNo(), m.getStatus(), + AuthAccountClient.AccountSummary account = accountSummary(u.getAuthUserId()); + return new MemberResponse(m.getId(), u.getId(), u.getAuthUserId(), account.username(), u.getDisplayName(), + u.getPhone(), u.getEmail(), m.getMemberType(), m.getMemberNo(), m.getStatus(), account.lastLoginAt(), repository.findRoleIds(tenantId, m.getId())); } private IamMembership required(long tenantId, Long membershipId) { @@ -171,6 +226,13 @@ public class MemberService { throw new BizException(ResultCode.FORBIDDEN, "禁止分配其他租户、其他应用或已停用角色"); } } + private void requireGrantRoleIfChanged(List currentRoleIds, List requestedRoleIds, + Set operatorPermissions) { + if (!new HashSet<>(currentRoleIds).equals(new HashSet<>(requestedRoleIds)) + && (operatorPermissions == null || !operatorPermissions.contains(GRANT_ROLE_PERMISSION))) { + throw new BizException(ResultCode.FORBIDDEN, "变更成员角色需要 merchant:member:grant-role 权限"); + } + } private void rejectOwnerMutation(String memberType) { if ("OWNER".equals(memberType)) { throw new BizException(ResultCode.FORBIDDEN, "不能通过成员管理接口创建或转让企业所有者"); 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 7cf003a1a..2b2748c2b 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -9,13 +9,18 @@ import com.baiyue.iam.entity.IamMembershipRole; import com.baiyue.iam.entity.IamRole; import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenantAddress; +import com.baiyue.iam.entity.IamTenantQualification; +import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.mapper.IamMembershipMapper; import com.baiyue.iam.mapper.IamMembershipRoleMapper; import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.mapper.IamTenantAddressMapper; import com.baiyue.iam.mapper.IamTenantMapper; +import com.baiyue.iam.mapper.IamTenantQualificationMapper; +import com.baiyue.iam.mapper.IamTenantReviewMapper; import com.baiyue.iam.mapper.IamUserMapper; +import com.baiyue.iam.repository.CustomerCodeRepository; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import jakarta.annotation.Resource; @@ -24,6 +29,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.time.LocalDateTime; +import java.util.List; @Service public class MerchantOnboardingService { @@ -33,6 +39,12 @@ public class MerchantOnboardingService { @Resource private IamRoleMapper roleMapper; @Resource private IamMembershipRoleMapper membershipRoleMapper; @Resource private IamTenantAddressMapper addressMapper; + @Resource private IamTenantQualificationMapper qualificationMapper; + @Resource private IamTenantReviewMapper reviewMapper; + @Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization; + @Resource private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; + @Resource private SecurityEventOutboxService securityEvents; + @Resource private CustomerCodeRepository customerCodes; @Transactional public MerchantRegistrationResponse register(MerchantRegistrationRequest request) { @@ -56,24 +68,28 @@ public class MerchantOnboardingService { IamTenant tenant = new IamTenant(); tenant.setId(tenantId); - tenant.setTenantCode("TEN" + tenantId); + tenant.setTenantCode(customerCodes.nextCode()); tenant.setTenantName(request.companyName()); + tenant.setCustomerNickname(TenantService.resolveCustomerNickname(null, request.companyName())); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); + tenant.setDimensionInitializationStatus("PENDING"); tenant.setLegalName(request.companyName()); - tenant.setContactName(request.legalPerson()); - tenant.setContactPhone(request.legalPhone()); + tenant.setLegalRepresentative(normalizeOptional(request.legalPerson())); + tenant.setContactName(request.displayName()); + tenant.setContactPhone(request.administratorPhone()); tenant.setContactEmail(request.contactEmail()); tenant.setDeleted(false); tenant.setVersion(0); tenantMapper.insert(tenant); + securityEvents.tenantStatus(tenantId, tenant.getStatus()); IamUser user = new IamUser(); user.setId(userId); user.setAuthUserId(request.authUserId()); user.setUserType("ENTERPRISE"); user.setDisplayName(request.displayName()); - user.setPhone(request.legalPhone()); + user.setPhone(request.administratorPhone()); user.setEmail(request.contactEmail()); user.setStatus("ACTIVE"); userMapper.insert(user); @@ -98,6 +114,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(); @@ -110,6 +128,85 @@ public class MerchantOnboardingService { address.setIsDefault(true); addressMapper.insert(address); } - return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus()); + dimensionRuleInitialization.initialize(tenantId, request.authUserId()); + overseasStorageFeeInitialization.initialize(tenantId, request.authUserId(), tenant.getTenantName()); + return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus(), + tenant.getDimensionInitializationStatus()); + } + + public MerchantRegistrationResponse registration(long authUserId) { + IamUser user = userMapper.selectOne(new LambdaQueryWrapper() + .eq(IamUser::getAuthUserId, authUserId) + .last("LIMIT 1")); + if (user == null) { + throw new BizException(ResultCode.NOT_FOUND, "Registration not found"); + } + IamMembership membership = membershipMapper.selectOne(new LambdaQueryWrapper() + .eq(IamMembership::getUserId, user.getId()) + .eq(IamMembership::getMemberType, "OWNER") + .last("LIMIT 1")); + if (membership == null) { + throw new BizException(ResultCode.NOT_FOUND, "Registration not found"); + } + IamTenant tenant = tenantMapper.selectById(membership.getTenantId()); + if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "Registration not found"); + } + return new MerchantRegistrationResponse(tenant.getId(), membership.getId(), tenant.getStatus(), + tenant.getDimensionInitializationStatus()); + } + + private String normalizeOptional(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + + /** + * Removes a just-created IAM registration when Auth could not persist the paired login account. + * Repeated calls are safe, while progressed onboarding data is deliberately protected. + */ + @Transactional + public void compensateRegistration(long authUserId) { + IamUser user = userMapper.selectOne(new LambdaQueryWrapper() + .eq(IamUser::getAuthUserId, authUserId) + .last("LIMIT 1 FOR UPDATE")); + if (user == null) { + return; + } + List memberships = membershipMapper.selectList(new LambdaQueryWrapper() + .eq(IamMembership::getUserId, user.getId()) + .orderByAsc(IamMembership::getId) + .last("FOR UPDATE")); + if (memberships.size() != 1) { + throw new BizException(ResultCode.CONFLICT, + "IAM registration compensation is unsafe: membership state changed"); + } + IamMembership membership = memberships.get(0); + IamTenant tenant = tenantMapper.selectOne(new LambdaQueryWrapper() + .eq(IamTenant::getId, membership.getTenantId()) + .last("FOR UPDATE")); + if (tenant == null || !"PROFILE_REQUIRED".equals(tenant.getStatus()) + || !"OWNER".equals(membership.getMemberType())) { + throw new BizException(ResultCode.CONFLICT, + "IAM registration compensation is unsafe: tenant state changed"); + } + long qualificationCount = qualificationMapper.selectCount(new LambdaQueryWrapper() + .eq(IamTenantQualification::getTenantId, tenant.getId())); + long reviewCount = reviewMapper.selectCount(new LambdaQueryWrapper() + .eq(IamTenantReview::getTenantId, tenant.getId())); + if (qualificationCount > 0 || reviewCount > 0) { + throw new BizException(ResultCode.CONFLICT, + "IAM registration compensation is unsafe: onboarding has progressed"); + } + + overseasStorageFeeInitialization.compensateRegistration(tenant.getId()); + dimensionRuleInitialization.compensateRegistration(tenant.getId()); + + membershipRoleMapper.delete(new LambdaQueryWrapper() + .eq(IamMembershipRole::getMembershipId, membership.getId())); + membershipMapper.deleteById(membership.getId()); + addressMapper.delete(new LambdaQueryWrapper() + .eq(IamTenantAddress::getTenantId, tenant.getId())); + userMapper.deleteById(user.getId()); + tenantMapper.deleteById(tenant.getId()); } } diff --git a/iam/src/main/java/com/baiyue/iam/service/OverseasStorageFeeInitializationService.java b/iam/src/main/java/com/baiyue/iam/service/OverseasStorageFeeInitializationService.java new file mode 100644 index 000000000..8afbb82c1 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/OverseasStorageFeeInitializationService.java @@ -0,0 +1,97 @@ +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.OverseasStorageFeeClient; +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; + +/** Creates the nine editable default fee tiers for a customer. */ +@Service +public class OverseasStorageFeeInitializationService { + private static final Logger log = LoggerFactory.getLogger(OverseasStorageFeeInitializationService.class); + private static final int MAX_ATTEMPTS = 3; + private static final String KEY_PREFIX = "iam-customer-create-overseas-fee:"; + + @Resource private OverseasStorageFeeClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public InitializationResult initialize(long tenantId, long operatorId, String customerName) { + String key = initializationKey(tenantId); + RuntimeException last = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + ApiResponse response = client.initialize( + internalToken, new OverseasStorageFeeClient.InitializationRequest(tenantId, operatorId, key, customerName)); + if (response != null && response.successful() && response.data() != null) { + InitializationResult result = new InitializationResult( + response.data().createdByThisInitialization(), key); + registerRollbackCompensation(tenantId, result); + return result; + } + last = unavailable(response); + } catch (RuntimeException failure) { + last = failure; + log.warn("海外仓费用初始化调用异常,tenantId={}, attempt={}", tenantId, attempt, failure); + } + } + compensateAfterUncertainInitialization(tenantId, key); + throw unavailable(last); + } + + public void compensateRegistration(long tenantId) { + compensateRequired(tenantId, 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 ex) { + log.error("客户创建事务回滚后海外仓费用补偿失败,tenantId={}, initializationKey={}", tenantId, + result.initializationKey(), ex); + } + } + }); + } + + private void compensateAfterUncertainInitialization(long tenantId, String key) { + try { compensateRequired(tenantId, key); } + catch (RuntimeException ex) { log.error("海外仓费用初始化结果不确定且补偿失败,tenantId={}, initializationKey={}", tenantId, key, ex); } + } + + private void compensateRequired(long tenantId, String key) { + RuntimeException last = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + ApiResponse response = client.compensate(internalToken, + new OverseasStorageFeeClient.CompensationRequest(tenantId, key)); + if (response != null && response.successful()) return; + last = unavailable(response); + } catch (RuntimeException ex) { last = ex; } + } + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外仓费用补偿失败,请根据客户ID修复数据一致性: " + tenantId); + } + + private static String initializationKey(long tenantId) { return KEY_PREFIX + tenantId; } + private static BizException unavailable(ApiResponse response) { + String detail = response == null || response.message() == null || response.message().isBlank() + ? "Warehouse 服务未返回有效响应" : response.message(); + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外仓费用初始化失败: " + detail); + } + private static BizException unavailable(RuntimeException cause) { + String detail = cause == null || cause.getMessage() == null || cause.getMessage().isBlank() + ? "Warehouse 服务暂时不可用" : cause.getMessage(); + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外仓费用初始化失败,已重试 3 次: " + detail); + } + + public record InitializationResult(boolean created, String initializationKey) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java b/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java index 0dc55e438..9631735be 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java @@ -4,18 +4,28 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.dto.ProfileDtos.PersonalProfileResponse; import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest; +import com.baiyue.iam.dto.ProfileDtos.ChangeLoginIdentifiersRequest; +import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.repository.MemberRepository; +import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ApiResponse; +import feign.FeignException; +import org.springframework.beans.factory.annotation.Value; /** 当前用户个人资料服务,必须同时匹配认证账号与当前租户成员关系。 */ @Service public class PersonalProfileService { - private final MemberRepository repository; - public PersonalProfileService(MemberRepository repository) { this.repository = repository; } + @Resource + private MemberRepository repository; + @Resource + private AuthAccountClient authClient; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; public PersonalProfileResponse get(long tenantId, Long authUserId, String username) { return response(tenantId, authUserId, username); @@ -29,12 +39,40 @@ public class PersonalProfileService { if (membership == null || !"ACTIVE".equals(membership.getStatus())) throw forbidden(); IamUser user = repository.findUser(membership.getUserId()); if (user == null || !authUserId.equals(user.getAuthUserId())) throw forbidden(); - user.setDisplayName(request.displayName()); user.setPhone(request.phone()); user.setEmail(request.email()); + user.setDisplayName(request.displayName()); user.setEmail(request.email()); repository.saveUser(user); return new PersonalProfileResponse(user.getId(), authUserId, username, user.getDisplayName(), user.getPhone(), user.getEmail()); } + @Transactional + @BusinessAudit(event = "PERSONAL_LOGIN_IDENTIFIERS_UPDATE", resourceType = "IAM_USER", + resourceId = "#authUserId", description = "修改本人用户名和手机号", captureAfter = false) + public PersonalProfileResponse changeLoginIdentifiers(long tenantId, Long authUserId, + ChangeLoginIdentifiersRequest request) { + IamMembership membership = repository.findByTenantAndAuthUser(tenantId, authUserId); + if (membership == null || !"ACTIVE".equals(membership.getStatus())) throw forbidden(); + IamUser user = repository.findUser(membership.getUserId()); + if (user == null || !authUserId.equals(user.getAuthUserId())) throw forbidden(); + AuthAccountClient.AccountSummary account; + try { + ApiResponse result = authClient.changeLoginIdentifiers( + internalToken, authUserId, new AuthAccountClient.ChangeLoginIdentifiersRequest( + request.username(), request.phone(), request.currentPassword())); + if (result == null || !result.successful() || result.data() == null) { + ResultCode code = result == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(result.code()); + throw new BizException(code, result == null ? "认证账号服务暂时不可用" : result.message()); + } + account = result.data(); + } catch (FeignException ex) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); + } + user.setPhone(account.phone()); + repository.saveUser(user); + return new PersonalProfileResponse(user.getId(), authUserId, account.username(), + user.getDisplayName(), user.getPhone(), user.getEmail()); + } + private PersonalProfileResponse response(long tenantId, Long authUserId, String username) { IamMembership membership = repository.findByTenantAndAuthUser(tenantId, authUserId); if (membership == null) throw forbidden(); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java new file mode 100644 index 000000000..8f94a889d --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -0,0 +1,373 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.audit.BusinessAudit; +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.AuthAccountClient; +import com.baiyue.iam.dto.PlatformInternalUserDtos.*; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.repository.MemberRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import feign.FeignException; +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.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; + +/** 平台内部用户编排服务:IAM 保存人员和角色,账号凭据只由 Auth 保存。 */ +@Service +public class PlatformInternalUserService { + private static final long PLATFORM_TENANT_ID = 0L; + private static final Logger log = LoggerFactory.getLogger(PlatformInternalUserService.class); + + @Resource + private MemberRepository repository; + @Resource + private IamRoleMapper roleMapper; + @Resource + private AuthAccountClient authClient; + @Resource + private SecurityEventOutboxService securityEvents; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + + @Transactional(readOnly = true) + public List list() { + return repository.findByTenant(PLATFORM_TENANT_ID).stream() + .map(this::response).toList(); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_INTERNAL_USER_CREATE", resourceType = "MEMBERSHIP", + resourceId = "#request.username", description = "新增平台内部用户", captureAfter = false) + public InternalUserResponse create(CreateInternalUserRequest request) { + validateLegacyType(request.userType()); + List roleIds = normalizeRoles(request.roleIds()); + validateRoles(roleIds); + + if (request.authUserId() != null) return linkExistingAccount(request, roleIds); + if (!StringUtils.hasText(request.username()) || !StringUtils.hasText(request.password())) { + throw new BizException(ResultCode.BAD_REQUEST, "新建平台用户时登录名和密码必填"); + } + + long authUserId = IdWorker.getId(); + long userId = IdWorker.getId(); + long membershipId = IdWorker.getId(); + String memberNo = memberNo(request.memberNo(), membershipId); + validateMemberNo(memberNo, null); + + call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest( + authUserId, request.username(), request.phone(), request.password(), request.displayName(), + PLATFORM_TENANT_ID, membershipId, "PLATFORM", "ACTIVE"))); + try { + IamUser user = newUser(userId, authUserId, request.displayName(), request.phone(), request.email()); + IamMembership membership = newMembership(membershipId, userId, memberNo); + 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); + throw localFailure; + } + } + + @Transactional + @BusinessAudit(event = "PLATFORM_INTERNAL_USER_UPDATE", resourceType = "MEMBERSHIP", + resourceId = "#membershipId", description = "编辑平台内部用户", captureAfter = false) + public InternalUserResponse update(Long membershipId, UpdateInternalUserRequest request) { + List roleIds = normalizeRoles(request.roleIds()); + validateRoles(roleIds); + validateMemberNo(request.memberNo(), membershipId); + IamMembership membership = required(membershipId); + IamUser user = requiredUser(membership.getUserId()); + AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId()); + boolean displayNameChanged = !Objects.equals(user.getDisplayName(), request.displayName()); + boolean phoneChanged = !Objects.equals(user.getPhone(), request.phone()); + List oldRoleIds = repository.findRoleIds(PLATFORM_TENANT_ID, membershipId); + boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds)); + if (displayNameChanged) { + call(() -> authClient.profile(internalToken, user.getAuthUserId(), + new AuthAccountClient.ProfileRequest(request.displayName()))); + } + if (phoneChanged) { + call(() -> authClient.phone(internalToken, user.getAuthUserId(), + new AuthAccountClient.PhoneRequest(request.phone()))); + } + try { + user.setDisplayName(request.displayName()); + user.setPhone(request.phone()); + user.setEmail(request.email()); + membership.setMemberNo(request.memberNo()); + repository.saveUser(user); + repository.save(membership); + repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + if (rolesChanged && !displayNameChanged) revokeSessions(user.getAuthUserId()); + } catch (RuntimeException localFailure) { + if (displayNameChanged) compensateProfile(user.getAuthUserId(), oldAccount.displayName(), localFailure); + throw localFailure; + } + return response(membership, user, request.displayName(), roleIds, oldAccount.username()); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_INTERNAL_USER_STATUS_CHANGE", resourceType = "MEMBERSHIP", + resourceId = "#membershipId", description = "启停平台内部用户", captureAfter = false) + public InternalUserResponse changeStatus(Long membershipId, ChangeInternalUserStatusRequest request, + Long operatorMembershipId) { + if (!"ACTIVE".equals(request.status()) && !"DISABLED".equals(request.status())) { + throw new BizException(ResultCode.BAD_REQUEST, "平台内部用户状态只允许 ACTIVE 或 DISABLED"); + } + IamMembership membership = required(membershipId); + if (membershipId.equals(operatorMembershipId) && "DISABLED".equals(request.status())) { + throw new BizException(ResultCode.BAD_REQUEST, "不能停用当前登录用户"); + } + IamUser user = requiredUser(membership.getUserId()); + List roleIds = repository.findRoleIds(PLATFORM_TENANT_ID, membershipId); + AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId()); + call(() -> authClient.status(internalToken, user.getAuthUserId(), + new AuthAccountClient.StatusRequest(request.status()))); + try { + 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(), PLATFORM_TENANT_ID, "PLATFORM", request.status()); + securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", + "PLATFORM_INTERNAL_USER_STATUS_CHANGED"); + return response(membership, user, authAccount.username(), roleIds); + } catch (RuntimeException localFailure) { + compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure); + throw localFailure; + } + } + + @BusinessAudit(event = "PLATFORM_INTERNAL_USER_PASSWORD_RESET", resourceType = "MEMBERSHIP", + resourceId = "#membershipId", description = "重置平台内部用户密码", + captureBefore = false, captureAfter = false) + public void resetPassword(Long membershipId, ResetInternalUserPasswordRequest request) { + IamMembership membership = required(membershipId); + IamUser user = requiredUser(membership.getUserId()); + call(() -> authClient.reset(internalToken, user.getAuthUserId(), + new AuthAccountClient.PasswordRequest(request.newPassword()))); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_INTERNAL_USER_ROLE_ASSIGN", resourceType = "MEMBERSHIP", + resourceId = "#membershipId", description = "分配平台内部用户角色", captureAfter = false) + public InternalUserResponse assignRoles(Long membershipId, AssignInternalUserRolesRequest request) { + IamMembership membership = required(membershipId); + List roleIds = normalizeRoles(request.roleIds()); + validateRoles(roleIds); + IamUser user = requiredUser(membership.getUserId()); + List currentRoleIds = repository.findRoleIds(PLATFORM_TENANT_ID, membershipId); + 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); + } + + private InternalUserResponse linkExistingAccount(CreateInternalUserRequest request, List roleIds) { + IamUser existingUser = repository.findUserByAuthId(request.authUserId()); + IamMembership existingMembership = repository.findByTenantAndAuthUser( + PLATFORM_TENANT_ID, request.authUserId()); + if (existingMembership != null) return response(existingMembership); + if (existingUser != null && !"INTERNAL".equals(existingUser.getUserType())) { + throw new BizException(ResultCode.CONFLICT, "认证账号已关联其他类型的 IAM 用户"); + } + AuthAccountClient.AccountSummary authAccount = account(request.authUserId()); + long membershipId = IdWorker.getId(); + String memberNo = memberNo(request.memberNo(), membershipId); + validateMemberNo(memberNo, null); + IamUser user = existingUser == null + ? newUser(IdWorker.getId(), request.authUserId(), request.displayName(), request.phone(), request.email()) + : existingUser; + if (existingUser == null) repository.insertUser(user); + 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()); + return response(membership, user, authAccount.username(), + repository.findRoleIds(PLATFORM_TENANT_ID, membership.getId())); + } + + private InternalUserResponse response(IamMembership membership, IamUser user, + String username, List roleIds) { + return response(membership, user, user.getDisplayName(), roleIds, username); + } + + private InternalUserResponse response(IamMembership membership, IamUser user, String displayName, + List roleIds, String username) { + return new InternalUserResponse(membership.getId(), user.getId(), user.getAuthUserId(), username, + displayName, user.getPhone(), user.getEmail(), membership.getMemberType(), + membership.getMemberNo(), membership.getStatus(), List.copyOf(roleIds)); + } + + private IamUser newUser(long userId, long authUserId, String displayName, String phone, String email) { + IamUser user = new IamUser(); + user.setId(userId); + user.setAuthUserId(authUserId); + user.setUserType("INTERNAL"); + user.setDisplayName(displayName); + user.setPhone(phone); + user.setEmail(email); + user.setStatus("ACTIVE"); + return user; + } + + private IamMembership newMembership(long membershipId, long userId, String memberNo) { + IamMembership membership = new IamMembership(); + membership.setId(membershipId); + membership.setTenantId(PLATFORM_TENANT_ID); + membership.setUserId(userId); + membership.setMemberType("EMPLOYEE"); + membership.setMemberNo(memberNo); + membership.setStatus("ACTIVE"); + membership.setJoinedAt(LocalDateTime.now()); + return membership; + } + + private IamMembership required(Long membershipId) { + IamMembership membership = repository.findByTenantAndId(PLATFORM_TENANT_ID, membershipId); + if (membership == null) throw new BizException(ResultCode.NOT_FOUND, "平台内部用户不存在"); + return membership; + } + + private IamUser requiredUser(Long userId) { + IamUser user = repository.findUser(userId); + if (user == null || !"INTERNAL".equals(user.getUserType())) { + throw new BizException(ResultCode.NOT_FOUND, "平台内部用户档案不存在"); + } + return user; + } + + private List normalizeRoles(List roleIds) { + if (roleIds == null) return List.of(); + if (roleIds.stream().anyMatch(Objects::isNull)) { + throw new BizException(ResultCode.BAD_REQUEST, "角色 ID 不能为空"); + } + return List.copyOf(new LinkedHashSet<>(roleIds)); + } + + private void validateRoles(List roleIds) { + for (Long roleId : roleIds) { + IamRole role = roleMapper.selectById(roleId); + if (role == null || role.getTenantId() == null || role.getTenantId() != PLATFORM_TENANT_ID + || !"PLATFORM".equals(role.getAppCode()) || !"ACTIVE".equals(role.getStatus())) { + throw new BizException(ResultCode.FORBIDDEN, "禁止分配其他租户、其他应用或已停用角色"); + } + } + } + + private void validateMemberNo(String memberNo, Long excludedMembershipId) { + if (repository.existsMemberNo(PLATFORM_TENANT_ID, memberNo, excludedMembershipId)) { + throw new BizException(ResultCode.CONFLICT, "平台成员编号已存在"); + } + } + + private String memberNo(String requested, long membershipId) { + return StringUtils.hasText(requested) ? requested.trim() : "PLATFORM-" + membershipId; + } + + private void validateLegacyType(String userType) { + if (StringUtils.hasText(userType) + && !"INTERNAL".equals(userType) && !"PLATFORM".equals(userType)) { + throw new BizException(ResultCode.BAD_REQUEST, "平台内部用户类型必须是 INTERNAL"); + } + } + + private AuthAccountClient.AccountSummary account(Long authUserId) { + return data(call(() -> authClient.summary(internalToken, authUserId))); + } + + private void revokeSessions(Long authUserId) { + call(() -> authClient.revokeSessions(internalToken, + new AuthAccountClient.RevokeAccountSessionsRequest(java.util.Set.of(authUserId)))); + } + + private void compensateCreate(long authUserId, RuntimeException localFailure) { + try { + call(() -> authClient.compensate(internalToken, authUserId)); + } catch (RuntimeException compensationFailure) { + log.error("平台用户创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "平台用户创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); + } + } + + private void compensateProfile(long authUserId, String oldDisplayName, RuntimeException localFailure) { + try { + call(() -> authClient.profile(internalToken, authUserId, + new AuthAccountClient.ProfileRequest(oldDisplayName))); + } catch (RuntimeException compensationFailure) { + log.error("平台用户编辑失败且认证资料补偿失败,authUserId={}", authUserId, compensationFailure); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "平台用户编辑未完成,认证资料补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); + } + } + + private void compensateStatus(long authUserId, String oldStatus, RuntimeException localFailure) { + try { + call(() -> authClient.status(internalToken, authUserId, + new AuthAccountClient.StatusRequest(oldStatus))); + } catch (RuntimeException compensationFailure) { + log.error("平台用户状态修改失败且认证状态补偿失败,authUserId={}", authUserId, compensationFailure); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "平台用户状态未完成,认证状态补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); + } + } + + private ApiResponse call(RemoteCall action) { + try { + ApiResponse result = action.call(); + if (result == null || !result.successful()) throw unavailable(); + return result; + } catch (FeignException exception) { + if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "登录账号已存在"); + if (exception.status() == 404) throw new BizException(ResultCode.NOT_FOUND, "认证账号不存在"); + throw unavailable(); + } + } + + private T data(ApiResponse response) { + if (response.data() == null) throw unavailable(); + return response.data(); + } + + private BizException unavailable() { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); + } + + @FunctionalInterface + private interface RemoteCall { ApiResponse call(); } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java new file mode 100644 index 000000000..fa73ece19 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -0,0 +1,380 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.audit.BusinessAudit; +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.iam.client.AuthAccountClient; +import com.baiyue.iam.dto.PlatformUserManagementDtos.*; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.repository.MemberRepository; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import feign.FeignException; +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.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 平台管理员维护内部登录用户;凭据仍只由 auth-service 保存。 */ +@Service +public class PlatformUserManagementService { + private static final long PLATFORM_TENANT_ID = 0L; + private static final Logger log = LoggerFactory.getLogger(PlatformUserManagementService.class); + + @Resource + private MemberRepository members; + @Resource + private IamRoleMapper roleMapper; + @Resource + private AuthAccountClient authClient; + @Resource + private SecurityEventOutboxService securityEvents; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + + @Transactional(readOnly = true) + public PageResult page(long page, long size, String keyword, String status) { + PageResult source = members.pageByTenant( + PLATFORM_TENANT_ID, page, size, keyword, status); + Map roleMap = platformRoles().stream() + .collect(Collectors.toMap(IamRole::getId, Function.identity())); + return new PageResult<>(source.records().stream() + .map(membership -> response(membership, roleMap)).toList(), + source.page(), source.size(), source.total(), source.pages()); + } + + @Transactional(readOnly = true) + public UserOptions options() { + List tenantOptions = List.of(new TenantOption(PLATFORM_TENANT_ID, "平台", "ACTIVE")); + List roleOptions = platformRoles().stream() + .map(value -> new RoleOption(value.getId(), value.getTenantId(), value.getRoleName(), value.getStatus())) + .toList(); + return new UserOptions(List.copyOf(tenantOptions), roleOptions); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_USER_CREATE", resourceType = "MEMBERSHIP", + resourceId = "#request.username", description = "平台新增用户", captureAfter = false) + public UserResponse create(CreateUserRequest request) { + 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(); + String appCode = appCode(tenantId); + call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest( + authUserId, request.username().trim(), request.phone().trim(), request.password(), + request.displayName().trim(), tenantId, membershipId, appCode, request.status()))); + try { + IamUser user = new IamUser(); + user.setId(userId); + user.setAuthUserId(authUserId); + user.setUserType(userType(tenantId)); + user.setDisplayName(request.displayName().trim()); + user.setPhone(request.phone().trim()); + user.setEmail(trimToNull(request.email())); + user.setStatus(request.status()); + members.insertUser(user); + + IamMembership membership = new IamMembership(); + membership.setId(membershipId); + membership.setTenantId(tenantId); + 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); + throw localFailure; + } + } + + @Transactional + @BusinessAudit(event = "PLATFORM_USER_UPDATE", resourceType = "MEMBERSHIP", + resourceId = "#membershipId", description = "平台编辑用户", captureAfter = false) + public UserResponse update(Long membershipId, UpdateUserRequest request, Set operatorPermissions) { + IamMembership membership = requiredPlatformMembership(membershipId); + IamUser user = requiredUser(membership.getUserId()); + long oldTenantId = membership.getTenantId(); + 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))) { + throw new BizException(ResultCode.FORBIDDEN, "系统管理员不能更换租户或移除超级管理员角色"); + } + if ((oldTenantId != newTenantId || !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds))) + && (operatorPermissions == null || !operatorPermissions.contains("platform:user:grant-role"))) { + throw new BizException(ResultCode.FORBIDDEN, "变更用户所属租户或角色需要分配角色权限"); + } + AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId()); + boolean profileChanged = !Objects.equals(user.getDisplayName(), request.displayName().trim()); + boolean phoneChanged = !Objects.equals(user.getPhone(), request.phone().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()))); + if (phoneChanged) call(() -> authClient.phone(internalToken, user.getAuthUserId(), + new AuthAccountClient.PhoneRequest(request.phone().trim()))); + if (scopeChanged) call(() -> authClient.scope(internalToken, user.getAuthUserId(), + new AuthAccountClient.ScopeRequest(newTenantId, membershipId, appCode(newTenantId)))); + try { + user.setDisplayName(request.displayName().trim()); + user.setPhone(request.phone().trim()); + 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)); + } + members.saveUser(user); + members.save(membership); + if (scopeChanged) members.replaceRoles(oldTenantId, membershipId, List.of()); + members.replaceRoles(newTenantId, membershipId, roleIds); + if ((rolesChanged || warehouseChanged) && !profileChanged && !scopeChanged) { + call(() -> authClient.revokeSessions(internalToken, + new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); + } + if (rolesChanged || warehouseChanged || scopeChanged) { + securityEvents.permissionChanged(user.getAuthUserId(), newTenantId, + appCode(newTenantId), "PLATFORM_USER_AUTHORIZATION_CHANGED"); + } + } catch (RuntimeException localFailure) { + compensateUpdate(user.getAuthUserId(), membershipId, oldTenantId, oldAccount, + profileChanged, scopeChanged, localFailure); + throw localFailure; + } + return response(membership); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_USER_STATUS_CHANGE", resourceType = "MEMBERSHIP", + resourceId = "#membershipId", description = "平台启停用户", captureAfter = false) + public UserResponse changeStatus(Long membershipId, ChangeStatusRequest request, Long operatorMembershipId) { + IamMembership membership = requiredPlatformMembership(membershipId); + if (membershipId.equals(operatorMembershipId) && "DISABLED".equals(request.status())) { + throw new BizException(ResultCode.BAD_REQUEST, "不能停用当前登录用户"); + } + IamUser user = requiredUser(membership.getUserId()); + if ("DISABLED".equals(request.status()) + && isProtectedPlatformAdmin(membership, + members.findRoleIds(membership.getTenantId(), membershipId))) { + throw new BizException(ResultCode.FORBIDDEN, "系统管理员不能被禁用"); + } + AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId()); + call(() -> authClient.status(internalToken, user.getAuthUserId(), + new AuthAccountClient.StatusRequest(request.status()))); + try { + membership.setStatus(request.status()); + membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); + user.setStatus(request.status()); + members.saveUser(user); + members.save(membership); + securityEvents.userStatus(user.getAuthUserId(), membership.getTenantId(), + appCode(membership.getTenantId()), request.status()); + securityEvents.permissionChanged(user.getAuthUserId(), membership.getTenantId(), + appCode(membership.getTenantId()), "PLATFORM_USER_STATUS_CHANGED"); + } catch (RuntimeException localFailure) { + call(() -> authClient.status(internalToken, user.getAuthUserId(), + new AuthAccountClient.StatusRequest(oldAccount.status()))); + throw localFailure; + } + return response(membership); + } + + @BusinessAudit(event = "PLATFORM_USER_PASSWORD_RESET", resourceType = "MEMBERSHIP", + resourceId = "#membershipId", description = "平台重置用户密码", + captureBefore = false, captureAfter = false) + public void resetPassword(Long membershipId, ResetPasswordRequest request) { + IamUser user = requiredUser(requiredPlatformMembership(membershipId).getUserId()); + call(() -> authClient.reset(internalToken, user.getAuthUserId(), + new AuthAccountClient.PasswordRequest(request.newPassword()))); + } + + private UserResponse response(IamMembership membership) { + Map roleMap = platformRoles().stream() + .collect(Collectors.toMap(IamRole::getId, Function.identity())); + return response(membership, roleMap); + } + + private UserResponse response(IamMembership membership, Map roleMap) { + IamUser user = requiredUser(membership.getUserId()); + AuthAccountClient.AccountSummary account = account(user.getAuthUserId()); + List roleIds = members.findRoleIds(membership.getTenantId(), membership.getId()); + List roleNames = roleIds.stream().map(roleMap::get).filter(Objects::nonNull) + .map(IamRole::getRoleName).toList(); + String tenantName = "平台"; + 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), + membership.getOverseasWarehouseId()); + } + + private List platformRoles() { + return roleMapper.selectList(new LambdaQueryWrapper() + .eq(IamRole::getTenantId, PLATFORM_TENANT_ID) + .eq(IamRole::getAppCode, "PLATFORM") + .orderByAsc(IamRole::getRoleName)); + } + + private List validateRoles(long tenantId, List requested) { + List roleIds = List.copyOf(new LinkedHashSet<>(requested == null ? List.of() : requested)); + if (roleIds.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "请选择角色"); + for (Long roleId : roleIds) { + IamRole role = roleMapper.selectById(roleId); + if (role == null || !Objects.equals(role.getTenantId(), tenantId) + || !appCode(tenantId).equals(role.getAppCode()) || !"ACTIVE".equals(role.getStatus())) { + throw new BizException(ResultCode.FORBIDDEN, "角色必须属于所选租户且处于启用状态"); + } + } + return roleIds; + } + + private Long validateWarehouseBinding(List roleIds, Long overseasWarehouseId) { + Set roleCodes = roleIds.stream().map(roleMapper::selectById).filter(Objects::nonNull) + .map(IamRole::getRoleCode).collect(Collectors.toSet()); + boolean overseasPda = roleCodes.contains("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); + boolean domesticPda = roleCodes.contains("CN_FIRSTLEG_PDA_OPERATOR"); + if (overseasPda && domesticPda) { + throw new BizException(ResultCode.BAD_REQUEST, "国内头程 PDA 与海外仓 PDA 角色不能同时分配"); + } + if (overseasPda && (overseasWarehouseId == null || overseasWarehouseId <= 0)) { + throw new BizException(ResultCode.BAD_REQUEST, "海外仓 PDA 作业员必须绑定一个有效海外仓"); + } + if (!overseasPda && overseasWarehouseId != null) { + throw new BizException(ResultCode.BAD_REQUEST, "仅海外仓 PDA 作业员可绑定海外仓"); + } + return overseasWarehouseId; + } + + private boolean isProtectedPlatformAdmin(IamMembership membership, List roleIds) { + return membership.getTenantId() == PLATFORM_TENANT_ID && containsPlatformAdminRole(roleIds); + } + + private boolean containsPlatformAdminRole(List roleIds) { + return roleIds.stream().map(roleMapper::selectById).filter(Objects::nonNull) + .anyMatch(role -> "PLATFORM_ADMIN".equals(role.getRoleCode())); + } + + private void requirePlatformTenant(long tenantId) { + if (tenantId != PLATFORM_TENANT_ID) { + throw new BizException(ResultCode.FORBIDDEN, "内部用户只能属于平台租户"); + } + } + + private IamMembership required(Long membershipId) { + IamMembership membership = members.findById(membershipId); + if (membership == null) throw new BizException(ResultCode.NOT_FOUND, "用户不存在"); + return membership; + } + + private IamMembership requiredPlatformMembership(Long membershipId) { + IamMembership membership = required(membershipId); + if (membership.getTenantId() != PLATFORM_TENANT_ID) { + throw new BizException(ResultCode.NOT_FOUND, "平台内部用户不存在"); + } + return membership; + } + + private IamUser requiredUser(Long userId) { + IamUser user = members.findUser(userId); + if (user == null) throw new BizException(ResultCode.NOT_FOUND, "用户档案不存在"); + return user; + } + + private AuthAccountClient.AccountSummary account(Long authUserId) { + ApiResponse result = call(() -> authClient.summary(internalToken, authUserId)); + if (result.data() == null) throw unavailable(); + return result.data(); + } + + private String appCode(long tenantId) { return tenantId == PLATFORM_TENANT_ID ? "PLATFORM" : "MERCHANT"; } + private String userType(long tenantId) { return tenantId == PLATFORM_TENANT_ID ? "INTERNAL" : "ENTERPRISE"; } + private String memberNo(long tenantId, long membershipId) { + return (tenantId == PLATFORM_TENANT_ID ? "PLATFORM-" : "MEMBER-") + membershipId; + } + private String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private void compensateCreate(long authUserId, RuntimeException localFailure) { + try { + call(() -> authClient.compensate(internalToken, authUserId)); + } catch (RuntimeException compensationFailure) { + log.error("内部用户创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "用户创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); + } + } + + private void compensateUpdate(long authUserId, long membershipId, long oldTenantId, + AuthAccountClient.AccountSummary oldAccount, boolean profileChanged, + boolean scopeChanged, RuntimeException localFailure) { + try { + if (scopeChanged) call(() -> authClient.scope(internalToken, authUserId, + new AuthAccountClient.ScopeRequest(oldTenantId, membershipId, appCode(oldTenantId)))); + if (profileChanged) call(() -> authClient.profile(internalToken, authUserId, + new AuthAccountClient.ProfileRequest(oldAccount.displayName()))); + } catch (RuntimeException compensationFailure) { + log.error("内部用户编辑失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "用户编辑未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); + } + } + + private ApiResponse call(RemoteCall action) { + try { + ApiResponse result = action.call(); + if (result == null) throw unavailable(); + if (!result.successful()) { + ResultCode code = ResultCode.of(result.code()); + throw new BizException(code, result.message() == null || result.message().isBlank() + ? code.message() : result.message()); + } + return result; + } catch (FeignException exception) { + throw unavailable(); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw unavailable(); + } + } + + private BizException unavailable() { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); + } + @FunctionalInterface private interface RemoteCall { ApiResponse call(); } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java index 716f31950..4b1cc284a 100644 --- a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java +++ b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java @@ -5,14 +5,17 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse; +import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest; +import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationResponse; +import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest; +import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageResponse; import com.baiyue.iam.dto.ProfileDtos.AddressRequest; -import com.baiyue.iam.dto.ProfileDtos.CreateUserRequest; -import com.baiyue.iam.dto.ProfileDtos.QualificationRequest; +import com.baiyue.iam.dto.ProfileDtos.QualificationDeletionResponse; +import com.baiyue.iam.dto.ProfileDtos.QualificationResponse; import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenantAddress; import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.entity.IamTenantReview; -import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.TenantRepository; import com.baomidou.mybatisplus.core.toolkit.IdWorker; @@ -29,16 +32,6 @@ public class ProfileResourceService { @Resource private ProfileResourceRepository repository; @Resource private TenantRepository tenantRepository; - @BusinessAudit(event = "INTERNAL_USER_CREATE", resourceType = "IAM_USER", - resourceId = "#q.authUserId", description = "新增平台内部用户") - public IamUser addUser(CreateUserRequest q) { - IamUser u = new IamUser(); - u.setId(IdWorker.getId()); u.setAuthUserId(q.authUserId()); u.setUserType(q.userType()); - u.setDisplayName(q.displayName()); u.setPhone(q.phone()); u.setEmail(q.email()); u.setStatus("ACTIVE"); - repository.insertUser(u); return u; - } - - public List internalUsers() { return repository.findInternalUsers(); } public List addresses(long tenant) { return repository.findAddresses(tenant); } @BusinessAudit(event = "TENANT_ADDRESS_CREATE", resourceType = "TENANT_ADDRESS", @@ -52,54 +45,277 @@ public class ProfileResourceService { repository.insertAddress(a); return a; } - public List qualifications(long tenant) { return repository.findQualifications(tenant); } + public List qualifications(long tenant) { + return repository.findQualifications(tenant).stream().map(this::toQualificationResponse).toList(); + } - public IamTenantQualification addQualification(long tenant, QualificationRequest q) { - throw new BizException(ResultCode.CONFLICT, - "Upload the business license through the qualification file API"); + public QualificationResponse qualification(long tenant, long qualificationId) { + IamTenantQualification qualification = repository.findQualification(tenant, qualificationId); + if (qualification == null) throw new BizException(ResultCode.NOT_FOUND, "Qualification not found / 企业资质不存在"); + return toQualificationResponse(qualification); + } + + public List platformAddresses(long tenantId) { + requireMerchant(tenantId); + return repository.findAddresses(tenantId); + } + + public List platformQualifications(long tenantId) { + requireMerchant(tenantId); + return qualifications(tenantId); + } + + public QualificationResponse platformQualification(long tenantId, long qualificationId) { + requireMerchant(tenantId); + return qualification(tenantId, qualificationId); } @Transactional - @BusinessAudit(event = "TENANT_QUALIFICATION_SUBMIT", resourceType = "TENANT_QUALIFICATION", - resourceId = "#request.tenantId", description = "提交企业资质", captureAfter = false) + @BusinessAudit(event = "TENANT_QUALIFICATION_DELETE", resourceType = "TENANT_QUALIFICATION", + resourceId = "#qualificationId", description = "删除送审前企业资质", captureAfter = false) + public QualificationDeletionResponse deleteQualification(long tenantId, long qualificationId) { + IamTenant tenant = tenantRepository.findTenantForUpdate(tenantId); + if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在"); + } + if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, + "Qualifications cannot be deleted after review submission / 资质提交审核后不可删除"); + } + IamTenantQualification qualification = repository.findQualificationForUpdate(tenantId, qualificationId); + if (qualification == null) { + return new QualificationDeletionResponse(qualificationId, null); + } + String documentUrl = qualification.getDocumentUrl(); + repository.deleteQualification(qualification); + return new QualificationDeletionResponse(qualificationId, documentUrl); + } + + @Transactional + @BusinessAudit(event = "TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION", + resourceId = "#request.tenantId", description = "上传企业资质", captureAfter = false) public QualificationSubmissionResponse submitQualification(QualificationSubmissionRequest request) { - IamTenant tenant = tenantRepository.findTenant(request.tenantId()); + IamTenant tenant = tenantRepository.findTenantForUpdate(request.tenantId()); if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found"); } - if ("ACTIVE".equals(tenant.getStatus())) { - throw new BizException(ResultCode.CONFLICT, "Enterprise is already approved"); + if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, + "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质"); } - if (tenantRepository.findPendingReviewByTenant(tenant.getId()) != null) { - throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review"); + requireOptionalQualificationType(request.qualificationType()); + String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo()); + List activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream() + .filter(item -> request.qualificationType().equals(item.getQualificationType()) + && isCurrentQualification(item)) + .toList(); + if (!activeQualifications.isEmpty() && !request.replace()) { + throw new BizException(ResultCode.CONFLICT, "Qualification type already uploaded / 该类资质已上传"); } - if (repository.findQualifications(tenant.getId()).stream() - .anyMatch(item -> !"REJECTED".equals(item.getStatus()))) { - throw new BizException(ResultCode.CONFLICT, "Phase one permits one enterprise qualification image"); + String replacedDocumentUrl = null; + for (IamTenantQualification existing : activeQualifications) { + if (replacedDocumentUrl == null) { + replacedDocumentUrl = existing.getDocumentUrl(); + } + existing.setStatus("SUPERSEDED"); + existing.setReviewComment("商户在送审前替换文件"); + repository.saveQualification(existing); } - IamTenantQualification qualification = createQualification(tenant.getId(), - request.qualificationType(), request.certificateNo(), request.documentUrl()); - if (StringUtils.hasText(request.certificateNo()) - && request.certificateNo().trim().toUpperCase().matches("[0-9A-Z]{18}")) { - tenant.setUnifiedSocialCreditCode(request.certificateNo().trim().toUpperCase()); - } - tenant.setStatus("PENDING"); - tenant.setRejectReason(null); - tenantRepository.saveTenant(tenant); - - IamTenantReview review = new IamTenantReview(); - review.setId(IdWorker.getId()); review.setTenantId(tenant.getId()); - review.setReviewType("REGISTER"); review.setStatus("PENDING"); - review.setCreatedAt(LocalDateTime.now()); - tenantRepository.insertReview(review); - return new QualificationSubmissionResponse(qualification.getId(), review.getId(), tenant.getStatus()); + request.qualificationType(), certificateNo, request.documentUrl(), request.originalFileName()); + qualification.setValidTo(request.validTo()); + repository.saveQualification(qualification); + return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl); } - private IamTenantQualification createQualification(long tenant, String type, String certificateNo, String url) { + @Transactional + @BusinessAudit(event = "PLATFORM_TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION", + resourceId = "#request.tenantId", description = "平台代客户上传资质", captureAfter = false) + public PlatformQualificationRegistrationResponse registerPlatformQualification(PlatformQualificationRegistrationRequest request) { + IamTenant tenant = tenantRepository.findTenantForUpdate(request.tenantId()); + if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在"); + } + if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质"); + } + requireOptionalQualificationType(request.qualificationType()); + String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo()); + List activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream() + .filter(item -> request.qualificationType().equals(item.getQualificationType()) + && isCurrentQualification(item)) + .toList(); + if (!activeQualifications.isEmpty() && !request.replace()) { + throw new BizException(ResultCode.CONFLICT, "Qualification type already uploaded / 该类资质已上传"); + } + String replacedDocumentUrl = null; + for (IamTenantQualification existing : activeQualifications) { + if (replacedDocumentUrl == null) { + replacedDocumentUrl = existing.getDocumentUrl(); + } + existing.setStatus("SUPERSEDED"); + existing.setReviewComment("平台在送审前替换文件"); + repository.saveQualification(existing); + } + IamTenantQualification qualification = createQualification(tenant.getId(), request.qualificationType(), + certificateNo, request.documentUrl(), request.originalFileName()); + qualification.setValidTo(request.validTo()); + repository.saveQualification(qualification); + return new PlatformQualificationRegistrationResponse( + qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl); + } + + @Transactional + @BusinessAudit(event = "TENANT_REQUIRED_QUALIFICATION_PACKAGE_UPLOAD", resourceType = "TENANT_QUALIFICATION", + resourceId = "#request.tenantId", description = "上传企业必填资质包", captureAfter = false) + public RequiredQualificationPackageResponse submitRequiredQualificationPackage( + RequiredQualificationPackageRequest request) { + return registerRequiredQualificationPackage(request, "商户在送审前替换文件"); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_REQUIRED_QUALIFICATION_PACKAGE_UPLOAD", resourceType = "TENANT_QUALIFICATION", + resourceId = "#request.tenantId", description = "平台代客户上传必填资质包", captureAfter = false) + public RequiredQualificationPackageResponse registerPlatformRequiredQualificationPackage( + RequiredQualificationPackageRequest request) { + return registerRequiredQualificationPackage(request, "平台在送审前替换文件"); + } + + private RequiredQualificationPackageResponse registerRequiredQualificationPackage( + RequiredQualificationPackageRequest request, String replacementComment) { + IamTenant tenant = tenantRepository.findTenantForUpdate(request.tenantId()); + if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在"); + } + if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, + "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质"); + } + String creditCode = resolveCertificateNo(tenant, "BUSINESS_LICENSE", request.unifiedSocialCreditCode()); + List locked = repository.findQualificationsForUpdate(tenant.getId()); + List businessLicenses = currentQualifications(locked, "BUSINESS_LICENSE"); + List legalPersonIds = currentQualifications(locked, "LEGAL_PERSON_ID"); + requireReplaceAllowed(businessLicenses, request.replaceBusinessLicense(), "营业执照"); + boolean hasLegalPersonId = StringUtils.hasText(request.legalPersonIdDocumentUrl()); + if (hasLegalPersonId) { + normalizeOriginalFileName(request.legalPersonIdOriginalFileName()); + requireReplaceAllowed(legalPersonIds, Boolean.TRUE.equals(request.replaceLegalPersonId()), "法人身份证"); + } else if (Boolean.TRUE.equals(request.replaceLegalPersonId())) { + throw new BizException(ResultCode.BAD_REQUEST, "未上传法人身份证时不能选择替换"); + } + + String replacedBusinessLicenseUrl = supersede(businessLicenses, replacementComment); + String replacedLegalPersonIdUrl = hasLegalPersonId ? supersede(legalPersonIds, replacementComment) : null; + IamTenantQualification businessLicense = createQualification(tenant.getId(), "BUSINESS_LICENSE", + creditCode, request.businessLicenseDocumentUrl(), request.businessLicenseOriginalFileName()); + businessLicense.setValidTo(request.businessLicenseValidTo()); + repository.saveQualification(businessLicense); + IamTenantQualification legalPersonId = null; + if (hasLegalPersonId) { + legalPersonId = createQualification(tenant.getId(), "LEGAL_PERSON_ID", + null, request.legalPersonIdDocumentUrl().trim(), request.legalPersonIdOriginalFileName()); + legalPersonId.setValidTo(request.legalPersonIdValidTo()); + repository.saveQualification(legalPersonId); + } + if (!creditCode.equals(tenant.getUnifiedSocialCreditCode())) { + tenant.setUnifiedSocialCreditCode(creditCode); + tenantRepository.saveTenant(tenant); + } + return new RequiredQualificationPackageResponse( + businessLicense.getId(), legalPersonId == null ? null : legalPersonId.getId(), tenant.getStatus(), + replacedBusinessLicenseUrl, replacedLegalPersonIdUrl); + } + + private List currentQualifications( + List qualifications, String type) { + return qualifications.stream() + .filter(item -> type.equals(item.getQualificationType()) && isCurrentQualification(item)) + .toList(); + } + + private void requireReplaceAllowed(List current, boolean replace, String label) { + if (!current.isEmpty() && !replace) { + throw new BizException(ResultCode.CONFLICT, label + "已上传,请明确选择替换"); + } + } + + private String supersede(List current, String comment) { + String replacedUrl = null; + for (IamTenantQualification existing : current) { + if (replacedUrl == null) replacedUrl = existing.getDocumentUrl(); + existing.setStatus("SUPERSEDED"); + existing.setReviewComment(comment); + repository.saveQualification(existing); + } + return replacedUrl; + } + + private void requireOptionalQualificationType(String type) { + if ("BUSINESS_LICENSE".equals(type)) { + throw new BizException(ResultCode.BAD_REQUEST, + "The business license must be uploaded in the required package / 营业执照和统一社会信用代码必须通过必填资质包上传"); + } + } + + private String resolveCertificateNo(IamTenant tenant, String qualificationType, String submittedCertificateNo) { + if (!"BUSINESS_LICENSE".equals(qualificationType)) { + return submittedCertificateNo; + } + String certificateNo = StringUtils.hasText(submittedCertificateNo) + ? submittedCertificateNo.trim().toUpperCase() + : tenant.getUnifiedSocialCreditCode(); + if (!StringUtils.hasText(certificateNo) || !certificateNo.matches("[0-9A-Z]{18}")) { + throw new BizException(ResultCode.BAD_REQUEST, + "A valid 18-character unified social credit code is required / 营业执照必须填写有效的18位统一社会信用代码"); + } + if (tenantRepository.existsByCreditCodeForOtherTenant(certificateNo, tenant.getId())) { + throw new BizException(ResultCode.CONFLICT, + "Unified social credit code already exists / 统一社会信用代码已存在"); + } + if (StringUtils.hasText(tenant.getUnifiedSocialCreditCode()) + && !certificateNo.equals(tenant.getUnifiedSocialCreditCode().trim().toUpperCase())) { + throw new BizException(ResultCode.CONFLICT, + "Unified social credit code cannot be changed / 统一社会信用代码已登记,营业执照代码必须与企业主档一致"); + } + return certificateNo; + } + + private IamTenantQualification createQualification(long tenant, String type, String certificateNo, String url, + String originalFileName) { IamTenantQualification x = new IamTenantQualification(); x.setId(IdWorker.getId()); x.setTenantId(tenant); x.setQualificationType(type); - x.setCertificateNo(certificateNo); x.setDocumentUrl(url); x.setStatus("PENDING"); + x.setCertificateNo(certificateNo); x.setDocumentUrl(url); + x.setOriginalFileName(normalizeOriginalFileName(originalFileName)); x.setStatus("PENDING"); repository.insertQualification(x); return x; } + + private String normalizeOriginalFileName(String originalFileName) { + if (!StringUtils.hasText(originalFileName)) { + throw new BizException(ResultCode.BAD_REQUEST, "Original file name is required / 必须提供上传时原始文件名"); + } + String normalized = originalFileName.trim(); + if (normalized.length() > 255) { + throw new BizException(ResultCode.BAD_REQUEST, "Original file name is too long / 原始文件名不能超过255个字符"); + } + return normalized; + } + + private QualificationResponse toQualificationResponse(IamTenantQualification item) { + return new QualificationResponse(item.getId(), item.getTenantId(), item.getQualificationType(), + item.getCertificateNo(), item.getDocumentUrl(), item.getOriginalFileName(), item.getValidFrom(), + item.getValidTo(), item.getStatus(), item.getReviewComment(), item.getCreatedAt(), item.getUpdatedAt()); + } + + private boolean isCurrentQualification(IamTenantQualification qualification) { + return !"REJECTED".equals(qualification.getStatus()) + && !"SUPERSEDED".equals(qualification.getStatus()); + } + + private void requireMerchant(long tenantId) { + IamTenant tenant = tenantRepository.findTenant(tenantId); + if (tenant == null || tenant.getId() == null || tenant.getId().longValue() != tenantId + || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "Merchant tenant not found / 企业客户不存在"); + } + } } 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 e991e1aeb..6f1669ee6 100644 --- a/iam/src/main/java/com/baiyue/iam/service/RbacService.java +++ b/iam/src/main/java/com/baiyue/iam/service/RbacService.java @@ -1,8 +1,10 @@ package com.baiyue.iam.service; import com.baiyue.common.audit.BusinessAudit; +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.AuthAccountClient; import com.baiyue.iam.dto.RbacDtos.*; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamPermission; @@ -11,6 +13,8 @@ import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.RbacRepository; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import jakarta.annotation.Resource; +import feign.FeignException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.dao.DataIntegrityViolationException; @@ -25,23 +29,33 @@ import java.util.Comparator; /** 双端 RBAC 应用服务,严格按租户和应用域隔离。 */ @Service public class RbacService { + private static final int SESSION_REVOCATION_BATCH_SIZE = 500; @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; public List roles(long tenantId, String appCode) { - return repository.findRoles(tenantId, appCode).stream() - .map(role -> response(role, repository.findRolePermissionIds(tenantId, role.getId()))) + List roles = repository.findRoles(tenantId, appCode); + Map memberCounts = repository.findRoleMemberCounts( + tenantId, roles.stream().map(IamRole::getId).toList()); + return roles.stream() + .map(role -> response(role, repository.findRolePermissionIds(tenantId, role.getId()), + memberCounts.getOrDefault(role.getId(), 0L))) .toList(); } public RoleResponse role(long tenantId, String appCode, Long roleId) { IamRole role = requireRole(tenantId, appCode, roleId); - return response(role, repository.findRolePermissionIds(tenantId, roleId)); + return response(role, repository.findRolePermissionIds(tenantId, roleId), + repository.countRoleMembers(tenantId, roleId)); } @BusinessAudit(event = "ROLE_CREATE", resourceType = "ROLE", resourceId = "#request.roleCode", description = "创建角色", captureBefore = false, captureAfter = true) - public IamRole createRole(long tenantId, String appCode, CreateRoleRequest request) { + public RoleResponse createRole(long tenantId, String appCode, CreateRoleRequest request) { if (repository.existsRoleCode(tenantId, appCode, request.roleCode(), null)) { throw new BizException(ResultCode.CONFLICT, "角色编码已存在"); } @@ -60,7 +74,7 @@ public class RbacService { // 数据库唯一键负责兜底并发创建,向调用方保持稳定的业务错误。 throw new BizException(ResultCode.CONFLICT, "角色编码已存在"); } - return role; + return response(role, Set.of(), 0L); } @BusinessAudit(event = "ROLE_UPDATE", resourceType = "ROLE", resourceId = "#roleId", @@ -70,17 +84,21 @@ public class RbacService { role.setRoleName(request.roleName()); role.setDescription(request.description()); repository.updateRole(role); - return response(role, repository.findRolePermissionIds(tenantId, roleId)); + return response(role, repository.findRolePermissionIds(tenantId, roleId), + repository.countRoleMembers(tenantId, roleId)); } @BusinessAudit(event = "ROLE_STATUS_CHANGE", resourceType = "ROLE", resourceId = "#roleId", description = "启停角色", captureBefore = true, captureAfter = true) + @Transactional public RoleResponse changeStatus(long tenantId, String appCode, Long roleId, ChangeRoleStatusRequest request) { IamRole role = requireCustomRole(tenantId, appCode, roleId); role.setStatus(request.status()); repository.updateRole(role); - return response(role, repository.findRolePermissionIds(tenantId, roleId)); + revokeAssignedSessions(tenantId, roleId); + return response(role, repository.findRolePermissionIds(tenantId, roleId), + repository.countRoleMembers(tenantId, roleId)); } /** 删除策略:系统角色禁止删除;已分配给成员的角色拒绝删除;其余角色清理授权关系后逻辑删除。 */ @@ -131,6 +149,7 @@ public class RbacService { permission.setHttpMethod(request.httpMethod()); permission.setSortNo(request.sortNo() == null ? 0 : request.sortNo()); permission.setStatus("ACTIVE"); + permission.setBuiltIn(false); try { repository.insertPermission(permission); } catch (DataIntegrityViolationException exception) { @@ -139,6 +158,52 @@ public class RbacService { return permission; } + @BusinessAudit(event = "PERMISSION_UPDATE", resourceType = "PERMISSION", resourceId = "#permissionId", + description = "编辑权限资源", captureBefore = true, captureAfter = true) + public IamPermission updatePermission(long tenantId, String appCode, Long permissionId, + UpdatePermissionRequest request) { + IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId); + validatePermissionParent(tenantId, appCode, permissionId, request.parentId()); + permission.setPermissionName(request.name()); + permission.setParentId(request.parentId() == null ? 0L : request.parentId()); + permission.setRoutePath(request.routePath()); + permission.setHttpMethod(request.httpMethod()); + permission.setSortNo(request.sortNo() == null ? 0 : request.sortNo()); + repository.updatePermission(permission); + return permission; + } + + @BusinessAudit(event = "PERMISSION_STATUS_CHANGE", resourceType = "PERMISSION", resourceId = "#permissionId", + description = "启停权限资源", captureBefore = true, captureAfter = true) + @Transactional + public IamPermission changePermissionStatus(long tenantId, String appCode, Long permissionId, + ChangePermissionStatusRequest request) { + IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId); + permission.setStatus(request.status()); + repository.updatePermission(permission); + Set affectedAccounts = new LinkedHashSet<>(); + for (Long roleId : repository.findRoleIdsByPermission(tenantId, permissionId)) { + affectedAccounts.addAll(repository.findAssignedAuthUserIds(tenantId, roleId)); + } + revokeSessions(List.copyOf(affectedAccounts)); + return permission; + } + + /** 带有子资源或已授权给角色的资源不得直接删除。 */ + @BusinessAudit(event = "PERMISSION_DELETE", resourceType = "PERMISSION", resourceId = "#permissionId", + description = "删除权限资源", captureBefore = true, captureAfter = false) + @Transactional + public void deletePermission(long tenantId, String appCode, Long permissionId) { + IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId); + if (repository.hasPermissionChildren(tenantId, appCode, permissionId)) { + throw new BizException(ResultCode.CONFLICT, "Permission has child resources / 权限资源存在子节点,不能删除"); + } + if (repository.isPermissionGranted(tenantId, permissionId)) { + throw new BizException(ResultCode.CONFLICT, "Permission is granted to roles / 权限资源已被角色授予,不能删除"); + } + repository.deletePermission(permission); + } + public MemberRolesResponse memberRoles(long tenantId, String appCode, Long membershipId) { requireMembership(tenantId, membershipId); Set scopedIds = new LinkedHashSet<>(); @@ -166,7 +231,17 @@ public class RbacService { throw new BizException(ResultCode.CONFLICT, "停用角色不能分配给成员"); } } + Set currentRoleIds = new LinkedHashSet<>(); + for (Long roleId : repository.findMemberRoleIds(tenantId, request.membershipId())) { + if (repository.findRole(roleId, tenantId, appCode) != null) currentRoleIds.add(roleId); + } repository.replaceMemberRoles(tenantId, appCode, request.membershipId(), request.roleIds()); + if (!currentRoleIds.equals(request.roleIds())) { + 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"); + } } @BusinessAudit(event = "ROLE_PERMISSION_GRANT", resourceType = "ROLE", @@ -183,6 +258,28 @@ public class RbacService { } } repository.replaceRolePermissions(tenantId, request.roleId(), request.permissionIds()); + 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) { + revokeSessions(repository.findAssignedAuthUserIds(tenantId, roleId)); + } + + private void revokeSessions(List accountIds) { + for (int start = 0; start < accountIds.size(); start += SESSION_REVOCATION_BATCH_SIZE) { + Set batch = Set.copyOf(accountIds.subList(start, + Math.min(start + SESSION_REVOCATION_BATCH_SIZE, accountIds.size()))); + try { + ApiResponse response = authClient.revokeSessions(internalToken, + new AuthAccountClient.RevokeAccountSessionsRequest(batch)); + if (response == null || !response.successful()) throw sessionRevocationUnavailable(); + } catch (FeignException exception) { + throw sessionRevocationUnavailable(); + } + } } private IamRole requireRole(long tenantId, String appCode, Long roleId) { @@ -191,6 +288,34 @@ public class RbacService { return role; } + private IamPermission requireMutablePermission(long tenantId, String appCode, Long permissionId) { + IamPermission permission = repository.findOwnedPermission(permissionId, tenantId, appCode); + if (permission == null) throw forbidden(); + if (Boolean.TRUE.equals(permission.getBuiltIn())) { + throw new BizException(ResultCode.CONFLICT, + "Built-in permission cannot be changed / 系统预置权限不允许编辑、启停或删除"); + } + return permission; + } + + private void validatePermissionParent(long tenantId, String appCode, Long permissionId, Long parentId) { + if (parentId == null || parentId == 0) return; + if (parentId.equals(permissionId)) { + throw new BizException(ResultCode.BAD_REQUEST, "Permission cannot be its own parent / 权限不能设置自身为父节点"); + } + IamPermission parent = repository.findPermission(parentId, tenantId, appCode); + if (parent == null) throw forbidden(); + Long cursor = parent.getParentId(); + while (cursor != null && cursor > 0) { + if (cursor.equals(permissionId)) { + throw new BizException(ResultCode.BAD_REQUEST, "Permission parent cycle is not allowed / 权限节点不能形成环"); + } + IamPermission ancestor = repository.findPermission(cursor, tenantId, appCode); + if (ancestor == null || cursor.equals(ancestor.getParentId())) break; + cursor = ancestor.getParentId(); + } + } + private IamRole requireCustomRole(long tenantId, String appCode, Long roleId) { IamRole role = requireRole(tenantId, appCode, roleId); if ("SYSTEM".equals(role.getRoleType())) { @@ -206,13 +331,18 @@ public class RbacService { return membership; } - private RoleResponse response(IamRole role, Set permissionIds) { + private RoleResponse response(IamRole role, Set permissionIds, long memberCount) { return new RoleResponse(role.getId(), role.getTenantId(), role.getAppCode(), role.getRoleCode(), role.getRoleName(), role.getRoleType(), role.getStatus(), role.getDescription(), - Set.copyOf(permissionIds)); + memberCount, Set.copyOf(permissionIds)); } private BizException forbidden() { return new BizException(ResultCode.FORBIDDEN, "禁止跨租户或跨应用访问"); } + + private BizException sessionRevocationUnavailable() { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, + "认证会话撤销失败,角色变更未生效"); + } } 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..6d5266658 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java @@ -0,0 +1,83 @@ +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.baiyue.iam.mapper.AuthorizationQueryMapper; +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; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** 在 IAM 状态事务内递增安全版本并写入可靠事件发送箱。 */ +@Service +public class SecurityEventOutboxService { + private static final List OVERSEAS_AUTHORITY_PREFIXES = List.of( + "platform:overseas-warehouse:", "platform:overseas-business:", + "platform:overseas-master-data:", "merchant:warehouse:", "merchant:overseas-warehouse:", + "merchant:cross-border:", "platform:finance:", "lastleg:"); + @Resource private JdbcTemplate jdbc; + @Resource private ObjectMapper objectMapper; + @Resource private AuthorizationQueryMapper authorizationQueries; + + 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); + Set roles = Set.copyOf(authorizationQueries.findRolesByIdentity(authUserId, tenantId, appCode)); + Set authorities = authorizationQueries.findPermissionsByIdentity(authUserId, tenantId, appCode) + .stream().filter(permission -> permission != null && OVERSEAS_AUTHORITY_PREFIXES.stream() + .anyMatch(permission::startsWith)).collect(Collectors.toUnmodifiableSet()); + write(EventTypes.IAM_PERMISSION_VERSION_CHANGED, "AUTHORIZATION", String.valueOf(authUserId), tenantId, version, + new IamSecurityEventsV1.PermissionVersionChanged(authUserId, tenantId, appCode, version, reason, + Instant.now(), roles, authorities)); + } + + 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 new file mode 100644 index 000000000..811857965 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java @@ -0,0 +1,301 @@ +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.AuthAccountClient; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.repository.MemberRepository; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.List; + +/** Provisions the first enterprise owner while keeping credentials in Auth only. */ +@Service +@Order(100) +public class TenantAdministratorProvisioningService implements TenantApprovalInitializer { + private static final Logger log = LoggerFactory.getLogger(TenantAdministratorProvisioningService.class); + private static final char[] UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ".toCharArray(); + private static final char[] LOWER = "abcdefghijkmnopqrstuvwxyz".toCharArray(); + private static final char[] DIGITS = "23456789".toCharArray(); + private static final char[] ALL = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789".toCharArray(); + private static final int PASSWORD_LENGTH = 16; + + @Resource + private MemberRepository members; + @Resource + 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(); + + @Override + public String name() { return "tenant-administrator"; } + + /** Creates the owner during platform customer creation and compensates Auth if the IAM transaction rolls back. */ + public ProvisioningResult provisionPlatformCreatedTenant(IamTenant tenant, long operatorId) { + ApprovalContext context = new ApprovalContext(tenant, 0L, operatorId, "platform customer creation"); + ProvisioningResult result = initialize(context); + if (result.created() && TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status != STATUS_ROLLED_BACK) return; + try { + compensate(context, result); + } catch (RuntimeException compensationFailure) { + log.error("平台创建客户事务回滚后认证账号补偿失败,tenantId={}, authUserId={}", + tenant.getId(), result.authUserId(), compensationFailure); + } + } + }); + } + return result; + } + + @Override + public ProvisioningResult initialize(ApprovalContext context) { + IamTenant tenant = context.tenant(); + long operatorId = context.reviewerId(); + IamRole ownerRole = requiredOwnerRole(); + IamMembership existingOwner = members.findOwnerByTenant(tenant.getId()); + if (existingOwner != null) return ensureExistingOwnerCanLogin(tenant, existingOwner, ownerRole); + if (!isPlatformCreatedTenant(tenant)) { + throw inconsistentOwner("客户端注册企业缺少原注册所有者,不能自动创建第二个管理员,请先修复注册身份"); + } + + long authUserId = IdWorker.getId(); + long userId = IdWorker.getId(); + long membershipId = IdWorker.getId(); + String password = temporaryPassword(); + String username = createAuthAccount(authUserId, password, tenant, membershipId, + tenant.getContactPhone(), tenant.getContactName()); + ProvisioningResult result = ProvisioningResult.created(authUserId, username, password); + + try { + IamUser user = new IamUser(); + user.setId(userId); + user.setAuthUserId(authUserId); + user.setUserType("ENTERPRISE"); + user.setDisplayName(tenant.getContactName()); + user.setPhone(tenant.getContactPhone()); + user.setEmail(tenant.getContactEmail()); + user.setStatus("ACTIVE"); + members.insertUser(user); + + IamMembership membership = new IamMembership(); + membership.setId(membershipId); + membership.setTenantId(tenant.getId()); + membership.setUserId(userId); + membership.setMemberType("OWNER"); + membership.setMemberNo("OWNER-" + membershipId); + membership.setStatus("ACTIVE"); + membership.setJoinedAt(LocalDateTime.now()); + membership.setCreatedBy(operatorId); + 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); + throw localFailure; + } + } + + private IamRole requiredOwnerRole() { + IamRole ownerRole = roles.selectOne(new LambdaQueryWrapper() + .eq(IamRole::getTenantId, 0L) + .eq(IamRole::getAppCode, "MERCHANT") + .eq(IamRole::getRoleCode, "MERCHANT_OWNER") + .eq(IamRole::getStatus, "ACTIVE") + .last("LIMIT 1")); + if (ownerRole == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "企业管理员角色模板未初始化,不能创建或确认企业管理员"); + } + return ownerRole; + } + + private boolean isPlatformCreatedTenant(IamTenant tenant) { + // 只有平台创建接口会记录操作人;客户端注册后也会补齐信用代码,不能据此推断来源。 + return tenant.getCreatedBy() != null; + } + + private ProvisioningResult ensureExistingOwnerCanLogin(IamTenant tenant, IamMembership owner, + IamRole ownerRole) { + if (!"ACTIVE".equals(owner.getStatus())) { + throw inconsistentOwner("企业所有者成员已停用,不能审核通过"); + } + IamUser user = members.findUser(owner.getUserId()); + if (user == null || user.getAuthUserId() == null || !"ACTIVE".equals(user.getStatus())) { + throw inconsistentOwner("企业所有者身份不完整或已停用,不能审核通过"); + } + + AuthAccountClient.AccountSummary account = findAuthAccount(user.getAuthUserId()); + if (account == null) { + String password = temporaryPassword(); + String username = createAuthAccount(user.getAuthUserId(), password, tenant, owner.getId(), + user.getPhone(), user.getDisplayName()); + publishInitialSecurityProjection(user.getAuthUserId(), tenant.getId()); + return ProvisioningResult.created(user.getAuthUserId(), username, password); + } + if (!"ACTIVE".equals(account.status()) + || !user.getAuthUserId().equals(account.accountId()) + || !tenant.getId().equals(account.tenantId()) + || !owner.getId().equals(account.membershipId()) + || !"MERCHANT".equals(account.appCode())) { + throw inconsistentOwner("企业所有者认证账号已停用或账号范围不一致,不能审核通过"); + } + List roleIds = members.findRoleIds(tenant.getId(), owner.getId()); + if (!roleIds.contains(ownerRole.getId())) { + 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(); + } + + private AuthAccountClient.AccountSummary findAuthAccount(long authUserId) { + try { + ApiResponse response = authClient.summary(internalToken, authUserId); + if (response == null) throw unavailable(); + if (!response.successful()) { + ResultCode code = ResultCode.of(response.code()); + if (code == ResultCode.NOT_FOUND) return null; + throw new BizException(code, response.message() == null || response.message().isBlank() + ? code.message() : response.message()); + } + if (response.data() == null) throw unavailable(); + return response.data(); + } catch (FeignException exception) { + if (exception.status() == 404) return null; + throw unavailable(); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw unavailable(); + } + } + + @Override + public void compensate(ApprovalContext context, InitializationResult initialization) { + ProvisioningResult result = (ProvisioningResult) initialization; + if (result.created()) compensate(result.authUserId()); + } + + private String createAuthAccount(long authUserId, String password, IamTenant tenant, long membershipId, + String loginPhone, String displayName) { + String username = generatedUsername(authUserId); + try { + ApiResponse response = authClient.create(internalToken, + new AuthAccountClient.CreateAccountRequest(authUserId, username, loginPhone, password, + displayName, tenant.getId(), membershipId, "MERCHANT", "ACTIVE")); + if (response == null) throw unavailable(); + if (!response.successful()) { + ResultCode code = ResultCode.of(response.code()); + throw new BizException(code, response.message() == null || response.message().isBlank() + ? code.message() : response.message()); + } + if (response.data() == null) throw unavailable(); + return username; + } catch (FeignException exception) { + if (exception.status() == 409) { + throw new BizException(ResultCode.CONFLICT, "管理员用户名或登录手机号已被其他账号使用"); + } + throw unavailable(); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw unavailable(); + } + } + + 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); + if (response == null || !response.successful()) throw unavailable(); + } catch (RuntimeException compensationFailure) { + log.error("企业管理员创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "企业管理员创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); + } + } + + private String temporaryPassword() { + char[] value = new char[PASSWORD_LENGTH]; + value[0] = pick(UPPER); + value[1] = pick(LOWER); + value[2] = pick(DIGITS); + for (int index = 3; index < value.length; index++) value[index] = pick(ALL); + for (int index = value.length - 1; index > 0; index--) { + int swap = random.nextInt(index + 1); + char current = value[index]; + value[index] = value[swap]; + value[swap] = current; + } + return new String(value); + } + + private String generatedUsername(long authUserId) { + return "merchant_" + authUserId; + } + + private char pick(char[] source) { return source[random.nextInt(source.length)]; } + private BizException unavailable() { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能创建或确认企业管理员"); + } + private BizException inconsistentOwner(String message) { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, message); + } + + public static final class ProvisioningResult implements InitializationResult { + private final boolean created; + private final Long authUserId; + private final String username; + private final String temporaryPassword; + + private ProvisioningResult(boolean created, Long authUserId, String username, String temporaryPassword) { + this.created = created; + this.authUserId = authUserId; + this.username = username; + this.temporaryPassword = temporaryPassword; + } + + static ProvisioningResult existing() { return new ProvisioningResult(false, null, null, null); } + static ProvisioningResult created(long authUserId, String username, String password) { + return new ProvisioningResult(true, authUserId, username, password); + } + + public boolean created() { return created; } + public Long authUserId() { return authUserId; } + public String username() { return username; } + public String temporaryPassword() { return temporaryPassword; } + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinator.java b/iam/src/main/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinator.java new file mode 100644 index 000000000..279906448 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinator.java @@ -0,0 +1,127 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Runs all tenant approval initializers and owns their reverse-order compensation lifecycle. */ +@Service +public class TenantApprovalInitializationCoordinator { + private static final Logger log = LoggerFactory.getLogger(TenantApprovalInitializationCoordinator.class); + @Resource + private List initializers; + + @PostConstruct + void initialize() { + List ordered = new ArrayList<>(initializers); + AnnotationAwareOrderComparator.sort(ordered); + Set names = new HashSet<>(); + for (TenantApprovalInitializer initializer : ordered) { + String name = initializer.name(); + if (name == null || name.isBlank() || !names.add(name)) { + throw new IllegalArgumentException("Tenant approval initializer names must be nonblank and unique: " + name); + } + } + this.initializers = List.copyOf(ordered); + } + + public InitializationBatch initialize(TenantApprovalInitializer.ApprovalContext context) { + InitializationBatch batch = new InitializationBatch(context); + registerRollbackCompensation(batch); + try { + for (TenantApprovalInitializer initializer : initializers) { + TenantApprovalInitializer.InitializationResult result = initializer.initialize(context); + if (result == null) { + throw new IllegalStateException("Tenant approval initializer returned null: " + initializer.name()); + } + batch.add(initializer, result); + } + return batch; + } catch (RuntimeException initializationFailure) { + try { + compensate(batch); + } catch (RuntimeException compensationFailure) { + compensationFailure.addSuppressed(initializationFailure); + throw compensationFailure; + } + throw initializationFailure; + } + } + + public InitializationBatch empty(TenantApprovalInitializer.ApprovalContext context) { + return new InitializationBatch(context); + } + + public void compensate(InitializationBatch batch) { + if (!batch.markCompensated()) return; + RuntimeException firstFailure = null; + List completed = new ArrayList<>(batch.completed()); + Collections.reverse(completed); + for (CompletedInitialization item : completed) { + try { + item.initializer().compensate(batch.context(), item.result()); + } catch (RuntimeException exception) { + log.error("企业审核初始化补偿失败,initializer={}, tenantId={}", + item.initializer().name(), batch.context().tenant().getId(), exception); + if (firstFailure == null) firstFailure = exception; + else firstFailure.addSuppressed(exception); + } + } + if (firstFailure != null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "企业审核初始化补偿失败,请检查服务日志并修复数据一致性"); + } + } + + private void registerRollbackCompensation(InitializationBatch batch) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) return; + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status == STATUS_ROLLED_BACK) compensate(batch); + } + }); + } + + private record CompletedInitialization(TenantApprovalInitializer initializer, + TenantApprovalInitializer.InitializationResult result) { } + + public static final class InitializationBatch { + private final TenantApprovalInitializer.ApprovalContext context; + private final List completed = new ArrayList<>(); + private final AtomicBoolean compensated = new AtomicBoolean(false); + + private InitializationBatch(TenantApprovalInitializer.ApprovalContext context) { + this.context = context; + } + + private void add(TenantApprovalInitializer initializer, + TenantApprovalInitializer.InitializationResult result) { + completed.add(new CompletedInitialization(initializer, result)); + } + + private TenantApprovalInitializer.ApprovalContext context() { return context; } + private List completed() { return List.copyOf(completed); } + private boolean markCompensated() { return compensated.compareAndSet(false, true); } + + public Optional result(Class type) { + return completed.stream().map(CompletedInitialization::result) + .filter(type::isInstance).map(type::cast).findFirst(); + } + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantApprovalInitializer.java b/iam/src/main/java/com/baiyue/iam/service/TenantApprovalInitializer.java new file mode 100644 index 000000000..33506b732 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/TenantApprovalInitializer.java @@ -0,0 +1,22 @@ +package com.baiyue.iam.service; + +import com.baiyue.iam.entity.IamTenant; + +/** + * Extension point for provisioning defaults after an enterprise is approved. + * Implementations may initialize accounts, roles, rules, fee plans or other tenant-scoped defaults. + * Implementations and their compensation must be idempotent. A failed implementation must clean + * up its own partial work before throwing; the coordinator compensates every initializer that + * completed successfully, in reverse order. Use {@code @Order} for explicit dependencies. + */ +public interface TenantApprovalInitializer { + String name(); + + InitializationResult initialize(ApprovalContext context); + + default void compensate(ApprovalContext context, InitializationResult result) { } + + record ApprovalContext(IamTenant tenant, long reviewId, long reviewerId, String comment) { } + + interface InitializationResult { } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java b/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java index 8ff2a9e30..48bddcd4b 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantGuard.java @@ -8,8 +8,14 @@ public final class TenantGuard { public static long tenant(AuthenticatedUser user,String expectedApp){ if(user==null||user.tenantId()==null) throw new BizException(ResultCode.UNAUTHORIZED,"Authentication required / 请先登录"); if(!expectedApp.equals(user.appCode())) throw new BizException(ResultCode.FORBIDDEN,"Wrong application scope / 应用权限域不匹配"); - if("PLATFORM".equals(expectedApp)) return 0L; - if(user.tenantId()<=0) throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant required / 缺少商户租户"); - return user.tenantId(); + if("PLATFORM".equals(expectedApp)) { + if(user.tenantId()!=0L) throw new BizException(ResultCode.FORBIDDEN,"Platform tenant must be zero / 平台作用域必须为 0"); + return 0L; + } + if(!"MERCHANT".equals(expectedApp)||user.tenantId()<=0) throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant required / 缺少商户租户"); + return user.tenantId(); + } + public static void rejectImpersonated(AuthenticatedUser user){ + if(user!=null&&user.impersonated()) throw new BizException(ResultCode.FORBIDDEN,"代登录会话不能执行账号或权限安全操作"); } } 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 70704bc15..0f0a7809a 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -2,34 +2,158 @@ package com.baiyue.iam.service; import jakarta.annotation.Resource; import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.PageResult; +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.security.AuthenticatedUser; import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.repository.TenantRepository; +import com.baiyue.iam.repository.CustomerCodeRepository; import com.baiyue.iam.repository.ProfileResourceRepository; +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; import com.baiyue.iam.dto.TenantDtos.RegisterRequest; import com.baiyue.iam.dto.TenantDtos.TenantResponse; +import com.baiyue.iam.dto.TenantDtos.FinanceCustomerSummary; +import com.baiyue.iam.dto.TenantDtos.PendingReviewResponse; import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest; +import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; +import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; +import com.baiyue.iam.dto.TenantDtos.ApprovalResponse; +import com.baiyue.iam.dto.TenantDtos.CreationResponse; +import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest; +import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse; +import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch; +import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Value; import org.springframework.transaction.annotation.Transactional; +import feign.FeignException; +import net.sourceforge.pinyin4j.PinyinHelper; import java.time.LocalDateTime; 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 TenantService { + private static final Set REQUIRED_QUALIFICATION_TYPES = Set.of("BUSINESS_LICENSE"); @Resource private TenantRepository repository; @Resource + private CustomerCodeRepository customerCodes; + @Resource private ProfileResourceRepository profileRepository; + @Resource + private AuthAccountClient authClient; + @Resource + private MemberRepository memberRepository; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + @Resource + private TenantApprovalInitializationCoordinator approvalInitialization; + @Resource + private CustomerDimensionRuleInitializationService dimensionRuleInitialization; + @Resource + private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; + @Resource + private TenantAdministratorProvisioningService administratorProvisioning; + @Resource + private SecurityEventOutboxService securityEvents; + + @Transactional + @BusinessAudit(event = "PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET", resourceType = "TENANT", + resourceId = "#tenantId", description = "平台重置客户企业管理员密码", + captureBefore = false, captureAfter = false) + public void resetOwnerPassword(long tenantId, ResetCustomerOwnerPasswordRequest request) { + requireMerchant(tenantId); + IamMembership owner = memberRepository.findOwnerByTenant(tenantId); + if (owner == null) throw new BizException(ResultCode.NOT_FOUND, "企业管理员不存在"); + IamUser user = memberRepository.findUser(owner.getUserId()); + if (user == null || user.getAuthUserId() == null) throw new BizException(ResultCode.NOT_FOUND, "企业管理员账号不存在"); + try { + ApiResponse result = authClient.reset(internalToken, user.getAuthUserId(), + new AuthAccountClient.PasswordRequest(request.newPassword(), request.forceChange())); + if (result == null || !result.successful()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); + } catch (FeignException ex) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); + } + } + + @Transactional + @BusinessAudit(event = "PLATFORM_CUSTOMER_ARCHIVE", resourceType = "TENANT", + resourceId = "#tenantId", description = "平台逻辑归档客户", + captureBefore = false, captureAfter = false) + public TenantResponse archiveMerchant(long tenantId) { + IamTenant tenant = requireMerchantForUpdate(tenantId); + if ("ARCHIVED".equals(tenant.getStatus())) return response(tenant); + if (!Set.of("PROFILE_REQUIRED", "REJECTED").contains(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "当前客户状态不允许归档"); + } + if (repository.findPendingReviewByTenant(tenantId) != null) { + throw new BizException(ResultCode.CONFLICT, "客户存在待审核记录,不能归档"); + } + try { + ApiResponse result = authClient.archiveTenant(internalToken, tenantId); + if (result == null || !result.successful()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); + } catch (FeignException ex) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); + } + tenant.setStatus("ARCHIVED"); + repository.saveTenant(tenant); + return response(tenant); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_CUSTOMER_IMPERSONATION_TICKET_CREATE", resourceType = "TENANT", + resourceId = "#tenantId", description = "平台创建客户代登录授权码", + captureBefore = false, captureAfter = false) + public CustomerImpersonationResponse createImpersonationTicket(long tenantId, + CustomerImpersonationRequest request, + AuthenticatedUser actor) { + IamTenant tenant = requireMerchant(tenantId); + if (!"ACTIVE".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "只有已启用客户可以代登录"); + } + IamMembership owner = memberRepository.findOwnerByTenant(tenantId); + if (owner == null || !"ACTIVE".equals(owner.getStatus())) { + throw new BizException(ResultCode.NOT_FOUND, "企业管理员不存在或已停用"); + } + IamUser ownerUser = memberRepository.findUser(owner.getUserId()); + if (ownerUser == null || ownerUser.getAuthUserId() == null || !"ACTIVE".equals(ownerUser.getStatus())) { + throw new BizException(ResultCode.NOT_FOUND, "企业管理员账号不存在或已停用"); + } + try { + ApiResponse result = authClient.createImpersonationTicket( + internalToken, + new AuthAccountClient.ImpersonationTicketRequest(ownerUser.getAuthUserId(), tenantId, owner.getId(), + actor.userId(), actor.membershipId(), request.reason().trim())); + if (result == null || !result.successful() || result.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); + } + AuthAccountClient.ImpersonationTicketResponse ticket = result.data(); + return new CustomerImpersonationResponse(ticket.code(), ticket.ticketId(), ticket.expiresIn()); + } catch (FeignException ex) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); + } + } @BusinessAudit( @@ -40,36 +164,89 @@ public class TenantService { captureAfter = false ) @Transactional - public TenantResponse register(RegisterRequest request) { - if (repository.existsByCreditCode(request.unifiedSocialCreditCode())) { - throw new BizException(ResultCode.CONFLICT, "Enterprise already exists / 企业已存在"); - } - + public CreationResponse register(RegisterRequest request, long operatorId) { + String contactPhone = normalizeContactPhone(request.contactPhone()); + ensureAdministratorPhoneAvailable(contactPhone); IamTenant tenant = new IamTenant(); tenant.setId(IdWorker.getId()); - tenant.setTenantCode("TEN" + tenant.getId()); + tenant.setTenantCode(customerCodes.nextCode()); tenant.setTenantName(request.tenantName()); + tenant.setCustomerNickname(resolveCustomerNickname(request.customerNickname(), request.tenantName())); tenant.setTenantType("MERCHANT"); - tenant.setStatus("PENDING"); + tenant.setStatus("PROFILE_REQUIRED"); + tenant.setDimensionInitializationStatus("PENDING"); tenant.setLegalName(request.legalName()); - tenant.setUnifiedSocialCreditCode(request.unifiedSocialCreditCode()); + tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative())); + tenant.setUnifiedSocialCreditCode(null); tenant.setContactName(request.contactName()); - tenant.setContactPhone(request.contactPhone()); + tenant.setContactPhone(contactPhone); tenant.setContactEmail(request.contactEmail()); + tenant.setServicePackage(request.servicePackage()); + tenant.setContactWechat(request.contactWechat()); + tenant.setSalesOwnerName(request.salesOwnerName()); + tenant.setPaymentTermDays(request.paymentTermDays()); + tenant.setEnterpriseType(request.enterpriseType()); + tenant.setEstablishedDate(request.establishedDate()); + tenant.setRegisteredCapital(request.registeredCapital()); + tenant.setRegisteredCapitalCurrency(request.registeredCapitalCurrency()); + tenant.setRegisteredAddress(request.registeredAddress()); + tenant.setInternalRemark(request.internalRemark()); + 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); - IamTenantReview review = new IamTenantReview(); - review.setId(IdWorker.getId()); - review.setTenantId(tenant.getId()); - review.setReviewType("REGISTER"); - review.setStatus("PENDING"); - review.setCreatedAt(LocalDateTime.now()); - repository.insertReview(review); - return response(tenant); + return new CreationResponse(response(tenant), administrator.username(), administrator.temporaryPassword()); } - public List pendingReviews() { - return repository.findPendingReviews(); + public List pendingReviews() { + List reviews = repository.findPendingReviews(); + if (reviews.isEmpty()) return List.of(); + Map tenants = repository.findMerchantsByIds(reviews.stream() + .map(IamTenantReview::getTenantId) + .collect(Collectors.toSet())) + .stream() + .collect(Collectors.toMap(IamTenant::getId, Function.identity())); + return reviews.stream() + .map(review -> pendingReviewResponse(review, tenants.get(review.getTenantId()))) + .toList(); + } + + public PageResult pageMerchants(long page, long size, String keyword, String status) { + PageResult source = repository.pageMerchants(page, size, keyword, status); + return new PageResult<>(source.records().stream().map(this::summaryResponse).toList(), + source.page(), source.size(), source.total(), source.pages()); + } + + public PageResult pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) { + PageResult source = repository.pageFinanceCustomers(page, size, keyword, status, customerId); + return new PageResult<>(source.records().stream().map(tenant -> new FinanceCustomerSummary( + tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), + tenant.getContactName(), tenant.getContactPhone())).toList(), + source.page(), source.size(), source.total(), source.pages()); + } + + public CustomerExportBatch exportMerchants(Long cursor, int limit, String keyword, String status) { + int boundedLimit = Math.min(Math.max(limit, 1), 500); + List source = repository.exportMerchants(cursor, boundedLimit, keyword, status); + boolean hasMore = source.size() > boundedLimit; + List page = hasMore ? source.subList(0, boundedLimit) : source; + List rows = page.stream().map(this::exportRow).toList(); + Long nextCursor = page.isEmpty() ? cursor : page.get(page.size() - 1).getId(); + return new CustomerExportBatch(rows, nextCursor, hasMore); + } + + private CustomerExportRow exportRow(IamTenant tenant) { + return new CustomerExportRow(String.valueOf(tenant.getId()), tenant.getTenantCode(), + tenant.getTenantName(), tenant.getCustomerNickname(), tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(), + tenant.getEnterpriseType(), tenant.getEstablishedDate(), tenant.getRegisteredCapital(), + tenant.getRegisteredCapitalCurrency(), tenant.getRegisteredAddress(), tenant.getContactName(), + tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(), + tenant.getSalesOwnerName(), tenant.getServicePackage(), tenant.getPaymentTermDays(), + tenant.getStatus(), tenant.getCreatedAt(), tenant.getApprovedAt()); } @BusinessAudit( @@ -81,8 +258,8 @@ public class TenantService { captureAfter = false ) @Transactional - public void review(long reviewId, long reviewerId, boolean approve, String comment) { - IamTenantReview review = repository.findReview(reviewId); + public ApprovalResponse review(long reviewId, long reviewerId, boolean approve, String comment) { + IamTenantReview review = repository.findReviewForUpdate(reviewId); if (review == null) { throw new BizException(ResultCode.NOT_FOUND, "Review not found / 审核单不存在"); } @@ -90,28 +267,60 @@ public class TenantService { throw new BizException(ResultCode.CONFLICT, "Review already completed / 审核已完成"); } - review.setStatus(approve ? "APPROVED" : "REJECTED"); - review.setReviewerId(reviewerId); - review.setReviewComment(comment); - review.setReviewedAt(LocalDateTime.now()); - repository.saveReview(review); + if (approve) { + var qualifications = profileRepository.findQualifications(review.getTenantId()); + if (!hasAllPendingQualifications(qualifications)) { + throw new BizException(ResultCode.CONFLICT, "Required qualifications are incomplete / 必填资质不完整,不能审核通过"); + } + } IamTenant tenant = repository.findTenant(review.getTenantId()); if (tenant == null) { throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在"); } - tenant.setStatus(approve ? "ACTIVE" : "REJECTED"); - tenant.setRejectReason(approve ? null : comment); - tenant.setApprovedAt(approve ? LocalDateTime.now() : null); - tenant.setApprovedBy(approve ? reviewerId : null); - repository.saveTenant(tenant); - for (var qualification : profileRepository.findQualifications(tenant.getId())) { - if ("PENDING".equals(qualification.getStatus())) { - qualification.setStatus(approve ? "APPROVED" : "REJECTED"); - qualification.setReviewComment(comment); - profileRepository.saveQualification(qualification); - } + if (approve) { + dimensionRuleInitialization.requireReady(tenant.getDimensionInitializationStatus()); } + + var initializationContext = new TenantApprovalInitializer.ApprovalContext( + tenant, reviewId, reviewerId, comment); + var initialization = approve + ? approvalInitialization.initialize(initializationContext) + : approvalInitialization.empty(initializationContext); + var administrator = initialization + .result(TenantAdministratorProvisioningService.ProvisioningResult.class) + .orElseGet(TenantAdministratorProvisioningService.ProvisioningResult::existing); + try { + review.setStatus(approve ? "APPROVED" : "REJECTED"); + review.setReviewerId(reviewerId); + review.setReviewComment(comment); + review.setReviewedAt(LocalDateTime.now()); + repository.saveReview(review); + + tenant.setStatus(approve ? "ACTIVE" : "REJECTED"); + tenant.setRejectReason(approve ? null : comment); + 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"); + qualification.setReviewComment(comment); + profileRepository.saveQualification(qualification); + } + } + } catch (RuntimeException localFailure) { + try { + approvalInitialization.compensate(initialization); + } catch (RuntimeException compensationFailure) { + compensationFailure.addSuppressed(localFailure); + throw compensationFailure; + } + throw localFailure; + } + return new ApprovalResponse(tenant.getId(), administrator.created(), + administrator.username(), administrator.temporaryPassword()); } public OrderEligibilityResponse orderEligibility(long tenantId) { @@ -123,18 +332,129 @@ public class TenantService { return new OrderEligibilityResponse(false, tenant.getStatus(), "Enterprise information must be approved before ordering"); } - if (profileRepository.countApprovedQualifications(tenantId) < 1) { + if (!"READY".equals(tenant.getDimensionInitializationStatus())) { return new OrderEligibilityResponse(false, tenant.getStatus(), - "At least one approved enterprise qualification is required"); + "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"); } return new OrderEligibilityResponse(true, tenant.getStatus(), null); } public TenantResponse profile(long tenantId) { - IamTenant tenant = repository.findTenant(tenantId); - if (tenant == null || !tenantIdEquals(tenant, tenantId)) { - throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在"); + IamTenant tenant = requireMerchant(tenantId); + return merchantVisibleResponse(tenant); + } + + /** 平台端查询客户档案:仅能查商户租户,不暴露平台租户。 */ + public TenantResponse merchant(long tenantId) { + 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); + } + + /** Platform creation uses the same initial value for company contact and owner login phone. */ + public boolean administratorPhoneAvailable(String phone) { + String normalized = normalizeContactPhone(phone); + ApiResponse response; + try { + response = authClient.availability(internalToken, normalized); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验管理员登录手机号"); } + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验管理员登录手机号"); + } + return response.data(); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_TENANT_UPDATE", resourceType = "TENANT", resourceId = "#tenantId", tenantId = "#tenantId", + description = "平台编辑企业客户档案") + public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) { + IamTenant tenant = requireMerchant(tenantId); + ensureNotArchived(tenant); + String contactPhone = normalizeContactPhone(request.contactPhone()); + tenant.setTenantName(request.tenantName()); + tenant.setCustomerNickname(requireCustomerNickname(request.customerNickname())); + tenant.setLegalName(request.legalName()); + tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative())); + tenant.setContactName(request.contactName()); + tenant.setContactPhone(contactPhone); + tenant.setContactEmail(request.contactEmail()); + tenant.setServicePackage(request.servicePackage()); + tenant.setContactWechat(request.contactWechat()); + tenant.setSalesOwnerName(request.salesOwnerName()); + tenant.setPaymentTermDays(request.paymentTermDays()); + tenant.setEnterpriseType(request.enterpriseType()); + tenant.setEstablishedDate(request.establishedDate()); + tenant.setRegisteredCapital(request.registeredCapital()); + tenant.setRegisteredCapitalCurrency(request.registeredCapitalCurrency()); + tenant.setRegisteredAddress(request.registeredAddress()); + tenant.setInternalRemark(request.internalRemark()); + repository.saveTenant(tenant); + return response(tenant); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_TENANT_STATUS_CHANGE", resourceType = "TENANT", resourceId = "#tenantId", + description = "平台启停企业客户") + public TenantResponse changeMerchantStatus(long tenantId, ChangeTenantStatusRequest request) { + IamTenant tenant = requireMerchant(tenantId); + // PENDING/REJECTED 必须走审核流程,不允许状态维护接口绕过审核。 + if (!"ACTIVE".equals(tenant.getStatus()) && !"DISABLED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Tenant status must be changed through review / 当前租户状态必须通过审核流程变更"); + } + tenant.setStatus(request.status()); + repository.saveTenant(tenant); + securityEvents.tenantStatus(tenantId, request.status()); + return response(tenant); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_TENANT_SUBMIT_REVIEW", resourceType = "TENANT", resourceId = "#tenantId", + description = "平台提交企业客户审核") + public TenantResponse submitMerchantReview(long tenantId) { + IamTenant tenant = requireMerchantForUpdate(tenantId); + if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Only draft or rejected enterprise can be submitted / 仅待完善或已驳回客户可提交审核"); + } + if (repository.findPendingReviewByTenant(tenantId) != null) { + throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中"); + } + if (tenant.getUnifiedSocialCreditCode() == null + || !tenant.getUnifiedSocialCreditCode().matches("[0-9A-Z]{18}")) { + throw new BizException(ResultCode.BAD_REQUEST, + "A valid unified social credit code is required / 请先填写有效的18位统一社会信用代码"); + } + var qualifications = profileRepository.findQualifications(tenantId); + if (!hasAllPendingQualifications(qualifications)) { + throw new BizException(ResultCode.BAD_REQUEST, + "Business license is required / 营业执照为必填资质"); + } + IamTenantReview review = new IamTenantReview(); + review.setId(IdWorker.getId()); review.setTenantId(tenantId); + review.setReviewType("REGISTER"); review.setStatus("PENDING"); + review.setCreatedAt(LocalDateTime.now()); + repository.insertReview(review); + tenant.setStatus("PENDING"); tenant.setRejectReason(null); + repository.saveTenant(tenant); return response(tenant); } @@ -142,28 +462,150 @@ public class TenantService { @BusinessAudit(event = "TENANT_PROFILE_UPDATE", resourceType = "TENANT", resourceId = "#tenantId", description = "编辑企业资料") public TenantResponse update(long tenantId, UpdateProfileRequest request) { - IamTenant tenant = repository.findTenant(tenantId); - if (tenant == null || !tenantIdEquals(tenant, tenantId)) { - throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在"); - } + IamTenant tenant = requireMerchant(tenantId); + ensureNotArchived(tenant); tenant.setTenantName(request.tenantName()); tenant.setLegalName(request.legalName()); + tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative())); tenant.setContactName(request.contactName()); - tenant.setContactPhone(request.contactPhone()); + tenant.setContactPhone(normalizeContactPhone(request.contactPhone())); tenant.setContactEmail(request.contactEmail()); repository.saveTenant(tenant); - return response(tenant); + return merchantVisibleResponse(tenant); } private boolean tenantIdEquals(IamTenant tenant, long id) { return tenant.getId() != null && tenant.getId().longValue() == id; } + private void ensureNotArchived(IamTenant tenant) { + if ("ARCHIVED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Archived customer is read-only / 已归档客户仅可查看"); + } + } + + private String normalizeContactPhone(String contactPhone) { + return contactPhone == null ? "" : contactPhone.trim(); + } + + private String normalizeOptional(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + static String resolveCustomerNickname(String nickname, String tenantName) { + if (nickname != null && !nickname.isBlank()) return nickname.trim(); + String normalizedName = tenantName == null ? "" : tenantName.trim(); + if (normalizedName.isEmpty()) { + throw new BizException(ResultCode.BAD_REQUEST, "公司名称不能为空"); + } + StringBuilder initials = new StringBuilder(); + normalizedName.codePoints() + .filter(codePoint -> Character.UnicodeScript.of(codePoint) == Character.UnicodeScript.HAN) + .filter(Character::isBmpCodePoint) + .forEach(codePoint -> { + String[] pinyin = PinyinHelper.toHanyuPinyinStringArray((char) codePoint); + if (pinyin != null && pinyin.length > 0 && !pinyin[0].isBlank()) { + initials.append(Character.toUpperCase(pinyin[0].charAt(0))); + } + }); + if (!initials.isEmpty()) return initials.substring(0, Math.min(initials.length(), 32)); + return new String(Character.toChars(normalizedName.codePointAt(0))).toUpperCase(java.util.Locale.ROOT); + } + + private String requireCustomerNickname(String nickname) { + if (nickname == null || nickname.isBlank()) { + throw new BizException(ResultCode.BAD_REQUEST, "客户昵称不能为空"); + } + return nickname.trim(); + } + + private void ensureAdministratorPhoneAvailable(String phone) { + if (!administratorPhoneAvailable(phone)) { + throw new BizException(ResultCode.CONFLICT, "管理员登录手机号已被其他账号使用"); + } + } + + private boolean hasAllPendingQualifications(List qualifications) { + return hasAllQualificationsWithStatus(qualifications, "PENDING"); + } + + private boolean hasAllQualificationsWithStatus(List qualifications, String status) { + return REQUIRED_QUALIFICATION_TYPES.stream().allMatch(type -> qualifications.stream() + .anyMatch(item -> type.equals(item.getQualificationType()) && status.equals(item.getStatus()))); + } + + private IamTenant requireMerchant(long tenantId) { + IamTenant tenant = repository.findTenant(tenantId); + if (tenant == null || !tenantIdEquals(tenant, tenantId) || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "Merchant tenant not found / 企业客户不存在"); + } + return tenant; + } + + private IamTenant requireMerchantForUpdate(long tenantId) { + IamTenant tenant = repository.findTenantForUpdate(tenantId); + if (tenant == null || !tenantIdEquals(tenant, tenantId) || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "Merchant tenant not found / 企业客户不存在"); + } + return tenant; + } + private TenantResponse response(IamTenant tenant) { return new TenantResponse( - tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), - tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(), - tenant.getContactPhone(), tenant.getContactEmail() + tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getCustomerNickname(), 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(), + tenant.getEstablishedDate(), tenant.getRegisteredCapital(), tenant.getRegisteredCapitalCurrency(), + tenant.getRegisteredAddress(), tenant.getInternalRemark(), tenant.getCreatedAt(), tenant.getApprovedAt() ); } + + private PendingReviewResponse pendingReviewResponse(IamTenantReview review, IamTenant tenant) { + return new PendingReviewResponse( + review.getId(), review.getTenantId(), review.getReviewType(), review.getStatus(), + tenant == null ? null : tenant.getTenantName(), + tenant == null ? null : tenant.getTenantCode(), + tenant == null ? null : tenant.getContactName(), + tenant == null ? null : tenant.getContactPhone(), + tenant == null ? null : tenant.getContactEmail(), + review.getCreatedAt(), review.getReviewComment()); + } + + private TenantResponse summaryResponse(IamTenant tenant) { + TenantResponse response = response(tenant); + return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.customerNickname(), 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(), + response.establishedDate(), response.registeredCapital(), response.registeredCapitalCurrency(), + response.registeredAddress(), response.internalRemark(), response.createdAt(), response.approvedAt()); + } + + private TenantResponse merchantVisibleResponse(IamTenant tenant) { + TenantResponse response = response(tenant); + return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.customerNickname(), 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(), + response.registeredCapital(), response.registeredCapitalCurrency(), response.registeredAddress(), + null, response.createdAt(), response.approvedAt()); + } + + private String maskPhone(String phone) { + if (phone == null || phone.isBlank()) return phone; + if (phone.length() <= 4) return "****"; + int prefix = Math.min(3, phone.length() - 4); + return phone.substring(0, prefix) + "****" + phone.substring(phone.length() - 4); + } + + private String maskEmail(String email) { + if (email == null || email.isBlank()) return email; + int separator = email.indexOf('@'); + if (separator <= 0) return "***"; + return email.substring(0, 1) + "***" + email.substring(separator); + } } diff --git a/iam/src/main/resources/application-dev.yml b/iam/src/main/resources/application-dev.yml new file mode 100644 index 000000000..de7343f90 --- /dev/null +++ b/iam/src/main/resources/application-dev.yml @@ -0,0 +1,6 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${IAM_DB_USERNAME:root} + password: ${IAM_DB_PASSWORD:123456} diff --git a/iam/src/main/resources/application-test.yml b/iam/src/main/resources/application-test.yml new file mode 100644 index 000000000..9566a4810 --- /dev/null +++ b/iam/src/main/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + cloud: + nacos: + config: + namespace: ${NACOS_NAMESPACE:test} + group: ${NACOS_GROUP:BAIYUE} + encode: UTF-8 + discovery: + namespace: ${NACOS_NAMESPACE:test} + group: ${NACOS_GROUP:BAIYUE} + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${IAM_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} + username: ${IAM_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} + password: ${IAM_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index 03228f214..a23c9fe33 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -1,6 +1,8 @@ server: port: ${IAM_PORT:9201} spring: + profiles: + active: ${SPRING_PROFILES_ACTIVE:dev} application: name: iam-service config: @@ -19,12 +21,17 @@ spring: namespace: ${NACOS_NAMESPACE:public} config: namespace: ${NACOS_NAMESPACE:public} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${IAM_DB_USERNAME:root} - password: ${IAM_DB_PASSWORD:123456} + 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} + mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} clients: auth: url: ${AUTH_SERVICE_URL:} diff --git a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql new file mode 100644 index 000000000..991c1a490 --- /dev/null +++ b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql @@ -0,0 +1,115 @@ +-- Admin prototype-aligned page menus and page permissions. +-- Permission codes remain stable; this migration only adds page-level MENU resources, +-- reparents existing finance action resources, and retires unsupported fee create/delete actions. +USE baiyue_iam; +SET NAMES utf8mb4; + +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 + (5102041,0,'PLATFORM','MENU',5102000,'platform:firstleg:inbound:measure:view','订单测量','/inbounds/measurements','GET',30,'ACTIVE',1,0), + (5102051,0,'PLATFORM','MENU',5102000,'firstleg:inbound:measurement-review','测量数据审核','/inbounds/measurement-reviews','GET',40,'ACTIVE',1,0), + (5103070,0,'PLATFORM','API',5103010,'platform:domestic-container:list','整柜业务','/api/platform/v1/domestic-containers','GET',70,'ACTIVE',1,0), + (5103071,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:inbound:create','新建整柜入库单',NULL,'POST',10,'ACTIVE',1,0), + (5103072,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:outbound:create','新建整柜出库单',NULL,'POST',20,'ACTIVE',1,0), + (5103080,0,'PLATFORM','MENU',5103000,'platform:supplier:list','供应商列表','/suppliers','GET',80,'ACTIVE',1,0), + (5103081,0,'PLATFORM','BUTTON',5103080,'platform:supplier:create','新增供应商',NULL,'POST',10,'ACTIVE',1,0), + (5103082,0,'PLATFORM','BUTTON',5103080,'platform:supplier:edit','编辑供应商',NULL,'PUT',20,'ACTIVE',1,0), + (5103083,0,'PLATFORM','BUTTON',5103080,'platform:supplier:status','启停供应商',NULL,'PATCH',30,'ACTIVE',1,0), + (5105040,0,'PLATFORM','MENU',5105000,'platform:finance:customer-account:list','客户账户','/finance/customer-accounts','GET',10,'ACTIVE',1,0), + (5105041,0,'PLATFORM','MENU',5105000,'platform:finance:customer-arap:list','客户应收应付','/finance/customer-arap','GET',20,'ACTIVE',1,0), + (5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,0), + (5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,0), + (5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0), + (5105045,0,'PLATFORM','MENU',5105000,'platform:finance:receivable-statistics:list','应收款统计表','/finance/receivable-statistics','GET',25,'ACTIVE',1,0), + (5105050,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:export','导出客户账户',NULL,'POST',90,'ACTIVE',1,0), + (5105051,0,'PLATFORM','BUTTON',5105041,'platform:finance:customer-arap:export','导出客户应收应付',NULL,'POST',90,'ACTIVE',1,0), + (5105052,0,'PLATFORM','BUTTON',5105042,'platform:finance:customer-statement:export','导出客户对账单',NULL,'POST',90,'ACTIVE',1,0), + (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), + (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), + 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; + +UPDATE iam_permission + SET status='INACTIVE', deleted=1 + WHERE tenant_id=0 AND app_code='PLATFORM' + AND permission_code IN ( + 'platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete', + 'platform:finance:bill:view','platform:finance:account:view', + 'platform:finance:fee-source:view','platform:finance:settlement-ar:view', + 'platform:finance:settlement-ap:view'); + +DELETE rp + FROM iam_role_permission rp + JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id + WHERE p.tenant_id=0 AND p.app_code='PLATFORM' + AND p.permission_code IN ( + 'platform:finance:bill:view','platform:finance:account:view', + 'platform:finance:fee-source:view','platform:finance:settlement-ar:view', + 'platform:finance:settlement-ap:view'); + +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='PLATFORM' + 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: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:customer-statement:export', + 'platform:finance:settlement-ar:export','platform:finance:settlement-ap:export', + 'platform:finance:receivable-statistics:list','platform:finance:receivable-statistics:export', + 'platform:download:list', + 'platform:firstleg:inbound:measure:view', + 'firstleg:inbound:measurement-review','platform:supplier:list', + 'platform:supplier:create','platform:supplier:edit','platform:supplier:status', + 'platform:domestic-container:list','platform:domestic-container:inbound:create', + 'platform:domestic-container:outbound:create'); + +UPDATE iam_permission p + JOIN (SELECT 'platform:firstleg:inbound:measure:edit' code, 5102041 parent_id UNION ALL + SELECT 'platform:firstleg:inbound:warehouse-suggestion:edit', 5102041 UNION ALL + SELECT 'platform:firstleg:inbound:measure:submit', 5102041 UNION ALL + SELECT 'platform:firstleg:inbound:measure:manual-entry', 5102041 UNION ALL + SELECT 'platform:firstleg:inbound:weight:edit', 5102041 UNION ALL + SELECT 'platform:firstleg:inbound:weight:confirm', 5102041 UNION ALL + SELECT 'firstleg:inbound:measurement-review-history', 5102051 UNION ALL + SELECT 'firstleg:inbound:exception-release', 5102051) mapping + ON mapping.code=p.permission_code + SET p.parent_id=mapping.parent_id + WHERE p.tenant_id=0 AND p.app_code='PLATFORM'; + +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 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 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/audit-outbox.sql b/iam/src/main/resources/db/audit-outbox.sql new file mode 100644 index 000000000..effbafffb --- /dev/null +++ b/iam/src/main/resources/db/audit-outbox.sql @@ -0,0 +1,15 @@ +USE baiyue_iam; + +-- 可靠审计本地 Outbox;与 IAM 业务表位于同一数据库。 +CREATE TABLE IF NOT EXISTS sys_audit_outbox ( + event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID', event_type VARCHAR(64) NOT NULL COMMENT '审计事件类型', resource_type VARCHAR(64) NOT NULL COMMENT '被审计资源类型', + resource_id VARCHAR(128) NULL COMMENT '被审计资源ID', service_name VARCHAR(64) 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 '当前重试周期发送次数', + compensation_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 '最近一次发送错误信息', dead_at DATETIME(3) 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_audit_outbox_publish (status, next_retry_at, lease_until, created_at), + KEY idx_audit_outbox_retention (status, sent_at) +) ENGINE=InnoDB COMMENT='可靠审计事件本地发送箱'; diff --git a/iam/src/main/resources/db/container-workspace-permissions.sql b/iam/src/main/resources/db/container-workspace-permissions.sql new file mode 100644 index 000000000..b55c70ebb --- /dev/null +++ b/iam/src/main/resources/db/container-workspace-permissions.sql @@ -0,0 +1,132 @@ +-- 国内送柜报关与国外清关提柜双工作台权限;国内沿用现有货柜权限,国外直接收敛到独立权限族。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS migrate_container_workspace_permissions; +DELIMITER $$ +CREATE PROCEDURE migrate_container_workspace_permissions() +BEGIN + IF EXISTS ( + SELECT 1 FROM iam_permission + WHERE (id = 5102090 AND permission_code <> 'platform:firstleg:container:list') + OR (id = 5102092 AND permission_code <> 'platform:firstleg:container:create') + OR (id = 5102093 AND permission_code <> 'platform:firstleg:container:edit') + OR (id = 5102200 AND permission_code <> 'platform:firstleg:terminal-drayage:list') + OR (id = 5102201 AND permission_code <> 'platform:firstleg:terminal-drayage:document:edit') + OR (id = 5102202 AND permission_code <> 'platform:firstleg:terminal-drayage:fleet:edit') + OR (id = 5102203 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup-appointment:edit') + OR (id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm') + ) OR EXISTS ( + SELECT 1 FROM iam_permission + WHERE (permission_code = 'platform:firstleg:container:list' AND id <> 5102090) + OR (permission_code = 'platform:firstleg:container:create' AND id <> 5102092) + OR (permission_code = 'platform:firstleg:container:edit' AND id <> 5102093) + OR (permission_code = 'platform:firstleg:terminal-drayage:list' AND id <> 5102200) + OR (permission_code = 'platform:firstleg:terminal-drayage:document:edit' AND id <> 5102201) + OR (permission_code = 'platform:firstleg:terminal-drayage:fleet:edit' AND id <> 5102202) + OR (permission_code = 'platform:firstleg:terminal-drayage:pickup-appointment:edit' AND id <> 5102203) + OR (permission_code = 'platform:firstleg:terminal-drayage:empty-return:confirm' AND id <> 5102205) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限ID或编码冲突'; + END IF; + + 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 + (5102090,0,'PLATFORM','MENU',5102000,'platform:firstleg:container:list', + '国内送柜报关','/container-loading/domestic','GET',40,'ACTIVE',1,0), + (5102092,0,'PLATFORM','BUTTON',5102090,'platform:firstleg:container:create', + '建立国内货柜',NULL,'POST',20,'ACTIVE',1,0), + (5102093,0,'PLATFORM','BUTTON',5102090,'platform:firstleg:container:edit', + '维护国内货柜资料',NULL,'PUT',30,'ACTIVE',1,0), + (5102200,0,'PLATFORM','MENU',5102000,'platform:firstleg:terminal-drayage:list', + '国外清关提柜','/container-loading/overseas','GET',41,'ACTIVE',1,0), + (5102201,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:document:edit', + '维护目的港清关与DO/PU',NULL,'POST',10,'ACTIVE',1,0), + (5102202,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:fleet:edit', + '维护目的港码头车队',NULL,'POST',20,'ACTIVE',1,0), + (5102203,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup-appointment:edit', + '预约目的港提柜',NULL,'POST',30,'ACTIVE',1,0), + (5102205,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:empty-return:confirm', + '确认目的港实际还柜',NULL,'POST',50,'ACTIVE',1,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=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', + built_in=1, deleted=0; + + -- list 已包含详情只读;旧 container:view 不再作为别名或双权限入口。 + DELETE grant_row FROM iam_role_permission grant_row + JOIN iam_permission permission + ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE permission.tenant_id=0 + AND permission.permission_code='platform:firstleg:container:view'; + UPDATE iam_permission + SET status='INACTIVE', deleted=1 + WHERE tenant_id=0 AND permission_code='platform:firstleg:container:view'; + + -- 平台超级管理员和现有订单运营保持货柜全链路能力;其他角色按两个菜单独立授权。 + INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) + SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(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:firstleg:container:list', + 'platform:firstleg:container:create', + 'platform:firstleg:container:edit', + 'platform:firstleg:terminal-drayage:list', + 'platform:firstleg:terminal-drayage:document:edit', + 'platform:firstleg:terminal-drayage:fleet:edit', + 'platform:firstleg:terminal-drayage:pickup-appointment:edit', + 'platform:firstleg:terminal-drayage:empty-return:confirm') + WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') + AND role.status='ACTIVE' AND role.deleted=0 + AND permission.status='ACTIVE' AND permission.deleted=0; + + IF (SELECT COUNT(*) FROM iam_permission + WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 + AND permission_code IN ( + 'platform:firstleg:container:list', + 'platform:firstleg:container:create', + 'platform:firstleg:container:edit', + 'platform:firstleg:terminal-drayage:list', + 'platform:firstleg:terminal-drayage:document:edit', + 'platform:firstleg:terminal-drayage:fleet:edit', + 'platform:firstleg:terminal-drayage:pickup-appointment:edit', + 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 8 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限目录不完整'; + END IF; + + IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code)) + 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 role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') + AND permission.permission_code IN ( + 'platform:firstleg:container:list', + 'platform:firstleg:container:create', + 'platform:firstleg:container:edit', + 'platform:firstleg:terminal-drayage:list', + 'platform:firstleg:terminal-drayage:document:edit', + 'platform:firstleg:terminal-drayage:fleet:edit', + 'platform:firstleg:terminal-drayage:pickup-appointment:edit', + 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 16 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:平台默认角色授权不完整'; + END IF; + + IF EXISTS (SELECT 1 FROM iam_permission + WHERE tenant_id=0 AND permission_code='platform:firstleg:container:view' + AND (status='ACTIVE' OR deleted=0)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:旧查看权限仍然有效'; + END IF; +END$$ +DELIMITER ; + +CALL migrate_container_workspace_permissions(); +DROP PROCEDURE IF EXISTS migrate_container_workspace_permissions; diff --git a/iam/src/main/resources/db/customer-dimension-config-permissions.sql b/iam/src/main/resources/db/customer-dimension-config-permissions.sql new file mode 100644 index 000000000..af6bfbef1 --- /dev/null +++ b/iam/src/main/resources/db/customer-dimension-config-permissions.sql @@ -0,0 +1,52 @@ +-- 客户尺寸计算配置权限;仅用于平台后台查看和维护,不控制内部初始化接口。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS migrate_customer_dimension_config_permissions; +DELIMITER $$ +CREATE PROCEDURE migrate_customer_dimension_config_permissions() +BEGIN + IF EXISTS ( + SELECT 1 + FROM iam_permission + WHERE (id = 5102111 AND permission_code <> 'firstleg:dimension-config:view') + OR (id = 5102112 AND permission_code <> 'firstleg:dimension-config:edit') + OR (permission_code = 'firstleg:dimension-config:view' AND id <> 5102111) + OR (permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'customer dimension config permission id/code conflict'; + END IF; + +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 + (5102111, 0, 'PLATFORM', 'BUTTON', 5100010, + 'firstleg:dimension-config:view', '查看客户尺寸计算配置', NULL, NULL, 90, 'ACTIVE', 1, 0), + (5102112, 0, 'PLATFORM', 'BUTTON', 5100010, + 'firstleg:dimension-config:edit', '维护客户尺寸计算配置', NULL, NULL, 100, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, role.tenant_id +FROM iam_role role +JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.app_code = 'PLATFORM' +WHERE role.tenant_id = 0 + AND role.app_code = 'PLATFORM' + AND ( + (role.role_code = 'PLATFORM_ADMIN' + AND permission.permission_code IN ( + 'firstleg:dimension-config:view', 'firstleg:dimension-config:edit')) + OR (role.role_code = 'ORDER_OPERATOR' + AND permission.permission_code = 'firstleg:dimension-config:view') + ); +END$$ +DELIMITER ; + +CALL migrate_customer_dimension_config_permissions(); +DROP PROCEDURE IF EXISTS migrate_customer_dimension_config_permissions; 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/domestic-container-permissions.sql b/iam/src/main/resources/db/domestic-container-permissions.sql new file mode 100644 index 000000000..359668d6e --- /dev/null +++ b/iam/src/main/resources/db/domestic-container-permissions.sql @@ -0,0 +1,61 @@ +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS migrate_domestic_container_permissions; +DELIMITER $$ +CREATE PROCEDURE migrate_domestic_container_permissions() +BEGIN + IF EXISTS ( + SELECT 1 FROM iam_permission + WHERE (id = 5103070 AND permission_code <> 'platform:domestic-container:list') + OR (id = 5103071 AND permission_code <> 'platform:domestic-container:inbound:create') + OR (id = 5103072 AND permission_code <> 'platform:domestic-container:outbound:create') + OR (permission_code = 'platform:domestic-container:list' AND id <> 5103070) + OR (permission_code = 'platform:domestic-container:inbound:create' AND id <> 5103071) + OR (permission_code = 'platform:domestic-container:outbound:create' AND id <> 5103072) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'domestic container permission id/code conflict'; + END IF; + + 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 + (5103070,0,'PLATFORM','API',5103010,'platform:domestic-container:list', + '查看国内整柜业务','/api/platform/v1/domestic-containers','GET',40,'ACTIVE',1,0), + (5103071,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:inbound:create', + '新建整柜入库单','/api/platform/v1/domestic-containers/inbounds','POST',10,'ACTIVE',1,0), + (5103072,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:outbound:create', + '新建整柜出库单','/api/platform/v1/domestic-containers/outbounds','POST',20,'ACTIVE',1,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=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, r.id, p.id, CURRENT_TIMESTAMP(3) + FROM iam_role r + JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM' + WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' + AND r.status='ACTIVE' AND r.deleted=0 + AND p.permission_code IN ('platform:domestic-container:list', + 'platform:domestic-container:inbound:create','platform:domestic-container:outbound:create'); + + IF (SELECT COUNT(*) FROM iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' + AND status='ACTIVE' AND built_in=1 AND deleted=0 + AND permission_code IN ('platform:domestic-container:list', + 'platform:domestic-container:inbound:create','platform:domestic-container:outbound:create')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'domestic container permission catalog is incomplete'; + END IF; + IF (SELECT COUNT(*) FROM iam_role_permission rp + JOIN iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id + JOIN 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='PLATFORM' AND r.role_code='PLATFORM_ADMIN' + AND p.permission_code IN ('platform:domestic-container:list', + 'platform:domestic-container:inbound:create','platform:domestic-container:outbound:create')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PLATFORM_ADMIN domestic container grant is incomplete'; + END IF; +END$$ +DELIMITER ; +CALL migrate_domestic_container_permissions(); +DROP PROCEDURE IF EXISTS migrate_domestic_container_permissions; diff --git a/iam/src/main/resources/db/finance-g1-g2-permissions.sql b/iam/src/main/resources/db/finance-g1-g2-permissions.sql new file mode 100644 index 000000000..5d979ecb0 --- /dev/null +++ b/iam/src/main/resources/db/finance-g1-g2-permissions.sql @@ -0,0 +1,89 @@ +-- Finance G1/G2 permissions exposed by FinancePhase1OperationController. +-- These resources are assignable to custom platform roles; this migration does not grant them by default. +USE baiyue_iam; +SET NAMES utf8mb4; + +DELETE rp FROM iam_role_permission rp +JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id +WHERE p.tenant_id=0 AND p.app_code='PLATFORM' + AND p.permission_code IN ('platform:finance:fee-source:settlement','platform:finance:bill:create','platform:finance:bill:issue'); +DELETE FROM iam_permission +WHERE tenant_id=0 AND app_code='PLATFORM' + AND permission_code IN ('platform:finance:fee-source:settlement','platform:finance:bill:create','platform:finance:bill:issue'); + +DROP PROCEDURE IF EXISTS migrate_finance_g1_g2_permissions; +DELIMITER $$ +CREATE PROCEDURE migrate_finance_g1_g2_permissions() +BEGIN + IF EXISTS ( + SELECT 1 + FROM iam_permission + WHERE (id = 5105000 AND permission_code <> 'platform:finance') + OR (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review') + OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:edit') + OR (permission_code = 'platform:finance' AND id <> 5105000) + OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024) + OR (permission_code = 'platform:finance:fee-source:edit' AND id <> 5105025) + OR (id IN (5105000, 5105024, 5105025) + AND (tenant_id <> 0 OR app_code <> 'PLATFORM')) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'finance G1/G2 permission id/code conflict'; + END IF; + + 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 + (5105000, 0, 'PLATFORM', 'MENU', 0, 'platform:finance', + '财务管理', '/finance', NULL, 60, 'ACTIVE', 1, 0), + (5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review', + '审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0), + (5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit', + '编辑财务费用来源', NULL, 'PUT', 160, 'ACTIVE', 1, 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 = VALUES(http_method), sort_no = VALUES(sort_no), + status = 'ACTIVE', built_in = 1, deleted = 0; + + DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id + WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN ('platform:finance:account:view','platform:finance:fee-source:view'); + UPDATE iam_permission SET status='INACTIVE', deleted=1 + WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:finance:account:view','platform:finance:fee-source:view'); + + IF (SELECT COUNT(*) + FROM iam_permission + WHERE tenant_id = 0 + AND app_code = 'PLATFORM' + AND permission_type = 'BUTTON' + AND parent_id = 5105000 + AND status = 'ACTIVE' + AND built_in = 1 + AND deleted = 0 + AND permission_code IN ( + 'platform:finance:fee-source:review', + 'platform:finance:fee-source:edit')) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'finance G1/G2 permission catalog is incomplete'; + END IF; + + IF (SELECT COUNT(*) + FROM iam_permission + WHERE id = 5105000 + AND tenant_id = 0 + AND app_code = 'PLATFORM' + AND permission_type = 'MENU' + AND parent_id = 0 + AND permission_code = 'platform:finance' + AND status = 'ACTIVE' + AND built_in = 1 + AND deleted = 0) <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'finance permission parent is invalid'; + END IF; +END$$ +DELIMITER ; + +CALL migrate_finance_g1_g2_permissions(); +DROP PROCEDURE IF EXISTS migrate_finance_g1_g2_permissions; diff --git a/iam/src/main/resources/db/finance-g3-g4-permissions.sql b/iam/src/main/resources/db/finance-g3-g4-permissions.sql new file mode 100644 index 000000000..469905a3a --- /dev/null +++ b/iam/src/main/resources/db/finance-g3-g4-permissions.sql @@ -0,0 +1,83 @@ +-- Finance G3/G4 permissions exposed by the frozen reconciliation and settlement controllers. +-- These resources are assignable to custom platform roles; this migration never grants them by default. +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS migrate_finance_g3_g4_permissions; +DELIMITER $$ +CREATE PROCEDURE migrate_finance_g3_g4_permissions() +BEGIN +IF EXISTS ( + SELECT 1 + FROM iam_permission + WHERE (id = 5105026 AND permission_code <> 'platform:finance:reconciliation:create') + OR (id = 5105027 AND permission_code <> 'platform:finance:reconciliation:review') + OR (id = 5105028 AND permission_code <> 'platform:finance:reconciliation:issue') + OR (id = 5105029 AND permission_code <> 'platform:finance:reconciliation:adjust') + OR (id = 5105030 AND permission_code <> 'platform:finance:reconciliation:void') + OR (id = 5105031 AND permission_code <> 'platform:finance:settlement-ar:create') + OR (id = 5105032 AND permission_code <> 'platform:finance:settlement-ap:create') + OR (id = 5105035 AND permission_code <> 'platform:finance:payment:create') + OR (permission_code = 'platform:finance:reconciliation:create' AND id <> 5105026) + OR (permission_code = 'platform:finance:reconciliation:review' AND id <> 5105027) + OR (permission_code = 'platform:finance:reconciliation:issue' AND id <> 5105028) + OR (permission_code = 'platform:finance:reconciliation:adjust' AND id <> 5105029) + OR (permission_code = 'platform:finance:reconciliation:void' AND id <> 5105030) + OR (permission_code = 'platform:finance:settlement-ar:create' AND id <> 5105031) + OR (permission_code = 'platform:finance:settlement-ap:create' AND id <> 5105032) + OR (permission_code = 'platform:finance:payment:create' AND id <> 5105035) + OR (id BETWEEN 5105026 AND 5105035 AND (tenant_id <> 0 OR app_code <> 'PLATFORM')) +) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='finance G3/G4 permission id/code conflict'; +END IF; + +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 + (5105026, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:create', '生成和提交客户对账单', NULL, 'POST', 170, 'ACTIVE', 1, 0), + (5105027, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:review', '审核客户对账单', NULL, 'POST', 180, 'ACTIVE', 1, 0), + (5105028, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:issue', '出账客户对账单', NULL, 'POST', 190, 'ACTIVE', 1, 0), + (5105029, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:adjust', '调整客户对账单', NULL, 'POST', 200, 'ACTIVE', 1, 0), + (5105030, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:void', '作废或重开客户对账单', NULL, 'POST', 210, 'ACTIVE', 1, 0), + (5105031, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ar:create', '生成应收结算单', NULL, 'POST', 220, 'ACTIVE', 1, 0), + (5105032, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ap:create', '生成应付结算单', NULL, 'POST', 230, 'ACTIVE', 1, 0), + (5105035, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:payment:create', '登记应付付款', NULL, 'POST', 260, 'ACTIVE', 1, 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 = VALUES(http_method), sort_no = VALUES(sort_no), + status = 'ACTIVE', built_in = 1, deleted = 0; + +DELETE rp + FROM iam_role_permission rp + JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id + WHERE p.tenant_id=0 AND p.app_code='PLATFORM' + AND p.permission_code IN ('platform:finance:settlement-ar:view','platform:finance:settlement-ap:view'); + +UPDATE iam_permission + SET status='INACTIVE', deleted=1 + WHERE tenant_id=0 AND app_code='PLATFORM' + AND permission_code IN ('platform:finance:settlement-ar:view','platform:finance:settlement-ap:view'); + +IF (SELECT COUNT(*) FROM iam_permission + WHERE tenant_id=0 AND app_code='PLATFORM' AND parent_id=5105000 + AND permission_code IN ('platform:finance:reconciliation:create','platform:finance:reconciliation:review', + 'platform:finance:reconciliation:issue','platform:finance:reconciliation:adjust','platform:finance:reconciliation:void', + 'platform:finance:settlement-ar:create','platform:finance:settlement-ap:create', + 'platform:finance:payment:create') + AND status='ACTIVE' AND deleted=0) <> 8 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='finance G3/G4 permission catalog is incomplete'; +END IF; + +IF (SELECT COUNT(*) FROM iam_permission + WHERE id=5105000 AND tenant_id=0 AND app_code='PLATFORM' + AND permission_type='MENU' AND permission_code='platform:finance' + AND status='ACTIVE' AND deleted=0) <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='finance permission parent is invalid'; +END IF; +END$$ +DELIMITER ; + +CALL migrate_finance_g3_g4_permissions(); +DROP PROCEDURE IF EXISTS migrate_finance_g3_g4_permissions; diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 7ffb12b24..333c94552 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -1,20 +1,34 @@ SET NAMES utf8mb4; -- IAM 服务独占数据库:负责租户、成员、双端角色、菜单/页面/按钮/API权限和数据范围。 + CREATE DATABASE IF NOT EXISTS baiyue_iam DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_iam; CREATE TABLE IF NOT EXISTS iam_tenant ( - id BIGINT NOT NULL COMMENT '租户ID(雪花ID)', - tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一租户编码', - tenant_name VARCHAR(128) NOT NULL COMMENT '租户名称', - tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '租户类型', - status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '租户状态:PROFILE_REQUIRED、PENDING、ACTIVE、REJECTED、DISABLED', + id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域', + tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM', + tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', + customer_nickname VARCHAR(32) 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 '统一社会信用代码', contact_name VARCHAR(64) NULL COMMENT '企业联系人', - contact_phone VARCHAR(32) NULL COMMENT '联系电话', + contact_phone VARCHAR(32) NULL COMMENT '公司联系电话,非登录标识且不要求跨企业唯一', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + service_package VARCHAR(64) NOT NULL DEFAULT '基础版' COMMENT '客户服务套餐', + contact_wechat VARCHAR(64) NULL COMMENT '联系微信', + sales_owner_name VARCHAR(64) NULL COMMENT '销售负责人姓名', + payment_term_days SMALLINT UNSIGNED NULL COMMENT '账期天数', + enterprise_type VARCHAR(64) NULL COMMENT '企业类型', + established_date DATE NULL COMMENT '成立日期', + registered_capital DECIMAL(18,2) NULL COMMENT '注册资本', + registered_capital_currency CHAR(3) NULL COMMENT '注册资本币种 ISO 4217', + registered_address VARCHAR(500) NULL COMMENT '注册地址文本', + internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注', approved_at DATETIME(3) NULL COMMENT '审核通过时间', approved_by BIGINT NULL COMMENT '审核人ID', reject_reason VARCHAR(500) NULL COMMENT '驳回原因', @@ -28,13 +42,44 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( UNIQUE KEY uk_iam_tenant_code (tenant_code), UNIQUE KEY uk_iam_tenant_credit (unified_social_credit_code), KEY idx_iam_tenant_status (status, created_at) -) ENGINE=InnoDB COMMENT='企业租户主表:作为SaaS数据隔离边界,保存商户企业资料、审核结果和启停状态'; +) ENGINE=InnoDB COMMENT='客户企业主档表:保存企业资料、审核结果和启停状态;ID同时作为客户数据隔离键'; + +CREATE TABLE IF NOT EXISTS iam_number_sequence ( + sequence_key VARCHAR(64) NOT NULL COMMENT '业务编号类型', + next_value BIGINT UNSIGNED NOT NULL COMMENT '下一待分配数字', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (sequence_key) +) ENGINE=InnoDB COMMENT='IAM业务编号序列表'; + +INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1); + +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', - review_type VARCHAR(20) NOT NULL DEFAULT 'REGISTER' COMMENT '审核类型', - status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态', + tenant_id BIGINT NOT NULL COMMENT '被审核客户ID和数据隔离键', + review_type VARCHAR(20) NOT NULL DEFAULT 'REGISTER' COMMENT '审核类型:REGISTER注册审核等', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回', reviewer_id BIGINT NULL COMMENT '审核人ID', review_comment VARCHAR(500) NULL COMMENT '审核意见', reviewed_at DATETIME(3) NULL COMMENT '审核完成时间', @@ -42,51 +87,52 @@ CREATE TABLE IF NOT EXISTS iam_tenant_review ( PRIMARY KEY (id), KEY idx_review_queue (status, created_at), KEY idx_review_tenant (tenant_id) -) ENGINE=InnoDB COMMENT='租户审核记录表:保存企业注册、资料变更等审核过程和审核意见'; +) ENGINE=InnoDB COMMENT='客户审核记录表:保存企业注册、资料变更等审核过程和审核意见'; CREATE TABLE IF NOT EXISTS iam_tenant_address ( - id BIGINT NOT NULL COMMENT '租户地址ID', - tenant_id BIGINT NOT NULL COMMENT '所属租户ID', - address_type VARCHAR(20) NOT NULL COMMENT '地址类型', + id BIGINT NOT NULL COMMENT '客户地址ID', + tenant_id BIGINT NOT NULL COMMENT '客户ID和数据隔离键', + address_type VARCHAR(20) NOT NULL COMMENT '地址类型:注册地址、办公地址等', country_code CHAR(2) NOT NULL DEFAULT 'CN' COMMENT '国家二字码', province VARCHAR(64) NULL COMMENT '省/州', city VARCHAR(64) NULL COMMENT '城市', district VARCHAR(64) NULL COMMENT '区县', detail VARCHAR(255) NOT NULL COMMENT '详细地址', postal_code VARCHAR(16) NULL COMMENT '邮政编码', - is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认地址', + is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认地址:0否、1是', 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 '更新时间', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', PRIMARY KEY (id), KEY idx_address_tenant (tenant_id, address_type) -) ENGINE=InnoDB COMMENT='租户地址表:保存企业注册地址、办公地址等租户级资料'; +) ENGINE=InnoDB COMMENT='客户地址表:保存企业注册地址、办公地址等客户资料'; CREATE TABLE IF NOT EXISTS iam_tenant_qualification ( id BIGINT NOT NULL COMMENT '资质记录ID', - tenant_id BIGINT NOT NULL COMMENT '所属租户ID', - qualification_type VARCHAR(32) NOT NULL COMMENT '资质类型', + tenant_id BIGINT NOT NULL COMMENT '客户ID和数据隔离键', + qualification_type VARCHAR(32) NOT NULL COMMENT '客户资质类型', certificate_no VARCHAR(128) NULL COMMENT '证书编号', document_url VARCHAR(500) NULL COMMENT '资质文件存储地址', + original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名', valid_from DATE NULL COMMENT '有效期开始日期', valid_to DATE NULL COMMENT '有效期结束日期', - status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回', review_comment 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 '更新时间', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', PRIMARY KEY (id), KEY idx_qualification_tenant (tenant_id, status) -) ENGINE=InnoDB COMMENT='租户资质表:保存企业证照、有效期、文件地址和审核状态'; +) ENGINE=InnoDB COMMENT='客户资质表:保存企业证照、有效期、文件地址和审核状态'; CREATE TABLE IF NOT EXISTS iam_user ( id BIGINT NOT NULL COMMENT 'IAM用户资料ID', auth_user_id BIGINT NOT NULL COMMENT '认证服务账号逻辑ID', - user_type VARCHAR(20) NOT NULL DEFAULT 'ENTERPRISE' COMMENT '用户类型', + user_type VARCHAR(20) NOT NULL DEFAULT 'ENTERPRISE' COMMENT '用户类型:ENTERPRISE企业用户、PLATFORM平台用户', display_name VARCHAR(64) NOT NULL COMMENT '用户展示名称', - phone VARCHAR(32) NULL COMMENT '手机号', + phone VARCHAR(32) NULL COMMENT '用户登录手机号,与企业联系电话独立', email VARCHAR(128) NULL COMMENT '邮箱', - status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '用户资料状态', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '用户资料状态:ACTIVE正常、DISABLED停用', 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 '更新时间', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', @@ -97,14 +143,15 @@ CREATE TABLE IF NOT EXISTS iam_user ( ) ENGINE=InnoDB COMMENT='IAM用户资料表:连接统一认证账号与业务身份资料,不保存密码'; CREATE TABLE IF NOT EXISTS iam_membership ( - id BIGINT NOT NULL COMMENT '租户成员关系ID', - tenant_id BIGINT NOT NULL COMMENT '所属租户ID', + id BIGINT NOT NULL COMMENT '客户成员关系ID', + tenant_id BIGINT NOT NULL COMMENT '客户ID和数据隔离键', user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID', - member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型', - member_no VARCHAR(64) NULL COMMENT '租户内部成员编号', - status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态', - joined_at DATETIME(3) NULL COMMENT '加入租户时间', - left_at DATETIME(3) NULL COMMENT '离开租户时间', + 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 '离开客户企业时间', 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 '更新时间', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', @@ -114,16 +161,16 @@ CREATE TABLE IF NOT EXISTS iam_membership ( UNIQUE KEY uk_membership_tenant_no (tenant_id, member_no), KEY idx_membership_user (user_id, status), KEY idx_membership_tenant (tenant_id, status) -) ENGINE=InnoDB COMMENT='租户成员关系表:表示一个用户在某个企业租户中的成员身份,是分配商户端角色的主体'; +) ENGINE=InnoDB COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体'; CREATE TABLE IF NOT EXISTS iam_role ( id BIGINT NOT NULL COMMENT '角色ID', - tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色所属租户;0表示平台预置角色', - app_code VARCHAR(20) NOT NULL COMMENT '应用端:MERCHANT或PLATFORM', + tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色作用域:0为平台预置模板,正数为客户ID和数据隔离键', + app_code VARCHAR(20) NOT NULL COMMENT '应用端:MERCHANT企业客户端、PLATFORM平台后台', role_code VARCHAR(64) NOT NULL COMMENT '作用域内唯一角色编码', role_name VARCHAR(128) NOT NULL COMMENT '角色名称', role_type VARCHAR(20) NOT NULL DEFAULT 'CUSTOM' COMMENT '角色类型:SYSTEM或CUSTOM', - status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '角色状态', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '角色状态:ACTIVE启用、DISABLED停用', description VARCHAR(255) NULL COMMENT '角色用途说明', created_by BIGINT NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -134,21 +181,21 @@ CREATE TABLE IF NOT EXISTS iam_role ( PRIMARY KEY (id), UNIQUE KEY uk_role_scope_code (tenant_id, app_code, role_code), KEY idx_role_tenant (tenant_id, app_code, status) -) ENGINE=InnoDB COMMENT='角色表:按租户和应用端隔离商户端、平台后台两套角色体系'; +) ENGINE=InnoDB COMMENT='角色表:按客户或平台作用域及应用端隔离企业客户端、平台后台两套角色体系'; CREATE TABLE IF NOT EXISTS iam_membership_role ( - membership_id BIGINT NOT NULL COMMENT '租户成员关系ID', + membership_id BIGINT NOT NULL COMMENT '客户成员关系ID', role_id BIGINT NOT NULL COMMENT '角色ID', - tenant_id BIGINT NOT NULL COMMENT '冗余租户ID,用于租户隔离校验和查询', + tenant_id BIGINT NOT NULL COMMENT '冗余客户ID和数据隔离键,用于授权归属校验和查询', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '授权时间', PRIMARY KEY (membership_id, role_id), KEY idx_mr_tenant (tenant_id) -) ENGINE=InnoDB COMMENT='成员角色关联表:将租户成员分配到对应应用端的一个或多个角色'; +) ENGINE=InnoDB COMMENT='成员角色关联表:将客户成员分配到对应应用端的一个或多个角色'; CREATE TABLE IF NOT EXISTS iam_permission ( id BIGINT NOT NULL COMMENT '权限资源ID', - tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '权限所属租户;0表示平台预置权限', - app_code VARCHAR(20) NOT NULL COMMENT '所属应用端:MERCHANT或PLATFORM', + tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '权限作用域:0为平台预置权限,正数为客户ID和数据隔离键', + app_code VARCHAR(20) NOT NULL COMMENT '所属应用端:MERCHANT企业客户端、PLATFORM平台后台', permission_type VARCHAR(20) NOT NULL COMMENT '权限类型:MENU、PAGE、BUTTON或API', parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父权限ID,用于构造菜单和页面树', permission_code VARCHAR(128) NOT NULL COMMENT '鉴权使用的唯一权限码', @@ -156,7 +203,8 @@ CREATE TABLE IF NOT EXISTS iam_permission ( route_path VARCHAR(255) NULL COMMENT '前端路由或API路径', http_method VARCHAR(16) NULL COMMENT 'API权限对应的HTTP方法', sort_no INT NOT NULL DEFAULT 0 COMMENT '同级显示顺序', - status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '权限状态', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '权限状态:ACTIVE启用、DISABLED停用', + built_in TINYINT NOT NULL DEFAULT 0 COMMENT '是否为系统预置权限:0否、1是;预置权限不可通过管理接口修改', 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 '更新时间', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', @@ -169,7 +217,7 @@ CREATE TABLE IF NOT EXISTS iam_permission ( CREATE TABLE IF NOT EXISTS iam_role_permission ( role_id BIGINT NOT NULL COMMENT '角色ID', permission_id BIGINT NOT NULL COMMENT '权限资源ID', - tenant_id BIGINT NOT NULL COMMENT '冗余租户ID,用于隔离校验和查询', + tenant_id BIGINT NOT NULL COMMENT '冗余客户ID或平台作用域ID,用于授权归属校验和查询', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '授权时间', PRIMARY KEY (role_id, permission_id), KEY idx_rp_tenant (tenant_id) @@ -178,13 +226,13 @@ CREATE TABLE IF NOT EXISTS iam_role_permission ( CREATE TABLE IF NOT EXISTS iam_data_scope ( id BIGINT NOT NULL COMMENT '数据范围规则ID', role_id BIGINT NOT NULL COMMENT '角色ID', - tenant_id BIGINT NOT NULL COMMENT '规则所属租户ID', - scope_type VARCHAR(20) NOT NULL DEFAULT 'SELF' COMMENT '范围类型:SELF、DEPARTMENT、TENANT或CUSTOM', + tenant_id BIGINT NOT NULL COMMENT '规则作用域:0为平台,正数为客户ID和数据隔离键', + scope_type VARCHAR(20) NOT NULL DEFAULT 'SELF' COMMENT '数据范围类型:SELF本人、DEPARTMENT部门、TENANT当前客户、CUSTOM自定义', scope_expression JSON NULL COMMENT '自定义数据范围表达式', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_scope_role (role_id) -) ENGINE=InnoDB COMMENT='角色数据范围表:在功能权限之外限制角色可查看和操作的租户内数据集合'; +) ENGINE=InnoDB COMMENT='角色数据范围表:在功能权限之外限制角色可查看和操作的平台或客户范围内数据集合'; -- 存量数据库重复执行初始化脚本时,不重复创建审核状态索引。 SET @review_status_index_sql = IF( diff --git a/iam/src/main/resources/db/inbound-hold-permission.sql b/iam/src/main/resources/db/inbound-hold-permission.sql new file mode 100644 index 000000000..ad20dd6e9 --- /dev/null +++ b/iam/src/main/resources/db/inbound-hold-permission.sql @@ -0,0 +1,85 @@ +-- 平台入库单扣货独立写权限;查看入库单不会自动获得扣货能力。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS migrate_inbound_hold_permission; +DELIMITER $$ +CREATE PROCEDURE migrate_inbound_hold_permission() +BEGIN + IF EXISTS ( + SELECT 1 + FROM iam_permission + WHERE (id = 5102032 AND (tenant_id <> 0 + OR app_code <> 'PLATFORM' + OR permission_type <> 'BUTTON' + OR permission_code <> 'platform:firstleg:inbound:hold')) + OR (permission_code = 'platform:firstleg:inbound:hold' + AND (id <> 5102032 OR tenant_id <> 0 OR app_code <> 'PLATFORM')) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='入库单扣货权限初始化失败:权限ID、编码或类型冲突'; + END IF; + + 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 + (5102032, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:hold', + '扣货与解除扣货', NULL, 'POST', 20, 'ACTIVE', 1, 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), + 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, CURRENT_TIMESTAMP(3) + FROM iam_role role + JOIN iam_permission permission + ON permission.tenant_id=0 + AND permission.app_code='PLATFORM' + AND permission.permission_code='platform:firstleg:inbound:hold' + 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; + + IF (SELECT COUNT(*) + FROM iam_permission + WHERE id=5102032 + AND tenant_id=0 + AND app_code='PLATFORM' + AND permission_type='BUTTON' + AND parent_id=5102030 + AND permission_code='platform:firstleg:inbound:hold' + AND http_method='POST' + AND status='ACTIVE' + AND built_in=1 + AND deleted=0) <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='入库单扣货权限初始化失败:权限资源不完整'; + END IF; + + 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 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.permission_code='platform:firstleg:inbound:hold' + AND permission.status='ACTIVE' + AND permission.deleted=0) <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='入库单扣货权限初始化失败:平台超级管理员授权不完整'; + END IF; +END$$ +DELIMITER ; + +CALL migrate_inbound_hold_permission(); +DROP PROCEDURE IF EXISTS migrate_inbound_hold_permission; 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..3e5897429 --- /dev/null +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -0,0 +1,316 @@ +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:%'; + +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id + AND permission.tenant_id=role_permission.tenant_id +WHERE permission.permission_code=CONCAT('platform:overseas-', 'warehouse:warehouse-transfer:list'); +UPDATE iam_permission SET status='INACTIVE', deleted=1 +WHERE permission_code=CONCAT('platform:overseas-', 'warehouse:warehouse-transfer:list'); + +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), + (5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'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), + (5107190,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-inventory:list','退货库存管理','/overseas-warehouse/return-inventory','GET',890,'ACTIVE',1,0), + (5107191,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-cartons:list','退货箱号管理','/overseas-warehouse/return-cartons','GET',891,'ACTIVE',1,0), + (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0), + (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0), + (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'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), + (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement','海外采购','/cross-border/procurement',NULL,70,'ACTIVE',1,0), + (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking',NULL,80,'ACTIVE',1,0), + (5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping',NULL,90,'ACTIVE',1,0), + (5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,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=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='OVERSEAS_WAREHOUSE_PDA_OPERATOR'); + +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='OVERSEAS_WAREHOUSE_PDA_OPERATOR' + 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='OVERSEAS_WAREHOUSE_PDA_OPERATOR') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:海外仓 PDA 作业员未精确授权'; + 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='OVERSEAS_WAREHOUSE_PDA_OPERATOR')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非海外仓 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','OVERSEAS_WAREHOUSE_PDA_OPERATOR','WAREHOUSE_TALLY','CN_FIRSTLEG_PDA_OPERATOR') + 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; + +-- 客户默认管理员获得当前客户海外业务入口;其他客户角色按企业实际分工授权。 +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='MERCHANT' + AND permission.permission_code IN ( + 'merchant:cross-border:procurement', + 'merchant:cross-border:domestic-trucking', + 'merchant:cross-border:pallet-shipping', + 'merchant:warehouse:inbound:list' + ) +WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS assert_overseas_merchant_permissions; +DELIMITER $$ +CREATE PROCEDURE assert_overseas_merchant_permissions() +BEGIN + IF (SELECT COUNT(*) FROM iam_permission + WHERE tenant_id=0 AND app_code='MERCHANT' AND status='ACTIVE' AND deleted=0 + AND permission_code IN ( + 'merchant:cross-border:procurement', + 'merchant:cross-border:domestic-trucking', + 'merchant:cross-border:pallet-shipping', + 'merchant:warehouse:inbound:list')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:权限资源不完整'; + END IF; + 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 + WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' + AND role.status='ACTIVE' AND role.deleted=0 + AND permission.tenant_id=0 AND permission.app_code='MERCHANT' + AND permission.status='ACTIVE' AND permission.deleted=0 + AND permission.permission_code IN ( + 'merchant:cross-border:procurement', + 'merchant:cross-border:domestic-trucking', + 'merchant:cross-border:pallet-shipping', + 'merchant:warehouse:inbound:list')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:客户默认管理员授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_overseas_merchant_permissions(); +DROP PROCEDURE assert_overseas_merchant_permissions; + +-- 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', + 'platform:overseas-warehouse:warehouse-transfer:approve' + ) +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; + + +-- 转仓审核权限与管理员授权必须实际落库,不能被其他权限的 ID 冲突静默吞掉。 +DROP PROCEDURE IF EXISTS assert_warehouse_transfer_approval_permission; +DELIMITER $ +CREATE PROCEDURE assert_warehouse_transfer_approval_permission() +BEGIN + IF (SELECT COUNT(*) FROM iam_permission WHERE id=5107003 AND tenant_id=0 + AND permission_code='platform:overseas-warehouse:warehouse-transfer:approve' + AND permission_type='BUTTON' AND status='ACTIVE' AND deleted=0) <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='转仓审核权限初始化失败:权限ID或编码冲突'; + END IF; + IF EXISTS (SELECT 1 FROM iam_role role 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 NOT EXISTS (SELECT 1 FROM iam_role_permission grant_row + WHERE grant_row.tenant_id=0 AND grant_row.role_id=role.id AND grant_row.permission_id=5107003)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='转仓审核权限初始化失败:平台管理员授权缺失'; + END IF; +END$ +DELIMITER ; +CALL assert_warehouse_transfer_approval_permission(); +DROP PROCEDURE assert_warehouse_transfer_approval_permission; + +-- PLATFORM_CUSTOM_ADMIN needs access to the overseas outbound and delivery lists shown in the admin navigation. +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:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:delivery:list' + ) +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; diff --git a/iam/src/main/resources/db/notification-center.sql b/iam/src/main/resources/db/notification-center.sql new file mode 100644 index 000000000..bfd2f26ea --- /dev/null +++ b/iam/src/main/resources/db/notification-center.sql @@ -0,0 +1,94 @@ +USE baiyue_iam; + +CREATE TABLE IF NOT EXISTS iam_notification_template ( + id BIGINT NOT NULL, tenant_id BIGINT NOT NULL DEFAULT 0, template_code VARCHAR(64) NOT NULL, + template_name VARCHAR(128) NOT NULL, category VARCHAR(64) NOT NULL, channel VARCHAR(16) NOT NULL DEFAULT 'IN_APP', + title_template VARCHAR(256) NOT NULL, body_template VARCHAR(10000) NOT NULL, + variable_whitelist_json JSON NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'DRAFT', version INT NOT NULL DEFAULT 1, + created_by BIGINT NOT NULL, updated_by BIGINT NOT 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 (id), UNIQUE KEY uk_notification_template_code (tenant_id, template_code), + KEY idx_notification_template_list (tenant_id, status, updated_at) +) ENGINE=InnoDB COMMENT='消息模板;首期仅IN_APP'; + +CREATE TABLE IF NOT EXISTS iam_notification_task ( + id BIGINT NOT NULL, task_no VARCHAR(64) NOT NULL, tenant_id BIGINT NOT NULL, template_id BIGINT NOT NULL, + template_code VARCHAR(64) NOT NULL, category VARCHAR(64) NOT NULL, title VARCHAR(256) NOT NULL, + body VARCHAR(10000) NOT NULL, recipient_type VARCHAR(32) NOT NULL, variables_json JSON NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, source_event_id VARCHAR(128) NULL, source_event_type VARCHAR(128) NULL, + status VARCHAR(16) NOT NULL, recipient_count INT NOT NULL DEFAULT 0, attempt_count INT NOT NULL DEFAULT 0, + scheduled_at DATETIME(3) NULL, sent_at DATETIME(3) NULL, last_error VARCHAR(1000) NULL, created_by BIGINT NOT 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 (id), UNIQUE KEY uk_notification_task_no (task_no), UNIQUE KEY uk_notification_task_idempotency (idempotency_key), + UNIQUE KEY uk_notification_source_event (source_event_id, source_event_type), KEY idx_notification_task_dispatch (status, scheduled_at, created_at), + KEY idx_notification_task_tenant (tenant_id, created_at) +) ENGINE=InnoDB COMMENT='消息任务与幂等状态机'; + +CREATE TABLE IF NOT EXISTS iam_notification_recipient ( + id BIGINT NOT NULL, task_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, membership_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, display_name VARCHAR(128) NOT NULL, app_code VARCHAR(16) NOT NULL, channel VARCHAR(16) NOT NULL DEFAULT 'IN_APP', + status VARCHAR(24) NOT NULL DEFAULT 'RESOLVED', failure_reason VARCHAR(1000) NULL, delivered_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 (id), UNIQUE KEY uk_notification_recipient (task_id, membership_id, channel), + KEY idx_notification_recipient_member (tenant_id, membership_id, created_at) +) ENGINE=InnoDB COMMENT='消息接收人不可变快照'; + +CREATE TABLE IF NOT EXISTS iam_notification_inbox ( + id BIGINT NOT NULL, task_id BIGINT NOT NULL, recipient_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, + membership_id BIGINT NOT NULL, app_code VARCHAR(16) NOT NULL, category VARCHAR(64) NOT NULL, + title VARCHAR(256) NOT NULL, body VARCHAR(10000) NOT NULL, source_type VARCHAR(128) NULL, source_id VARCHAR(128) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'UNREAD', sent_at DATETIME(3) NOT NULL, read_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 (id), UNIQUE KEY uk_notification_inbox_recipient (recipient_id), + KEY idx_notification_inbox_list (tenant_id, membership_id, app_code, status, sent_at), + KEY idx_notification_inbox_category (tenant_id, membership_id, app_code, category, sent_at) +) ENGINE=InnoDB COMMENT='按客户成员隔离的站内信'; + +CREATE TABLE IF NOT EXISTS iam_notification_event_inbox ( + id BIGINT NOT NULL, source_event_id VARCHAR(128) NOT NULL, event_type VARCHAR(128) NOT NULL, + payload_hash CHAR(64) NOT NULL, task_id BIGINT NULL, status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING', + last_error VARCHAR(1000) NULL, processed_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 (id), UNIQUE KEY uk_notification_event (source_event_id, event_type), + KEY idx_notification_event_status (status, updated_at) +) ENGINE=InnoDB COMMENT='业务事件消费幂等记录'; + +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 + (5106000,0,'PLATFORM','MENU',0,'platform:notification','消息中心','/notifications',NULL,600,'ACTIVE',0), + (5106001,0,'PLATFORM','API',5106000,'platform:notification:view','查看消息','/api/platform/v1/notifications','GET',601,'ACTIVE',0), + (5106002,0,'PLATFORM','BUTTON',5106000,'platform:notification:create','发送消息','/api/platform/v1/notifications','POST',602,'ACTIVE',0), + (5106003,0,'PLATFORM','MENU',5106000,'platform:notification:template:list','模板管理','/notifications/templates','GET',603,'ACTIVE',0), + (5106004,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:create','新增消息模板','/api/platform/v1/notification-templates','POST',604,'ACTIVE',0), + (5106005,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:edit','编辑消息模板','/api/platform/v1/notification-templates/{id}','PUT',605,'ACTIVE',0), + (5106006,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:status','变更模板状态','/api/platform/v1/notification-templates/{id}/status','PUT',606,'ACTIVE',0), + (5106007,0,'PLATFORM','MENU',5106000,'platform:notification:task:list','消息管理','/notifications/messages','GET',607,'ACTIVE',0), + (5106008,0,'PLATFORM','BUTTON',5106000,'platform:notification:task:cancel','取消消息任务','/api/platform/v1/notification-tasks/{id}/cancel','PUT',608,'ACTIVE',0), + (5106009,0,'PLATFORM','BUTTON',5106000,'platform:notification:task:retry','重试消息任务','/api/platform/v1/notification-tasks/{id}/retry','PUT',609,'ACTIVE',0), + (5106011,0,'PLATFORM','BUTTON',5106001,'platform:notification:read','标记消息已读','/api/platform/v1/notifications/{id}/read','PUT',610,'ACTIVE',0), + (5106012,0,'PLATFORM','BUTTON',5106001,'platform:notification:read-all','全部标记已读','/api/platform/v1/notifications/read-all','PUT',611,'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=VALUES(http_method), status='ACTIVE', deleted=0; + +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) +VALUES + (5106010,0,'MERCHANT','API',0,'merchant:notification:view','查看我的消息','/api/merchant/v1/notifications','GET',900,'ACTIVE',0), + (5106013,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read','标记消息已读','/api/merchant/v1/notifications/{id}/read','PUT',901,'ACTIVE',0), + (5106014,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read-all','全部标记已读','/api/merchant/v1/notifications/read-all','PUT',902,'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=VALUES(http_method), status='ACTIVE', deleted=0; + +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_permission p JOIN iam_role r ON r.tenant_id=0 AND r.app_code='MERCHANT' AND r.status='ACTIVE' +WHERE p.tenant_id=0 AND p.permission_code IN ('merchant:notification:view','merchant:notification:read','merchant:notification:read-all') AND r.role_code='MERCHANT_OWNER'; + +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_permission p JOIN iam_role r ON r.tenant_id=0 AND r.app_code='PLATFORM' AND r.status='ACTIVE' +WHERE p.tenant_id=0 AND p.permission_code LIKE 'platform:notification%' AND r.role_code='PLATFORM_ADMIN'; + +-- All active platform employee roles can receive and read their own inbox messages. +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_permission p JOIN iam_role r ON r.tenant_id=0 AND r.app_code='PLATFORM' AND r.status='ACTIVE' +WHERE p.tenant_id=0 + AND p.permission_code IN ('platform:notification:view','platform:notification:read','platform:notification:read-all'); diff --git a/iam/src/main/resources/db/order-declaration-planning-permission.sql b/iam/src/main/resources/db/order-declaration-planning-permission.sql new file mode 100644 index 000000000..4c547eb9c --- /dev/null +++ b/iam/src/main/resources/db/order-declaration-planning-permission.sql @@ -0,0 +1,20 @@ +-- 订单申报与运营预估权限模块来源。 +-- 只操作 IAM 权限资源和角色授权,不创建账号、客户或业务数据。 +USE baiyue_iam; +SET NAMES utf8mb4; + +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 + (5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit', + '修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + permission_type = VALUES(permission_type), app_code = VALUES(app_code), + sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role_id, 5102027, 0 +FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index 53dad1952..6b000fdd7 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -26,13 +26,11 @@ 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, 'PHASE1_DEMO', '一期演示企业', 'MERCHANT', 'ACTIVE', '一期演示企业有限公司', - '91310000PHASE10001', '企业管理员', '13800000000', 'merchant@example.com', - NOW(3), 1000001, 1000001, 0, 0) + NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0) ON DUPLICATE KEY UPDATE - tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), + tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), status = 'ACTIVE', legal_name = VALUES(legal_name), + unified_social_credit_code = VALUES(unified_social_credit_code), contact_name = VALUES(contact_name), contact_phone = VALUES(contact_phone), contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; @@ -40,9 +38,7 @@ ON DUPLICATE KEY UPDATE 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', '一期演示企业管理员', '13800000000', - 'merchant@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; @@ -50,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; @@ -61,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; @@ -79,19 +72,27 @@ VALUES (5104000, 0, 'PLATFORM', 'MENU', 0, 'platform:audit', '审计管理', '/audit', NULL, 50, 'ACTIVE', 0), -- Platform: level-two menus. (5100010, 0, 'PLATFORM', 'MENU', 5100000, 'platform:customer:list', '企业客户', '/customers/list', NULL, 10, 'ACTIVE', 0), - (5100020, 0, 'PLATFORM', 'MENU', 5100000, 'platform:customer:review', '企业审核', '/customers/reviews', NULL, 20, 'ACTIVE', 0), (5101010, 0, 'PLATFORM', 'MENU', 5101000, 'platform:user:list', '内部用户', '/system/users', NULL, 10, 'ACTIVE', 0), (5101020, 0, 'PLATFORM', 'MENU', 5101000, 'platform:role:list', '角色管理', '/system/roles', NULL, 20, 'ACTIVE', 0), (5101030, 0, 'PLATFORM', 'MENU', 5101000, 'platform:permission:list', '菜单按钮', '/system/permissions', NULL, 30, 'ACTIVE', 0), (5102010, 0, 'PLATFORM', 'MENU', 5102000, 'platform:order:list', '订单列表', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5102020, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:task:list', '履约任务', '/first-leg-tasks/list', NULL, 20, 'ACTIVE', 0), (5103010, 0, 'PLATFORM', 'MENU', 5103000, 'platform:warehouse:list', '国内仓/FBA仓', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), (5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 0), (5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 0), + (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', '/downloads', NULL, 40, 'ACTIVE', 0), (5104010, 0, 'PLATFORM', 'MENU', 5104000, 'platform:audit:list', '操作日志', '/audit/logs', NULL, 10, 'ACTIVE', 0), -- Platform: buttons. + (5100020, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:review', '客户审核', NULL, NULL, 50, 'ACTIVE', 0), (5100021, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:approve', '审核通过', NULL, NULL, 10, 'ACTIVE', 0), (5100022, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:reject', '审核驳回', NULL, NULL, 20, 'ACTIVE', 0), + (5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', NULL, NULL, 40, 'ACTIVE', 0), + (5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', NULL, NULL, 10, 'ACTIVE', 0), (5101011, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:create', '新增用户', NULL, NULL, 10, 'ACTIVE', 0), + (5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑用户', NULL, NULL, 20, 'ACTIVE', 0), + (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', NULL, NULL, 30, 'ACTIVE', 0), + (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', NULL, NULL, 40, 'ACTIVE', 0), + (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配用户角色', NULL, NULL, 50, 'ACTIVE', 0), (5101021, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), (5101022, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), (5101031, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:create', '新增菜单按钮', NULL, NULL, 10, 'ACTIVE', 0), @@ -103,47 +104,49 @@ VALUES (5102016, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:cancel', '取消订单', NULL, NULL, 60, 'ACTIVE', 0), (5102017, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:note', '内部备注', NULL, NULL, 70, 'ACTIVE', 0), (5102018, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:fulfillment', '登记履约快递', NULL, NULL, 80, 'ACTIVE', 0), + (5102021, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:task:view', '查看履约任务', NULL, NULL, 10, 'ACTIVE', 0), + (5102022, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:assignment:history', '查看分配历史', NULL, NULL, 20, 'ACTIVE', 0), + (5102023, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:assignment:assign', '分配履约任务', NULL, NULL, 30, 'ACTIVE', 0), (5103011, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:create', '新增仓库', NULL, NULL, 10, 'ACTIVE', 0), (5103012, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:edit', '编辑仓库', NULL, NULL, 20, 'ACTIVE', 0), - (5103013, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:status', '启用停用', NULL, NULL, 30, 'ACTIVE', 0) + (5103013, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:status', '启用停用', NULL, NULL, 30, 'ACTIVE', 0), + (5103021, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:create', '新增快递公司', NULL, NULL, 10, 'ACTIVE', 0), + (5103022, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:edit', '编辑快递公司', NULL, NULL, 20, 'ACTIVE', 0), + (5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启用停用快递公司', NULL, NULL, 30, 'ACTIVE', 0), + (5103024, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:import', '批量导入快递公司', NULL, NULL, 40, 'ACTIVE', 0), + (5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', NULL, NULL, 10, 'ACTIVE', 0), + (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', NULL, NULL, 20, 'ACTIVE', 0), + (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启用停用往来单位', NULL, NULL, 30, 'ACTIVE', 0), + (5103034, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:import', '批量导入往来单位', 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; --- 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) + (5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0), + (5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0), + (5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0), + (5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0), + (5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit', '修改订单申报与运营预估', NULL, NULL, 130, '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; + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 FROM iam_permission +WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000003, id, 0 FROM iam_permission +WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); 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. @@ -152,34 +155,25 @@ 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 WHERE permission_code IN ( 'platform:customer', 'platform:customer:list', 'platform:customer:review', - 'platform:customer:approve', 'platform:customer:reject' + 'platform:customer:approve', 'platform:customer:reject', 'platform:customer:export', + 'platform:master-data', 'platform:download:list' ) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000003, id, 0 FROM iam_permission WHERE permission_code LIKE 'platform:order%' -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') + OR permission_code LIKE 'platform:firstleg%' + OR permission_code = 'platform:download: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-customer-profile.sql b/iam/src/main/resources/db/phase1-customer-profile.sql new file mode 100644 index 000000000..019de2112 --- /dev/null +++ b/iam/src/main/resources/db/phase1-customer-profile.sql @@ -0,0 +1,48 @@ +-- 一期平台客户档案字段增量迁移。可在同一数据库重复执行。 +USE baiyue_iam; +SET NAMES utf8mb4; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'contact_wechat'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN contact_wechat VARCHAR(64) NULL COMMENT ''联系微信'' AFTER contact_email'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'sales_owner_name'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN sales_owner_name VARCHAR(64) NULL COMMENT ''销售负责人姓名'' AFTER contact_wechat'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'payment_term_days'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN payment_term_days SMALLINT UNSIGNED NULL COMMENT ''账期天数'' AFTER sales_owner_name'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'enterprise_type'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN enterprise_type VARCHAR(64) NULL COMMENT ''企业类型'' AFTER payment_term_days'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'established_date'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN established_date DATE NULL COMMENT ''成立日期'' AFTER enterprise_type'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'registered_capital'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_capital DECIMAL(18,2) NULL COMMENT ''注册资本'' AFTER established_date'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'registered_capital_currency'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_capital_currency CHAR(3) NULL COMMENT ''注册资本币种 ISO 4217'' AFTER registered_capital'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'registered_address'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_address VARCHAR(500) NULL COMMENT ''注册地址文本'' AFTER registered_capital_currency'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'internal_remark'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN internal_remark VARCHAR(1000) NULL COMMENT ''平台内部备注'' AFTER registered_address'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; 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 5d2c493eb..27a7da36e 100644 --- a/iam/src/main/resources/db/phase1-onboarding.sql +++ b/iam/src/main/resources/db/phase1-onboarding.sql @@ -3,7 +3,7 @@ USE baiyue_iam; SET NAMES utf8mb4; ALTER TABLE iam_tenant MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' - COMMENT 'PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED or DISABLED'; + COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用'; DROP PROCEDURE IF EXISTS phase1_iam_add_column; DELIMITER $$ CREATE PROCEDURE phase1_iam_add_column( @@ -22,12 +22,13 @@ BEGIN END IF; END$$ DELIMITER ; -CALL phase1_iam_add_column('iam_tenant', 'approved_at', "DATETIME(3) NULL COMMENT 'Approval time'"); -CALL phase1_iam_add_column('iam_tenant', 'approved_by', "BIGINT NULL COMMENT 'Reviewer ID'"); -CALL phase1_iam_add_column('iam_tenant', 'reject_reason', "VARCHAR(500) NULL COMMENT 'Rejection reason'"); -CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment', "VARCHAR(500) NULL COMMENT 'Review comment'"); -CALL phase1_iam_add_column('iam_membership', 'created_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Creator ID'"); -CALL phase1_iam_add_column('iam_membership', 'updated_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Updater ID'"); +CALL phase1_iam_add_column('iam_tenant', 'approved_at', "DATETIME(3) NULL COMMENT '客户资料审核通过时间'"); +CALL phase1_iam_add_column('iam_tenant', 'approved_by', "BIGINT NULL COMMENT '客户资料审核人ID'"); +CALL phase1_iam_add_column('iam_tenant', 'reject_reason', "VARCHAR(500) NULL COMMENT '客户资料审核驳回原因'"); +CALL phase1_iam_add_column('iam_tenant', 'legal_representative', "VARCHAR(64) NULL COMMENT '法定代表人' AFTER legal_name"); +CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment', "VARCHAR(500) NULL COMMENT '客户资质审核意见'"); +CALL phase1_iam_add_column('iam_membership', 'created_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户成员关系创建人ID;系统创建时为0'"); +CALL phase1_iam_add_column('iam_membership', 'updated_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户成员关系最后修改人ID;系统修改时为0'"); DROP PROCEDURE phase1_iam_add_column; -- A global merchant owner template is assigned to newly registered enterprises. @@ -40,32 +41,49 @@ 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; -INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000100, id, 0 -FROM iam_permission -WHERE app_code='MERCHANT' AND tenant_id=0 - AND permission_type IN ('MENU','BUTTON') AND status='ACTIVE' AND 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 +JOIN iam_permission permission + ON permission.app_code='MERCHANT' AND permission.tenant_id=0 + AND permission.permission_type IN ('MENU','BUTTON') + AND permission.status='ACTIVE' AND permission.deleted=0 +WHERE owner_role.tenant_id=0 AND owner_role.app_code='MERCHANT' + AND owner_role.role_code='MERCHANT_OWNER' + AND owner_role.status='ACTIVE' AND owner_role.deleted=0 +ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); --- Keep the phase-one demo merchant eligible for existing order acceptance tests. -INSERT INTO iam_tenant_qualification ( - id, tenant_id, qualification_type, certificate_no, document_url, status, deleted -) VALUES ( - 7000101, 10001, 'BUSINESS_LICENSE', '91310000PHASE10001', - '/phase1-seed/demo-business-license.jpg', 'APPROVED', 0 -) -ON DUPLICATE KEY UPDATE status='APPROVED', deleted=0; +-- 不再写入伪造的资质 URL。资质必须先由 File 服务保存真实图片,再由 IAM 记录文件地址和审核结果。 +-- 已部署环境中的 /phase1-seed/ 历史记录按 docs/企业资质文件迁移说明.md 处理,本脚本不得将其改成不存在的 UUID。 diff --git a/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql new file mode 100644 index 000000000..b98cb4fe1 --- /dev/null +++ b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql @@ -0,0 +1,99 @@ +-- 权限资源维护与平台主数据权限迁移。 +-- 在 phase1-admin-rbac.sql、rbac-role-management-v2.sql 之后执行,可重复执行。 +USE baiyue_iam; + +-- 兼容不支持 ADD COLUMN IF NOT EXISTS 的 MySQL 版本,通过元数据判断后执行。 +SET @iam_permission_built_in_sql = IF( + EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'iam_permission' AND column_name = 'built_in'), + 'SELECT 1', + "ALTER TABLE iam_permission ADD COLUMN built_in TINYINT NOT NULL DEFAULT 0 COMMENT '是否为系统预置权限;预置资源不可通过管理接口修改' AFTER status" +); +PREPARE iam_permission_built_in_stmt FROM @iam_permission_built_in_sql; +EXECUTE iam_permission_built_in_stmt; +DEALLOCATE PREPARE iam_permission_built_in_stmt; + +-- 一期固定编号范围内的资源均为种子资源,运行期生成的雪花编号不作预置标记。 +UPDATE iam_permission +SET built_in = 1 +WHERE id BETWEEN 5000000 AND 5399999 AND built_in = 0; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, + route_path, sort_no, status, built_in, deleted) +VALUES + (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', + '/downloads', 40, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), route_path = VALUES(route_path), sort_no = VALUES(sort_no), + status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, + sort_no, status, built_in, deleted) +VALUES + (5100014, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:view', '查看企业客户档案', 15, 'ACTIVE', 1, 0), + (5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, 'ACTIVE', 1, 0), + (5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', 30, 'ACTIVE', 1, 0), + (5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', 40, 'ACTIVE', 1, 0), + (5100016, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:reset-password', '重置客户管理员密码', 50, 'ACTIVE', 1, 0), + (5100017, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:delete', '归档企业客户', 60, 'ACTIVE', 1, 0), + (5100018, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:impersonate', '跳转客户登录', 70, 'ACTIVE', 1, 0), + (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 1, 0), + (5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, 'ACTIVE', 1, 0), + (5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', 30, 'ACTIVE', 1, 0), + (5101034, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:delete', '删除权限资源', 40, 'ACTIVE', 1, 0), + (5103021, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:create', '新增快递公司', 10, 'ACTIVE', 1, 0), + (5103022, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:edit', '编辑快递公司', 20, 'ACTIVE', 1, 0), + (5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启停快递公司', 30, 'ACTIVE', 1, 0), + (5103024, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:import', '批量导入快递公司', 40, 'ACTIVE', 1, 0), + (5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', 10, 'ACTIVE', 1, 0), + (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', 20, 'ACTIVE', 1, 0), + (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', 30, 'ACTIVE', 1, 0), + (5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', 10, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), sort_no = VALUES(sort_no), status = 'ACTIVE', + built_in = 1, deleted = 0; + +-- 新增平台操作权限默认只授予系统管理员。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 +FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code IN ( + 'platform:customer:edit', 'platform:customer:status', 'platform:customer:reset-password', + 'platform:customer:delete', 'platform:customer:impersonate', 'platform:user:grant-role', + 'platform:permission:edit', 'platform:permission:status', 'platform:permission:delete', + 'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status', 'platform:express:import', + 'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status', + 'platform:customer:export', 'platform:download:list', 'platform:download:all' + ) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- 已有客户运营角色继续具备客户导出与本人下载任务查询能力,但不授予查看全部任务。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT DISTINCT existing_grant.role_id, added_permission.id, existing_grant.tenant_id +FROM iam_role_permission existing_grant +JOIN iam_permission existing_permission ON existing_permission.id = existing_grant.permission_id +JOIN iam_permission added_permission + ON added_permission.tenant_id = 0 AND added_permission.app_code = 'PLATFORM' + AND added_permission.permission_code IN ('platform:customer:export', 'platform:download:list') +WHERE existing_permission.tenant_id = 0 + AND existing_permission.app_code = 'PLATFORM' + AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- 详情查看从列表和审核权限中拆分;升级时保留既有运营角色的查询能力。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT DISTINCT existing_grant.role_id, view_permission.id, existing_grant.tenant_id +FROM iam_role_permission existing_grant +JOIN iam_permission existing_permission + ON existing_permission.id = existing_grant.permission_id +JOIN iam_permission view_permission + ON view_permission.tenant_id = 0 + AND view_permission.app_code = 'PLATFORM' + AND view_permission.permission_code = 'platform:customer:view' +WHERE existing_permission.tenant_id = 0 + AND existing_permission.app_code = 'PLATFORM' + AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/phase2-fba-pricing-permissions.sql b/iam/src/main/resources/db/phase2-fba-pricing-permissions.sql new file mode 100644 index 000000000..21fe1fa4a --- /dev/null +++ b/iam/src/main/resources/db/phase2-fba-pricing-permissions.sql @@ -0,0 +1,39 @@ +-- 二期 FBA 计费权限;在入库查询、测量和审核权限脚本后执行,可重复部署。 +USE baiyue_iam; +SET NAMES utf8mb4; + +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 + (5102061, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:rule:view', '查看客户计费规则', NULL, NULL, 10, 'ACTIVE', 1, 0), + (5102062, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:rule:edit', '维护客户计费规则', NULL, NULL, 20, 'ACTIVE', 1, 0), + (5102063, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:rate:view', '查看承运商价格', NULL, NULL, 30, 'ACTIVE', 1, 0), + (5102064, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:rate:edit', '维护承运商价格', NULL, NULL, 40, 'ACTIVE', 1, 0), + (5102065, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:calculate', '计算入库费用', NULL, NULL, 50, 'ACTIVE', 1, 0), + (5102066, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:confirm', '内部确认入库费用', NULL, NULL, 60, 'ACTIVE', 1, 0), + (5102067, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:view', '查看入库费用', NULL, NULL, 70, 'ACTIVE', 1, 0), + (5102068, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:adjust', '调整已发布费用', NULL, NULL, 80, 'ACTIVE', 1, 0), + (5102069, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:confirm-send-fee', '确认并推送客户费用', NULL, NULL, 90, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 FROM iam_permission +WHERE id BETWEEN 5102061 AND 5102069 +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000003, id, 0 FROM iam_permission +WHERE id IN (5102061, 5102063, 5102065, 5102066, 5102067, 5102068, 5102069) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, 0 +FROM iam_role role +JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.permission_code = 'firstleg:inbound:confirm-send-fee' +WHERE role.tenant_id = 0 AND UPPER(role.role_code) = 'PLATFORM_CUSTOM_ADMIN' +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/phase2-fee-item-permissions.sql b/iam/src/main/resources/db/phase2-fee-item-permissions.sql new file mode 100644 index 000000000..d7e01c988 --- /dev/null +++ b/iam/src/main/resources/db/phase2-fee-item-permissions.sql @@ -0,0 +1,33 @@ +USE baiyue_iam; +SET NAMES utf8mb4; + +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 + (5102070, 0, 'PLATFORM', 'MENU', 5103000, 'firstleg:fee-item:view', + '费用项目', '/master-data/fee-items', NULL, 40, 'ACTIVE', 1, 0), + (5102071, 0, 'PLATFORM', 'BUTTON', 5102070, 'firstleg:fee-item:create', + '新增费用项目', NULL, NULL, 10, 'ACTIVE', 1, 0), + (5102072, 0, 'PLATFORM', 'BUTTON', 5102070, 'firstleg:fee-item:edit', + '编辑或停用费用项目', NULL, NULL, 20, 'ACTIVE', 1, 0), + (5102073, 0, 'PLATFORM', 'BUTTON', 5102070, 'firstleg:fee-item:delete', + '删除费用项目', NULL, NULL, 30, 'ACTIVE', 1, 0), + (5102081, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:fee-detail:view', + '查看入库费用明细', NULL, NULL, 100, 'ACTIVE', 1, 0), + (5102082, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:fee-detail:edit', + '新增编辑入库费用明细', NULL, NULL, 110, 'ACTIVE', 1, 0), + (5102083, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:fee-detail:delete', + '删除入库费用明细', NULL, NULL, 120, 'ACTIVE', 1, 0), + (5102084, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:fee-detail:review', + '审核入库费用明细', NULL, NULL, 130, 'ACTIVE', 1, 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', built_in = 1, deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 +FROM iam_permission +WHERE id IN (5102070, 5102071, 5102072, 5102073, 5102081, 5102082, 5102083, 5102084) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/phase2-inbound-measurement-permissions.sql b/iam/src/main/resources/db/phase2-inbound-measurement-permissions.sql new file mode 100644 index 000000000..89d66d2cc --- /dev/null +++ b/iam/src/main/resources/db/phase2-inbound-measurement-permissions.sql @@ -0,0 +1,54 @@ +-- 二期入库测量平台菜单和按钮权限,可重复执行。 +SET NAMES utf8mb4; +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) +VALUES + (5102041, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:measure:view', '查看入库测量', NULL, NULL, 10, 'ACTIVE', 0), + (5102042, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:measure:edit', '保存测量草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5102043, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:measure:submit', '确认并提交测量', NULL, NULL, 20, 'ACTIVE', 0), + (5102044, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:weight:edit', '维护OCR重量', NULL, NULL, 30, 'ACTIVE', 0), + (5102045, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:weight:confirm', '确认OCR重量', NULL, NULL, 40, 'ACTIVE', 0), + (5102194, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:measure:manual-entry', '手动录入箱规', NULL, NULL, 25, 'ACTIVE', 0), + (5102195, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:warehouse-suggestion:edit', '维护仓库建议测量数据', NULL, NULL, 27, '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_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 FROM iam_permission +WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194, 5102195) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000003, id, 0 FROM iam_permission +WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194, 5102195) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, 0 +FROM iam_role role +JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.permission_code = 'platform:firstleg:inbound:warehouse-suggestion:edit' +WHERE role.tenant_id = 0 AND UPPER(role.role_code) = 'PLATFORM_CUSTOM_ADMIN' +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, 0 +FROM iam_role role +JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.permission_code = 'platform:firstleg:inbound:measure:manual-entry' +WHERE role.tenant_id = 0 AND role.role_code = 'WAREHOUSE_TALLY' +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/phase2-inbound-permissions.sql b/iam/src/main/resources/db/phase2-inbound-permissions.sql new file mode 100644 index 000000000..7eb5d33e3 --- /dev/null +++ b/iam/src/main/resources/db/phase2-inbound-permissions.sql @@ -0,0 +1,33 @@ +-- 二期入库审核与异常放行按钮权限,可重复部署。 +USE baiyue_iam; +SET NAMES utf8mb4; + +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 + (5102051, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:measurement-review', + '测量审核', NULL, NULL, 10, 'ACTIVE', 0), + (5102052, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:measurement-review-history', + '查看测量审核历史', NULL, NULL, 20, 'ACTIVE', 0), + (5102053, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:exception-release', + '测量异常放行', NULL, NULL, 30, 'ACTIVE', 0), + (5102054, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:send-fee', + '发送费用并入库', NULL, NULL, 40, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + status = 'ACTIVE', deleted = 0; + +-- 仅给平台超级管理员补齐默认权限;业务角色由 IAM 页面按职责授权。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, p.id, 0 +FROM iam_permission p +WHERE p.id BETWEEN 5102051 AND 5102054 +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- 客服(订单运营)负责测量审核;费用发送由审核通过后的服务端编排自动执行。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000003, p.id, 0 +FROM iam_permission p +WHERE p.id IN (5102051, 5102052) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/phase2-inbound-query-permissions.sql b/iam/src/main/resources/db/phase2-inbound-query-permissions.sql new file mode 100644 index 000000000..c56ed9c72 --- /dev/null +++ b/iam/src/main/resources/db/phase2-inbound-query-permissions.sql @@ -0,0 +1,25 @@ +-- 二期入库单列表和详情只读权限,可在一期 RBAC 脚本后重复执行。 +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, built_in, deleted) +VALUES + (5102030, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:inbound:list', + '入库单', '/inbounds', 'GET', 30, 'ACTIVE', 1, 0), + (5102031, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:view', + '查看入库单详情', NULL, 'GET', 10, 'ACTIVE', 1, 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', built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, role.tenant_id +FROM iam_role role +JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.permission_code IN ( + 'platform:firstleg:inbound:list', 'platform:firstleg:inbound:view') +WHERE role.tenant_id = 0 + AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR'); diff --git a/iam/src/main/resources/db/phase2-location-permissions.sql b/iam/src/main/resources/db/phase2-location-permissions.sql new file mode 100644 index 000000000..67057e6eb --- /dev/null +++ b/iam/src/main/resources/db/phase2-location-permissions.sql @@ -0,0 +1,122 @@ +-- 二期库位管理菜单和按钮权限。 +-- 在 phase1-admin-rbac.sql、phase1-platform-master-data-and-permission-management.sql 之后执行,可重复执行。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS repair_location_permission_ids; +DELIMITER $$ +CREATE PROCEDURE repair_location_permission_ids() +BEGIN + IF EXISTS ( + SELECT 1 FROM iam_permission + WHERE id IN (5103060, 5103061, 5103062, 5103063, 5103064) + AND permission_code <> CASE id + WHEN 5103060 THEN 'platform:location:list' + WHEN 5103061 THEN 'platform:location:create' + WHEN 5103062 THEN 'platform:location:edit' + WHEN 5103063 THEN 'platform:location:status' + WHEN 5103064 THEN 'platform:location:delete' + END + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '库位权限迁移失败:5103060-5103064 已被其他权限占用'; + END IF; + IF EXISTS ( + SELECT 1 FROM iam_permission + WHERE (id = 5103040 AND NOT ( + (permission_code = 'platform:download:list' AND parent_id = 5103000) + OR (permission_code = 'platform:location:list' AND parent_id = 5103000))) + OR (id = 5103041 AND NOT ( + (permission_code = 'platform:download:all' AND parent_id = 5103040) + OR (permission_code = 'platform:location:create' AND parent_id = 5103040))) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '库位权限迁移失败:下载中心稳定 ID 被未知权限占用'; + END IF; + + DROP TEMPORARY TABLE IF EXISTS tmp_location_role_grant; + CREATE TEMPORARY TABLE tmp_location_role_grant ( + role_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + permission_code VARCHAR(128) NOT NULL, + PRIMARY KEY (role_id, tenant_id, permission_code) + ) ENGINE=MEMORY; + INSERT IGNORE INTO tmp_location_role_grant (role_id, tenant_id, permission_code) + SELECT role_permission.role_id, role_permission.tenant_id, permission.permission_code + FROM iam_role_permission role_permission + JOIN iam_permission permission ON permission.id = role_permission.permission_id + WHERE permission.permission_code IN ( + 'platform:location:list', 'platform:location:create', 'platform:location:edit', + 'platform:location:status', 'platform:location:delete' + ); + + DELETE role_permission + FROM iam_role_permission role_permission + JOIN iam_permission permission ON permission.id = role_permission.permission_id + WHERE permission.permission_code IN ( + 'platform:location:list', 'platform:location:create', 'platform:location:edit', + 'platform:location:status', 'platform:location:delete' + ); + DELETE FROM iam_permission + WHERE permission_code IN ( + 'platform:location:create', 'platform:location:edit', + 'platform:location:status', 'platform:location:delete' + ); + DELETE FROM iam_permission WHERE permission_code = 'platform:location:list'; + + 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 + (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', + '/downloads', NULL, 40, 'ACTIVE', 1, 0), + (5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', + NULL, NULL, 10, 'ACTIVE', 1, 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 = VALUES(http_method), sort_no = VALUES(sort_no), status = 'ACTIVE', + built_in = 1, deleted = 0; +END$$ +DELIMITER ; +CALL repair_location_permission_ids(); +DROP PROCEDURE repair_location_permission_ids; + +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 + (5103060, 0, 'PLATFORM', 'MENU', 5103000, 'platform:location:list', '库位管理', + '/master-data/locations', NULL, 40, 'ACTIVE', 1, 0), + (5103061, 0, 'PLATFORM', 'BUTTON', 5103060, 'platform:location:create', '新增库位', + NULL, NULL, 10, 'ACTIVE', 1, 0), + (5103062, 0, 'PLATFORM', 'BUTTON', 5103060, 'platform:location:edit', '编辑库位', + NULL, NULL, 20, 'ACTIVE', 1, 0), + (5103063, 0, 'PLATFORM', 'BUTTON', 5103060, 'platform:location:status', '启停库位', + NULL, NULL, 30, 'ACTIVE', 1, 0), + (5103064, 0, 'PLATFORM', 'BUTTON', 5103060, 'platform:location:delete', '删除库位', + NULL, NULL, 40, 'ACTIVE', 1, 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', built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT grant_row.role_id, permission.id, grant_row.tenant_id + FROM tmp_location_role_grant grant_row + JOIN iam_permission permission + ON permission.tenant_id = 0 AND permission.app_code = 'PLATFORM' + AND permission.permission_code = grant_row.permission_code; + +-- 二期新增库位权限默认只授予平台系统管理员。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 +FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code IN ( + 'platform:location:list', 'platform:location:create', 'platform:location:edit', + 'platform:location:status', 'platform:location:delete' + ) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +DROP TEMPORARY TABLE IF EXISTS tmp_location_role_grant; diff --git a/iam/src/main/resources/db/phase2-pallet-permissions.sql b/iam/src/main/resources/db/phase2-pallet-permissions.sql new file mode 100644 index 000000000..8794bb607 --- /dev/null +++ b/iam/src/main/resources/db/phase2-pallet-permissions.sql @@ -0,0 +1,39 @@ +-- 二期托盘管理菜单和按钮权限。 +-- 在 phase1-admin-rbac.sql、phase1-platform-master-data-and-permission-management.sql 之后执行,可重复执行。 +USE baiyue_iam; +SET NAMES utf8mb4; + +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 + (5103050, 0, 'PLATFORM', 'MENU', 5103000, 'platform:pallet:list', '托盘管理', + '/master-data/pallets', NULL, 50, 'ACTIVE', 1, 0), + (5103051, 0, 'PLATFORM', 'BUTTON', 5103050, 'platform:pallet:create', '新增托盘', + NULL, NULL, 10, 'ACTIVE', 1, 0), + (5103052, 0, 'PLATFORM', 'BUTTON', 5103050, 'platform:pallet:edit', '编辑托盘', + NULL, NULL, 20, 'ACTIVE', 1, 0), + (5103054, 0, 'PLATFORM', 'BUTTON', 5103050, 'platform:pallet:delete', '删除托盘', + NULL, NULL, 30, 'ACTIVE', 1, 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', built_in = 1, deleted = 0; + +-- 二期新增托盘权限默认只授予平台系统管理员。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 +FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code IN ( + 'platform:pallet:list', 'platform:pallet:create', 'platform:pallet:edit', 'platform:pallet:delete' + ) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- 兼容已经试运行过旧脚本的环境:移除人工启停权限。 +DELETE rp FROM iam_role_permission rp +JOIN iam_permission p ON p.id = rp.permission_id +WHERE p.tenant_id = 0 AND p.app_code = 'PLATFORM' AND p.permission_code = 'platform:pallet:status'; +UPDATE iam_permission +SET status = 'INACTIVE', deleted = 1 +WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_code = 'platform:pallet:status'; diff --git a/iam/src/main/resources/db/phase3-container-customs-permissions.sql b/iam/src/main/resources/db/phase3-container-customs-permissions.sql new file mode 100644 index 000000000..c6017b4ac --- /dev/null +++ b/iam/src/main/resources/db/phase3-container-customs-permissions.sql @@ -0,0 +1,152 @@ +-- 三期整柜报关权限资源;在一期平台 RBAC 初始化后执行,可重复部署。 +-- Client 退税资料复用 merchant:order:view/edit;买单无客户入口,不新增客户报关权限。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS guard_phase3_container_customs_permission_ids; +DELIMITER $$ +CREATE PROCEDURE guard_phase3_container_customs_permission_ids() +BEGIN + IF EXISTS ( + SELECT 1 + FROM iam_permission + WHERE (id = 5102170 AND permission_code <> 'platform:firstleg:customs:list') + OR (id = 5102172 AND permission_code <> 'platform:firstleg:customs:manage') + OR (permission_code = 'platform:firstleg:customs:list' AND id <> 5102170) + OR (permission_code = 'platform:firstleg:customs:manage' AND id <> 5102172) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '整柜报关权限固定 ID 或权限码已被其他语义占用'; + END IF; +END$$ +DELIMITER ; +CALL guard_phase3_container_customs_permission_ids(); +DROP PROCEDURE IF EXISTS guard_phase3_container_customs_permission_ids; + +-- 当前未上线,旧 cases 动作和装柜清单导出权限直接删除,不保留别名或双权限。 +DELETE role_permission + FROM iam_role_permission role_permission + JOIN iam_permission permission + ON permission.id = role_permission.permission_id + AND permission.tenant_id = role_permission.tenant_id + WHERE permission.tenant_id = 0 + AND permission.app_code = 'PLATFORM' + AND permission.permission_code IN ( + 'platform:firstleg:customs:view', + 'platform:firstleg:customs:review', + 'platform:firstleg:customs:submit', + 'platform:firstleg:customs:receipt', + 'platform:firstleg:loading-list:export' + ); + +DELETE FROM iam_permission + WHERE tenant_id = 0 + AND app_code = 'PLATFORM' + AND permission_code IN ( + 'platform:firstleg:customs:view', + 'platform:firstleg:customs:review', + 'platform:firstleg:customs:submit', + 'platform:firstleg:customs:receipt', + 'platform:firstleg:loading-list:export' + ); + +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 + (5102170, 0, 'PLATFORM', 'MENU', 5102000, + 'platform:firstleg:customs:list', '整柜报关', '/first-leg/customs', 'GET', + 100, 'ACTIVE', 1, 0), + (5102172, 0, 'PLATFORM', 'BUTTON', 5102170, + 'platform:firstleg:customs:manage', '维护整柜报关', NULL, 'POST', + 20, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + tenant_id = 0, + app_code = 'PLATFORM', + permission_type = VALUES(permission_type), + parent_id = VALUES(parent_id), + 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 (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, 0 + FROM iam_role role + JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.app_code = 'PLATFORM' + AND permission.permission_code IN ( + 'platform:firstleg:customs:list', + 'platform:firstleg:customs:manage' + ) + WHERE role.tenant_id = 0 + AND role.app_code = 'PLATFORM' + AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') + AND role.status = 'ACTIVE' + AND role.deleted = 0; + +DROP PROCEDURE IF EXISTS assert_phase3_container_customs_permissions; +DELIMITER $$ +CREATE PROCEDURE assert_phase3_container_customs_permissions() +BEGIN + IF ( + SELECT COUNT(*) + FROM iam_permission + WHERE tenant_id = 0 + AND app_code = 'PLATFORM' + AND status = 'ACTIVE' + AND deleted = 0 + AND permission_code IN ( + 'platform:firstleg:customs:list', + 'platform:firstleg:customs:manage' + ) + ) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '整柜报关正式权限资源初始化不完整'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM iam_permission + WHERE tenant_id = 0 + AND app_code = 'PLATFORM' + AND permission_code IN ( + 'platform:firstleg:customs:view', + 'platform:firstleg:customs:review', + 'platform:firstleg:customs:submit', + 'platform:firstleg:customs:receipt', + 'platform:firstleg:loading-list:export' + ) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '整柜报关旧权限或装柜清单导出权限未清除'; + END IF; + + IF ( + SELECT COUNT(*) + 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 role.tenant_id = 0 + AND role.app_code = 'PLATFORM' + AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') + AND permission.permission_code IN ( + 'platform:firstleg:customs:list', + 'platform:firstleg:customs:manage' + ) + ) <> 4 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '平台管理员或订单运营整柜报关权限授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_container_customs_permissions(); +DROP PROCEDURE IF EXISTS assert_phase3_container_customs_permissions; diff --git a/iam/src/main/resources/db/phase3-warehouse-roles.sql b/iam/src/main/resources/db/phase3-warehouse-roles.sql new file mode 100644 index 000000000..63424653a --- /dev/null +++ b/iam/src/main/resources/db/phase3-warehouse-roles.sql @@ -0,0 +1,231 @@ +-- 三期仓库系统角色:国内仓后台、国内头程 PDA、海外仓 PDA、国内仓理货复核。 +-- 本脚本只组合已经存在的权限资源,需在三期 Firstleg/IAM 权限脚本之后执行,可重复部署。 +USE baiyue_iam; +SET NAMES utf8mb4; + +UPDATE iam_role + SET role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', + role_name = '海外仓 PDA 作业员', + description = '负责使用海外仓 PDA 执行接柜、入仓和库内作业,必须绑定一个海外仓' + WHERE id = 4000005 AND tenant_id = 0 AND app_code = 'PLATFORM' + AND role_code = 'WAREHOUSE_PICKER'; + +DROP PROCEDURE IF EXISTS assert_phase3_warehouse_role_ids_available; +DELIMITER $$ +CREATE PROCEDURE assert_phase3_warehouse_role_ids_available() +BEGIN + IF EXISTS ( + SELECT 1 + FROM iam_role + WHERE (id = 4000004 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'WAREHOUSE_OPERATOR')) + OR (id = 4000005 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'OVERSEAS_WAREHOUSE_PDA_OPERATOR')) + OR (id = 4000006 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'WAREHOUSE_TALLY')) + OR (id = 4000007 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'CN_FIRSTLEG_PDA_OPERATOR')) + OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'WAREHOUSE_OPERATOR' AND id <> 4000004) + OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' AND id <> 4000005) + OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'WAREHOUSE_TALLY' AND id <> 4000006) + OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'CN_FIRSTLEG_PDA_OPERATOR' AND id <> 4000007) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '仓库角色初始化失败:4000004-4000007 或角色编码已被占用'; + END IF; + IF EXISTS ( + SELECT 1 + FROM iam_data_scope + WHERE (id = 6000004 AND role_id <> 4000004) + OR (id = 6000005 AND role_id <> 4000005) + OR (id = 6000006 AND role_id <> 4000006) + OR (id = 6000007 AND role_id <> 4000007) + OR (role_id = 4000004 AND id <> 6000004) + OR (role_id = 4000005 AND id <> 6000005) + OR (role_id = 4000006 AND id <> 6000006) + OR (role_id = 4000007 AND id <> 6000007) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '仓库角色初始化失败:6000004-6000007 数据范围ID已被占用'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_warehouse_role_ids_available(); +DROP PROCEDURE assert_phase3_warehouse_role_ids_available; + +INSERT INTO iam_role ( + id, tenant_id, app_code, role_code, role_name, role_type, status, + description, deleted, version +) VALUES + (4000004, 0, 'PLATFORM', 'WAREHOUSE_OPERATOR', '国内仓后台操作员', 'SYSTEM', 'ACTIVE', + '负责查看入仓业务并在后台维护装柜证据', 0, 0), + (4000005, 0, 'PLATFORM', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', '海外仓 PDA 作业员', 'SYSTEM', 'ACTIVE', + '负责使用海外仓 PDA 执行接柜、入仓和库内作业,必须绑定一个海外仓', 0, 0), + (4000006, 0, 'PLATFORM', 'WAREHOUSE_TALLY', '国内仓理货复核员', 'SYSTEM', 'ACTIVE', + '负责国内入库测量、PDA复核和装柜证据维护', 0, 0), + (4000007, 0, 'PLATFORM', 'CN_FIRSTLEG_PDA_OPERATOR', '国内头程 PDA 作业员', 'SYSTEM', 'ACTIVE', + '负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成', 0, 0) +ON DUPLICATE KEY UPDATE + role_name = VALUES(role_name), role_type = 'SYSTEM', status = 'ACTIVE', + description = VALUES(description), deleted = 0; + +DROP TEMPORARY TABLE IF EXISTS tmp_phase3_warehouse_role_permission; +CREATE TEMPORARY TABLE tmp_phase3_warehouse_role_permission ( + role_code VARCHAR(64) NOT NULL, + permission_code VARCHAR(128) NOT NULL, + PRIMARY KEY (role_code, permission_code) +) ENGINE=MEMORY; + +-- 操作员:查看入仓业务并维护后台装柜证据,不授予完成装柜、任务分派或出仓动作。 +INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES + ('WAREHOUSE_OPERATOR', 'platform:order'), + ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:list'), + ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:view'), + ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:view'), + ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'); + +-- 国内头程 PDA 作业员:只允许国内任务查询、作业和现场照片,不授予后台、海外、分派或复核权限。 +INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES + ('CN_FIRSTLEG_PDA_OPERATOR', 'platform:firstleg:pda:view'), + ('CN_FIRSTLEG_PDA_OPERATOR', 'platform:firstleg:pda:work'); + +-- 理货员:负责到仓测量、装柜证据维护和PDA复核,不允许完成装柜或确认出仓。 +INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES + ('WAREHOUSE_TALLY', 'platform:order'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:list'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:view'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:measure:view'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:measure:edit'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:measure:submit'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:weight:edit'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:weight:confirm'), + ('WAREHOUSE_TALLY', 'platform:firstleg:pda:view'), + ('WAREHOUSE_TALLY', 'platform:firstleg:pda:review'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:loading:view'), + ('WAREHOUSE_TALLY', 'platform:firstleg:inbound:loading:edit'); + +DROP PROCEDURE IF EXISTS assert_phase3_warehouse_role_permissions_available; +DELIMITER $$ +CREATE PROCEDURE assert_phase3_warehouse_role_permissions_available() +BEGIN + DECLARE operator_permission_count INT DEFAULT 0; + DECLARE domestic_pda_permission_count INT DEFAULT 0; + DECLARE tally_permission_count INT DEFAULT 0; + SELECT COALESCE(SUM(role_code = 'WAREHOUSE_OPERATOR'), 0), + COALESCE(SUM(role_code = 'CN_FIRSTLEG_PDA_OPERATOR'), 0), + COALESCE(SUM(role_code = 'WAREHOUSE_TALLY'), 0) + INTO operator_permission_count, domestic_pda_permission_count, tally_permission_count + FROM tmp_phase3_warehouse_role_permission; + IF operator_permission_count <> 5 + OR domestic_pda_permission_count <> 2 + OR tally_permission_count <> 12 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '仓库角色初始化失败:角色权限组合数量不符合预期'; + END IF; + IF EXISTS ( + SELECT 1 + FROM tmp_phase3_warehouse_role_permission expected + 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 + WHERE permission.id IS NULL + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '仓库角色初始化失败:依赖的三期平台权限尚未完整部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_warehouse_role_permissions_available(); +DROP PROCEDURE assert_phase3_warehouse_role_permissions_available; + +-- 四个系统角色的权限集合由初始化维护。重复执行时移除人工误授的越权项, +-- 避免最小权限角色因脚本升级只增不减。 +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 + LEFT JOIN tmp_phase3_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', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', 'WAREHOUSE_TALLY', 'CN_FIRSTLEG_PDA_OPERATOR') + AND NOT (role.role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' 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) +SELECT role.id, permission.id, 0 + FROM tmp_phase3_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; + +INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES + (6000004, 4000004, 0, 'TENANT', NULL), + (6000005, 4000005, 0, 'TENANT', NULL), + (6000006, 4000006, 0, 'TENANT', NULL), + (6000007, 4000007, 0, 'TENANT', NULL) +ON DUPLICATE KEY UPDATE + tenant_id = 0, scope_type = 'TENANT', scope_expression = NULL; + +DROP PROCEDURE IF EXISTS assert_phase3_warehouse_roles_ready; +DELIMITER $$ +CREATE PROCEDURE assert_phase3_warehouse_roles_ready() +BEGIN + IF (SELECT COUNT(*) + FROM iam_role + WHERE tenant_id = 0 + AND app_code = 'PLATFORM' + AND role_type = 'SYSTEM' + AND status = 'ACTIVE' + AND deleted = 0 + AND role_code IN ('WAREHOUSE_OPERATOR', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', 'WAREHOUSE_TALLY', 'CN_FIRSTLEG_PDA_OPERATOR')) <> 4 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '仓库角色初始化失败:系统角色未完整启用'; + END IF; + IF (SELECT COUNT(*) + FROM tmp_phase3_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 + JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.app_code = 'PLATFORM' + AND permission.permission_code = expected.permission_code + JOIN iam_role_permission grant_row + ON grant_row.role_id = role.id + AND grant_row.permission_id = permission.id + AND grant_row.tenant_id = 0) <> 19 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '仓库角色初始化失败:最小权限授权未完整写入'; + END IF; + IF (SELECT COUNT(*) + FROM iam_data_scope scope_row + JOIN iam_role role ON role.id = scope_row.role_id + WHERE role.tenant_id = 0 + AND role.app_code = 'PLATFORM' + AND role.role_code IN ('WAREHOUSE_OPERATOR', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', 'WAREHOUSE_TALLY', 'CN_FIRSTLEG_PDA_OPERATOR') + AND scope_row.tenant_id = 0 + AND scope_row.scope_type = 'TENANT') <> 4 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '仓库角色初始化失败:数据范围未完整写入'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_warehouse_roles_ready(); +DROP PROCEDURE assert_phase3_warehouse_roles_ready; + +DROP TEMPORARY TABLE IF EXISTS tmp_phase3_warehouse_role_permission; diff --git a/iam/src/main/resources/db/phase5-delivery-permissions.sql b/iam/src/main/resources/db/phase5-delivery-permissions.sql new file mode 100644 index 000000000..5d850ee2b --- /dev/null +++ b/iam/src/main/resources/db/phase5-delivery-permissions.sql @@ -0,0 +1,327 @@ +USE baiyue_iam; +SET NAMES utf8mb4; + +-- 五期海外排车、出库与派送权限。列表权限同时覆盖列表和详情只读,写操作保持独立授权。 +DROP PROCEDURE IF EXISTS guard_phase5_delivery_permission_ids; +DELIMITER $$ +CREATE PROCEDURE guard_phase5_delivery_permission_ids() +BEGIN + IF EXISTS ( + SELECT 1 FROM iam_permission + WHERE (id = 5107064 AND permission_code <> 'platform:overseas-warehouse:dispatch:list') + OR (id = 5107016 AND permission_code <> 'platform:overseas-warehouse:dispatch:edit') + OR (id = 5107017 AND permission_code <> 'platform:overseas-warehouse:dispatch:cancel') + OR (id = 5107018 AND permission_code <> 'platform:overseas-warehouse:dispatch:transfer-delivery') + OR (id = 5107090 AND permission_code <> 'platform:overseas-warehouse:outbound:list') + OR (id = 5107091 AND permission_code <> 'platform:overseas-warehouse:outbound:create') + OR (id = 5107092 AND permission_code <> 'platform:overseas-warehouse:delivery:list') + OR (id = 5107094 AND permission_code <> 'platform:overseas-warehouse:delivery:documents') + OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel') + OR (id = 5107070 AND permission_code <> 'platform:overseas-warehouse:exception-order:list') + OR (id = 5107071 AND permission_code <> 'platform:overseas-warehouse:exception-order:create') + OR (id = 5107073 AND permission_code <> 'platform:overseas-warehouse:exception-order:process') + OR (id = 5107072 AND permission_code <> 'platform:overseas-warehouse:delivery:return') + OR (id = 5107080 AND permission_code <> 'platform:overseas-warehouse:customer-visit:list') + OR (id = 5107081 AND permission_code <> 'platform:overseas-warehouse:customer-visit:edit') + OR (id = 5107082 AND permission_code <> 'platform:overseas-warehouse:delivery:status') + OR (id = 5306000 AND permission_code <> 'merchant:warehouse:delivery:list') + OR (id = 5306001 AND permission_code <> 'merchant:warehouse:delivery:create') + ) OR EXISTS ( + SELECT 1 FROM iam_permission + WHERE (permission_code = 'platform:overseas-warehouse:dispatch:list' AND id <> 5107064) + OR (permission_code = 'platform:overseas-warehouse:dispatch:edit' AND id <> 5107016) + OR (permission_code = 'platform:overseas-warehouse:dispatch:cancel' AND id <> 5107017) + OR (permission_code = 'platform:overseas-warehouse:dispatch:transfer-delivery' AND id <> 5107018) + OR (permission_code = 'platform:overseas-warehouse:outbound:list' AND id <> 5107090) + OR (permission_code = 'platform:overseas-warehouse:outbound:create' AND id <> 5107091) + OR (permission_code = 'platform:overseas-warehouse:delivery:list' AND id <> 5107092) + OR (permission_code = 'platform:overseas-warehouse:delivery:documents' AND id <> 5107094) + OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093) + OR (permission_code = 'platform:overseas-warehouse:exception-order:list' AND id <> 5107070) + OR (permission_code = 'platform:overseas-warehouse:exception-order:create' AND id <> 5107071) + OR (permission_code = 'platform:overseas-warehouse:exception-order:process' AND id <> 5107073) + OR (permission_code = 'platform:overseas-warehouse:delivery:return' AND id <> 5107072) + OR (permission_code = 'platform:overseas-warehouse:customer-visit:list' AND id <> 5107080) + OR (permission_code = 'platform:overseas-warehouse:customer-visit:edit' AND id <> 5107081) + OR (permission_code = 'platform:overseas-warehouse:delivery:status' AND id <> 5107082) + OR (permission_code = 'merchant:warehouse:delivery:list' AND id <> 5306000) + OR (permission_code = 'merchant:warehouse:delivery:create' AND id <> 5306001) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限ID或编码冲突'; + END IF; +END$$ +DELIMITER ; +CALL guard_phase5_delivery_permission_ids(); +DROP PROCEDURE guard_phase5_delivery_permission_ids; + +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 + (5107064,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:dispatch:list','海外排车单','/overseas-warehouse/dispatches','GET',764,'ACTIVE',1,0), + (5107016,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:edit','编辑海外排车',NULL,'PUT',716,'ACTIVE',1,0), + (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE',717,'ACTIVE',1,0), + (5107018,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,0), + (5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,0), + (5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,0), + (5107073,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST',773,'ACTIVE',1,0), + (5107072,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,0), + (5107080,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET',780,'ACTIVE',1,0), + (5107081,0,'PLATFORM','BUTTON',5107080,'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT',781,'ACTIVE',1,0), + (5107082,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:status','编辑派送单状态',NULL,'PUT',782,'ACTIVE',1,0), + (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0), + (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0), + (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0), + (5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST',769,'ACTIVE',1,0), + (5107093,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:cancel','取消海外出库单',NULL,'POST',768,'ACTIVE',1,0), + (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,0), + (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,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=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, CURRENT_TIMESTAMP(3) + FROM iam_role role + JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + 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.permission_code IN ( + 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list', + 'platform:overseas-warehouse:exception-order:list', + 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', + 'platform:overseas-warehouse:delivery:return', + 'platform:overseas-warehouse:delivery:status', + 'platform:overseas-warehouse:delivery:documents', + 'platform:overseas-warehouse:customer-visit:list', + 'platform:overseas-warehouse:customer-visit:edit') + AND permission.status='ACTIVE' AND permission.deleted=0; + +-- 已创建的客户拥有租户级默认管理员角色;权限资源仍引用 tenant_id=0 的公共目录。 +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT role.tenant_id, role.id, permission.id, CURRENT_TIMESTAMP(3) + FROM iam_role role + JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT' + WHERE role.tenant_id>0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' + AND role.status='ACTIVE' AND role.deleted=0 + AND permission.permission_code IN ( + 'merchant:warehouse:delivery:list', + 'merchant:warehouse:delivery:create') + AND permission.status='ACTIVE' AND permission.deleted=0; + +-- 仓库后台岗位按最小职责授权;PDA 岗位不得获得后台排车、出库或派送权限。 +DROP TEMPORARY TABLE IF EXISTS tmp_phase5_delivery_role_permission; +CREATE TEMPORARY TABLE tmp_phase5_delivery_role_permission ( + role_code VARCHAR(64) NOT NULL, + permission_code VARCHAR(160) NOT NULL, + PRIMARY KEY (role_code, permission_code) +) ENGINE=MEMORY; +INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VALUES + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:cancel'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:transfer-delivery'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:create'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:cancel'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:create'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:documents'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:edit'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:customer-visit: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_phase5_delivery_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_TALLY','OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR') + AND permission.permission_code IN ( + 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list', + 'platform:overseas-warehouse:exception-order:list', + 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', + 'platform:overseas-warehouse:delivery:return', + 'platform:overseas-warehouse:delivery:status', + 'platform:overseas-warehouse:delivery:documents', + 'platform:overseas-warehouse:customer-visit:list', + 'platform:overseas-warehouse:customer-visit:edit') + 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, CURRENT_TIMESTAMP(3) + FROM tmp_phase5_delivery_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; + +-- 客户默认管理员获得派送列表/详情只读及独立创建;其他客户角色不自动扩权。 +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) + FROM iam_role role + JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT' + WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' + AND role.status='ACTIVE' AND role.deleted=0 + AND permission.permission_code IN ( + 'merchant:warehouse:delivery:list', + 'merchant:warehouse:delivery:create') + AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions; +DELIMITER $$ +CREATE PROCEDURE assert_phase5_delivery_permissions() +BEGIN + DECLARE v_warehouse_operator_permission_count INT DEFAULT 0; + DECLARE v_warehouse_tally_permission_count INT DEFAULT 0; + + IF (SELECT COUNT(*) FROM iam_permission + WHERE tenant_id=0 AND status='ACTIVE' AND deleted=0 + AND permission_code IN ( + 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list', + 'platform:overseas-warehouse:exception-order:list', + 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', + 'platform:overseas-warehouse:delivery:return', + 'platform:overseas-warehouse:delivery:status', + 'platform:overseas-warehouse:delivery:documents', + 'platform:overseas-warehouse:customer-visit:list', + 'platform:overseas-warehouse:customer-visit:edit', + 'merchant:warehouse:delivery:list', + 'merchant:warehouse:delivery:create')) <> 18 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整'; + END IF; + 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 + WHERE role.role_code='PLATFORM_ADMIN' + AND permission.permission_code IN ( + 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list', + 'platform:overseas-warehouse:exception-order:list', + 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', + 'platform:overseas-warehouse:delivery:return', + 'platform:overseas-warehouse:delivery:status', + 'platform:overseas-warehouse:delivery:documents', + 'platform:overseas-warehouse:customer-visit:list', + 'platform:overseas-warehouse:customer-visit:edit')) <> 16 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整'; + END IF; + IF EXISTS (SELECT 1 FROM iam_role role + 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 (SELECT COUNT(*) FROM iam_role_permission grant_row + JOIN iam_permission permission ON permission.id=grant_row.permission_id + WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id + AND permission.permission_code IN ( + 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list', + 'platform:overseas-warehouse:exception-order:list', + 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', + 'platform:overseas-warehouse:delivery:return', + 'platform:overseas-warehouse:delivery:status', + 'platform:overseas-warehouse:delivery:documents', + 'platform:overseas-warehouse:customer-visit:list', + 'platform:overseas-warehouse:customer-visit:edit')) <> 16) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台自定义管理员授权不完整'; + END IF; + SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0), + COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0) + INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count + FROM tmp_phase5_delivery_role_permission; + IF v_warehouse_operator_permission_count <> 16 + OR v_warehouse_tally_permission_count <> 5 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常'; + 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 + WHERE role.role_code IN ('OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR') + AND permission.permission_code IN ( + 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list', + 'platform:overseas-warehouse:exception-order:list', + 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', + 'platform:overseas-warehouse:delivery:return', + 'platform:overseas-warehouse:delivery:status', + 'platform:overseas-warehouse:delivery:documents', + 'platform:overseas-warehouse:customer-visit:list', + 'platform:overseas-warehouse:customer-visit:edit')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限'; + END IF; + IF EXISTS (SELECT role.id FROM iam_role role + LEFT JOIN iam_role_permission grant_row + ON grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id + LEFT JOIN iam_permission permission ON permission.id=grant_row.permission_id + AND permission.permission_code IN ( + 'merchant:warehouse:delivery:list', + 'merchant:warehouse:delivery:create') + WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT' + AND role.status='ACTIVE' AND role.deleted=0 + GROUP BY role.id HAVING COUNT(DISTINCT permission.permission_code) <> 2) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:客户默认管理员授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase5_delivery_permissions(); +DROP PROCEDURE assert_phase5_delivery_permissions; +DROP TEMPORARY TABLE tmp_phase5_delivery_role_permission; diff --git a/iam/src/main/resources/db/platform-supplier-permissions.sql b/iam/src/main/resources/db/platform-supplier-permissions.sql new file mode 100644 index 000000000..b22ad52c3 --- /dev/null +++ b/iam/src/main/resources/db/platform-supplier-permissions.sql @@ -0,0 +1,46 @@ +-- 平台供应商权限资源及 PLATFORM_ADMIN 默认授权;可重复执行。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS guard_platform_supplier_permission_ids; +DELIMITER $$ +CREATE PROCEDURE guard_platform_supplier_permission_ids() +BEGIN + IF EXISTS (SELECT 1 FROM iam_permission WHERE id BETWEEN 5103080 AND 5103083 AND permission_code <> CASE id WHEN 5103080 THEN 'platform:supplier:list' WHEN 5103081 THEN 'platform:supplier:create' WHEN 5103082 THEN 'platform:supplier:edit' WHEN 5103083 THEN 'platform:supplier:status' END) + OR EXISTS (SELECT 1 FROM iam_permission WHERE permission_code IN ('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status') AND id NOT BETWEEN 5103080 AND 5103083) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商权限固定ID或权限码已被其他语义占用'; + END IF; +END$$ +DELIMITER ; +CALL guard_platform_supplier_permission_ids(); +DROP PROCEDURE guard_platform_supplier_permission_ids; + +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 + (5103080,0,'PLATFORM','MENU',5103000,'platform:supplier:list','供应商列表','/suppliers','GET',80,'ACTIVE',1,0), + (5103081,0,'PLATFORM','BUTTON',5103080,'platform:supplier:create','新增供应商',NULL,'POST',10,'ACTIVE',1,0), + (5103082,0,'PLATFORM','BUTTON',5103080,'platform:supplier:edit','编辑供应商',NULL,'PUT',20,'ACTIVE',1,0), + (5103083,0,'PLATFORM','BUTTON',5103080,'platform:supplier:status','启停供应商',NULL,'PATCH',30,'ACTIVE',1,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=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,r.id,p.id,CURRENT_TIMESTAMP(3) +FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM' +WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0 + AND p.permission_code IN ('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status'); + +DROP PROCEDURE IF EXISTS assert_platform_supplier_permissions; +DELIMITER $$ +CREATE PROCEDURE assert_platform_supplier_permissions() +BEGIN + IF (SELECT COUNT(*) FROM iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 AND permission_code IN ('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商权限资源初始化不完整'; + END IF; + IF (SELECT COUNT(*) FROM iam_role_permission rp JOIN iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PLATFORM_ADMIN供应商权限授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_platform_supplier_permissions(); +DROP PROCEDURE assert_platform_supplier_permissions; 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 fe97b6c9a..87f2ea5de 100644 --- a/iam/src/main/resources/db/rbac-role-management-v2.sql +++ b/iam/src/main/resources/db/rbac-role-management-v2.sql @@ -7,14 +7,18 @@ INSERT INTO iam_permission permission_name, sort_no, status, deleted) VALUES (5100011, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:create', '新增企业客户', 10, 'ACTIVE', 0), + (5100016, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:reset-password', '重置客户管理员密码', 50, 'ACTIVE', 0), + (5100017, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:delete', '归档企业客户', 60, 'ACTIVE', 0), + (5100018, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:impersonate', '跳转客户登录', 70, 'ACTIVE', 0), (5101023, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:edit', '编辑角色', 30, 'ACTIVE', 0), (5101024, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:status', '启停角色', 40, 'ACTIVE', 0), (5101025, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:delete', '删除角色', 50, 'ACTIVE', 0), (5101040, 0, 'PLATFORM', 'MENU', 5101000, 'platform:member:list', '平台成员', 40, 'ACTIVE', 0), (5101041, 0, 'PLATFORM', 'BUTTON', 5101040, 'platform:member:grant-role', '分配成员角色', 10, '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), + (5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑用户', 20, 'ACTIVE', 0), + (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), (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) @@ -28,13 +32,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000001, id, 0 FROM iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_code IN ('platform:role:edit', 'platform:role:status', 'platform:role:delete', - 'platform:member:list', 'platform:member:grant-role', 'platform:customer:create') -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') + 'platform:member:list', 'platform:member:grant-role', 'platform:customer:create', + 'platform:user:edit', 'platform:user:status', 'platform:user:reset-password', + 'platform:user:grant-role') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) diff --git a/iam/src/main/resources/db/security-event-outbox.sql b/iam/src/main/resources/db/security-event-outbox.sql new file mode 100644 index 000000000..ec4693cdb --- /dev/null +++ b/iam/src/main/resources/db/security-event-outbox.sql @@ -0,0 +1,35 @@ +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可靠安全事件本地发送箱'; diff --git a/iam/src/main/resources/db/wallet-permissions.sql b/iam/src/main/resources/db/wallet-permissions.sql new file mode 100644 index 000000000..d076b723c --- /dev/null +++ b/iam/src/main/resources/db/wallet-permissions.sql @@ -0,0 +1,122 @@ +-- 客户资金账户与充值权限。只读访问和资金写操作使用独立权限。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS migrate_wallet_permissions; +DELIMITER $$ +CREATE PROCEDURE migrate_wallet_permissions() +BEGIN + IF EXISTS ( + SELECT 1 + FROM iam_permission + WHERE (id = 5105056 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' + OR permission_type <> 'BUTTON' + OR permission_code <> 'platform:finance:recharge:review')) + OR (id = 5105057 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' + OR permission_type <> 'BUTTON' + OR permission_code <> 'platform:finance:customer-account:adjust')) + OR (id = 5305020 AND (tenant_id <> 0 OR app_code <> 'MERCHANT' + OR permission_type <> 'MENU' + OR permission_code <> 'merchant:finance:account:view')) + OR (id = 5305021 AND (tenant_id <> 0 OR app_code <> 'MERCHANT' + OR permission_type <> 'BUTTON' + OR permission_code <> 'merchant:finance:recharge:create')) + OR (permission_code = 'platform:finance:recharge:review' + AND (id <> 5105056 OR tenant_id <> 0 OR app_code <> 'PLATFORM')) + OR (permission_code = 'platform:finance:customer-account:adjust' + AND (id <> 5105057 OR tenant_id <> 0 OR app_code <> 'PLATFORM')) + OR (permission_code = 'merchant:finance:account:view' + AND (id <> 5305020 OR tenant_id <> 0 OR app_code <> 'MERCHANT')) + OR (permission_code = 'merchant:finance:recharge:create' + AND (id <> 5305021 OR tenant_id <> 0 OR app_code <> 'MERCHANT')) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:权限ID、编码或类型冲突'; + END IF; + + 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 + (5105056,0,'PLATFORM','BUTTON',5105040,'platform:finance:recharge:review', + '审核客户充值',NULL,'POST',100,'ACTIVE',1,0), + (5105057,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:adjust', + '调整客户资金账户',NULL,'POST',110,'ACTIVE',1,0), + (5305020,0,'MERCHANT','MENU',5300000,'merchant:finance:account:view', + '资金账户','/finance/account','GET',50,'ACTIVE',1,0), + (5305021,0,'MERCHANT','BUTTON',5305020,'merchant:finance:recharge:create', + '提交充值申请',NULL,'POST',10,'ACTIVE',1,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), + 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, CURRENT_TIMESTAMP(3) + FROM iam_role role + JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + 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.permission_code IN ( + 'platform:finance:recharge:review', + 'platform:finance:customer-account:adjust') + AND permission.status='ACTIVE' AND permission.deleted=0; + + INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) + SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) + FROM iam_role role + JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT' + WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' + AND role.status='ACTIVE' AND role.deleted=0 + AND permission.permission_code IN ( + 'merchant:finance:account:view', + 'merchant:finance:recharge:create') + AND permission.status='ACTIVE' AND permission.deleted=0; + + IF (SELECT COUNT(*) FROM iam_permission + WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_type='BUTTON' + AND parent_id=5105040 AND status='ACTIVE' AND built_in=1 AND deleted=0 + AND permission_code IN ( + 'platform:finance:recharge:review', + 'platform:finance:customer-account:adjust')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:平台写权限资源不完整'; + END IF; + + IF (SELECT COUNT(*) FROM iam_permission + WHERE tenant_id=0 AND app_code='MERCHANT' AND status='ACTIVE' AND built_in=1 AND deleted=0 + AND ((id=5305020 AND permission_type='MENU' AND parent_id=5300000 + AND permission_code='merchant:finance:account:view') + OR (id=5305021 AND permission_type='BUTTON' AND parent_id=5305020 + AND permission_code='merchant:finance:recharge:create'))) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:客户权限资源不完整'; + END IF; + + 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 + 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.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.status='ACTIVE' AND permission.deleted=0 + AND permission.permission_code IN ( + 'platform:finance:recharge:review', + 'platform:finance:customer-account:adjust')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:平台超级管理员授权不完整'; + END IF; + + 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 + WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' + AND role.status='ACTIVE' AND role.deleted=0 + AND permission.tenant_id=0 AND permission.app_code='MERCHANT' + AND permission.status='ACTIVE' AND permission.deleted=0 + AND permission.permission_code IN ( + 'merchant:finance:account:view', + 'merchant:finance:recharge:create')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='资金账户权限初始化失败:企业负责人授权不完整'; + END IF; +END$$ +DELIMITER ; + +CALL migrate_wallet_permissions(); +DROP PROCEDURE IF EXISTS migrate_wallet_permissions; diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index c4928ea49..33640c020 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Baiyue IAM API / 百悦身份与权限 API - description: Tenant, member, role, menu, page, button and API permission management. / 租户、成员、角色、菜单、页面、按钮和 API 权限管理。 + description: Customer master data, members, roles, menus, pages, buttons and API permissions for the current single-tenant product. Historical tenant-named paths, schemas and fields are retained for compatibility; tenantId > 0 currently means customer ID, while 0 is technical scope for platform identities, RBAC, and public warehouse resources. / 当前单租户产品的客户主档、成员、角色、菜单、页面、按钮和 API 权限管理。tenant 命名的路径、模型和字段仅为历史兼容;tenantId 大于 0 当前表示客户 ID,0 表示平台身份、RBAC 和公共仓储资源技术作用域。 version: 1.0.1 servers: - url: / @@ -9,42 +9,365 @@ security: - bearerAuth: [] tags: - name: Platform Tenant / 平台租户 - description: Enterprise registration and review / 企业注册与审核 + description: Historical tag for platform customer administration; it does not represent a real tenant layer. / 历史兼容标签,实际用于平台客户管理,不表示真实租户层。 - name: Merchant Tenant / 商户企业 - description: Current tenant profile and members / 当前企业资料与成员 + description: Current customer profile and members; tenant naming is retained for compatibility. / 当前客户企业资料与成员;tenant 命名仅为兼容。 - name: IAM Resources / IAM 资源 description: Internal users, addresses and qualifications / 内部用户、地址与资质 - name: RBAC description: Platform and merchant role-permission management / 平台端与商户端角色权限管理 paths: + /api/merchant/v1/iam/profile: + get: + tags: [Merchant Tenant / 商户企业] + summary: 查看当前登录用户个人资料 + responses: + '200': + description: 查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/PersonalProfileApiResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + put: + tags: [Merchant Tenant / 商户企业] + summary: 修改当前登录用户普通资料 + description: 仅修改显示名称和邮箱;用户名、手机号必须走敏感标识接口。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UpdatePersonalProfileRequest'} + responses: + '200': + description: 修改成功 + content: + application/json: + schema: {$ref: '#/components/schemas/PersonalProfileApiResponse'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/merchant/v1/iam/profile/login-identifiers: + put: + tags: [Merchant Tenant / 商户企业] + summary: 修改当前登录用户的用户名和手机号 + description: 仅允许当前非代登录用户修改本人标识,必须用当前密码重新认证;成功后全部 Token 和会话失效,需重新登录。权限为已认证客户本人,无额外权限码。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ChangeLoginIdentifiersRequest'} + responses: + '200': + description: 修改成功并已吊销会话 + content: + application/json: + schema: {$ref: '#/components/schemas/PersonalProfileApiResponse'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /api/platform/v1/iam/tenants/administrator-phone-availability: + get: + tags: [Platform Tenant / 平台租户] + summary: 校验首个管理员登录手机号唯一性 + description: 仅在创建客户前校验 Auth 登录手机号;公司联系电话不要求跨企业唯一。 + parameters: + - {name: phone, in: query, required: true, schema: {type: string, pattern: '^1[3-9]\d{9}$'}} + responses: + '200': + description: 返回手机号是否可作为首个管理员登录手机号 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {type: boolean} + '403': {$ref: '#/components/responses/Forbidden'} /api/platform/v1/iam/tenants: + get: + tags: [Platform Tenant / 平台租户] + summary: 查询企业客户列表 + description: 分页查询商户企业客户,需要 `platform:customer:list` 权限;联系电话和邮箱仅返回脱敏摘要。 + parameters: + - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + - {name: keyword, in: query, required: false, schema: {type: string}, description: 客户名称关键字,不支持客户编号、信用代码或联系人搜索} + - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}} + responses: + '200': + description: 企业客户分页查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/TenantPageApiResponse'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '422': {$ref: '#/components/responses/UnprocessableEntity'} post: tags: [Platform Tenant / 平台租户] - summary: Register enterprise tenant / 创建企业租户 - description: Create a pending enterprise tenant and review task. Platform identity is required. / 创建待审核企业租户及审核任务,要求平台端身份。 + summary: Register enterprise tenant / 创建企业客户草稿 + 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: application/json: schema: {$ref: '#/components/schemas/RegisterRequest'} responses: - '200': {$ref: '#/components/responses/Success'} + '200': {$ref: '#/components/responses/TenantCreationSuccess'} '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} '409': {$ref: '#/components/responses/Conflict'} + /api/platform/v1/iam/authorization/permissions: + get: + tags: [IAM Resources / IAM 资源] + summary: 查询当前用户有效权限 + description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。 + responses: + '200': + description: 当前平台成员权限查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/PermissionCodesApiResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/platform/v1/iam/tenants/{tenantId}: + parameters: + - {$ref: '#/components/parameters/TenantId'} + get: + tags: [Platform Tenant / 平台租户] + summary: 查询企业客户档案 + description: 需要 `platform:customer:list` 权限,返回完整联系方式。 + responses: + '200': {$ref: '#/components/responses/TenantSuccess'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [Platform Tenant / 平台租户] + summary: 编辑企业客户档案 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UpdateTenantRequest'} + responses: + '200': {$ref: '#/components/responses/TenantSuccess'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/members: + parameters: + - {$ref: '#/components/parameters/TenantId'} + get: + tags: [Platform Tenant / 平台租户] + summary: 查询企业客户成员及账号 + description: 平台只读查询指定客户的全部企业成员,包含 OWNER 企业管理员并返回登录账号;需要 `platform:customer:list` 权限。 + responses: + '200': + description: 企业成员查询成功 + content: + application/json: + schema: + $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'} + post: + tags: [Platform Tenant / 平台租户] + summary: 提交企业客户审核 + description: 校验营业执照和统一社会信用代码后创建审核单,并把客户状态改为 `PENDING`;法人身份证选填,保存资料和上传文件均不会自动调用本接口。 + responses: + '200': {$ref: '#/components/responses/TenantSuccess'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + /api/platform/v1/iam/tenants/{tenantId}/addresses: + parameters: + - {$ref: '#/components/parameters/TenantId'} + get: + tags: [Platform Tenant / 平台租户] + summary: 查询企业客户地址 + description: 需要 `platform:customer:list` 权限;只读接口,不提供平台端地址写入。 + responses: + '200': + description: 企业客户地址查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/TenantAddressListApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/qualifications: + parameters: + - {$ref: '#/components/parameters/TenantId'} + get: + tags: [Platform Tenant / 平台租户] + summary: 查询企业客户资质 + description: 需要 `platform:customer:list` 权限;只读查询资质元数据,不授予审核权限。 + responses: + '200': + description: 企业客户资质查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/TenantQualificationListApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {name: qualificationId, in: path, required: true, schema: {type: integer, format: int64}} + get: + tags: [Platform Tenant / 平台租户] + summary: 查询企业客户资质详情 + description: 需要 `platform:customer:list` 权限,并同时校验客户与资质归属。 + responses: + '200': + description: 企业客户资质详情查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/TenantQualificationApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/status: + parameters: + - {$ref: '#/components/parameters/TenantId'} + put: + tags: [Platform Tenant / 平台租户] + summary: 启停企业客户 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ChangeTenantStatusRequest'} + responses: + '200': {$ref: '#/components/responses/TenantSuccess'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + /api/platform/v1/iam/tenants/{tenantId}/owner/password: + parameters: + - {$ref: '#/components/parameters/TenantId'} + put: + tags: [Platform Tenant / 平台租户] + summary: 重置客户企业管理员密码 + description: 仅重置指定客户 OWNER 的认证密码;密码不写入审计快照并撤销该账号现有会话。 + security: [{bearerAuth: []}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ResetCustomerOwnerPasswordRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '503': {$ref: '#/components/responses/ServiceUnavailable'} + /api/platform/v1/iam/tenants/{tenantId}/archive: + parameters: + - {$ref: '#/components/parameters/TenantId'} + post: + tags: [Platform Tenant / 平台租户] + summary: 逻辑归档客户 + description: 仅停用客户及其认证账号,保留业务历史数据;重复归档幂等返回当前客户。 + security: [{bearerAuth: []}] + responses: + '200': {$ref: '#/components/responses/TenantSuccess'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '503': {$ref: '#/components/responses/ServiceUnavailable'} + /api/platform/v1/iam/tenants/{tenantId}/impersonation: + parameters: + - {$ref: '#/components/parameters/TenantId'} + post: + tags: [Platform Tenant / 平台租户] + summary: 创建客户代登录授权码 + description: 创建 60 秒有效、只能兑换一次且不包含刷新令牌的客户 OWNER 代登录授权码。 + security: [{bearerAuth: []}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CustomerImpersonationRequest'} + responses: + '200': + description: 授权码创建成功 + content: + application/json: + schema: {$ref: '#/components/schemas/CustomerImpersonationApiResponse'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '503': {$ref: '#/components/responses/ServiceUnavailable'} + /api/merchant/v1/iam/authorization/permissions: + get: + tags: [IAM Resources / IAM 资源] + summary: 查询当前用户有效权限 + description: 返回当前有效客户成员权限编码。资金账户和流水只读返回 `merchant:finance:account:view`,提交充值申请返回 `merchant:finance:recharge:create`,两者独立授权。海外采购、本土卡、本土散板和海外入库订单分别返回 `merchant:cross-border:procurement`、`merchant:cross-border:domestic-trucking`、`merchant:cross-border:pallet-shipping`、`merchant:warehouse:inbound:list`;客户派送返回 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`。列表权限包含详情只读,创建权限独立授权。 + responses: + '200': + description: 当前商户成员权限查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/PermissionCodesApiResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} /api/platform/v1/iam/reviews: get: tags: [Platform Tenant / 平台租户] summary: List pending tenant reviews / 查询待审核企业 description: Return enterprise registrations waiting for a platform decision. / 返回等待平台处理的企业注册审核记录。 responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 待审核企业及客户摘要查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/PendingEnterpriseReviewListApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} /api/platform/v1/iam/reviews/{id}/approve: post: tags: [Platform Tenant / 平台租户] summary: Approve enterprise registration / 审核通过企业注册 - description: Approve a pending review and activate the tenant. / 通过待审核申请并激活企业租户。 + description: Approve and activate the tenant. Client registrations keep the registration account as administrator. Only platform-created tenants without an owner receive a generated administrator and one-time credentials. / 审核通过并激活企业;客户端注册账号继续作为企业管理员,仅平台代建且没有所有者的企业自动创建管理员并返回一次性凭据。 parameters: - {$ref: '#/components/parameters/Id'} requestBody: @@ -53,7 +376,11 @@ paths: application/json: schema: {$ref: '#/components/schemas/ReviewRequest'} responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 审核通过;data 返回开户结果及一次性初始凭据 + content: + application/json: + schema: {$ref: '#/components/schemas/TenantApprovalApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} '404': {$ref: '#/components/responses/NotFound'} '409': {$ref: '#/components/responses/Conflict'} @@ -74,32 +401,182 @@ paths: '403': {$ref: '#/components/responses/Forbidden'} '404': {$ref: '#/components/responses/NotFound'} '409': {$ref: '#/components/responses/Conflict'} - /api/platform/v1/iam/internal-users: + /api/platform/v1/iam/users: get: tags: [IAM Resources / IAM 资源] - summary: List platform internal users / 查询平台内部用户 + summary: 分页查询平台内部用户 + 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: keyword, in: query, schema: {type: string}, description: 匹配姓名、手机号、邮箱或成员编号} + - {name: status, in: query, schema: {type: string, enum: [ACTIVE, DISABLED]}} responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 仅返回 tenant_id=0 的平台内部用户分页 + content: + application/json: + schema: {$ref: '#/components/schemas/PlatformManagedUserPageApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} post: tags: [IAM Resources / IAM 资源] - summary: Create platform internal user / 新增平台内部用户 - description: Create an IAM profile linked to an existing authentication account. / 创建关联已有认证账号的 IAM 用户资料。 + summary: 新增平台内部用户 requestBody: required: true content: application/json: - schema: {$ref: '#/components/schemas/CreateIamUserRequest'} + schema: {$ref: '#/components/schemas/CreatePlatformManagedUserRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/platform/v1/iam/users/options: + get: + tags: [IAM Resources / IAM 资源] + summary: 查询平台技术作用域和 PLATFORM 角色选项 + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/platform/v1/iam/users/{membershipId}: + parameters: + - {$ref: '#/components/parameters/MembershipId'} + put: + tags: [IAM Resources / IAM 资源] + summary: 编辑平台内部用户资料及角色 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UpdatePlatformManagedUserRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/platform/v1/iam/users/{membershipId}/status: + parameters: + - {$ref: '#/components/parameters/MembershipId'} + put: + tags: [IAM Resources / IAM 资源] + summary: 启用或停用平台内部用户 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ChangeInternalUserStatusRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/platform/v1/iam/users/{membershipId}/password: + parameters: + - {$ref: '#/components/parameters/MembershipId'} + put: + tags: [IAM Resources / IAM 资源] + summary: 重置平台内部用户密码 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ResetInternalUserPasswordRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/platform/v1/iam/internal-users: + get: + tags: [IAM Resources / IAM 资源] + summary: 查询平台内部用户 + responses: + '200': + description: 平台内部用户查询成功,公开主键为 membershipId 并回显 roleIds + content: + application/json: + schema: {$ref: '#/components/schemas/InternalUserListApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + post: + tags: [IAM Resources / IAM 资源] + summary: 新增平台内部用户 + description: 创建完整的 Auth/IAM 平台账号,也兼容通过 authUserId 关联已有账号。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CreateInternalUserRequest'} responses: '200': {$ref: '#/components/responses/Success'} '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} '409': {$ref: '#/components/responses/Conflict'} + /api/platform/v1/iam/internal-users/{membershipId}: + parameters: + - {$ref: '#/components/parameters/MembershipId'} + put: + tags: [IAM Resources / IAM 资源] + summary: 编辑平台内部用户 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UpdateInternalUserRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + /api/platform/v1/iam/internal-users/{membershipId}/status: + parameters: + - {$ref: '#/components/parameters/MembershipId'} + put: + tags: [IAM Resources / IAM 资源] + summary: 启用或停用平台内部用户 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ChangeInternalUserStatusRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/internal-users/{membershipId}/password: + parameters: + - {$ref: '#/components/parameters/MembershipId'} + put: + tags: [IAM Resources / IAM 资源] + summary: 重置平台内部用户密码 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ResetInternalUserPasswordRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/internal-users/{membershipId}/roles: + parameters: + - {$ref: '#/components/parameters/MembershipId'} + put: + tags: [IAM 资源] + summary: 全量分配平台内部用户角色 + description: 仅允许分配平台技术作用域(tenant_id=0)下已启用的平台端角色;空集合表示清空角色。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/AssignInternalUserRolesRequest'} + responses: + '200': + description: 角色分配成功并返回最新内部用户模型 + content: + application/json: + schema: {$ref: '#/components/schemas/InternalUserApiResponse'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} /api/merchant/v1/iam/tenant/profile: get: tags: [Merchant Tenant / 商户企业] summary: Get current tenant profile / 查询当前企业资料 - description: Tenant ID comes only from the verified JWT. / 租户 ID 仅取自已验证 JWT。 + description: The compatibility field tenant ID comes only from the verified JWT and currently identifies the customer. / 客户隔离兼容字段 tenant ID 仅取自已验证 JWT,当前表示登录客户 ID。 responses: '200': {$ref: '#/components/responses/Success'} '401': {$ref: '#/components/responses/Unauthorized'} @@ -123,28 +600,77 @@ paths: tags: [Merchant Tenant / 商户企业] summary: List enterprise members / 查询企业成员 responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 企业成员列表,包含真实登录账号与最后登录时间 + content: + application/json: + schema: {$ref: '#/components/schemas/MerchantMemberListApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} post: tags: [Merchant Tenant / 商户企业] summary: Add enterprise member / 新增企业成员 - description: Attach an existing IAM user to the current tenant. / 将已有 IAM 用户加入当前企业。 + description: 新增基础资料需要 merchant:member:create;roleIds 非空时还必须具有 merchant:member:grant-role,否则返回 403/A0403。空数组表示不分配角色。 requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/CreateMemberRequest'} responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 新增后的企业成员 + content: + application/json: + schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'} '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} '409': {$ref: '#/components/responses/Conflict'} + /api/merchant/v1/iam/members/{membershipId}: + parameters: + - name: membershipId + in: path + required: true + description: 当前租户内的成员关系 ID + schema: {type: string, pattern: '^\d+$'} + get: + tags: [Merchant Tenant / 商户企业] + summary: 查询成员详情及当前角色 + description: 返回当前客户成员资料,roleIds 为服务端实时查询的当前角色集合;跨客户成员按不存在处理。 + responses: + '200': + description: 企业成员详情 + content: + application/json: + schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [Merchant Tenant / 商户企业] + summary: 编辑成员资料及角色 + description: 基础资料编辑需要 merchant:member:edit。仅当 roleIds 与当前角色集合实际不同时额外要求 merchant:member:grant-role;传空数组清空角色也属于角色变更。角色集合未变化时不误拒绝。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UpdateMemberRequest'} + responses: + '200': + description: 更新后的企业成员 + content: + application/json: + schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} /api/merchant/v1/iam/tenant/addresses: get: tags: [IAM Resources / IAM 资源] summary: List tenant addresses / 查询企业地址 responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 角色列表及各角色实时成员数 + content: + application/json: + schema: {$ref: '#/components/schemas/RoleListApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} post: tags: [IAM Resources / IAM 资源] @@ -155,7 +681,11 @@ paths: application/json: schema: {$ref: '#/components/schemas/AddressRequest'} responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 新增后的角色及实时成员数 + content: + application/json: + schema: {$ref: '#/components/schemas/RoleApiResponse'} '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} /api/merchant/v1/iam/tenant/qualifications: @@ -163,27 +693,56 @@ paths: tags: [IAM Resources / IAM 资源] summary: List tenant qualifications / 查询企业资质 responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 当前客户资质列表 + content: + application/json: + schema: {$ref: '#/components/schemas/TenantQualificationListApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} + /api/merchant/v1/iam/tenant/qualifications/{qualificationId}: + parameters: + - {name: qualificationId, in: path, required: true, schema: {type: integer, format: int64}} + get: + tags: [IAM Resources / IAM 资源] + summary: 查询当前客户资质详情 + description: 需要 `merchant:profile:view` 权限;客户范围来自 JWT,跨客户资质按不存在处理。 + responses: + '200': + description: 当前客户资质详情 + content: + application/json: + schema: {$ref: '#/components/schemas/TenantQualificationApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + delete: + tags: [IAM Resources / IAM 资源] + summary: 删除送审前企业资质 + description: 需要 `merchant:qualification:upload` 权限;仅客户状态为 `PROFILE_REQUIRED` 或 `REJECTED` 时允许,`PENDING`、`ACTIVE` 等提交审核后的状态由服务端拒绝。客户范围来自 JWT;重复删除或跨客户 ID 返回相同幂等结果,不泄露归属。 + responses: + '200': + description: 删除成功或目标已不存在 + content: + application/json: + schema: {$ref: '#/components/schemas/QualificationDeletionApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /api/merchant/v1/iam/tenant/qualifications/submit-review: post: tags: [IAM Resources / IAM 资源] - summary: Add tenant qualification / 新增企业资质 - requestBody: - required: true - content: - application/json: - schema: {$ref: '#/components/schemas/QualificationRequest'} + summary: Submit enterprise qualifications for review / 提交企业资质审核 + description: Uploading or replacing a qualification keeps the tenant in draft state. This endpoint requires only the business license and unified social credit code; legal-person ID, freight-forwarder certificate and bank account license are optional. / 上传或替换资质不会自动送审;本接口只要求营业执照和统一社会信用代码,法人身份证、货代资质证书和银行开户许可证选填。 responses: - '200': {$ref: '#/components/responses/Success'} + '200': {$ref: '#/components/responses/TenantSuccess'} '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} /api/{app}/v1/iam/roles: parameters: - {$ref: '#/components/parameters/App'} get: tags: [RBAC] summary: List roles / 查询角色 - description: List roles in the selected application and tenant scope. / 查询指定应用及租户范围内的角色。 + description: List roles in the selected application and current customer or platform technical scope. Domestic first-leg PDA and overseas warehouse PDA operators are separate, mutually exclusive roles. / 查询指定应用及当前客户范围或平台技术作用域内的角色;国内头程 PDA 作业员与海外仓 PDA 作业员是两批独立人员,对应角色互斥且权限不交叉。 responses: '200': {$ref: '#/components/responses/Success'} '403': {$ref: '#/components/responses/Forbidden'} @@ -204,7 +763,7 @@ paths: put: tags: [RBAC] summary: Assign roles to member / 设置成员角色 - description: Replace member role assignments in the same tenant and application. / 在同一租户和应用域内替换成员角色。 + description: Replace member role assignments in the same tenant and application. Platform administrators can assign the built-in warehouse roles to active internal-user memberships; the API does not create accounts or fixed credentials. / 在同一客户和应用域内替换成员角色;平台管理员可将内置仓库角色分配给已启用的内部用户成员,本接口不会创建账号或固定密码。 parameters: - {$ref: '#/components/parameters/App'} requestBody: @@ -243,7 +802,7 @@ paths: put: tags: [RBAC] summary: Grant permissions to role / 设置角色权限 - description: Replace role permissions; cross-tenant and cross-application grants are rejected. / 替换角色权限,拒绝跨租户和跨应用授权。 + description: Replace role permissions; cross-customer and cross-application grants are rejected. / 替换角色权限,拒绝跨客户和跨应用授权。 parameters: - {$ref: '#/components/parameters/App'} requestBody: @@ -255,6 +814,220 @@ paths: '200': {$ref: '#/components/responses/Success'} '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + /api/{app}/v1/iam/permissions/{permissionId}: + parameters: + - {$ref: '#/components/parameters/App'} + - {$ref: '#/components/parameters/PermissionId'} + put: + tags: [RBAC] + summary: 编辑自定义权限资源 + description: 权限编码和类型不可变更,系统预置资源不可修改。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UpdatePermissionRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + delete: + tags: [RBAC] + summary: 删除未绑定的自定义权限资源 + description: 系统预置资源、存在子资源或已绑定角色的资源不可删除。 + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /api/{app}/v1/iam/permissions/{permissionId}/status: + parameters: + - {$ref: '#/components/parameters/App'} + - {$ref: '#/components/parameters/PermissionId'} + put: + tags: [RBAC] + summary: 启停自定义权限资源 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ChangePermissionStatusRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /internal/iam/merchant-registrations/{authUserId}: + get: + tags: [IAM Resources / IAM 资源] + summary: 查询已创建的企业注册状态 + description: 仅供 Auth Service 对相同账号、手机号和密码的注册重试进行幂等确认,不创建或修改企业资料。 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + - {name: authUserId, in: path, required: true, schema: {type: integer, format: int64}} + responses: + '200': + description: 注册状态查询成功 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: object + required: [tenantId, membershipId, tenantStatus, dimensionInitializationStatus] + properties: + tenantId: {type: integer, format: int64} + membershipId: {type: integer, format: int64} + tenantStatus: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]} + dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED]} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/iam/qualifications: + post: + tags: [IAM Resources / IAM 资源] + summary: 登记客户单个选填资质 + description: 仅供 File Service 调用;原始文件名必须由上传请求传入,不得从存储 URL 推断。 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/QualificationSubmissionRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /internal/iam/platform-qualifications: + post: + tags: [IAM Resources / IAM 资源] + summary: 平台代客户登记单个选填资质 + description: 仅供 File Service 调用;原始文件名必须由上传请求传入,不得从存储 URL 推断。 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/QualificationSubmissionRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /internal/iam/required-qualification-packages: + post: + tags: [IAM Resources / IAM 资源] + summary: 商户营业执照资质包登记 + description: 仅供 File Service 调用;在一个 IAM 事务中登记必填营业执照与统一社会信用代码,法人身份证可选。 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/RequiredQualificationPackageRequest'} + responses: + '200': + description: 统一响应;仅 data 非空且 code 为 200 表示资质包已完整登记 + content: + application/json: + schema: {$ref: '#/components/schemas/RequiredQualificationPackageApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + /internal/iam/platform-required-qualification-packages: + post: + tags: [IAM Resources / IAM 资源] + summary: 平台代客户登记营业执照资质包 + description: 仅供 File Service 调用;与商户入口使用相同规则,营业执照与统一社会信用代码必填,法人身份证可选。 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/RequiredQualificationPackageRequest'} + responses: + '200': + description: 统一响应;仅 data 非空且 code 为 200 表示资质包已完整登记 + content: + application/json: + schema: {$ref: '#/components/schemas/RequiredQualificationPackageApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + /internal/iam/finance-customers: + get: + tags: [IAM Resources / IAM 资源] + summary: 提供财务客户主数据摘要 + description: 仅供 Finance Service 调用;只返回正常/停用客户及财务账户所需的客户编号、名称和联系方式,不依赖财务表是否已有记录。 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + - {name: keyword, in: query, required: false, schema: {type: string}} + - {name: status, in: query, required: false, schema: {type: string, enum: [ACTIVE, DISABLED]}} + - {name: customerId, in: query, required: false, schema: {type: integer, format: int64, minimum: 1}} + responses: + '200': + description: 财务客户主数据分页摘要 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/FinanceCustomerSummary'}} + page: {type: integer} + size: {type: integer} + total: {type: integer, format: int64} + pages: {type: integer} + '403': {$ref: '#/components/responses/Forbidden'} + /internal/exports/v1/CUSTOMER_LIST/batches: + post: + tags: [Internal Export Provider] + summary: 分批提供企业客户导出数据 + description: 仅供 File Service 调用;按平台访问域、签名最大 ID 快照和游标只读查询商户客户。 + security: [] + parameters: + - name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit] + properties: + taskId: {type: string, pattern: '^\d+$'} + tenantId: {type: string, pattern: '^\d+$'} + appCode: {type: string, enum: [PLATFORM]} + requestedBy: {type: string, pattern: '^\d+$'} + dispatchVersion: {type: integer, minimum: 1} + parameters: {type: object} + cursor: {type: string, nullable: true} + snapshotToken: {type: string, nullable: true, maxLength: 512} + limit: {type: integer, minimum: 1, maximum: 500} + responses: + '200': + description: 统一响应;data 为 ExportBatchResponse,Long 值以字符串传输 + content: + application/json: + schema: {$ref: '#/components/schemas/ApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} components: securitySchemes: bearerAuth: @@ -262,6 +1035,11 @@ components: scheme: bearer bearerFormat: JWT parameters: + InternalServiceToken: + name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} Id: name: id in: path @@ -276,14 +1054,65 @@ components: description: Application domain / 应用域 schema: {type: string, enum: [platform, merchant]} example: platform + MembershipId: + name: membershipId + in: path + required: true + description: 平台成员关系 ID + schema: {type: integer, format: int64} + TenantId: + name: tenantId + in: path + required: true + description: 企业客户 ID;tenantId 为历史兼容参数名 + schema: {type: string, pattern: '^\d+$'} + PermissionId: + name: permissionId + in: path + required: true + description: 权限资源 ID + schema: {type: integer, format: int64} responses: Success: {description: Operation succeeded / 操作成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + TenantSuccess: {description: Enterprise tenant returned / 返回企业客户档案, content: {application/json: {schema: {$ref: '#/components/schemas/TenantApiResponse'}}}} + 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 or tenant 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'}}}} + UnprocessableEntity: {description: 参数校验失败, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} schemas: + PersonalProfileResponse: + type: object + required: [userId, authUserId, username, displayName, phone] + properties: + userId: {type: string, pattern: '^\d+$'} + authUserId: {type: string, pattern: '^\d+$'} + username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'} + displayName: {type: string, maxLength: 64} + phone: {type: string, pattern: '^1[3-9]\d{9}$'} + email: {type: string, format: email, nullable: true} + UpdatePersonalProfileRequest: + type: object + required: [displayName] + properties: + displayName: {type: string, minLength: 1, maxLength: 64} + email: {type: string, format: email, maxLength: 128, nullable: true} + ChangeLoginIdentifiersRequest: + type: object + required: [username, phone, currentPassword] + properties: + username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'} + phone: {type: string, pattern: '^1[3-9]\d{9}$'} + currentPassword: {type: string, format: password, writeOnly: true} + PersonalProfileApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/PersonalProfileResponse'} ApiResponse: type: object required: [code, message, timestamp] @@ -294,50 +1123,546 @@ components: requestId: {type: string} traceId: {type: string} timestamp: {type: string, format: date-time} + PermissionCodesApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: array + uniqueItems: true + items: {type: string} + MerchantMemberResponse: + type: object + required: [membershipId, userId, authUserId, username, displayName, memberType, status, lastLoginAt, roleIds] + properties: + membershipId: {type: string, pattern: '^\d+$'} + userId: {type: string, pattern: '^\d+$'} + authUserId: {type: string, pattern: '^\d+$'} + username: {type: string, description: 统一认证登录账号} + displayName: {type: string} + phone: {type: string, nullable: true} + email: {type: string, format: email, nullable: true} + memberType: {type: string, enum: [OWNER, EMPLOYEE]} + memberNo: {type: string, nullable: true, description: 企业内部成员编号,不作为登录账号} + status: {type: string, enum: [ACTIVE, DISABLED]} + lastLoginAt: {type: string, format: date-time, nullable: true, description: 从 Auth 账号事实返回;null 表示从未登录} + roleIds: {type: array, items: {type: string, pattern: '^\d+$'}} + MerchantMemberListApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {type: array, items: {$ref: '#/components/schemas/MerchantMemberResponse'}} + MerchantMemberApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/MerchantMemberResponse'} + RoleResponse: + type: object + required: [id, tenantId, appCode, roleCode, roleName, roleType, status, memberCount, permissionIds] + properties: + id: {type: string, pattern: '^\d+$'} + tenantId: {type: string, pattern: '^\d+$'} + appCode: {type: string, enum: [PLATFORM, MERCHANT]} + roleCode: {type: string} + roleName: {type: string} + roleType: {type: string, enum: [SYSTEM, CUSTOM]} + status: {type: string, enum: [ACTIVE, DISABLED]} + description: {type: string, nullable: true} + memberCount: {type: integer, format: int64, minimum: 0, description: 当前客户及应用域内分配该角色的成员数} + permissionIds: {type: array, uniqueItems: true, items: {type: string, pattern: '^\d+$'}} + RoleListApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {type: array, items: {$ref: '#/components/schemas/RoleResponse'}} + RoleApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/RoleResponse'} + TenantPageApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/TenantResponse'}} + 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} + TenantApprovalApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/TenantApprovalResponse'} + PendingEnterpriseReviewListApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: array + items: {$ref: '#/components/schemas/PendingEnterpriseReviewResponse'} + PendingEnterpriseReviewResponse: + type: object + required: [id, tenantId, reviewType, status, tenantName, tenantCode, contactName, submittedAt] + properties: + id: {type: string, pattern: '^\d+$', description: 审核单 ID} + tenantId: {type: string, pattern: '^\d+$', description: 客户 ID} + reviewType: {type: string, example: REGISTER} + status: {type: string, enum: [PENDING]} + tenantName: {type: string, description: 客户名称} + tenantCode: {type: string, pattern: '^\d{5}$', example: '00001', description: 五位客户编号} + contactName: {type: string, description: 联系人} + contactPhone: {type: string, nullable: true, description: 联系电话} + contactEmail: {type: string, format: email, nullable: true, description: 联系邮箱} + submittedAt: {type: string, format: date-time, description: 提交审核时间} + reviewComment: {type: string, nullable: true, description: 审核意见} + TenantCreationApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/TenantCreationResponse'} + TenantCreationResponse: + type: object + required: [tenant, administratorUsername, temporaryPassword] + properties: + tenant: {$ref: '#/components/schemas/TenantResponse'} + administratorUsername: {type: string, example: '13800138000'} + temporaryPassword: {type: string, format: password, description: 一次性初始密码,仅本次创建响应返回} + TenantApprovalResponse: + type: object + required: [tenantId, administratorCreated] + properties: + tenantId: {type: string, pattern: '^\d+$', example: '190000000000000001'} + administratorCreated: {type: boolean, description: 本次审核是否新建企业管理员} + administratorUsername: {type: string, nullable: true, example: '13800138000'} + temporaryPassword: {type: string, format: password, nullable: true, description: 一次性初始密码,仅新建管理员时返回} + TenantApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/TenantResponse'} + InternalUserListApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {type: array, items: {$ref: '#/components/schemas/InternalUserResponse'}} + InternalUserApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/InternalUserResponse'} + PlatformCustomerMemberResponse: + type: object + required: [membershipId, userId, authUserId, username, displayName, memberType, status, roleIds, roleNames] + properties: + membershipId: {type: string, pattern: '^\d+$'} + userId: {type: string, pattern: '^\d+$'} + authUserId: {type: string, pattern: '^\d+$'} + username: {type: string} + displayName: {type: string} + phone: {type: string} + email: {type: string, format: email} + memberType: {type: string, enum: [OWNER, EMPLOYEE]} + memberNo: {type: string} + status: {type: string, enum: [ACTIVE, DISABLED]} + roleIds: {type: array, items: {type: string, pattern: '^\d+$'}} + roleNames: {type: array, items: {type: string}} + PlatformCustomerMembersApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {type: array, items: {$ref: '#/components/schemas/PlatformCustomerMemberResponse'}} + PlatformManagedUserPageApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/PlatformManagedUserResponse'}} + page: {type: integer, minimum: 1} + size: {type: integer, minimum: 1} + total: {type: integer, minimum: 0} + pages: {type: integer, minimum: 0} + TenantResponse: + type: object + required: [id, tenantCode, tenantName, customerNickname, status, dimensionInitializationStatus, servicePackage] + properties: + id: {type: string, pattern: '^\d+$', example: '190000000000000001'} + tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'} + tenantName: {type: string} + customerNickname: {type: string, maxLength: 32, description: 客户昵称} + 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, nullable: true} + unifiedSocialCreditCode: {type: string} + contactName: {type: string} + contactPhone: {type: string} + contactEmail: {type: string, format: email} + contactWechat: {type: string, maxLength: 64} + servicePackage: {type: string, maxLength: 64} + salesOwnerName: {type: string, maxLength: 64} + paymentTermDays: {type: integer, minimum: 0, maximum: 365} + enterpriseType: {type: string, maxLength: 64} + establishedDate: {type: string, format: date} + registeredCapital: {type: number, format: decimal, minimum: 0} + registeredCapitalCurrency: {type: string, pattern: '^[A-Z]{3}$', example: CNY} + registeredAddress: {type: string, maxLength: 500} + 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] + properties: + id: {type: string, pattern: '^\d+$'} + tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'} + tenantName: {type: string} + status: {type: string, enum: [ACTIVE, DISABLED]} + contactName: {type: string, nullable: true} + contactPhone: {type: string, nullable: true} + TenantAddressListApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: array + items: {$ref: '#/components/schemas/TenantAddress'} + TenantQualificationListApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: array + items: {$ref: '#/components/schemas/TenantQualification'} + TenantQualificationApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/TenantQualification'} + QualificationDeletionApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: object + required: [qualificationId] + properties: + qualificationId: {type: string, pattern: '^\d+$'} + deletedDocumentUrl: {type: string, nullable: true, description: 首次删除时返回,供 File Service 回收文件;重复删除为空} + TenantAddress: + type: object + properties: + id: {type: string, pattern: '^\d+$'} + tenantId: {type: string, pattern: '^\d+$'} + addressType: {type: string} + countryCode: {type: string} + province: {type: string} + city: {type: string} + district: {type: string} + detail: {type: string} + postalCode: {type: string} + isDefault: {type: boolean} + TenantQualification: + type: object + required: [id, tenantId, qualificationType, documentUrl, originalFileName, status, createdAt, updatedAt] + properties: + id: {type: string, pattern: '^\d+$'} + tenantId: {type: string, pattern: '^\d+$'} + qualificationType: {type: string, enum: [BUSINESS_LICENSE, LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]} + certificateNo: {type: string} + documentUrl: {type: string} + originalFileName: {type: string, description: 上传时原始文件名;前端不得从 URL 或内部 ID 推断} + validFrom: {type: string, format: date} + validTo: {type: string, format: date, nullable: true} + status: {type: string, enum: [PENDING, APPROVED, REJECTED, SUPERSEDED]} + reviewComment: {type: string} + createdAt: {type: string, format: date-time} + updatedAt: {type: string, format: date-time} + QualificationSubmissionRequest: + type: object + required: [tenantId, qualificationType, documentUrl, originalFileName, replace] + properties: + tenantId: {type: string, pattern: '^\d+$'} + qualificationType: {type: string, enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]} + certificateNo: {type: string, nullable: true} + documentUrl: {type: string, minLength: 1} + originalFileName: {type: string, minLength: 1, maxLength: 255} + validTo: {type: string, format: date, nullable: true} + replace: {type: boolean} + RequiredQualificationPackageRequest: + type: object + required: [tenantId, unifiedSocialCreditCode, businessLicenseDocumentUrl, businessLicenseOriginalFileName, replaceBusinessLicense] + properties: + tenantId: {type: string, pattern: '^\d+$'} + unifiedSocialCreditCode: {type: string, pattern: '^[0-9A-Za-z]{18}$'} + businessLicenseDocumentUrl: {type: string, minLength: 1} + businessLicenseOriginalFileName: {type: string, minLength: 1, maxLength: 255} + businessLicenseValidTo: {type: string, format: date, nullable: true} + replaceBusinessLicense: {type: boolean} + legalPersonIdDocumentUrl: {type: string, minLength: 1, nullable: true, description: 法人身份证选填} + legalPersonIdOriginalFileName: {type: string, maxLength: 255, nullable: true, description: 上传法人身份证时必传} + legalPersonIdValidTo: {type: string, format: date, nullable: true} + replaceLegalPersonId: {type: boolean, nullable: true, default: false} + RequiredQualificationPackageResponse: + type: object + required: [businessLicenseQualificationId, tenantStatus] + properties: + businessLicenseQualificationId: {type: string, pattern: '^\d+$'} + legalPersonIdQualificationId: {type: string, pattern: '^\d+$', nullable: true} + tenantStatus: {type: string, enum: [PROFILE_REQUIRED, REJECTED]} + replacedBusinessLicenseDocumentUrl: {type: string, nullable: true} + replacedLegalPersonIdDocumentUrl: {type: string, nullable: true} + RequiredQualificationPackageApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/RequiredQualificationPackageResponse'} RegisterRequest: type: object - required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone] + required: [tenantName, legalName, contactName, contactPhone, servicePackage] properties: tenantName: {type: string, example: 深圳虫虫出海科技有限公司} - legalName: {type: string, example: 张三} - unifiedSocialCreditCode: {type: string, pattern: '^[0-9A-Z]{18}$', example: 91440300MA5ABCDE12} + customerNickname: {type: string, maxLength: 32, example: SZWL, description: 选填;不填时默认取公司名称各中文字符的拼音大写首字母,没有中文时取首个非空字符并转大写} + legalName: {type: string, example: 深圳虫虫出海科技有限公司} + legalRepresentative: {type: string, maxLength: 64, nullable: true, example: 张三, description: 选填} contactName: {type: string, example: 李四} - contactPhone: {type: string, example: '13800138000'} + contactPhone: {type: string, pattern: '^1[3-9]\d{9}$', example: '13800138000', description: 创建时同时初始化公司联系电话与首个管理员登录手机号;创建后二者独立维护} contactEmail: {type: string, format: email, example: contact@example.com} + contactWechat: {type: string, maxLength: 64} + servicePackage: {type: string, maxLength: 64, example: 基础版} + salesOwnerName: {type: string, maxLength: 64} + paymentTermDays: {type: integer, minimum: 0, maximum: 365} + enterpriseType: {type: string, maxLength: 64} + establishedDate: {type: string, format: date} + registeredCapital: {type: number, format: decimal, minimum: 0} + registeredCapitalCurrency: {type: string, pattern: '^[A-Z]{3}$', example: CNY} + registeredAddress: {type: string, maxLength: 500} + internalRemark: {type: string, maxLength: 1000} UpdateProfileRequest: type: object required: [tenantName, legalName, contactName, contactPhone] properties: tenantName: {type: string} legalName: {type: string} + legalRepresentative: {type: string, maxLength: 64, nullable: true, description: 选填} contactName: {type: string} - contactPhone: {type: string} + contactPhone: {type: string, pattern: '^[0-9-]{4,32}$', description: 公司联系电话,不作为登录标识且不要求跨企业唯一} contactEmail: {type: string, format: email} + UpdateTenantRequest: + type: object + required: [tenantName, customerNickname, legalName, contactName, contactPhone] + properties: + tenantName: {type: string} + customerNickname: {type: string, maxLength: 32, description: 客户昵称} + legalName: {type: string} + legalRepresentative: {type: string, maxLength: 64, nullable: true, description: 选填} + contactName: {type: string} + contactPhone: {type: string, pattern: '^[0-9-]{4,32}$', description: 公司联系电话,与管理员及成员登录手机号独立且不要求跨企业唯一} + contactEmail: {type: string, format: email} + contactWechat: {type: string, maxLength: 64} + servicePackage: {type: string, maxLength: 64, example: 基础版} + salesOwnerName: {type: string, maxLength: 64} + paymentTermDays: {type: integer, minimum: 0, maximum: 365} + enterpriseType: {type: string, maxLength: 64} + establishedDate: {type: string, format: date} + registeredCapital: {type: number, format: decimal, minimum: 0} + registeredCapitalCurrency: {type: string, pattern: '^[A-Z]{3}$'} + registeredAddress: {type: string, maxLength: 500} + internalRemark: {type: string, maxLength: 1000} + ChangeTenantStatusRequest: + type: object + required: [status] + properties: + status: {type: string, enum: [ACTIVE, DISABLED]} + ResetCustomerOwnerPasswordRequest: + type: object + required: [newPassword] + properties: + newPassword: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} + forceChangePassword: {type: boolean, default: true} + CustomerImpersonationRequest: + type: object + required: [reason] + properties: + reason: {type: string, minLength: 5, maxLength: 500} + CustomerImpersonationApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: object + required: [code, ticketId, expiresIn] + properties: + code: {type: string, writeOnly: true} + ticketId: {type: string, format: uuid} + expiresIn: {type: integer, example: 60} ReviewRequest: type: object properties: - comment: {type: string, description: Review comment or rejection reason / 审核意见或驳回原因} - CreateIamUserRequest: + comment: {type: string, maxLength: 500, description: Review comment or rejection reason / 审核意见或驳回原因} + CreatePlatformManagedUserRequest: type: object - required: [authUserId, userType, displayName] + required: [username, displayName, phone, tenantId, roleIds, password, status] properties: + username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'} + displayName: {type: string, maxLength: 64} + phone: {type: string, pattern: '^1[3-9]\\d{9}$', description: 平台账号登录手机号;修改时同步 Auth、撤销该账号会话并审计} + 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: 选择 OVERSEAS_WAREHOUSE_PDA_OPERATOR 时必填且只能绑定一个实际海外仓;CN_FIRSTLEG_PDA_OPERATOR 与其他角色必须为空;两个 PDA 角色不能同时分配} + password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} + status: {type: string, enum: [ACTIVE, DISABLED]} + UpdatePlatformManagedUserRequest: + type: object + required: [displayName, phone, tenantId, roleIds] + properties: + displayName: {type: string, maxLength: 64} + phone: {type: string, pattern: '^1[3-9]\\d{9}$', description: 平台账号登录手机号;与任何企业联系电话无关} + 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: 选择 OVERSEAS_WAREHOUSE_PDA_OPERATOR 时必填且只能绑定一个实际海外仓;CN_FIRSTLEG_PDA_OPERATOR 与其他角色必须为空;两个 PDA 角色不能同时分配} + CreateInternalUserRequest: + type: object + required: [displayName] + properties: + authUserId: {type: integer, format: int64, description: 兼容旧接口;填写后关联已有认证账号} + userType: {type: string, enum: [INTERNAL, PLATFORM], description: 兼容旧接口,可省略} + username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'} + password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} + displayName: {type: string, maxLength: 64} + phone: {type: string, pattern: '^1[3-9]\d{9}$', description: 企业成员登录手机号;修改时同步 Auth、撤销该账号会话并审计} + email: {type: string, format: email, maxLength: 128} + memberNo: {type: string, maxLength: 64} + roleIds: {type: array, items: {type: integer, format: int64}} + UpdateInternalUserRequest: + type: object + required: [displayName, memberNo, roleIds] + properties: + displayName: {type: string, maxLength: 64} + phone: {type: string, pattern: '^1[3-9]\d{9}$', description: 企业成员登录手机号;与公司联系电话无关} + email: {type: string, format: email, maxLength: 128} + memberNo: {type: string, maxLength: 64} + roleIds: {type: array, items: {type: integer, format: int64}} + ChangeInternalUserStatusRequest: + type: object + required: [status] + properties: + status: {type: string, enum: [ACTIVE, DISABLED]} + ResetInternalUserPasswordRequest: + type: object + required: [newPassword] + properties: + newPassword: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} + AssignInternalUserRolesRequest: + type: object + required: [roleIds] + properties: + roleIds: {type: array, items: {type: integer, format: int64}} + InternalUserResponse: + type: object + required: [membershipId, userId, authUserId, username, displayName, memberType, status, roleIds] + properties: + membershipId: {type: integer, format: int64} + userId: {type: integer, format: int64} authUserId: {type: integer, format: int64} - userType: {type: string, enum: [PLATFORM, ENTERPRISE]} + username: {type: string} displayName: {type: string} phone: {type: string} email: {type: string, format: email} + memberType: {type: string, enum: [EMPLOYEE]} + memberNo: {type: string} + status: {type: string, enum: [ACTIVE, DISABLED]} + roleIds: {type: array, items: {type: integer, format: int64}} + PlatformManagedUserResponse: + type: object + required: [membershipId, userId, authUserId, username, displayName, tenantId, tenantName, memberType, status, roleIds, roleNames, protectedAccount] + properties: + membershipId: {type: string, pattern: '^\d+$'} + userId: {type: string, pattern: '^\d+$'} + authUserId: {type: string, pattern: '^\d+$'} + username: {type: string} + displayName: {type: string} + phone: {type: string} + email: {type: string, format: email} + tenantId: {type: string, enum: ['0']} + tenantName: {type: string, enum: [平台]} + memberType: {type: string, enum: [EMPLOYEE]} + memberNo: {type: string} + status: {type: string, enum: [ACTIVE, DISABLED]} + roleIds: {type: array, items: {type: string, pattern: '^\d+$'}} + 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] properties: - username: {type: string, minLength: 4, maxLength: 64} + username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'} password: {type: string, format: password, minLength: 8, maxLength: 64} displayName: {type: string, maxLength: 64} - phone: {type: string, maxLength: 32} + phone: {type: string, pattern: '^1[3-9]\d{9}$'} email: {type: string, format: email, maxLength: 128} - memberType: {type: string, enum: [OWNER, ADMIN, EMPLOYEE]} + memberType: {type: string, enum: [EMPLOYEE]} memberNo: {type: string} - roleIds: {type: array, items: {type: integer, format: int64}} + roleIds: + type: array + description: 初始角色 ID;非空时额外要求 merchant:member:grant-role + items: {type: string, pattern: '^\d+$'} + UpdateMemberRequest: + type: object + required: [displayName, memberType] + properties: + displayName: {type: string, maxLength: 64} + phone: {type: string, pattern: '^1[3-9]\d{9}$'} + email: {type: string, format: email, maxLength: 128} + memberType: {type: string, enum: [EMPLOYEE]} + memberNo: {type: string, maxLength: 64} + roleIds: + type: array + description: 完整目标角色集合;空数组表示清空,集合变化时额外要求 merchant:member:grant-role + items: {type: string, pattern: '^\d+$'} AddressRequest: type: object required: [addressType, detail] @@ -350,15 +1675,6 @@ components: detail: {type: string} postalCode: {type: string} isDefault: {type: boolean} - QualificationRequest: - type: object - required: [qualificationType, documentUrl] - properties: - qualificationType: {type: string, example: BUSINESS_LICENSE} - certificateNo: {type: string} - documentUrl: {type: string, format: uri} - validFrom: {type: string, format: date} - validTo: {type: string, format: date} CreateRoleRequest: type: object required: [roleCode, roleName] @@ -377,6 +1693,20 @@ components: routePath: {type: string} httpMethod: {type: string, enum: [GET, POST, PUT, PATCH, DELETE]} sortNo: {type: integer, example: 10} + UpdatePermissionRequest: + type: object + required: [name] + properties: + name: {type: string} + parentId: {type: integer, format: int64} + routePath: {type: string} + httpMethod: {type: string, enum: [GET, POST, PUT, PATCH, DELETE]} + sortNo: {type: integer} + ChangePermissionStatusRequest: + type: object + required: [status] + properties: + status: {type: string, enum: [ACTIVE, DISABLED]} AssignRolesRequest: type: object required: [membershipId, roleIds] diff --git a/iam/src/main/resources/openapi/notification-v1.yaml b/iam/src/main/resources/openapi/notification-v1.yaml new file mode 100644 index 000000000..941f3afc8 --- /dev/null +++ b/iam/src/main/resources/openapi/notification-v1.yaml @@ -0,0 +1,198 @@ +openapi: 3.0.3 +info: + title: IAM Notification Center API + version: 1.0.0 + description: First release supports IN_APP only and does not support deleting or clearing read messages. +servers: [{url: /api}] +tags: + - {name: Notification inbox} + - {name: Platform notification administration} +paths: + /{app}/v1/notifications: + get: + tags: [Notification inbox] + summary: List current user's inbox + parameters: + - {$ref: '#/components/parameters/AppPath'} + - {$ref: '#/components/parameters/Page'} + - {$ref: '#/components/parameters/Size'} + - {name: status, in: query, schema: {type: string, enum: [UNREAD, READ]}} + - {name: category, in: query, schema: {type: string, maxLength: 64}} + responses: {'200': {$ref: '#/components/responses/InboxPage'}} + /{app}/v1/notifications/unread-count: + get: + tags: [Notification inbox] + summary: Count current user's unread messages + parameters: [{$ref: '#/components/parameters/AppPath'}] + responses: {'200': {$ref: '#/components/responses/UnreadCount'}} + /{app}/v1/notifications/{id}: + get: + tags: [Notification inbox] + summary: Get one inbox message + parameters: [{$ref: '#/components/parameters/AppPath'}, {$ref: '#/components/parameters/IdPath'}] + responses: {'200': {$ref: '#/components/responses/InboxDetail'}} + /{app}/v1/notifications/{id}/read: + put: + tags: [Notification inbox] + summary: Mark one message as read (idempotent) + description: Requires {app}:notification:read. + parameters: [{$ref: '#/components/parameters/AppPath'}, {$ref: '#/components/parameters/IdPath'}] + responses: {'200': {$ref: '#/components/responses/InboxDetail'}} + /{app}/v1/notifications/read-all: + put: + tags: [Notification inbox] + summary: Mark all current-user messages as read (idempotent) + description: Requires {app}:notification:read-all. + parameters: [{$ref: '#/components/parameters/AppPath'}] + responses: {'200': {$ref: '#/components/responses/ReadAll'}} + /platform/v1/notifications: + post: + tags: [Platform notification administration] + summary: Create an IN_APP notification task + requestBody: {$ref: '#/components/requestBodies/CreateNotification'} + responses: {'200': {$ref: '#/components/responses/TaskItem'}} + /platform/v1/notification-templates: + get: + tags: [Platform notification administration] + summary: List templates + parameters: + - {$ref: '#/components/parameters/Page'} + - {$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: status, in: query, schema: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}} + responses: {'200': {$ref: '#/components/responses/TemplatePage'}} + post: + tags: [Platform notification administration] + summary: Create a DRAFT template + requestBody: {$ref: '#/components/requestBodies/CreateTemplate'} + responses: {'200': {$ref: '#/components/responses/TemplateDetail'}} + /platform/v1/notification-templates/{id}: + put: + tags: [Platform notification administration] + summary: Edit a DRAFT template only + parameters: [{$ref: '#/components/parameters/IdPath'}] + requestBody: {$ref: '#/components/requestBodies/UpdateTemplate'} + responses: {'200': {$ref: '#/components/responses/TemplateDetail'}} + /platform/v1/notification-templates/{id}/status: + put: + tags: [Platform notification administration] + summary: Change template status + description: Only DRAFT to ACTIVE and ACTIVE to DISABLED are valid. DISABLED cannot return to ACTIVE; copy it to a new DRAFT instead. + parameters: [{$ref: '#/components/parameters/IdPath'}] + requestBody: {$ref: '#/components/requestBodies/ChangeTemplateStatus'} + responses: {'200': {$ref: '#/components/responses/TemplateDetail'}} + /platform/v1/notification-tasks: + get: + tags: [Platform notification administration] + summary: List notification tasks + parameters: + - {$ref: '#/components/parameters/Page'} + - {$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: 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}: + get: + tags: [Platform notification administration] + summary: Get notification task delivery details + description: Uses the task list permission and exposes bounded operational evidence without message body content. + parameters: [{$ref: '#/components/parameters/IdPath'}] + responses: {'200': {$ref: '#/components/responses/TaskDetail'}} + /platform/v1/notification-tasks/{id}/cancel: + put: + tags: [Platform notification administration] + summary: Cancel a CREATED or SCHEDULED task + parameters: [{$ref: '#/components/parameters/IdPath'}] + responses: {'200': {$ref: '#/components/responses/TaskItem'}} + /platform/v1/notification-tasks/{id}/retry: + put: + tags: [Platform notification administration] + summary: Requeue a RETRYING or DEAD task + description: Resets the current delivery-attempt counter and returns the task in RETRYING; scheduler delivery starts after the retry delay. + parameters: [{$ref: '#/components/parameters/IdPath'}] + responses: {'200': {$ref: '#/components/responses/TaskItem'}} +components: + parameters: + AppPath: {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}} + IdPath: {name: id, in: path, required: true, schema: {type: integer, format: int64}} + 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}} + requestBodies: + CreateNotification: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateNotificationRequest'}}}} + CreateTemplate: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateTemplateRequest'}}}} + UpdateTemplate: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/UpdateTemplateRequest'}}}} + ChangeTemplateStatus: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ChangeTemplateStatusRequest'}}}} + responses: + InboxPage: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/InboxPageResponse'}}}} + InboxDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/InboxDetailResponse'}}}} + UnreadCount: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/UnreadCountResponse'}}}} + ReadAll: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/ReadAllResponse'}}}} + TemplatePage: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplatePageResponse'}}}} + TemplateDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplateDetailResponse'}}}} + TaskPage: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskPageResponse'}}}} + TaskItem: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskItemResponse'}}}} + TaskDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskDetailResponse'}}}} + schemas: + CreateNotificationRequest: + type: object + required: [tenantId, templateCode, recipientType, variables, idempotencyKey] + description: tenantId is a backend compatibility field. 0 means platform scope and a positive value is the customer (enterprise) ID; UI copy must use customer or enterprise, never tenant. + properties: + 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, minItems: 1, maxItems: 1000, items: {type: integer, format: int64}, description: "Required for USER or MEMBERSHIP; forbidden for role/all recipient types. For a specified enterprise member, pass the user's ID and use USER; membership IDs are reserved for explicit membership-level calls."} + 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} + 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] + properties: {templateCode: {type: string, pattern: '^[A-Z][A-Z0-9_]{0,63}$'}, templateName: {type: string, maxLength: 128}, category: {type: string, maxLength: 64}, titleTemplate: {type: string, maxLength: 256}, bodyTemplate: {type: string, maxLength: 10000}, variableWhitelist: {type: array, maxItems: 32, uniqueItems: true, items: {type: string, pattern: '^[a-z][a-z0-9_]{0,31}$'}}} + UpdateTemplateRequest: + type: object + required: [templateName, category, titleTemplate, bodyTemplate, variableWhitelist, version] + properties: {templateName: {type: string, maxLength: 128}, category: {type: string, maxLength: 64}, titleTemplate: {type: string, maxLength: 256}, bodyTemplate: {type: string, maxLength: 10000}, variableWhitelist: {type: array, maxItems: 32, uniqueItems: true, items: {type: string, pattern: '^[a-z][a-z0-9_]{0,31}$'}}, version: {type: integer, minimum: 1}} + ChangeTemplateStatusRequest: + type: object + required: [targetStatus, version] + properties: {targetStatus: {type: string, enum: [ACTIVE, DISABLED], description: ACTIVE is accepted only from DRAFT; DISABLED only from ACTIVE.}, version: {type: integer, minimum: 1}} + InboxItem: {type: object, required: [id, category, title, summary, status, sentAt], properties: {id: {type: integer, format: int64}, category: {type: string}, title: {type: string}, summary: {type: string, maxLength: 120, description: Whitespace-compacted body preview limited to 120 Unicode code points; never contains the full body unless the body itself fits.}, status: {type: string, enum: [UNREAD, READ]}, sentAt: {type: string, format: date-time}, readAt: {type: string, format: date-time, nullable: true}}} + InboxDetail: {allOf: [{$ref: '#/components/schemas/InboxItem'}, {type: object, required: [body], properties: {body: {type: string}, sourceType: {type: string, nullable: true}, sourceId: {type: string, nullable: true}}}]} + TemplateItem: {type: object, required: [id, templateCode, templateName, category, channel, titleTemplate, bodyTemplate, variableWhitelist, status, version], properties: {id: {type: integer, format: int64}, templateCode: {type: string}, templateName: {type: string}, category: {type: string}, channel: {type: string, enum: [IN_APP]}, titleTemplate: {type: string}, bodyTemplate: {type: string}, variableWhitelist: {type: array, items: {type: string}}, status: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}, version: {type: integer}}} + TaskItem: {type: object, required: [id, taskNo, category, title, status, recipientCount, duplicated], properties: {id: {type: integer, format: int64}, taskNo: {type: string}, category: {type: string}, title: {type: string}, status: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}, recipientCount: {type: integer}, scheduledAt: {type: string, format: date-time, nullable: true}, sentAt: {type: string, format: date-time, nullable: true}, duplicated: {type: boolean}}} + TaskRecipientItem: {type: object, required: [id, displayName, appCode, status], properties: {id: {type: integer, format: int64}, displayName: {type: string}, appCode: {type: string}, status: {type: string}, failureReason: {type: string, nullable: true}, deliveredAt: {type: string, format: date-time, nullable: true}}} + TaskDetail: {type: object, required: [id, taskNo, category, title, status, recipientCount, attemptCount, recipients], properties: {id: {type: integer, format: int64}, taskNo: {type: string}, category: {type: string}, title: {type: string}, status: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}, recipientCount: {type: integer}, attemptCount: {type: integer, minimum: 0, maximum: 3, description: Number of started delivery attempts in the current automatic retry cycle.}, scheduledAt: {type: string, format: date-time, nullable: true}, sentAt: {type: string, format: date-time, nullable: true}, lastError: {type: string, nullable: true}, recipients: {type: array, items: {$ref: '#/components/schemas/TaskRecipientItem'}}}} + InboxPageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/InboxItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}} + TemplatePageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/TemplateItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}} + TaskPageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/TaskItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}} + InboxPageResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/InboxPageData'}}} + InboxDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/InboxDetail'}}} + UnreadCountResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {type: object, required: [unreadCount], properties: {unreadCount: {type: integer, format: int64}}}}} + ReadAllResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {type: object, required: [updatedCount], properties: {updatedCount: {type: integer, format: int64}}}}} + TemplatePageResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TemplatePageData'}}} + TemplateDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TemplateItem'}}} + TaskPageResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskPageData'}}} + TaskItemResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskItem'}}} + TaskDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskDetail'}}} diff --git a/iam/src/test/java/com/baiyue/iam/BaiyueIamApplicationContractTest.java b/iam/src/test/java/com/baiyue/iam/BaiyueIamApplicationContractTest.java new file mode 100644 index 000000000..21a2c438c --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/BaiyueIamApplicationContractTest.java @@ -0,0 +1,18 @@ +package com.baiyue.iam; + +import org.apache.ibatis.annotations.Mapper; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.annotation.MapperScan; + +import static org.assertj.core.api.Assertions.assertThat; + +class BaiyueIamApplicationContractTest { + @Test + void scansAnnotatedMappersFromTheStableServiceRoot() { + MapperScan mapperScan = BaiyueIamApplication.class.getAnnotation(MapperScan.class); + + assertThat(mapperScan).isNotNull(); + assertThat(mapperScan.basePackages()).containsExactly("com.baiyue.iam"); + assertThat(mapperScan.annotationClass()).isEqualTo(Mapper.class); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java b/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java new file mode 100644 index 000000000..10db975cc --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java @@ -0,0 +1,30 @@ +package com.baiyue.iam.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerDimensionRuleClientContractTest { + @Test + void usesOneAtomicCallForBoxDimensionsAndWeightCoefficientRule() throws Exception { + FeignClient feign = CustomerDimensionRuleClient.class.getAnnotation(FeignClient.class); + PostMapping initialize = CustomerDimensionRuleClient.class + .getMethod("initialize", String.class, + CustomerDimensionRuleClient.InitializationRequest.class) + .getAnnotation(PostMapping.class); + DeleteMapping compensate = CustomerDimensionRuleClient.class + .getMethod("compensate", String.class, long.class, String.class) + .getAnnotation(DeleteMapping.class); + + assertThat(feign.name()).isEqualTo("firstleg-service"); + assertThat(feign.path()).isEqualTo("/internal/firstleg/v1/customer-dimension-configs"); + assertThat(initialize.value()).containsExactly("/defaults"); + assertThat(compensate.value()).containsExactly("/defaults"); + assertThat(CustomerDimensionRuleClient.InitializationResponse.class.getRecordComponents()) + .extracting(java.lang.reflect.RecordComponent::getName) + .containsExactly("configId", "chargeRuleId", "createdByThisInitialization"); + } +} 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/controller/AuthorizationControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java new file mode 100644 index 000000000..5da6f3e88 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java @@ -0,0 +1,23 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class AuthorizationControllerContractTest { + @Test + void shouldHandleCase01() throws Exception { + Method method = AuthorizationController.class.getMethod("permissions", String.class, + AuthenticatedUser.class); + + assertEquals("/api/{app}/v1/iam/authorization/permissions", + method.getAnnotation(GetMapping.class).value()[0]); + assertNull(method.getAnnotation(PreAuthorize.class)); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/CustomerListExportProviderControllerTest.java b/iam/src/test/java/com/baiyue/iam/controller/CustomerListExportProviderControllerTest.java new file mode 100644 index 000000000..132d87169 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/CustomerListExportProviderControllerTest.java @@ -0,0 +1,62 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.iam.service.CustomerListExportProviderService; +import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +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.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +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.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(CustomerListExportProviderController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=test-internal-service-token-at-least-32-bytes") +class CustomerListExportProviderControllerTest { + @MockBean private CustomerListExportProviderService service; + @Resource private CustomerListExportProviderController controller; + @Test + void exposesTheGenericCustomerListProviderPath() throws Exception { + assertEquals("/internal/exports/v1/CUSTOMER_LIST", + CustomerListExportProviderController.class.getAnnotation(RequestMapping.class).value()[0]); + assertEquals("/batches", CustomerListExportProviderController.class + .getMethod("batch", String.class, ExportBatchRequest.class) + .getAnnotation(PostMapping.class).value()[0]); + } + + @Test + void requiresInternalTokenBeforeCallingProvider() { + ExportBatchRequest request = request(); + + BizException missing = assertThrows(BizException.class, () -> controller.batch(null, request)); + BizException wrong = assertThrows(BizException.class, () -> controller.batch("wrong", request)); + assertEquals(ResultCode.FORBIDDEN, missing.getResultCode()); + assertEquals(ResultCode.FORBIDDEN, wrong.getResultCode()); + + ExportBatchResponse batch = new ExportBatchResponse(1, "sha256:test", "snapshot", + List.of(), List.of(), null, false, 0L); + when(service.exportBatch(request)).thenReturn(batch); + var response = controller.batch("test-internal-service-token-at-least-32-bytes", request); + + assertEquals(ResultCode.SUCCESS.code(), response.code()); + assertEquals(batch, response.data()); + verify(service).exportBatch(request); + } + + private ExportBatchRequest request() { + return new ExportBatchRequest("190000000000000001", "0", "PLATFORM", + "190000000000000009", 1, Map.of(), null, null, 500); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java new file mode 100644 index 000000000..99e75072f --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -0,0 +1,113 @@ +package com.baiyue.iam.controller; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class IamOpenApiContractTest { + @Test + void shouldHandleCase01() throws IOException { + String document; + try (var stream = getClass().getResourceAsStream("/openapi/iam-v1.yaml")) { + assertThat(stream).isNotNull(); + document = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(document).contains("/api/platform/v1/iam/authorization/permissions:") + .contains("/api/merchant/v1/iam/authorization/permissions:") + .contains("platform:overseas-warehouse:dispatch:list") + .contains("platform:overseas-warehouse:dispatch:edit") + .contains("platform:overseas-warehouse:dispatch:cancel") + .contains("platform:overseas-warehouse:dispatch:transfer-delivery") + .contains("platform:overseas-warehouse:outbound:list") + .contains("platform:overseas-warehouse:outbound:create") + .contains("platform:overseas-warehouse:delivery:list") + .contains("platform:overseas-warehouse:delivery:return") + .contains("platform:overseas-warehouse:delivery:status") + .contains("platform:overseas-warehouse:delivery:documents") + .contains("读取和下载仍使用 `platform:overseas-warehouse:delivery:list`") + .contains("platform:overseas-warehouse:exception-order:list") + .contains("platform:overseas-warehouse:exception-order:create") + .contains("platform:overseas-warehouse:exception-order:process") + .contains("platform:overseas-warehouse:customer-visit:list") + .contains("platform:overseas-warehouse:customer-visit:edit") + .contains("platform:overseas-warehouse:outbound:cancel") + .contains("platform:firstleg:container:list/create/edit") + .contains("platform:firstleg:terminal-drayage:list") + .contains("platform:firstleg:terminal-drayage:document:edit") + .contains("platform:firstleg:terminal-drayage:fleet:edit") + .contains("platform:firstleg:terminal-drayage:pickup-appointment:edit") + .contains("platform:firstleg:terminal-drayage:empty-return:confirm") + .contains("不以 `container:list/edit` 兼容放行国外操作") + .contains("merchant:warehouse:delivery:list") + .contains("merchant:warehouse:delivery:create") + .contains("merchant:cross-border:procurement") + .contains("merchant:cross-border:domestic-trucking") + .contains("merchant:cross-border:pallet-shipping") + .contains("merchant:warehouse:inbound:list") + .contains("lastleg:pda:operate") + .contains("OVERSEAS_WAREHOUSE_PDA_OPERATOR") + .contains("merchant:finance:account:view") + .contains("merchant:finance:recharge:create") + .contains("platform:finance:recharge:review") + .contains("platform:finance:customer-account:adjust") + .contains("platform:firstleg:inbound:hold") + .contains("不会由 `platform:firstleg:inbound:list` 查看权限自动获得") + .contains("两者独立授权") + .contains("列表权限包含详情只读") + .contains("PermissionCodesApiResponse:") + .contains("TenantPageApiResponse:") + .contains("TenantApiResponse:") + .contains("PendingEnterpriseReviewListApiResponse:") + .contains("PendingEnterpriseReviewResponse:") + .contains("required: [id, tenantId, reviewType, status, tenantName, tenantCode, contactName, submittedAt]") + .contains("submittedAt: {type: string, format: date-time, description: 提交审核时间}") + .contains("TenantSuccess:") + .contains("salesOwnerName:") + .contains("paymentTermDays:") + .contains("registeredCapitalCurrency:") + .contains("createdAt: {type: string, format: date-time, description: 入驻时间}") + .contains("id: {type: string, pattern: '^\\d+$'") + .contains("/api/platform/v1/iam/tenants/{tenantId}/addresses:") + .contains("/api/platform/v1/iam/tenants/{tenantId}/qualifications:") + .contains("/api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}:") + .contains("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}:") + .contains("originalFileName: {type: string, description: 上传时原始文件名;前端不得从 URL 或内部 ID 推断}") + .contains("updatedAt: {type: string, format: date-time}") + .contains("仅客户状态为 `PROFILE_REQUIRED` 或 `REJECTED` 时允许") + .contains("merchant:qualification:upload") + .contains("platform:customer:list") + .doesNotContain("platform:customer:view") + .contains("TenantAddressListApiResponse:") + .contains("TenantQualificationListApiResponse:") + .contains("InternalUserListApiResponse:") + .contains("MerchantMemberResponse:") + .contains("username: {type: string, description: 统一认证登录账号}") + .contains("lastLoginAt: {type: string, format: date-time, nullable: true") + .contains("RoleResponse:") + .contains("memberCount: {type: integer, format: int64, minimum: 0") + .contains("/api/platform/v1/iam/internal-users/{membershipId}/roles:") + .contains("AssignInternalUserRolesRequest:") + .contains("/api/merchant/v1/iam/members/{membershipId}:") + .contains("仅当 roleIds 与当前角色集合实际不同时额外要求 merchant:member:grant-role") + .contains("传空数组清空角色也属于角色变更") + .contains("roleIds 为服务端实时查询的当前角色集合") + .contains("UpdateMemberRequest:") + .contains("membershipId: {type: integer, format: int64}") + .contains("roleIds: {type: array, items: {type: integer, format: int64}}") + .contains("/api/platform/v1/iam/tenants/administrator-phone-availability:") + .doesNotContain("/api/platform/v1/iam/tenants/contact-phone-availability:") + .contains("公司联系电话,不作为登录标识且不要求跨企业唯一") + .contains("法人身份证、货代资质证书和银行开户许可证选填") + .contains("'422': {$ref: '#/components/responses/UnprocessableEntity'}"); + + Map openApi = new Yaml().load(document); + assertThat(openApi).containsEntry("openapi", "3.0.3"); + assertThat(openApi.get("paths")).isInstanceOf(Map.class); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java new file mode 100644 index 000000000..ca4efc0c5 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java @@ -0,0 +1,57 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch; +import com.baiyue.iam.service.MerchantOnboardingService; +import com.baiyue.iam.service.ProfileResourceService; +import com.baiyue.iam.service.TenantService; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(InternalTenantController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=test-internal-service-token-at-least-32-bytes") +class InternalCustomerExportContractTest { + @MockBean private MerchantOnboardingService onboardingService; + @MockBean private ProfileResourceService profileResourceService; + @MockBean private TenantService service; + @Resource private InternalTenantController controller; + @Test + void rejectsMissingInternalTokenAndAcceptsConfiguredToken() { + + BizException denied = assertThrows(BizException.class, + () -> controller.exportCustomers(null, null, 100, null, null)); + assertEquals(ResultCode.FORBIDDEN, denied.getResultCode()); + + when(service.exportMerchants(null, 100, "柏悦", "ACTIVE")) + .thenReturn(new CustomerExportBatch(List.of(), null, false)); + var response = controller.exportCustomers("test-internal-service-token-at-least-32-bytes", null, 100, "柏悦", "ACTIVE"); + assertEquals(ResultCode.SUCCESS.code(), response.code()); + verify(service).exportMerchants(null, 100, "柏悦", "ACTIVE"); + } + + @Test + void exposesFinanceCustomerMasterDataOnlyWithInternalToken() { + BizException denied = assertThrows(BizException.class, + () -> controller.financeCustomers(null, 1, 20, "Alice", "ACTIVE", null)); + assertEquals(ResultCode.FORBIDDEN, denied.getResultCode()); + + when(service.pageFinanceCustomers(1, 20, "Alice", "ACTIVE", null)) + .thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0)); + var response = controller.financeCustomers("test-internal-service-token-at-least-32-bytes", 1, 20, + "Alice", "ACTIVE", null); + assertEquals(ResultCode.SUCCESS.code(), response.code()); + verify(service).pageFinanceCustomers(1, 20, "Alice", "ACTIVE", null); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/InternalServiceTokenConfigurationTest.java b/iam/src/test/java/com/baiyue/iam/controller/InternalServiceTokenConfigurationTest.java new file mode 100644 index 000000000..a68cd054e --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/InternalServiceTokenConfigurationTest.java @@ -0,0 +1,34 @@ +package com.baiyue.iam.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class InternalServiceTokenConfigurationTest { + @Test + void internalTenantControllerRejectsEmptyAndShortTokens() { + assertInternalTenantTokenRejected(null); + assertInternalTenantTokenRejected(""); + assertInternalTenantTokenRejected("short-token"); + } + + @Test + void customerExportControllerRejectsEmptyAndShortTokens() { + assertCustomerExportTokenRejected(null); + assertCustomerExportTokenRejected(""); + assertCustomerExportTokenRejected("short-token"); + } + + private void assertInternalTenantTokenRejected(String token) { + InternalTenantController controller = new InternalTenantController(); + ReflectionTestUtils.setField(controller, "internalTokenValue", token); + assertThrows(IllegalStateException.class, controller::initializeToken); + } + + private void assertCustomerExportTokenRejected(String token) { + CustomerListExportProviderController controller = new CustomerListExportProviderController(); + ReflectionTestUtils.setField(controller, "internalTokenValue", token); + assertThrows(IllegalStateException.class, controller::initializeToken); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java b/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java index f55bb491c..9f18fc398 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java @@ -1,5 +1,6 @@ package com.baiyue.iam.controller; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.web.util.pattern.PathPatternParser; import org.springframework.http.server.PathContainer; @@ -7,11 +8,12 @@ import org.springframework.http.server.PathContainer; import static org.assertj.core.api.Assertions.assertThat; /** 防止成员数字主键路由再次拦截固定的角色分配路径。 */ +@DisplayName("商户成员路由") class MerchantMemberRouteTest { private final PathPatternParser parser = new PathPatternParser(); @Test - void 成员编辑路由只接受数字主键() { + void memberUpdateRouteAcceptsNumericPrimaryKeyOnly() { var pattern = parser.parse("/api/merchant/v1/iam/members/{membershipId:\\d+}"); assertThat(pattern.matches(PathContainer.parsePath("/api/merchant/v1/iam/members/123"))).isTrue(); diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java new file mode 100644 index 000000000..2c05b5d40 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java @@ -0,0 +1,46 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PlatformCustomerResourceContractTest { + + @Test + void shouldHandleCase01() throws Exception { + assertListPermissionContract("platformAddresses", + "/api/platform/v1/iam/tenants/{tenantId}/addresses"); + assertListPermissionContract("platformQualifications", + "/api/platform/v1/iam/tenants/{tenantId}/qualifications"); + Method platformDetail = ProfileResourceController.class.getMethod( + "platformQualification", long.class, long.class, AuthenticatedUser.class); + assertEquals("/api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}", + platformDetail.getAnnotation(GetMapping.class).value()[0]); + assertEquals("hasAuthority('platform:customer:list')", + platformDetail.getAnnotation(PreAuthorize.class).value()); + Method merchantDetail = ProfileResourceController.class.getMethod( + "qualification", long.class, AuthenticatedUser.class); + assertEquals("hasAuthority('merchant:profile:view')", + merchantDetail.getAnnotation(PreAuthorize.class).value()); + Method merchantDelete = ProfileResourceController.class.getMethod( + "deleteQualification", long.class, AuthenticatedUser.class); + assertEquals("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}", + merchantDelete.getAnnotation(DeleteMapping.class).value()[0]); + assertEquals("hasAuthority('merchant:qualification:upload')", + merchantDelete.getAnnotation(PreAuthorize.class).value()); + } + + private void assertListPermissionContract(String methodName, String path) throws Exception { + Method method = ProfileResourceController.class.getMethod( + methodName, long.class, AuthenticatedUser.class); + assertEquals(path, method.getAnnotation(GetMapping.class).value()[0]); + assertEquals("hasAuthority('platform:customer:list')", + method.getAnnotation(PreAuthorize.class).value()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java new file mode 100644 index 000000000..3d03e75a7 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java @@ -0,0 +1,79 @@ +package com.baiyue.iam.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.PostMapping; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.*; + +class PlatformInternalUserContractTest { + + @Test + void shouldHandleCase01() throws Exception { + Method method = ProfileResourceController.class.getMethod("internalUsers", + com.baiyue.common.security.AuthenticatedUser.class); + + assertEquals("hasAuthority('platform:user:list')", + method.getAnnotation(PreAuthorize.class).value()); + assertEquals("/api/platform/v1/iam/internal-users", + method.getAnnotation(GetMapping.class).value()[0]); + } + + @Test + void shouldHandleCase02() { + assertMapping("updateInternalUser", "/api/platform/v1/iam/internal-users/{membershipId:\\d+}"); + assertMapping("changeInternalUserStatus", + "/api/platform/v1/iam/internal-users/{membershipId:\\d+}/status"); + assertMapping("resetInternalUserPassword", + "/api/platform/v1/iam/internal-users/{membershipId:\\d+}/password"); + assertMapping("assignInternalUserRoles", + "/api/platform/v1/iam/internal-users/{membershipId:\\d+}/roles"); + } + + @Test + void unifiedPlatformUserEndpointsKeepIndependentPermissions() throws Exception { + Method list = PlatformUserManagementController.class.getMethod("list", + long.class, long.class, String.class, String.class, + com.baiyue.common.security.AuthenticatedUser.class); + Method create = PlatformUserManagementController.class.getMethod("create", + com.baiyue.iam.dto.PlatformUserManagementDtos.CreateUserRequest.class, + com.baiyue.common.security.AuthenticatedUser.class); + Method update = PlatformUserManagementController.class.getMethod("update", Long.class, + com.baiyue.iam.dto.PlatformUserManagementDtos.UpdateUserRequest.class, + com.baiyue.common.security.AuthenticatedUser.class); + Method status = PlatformUserManagementController.class.getMethod("status", Long.class, + com.baiyue.iam.dto.PlatformUserManagementDtos.ChangeStatusRequest.class, + com.baiyue.common.security.AuthenticatedUser.class); + Method password = PlatformUserManagementController.class.getMethod("password", Long.class, + com.baiyue.iam.dto.PlatformUserManagementDtos.ResetPasswordRequest.class, + com.baiyue.common.security.AuthenticatedUser.class); + + assertEquals("hasAuthority('platform:user:list')", list.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:user:create') and hasAuthority('platform:user:grant-role')", + create.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:user:edit')", update.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:user:status')", status.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:user:reset-password')", password.getAnnotation(PreAuthorize.class).value()); + assertNotNull(create.getAnnotation(PostMapping.class)); + assertNotNull(update.getAnnotation(PutMapping.class)); + } + + @Test + void tenantProfileReadRequiresViewPermission() throws Exception { + Method method = MerchantTenantController.class.getMethod("profile", + com.baiyue.common.security.AuthenticatedUser.class); + assertEquals("hasAuthority('merchant:profile:view')", + method.getAnnotation(PreAuthorize.class).value()); + } + + private void assertMapping(String methodName, String path) { + Method method = java.util.Arrays.stream(ProfileResourceController.class.getMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + assertEquals(path, method.getAnnotation(PutMapping.class).value()[0]); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java new file mode 100644 index 000000000..b2ed7fb78 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -0,0 +1,106 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class PlatformTenantControllerContractTest { + @Test + void shouldHandleCase01() throws Exception { + Method method = PlatformTenantController.class.getMethod("page", long.class, long.class, + String.class, String.class, AuthenticatedUser.class); + + assertEquals("/tenants", method.getAnnotation(GetMapping.class).value()[0]); + assertEquals("hasAuthority('platform:customer:list')", + method.getAnnotation(PreAuthorize.class).value()); + assertNotNull(annotation(method, 0, Min.class)); + assertNotNull(annotation(method, 1, Min.class)); + assertEquals(200, annotation(method, 1, Max.class).value()); + assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED", + annotation(method, 3, Pattern.class).regexp()); + + Set statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value()) + .map(io.swagger.v3.oas.annotations.responses.ApiResponse::responseCode) + .collect(Collectors.toSet()); + assertEquals(Set.of("200", "400", "401", "403", "422"), statuses); + } + + @Test + void shouldHandleCase02() throws Exception { + Method method = PlatformTenantController.class.getMethod("reviews", AuthenticatedUser.class); + + assertEquals("hasAuthority('platform:customer:review')", + method.getAnnotation(PreAuthorize.class).value()); + ParameterizedType apiResponse = (ParameterizedType) method.getGenericReturnType(); + ParameterizedType list = (ParameterizedType) apiResponse.getActualTypeArguments()[0]; + assertEquals(java.util.List.class, list.getRawType()); + assertEquals(com.baiyue.iam.dto.TenantDtos.PendingReviewResponse.class, + list.getActualTypeArguments()[0]); + } + + @Test + void shouldHandleCase03() throws Exception { + Method method = PlatformTenantController.class.getMethod("tenant", long.class, AuthenticatedUser.class); + + assertEquals("hasAuthority('platform:customer:list')", + method.getAnnotation(PreAuthorize.class).value()); + } + + @Test + void shouldHandleCase04() throws Exception { + Method method = PlatformTenantController.class.getMethod("approve", long.class, + com.baiyue.iam.dto.TenantDtos.ReviewRequest.class, AuthenticatedUser.class); + + ParameterizedType returnType = (ParameterizedType) method.getGenericReturnType(); + assertEquals(com.baiyue.iam.dto.TenantDtos.ApprovalResponse.class, + returnType.getActualTypeArguments()[0]); + } + + @Test + void platformCreationReturnsTenantAndOneTimeAdministratorCredentials() throws Exception { + Method method = PlatformTenantController.class.getMethod("register", + com.baiyue.iam.dto.TenantDtos.RegisterRequest.class, AuthenticatedUser.class); + + ParameterizedType returnType = (ParameterizedType) method.getGenericReturnType(); + assertEquals(com.baiyue.iam.dto.TenantDtos.CreationResponse.class, + returnType.getActualTypeArguments()[0]); + } + + @Test + void shouldExposeCustomerOwnerPasswordResetWithDedicatedPermission() throws Exception { + Method method = PlatformTenantController.class.getMethod("resetOwnerPassword", long.class, + com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest.class, AuthenticatedUser.class); + assertEquals("/tenants/{tenantId:\\d+}/owner/password", method.getAnnotation(org.springframework.web.bind.annotation.PutMapping.class).value()[0]); + assertEquals("hasAuthority('platform:customer:reset-password')", method.getAnnotation(PreAuthorize.class).value()); + } + + @Test + void shouldExposeArchiveAndImpersonationWithDedicatedPermissions() throws Exception { + Method archive = PlatformTenantController.class.getMethod("archive", long.class, AuthenticatedUser.class); + assertEquals("/tenants/{tenantId:\\d+}/archive", archive.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class).value()[0]); + assertEquals("hasAuthority('platform:customer:delete')", archive.getAnnotation(PreAuthorize.class).value()); + Method impersonation = PlatformTenantController.class.getMethod("createImpersonation", long.class, + com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest.class, AuthenticatedUser.class); + assertEquals("/tenants/{tenantId:\\d+}/impersonation", impersonation.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class).value()[0]); + assertEquals("hasAuthority('platform:customer:impersonate')", impersonation.getAnnotation(PreAuthorize.class).value()); + } + + private T annotation(Method method, int parameter, Class type) { + return method.getParameters()[parameter].getAnnotation(type); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java new file mode 100644 index 000000000..5c3ef9b84 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java @@ -0,0 +1,65 @@ +package com.baiyue.iam.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.PostMapping; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class QualificationUploadContractTest { + + @Test + void iamControllerDoesNotExposeQualificationUpload() { + boolean exposesLegacyPost = java.util.Arrays.stream(ProfileResourceController.class.getDeclaredMethods()) + .map(method -> method.getAnnotation(PostMapping.class)) + .filter(java.util.Objects::nonNull) + .flatMap(mapping -> java.util.Arrays.stream(mapping.value())) + .anyMatch("/api/merchant/v1/iam/tenant/qualifications"::equals); + + assertFalse(exposesLegacyPost, "IAM 商户资质接口只读,上传必须由 File 服务处理"); + } + + @Test + void staticOpenApiDoesNotAdvertiseLegacyQualificationPost() throws IOException { + try (var input = getClass().getClassLoader().getResourceAsStream("openapi/iam-v1.yaml")) { + assertNotNull(input); + String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + int pathStart = yaml.indexOf(" /api/merchant/v1/iam/tenant/qualifications:"); + int nextPath = yaml.indexOf("\n /api/", pathStart + 1); + assertTrue(pathStart >= 0 && nextPath > pathStart); + String pathContract = yaml.substring(pathStart, nextPath); + + assertFalse(pathContract.contains("\n post:")); + assertFalse(yaml.contains(" QualificationRequest:")); + assertTrue(yaml.contains(" /api/merchant/v1/iam/tenant/qualifications/submit-review:")); + } + } + + @Test + void internalControllerExposesBothRequiredPackageRoutes() { + var routes = java.util.Arrays.stream(InternalTenantController.class.getDeclaredMethods()) + .map(method -> method.getAnnotation(PostMapping.class)) + .filter(java.util.Objects::nonNull) + .flatMap(mapping -> java.util.Arrays.stream(mapping.value())) + .toList(); + assertTrue(routes.contains("/required-qualification-packages")); + assertTrue(routes.contains("/platform-required-qualification-packages")); + } + + @Test + void staticOpenApiDocumentsTheRequiredPackageContract() throws IOException { + try (var input = getClass().getClassLoader().getResourceAsStream("openapi/iam-v1.yaml")) { + assertNotNull(input); + String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(yaml.contains("/internal/iam/required-qualification-packages:")); + assertTrue(yaml.contains("/internal/iam/platform-required-qualification-packages:")); + assertTrue(yaml.contains("RequiredQualificationPackageRequest:")); + assertTrue(yaml.contains("replaceBusinessLicense")); + assertTrue(yaml.contains("replaceLegalPersonId")); + } + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationBusinessEventConsumerTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationBusinessEventConsumerTest.java new file mode 100644 index 000000000..807ced3bb --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationBusinessEventConsumerTest.java @@ -0,0 +1,109 @@ +package com.baiyue.iam.notification; + +import com.baiyue.iam.notification.dto.NotificationDtos.CreateNotificationRequest; +import com.baiyue.iam.notification.dto.NotificationDtos.TaskListItem; +import com.baiyue.iam.notification.entity.NotificationEventInbox; +import com.baiyue.iam.notification.mq.NotificationBusinessEventConsumer; +import com.baiyue.iam.notification.service.NotificationService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Map; + +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.*; + +class NotificationBusinessEventConsumerTest { + private final NotificationService service = mock(NotificationService.class); + private final NotificationBusinessEventConsumer consumer = new NotificationBusinessEventConsumer(); + + @BeforeEach void setup() { + ReflectionTestUtils.setField(consumer, "service", service); + ReflectionTestUtils.setField(consumer, "objectMapper", new ObjectMapper()); + } + + @Test void createsTaskWithEventTypeAndCompletesAudit() { + NotificationEventInbox event = event(); + var claim = new NotificationService.EventClaim(event, NotificationService.EventClaimStatus.ACQUIRED); + when(service.beginBusinessEvent(eq("source-1"), eq("order.status.changed"), anyString())).thenReturn(claim); + when(service.create(any(), eq(0L))).thenReturn(new TaskListItem(31L, "NT31", "ORDER", "A", "SENT", 1, null, null, false)); + + consumer.onMessage(message("READY")); + + ArgumentCaptor request = ArgumentCaptor.forClass(CreateNotificationRequest.class); + verify(service).create(request.capture(), eq(0L)); + assertThat(request.getValue().sourceEventId()).isEqualTo("source-1"); + assertThat(request.getValue().sourceEventType()).isEqualTo("order.status.changed"); + verify(service).completeBusinessEvent(claim, 31L); + } + + @Test void processedDuplicateIsAcknowledgedWithoutCreatingTask() { + var claim = new NotificationService.EventClaim(event(), NotificationService.EventClaimStatus.PROCESSED); + when(service.beginBusinessEvent(eq("source-1"), eq("order.status.changed"), anyString())).thenReturn(claim); + + consumer.onMessage(message("READY")); + + verify(service, never()).create(any(), anyLong()); + verify(service, never()).completeBusinessEvent(any(), anyLong()); + } + + @Test void processingDuplicateIsRetriedWithoutOverwritingOwnerAudit() { + var claim = new NotificationService.EventClaim(event(), NotificationService.EventClaimStatus.PROCESSING); + when(service.beginBusinessEvent(eq("source-1"), eq("order.status.changed"), anyString())).thenReturn(claim); + + assertThatThrownBy(() -> consumer.onMessage(message("READY"))) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("NOTIFICATION_EVENT_PROCESSING"); + verify(service, never()).failBusinessEvent(any(), any()); + } + + @Test void taskFailureIsRecordedAndRethrownForRocketMqRetry() { + var claim = new NotificationService.EventClaim(event(), NotificationService.EventClaimStatus.ACQUIRED); + when(service.beginBusinessEvent(eq("source-1"), eq("order.status.changed"), anyString())).thenReturn(claim); + when(service.create(any(), eq(0L))).thenThrow(new IllegalStateException("delivery failed")); + + assertThatThrownBy(() -> consumer.onMessage(message("READY"))) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("delivery failed"); + verify(service).failBusinessEvent(eq(claim), argThat(error -> "delivery failed".equals(error.getMessage()))); + } + + @Test void payloadHashIsStableAcrossMapKeyOrder() { + var processed = new NotificationService.EventClaim(event(), NotificationService.EventClaimStatus.PROCESSED); + when(service.beginBusinessEvent(eq("source-1"), eq("order.status.changed"), anyString())).thenReturn(processed); + consumer.onMessage(message("READY")); + String first = hashArgument(); + reset(service); + when(service.beginBusinessEvent(eq("source-1"), eq("order.status.changed"), anyString())).thenReturn(processed); + consumer.onMessage("{\"sourceEventId\":\"source-1\",\"tenantId\":9,\"eventType\":\"order.status.changed\",\"payload\":{\"variables\":{\"status\":\"READY\"},\"recipientIds\":[11],\"recipientType\":\"MEMBERSHIP\",\"templateCode\":\"ORDER_STATUS_CHANGED\"}}"); + String second = hashArgument(); + assertThat(second).isEqualTo(first); + } + + @Test void rejectsOversizedEventIdentityBeforeDatabaseRegistration() { + String message = "{\"sourceEventId\":\"" + "x".repeat(129) + + "\",\"eventType\":\"order.status.changed\",\"tenantId\":9,\"payload\":{}}"; + assertThatThrownBy(() -> consumer.onMessage(message)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("exceeds 128"); + verifyNoInteractions(service); + } + + private String hashArgument() { + ArgumentCaptor hash = ArgumentCaptor.forClass(String.class); + verify(service).beginBusinessEvent(eq("source-1"), eq("order.status.changed"), hash.capture()); + return hash.getValue(); + } + + private NotificationEventInbox event() { + NotificationEventInbox value = new NotificationEventInbox(); + value.setId(7L); value.setPayloadHash("hash"); value.setStatus("PROCESSING"); + return value; + } + + private String message(String status) { + return "{\"sourceEventId\":\"source-1\",\"eventType\":\"order.status.changed\",\"tenantId\":9,\"payload\":{\"templateCode\":\"ORDER_STATUS_CHANGED\",\"recipientType\":\"MEMBERSHIP\",\"recipientIds\":[11],\"variables\":{\"status\":\"" + status + "\"}}}"; + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java new file mode 100644 index 000000000..a21f4e66b --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java @@ -0,0 +1,32 @@ +package com.baiyue.iam.notification; + +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 NotificationConfigurationContractTest { + @Test void applicationAndNacosKeepConsumersDisabledAndExposeRecoveryTimeout() throws Exception { + assertSafeDefaults(read(Path.of("iam", "src", "main", "resources", "application.yml"), + Path.of("src", "main", "resources", "application.yml"))); + assertSafeDefaults(read(Path.of("config", "nacos", "local", "iam-service.yml"), + Path.of("..", "config", "nacos", "local", "iam-service.yml"))); + assertSafeDefaults(read(Path.of("config", "nacos", "cn", "iam-service.yml"), + Path.of("..", "config", "nacos", "cn", "iam-service.yml"))); + } + + private void assertSafeDefaults(String yaml) { + assertThat(yaml).contains( + "scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false}", + "dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300}", + "mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false}"); + } + + private String read(Path reactorPath, Path modulePath) throws Exception { + Path path = Files.exists(reactorPath) ? reactorPath : modulePath; + return Files.readString(path.normalize(), StandardCharsets.UTF_8); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationDispatchTransactionServiceTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationDispatchTransactionServiceTest.java new file mode 100644 index 000000000..788fa540b --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationDispatchTransactionServiceTest.java @@ -0,0 +1,126 @@ +package com.baiyue.iam.notification; + +import com.baiyue.iam.notification.entity.NotificationRecipient; +import com.baiyue.iam.notification.entity.NotificationTask; +import com.baiyue.iam.notification.repository.NotificationRepository; +import com.baiyue.iam.notification.service.NotificationDispatchTransactionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +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.*; + +class NotificationDispatchTransactionServiceTest { + private final NotificationRepository repository = mock(NotificationRepository.class); + private final NotificationDispatchTransactionService service = new NotificationDispatchTransactionService(); + + @BeforeEach void setup() { + ReflectionTestUtils.setField(service, "repository", repository); + } + + @Test void claimDeliveryAndFailureEvidenceUseIndependentTransactions() throws Exception { + for (var method : List.of( + NotificationDispatchTransactionService.class.getMethod("claimScheduled", Long.class), + NotificationDispatchTransactionService.class.getMethod("claimRetrying", Long.class), + NotificationDispatchTransactionService.class.getMethod("deliverClaimed", Long.class), + NotificationDispatchTransactionService.class.getMethod("recordFailure", Long.class, Throwable.class))) { + assertThat(method.getAnnotation(Transactional.class)).isNotNull() + .extracting(Transactional::propagation) + .isEqualTo(Propagation.REQUIRES_NEW); + } + } + + @Test void claimStartsOneOfThreeAllowedAttempts() { + when(repository.claimScheduled(eq(41L), eq(3), any())).thenReturn(true); + + assertThat(service.claimScheduled(41L)).isTrue(); + + verify(repository).claimScheduled(eq(41L), eq(3), any()); + } + + @Test void retryClaimEnforcesDelayAndSameAttemptLimit() { + when(repository.claimRetrying(eq(41L), eq(3), any(), any())).thenReturn(true); + + assertThat(service.claimRetrying(41L)).isTrue(); + + ArgumentCaptor dueBefore = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor now = ArgumentCaptor.forClass(LocalDateTime.class); + verify(repository).claimRetrying(eq(41L), eq(3), dueBefore.capture(), now.capture()); + assertThat(dueBefore.getValue()).isEqualTo(now.getValue().minusSeconds(30)); + } + + @Test void successfulDeliveryWritesInboxThenCompletesTaskAndClearsRecipientFailure() { + NotificationTask task = task("DISPATCHING"); + NotificationRecipient recipient = recipient("FAILED"); + when(repository.task(41L)).thenReturn(task); + when(repository.recipients(41L)).thenReturn(List.of(recipient)); + when(repository.markRecipientDelivered(eq(51L), any())).thenReturn(true); + when(repository.completeDispatch(eq(41L), any())).thenReturn(true); + + service.deliverClaimed(41L); + + verify(repository).insertInbox(argThat(item -> item.getTaskId().equals(41L) + && item.getRecipientId().equals(51L) && "UNREAD".equals(item.getStatus()))); + var ordered = inOrder(repository); + ordered.verify(repository).markRecipientDelivered(eq(51L), any()); + ordered.verify(repository).completeDispatch(eq(41L), any()); + } + + @Test void alreadyDeliveredRecipientIsNotInsertedAgain() { + NotificationTask task = task("DISPATCHING"); + NotificationRecipient recipient = recipient("DELIVERED"); + when(repository.task(41L)).thenReturn(task); + when(repository.recipients(41L)).thenReturn(List.of(recipient)); + when(repository.completeDispatch(eq(41L), any())).thenReturn(true); + + service.deliverClaimed(41L); + + verify(repository, never()).insertInbox(any()); + verify(repository, never()).markRecipientDelivered(anyLong(), any()); + } + + @Test void failurePersistsBoundedEvidenceAndUsesAttemptLimit() { + when(repository.failDispatch(eq(41L), eq(3), anyString(), any())).thenReturn(true); + + service.recordFailure(41L, new IllegalStateException("x".repeat(1200))); + + ArgumentCaptor error = ArgumentCaptor.forClass(String.class); + verify(repository).markUndeliveredRecipientsFailed(eq(41L), error.capture(), any()); + assertThat(error.getValue()).hasSize(1000); + verify(repository).failDispatch(eq(41L), eq(3), eq(error.getValue()), any()); + } + + @Test void rejectsDeliveryWithoutCurrentClaim() { + when(repository.task(41L)).thenReturn(task("RETRYING")); + + assertThatThrownBy(() -> service.deliverClaimed(41L)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("NOTIFICATION_DISPATCH_CLAIM_LOST"); + + verify(repository, never()).insertInbox(any()); + } + + private NotificationTask task(String status) { + NotificationTask task = new NotificationTask(); + task.setId(41L); task.setStatus(status); task.setCategory("ORDER"); task.setTitle("状态变化"); + task.setBody("正文"); task.setSourceEventId("event-1"); task.setSourceEventType("order.status.changed"); + return task; + } + + private NotificationRecipient recipient(String status) { + NotificationRecipient recipient = new NotificationRecipient(); + recipient.setId(51L); recipient.setTaskId(41L); recipient.setTenantId(9L); + recipient.setMembershipId(11L); recipient.setAppCode("MERCHANT"); recipient.setStatus(status); + recipient.setFailureReason("old error"); recipient.setUpdatedAt(LocalDateTime.now()); + return recipient; + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java new file mode 100644 index 000000000..ea97693f2 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.iam.notification; + +import com.baiyue.iam.notification.controller.NotificationInboxController; +import com.baiyue.iam.notification.controller.PlatformNotificationController; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class NotificationInboxPermissionContractTest { + @Test + void separatesReadOperationsFromInboxViewing() { + assertPermission("list", "hasAuthority(#app + ':notification:view')"); + assertPermission("unread", "hasAuthority(#app + ':notification:view')"); + assertPermission("detail", "hasAuthority(#app + ':notification:view')"); + assertPermission("read", "hasAuthority(#app + ':notification:read')"); + assertPermission("readAll", "hasAuthority(#app + ':notification:read-all')"); + } + + @Test + void taskDetailUsesTheTaskListPermission() { + var method = Arrays.stream(PlatformNotificationController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals("taskDetail")) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:notification:task:list')"); + } + + private static void assertPermission(String methodName, String expected) { + var method = Arrays.stream(NotificationInboxController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java new file mode 100644 index 000000000..d514112cd --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java @@ -0,0 +1,109 @@ +package com.baiyue.iam.notification; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class NotificationOpenApiContractTest { + @Test + void parsesAndExposesFrozenManagementAndInboxContracts() throws IOException { + String document; + try (var stream = getClass().getResourceAsStream("/openapi/notification-v1.yaml")) { + assertThat(stream).isNotNull(); + document = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + + Map api = new Yaml().load(document); + assertThat(api).containsEntry("openapi", "3.0.3"); + Map paths = map(api.get("paths")); + assertThat(paths).containsKeys( + "/{app}/v1/notifications", "/{app}/v1/notifications/unread-count", + "/{app}/v1/notifications/{id}", "/{app}/v1/notifications/{id}/read", + "/{app}/v1/notifications/read-all", "/platform/v1/notifications", + "/platform/v1/notification-templates", "/platform/v1/notification-templates/{id}", + "/platform/v1/notification-templates/{id}/status", "/platform/v1/notification-tasks", + "/platform/v1/notification-tasks/{id}", + "/platform/v1/notification-tasks/{id}/cancel", "/platform/v1/notification-tasks/{id}/retry"); + assertThat(paths).doesNotContainKey("/merchant/v1/notifications:create"); + + assertRequestBody(paths, "/platform/v1/notifications", "post"); + assertRequestBody(paths, "/platform/v1/notification-templates", "post"); + assertRequestBody(paths, "/platform/v1/notification-templates/{id}", "put"); + assertRequestBody(paths, "/platform/v1/notification-templates/{id}/status", "put"); + for (String path : List.of("/platform/v1/notification-templates/{id}", + "/platform/v1/notification-templates/{id}/status", + "/platform/v1/notification-tasks/{id}", + "/platform/v1/notification-tasks/{id}/cancel", + "/platform/v1/notification-tasks/{id}/retry")) { + Map operation = map(map(paths.get(path)).values().iterator().next()); + assertThat(list(operation.get("parameters"))).anySatisfy(value -> + assertThat(map(value)).containsEntry("$ref", "#/components/parameters/IdPath")); + } + + Map components = map(api.get("components")); + Map schemas = map(components.get("schemas")); + assertThat(schemas).containsKeys("CreateNotificationRequest", "CreateTemplateRequest", + "UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxPageResponse", + "InboxDetailResponse", "UnreadCountResponse", "ReadAllResponse", + "TemplateItem", "TemplateDetailResponse", "TaskItem", "TaskDetailResponse"); + assertThat(schemas).containsKeys("TaskItemResponse", "TaskRecipientItem", "TaskDetail"); + for (String schema : List.of("CreateNotificationRequest", "CreateTemplateRequest", + "UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxItem", "TemplateItem", + "TaskItem", "InboxPageResponse", "TemplateDetailResponse", "TaskDetailResponse")) { + assertThat(list(map(schemas.get(schema)).get("required"))).isNotEmpty(); + } + assertThat(document).contains("tenantId is a backend compatibility field") + .contains("DISABLED cannot return to ACTIVE") + .contains("does not support deleting or clearing read messages") + .contains("name: taskNo").contains("name: templateCode").contains("name: title"); + Map inboxItem = map(schemas.get("InboxItem")); + assertThat(list(inboxItem.get("required"))).contains("summary"); + assertThat(map(map(inboxItem.get("properties")).get("summary"))).containsEntry("maxLength", 120); + Map taskDetail = map(schemas.get("TaskDetail")); + 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) { + assertThat(map(map(paths.get(path)).get(method))).containsKey("requestBody"); + } + + @SuppressWarnings("unchecked") + private Map map(Object value) { return (Map) value; } + @SuppressWarnings("unchecked") + private List list(Object value) { return (List) value; } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationRecipientResolverTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationRecipientResolverTest.java new file mode 100644 index 000000000..c388bbd20 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationRecipientResolverTest.java @@ -0,0 +1,101 @@ +package com.baiyue.iam.notification; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamMembershipMapper; +import com.baiyue.iam.mapper.IamMembershipRoleMapper; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.mapper.IamUserMapper; +import com.baiyue.iam.notification.dto.NotificationDtos.CreateNotificationRequest; +import com.baiyue.iam.notification.dto.NotificationDtos.RecipientType; +import com.baiyue.iam.notification.service.NotificationRecipientResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class NotificationRecipientResolverTest { + private final IamMembershipMapper memberships = mock(IamMembershipMapper.class); + private final IamUserMapper users = mock(IamUserMapper.class); + private final IamRoleMapper roles = mock(IamRoleMapper.class); + private final IamMembershipRoleMapper membershipRoles = mock(IamMembershipRoleMapper.class); + private final NotificationRecipientResolver resolver = new NotificationRecipientResolver(); + + @BeforeEach + void setup() { + ReflectionTestUtils.setField(resolver, "memberships", memberships); + ReflectionTestUtils.setField(resolver, "users", users); + ReflectionTestUtils.setField(resolver, "roles", roles); + ReflectionTestUtils.setField(resolver, "membershipRoles", membershipRoles); + } + + @Test + void resolvesSpecifiedEnterpriseMemberByUserId() { + IamMembership membership = new IamMembership(); + membership.setId(301L); + membership.setTenantId(9L); + membership.setUserId(2092537408743079938L); + membership.setStatus("ACTIVE"); + IamUser user = new IamUser(); + user.setId(membership.getUserId()); + user.setDisplayName("测试成员"); + user.setStatus("ACTIVE"); + when(memberships.selectList(any())).thenReturn(List.of(membership)); + when(users.selectById(membership.getUserId())).thenReturn(user); + + var recipients = resolver.resolve(request(RecipientType.USER, List.of(membership.getUserId()))); + + assertThat(recipients).containsExactly(new NotificationRecipientResolver.ResolvedRecipient( + 9L, 301L, membership.getUserId(), "测试成员", "MERCHANT")); + } + + @Test + void explainsWhenNoActiveRecipientCanBeResolved() { + when(memberships.selectList(any())).thenReturn(List.of()); + + assertThatThrownBy(() -> resolver.resolve(request(RecipientType.USER, List.of(2092537408743079938L)))) + .isInstanceOf(BizException.class) + .hasMessage("未找到可接收消息的有效成员,请检查用户 ID 和目标企业"); + } + + @Test + void explainsWhenRoleCodeDoesNotExist() { + when(roles.selectOne(any())).thenReturn(null); + + assertThatThrownBy(() -> resolver.resolve(roleRequest(RecipientType.PLATFORM_ROLE, "MISSING_ROLE"))) + .isInstanceOf(BizException.class) + .hasMessage("角色编码不存在,请检查角色编码是否正确"); + } + + @Test + void explainsWhenRoleHasNoActiveMembers() { + IamRole role = new IamRole(); + role.setId(501L); + when(roles.selectOne(any())).thenReturn(role); + when(membershipRoles.selectList(any())).thenReturn(List.of()); + + assertThatThrownBy(() -> resolver.resolve(roleRequest(RecipientType.PLATFORM_ROLE, "CUSTOMER_REVIEWER"))) + .isInstanceOf(BizException.class) + .hasMessage("该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员"); + } + + private CreateNotificationRequest request(RecipientType recipientType, List recipientIds) { + return new CreateNotificationRequest(9L, "ORDER_STATUS_CHANGED", recipientType, recipientIds, + null, Map.of(), null, "notification-test-key", null, null); + } + + private CreateNotificationRequest roleRequest(RecipientType recipientType, String roleCode) { + return new CreateNotificationRequest(0L, "ORDER_STATUS_CHANGED", recipientType, null, + roleCode, Map.of(), null, "notification-role-test-key", null, null); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java new file mode 100644 index 000000000..c629d44be --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java @@ -0,0 +1,101 @@ +package com.baiyue.iam.notification; + +import com.baiyue.iam.notification.mapper.NotificationTaskMapper; +import com.baiyue.iam.notification.service.NotificationScheduler; +import com.baiyue.iam.notification.service.NotificationService; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class NotificationSchedulerRecoveryTest { + @Test void claimSqlAtomicallyIncrementsAttemptsAndEnforcesDueTimeAndLimit() throws Exception { + Update scheduled = NotificationTaskMapper.class + .getMethod("claimScheduled", Long.class, int.class, java.time.LocalDateTime.class) + .getAnnotation(Update.class); + String scheduledSql = String.join(" ", scheduled.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(scheduledSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1", + "STATUS = 'SCHEDULED'", "SCHEDULED_AT <= #{NOW}", "ATTEMPT_COUNT < #{MAXATTEMPTS}"); + + Update retrying = NotificationTaskMapper.class + .getMethod("claimRetrying", Long.class, int.class, java.time.LocalDateTime.class, + java.time.LocalDateTime.class) + .getAnnotation(Update.class); + String retryingSql = String.join(" ", retrying.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(retryingSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1", + "STATUS = 'RETRYING'", "UPDATED_AT <= #{DUEBEFORE}", "ATTEMPT_COUNT < #{MAXATTEMPTS}"); + } + + @Test void recoverySqlRequiresTimeoutAndNoSuccessfulDeliveryEvidence() throws Exception { + Update retry = NotificationTaskMapper.class + .getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class, + java.time.LocalDateTime.class, int.class) + .getAnnotation(Update.class); + String retrySql = String.join(" ", retry.value()).replaceAll("\\s+", " ").toUpperCase(); + + assertThat(retrySql).contains("TASK.STATUS = 'DISPATCHING'", "TASK.UPDATED_AT <= #{STALEBEFORE}", + "TASK.SENT_AT IS NULL", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX") + .contains("SET TASK.STATUS = 'RETRYING'", "TASK.ATTEMPT_COUNT < #{MAXATTEMPTS}") + .doesNotContain("ATTEMPT_COUNT = TASK.ATTEMPT_COUNT + 1"); + + Update dead = NotificationTaskMapper.class + .getMethod("deadTimedOutDispatchingWithDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class) + .getAnnotation(Update.class); + String deadSql = String.join(" ", dead.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(deadSql).contains("SET TASK.STATUS = 'DEAD'", "TASK.STATUS = 'DISPATCHING'", + "TASK.UPDATED_AT <= #{STALEBEFORE}", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX", + "DISPATCH_TIMEOUT_WITH_DELIVERY_EVIDENCE"); + + Update attemptLimit = NotificationTaskMapper.class + .getMethod("deadTimedOutDispatchingAtAttemptLimit", java.time.LocalDateTime.class, + java.time.LocalDateTime.class, int.class) + .getAnnotation(Update.class); + String attemptLimitSql = String.join(" ", attemptLimit.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(attemptLimitSql).contains("SET TASK.STATUS = 'DEAD'", + "TASK.ATTEMPT_COUNT >= #{MAXATTEMPTS}", "DISPATCH_TIMEOUT_ATTEMPT_LIMIT"); + + Update failed = NotificationTaskMapper.class + .getMethod("failDispatch", Long.class, int.class, String.class, java.time.LocalDateTime.class) + .getAnnotation(Update.class); + String failedSql = String.join(" ", failed.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(failedSql).contains("STATUS = CASE WHEN ATTEMPT_COUNT >= #{MAXATTEMPTS} THEN 'DEAD' ELSE 'RETRYING' END", + "LAST_ERROR = #{ERROR}", "STATUS = 'DISPATCHING'"); + } + + @Test void schedulerRecoversBeforeClaimingScheduledAndRetryingTasks() { + NotificationService service = mock(NotificationService.class); + NotificationScheduler scheduler = new NotificationScheduler(); + ReflectionTestUtils.setField(scheduler, "service", service); + ReflectionTestUtils.setField(scheduler, "dispatchTimeoutSeconds", 300L); + when(service.dueScheduledTaskIds()).thenReturn(List.of(11L)); + when(service.retryingTaskIds()).thenReturn(List.of(12L)); + + scheduler.dispatch(); + + var ordered = inOrder(service); + ordered.verify(service).recoverTimedOutDispatching(300L); + ordered.verify(service).dueScheduledTaskIds(); + ordered.verify(service).dispatchScheduled(11L); + ordered.verify(service).retryingTaskIds(); + ordered.verify(service).dispatchRetrying(12L); + } + + @Test void oneTaskFailureDoesNotBlockRemainingTasksOrRetryQueue() { + NotificationService service = mock(NotificationService.class); + NotificationScheduler scheduler = new NotificationScheduler(); + ReflectionTestUtils.setField(scheduler, "service", service); + ReflectionTestUtils.setField(scheduler, "dispatchTimeoutSeconds", 300L); + when(service.dueScheduledTaskIds()).thenReturn(List.of(11L, 12L)); + when(service.dispatchScheduled(11L)).thenThrow(new IllegalStateException("failed")); + when(service.retryingTaskIds()).thenReturn(List.of(13L)); + + scheduler.dispatch(); + + verify(service).dispatchScheduled(12L); + verify(service).dispatchRetrying(13L); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java new file mode 100644 index 000000000..b000b0ae0 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java @@ -0,0 +1,266 @@ +package com.baiyue.iam.notification; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.notification.dto.NotificationDtos.*; +import com.baiyue.iam.notification.entity.NotificationRecipient; +import com.baiyue.iam.notification.entity.NotificationEventInbox; +import com.baiyue.iam.notification.entity.NotificationInbox; +import com.baiyue.iam.notification.entity.NotificationTask; +import com.baiyue.iam.notification.entity.NotificationTemplate; +import com.baiyue.iam.notification.repository.NotificationRepository; +import com.baiyue.iam.notification.service.NotificationRecipientResolver; +import com.baiyue.iam.notification.service.NotificationDispatchTransactionService; +import com.baiyue.iam.notification.service.NotificationService; +import com.baiyue.iam.notification.service.NotificationTemplateRenderer; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class NotificationServiceTest { + private final NotificationRepository repository = mock(NotificationRepository.class); + private final NotificationRecipientResolver resolver = mock(NotificationRecipientResolver.class); + private final NotificationDispatchTransactionService dispatchTransactions = + mock(NotificationDispatchTransactionService.class); + private final NotificationService service = new NotificationService(); + + @BeforeEach void setup() { + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "renderer", new NotificationTemplateRenderer()); + ReflectionTestUtils.setField(service, "resolver", resolver); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(service, "dispatchTransactions", dispatchTransactions); + } + + @Test void createsTaskAndImmutableRecipientSnapshot() { + List stored = new ArrayList<>(); + doAnswer(call -> { stored.add(call.getArgument(0)); return null; }).when(repository).insertRecipient(any()); + when(repository.recipients(anyLong())).thenAnswer(call -> List.copyOf(stored)); + NotificationTemplate template = activeTemplate(); + when(repository.templateByCode(0, "ORDER_STATUS_CHANGED")).thenReturn(template); + when(resolver.resolve(any())).thenReturn(List.of( + new NotificationRecipientResolver.ResolvedRecipient(9, 11, 12, "User", "MERCHANT"))); + + var request = new CreateNotificationRequest(9L, "ORDER_STATUS_CHANGED", RecipientType.MEMBERSHIP, + List.of(11L), null, Map.of("order_no", "A", "status", "READY"), null, "idem-key-1", + "source-1", "order.status.changed"); + TaskListItem result = service.create(request, 99); + + assertThat(result.status()).isEqualTo("SENT"); + verify(repository).insertTask(any()); + verify(repository).insertRecipient(any()); + verify(repository).insertInbox(any()); + ArgumentCaptor task = ArgumentCaptor.forClass(NotificationTask.class); + verify(repository).saveTask(task.capture()); + assertThat(task.getValue().getTitle()).isEqualTo("A"); + assertThat(task.getValue().getSourceEventId()).isEqualTo("source-1"); + assertThat(task.getValue().getSourceEventType()).isEqualTo("order.status.changed"); + assertThat(stored).hasSize(1); + assertThat(stored.get(0).getStatus()).isEqualTo("DELIVERED"); + } + + @Test void returnsWhitespaceCompactedUnicodeBoundedInboxSummaryWithoutFullBody() { + NotificationInbox item = new NotificationInbox(); + item.setId(8L); item.setCategory("ORDER"); item.setTitle("状态变化"); item.setStatus("UNREAD"); + item.setSentAt(LocalDateTime.now()); item.setBody(" 第一行\n" + "测".repeat(140) + " "); + when(repository.pageInbox(9L, 11L, "MERCHANT", 1, 20, null, null)) + .thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(item), 1, 20, 1, 1)); + + NotificationSummary summary = service.inbox(9L, 11L, "MERCHANT", 1, 20, null, null).records().get(0); + + assertThat(summary.summary()).startsWith("第一行 "); + assertThat(summary.summary().codePointCount(0, summary.summary().length())).isEqualTo(120); + assertThat(summary.summary()).isNotEqualTo(item.getBody()); + } + + @Test void returnsTaskAndRecipientFailureEvidence() { + NotificationTask task = task(41L, "DEAD"); + task.setTaskNo("NT41"); task.setLastError("gateway timeout"); task.setAttemptCount(3); + NotificationRecipient recipient = new NotificationRecipient(); + recipient.setId(51L); recipient.setDisplayName("客户成员"); recipient.setAppCode("MERCHANT"); + recipient.setStatus("FAILED"); recipient.setFailureReason("gateway timeout"); + when(repository.task(41L)).thenReturn(task); + when(repository.recipients(41L)).thenReturn(List.of(recipient)); + + TaskDetail detail = service.taskDetail(41L); + + assertThat(detail.attemptCount()).isEqualTo(3); + assertThat(detail.lastError()).isEqualTo("gateway timeout"); + assertThat(detail.recipients()).singleElement().satisfies(value -> { + assertThat(value.status()).isEqualTo("FAILED"); + assertThat(value.failureReason()).isEqualTo("gateway timeout"); + }); + } + + @Test void returnsOriginalTaskForRepeatedIdempotencyKey() { + NotificationTask existing = new NotificationTask(); existing.setId(5L); existing.setTaskNo("NT5"); + existing.setCategory("ORDER"); existing.setTitle("A"); existing.setStatus("SENT"); existing.setRecipientCount(1); + when(repository.taskByIdempotency("idem-key-1")).thenReturn(existing); + var request = new CreateNotificationRequest(9L, "ORDER_STATUS_CHANGED", RecipientType.MEMBERSHIP, + List.of(11L), null, Map.of(), null, "idem-key-1", null, null); + assertThat(service.create(request, 99).duplicated()).isTrue(); + verify(repository, never()).insertTask(any()); + } + + @Test void disabledTemplateCannotReturnToActive() { + NotificationTemplate template = new NotificationTemplate(); template.setId(1L); + template.setStatus("DISABLED"); template.setVersion(3); + when(repository.template(1L)).thenReturn(template); + assertThatThrownBy(() -> service.changeTemplateStatus(1L, + new ChangeTemplateStatusRequest("ACTIVE", 3), 9)) + .isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_INVALID_STATE"); + } + + @Test void recordsAndCompletesBusinessEventClaim() { + when(repository.insertEventIfAbsent(any())).thenReturn(true); + var claim = service.beginBusinessEvent("event-1", "order.status.changed", "hash-1"); + + assertThat(claim.status()).isEqualTo(NotificationService.EventClaimStatus.ACQUIRED); + assertThat(claim.event().getStatus()).isEqualTo("PROCESSING"); + when(repository.completeEvent(eq(claim.event().getId()), eq("hash-1"), + eq(claim.event().getUpdatedAt()), eq(31L), any())).thenReturn(true); + service.completeBusinessEvent(claim, 31L); + + verify(repository).completeEvent(eq(claim.event().getId()), eq("hash-1"), + eq(claim.event().getUpdatedAt()), eq(31L), any()); + } + + @Test void reusesProcessedEventWithSamePayload() { + NotificationEventInbox existing = event("PROCESSED", "hash-1"); + when(repository.event("event-1", "order.status.changed")).thenReturn(existing); + + var claim = service.beginBusinessEvent("event-1", "order.status.changed", "hash-1"); + + assertThat(claim.status()).isEqualTo(NotificationService.EventClaimStatus.PROCESSED); + verify(repository, never()).claimEvent(any(), any(), any()); + } + + @Test void rejectsSameEventIdentityWithDifferentPayload() { + when(repository.event("event-1", "order.status.changed")).thenReturn(event("PROCESSED", "hash-1")); + + assertThatThrownBy(() -> service.beginBusinessEvent("event-1", "order.status.changed", "hash-2")) + .isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_EVENT_PAYLOAD_CONFLICT"); + } + + @Test void freshConcurrentClaimRemainsProcessing() { + NotificationEventInbox existing = event("PROCESSING", "hash-1"); + when(repository.event("event-1", "order.status.changed")).thenReturn(existing); + when(repository.claimEvent(eq(existing), any(), any())).thenReturn(false); + + var claim = service.beginBusinessEvent("event-1", "order.status.changed", "hash-1"); + + assertThat(claim.status()).isEqualTo(NotificationService.EventClaimStatus.PROCESSING); + } + + @Test void failedEventCanBeClaimedForRocketMqRetry() { + NotificationEventInbox existing = event("FAILED", "hash-1"); + when(repository.event("event-1", "order.status.changed")).thenReturn(existing); + when(repository.claimEvent(eq(existing), any(), any())).thenAnswer(call -> { + existing.setUpdatedAt(call.getArgument(2)); + return true; + }); + + var claim = service.beginBusinessEvent("event-1", "order.status.changed", "hash-1"); + + assertThat(claim.status()).isEqualTo(NotificationService.EventClaimStatus.ACQUIRED); + } + + @Test void recordsTruncatedFailureForClaim() { + NotificationEventInbox existing = event("PROCESSING", "hash-1"); + var claim = new NotificationService.EventClaim(existing, NotificationService.EventClaimStatus.ACQUIRED); + when(repository.failEvent(eq(existing.getId()), eq("hash-1"), eq(existing.getUpdatedAt()), any(), any())) + .thenReturn(true); + + service.failBusinessEvent(claim, new IllegalStateException("x".repeat(1200))); + + ArgumentCaptor error = ArgumentCaptor.forClass(String.class); + verify(repository).failEvent(eq(existing.getId()), eq("hash-1"), eq(existing.getUpdatedAt()), + error.capture(), any()); + assertThat(error.getValue()).hasSize(1000); + } + + @Test void recoversOnlyTasksOlderThanConfiguredDispatchTimeout() { + when(repository.recoverTimedOutDispatching(any(), any(), eq(3))).thenReturn(2); + + assertThat(service.recoverTimedOutDispatching(300)).isEqualTo(2); + + ArgumentCaptor staleBefore = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor now = ArgumentCaptor.forClass(LocalDateTime.class); + verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture(), eq(3)); + assertThat(staleBefore.getValue()).isEqualTo(now.getValue().minusSeconds(300)); + } + + @Test void onlyOneInstanceCanClaimRecoveredTask() { + when(dispatchTransactions.claimRetrying(41L)).thenReturn(true, false); + + assertThat(service.dispatchRetrying(41L)).isTrue(); + assertThat(service.dispatchRetrying(41L)).isFalse(); + + verify(dispatchTransactions, times(2)).claimRetrying(41L); + verify(dispatchTransactions, times(1)).deliverClaimed(41L); + } + + @Test void sentTaskCannotBeClaimedForRecoveryDelivery() { + when(dispatchTransactions.claimRetrying(42L)).thenReturn(false); + + assertThat(service.dispatchRetrying(42L)).isFalse(); + + verify(repository, never()).task(42L); + verify(repository, never()).recipients(42L); + } + + @Test void persistsScheduledDeliveryFailureAfterCommittedClaim() { + IllegalStateException failure = new IllegalStateException("inbox unavailable"); + when(dispatchTransactions.claimScheduled(43L)).thenReturn(true); + doThrow(failure).when(dispatchTransactions).deliverClaimed(43L); + + assertThatThrownBy(() -> service.dispatchScheduled(43L)).isSameAs(failure); + + verify(dispatchTransactions).recordFailure(43L, failure); + } + + @Test void manualRetryResetsAttemptBudgetAndWaitsForScheduler() { + NotificationTask task = task(44L, "DEAD"); + task.setAttemptCount(3); task.setLastError("timeout"); + when(repository.task(44L)).thenReturn(task); + when(repository.requeueForManualRetry(eq(44L), any())).thenReturn(true); + + TaskListItem result = service.retry(44L); + + assertThat(result.status()).isEqualTo("RETRYING"); + assertThat(task.getAttemptCount()).isZero(); + assertThat(task.getLastError()).isNull(); + verify(dispatchTransactions, never()).deliverClaimed(anyLong()); + } + + private NotificationTask task(long id, String status) { + NotificationTask value = new NotificationTask(); + value.setId(id); value.setStatus(status); value.setCategory("ORDER"); value.setTitle("A"); + value.setRecipientCount(0); value.setAttemptCount(1); + return value; + } + + private NotificationEventInbox event(String status, String hash) { + NotificationEventInbox value = new NotificationEventInbox(); + value.setId(7L); value.setSourceEventId("event-1"); value.setEventType("order.status.changed"); + value.setPayloadHash(hash); value.setStatus(status); value.setUpdatedAt(LocalDateTime.now().minusMinutes(1)); + return value; + } + + private NotificationTemplate activeTemplate() { + NotificationTemplate value = new NotificationTemplate(); value.setId(1L); + value.setTemplateCode("ORDER_STATUS_CHANGED"); value.setCategory("ORDER"); + value.setTitleTemplate("{{order_no}}"); value.setBodyTemplate("{{status}}"); + value.setVariableWhitelistJson("[\"order_no\",\"status\"]"); value.setStatus("ACTIVE"); + return value; + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java new file mode 100644 index 000000000..5020e27cf --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java @@ -0,0 +1,29 @@ +package com.baiyue.iam.notification; +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 NotificationSqlContractTest { + @Test void definesTablesIsolationIdempotencyAndPermissions() throws Exception {String sql;try(var in=getClass().getResourceAsStream("/db/notification-center.sql")){assertThat(in).isNotNull();sql=new String(in.readAllBytes(),StandardCharsets.UTF_8);}assertThat(sql).contains("iam_notification_template","iam_notification_task","iam_notification_recipient","iam_notification_inbox","iam_notification_event_inbox","uk_notification_task_no","uk_notification_task_idempotency","uk_notification_source_event","uk_notification_recipient","idx_notification_inbox_list","platform:notification:template:status","platform:notification:task:retry","platform:notification:read","platform:notification:read-all","merchant:notification:view","merchant:notification:read","merchant:notification:read-all").contains("'MENU',5106000,'platform:notification:template:list'","'MENU',5106000,'platform:notification:task:list'");} + + @Test void unifiedMigrationContainsNotificationBaselineAndSevenIndexAssertion() 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("iam_notification_template","iam_notification_task","iam_notification_recipient", + "iam_notification_inbox","iam_notification_event_inbox","uk_notification_template_code", + "uk_notification_task_no","uk_notification_task_idempotency","uk_notification_source_event", + "uk_notification_recipient","uk_notification_inbox_recipient","uk_notification_event", + "assert_notification_center_schema","permission_code='merchant:notification:view'", + "permission_code LIKE 'platform:notification%'"); + assertThat(sql).contains("COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_iam'"); + assertThat(sql) + .contains(")) <> 7 THEN") + .contains("permission_code='merchant:notification:view')) <> 13 THEN") + .contains("permission_code LIKE 'platform:notification%') <> 12 THEN") + .contains("'merchant:notification:view','merchant:notification:read','merchant:notification:read-all')) <> 3 THEN"); + assertThat(sql.indexOf("(5106014,0,'MERCHANT','BUTTON'")) + .isLessThan(sql.indexOf("CALL baiyue_iam.assert_notification_center_schema();")); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationTemplateRendererTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationTemplateRendererTest.java new file mode 100644 index 000000000..38ff24dcd --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationTemplateRendererTest.java @@ -0,0 +1,12 @@ +package com.baiyue.iam.notification; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.notification.service.NotificationTemplateRenderer; +import org.junit.jupiter.api.Test; +import java.util.Map; +import java.util.Set; +import static org.assertj.core.api.Assertions.*; +class NotificationTemplateRendererTest { + private final NotificationTemplateRenderer renderer=new NotificationTemplateRenderer(); + @Test void rendersOnlyWhitelistedVariables(){assertThat(renderer.render("Order {{order_no}} is {{status}}",Set.of("order_no","status"),Map.of("order_no","A1","status","ready"))).isEqualTo("Order A1 is ready");} + @Test void rejectsMissingAndUnknownVariables(){assertThatThrownBy(()->renderer.render("{{order_no}}",Set.of("order_no"),Map.of())).isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_VARIABLE_MISSING");assertThatThrownBy(()->renderer.render("ok",Set.of(),Map.of("secret","x"))).isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_VARIABLE_NOT_ALLOWED");} +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java new file mode 100644 index 000000000..2075ae94c --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java @@ -0,0 +1,106 @@ +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 AdminPrototypeMenuPermissionSqlContractTest { + private static final String[] FINANCE_MENUS = { + "platform:finance:customer-account:list", "platform:finance:customer-arap:list", + "platform:finance:customer-statement:list", "platform:finance:settlement-ar:list", + "platform:finance:settlement-ap:list", "platform:finance:receivable-statistics:list" + }; + private static final String[] FINANCE_EXPORTS = { + "platform:finance:customer-account:export", "platform:finance:customer-arap:export", + "platform:finance:customer-statement:export", "platform:finance:settlement-ar:export", + "platform:finance:settlement-ap:export", "platform:finance:receivable-statistics:export" + }; + + @Test + void declaresFinancePageMenusAndReparentsActions() throws Exception { + String sql = new ClassPathResource("db/admin-prototype-menu-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + + for (int index = 0; index < FINANCE_MENUS.length; index++) { + assertThat(sql).contains("(" + (5105040 + index) + ",0,'PLATFORM','MENU',5105000,'" + FINANCE_MENUS[index] + "'"); + 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") + .contains("'platform:finance:fee-source:edit', 5105041") + .contains("'platform:finance:reconciliation:create', 5105042") + .contains("'platform:finance:settlement-ar:create', 5105043") + .contains("'platform:finance:settlement-ap:create', 5105044") + .contains("'platform:finance:payment:create', 5105044)") + .contains("'platform:finance:bill:view','platform:finance:account:view'") + .contains("'platform:finance:fee-source:view','platform:finance:settlement-ar:view'") + .contains("'platform:finance:settlement-ap:view'") + .doesNotContain("SELECT 'platform:finance:settlement-ar:view', 5105043") + .doesNotContain("SELECT 'platform:finance:settlement-ap:view', 5105044") + .contains("role_code='PLATFORM_ADMIN'"); + + int grantStart = sql.indexOf("role_code='PLATFORM_ADMIN'"); + String platformAdminGrant = sql.substring(grantStart, sql.indexOf(");", grantStart)); + for (String permission : FINANCE_EXPORTS) assertThat(platformAdminGrant).contains(permission); + assertThat(platformAdminGrant).contains("platform:download:list"); + + String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); + for (int index = 0; index < FINANCE_EXPORTS.length; index++) { + assertThat(unified).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'"); + } + assertThat(unified) + .contains("platform:finance:receivable-statistics:export") + .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 34") + .contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 35") + .contains("Finance 导出和下载中心授权不完整") + .contains("客户全局 Finance 权限模板不完整") + .contains("客户全局管理员 Finance 模板授权不完整") + .satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'")) + .isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()"))) + .satisfies(content -> assertThat(content.indexOf("CALL migrate_wallet_permissions();")) + .isLessThan(content.indexOf("CALL assert_finance_permissions();"))); + } + + @Test + void retiresUnsupportedOverseasFeeCreateAndDelete() throws Exception { + String sql = new ClassPathResource("db/admin-prototype-menu-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql).contains("status='INACTIVE', deleted=1") + .contains("'platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete'"); + + String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); + assertThat(unified).contains("<> 4") + .contains("platform:overseas-storage-fee:list', 'platform:overseas-storage-fee:create'"); + } + + @Test + void keepsDomesticContainerAndSupplierPermissionIdsDisjoint() throws Exception { + String sql = new ClassPathResource("db/admin-prototype-menu-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(sql) + .contains("(5103070,0,'PLATFORM','API',5103010,'platform:domestic-container:list'") + .contains("(5103071,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:inbound:create'") + .contains("(5103072,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:outbound:create'") + .contains("(5103080,0,'PLATFORM','MENU',5103000,'platform:supplier:list'") + .contains("(5103081,0,'PLATFORM','BUTTON',5103080,'platform:supplier:create'") + .contains("(5103082,0,'PLATFORM','BUTTON',5103080,'platform:supplier:edit'") + .contains("(5103083,0,'PLATFORM','BUTTON',5103080,'platform:supplier:status'") + .doesNotContain("platform:supplier:view"); + } + + private static Path resolveUnifiedSql() { + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/ArchivedCustomerListContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/ArchivedCustomerListContractTest.java new file mode 100644 index 000000000..b33acb2ac --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/ArchivedCustomerListContractTest.java @@ -0,0 +1,19 @@ +package com.baiyue.iam.repository; + +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 ArchivedCustomerListContractTest { + @Test + void customerListAndExportExcludeArchivedTenants() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/com/baiyue/iam/repository/TenantRepository.java")); + + assertThat(source.split("\\.ne\\(IamTenant::getStatus, \\\"ARCHIVED\\\"\\)", -1)) + .hasSizeGreaterThanOrEqualTo(3); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/ContainerCustomsPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/ContainerCustomsPermissionSqlContractTest.java new file mode 100644 index 000000000..1c78e7a94 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/ContainerCustomsPermissionSqlContractTest.java @@ -0,0 +1,70 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerCustomsPermissionSqlContractTest { + + @Test + void registersOnlyTheFinalPlatformPermissionsAndDefaultRoles() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "5102170", + "5102172", + "platform:firstleg:customs:list", + "platform:firstleg:customs:manage", + "'/first-leg/customs'", + "role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')", + "ON DUPLICATE KEY UPDATE", + "INSERT IGNORE INTO iam_role_permission", + "guard_phase3_container_customs_permission_ids", + "assert_phase3_container_customs_permissions", + "SIGNAL SQLSTATE '45000'" + ); + } + + @Test + void removesLegacyCaseAndLoadingListExportPermissionsWithoutAliases() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "DELETE role_permission", + "DELETE FROM iam_permission", + "'platform:firstleg:customs:view'", + "'platform:firstleg:customs:review'", + "'platform:firstleg:customs:submit'", + "'platform:firstleg:customs:receipt'", + "'platform:firstleg:loading-list:export'" + ); + assertThat(sql).doesNotContain( + "hasAnyAuthority", + "merchant:customs", + "platform:firstleg:customs:cases" + ); + } + + @Test + void reusesOrderPermissionsForMerchantTaxRefundDocuments() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "merchant:order:view/edit", + "买单无客户入口" + ); + assertThat(sql).doesNotContain( + "'merchant:order:view'", + "'merchant:order:edit'", + "'MERCHANT'" + ); + } + + private String sql() throws Exception { + return new ClassPathResource("db/phase3-container-customs-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java new file mode 100644 index 000000000..70a0f887e --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java @@ -0,0 +1,134 @@ +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.Set; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerWorkspacePermissionSqlContractTest { + private static final Pattern CODE = Pattern.compile("platform:firstleg:(?:container|terminal-drayage):[a-z0-9:-]+"); + private static final Set REQUIRED = Set.of( + "platform:firstleg:container:list", + "platform:firstleg:container:create", + "platform:firstleg:container:edit", + "platform:firstleg:terminal-drayage:list", + "platform:firstleg:terminal-drayage:document:edit", + "platform:firstleg:terminal-drayage:fleet:edit", + "platform:firstleg:terminal-drayage:pickup-appointment:edit", + "platform:firstleg:terminal-drayage:empty-return:confirm"); + + @Test + void definesSeparateDomesticAndOverseasMenusWithoutAliases() throws Exception { + String sql = sql(); + + assertThat(codes(catalog(sql))).containsExactlyInAnyOrderElementsOf(REQUIRED); + assertThat(sql).contains( + "'platform:firstleg:container:list',\n '国内送柜报关','/container-loading/domestic','GET'", + "'platform:firstleg:terminal-drayage:list',\n '国外清关提柜','/container-loading/overseas','GET'", + "permission.permission_code='platform:firstleg:container:view'", + "SET status='INACTIVE', deleted=1"); + assertThat(codes(catalog(sql))).noneMatch(code -> code.endsWith(":view")); + assertThat(sql).doesNotContain( + "platform:firstleg:domestic-container:", + "platform:firstleg:overseas-container:", + "platform:firstleg:terminal-drayage:edit"); + } + + @Test + void keepsDomesticWritesAndDefinesFineGrainedOverseasWrites() throws Exception { + assertThat(sql()).contains( + "'platform:firstleg:container:create',\n '建立国内货柜',NULL,'POST'", + "'platform:firstleg:container:edit',\n '维护国内货柜资料',NULL,'PUT'", + "'platform:firstleg:terminal-drayage:document:edit',\n '维护目的港清关与DO/PU',NULL,'POST'", + "'platform:firstleg:terminal-drayage:fleet:edit',\n '维护目的港码头车队',NULL,'POST'", + "'platform:firstleg:terminal-drayage:pickup-appointment:edit',\n '预约目的港提柜',NULL,'POST'", + "'platform:firstleg:terminal-drayage:empty-return:confirm',\n '确认目的港实际还柜',NULL,'POST'"); + } + + @Test + void grantsTheCompleteMatrixOnlyToTheExistingDefaultPlatformRoles() throws Exception { + String sql = sql(); + int grantStart = sql.indexOf("-- 平台超级管理员和现有订单运营保持货柜全链路能力"); + int assertionStart = sql.indexOf("IF (SELECT COUNT(*) FROM iam_permission", grantStart); + String grant = sql.substring(grantStart, assertionStart); + + assertThat(grant).contains( + "role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')", + "platform:firstleg:container:list", + "platform:firstleg:terminal-drayage:list", + "platform:firstleg:terminal-drayage:empty-return:confirm"); + assertThat(grant).doesNotContain( + "PLATFORM_CUSTOM_ADMIN", + "WAREHOUSE_OPERATOR", + "WAREHOUSE_TALLY", + "OVERSEAS_WAREHOUSE_PDA_OPERATOR", + "CN_FIRSTLEG_PDA_OPERATOR", + "MERCHANT_OWNER"); + assertThat(sql).contains( + "permission_code IN (", + ")) <> 8 THEN", + ")) <> 16 THEN"); + } + + @Test + void failsClosedForFixedIdentityConflictsAndRemainsReplaySafe() throws Exception { + assertThat(sql()).contains( + "migrate_container_workspace_permissions", + "id = 5102090 AND permission_code <> 'platform:firstleg:container:list'", + "id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm'", + "permission_code = 'platform:firstleg:terminal-drayage:list' AND id <> 5102200", + "SIGNAL SQLSTATE '45000'", + "ON DUPLICATE KEY UPDATE", + "INSERT IGNORE INTO iam_role_permission"); + } + + @Test + void unifiedIncrementContainsTheCompleteModuleSql() throws Exception { + Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(unifiedPath)) { + unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + } + String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); + String startMarker = "-- 货柜双工作台权限开始(与 iam/db/container-workspace-permissions.sql 保持一致)"; + String endMarker = "-- 货柜双工作台权限结束,恢复头程数据库上下文"; + int start = unified.indexOf(startMarker); + int bodyStart = unified.indexOf('\n', start) + 1; + int end = unified.indexOf(endMarker, bodyStart); + + assertThat(start).isGreaterThanOrEqualTo(0); + assertThat(bodyStart).isGreaterThan(start); + assertThat(end).isGreaterThan(bodyStart); + assertThat(normalizeLineEndings(unified.substring(bodyStart, end)).trim()) + .isEqualTo(normalizeLineEndings(sql()).trim()); + } + + private static String normalizeLineEndings(String value) { + return value.replace("\r\n", "\n"); + } + + private static String sql() throws Exception { + return new ClassPathResource("db/container-workspace-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static Set codes(String source) { + Set values = new LinkedHashSet<>(); + CODE.matcher(source).results().map(result -> result.group()).forEach(values::add); + return values; + } + + private static String catalog(String sql) { + int start = sql.indexOf("INSERT INTO iam_permission"); + int end = sql.indexOf("ON DUPLICATE KEY UPDATE", start); + assertThat(start).isGreaterThanOrEqualTo(0); + assertThat(end).isGreaterThan(start); + return sql.substring(start, end); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeRepositoryTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeRepositoryTest.java new file mode 100644 index 000000000..9b7f0ab7d --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeRepositoryTest.java @@ -0,0 +1,23 @@ +package com.baiyue.iam.repository; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CustomerCodeRepositoryTest { + @Test + void allocatesFiveDigitCodeFromOne() { + assertEquals("00001", CustomerCodeRepository.format(1)); + assertEquals("99999", CustomerCodeRepository.format(99_999)); + } + + @Test + void rejectsAllocationAfterFiveDigitCapacityIsExhausted() { + BizException failure = assertThrows(BizException.class, + () -> CustomerCodeRepository.format(100_000)); + + assertEquals(ResultCode.CONFLICT, failure.getResultCode()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeSchemaContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeSchemaContractTest.java new file mode 100644 index 000000000..27e88c17d --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeSchemaContractTest.java @@ -0,0 +1,34 @@ +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 CustomerCodeSchemaContractTest { + @Test + void baselineAndUnifiedSqlInitializeCustomerCodeSequenceAtOne() throws Exception { + String baseline = new ClassPathResource("db/iam.sql") + .getContentAsString(StandardCharsets.UTF_8); + Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(unifiedPath)) { + unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + } + String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); + + assertSequenceDefinition(baseline); + assertSequenceDefinition(unified); + } + + private static void assertSequenceDefinition(String sql) { + assertThat(sql) + .contains("tenant_code VARCHAR(8) NOT NULL") + .contains("CREATE TABLE IF NOT EXISTS iam_number_sequence") + .contains("PRIMARY KEY (sequence_key)") + .contains("INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1)"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionConfigPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionConfigPermissionSqlContractTest.java new file mode 100644 index 000000000..a5106e2f4 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionConfigPermissionSqlContractTest.java @@ -0,0 +1,40 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerDimensionConfigPermissionSqlContractTest { + @Test + void declaresIndependentPlatformViewAndEditPermissions() throws Exception { + String sql = new ClassPathResource("db/customer-dimension-config-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010") + .contains("(5102112, 0, 'PLATFORM', 'BUTTON', 5100010") + .contains("'firstleg:dimension-config:view'") + .contains("'firstleg:dimension-config:edit'") + .contains("SIGNAL SQLSTATE '45000'") + .contains("id = 5102111 AND permission_code <> 'firstleg:dimension-config:view'") + .contains("permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112"); + } + + @Test + void resolvesRoleGrantsByBusinessKeys() throws Exception { + String sql = new ClassPathResource("db/customer-dimension-config-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("SELECT role.id, permission.id, role.tenant_id") + .contains("role.role_code = 'PLATFORM_ADMIN'") + .contains("permission.permission_code IN (") + .contains("role.role_code = 'ORDER_OPERATOR'") + .contains("permission.permission_code = 'firstleg:dimension-config:view'") + .doesNotContain("SELECT 4000001") + .doesNotContain("SELECT 4000003"); + } +} 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/CustomerListExportProviderRepositoryTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java new file mode 100644 index 000000000..0205e4283 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java @@ -0,0 +1,53 @@ +package com.baiyue.iam.repository; + +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.mapper.IamTenantMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.AbstractWrapper; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.junit.jupiter.api.Test; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.mockito.ArgumentCaptor; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(CustomerListExportProviderRepository.class) +class CustomerListExportProviderRepositoryTest { + @MockBean private IamTenantMapper mapper; + @Resource private CustomerListExportProviderRepository repository; + @Test + void batchQueryAlwaysConstrainsMerchantSnapshotAndAscendingCursor() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + IamTenant.class); + when(mapper.selectList(any())).thenReturn(List.of()); + + repository.findBatch(100L, 200L, 501, "柏悦", "ACTIVE"); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(mapper).selectList(captor.capture()); + Wrapper query = captor.getValue(); + assertThat(query.getSqlSegment()) + .contains("tenant_type") + .contains("status") + .contains("tenant_name") + .doesNotContain("tenant_code") + .contains("id >") + .contains("id <=") + .contains("ORDER BY id ASC") + .endsWith("LIMIT 501"); + assertThat(((AbstractWrapper) query).getParamNameValuePairs().values()) + .contains("MERCHANT", "ACTIVE", 100L, 200L) + .anyMatch(value -> "%柏悦%".equals(value)); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java new file mode 100644 index 000000000..adf1dd423 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java @@ -0,0 +1,102 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerProfileMigrationContractTest { + @Test + void shouldHandleCase01() throws IOException { + String sql; + try (var stream = getClass().getResourceAsStream("/db/phase1-onboarding.sql")) { + assertThat(stream).isNotNull(); + sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(sql) + .contains("'MERCHANT_OWNER'") + .contains("SELECT owner_role.id, permission.id, 0") + .contains("owner_role.role_code='MERCHANT_OWNER'") + .doesNotContain("SELECT 4000100, id, 0"); + } + + @Test + void shouldHandleCase02() throws IOException { + String sql; + try (var stream = getClass().getResourceAsStream("/db/phase1-customer-profile.sql")) { + assertThat(stream).isNotNull(); + sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(sql) + .contains("USE baiyue_iam;") + .contains("SET NAMES utf8mb4;") + .contains("information_schema.columns") + .contains("contact_wechat") + .contains("sales_owner_name") + .contains("payment_term_days") + .contains("enterprise_type") + .contains("established_date") + .contains("registered_capital") + .contains("registered_capital_currency") + .contains("registered_address") + .contains("internal_remark"); + } + + @Test + void shouldHandleCase03() throws IOException { + Path repositoryRoot = Path.of(System.getProperty("maven.multiModuleProjectDirectory", "..")); + Path migration = repositoryRoot.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); + Path readme = repositoryRoot.resolve("deploy/sql/README.md"); + + assertThat(migration).exists(); + assertThat(readme).exists(); + String sql = Files.readString(migration, StandardCharsets.UTF_8); + String instructions = Files.readString(readme, StandardCharsets.UTF_8); + + assertThat(sql) + .contains("customer_nickname VARCHAR(32)") + .contains("ALTER TABLE iam_tenant ADD COLUMN customer_nickname VARCHAR(32)") + .contains("ALTER TABLE iam_tenant MODIFY COLUMN status") + .contains("DEFAULT 'PROFILE_REQUIRED'") + .contains("CALL phase1_iam_add_column('iam_tenant', 'approved_at'") + .contains("CALL phase1_iam_add_column('iam_tenant', 'approved_by'") + .contains("CALL phase1_iam_add_column('iam_tenant', 'reject_reason'") + .contains("CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment'") + .contains("platform:customer:view") + .contains("platform:customer:export") + .contains("platform:download:list") + .contains("'MERCHANT_OWNER'") + .contains("owner_role.id, permission.id, 0") + .contains("统一增量全部执行完成"); + assertThat(instructions) + .contains("phase1-platform-master-data-and-permission-management.sql") + .contains("phase1-customer-profile.sql") + .contains("phase1-onboarding.sql") + .contains("`PROFILE_REQUIRED` 默认状态、审核结果字段、资质审核备注及成员审计字段") + .contains("全局 `MERCHANT_OWNER` 角色模板") + .contains("测试环境结构发布只执行全量固定入口"); + } + + @Test + void unifiedMigrationGrantsCustomerOperationsToPlatformAdmin() throws IOException { + Path repositoryRoot = Path.of(System.getProperty("maven.multiModuleProjectDirectory", "..")); + String sql = Files.readString( + repositoryRoot.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + int grantStart = sql.indexOf("-- 新增平台操作权限默认只授予系统管理员。"); + int grantEnd = sql.indexOf("-- 已有客户运营角色继续具备", grantStart); + + assertThat(grantStart).isGreaterThanOrEqualTo(0); + assertThat(grantEnd).isGreaterThan(grantStart); + assertThat(sql.substring(grantStart, grantEnd)) + .contains("'platform:customer:reset-password'") + .contains("'platform:customer:delete'") + .contains("'platform:customer:impersonate'"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/DomesticContainerPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/DomesticContainerPermissionSqlContractTest.java new file mode 100644 index 000000000..41a0bf9a2 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/DomesticContainerPermissionSqlContractTest.java @@ -0,0 +1,36 @@ +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 DomesticContainerPermissionSqlContractTest { + @Test + void registersAndGrantsDedicatedPermissionsIdempotently() throws Exception { + String sql = new ClassPathResource("db/domestic-container-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(sql).contains("5103070", "5103071", "5103072", + "platform:domestic-container:list", "platform:domestic-container:inbound:create", + "platform:domestic-container:outbound:create", "ON DUPLICATE KEY UPDATE", + "INSERT IGNORE INTO iam_role_permission", "role_code='PLATFORM_ADMIN'", "<> 3"); + assertThat(sql).contains("permission id/code conflict", "permission catalog is incomplete", + "PLATFORM_ADMIN domestic container grant is incomplete"); + } + + @Test + void reservesFinalIdsAndRejectsSupplierReuse() throws Exception { + String sql = new ClassPathResource("db/domestic-container-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(sql).contains("(5103070,0,'PLATFORM','API'", "(5103071,0,'PLATFORM','BUTTON',5103070", + "(5103072,0,'PLATFORM','BUTTON',5103070"); + Path supplier = Path.of("src/main/resources/db/platform-supplier-permissions.sql"); + if (!Files.exists(supplier)) supplier = Path.of("iam/src/main/resources/db/platform-supplier-permissions.sql"); + if (Files.exists(supplier)) { + String supplierSql = Files.readString(supplier, StandardCharsets.UTF_8); + assertThat(supplierSql).doesNotContain("5103070", "5103071", "5103072"); + } + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java new file mode 100644 index 000000000..65157ea37 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java @@ -0,0 +1,77 @@ +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 FinanceG1G2PermissionSqlContractTest { + @Test + void declaresActionPermissionsAndRetiresSupersededReadPermissions() throws Exception { + String sql = new ClassPathResource("db/finance-g1-g2-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review'") + .contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit'") + .contains("NULL, 'POST', 150, 'ACTIVE', 1, 0") + .contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0") + .contains("ON DUPLICATE KEY UPDATE") + .contains("CALL migrate_finance_g1_g2_permissions()") + .contains("DROP PROCEDURE IF EXISTS migrate_finance_g1_g2_permissions") + .contains("status='INACTIVE', deleted=1") + .doesNotContain("'platform:finance:account:view',\n '查看客户财务账户及流水'") + .doesNotContain("'platform:finance:fee-source:view',\n '查看财务费用来源'"); + } + + @Test + void failsClosedOnPermissionIdentityConflictsAndAssertsCatalogCompleteness() throws Exception { + String sql = new ClassPathResource("db/finance-g1-g2-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("SIGNAL SQLSTATE '45000'") + .contains("permission_code = 'platform:finance:fee-source:edit' AND id <> 5105025") + .contains("tenant_id <> 0 OR app_code <> 'PLATFORM'") + .contains("parent_id = 5105000") + .contains("permission_type = 'MENU'") + .contains("built_in = 1") + .contains("finance permission parent is invalid") + .contains("permission catalog is incomplete"); + } + + @Test + void unifiedSqlMirrorsCatalogAndGrantsTheFinanceCatalogToPlatformAdmin() throws Exception { + String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8) + .replace("\r\n", "\n"); + String financeSection = sql.substring(sql.indexOf("-- Finance F1 人工财务权限"), + sql.indexOf("-- Admin 原型菜单与财务五页权限补充")); + + assertThat(financeSection) + .contains("(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review'") + .contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit'") + .contains("NULL, 'POST', 150, 'ACTIVE', 1, 0") + .contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0") + .contains("permission_code IN (\n 'platform:finance',") + .contains("Finance G1-G4"); + assertThat(financeSection) + .contains("SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission") + .contains("status='INACTIVE',deleted=1"); + assertThat(sql) + .contains("platform:finance:settlement-ar:list") + .contains("platform:finance:settlement-ap:list") + .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 34") + .contains("rp.role_id=4000001") + .contains("LIKE 'platform:finance:%')) <> 35") + .contains("平台管理员 Finance 授权不完整"); + } + + private static Path resolveUnifiedSql() { + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java new file mode 100644 index 000000000..f20090187 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java @@ -0,0 +1,63 @@ +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 FinanceG3G4PermissionSqlContractTest { + private static final String[] CODES = { + "platform:finance:reconciliation:create", "platform:finance:reconciliation:review", + "platform:finance:reconciliation:issue", "platform:finance:reconciliation:adjust", + "platform:finance:reconciliation:void", "platform:finance:settlement-ar:create", + "platform:finance:settlement-ap:create", "platform:finance:payment:create" + }; + + @Test + void declaresAllFrozenControllerPermissions() throws Exception { + String sql = new ClassPathResource("db/finance-g3-g4-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + + for (String code : CODES) assertThat(sql).contains(code); + assertThat(sql).contains("SIGNAL SQLSTATE '45000'") + .contains("id = 5105026 AND permission_code <> 'platform:finance:reconciliation:create'") + .contains("permission_code = 'platform:finance:payment:create' AND id <> 5105035") + .contains("id BETWEEN 5105026 AND 5105035 AND (tenant_id <> 0 OR app_code <> 'PLATFORM')") + .contains("finance G3/G4 permission id/code conflict") + .contains("permission catalog is incomplete") + .contains("finance permission parent is invalid") + .contains("CALL migrate_finance_g3_g4_permissions()") + .contains("status='INACTIVE', deleted=1") + .doesNotContain("'platform:finance:settlement-ar:view', '查看应收结算单'") + .doesNotContain("'platform:finance:settlement-ap:view', '查看应付结算单'"); + } + + @Test + void unifiedSqlContainsCatalogAndGrantsItToPlatformAdmin() throws Exception { + String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); + String financeSection = sql.substring(sql.indexOf("-- Finance F1 人工财务权限"), + sql.indexOf("-- Admin 原型菜单与财务五页权限补充")); + for (String code : CODES) assertThat(financeSection).contains(code); + assertThat(financeSection) + .contains("migrate_finance_g1_g4_permissions") + .contains("finance G1-G4 permission id/code conflict"); + assertThat(financeSection) + .contains("SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission") + .contains("platform:finance:reconciliation:create"); + assertThat(sql) + .contains("platform:finance:settlement-ar:list") + .contains("platform:finance:settlement-ap:list") + .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 34") + .contains("rp.role_id=4000001") + .contains("LIKE 'platform:finance:%')) <> 35"); + } + + private static Path resolveUnifiedSql() { + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/IdentityContactDecouplingContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/IdentityContactDecouplingContractTest.java new file mode 100644 index 000000000..32a424e52 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/IdentityContactDecouplingContractTest.java @@ -0,0 +1,23 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class IdentityContactDecouplingContractTest { + + @Test + void baselineKeepsCompanyContactNonUniqueAndUserPhoneAsLoginIdentity() throws Exception { + String sql = new ClassPathResource("db/iam.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("公司联系电话,非登录标识且不要求跨企业唯一") + .contains("用户登录手机号,与企业联系电话独立") + .doesNotContain("UNIQUE KEY uk_iam_tenant_contact_phone") + .doesNotContain("UNIQUE KEY uk_tenant_contact_phone"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java new file mode 100644 index 000000000..4df605477 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java @@ -0,0 +1,90 @@ +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 InboundHoldPermissionSqlContractTest { + @Test + void registersHoldAsASeparateWritePermissionUnderInboundList() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "(5102032, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:hold'", + "'扣货与解除扣货', NULL, 'POST'", + "permission_type='BUTTON'", + "parent_id=5102030", + "http_method='POST'"); + assertThat(sql).doesNotContain( + "permission_code IN ('platform:firstleg:inbound:list', 'platform:firstleg:inbound:hold')", + "permission_code IN ('platform:firstleg:inbound:view', 'platform:firstleg:inbound:hold')"); + } + + @Test + void failsClosedWhenTheFixedIdentityIsOccupied() throws Exception { + assertThat(sql()).contains( + "migrate_inbound_hold_permission", + "id = 5102032", + "permission_type <> 'BUTTON'", + "permission_code = 'platform:firstleg:inbound:hold'", + "SIGNAL SQLSTATE '45000'", + "权限ID、编码或类型冲突"); + } + + @Test + void grantsHoldOnlyToPlatformAdminByDefault() throws Exception { + String sql = sql(); + int grantStart = sql.indexOf("INSERT IGNORE INTO iam_role_permission"); + int grantEnd = sql.indexOf(';', grantStart); + String grant = sql.substring(grantStart, grantEnd); + + assertThat(grant).contains( + "role.app_code='PLATFORM'", + "role.role_code='PLATFORM_ADMIN'", + "permission.permission_code='platform:firstleg:inbound:hold'"); + assertThat(grant).doesNotContain( + "ORDER_OPERATOR", + "WAREHOUSE_OPERATOR", + "WAREHOUSE_TALLY", + "PLATFORM_CUSTOM_ADMIN"); + } + + @Test + void assertsTheResourceAndDefaultGrant() throws Exception { + assertThat(sql()).contains( + "入库单扣货权限初始化失败:权限资源不完整", + "入库单扣货权限初始化失败:平台超级管理员授权不完整", + ") <> 1 THEN"); + } + + @Test + void unifiedIncrementContainsTheSameMigrationBody() throws Exception { + String marker = "DROP PROCEDURE IF EXISTS migrate_inbound_hold_permission;"; + + assertThat(migrationBody(unifiedSql(), marker)).isEqualTo(migrationBody(sql(), marker)); + } + + private static String sql() throws Exception { + return new ClassPathResource("db/inbound-hold-permission.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static String unifiedSql() throws Exception { + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + Path path = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + return Files.readString(path.normalize(), StandardCharsets.UTF_8); + } + + private static String migrationBody(String sql, String marker) { + int start = sql.indexOf(marker); + int end = sql.indexOf(marker, start + marker.length()); + assertThat(start).isGreaterThanOrEqualTo(0); + assertThat(end).isGreaterThan(start); + return sql.substring(start, end + marker.length()).replaceAll("\\s+", " ").trim(); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlExecutionTest.java b/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlExecutionTest.java new file mode 100644 index 000000000..fff80a052 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlExecutionTest.java @@ -0,0 +1,149 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundHoldPermissionSqlExecutionTest { + @Test + void replayKeepsOneResourceAndOnePlatformAdminGrant() throws Exception { + try (Connection connection = database()) { + insertRole(connection, 11L, "PLATFORM_ADMIN", "ACTIVE", 0); + + executeMigrationDml(connection); + executeMigrationDml(connection); + + assertThat(count(connection, "SELECT COUNT(*) FROM iam_permission WHERE permission_code=?", + "platform:firstleg:inbound:hold")).isOne(); + assertThat(grantCount(connection, 11L)).isOne(); + assertThat(count(connection, "SELECT COUNT(*) FROM iam_role_permission", null)).isOne(); + } + } + + @Test + void ordinaryAndInactiveRolesDoNotReceiveTheWritePermission() throws Exception { + try (Connection connection = database()) { + insertRole(connection, 11L, "PLATFORM_ADMIN", "ACTIVE", 0); + insertRole(connection, 12L, "ORDER_OPERATOR", "ACTIVE", 0); + insertRole(connection, 13L, "WAREHOUSE_OPERATOR", "ACTIVE", 0); + insertRole(connection, 14L, "WAREHOUSE_TALLY", "ACTIVE", 0); + insertRole(connection, 15L, "PLATFORM_CUSTOM_ADMIN", "ACTIVE", 0); + insertRole(connection, 16L, "PLATFORM_ADMIN", "DISABLED", 0); + insertRole(connection, 17L, "PLATFORM_ADMIN", "ACTIVE", 1); + + executeMigrationDml(connection); + + assertThat(grantCount(connection, 11L)).isOne(); + for (long roleId = 12L; roleId <= 17L; roleId++) { + assertThat(grantCount(connection, roleId)).as("role %s", roleId).isZero(); + } + } + } + + private static Connection database() throws Exception { + Connection connection = DriverManager.getConnection( + "jdbc:h2:mem:inbound_hold_" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE"); + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE iam_role ( + id BIGINT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + app_code VARCHAR(32) NOT NULL, + role_code VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + deleted INT NOT NULL + ) + """); + statement.execute(""" + CREATE TABLE iam_permission ( + id BIGINT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + app_code VARCHAR(32) NOT NULL, + permission_type VARCHAR(16) NOT NULL, + parent_id BIGINT, + permission_code VARCHAR(160) NOT NULL UNIQUE, + permission_name VARCHAR(128) NOT NULL, + route_path VARCHAR(255), + http_method VARCHAR(16), + sort_no INT NOT NULL, + status VARCHAR(16) NOT NULL, + built_in INT NOT NULL, + deleted INT NOT NULL + ) + """); + statement.execute(""" + CREATE TABLE iam_role_permission ( + tenant_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + permission_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (tenant_id, role_id, permission_id) + ) + """); + } + return connection; + } + + private static void insertRole(Connection connection, long id, String roleCode, String status, int deleted) + throws Exception { + try (PreparedStatement insert = connection.prepareStatement( + "INSERT INTO iam_role VALUES (?,0,'PLATFORM',?,?,?)")) { + insert.setLong(1, id); + insert.setString(2, roleCode); + insert.setString(3, status); + insert.setInt(4, deleted); + insert.executeUpdate(); + } + } + + private static void executeMigrationDml(Connection connection) throws Exception { + String sql = sql(); + try (Statement statement = connection.createStatement()) { + statement.executeUpdate(statement(sql, "INSERT INTO iam_permission", "INSERT IGNORE INTO iam_role_permission")); + statement.executeUpdate(statement(sql, "INSERT IGNORE INTO iam_role_permission", " IF (SELECT COUNT(*)")); + } + } + + private static String statement(String sql, String startMarker, String endMarker) { + int start = sql.indexOf(startMarker); + int end = sql.indexOf(endMarker, start + startMarker.length()); + assertThat(start).isGreaterThanOrEqualTo(0); + assertThat(end).isGreaterThan(start); + return sql.substring(start, end).trim().replaceFirst(";$", ""); + } + + private static int grantCount(Connection connection, long roleId) throws Exception { + try (PreparedStatement query = connection.prepareStatement( + "SELECT COUNT(*) FROM iam_role_permission WHERE tenant_id=0 AND role_id=?")) { + query.setLong(1, roleId); + try (ResultSet result = query.executeQuery()) { + result.next(); + return result.getInt(1); + } + } + } + + private static int count(Connection connection, String sql, String permissionCode) throws Exception { + try (PreparedStatement query = connection.prepareStatement(sql)) { + if (permissionCode != null) query.setString(1, permissionCode); + try (ResultSet result = query.executeQuery()) { + result.next(); + return result.getInt(1); + } + } + } + + private static String sql() throws Exception { + return new ClassPathResource("db/inbound-hold-permission.sql") + .getContentAsString(StandardCharsets.UTF_8); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java new file mode 100644 index 000000000..64008a223 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java @@ -0,0 +1,54 @@ +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 InitialIdentitySeedSqlContractTest { + @Test + 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 assertPlatformIdentitySeed(String sql) { + assertThat(sql) + .contains("(0, 'PLATFORM', '虫虫出海平台'") + .contains("(2000001, 1000001, 'INTERNAL', '平台系统管理员'") + .contains("(3000001, 0, 2000001, 'EMPLOYEE'") + .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/LocationPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/LocationPermissionSqlContractTest.java new file mode 100644 index 000000000..cffa4ece1 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/LocationPermissionSqlContractTest.java @@ -0,0 +1,67 @@ +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 LocationPermissionSqlContractTest { + @Test + void acceptsKnownPollutionAndRestoresCanonicalPermissionIds() throws Exception { + assertRepairContract(moduleSql()); + assertRepairContract(unifiedSql()); + } + + private static void assertRepairContract(String sql) { + assertThat(sql) + .contains("permission_code = 'platform:download:list' AND parent_id = 5103000") + .contains("permission_code = 'platform:location:list' AND parent_id = 5103000") + .contains("permission_code = 'platform:download:all' AND parent_id = 5103040") + .contains("permission_code = 'platform:location:create' AND parent_id = 5103040") + .contains("库位权限迁移失败:下载中心稳定 ID 被未知权限占用") + .contains("(5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list'") + .contains("(5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all'") + .contains("(5103060, 0, 'PLATFORM', 'MENU', 5103000, 'platform:location:list'") + .contains("(5103061, 0, 'PLATFORM', 'BUTTON', 5103060, 'platform:location:create'"); + + int snapshot = sql.indexOf("INSERT IGNORE INTO tmp_location_role_grant"); + int delete = firstPositive( + sql.indexOf("DELETE role_permission", snapshot), + sql.indexOf("DELETE rp", snapshot)); + int canonicalLocation = sql.indexOf("(5103060, 0, 'PLATFORM'", delete); + int restore = sql.indexOf("FROM tmp_location_role_grant", canonicalLocation); + int cleanup = sql.indexOf("DROP TEMPORARY TABLE IF EXISTS tmp_location_role_grant", restore); + assertThat(snapshot).isGreaterThan(0); + assertThat(delete).isGreaterThan(snapshot); + assertThat(canonicalLocation).isGreaterThan(delete); + assertThat(restore).isGreaterThan(canonicalLocation); + assertThat(cleanup).isGreaterThan(restore); + } + + private static String moduleSql() throws Exception { + return new ClassPathResource("db/phase2-location-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static int firstPositive(int left, int right) { + if (left < 0) { + return right; + } + if (right < 0) { + return left; + } + return Math.min(left, right); + } + + private static String unifiedSql() throws Exception { + Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(path)) { + path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java new file mode 100644 index 000000000..d760aec46 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasCustomerPermissionSqlContractTest.java @@ -0,0 +1,109 @@ +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.Set; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasCustomerPermissionSqlContractTest { + private static final Pattern MERCHANT_CODE = Pattern.compile("merchant:[a-z0-9:-]+"); + private static final Set CUSTOMER_OVERSEAS_PERMISSIONS = Set.of( + "merchant:cross-border:procurement", + "merchant:cross-border:domestic-trucking", + "merchant:cross-border:pallet-shipping", + "merchant:warehouse:inbound:list"); + + @Test + void declaresTheFrozenCustomerOverseasPermissionCatalog() throws Exception { + String sql = sql(); + + assertThat(merchantCodes(sql)).containsExactlyInAnyOrderElementsOf(CUSTOMER_OVERSEAS_PERMISSIONS); + for (String permission : CUSTOMER_OVERSEAS_PERMISSIONS) { + assertThat(sql).contains("0,'MERCHANT','MENU'", "'" + permission + "'"); + } + assertThat(sql).contains( + "'merchant:cross-border:procurement','海外采购','/cross-border/procurement'", + "'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking'", + "'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping'", + "'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound'"); + } + + @Test + void grantsAllFourPermissionsOnlyToTheDefaultCustomerAdministrator() throws Exception { + String sql = sql(); + int comment = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口"); + int grantStart = sql.indexOf("INSERT IGNORE INTO iam_role_permission", comment); + int grantEnd = sql.indexOf(';', grantStart); + + assertThat(comment).isGreaterThanOrEqualTo(0); + assertThat(grantStart).isGreaterThan(comment); + assertThat(grantEnd).isGreaterThan(grantStart); + String grant = sql.substring(grantStart, grantEnd); + assertThat(merchantCodes(grant)).containsExactlyInAnyOrderElementsOf(CUSTOMER_OVERSEAS_PERMISSIONS); + assertThat(grant).contains( + "role.app_code='MERCHANT'", + "role.role_code='MERCHANT_OWNER'", + "permission.app_code='MERCHANT'"); + assertThat(grant).doesNotContain("PLATFORM_ADMIN", "PLATFORM_CUSTOM_ADMIN"); + } + + @Test + void assertsCatalogAndOwnerGrantCompleteness() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "assert_overseas_merchant_permissions", + "海外客户权限初始化失败:权限资源不完整", + "海外客户权限初始化失败:客户默认管理员授权不完整", + "permission.app_code='MERCHANT'", + "role.role_code='MERCHANT_OWNER'", + ")) <> 4 THEN"); + } + + @Test + void keepsAllOverseasPdaOperationsBehindTheSingleExistingPermission() throws Exception { + String sql = sql(); + Set pdaCodes = new LinkedHashSet<>(); + Pattern.compile("lastleg:pda:[a-z0-9:-]+") + .matcher(sql).results().map(result -> result.group()).forEach(pdaCodes::add); + + assertThat(pdaCodes).containsExactly("lastleg:pda:operate"); + assertThat(sql).contains( + "role.id=4000005", + "role.role_code='OVERSEAS_WAREHOUSE_PDA_OPERATOR'", + "存在非海外仓 PDA 作业员授权"); + assertThat(sql).doesNotContain( + "lastleg:pda:stocktake", + "lastleg:pda:transfer", + "lastleg:pda:relabel", + "lastleg:pda:query"); + } + + @Test + void matchesTheFrozenLastlegPermissionConstants() throws Exception { + Path constants = Path.of( + "lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java"); + if (!Files.exists(constants)) constants = Path.of("..").resolve(constants); + + assertThat(merchantCodes(Files.readString(constants, StandardCharsets.UTF_8))) + .containsAll(CUSTOMER_OVERSEAS_PERMISSIONS); + } + + private static Set merchantCodes(String source) { + Set values = new LinkedHashSet<>(); + MERCHANT_CODE.matcher(source).results().map(result -> result.group()).forEach(values::add); + return values; + } + + private static String sql() throws Exception { + return new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + } +} 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..b7b398184 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -0,0 +1,179 @@ +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:warehouse-transfer:approve", + "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(59).noneMatch(code -> code.endsWith(":view")); + assertThat(sql).contains( + "(5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve'"); + assertThat(sql).contains("CALL assert_warehouse_transfer_approval_permission()", + "grant_row.permission_id=5107003", "权限ID或编码冲突", "平台管理员授权缺失"); + assertThat(sql).doesNotContain( + "'platform:overseas-warehouse:warehouse-transfer:list','仓储转运审核'"); + assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); + assertThat(sql).doesNotContain(":carton:", ":product:"); + assertThat(catalog).contains("platform:overseas-warehouse:delivery:list"); + assertCustomAdminGrant(sql); + assertCustomAdminNavigationGrant(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); + assertCustomAdminNavigationGrant(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("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); + assertThat(matrix).doesNotContain(":create'", ":edit'", ":status'", ":export'", ":adjust'"); + assertThat(sql).contains("assert_phase4_warehouse_role_minimum", "<> 20", + "role.role_code IN ('WAREHOUSE_OPERATOR','OVERSEAS_WAREHOUSE_PDA_OPERATOR','WAREHOUSE_TALLY','CN_FIRSTLEG_PDA_OPERATOR')"); + } + } + + @Test + void servicePermissionConstantsExactlyMatchTheIamCatalog() throws Exception { + Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8)); + codes(new ClassPathResource("db/phase5-delivery-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8)).stream() + .filter(code -> code.startsWith("platform:")) + .forEach(moduleCatalog::add); + 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", + "存在非海外仓 PDA 作业员授权", + "role.role_code='OVERSEAS_WAREHOUSE_PDA_OPERATOR'", + "permission.permission_code='lastleg:pda:operate'", + "role.role_code='PLATFORM_ADMIN'", + "DELETE role_permission FROM"); + int picker = sql.indexOf("role.role_code='OVERSEAS_WAREHOUSE_PDA_OPERATOR'"); + 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 void assertCustomAdminNavigationGrant(String sql) { + int permissionIndex = sql.indexOf("'platform:overseas-warehouse:delivery:list'", sql.indexOf("PLATFORM_CUSTOM_ADMIN needs access")); + assertThat(permissionIndex).isGreaterThanOrEqualTo(0); + int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", permissionIndex); + int grantEnd = sql.indexOf(';', permissionIndex); + assertThat(sql.substring(grantStart, grantEnd)).contains( + "UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'", + "platform:overseas-warehouse:outbound:list", + "platform:overseas-warehouse:delivery:list"); + } + + 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/PermissionInitializationSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/PermissionInitializationSqlContractTest.java new file mode 100644 index 000000000..c0eddc5cc --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/PermissionInitializationSqlContractTest.java @@ -0,0 +1,44 @@ +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 PermissionInitializationSqlContractTest { + + @Test + void keepsSharedPermissionEntryFocusedAndAlignedWithFullBaseline() throws Exception { + String module = new ClassPathResource("db/order-declaration-planning-permission.sql") + .getContentAsString(StandardCharsets.UTF_8); + String permissionOnly = Files.readString(resolve("deploy/sql/权限点初始化-MySQL8.sql"), + StandardCharsets.UTF_8); + String unified = Files.readString(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + + for (String source : new String[] {module, permissionOnly, unified}) { + assertThat(source) + .contains("5102027") + .contains("platform:order:declaration:edit") + .contains("4000001") + .contains("4000003"); + } + assertThat(permissionOnly) + .contains("START TRANSACTION") + .contains("permission_initialization_status") + .contains("SIGNAL SQLSTATE '45000'") + .contains("apply_permission_points") + .doesNotContain("apply_domestic_permission_points", "CREATE TABLE", "ALTER TABLE", + "INSERT INTO iam_tenant", "INSERT INTO iam_user", "INSERT INTO iam_membership", + "INSERT INTO auth_account"); + } + + private static Path resolve(String relative) { + Path direct = Path.of(relative); + return Files.exists(direct) ? direct : Path.of("..").resolve(relative); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java new file mode 100644 index 000000000..cf440e040 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java @@ -0,0 +1,193 @@ +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.Set; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class Phase5DeliveryPermissionSqlContractTest { + private static final Pattern CODE = Pattern.compile("(?:platform:overseas-warehouse|merchant:warehouse):[a-z0-9:-]+"); + private static final Set REQUIRED = Set.of( + "platform:overseas-warehouse:dispatch:list", + "platform:overseas-warehouse:dispatch:edit", + "platform:overseas-warehouse:dispatch:cancel", + "platform:overseas-warehouse:dispatch:transfer-delivery", + "platform:overseas-warehouse:outbound:list", + "platform:overseas-warehouse:outbound:create", + "platform:overseas-warehouse:outbound:cancel", + "platform:overseas-warehouse:delivery:list", + "platform:overseas-warehouse:exception-order:list", + "platform:overseas-warehouse:exception-order:create", + "platform:overseas-warehouse:exception-order:process", + "platform:overseas-warehouse:delivery:return", + "platform:overseas-warehouse:delivery:status", + "platform:overseas-warehouse:delivery:documents", + "platform:overseas-warehouse:customer-visit:list", + "platform:overseas-warehouse:customer-visit:edit", + "merchant:warehouse:delivery:list", + "merchant:warehouse:delivery:create"); + + @Test + void definesOnlyTheFrozenPhase5DeliveryCatalogWithoutAliases() throws Exception { + String sql = sql(); + + assertThat(codes(sql)).containsExactlyInAnyOrderElementsOf(REQUIRED); + assertThat(codes(sql)).noneMatch(code -> code.endsWith(":view")); + assertThat(sql).contains( + "guard_phase5_delivery_permission_ids", + "权限ID或编码冲突", + "permission_type=VALUES(permission_type)", + "列表权限同时覆盖列表和详情只读"); + } + + @Test + void keepsMenuRoutesAndHttpMethodsAlignedWithTheFrozenAdminAndClientContracts() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "'platform:overseas-warehouse:dispatch:list','海外排车单','/overseas-warehouse/dispatches','GET'", + "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE'", + "'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET'", + "'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST'", + "'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST'", + "'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST'", + "'platform:overseas-warehouse:delivery:status','编辑派送单状态',NULL,'PUT'", + "(5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST'", + "'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET'", + "'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT'", + "'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET'"); + assertThat(sql).doesNotContain( + "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST'", + "'merchant:warehouse:delivery:list','海外仓派送单','/overseas-warehouse/delivery-orders'"); + } + + @Test + void grantsAdministratorsAndWarehouseRolesByTheFrozenMinimumMatrix() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')", + "tmp_phase5_delivery_role_permission", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:list')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:create')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:create')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:documents')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:edit')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:list')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:list')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:list')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:list')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:customer-visit:list')", + "role.role_code IN ('OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR')", + "PDA岗位错误获得后台权限"); + assertThat(sql).doesNotContain( + "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:edit')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:create')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:create')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:process')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:return')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:status')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:documents')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:customer-visit:edit')", + "('OVERSEAS_WAREHOUSE_PDA_OPERATOR','platform:overseas-warehouse:", + "('CN_FIRSTLEG_PDA_OPERATOR','platform:overseas-warehouse:"); + } + + @Test + void validatesTheWarehouseRoleMatrixWithOneTemporaryTableReadPerStatement() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "DECLARE v_warehouse_operator_permission_count INT DEFAULT 0", + "DECLARE v_warehouse_tally_permission_count INT DEFAULT 0", + "SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0)", + "COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)", + "INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count", + "IF v_warehouse_operator_permission_count <> 16", + "OR v_warehouse_tally_permission_count <> 5 THEN", + "IF EXISTS (SELECT 1 FROM iam_role role", + "AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'", + "WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id", + "平台自定义管理员授权不完整"); + assertThat(sql).doesNotContain("WHERE role.role_code='PLATFORM_CUSTOM_ADMIN'"); + assertThat(sql).doesNotContain( + "IF (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission", + "OR (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission"); + } + + @Test + void grantsOnlyTheDefaultCustomerAdministratorAutomatically() throws Exception { + String sql = sql(); + int marker = sql.indexOf("-- 客户默认管理员获得派送列表/详情只读及独立创建"); + int assertion = sql.indexOf("DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions", marker); + assertThat(marker).isGreaterThanOrEqualTo(0); + assertThat(assertion).isGreaterThan(marker); + + String grant = sql.substring(marker, assertion); + assertThat(grant).contains( + "role.role_code='MERCHANT_OWNER'", + "merchant:warehouse:delivery:list", + "merchant:warehouse:delivery:create"); + assertThat(grant).doesNotContain("MERCHANT_OPERATOR", "ORDER_VIEWER", "role.role_code IN"); + } + + @Test + void domesticUnifiedEntryInlinesTheCompleteModuleSqlAfterPhase4Permissions() throws Exception { + String moduleSql = sql().trim(); + String unifiedSql = repositoryFile("deploy/sql/头程数据库统一增量-MySQL8.sql"); + int overseasCatalog = unifiedSql.indexOf("-- 海外权限统一目录"); + int outboundDelivery = unifiedSql.indexOf("-- 海外排车、出库与派送权限(来源:iam/db/phase5-delivery-permissions.sql)"); + int bodyStart = unifiedSql.indexOf("USE baiyue_iam;", outboundDelivery); + int nextSection = unifiedSql.indexOf("-- IAM 客户提交即成功", bodyStart); + + assertThat(overseasCatalog).isGreaterThanOrEqualTo(0); + assertThat(outboundDelivery).isGreaterThan(overseasCatalog); + assertThat(bodyStart).isGreaterThan(outboundDelivery); + assertThat(nextSection).isGreaterThan(bodyStart); + assertThat(unifiedSql.substring(bodyStart, nextSection).trim()).isEqualTo(moduleSql); + assertThat(codes(unifiedSql.substring(outboundDelivery, nextSection))) + .containsExactlyInAnyOrderElementsOf(REQUIRED); + } + + @Test + void deploymentGuideUsesOnlyTheDomesticUnifiedEntry() throws Exception { + String readme = repositoryFile("deploy/sql/README.md"); + + assertThat(readme).contains( + "海外排车、出库与派送权限", + "测试和部署只执行固定入口,不再单独执行模块脚本", + "排车列表/编辑/取消/转派送", + "异常订单列表/创建/处理和客户回访列表/编辑十六项权限", + "merchant:warehouse:delivery:list", + "merchant:warehouse:delivery:create", + "PDA 岗位不得获得上述后台权限"); + } + + private static String sql() throws Exception { + return new ClassPathResource("db/phase5-delivery-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static Set codes(String source) { + Set values = new LinkedHashSet<>(); + CODE.matcher(source).results().map(result -> result.group()).forEach(values::add); + return values; + } + + 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/Phase5DeliveryPermissionSqlExecutionTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java new file mode 100644 index 000000000..3d58e8216 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java @@ -0,0 +1,254 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class Phase5DeliveryPermissionSqlExecutionTest { + private static final Pattern PLATFORM_CODE = + Pattern.compile("platform:overseas-warehouse:[a-z0-9:-]+"); + + @Test + void noCustomAdministratorDoesNotCreateAnAssertionViolation() throws Exception { + try (Connection connection = database()) { + executeGrant(connection); + + assertThat(customAdministratorViolations(connection)).isZero(); + } + } + + @Test + void onePartiallyGrantedCustomAdministratorIsCompletedIdempotently() throws Exception { + try (Connection connection = database()) { + insertRole(connection, 11L, "platform_custom_admin"); + grantFirstPermissions(connection, 11L, 3); + assertThat(customAdministratorViolations(connection)).isEqualTo(1); + + executeGrant(connection); + assertThat(grantCount(connection, 11L)).isEqualTo(16); + assertThat(customAdministratorViolations(connection)).isZero(); + + executeGrant(connection); + assertThat(grantCount(connection, 11L)).isEqualTo(16); + assertThat(totalGrantCount(connection)).isEqualTo(16); + } + } + + @Test + void multipleCustomAdministratorsExposeTheSingleIncompleteRole() throws Exception { + try (Connection connection = database()) { + insertRole(connection, 21L, "PLATFORM_CUSTOM_ADMIN"); + insertRole(connection, 22L, "Platform_Custom_Admin"); + grantFirstPermissions(connection, 21L, 16); + grantFirstPermissions(connection, 22L, 15); + + assertThat(customAdministratorViolations(connection)).isEqualTo(1); + + executeGrant(connection); + assertThat(grantCount(connection, 21L)).isEqualTo(16); + assertThat(grantCount(connection, 22L)).isEqualTo(16); + assertThat(customAdministratorViolations(connection)).isZero(); + } + } + + @Test + void multipleCompleteCustomAdministratorsRemainCompleteAfterReplay() throws Exception { + try (Connection connection = database()) { + insertRole(connection, 31L, "PLATFORM_CUSTOM_ADMIN"); + insertRole(connection, 32L, "platform_custom_admin"); + grantFirstPermissions(connection, 31L, 16); + grantFirstPermissions(connection, 32L, 16); + + assertThat(customAdministratorViolations(connection)).isZero(); + executeGrant(connection); + executeGrant(connection); + + assertThat(grantCount(connection, 31L)).isEqualTo(16); + assertThat(grantCount(connection, 32L)).isEqualTo(16); + assertThat(totalGrantCount(connection)).isEqualTo(32); + } + } + + @Test + void administratorGrantDoesNotEscalateAReadOnlyRoleOrDisabledAdministrator() throws Exception { + try (Connection connection = database()) { + insertRole(connection, 41L, "PLATFORM_ADMIN"); + insertRole(connection, 42L, "WAREHOUSE_TALLY"); + insertRole(connection, 43L, "PLATFORM_CUSTOM_ADMIN"); + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("UPDATE iam_role SET status='INACTIVE' WHERE id=43"); + statement.executeUpdate(""" + INSERT INTO iam_role_permission + SELECT 0, 42, id, CURRENT_TIMESTAMP FROM iam_permission + WHERE permission_code='platform:overseas-warehouse:delivery:list' + """); + } + + executeGrant(connection); + executeGrant(connection); + + assertThat(grantCount(connection, 41L)).isEqualTo(16); + assertThat(grantCount(connection, 42L)).isEqualTo(1); + assertThat(grantCount(connection, 43L)).isZero(); + try (Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery(""" + SELECT role_id FROM iam_role_permission grant_row + JOIN iam_permission permission ON permission.id=grant_row.permission_id + WHERE permission.permission_code='platform:overseas-warehouse:delivery:documents' + """)) { + assertThat(result.next()).isTrue(); + assertThat(result.getLong(1)).isEqualTo(41L); + assertThat(result.next()).isFalse(); + } + } + } + + private static Connection database() throws Exception { + Connection connection = DriverManager.getConnection( + "jdbc:h2:mem:phase5_" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE"); + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE iam_role ( + id BIGINT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + app_code VARCHAR(32) NOT NULL, + role_code VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + deleted INT NOT NULL + ) + """); + statement.execute(""" + CREATE TABLE iam_permission ( + id BIGINT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + app_code VARCHAR(32) NOT NULL, + permission_code VARCHAR(160) NOT NULL, + status VARCHAR(16) NOT NULL, + deleted INT NOT NULL + ) + """); + statement.execute(""" + CREATE TABLE iam_role_permission ( + tenant_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + permission_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (tenant_id, role_id, permission_id) + ) + """); + } + long id = 1; + for (String code : platformPermissions()) { + try (PreparedStatement insert = connection.prepareStatement( + "INSERT INTO iam_permission VALUES (?,0,'PLATFORM',?,'ACTIVE',0)")) { + insert.setLong(1, id++); + insert.setString(2, code); + insert.executeUpdate(); + } + } + return connection; + } + + private static void insertRole(Connection connection, long id, String roleCode) throws Exception { + try (PreparedStatement insert = connection.prepareStatement( + "INSERT INTO iam_role VALUES (?,0,'PLATFORM',?,'ACTIVE',0)")) { + insert.setLong(1, id); + insert.setString(2, roleCode); + insert.executeUpdate(); + } + } + + private static void grantFirstPermissions(Connection connection, long roleId, int limit) throws Exception { + try (PreparedStatement insert = connection.prepareStatement(""" + INSERT INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) + SELECT 0, ?, id, CURRENT_TIMESTAMP FROM iam_permission ORDER BY id LIMIT ? + """)) { + insert.setLong(1, roleId); + insert.setInt(2, limit); + insert.executeUpdate(); + } + } + + private static void executeGrant(Connection connection) throws Exception { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate(administratorGrantSql()); + } + } + + private static int grantCount(Connection connection, long roleId) throws Exception { + try (PreparedStatement query = connection.prepareStatement( + "SELECT COUNT(*) FROM iam_role_permission WHERE tenant_id=0 AND role_id=?")) { + query.setLong(1, roleId); + try (ResultSet result = query.executeQuery()) { + result.next(); + return result.getInt(1); + } + } + } + + private static int totalGrantCount(Connection connection) throws Exception { + try (Statement query = connection.createStatement(); + ResultSet result = query.executeQuery("SELECT COUNT(*) FROM iam_role_permission")) { + result.next(); + return result.getInt(1); + } + } + + private static int customAdministratorViolations(Connection connection) throws Exception { + String placeholders = String.join(",", platformPermissions().stream().map(code -> "?").toList()); + String sql = """ + SELECT COUNT(*) FROM iam_role role + 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 (SELECT COUNT(*) FROM iam_role_permission grant_row + JOIN iam_permission permission ON permission.id=grant_row.permission_id + WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id + AND permission.permission_code IN (%s)) <> 16 + """.formatted(placeholders); + try (PreparedStatement query = connection.prepareStatement(sql)) { + int index = 1; + for (String code : platformPermissions()) query.setString(index++, code); + try (ResultSet result = query.executeQuery()) { + result.next(); + return result.getInt(1); + } + } + } + + private static String administratorGrantSql() throws Exception { + String sql = sql(); + int comment = sql.indexOf("-- 平台管理员默认拥有五期全部后台动作"); + int start = sql.indexOf("INSERT IGNORE INTO iam_role_permission", comment); + int end = sql.indexOf(';', start); + assertThat(comment).isGreaterThanOrEqualTo(0); + assertThat(start).isGreaterThan(comment); + assertThat(end).isGreaterThan(start); + return sql.substring(start, end); + } + + private static Set platformPermissions() throws Exception { + LinkedHashSet codes = new LinkedHashSet<>(); + String grant = administratorGrantSql(); + PLATFORM_CODE.matcher(grant).results().map(result -> result.group()).forEach(codes::add); + assertThat(codes).hasSize(16); + return codes; + } + + private static String sql() throws Exception { + return new ClassPathResource("db/phase5-delivery-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/PlatformSupplierPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/PlatformSupplierPermissionSqlContractTest.java new file mode 100644 index 000000000..cdec2a141 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/PlatformSupplierPermissionSqlContractTest.java @@ -0,0 +1,18 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import java.nio.charset.StandardCharsets; +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformSupplierPermissionSqlContractTest { + @Test void registersFourSupplierPermissionsAndGrantsPlatformAdmin() throws Exception { + String sql = new ClassPathResource("db/platform-supplier-permissions.sql").getContentAsString(StandardCharsets.UTF_8); + assertThat(sql).contains("5103080", "5103081", "5103082", "5103083", + "platform:supplier:list", "platform:supplier:create", + "platform:supplier:edit", "platform:supplier:status", "role_code='PLATFORM_ADMIN'", + "ON DUPLICATE KEY UPDATE", "INSERT IGNORE INTO iam_role_permission", + "guard_platform_supplier_permission_ids", "SIGNAL SQLSTATE '45000'", + "assert_platform_supplier_permissions"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java new file mode 100644 index 000000000..547e94802 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java @@ -0,0 +1,31 @@ +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 QualificationSchemaContractTest { + @Test + void baselineAndUnifiedSqlPersistTheUploadOriginalFileName() throws Exception { + String baseline = new ClassPathResource("db/iam.sql") + .getContentAsString(StandardCharsets.UTF_8); + Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(unifiedPath)) { + unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + } + String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); + + assertThat(baseline).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'"); + assertThat(unified) + .contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'") + .contains("CALL phase1_iam_add_column('iam_tenant_qualification', 'original_file_name',") + .contains("table_name='iam_tenant_qualification'") + .contains("column_name='original_file_name'") + .contains("统一迁移失败:IAM客户资质缺少原始文件名列"); + } +} 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/WalletPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/WalletPermissionSqlContractTest.java new file mode 100644 index 000000000..b51377eb1 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/WalletPermissionSqlContractTest.java @@ -0,0 +1,88 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class WalletPermissionSqlContractTest { + @Test + void declaresReadAndWritePermissionsAsSeparateResources() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "(5105056,0,'PLATFORM','BUTTON',5105040,'platform:finance:recharge:review'", + "(5105057,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:adjust'", + "(5305020,0,'MERCHANT','MENU',5300000,'merchant:finance:account:view'", + "(5305021,0,'MERCHANT','BUTTON',5305020,'merchant:finance:recharge:create'", + "'资金账户','/finance/account','GET'", + "'提交充值申请',NULL,'POST'", + "'审核客户充值',NULL,'POST'", + "'调整客户资金账户',NULL,'POST'"); + } + + @Test + void failsClosedOnPermissionIdentityOrTypeConflicts() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "migrate_wallet_permissions", + "permission_type <> 'MENU'", + "permission_type <> 'BUTTON'", + "permission_code = 'merchant:finance:account:view'", + "permission_code = 'platform:finance:customer-account:adjust'", + "资金账户权限初始化失败:权限ID、编码或类型冲突"); + } + + @Test + void grantsMerchantPermissionsOnlyToTheEnterpriseOwner() throws Exception { + String sql = sql(); + int permissionIndex = sql.indexOf("'merchant:finance:account:view'", sql.indexOf("INSERT IGNORE INTO")); + int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", permissionIndex); + int grantEnd = sql.indexOf(';', permissionIndex); + String grant = sql.substring(grantStart, grantEnd); + + assertThat(grant).contains( + "role.app_code='MERCHANT'", + "role.role_code='MERCHANT_OWNER'", + "'merchant:finance:account:view'", + "'merchant:finance:recharge:create'"); + assertThat(grant).doesNotContain("PLATFORM_ADMIN", "PLATFORM_CUSTOM_ADMIN"); + } + + @Test + void grantsPlatformWritesOnlyToTheSuperAdministratorByDefault() throws Exception { + String sql = sql(); + int permissionIndex = sql.indexOf("'platform:finance:recharge:review'", sql.indexOf("INSERT IGNORE INTO")); + int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", permissionIndex); + int grantEnd = sql.indexOf(';', permissionIndex); + String grant = sql.substring(grantStart, grantEnd); + + assertThat(grant).contains( + "role.app_code='PLATFORM'", + "role.role_code='PLATFORM_ADMIN'", + "'platform:finance:recharge:review'", + "'platform:finance:customer-account:adjust'"); + assertThat(grant).doesNotContain( + "PLATFORM_CUSTOM_ADMIN", + "CUSTOMER_REVIEWER", + "ORDER_OPERATOR"); + } + + @Test + void assertsCatalogAndDefaultRoleGrants() throws Exception { + assertThat(sql()).contains( + "资金账户权限初始化失败:平台写权限资源不完整", + "资金账户权限初始化失败:客户权限资源不完整", + "资金账户权限初始化失败:平台超级管理员授权不完整", + "资金账户权限初始化失败:企业负责人授权不完整", + ")) <> 2 THEN"); + } + + private static String sql() throws Exception { + return new ClassPathResource("db/wallet-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java new file mode 100644 index 000000000..1a4aaeb7d --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java @@ -0,0 +1,124 @@ +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 WarehouseRoleSqlContractTest { + @Test + void initializesWarehouseRolesWithLeastPrivilegeAndStableIds() throws Exception { + assertWarehouseRoleContract(moduleSql()); + } + + @Test + void unifiedDeploymentContainsTheSameWarehouseRoleContract() throws Exception { + String sql = unifiedSql(); + assertWarehouseRoleContract(sql); + assertThat(roleMigrationBlock(sql)) + .doesNotContain("\n ADD COLUMN IF NOT EXISTS") + .doesNotContain("DROP CHECK IF EXISTS"); + assertThat(sql.lastIndexOf("SELECT 'PASS' AS migration_status")) + .isGreaterThan(sql.indexOf("CALL assert_phase3_warehouse_roles_ready()")); + assertThat(sql.trim()).endsWith("AS message;"); + } + + private static void assertWarehouseRoleContract(String sql) { + + assertThat(sql) + .contains("(4000004, 0, 'PLATFORM', 'WAREHOUSE_OPERATOR', '国内仓后台操作员', 'SYSTEM'") + .contains("(4000005, 0, 'PLATFORM', 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', '海外仓 PDA 作业员', 'SYSTEM'") + .contains("(4000006, 0, 'PLATFORM', 'WAREHOUSE_TALLY', '国内仓理货复核员', 'SYSTEM'") + .contains("(4000007, 0, 'PLATFORM', 'CN_FIRSTLEG_PDA_OPERATOR', '国内头程 PDA 作业员', 'SYSTEM'") + .contains("ON DUPLICATE KEY UPDATE") + .contains("INSERT IGNORE INTO iam_role_permission") + .contains("'CN_FIRSTLEG_PDA_OPERATOR', 'platform:firstleg:pda:work'") + .contains("'WAREHOUSE_TALLY', 'platform:firstleg:pda:review'") + .contains("'WAREHOUSE_TALLY', 'platform:firstleg:inbound:loading:edit'") + .contains("'WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'") + .contains("operator_permission_count <> 5") + .contains("domestic_pda_permission_count <> 2") + .contains("tally_permission_count <> 12") + .contains("依赖的三期平台权限尚未完整部署") + .contains("DELETE role_permission") + .contains("expected.permission_code IS NULL") + .contains("role.role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' AND permission.permission_code = 'lastleg:pda:operate'") + .contains("最小权限授权未完整写入") + .contains("grant_row.tenant_id = 0) <> 19") + .contains("(6000004, 4000004, 0, 'TENANT', NULL)") + .contains("(6000005, 4000005, 0, 'TENANT', NULL)") + .contains("(6000006, 4000006, 0, 'TENANT', NULL)") + .contains("(6000007, 4000007, 0, 'TENANT', NULL)"); + + assertRoleGrant(sql, "CN_FIRSTLEG_PDA_OPERATOR", "platform:firstleg:pda:work"); + assertRoleGrant(sql, "WAREHOUSE_TALLY", "platform:firstleg:pda:review"); + assertRoleGrant(sql, "WAREHOUSE_OPERATOR", "platform:firstleg:inbound:loading:edit"); + + assertThat(roleGrantBlock(sql, "CN_FIRSTLEG_PDA_OPERATOR")) + .doesNotContain("platform:order") + .doesNotContain("platform:master-data") + .doesNotContain("platform:warehouse:list") + .doesNotContain("platform:firstleg:inbound:") + .doesNotContain("platform:firstleg:container:") + .doesNotContain("platform:firstleg:preload:") + .doesNotContain("platform:firstleg:loading-list:") + .doesNotContain("platform:firstleg:pda:assign") + .doesNotContain("platform:firstleg:pda:review") + .doesNotContain("platform:firstleg:outbound-dispatch:confirm"); + assertThat(roleGrantBlock(sql, "WAREHOUSE_TALLY")) + .doesNotContain("platform:firstleg:inbound:loading:complete") + .doesNotContain("platform:firstleg:inbound:picking:view") + .doesNotContain("platform:firstleg:outbound-dispatch:confirm"); + assertThat(sql) + .doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:pda:assign'") + .doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:complete'") + .doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:confirm'") + .doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:reverse'") + .doesNotContain("'CN_FIRSTLEG_PDA_OPERATOR', 'platform:firstleg:smart-loading:delete'") + .doesNotContain("'WAREHOUSE_TALLY', 'firstleg:inbound:fee-detail:edit'") + .doesNotContain("'CN_FIRSTLEG_PDA_OPERATOR', 'merchant:") + .doesNotContain("'WAREHOUSE_OPERATOR', 'merchant:") + .doesNotContain("'WAREHOUSE_TALLY', 'merchant:"); + } + + private static void assertRoleGrant(String sql, String roleCode, String permissionCode) { + assertThat(sql).contains("('" + roleCode + "', '" + permissionCode + "')"); + } + + private static String roleGrantBlock(String sql, String roleCode) { + String marker = "-- " + switch (roleCode) { + case "CN_FIRSTLEG_PDA_OPERATOR" -> "国内头程 PDA 作业员:"; + case "WAREHOUSE_TALLY" -> "理货员:"; + default -> throw new IllegalArgumentException("unsupported role " + roleCode); + }; + int start = sql.indexOf(marker); + int end = sql.indexOf("\n-- ", start + marker.length()); + return sql.substring(start, end > start ? end : sql.length()); + } + + private static String roleMigrationBlock(String sql) { + String marker = "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql"; + String endMarker = "-- BEGIN FINANCE FEE SOURCE MODULE MIGRATIONS"; + int start = sql.indexOf(marker); + int end = sql.indexOf(endMarker, start + marker.length()); + if (start < 0 || end < 0) throw new IllegalStateException("统一入口缺少仓库角色连续区块"); + return sql.substring(start, end); + } + + private static String moduleSql() throws Exception { + return new ClassPathResource("db/phase3-warehouse-roles.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static String unifiedSql() throws Exception { + Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(path)) { + path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + return Files.readString(path, StandardCharsets.UTF_8); + } +} 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 67fea8c50..d9ffdae80 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -3,42 +3,47 @@ package com.baiyue.iam.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest; +import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.mapper.AuthorizationQueryMapper; import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; 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.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(AuthorizationSnapshotService.class) class AuthorizationSnapshotServiceTest { + @MockBean private AuthorizationQueryMapper mapper; + @Resource private AuthorizationSnapshotService service; @Test void loadsRolesAndPermissionsForActiveMerchantIdentity() { - AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")) .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(mapper) + var result = service .load(new SnapshotRequest(42L, 7L, 9L, "merchant")); assertThat(result.getAppCode()).isEqualTo("MERCHANT"); 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 void rejectsPlatformIdentityOutsidePlatformTenant() { - AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); - - assertThatThrownBy(() -> service(mapper) + assertThatThrownBy(() -> service .load(new SnapshotRequest(42L, 7L, 9L, "PLATFORM"))) .isInstanceOfSatisfying(BizException.class, exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); @@ -46,19 +51,146 @@ class AuthorizationSnapshotServiceTest { @Test void rejectsInactiveMembership() { - AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(0); - assertThatThrownBy(() -> service(mapper) + assertThatThrownBy(() -> service .load(new SnapshotRequest(42L, 7L, 9L, "MERCHANT"))) .isInstanceOfSatisfying(BizException.class, exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); verify(mapper).countActiveIdentity(42L, 7L, 9L); } - private AuthorizationSnapshotService service(AuthorizationQueryMapper mapper) { - AuthorizationSnapshotService service = new AuthorizationSnapshotService(); - ReflectionTestUtils.setField(service, "mapper", mapper); - return service; + @Test + void loadsCurrentPlatformPermissionsWithoutRoleManagementPermission() { + when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1); + when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(List.of("ORDER_OPERATOR")); + when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM")) + .thenReturn(List.of("platform:order:list")); + + var result = service.currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L, + "operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); + + assertThat(result).containsExactly("platform:order:list"); + verify(mapper).findPermissions(42L, 0L, 9L, "PLATFORM"); } + + @Test + void exposesPhase5DeliveryPermissionsFromCurrentRoleAssignments() { + when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); + when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER")); + when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of( + "merchant:warehouse:delivery:list", + "merchant:warehouse:delivery:create")); + + var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L, + "owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT"); + + assertThat(result).containsExactlyInAnyOrder( + "merchant:warehouse:delivery:create", + "merchant:warehouse:delivery:list"); + } + + @Test + void exposesCustomerOverseasBusinessPermissionsFromCurrentRoleAssignments() { + when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); + when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER")); + when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of( + "merchant:cross-border:procurement", + "merchant:cross-border:domestic-trucking", + "merchant:cross-border:pallet-shipping", + "merchant:warehouse:inbound:list")); + + var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L, + "owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT"); + + assertThat(result).containsExactlyInAnyOrder( + "merchant:cross-border:procurement", + "merchant:cross-border:domestic-trucking", + "merchant:cross-border:pallet-shipping", + "merchant:warehouse:inbound:list"); + } + + @Test + void exposesWalletPermissionsFromCurrentRoleAssignments() { + when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); + when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER")); + when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of( + "merchant:finance:account:view", + "merchant:finance:recharge:create")); + + var merchantPermissions = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L, + "owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT"); + + assertThat(merchantPermissions).containsExactlyInAnyOrder( + "merchant:finance:account:view", + "merchant:finance:recharge:create"); + + when(mapper.countActiveIdentity(51L, 0L, 19L)).thenReturn(1); + when(mapper.findRoles(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of("PLATFORM_ADMIN")); + when(mapper.findPermissions(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of( + "platform:finance:recharge:review", + "platform:finance:customer-account:adjust")); + + var platformPermissions = service.currentPermissions(new AuthenticatedUser(51L, 0L, 0L, 19L, + "platform-admin", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); + + assertThat(platformPermissions).containsExactlyInAnyOrder( + "platform:finance:recharge:review", + "platform:finance:customer-account:adjust"); + } + + @Test + void exposesInboundHoldIndependentlyFromReadPermissions() { + when(mapper.countActiveIdentity(51L, 0L, 19L)).thenReturn(1); + when(mapper.findRoles(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of("PLATFORM_ADMIN")); + when(mapper.findPermissions(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of( + "platform:firstleg:inbound:list", + "platform:firstleg:inbound:hold")); + + var permissions = service.currentPermissions(new AuthenticatedUser(51L, 0L, 0L, 19L, + "platform-admin", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); + + assertThat(permissions).containsExactlyInAnyOrder( + "platform:firstleg:inbound:list", + "platform:firstleg:inbound:hold"); + } + + @Test + void rejectsIncompleteCurrentIdentityAsBusinessError() { + assertThatThrownBy(() -> service + .currentPermissions(new AuthenticatedUser(42L, 0L, 0L, null, + "operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM")) + .isInstanceOfSatisfying(BizException.class, + exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED)); + } + + @Test + void shouldHandleCase01() { + when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1); + when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(null); + when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM")).thenReturn(null); + + var result = service.currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L, + "operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); + + assertThat(result).isEmpty(); + } + + @Test + void newLoginPermissionsDisappearWhileRoleDisabledAndReturnAfterReenable() { + when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); + when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")) + .thenReturn(List.of("ORDER_VIEWER"), List.of(), List.of("ORDER_VIEWER")); + when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")) + .thenReturn(List.of("merchant:order:list", "merchant:order:view"), List.of(), + List.of("merchant:order:list", "merchant:order:view")); + SnapshotRequest request = new SnapshotRequest(42L, 7L, 9L, "MERCHANT"); + + assertThat(service.load(request).getPermissions()) + .containsExactlyInAnyOrder("merchant:order:list", "merchant:order:view"); + assertThat(service.load(request).getPermissions()).isEmpty(); + assertThat(service.load(request).getPermissions()) + .containsExactlyInAnyOrder("merchant:order:list", "merchant:order:view"); + } + } diff --git a/iam/src/test/java/com/baiyue/iam/service/BusinessAuditCoverageTest.java b/iam/src/test/java/com/baiyue/iam/service/BusinessAuditCoverageTest.java new file mode 100644 index 000000000..ac05f776c --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/BusinessAuditCoverageTest.java @@ -0,0 +1,82 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.iam.dto.MemberDtos.ResetMemberPasswordRequest; +import com.baiyue.iam.dto.PlatformInternalUserDtos.ResetInternalUserPasswordRequest; +import com.baiyue.iam.dto.PlatformUserManagementDtos.ResetPasswordRequest; +import com.baiyue.iam.dto.RbacDtos.AssignRolesRequest; +import com.baiyue.iam.dto.RbacDtos.CreateRoleRequest; +import com.baiyue.iam.dto.RbacDtos.GrantPermissionsRequest; +import com.baiyue.iam.dto.RbacDtos.UpdateRoleRequest; +import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class BusinessAuditCoverageTest { + @Test + void shouldAuditRbacChanges() throws Exception { + assertAudit(RbacService.class, "createRole", "ROLE_CREATE", "ROLE", "#request.roleCode", + long.class, String.class, CreateRoleRequest.class); + assertAudit(RbacService.class, "updateRole", "ROLE_UPDATE", "ROLE", "#roleId", + long.class, String.class, Long.class, UpdateRoleRequest.class); + assertAudit(RbacService.class, "changeStatus", "ROLE_STATUS_CHANGE", "ROLE", "#roleId", + long.class, String.class, Long.class, + com.baiyue.iam.dto.RbacDtos.ChangeRoleStatusRequest.class); + assertAudit(RbacService.class, "deleteRole", "ROLE_DELETE", "ROLE", "#roleId", + long.class, String.class, Long.class); + assertAudit(RbacService.class, "assignRoles", "MEMBER_ROLE_ASSIGN", "MEMBERSHIP", "#request.membershipId", + long.class, String.class, AssignRolesRequest.class); + assertAudit(RbacService.class, "grantPermissions", "ROLE_PERMISSION_GRANT", "ROLE", "#request.roleId", + long.class, String.class, GrantPermissionsRequest.class); + } + + @Test + void shouldNeverCapturePasswordSnapshots() throws Exception { + assertPasswordAudit(MemberService.class, "resetPassword", long.class, Long.class, + ResetMemberPasswordRequest.class); + assertPasswordAudit(PlatformUserManagementService.class, "resetPassword", Long.class, + ResetPasswordRequest.class); + assertPasswordAudit(PlatformInternalUserService.class, "resetPassword", Long.class, + ResetInternalUserPasswordRequest.class); + } + + @Test + void shouldAuditCustomerReview() throws Exception { + assertAudit(TenantService.class, "review", "TENANT_REVIEW", "TENANT_REVIEW", "#reviewId", + long.class, long.class, boolean.class, String.class); + } + + @Test + void shouldBindPlatformCustomerUpdateAuditToTargetCustomer() throws Exception { + BusinessAudit audit = annotation(TenantService.class, "updateMerchant", + long.class, UpdateTenantRequest.class); + + assertEquals("PLATFORM_TENANT_UPDATE", audit.event()); + assertEquals("TENANT", audit.resourceType()); + assertEquals("#tenantId", audit.resourceId()); + assertEquals("#tenantId", audit.tenantId()); + } + + private void assertAudit(Class type, String methodName, String event, String resourceType, + String resourceId, Class... parameterTypes) throws Exception { + BusinessAudit audit = annotation(type, methodName, parameterTypes); + assertEquals(event, audit.event()); + assertEquals(resourceType, audit.resourceType()); + assertEquals(resourceId, audit.resourceId()); + } + + private void assertPasswordAudit(Class type, String methodName, Class... parameterTypes) throws Exception { + BusinessAudit audit = annotation(type, methodName, parameterTypes); + assertFalse(audit.captureBefore()); + assertFalse(audit.captureAfter()); + } + + private BusinessAudit annotation(Class type, String methodName, Class... parameterTypes) throws Exception { + Method method = type.getDeclaredMethod(methodName, parameterTypes); + return method.getAnnotation(BusinessAudit.class); + } +} 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..2fcc59dd1 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java @@ -0,0 +1,123 @@ +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, 191L, true))); + + relay.deliver(10001L); + + ArgumentCaptor request = + ArgumentCaptor.forClass(CustomerDimensionRuleClient.InitializationRequest.class); + verify(client).initialize(anyString(), request.capture()); + assertTrue("柏悦物流".equals(request.getValue().customerName())); + verify(repository).markReady(task, workerId(), 91L); + verify(repository, never()).recordFailure(any(), anyString(), anyInt(), any(), anyString(), + anyString(), any(Boolean.class)); + } + + @Test + void missingWeightCoefficientRuleKeepsInitializationFromBecomingReady() { + 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, null, true))); + + relay.deliver(10001L); + + verify(repository, never()).markReady(any(), anyString(), any()); + verify(repository).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 new file mode 100644 index 000000000..b56e9c63d --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java @@ -0,0 +1,61 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; +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 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.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomerDimensionRuleInitializationServiceTest { + @Mock private CustomerDimensionInitializationRepository repository; + @InjectMocks private CustomerDimensionRuleInitializationService service; + + @Test + void customerCreationOnlyPersistsBackgroundIntentWithStableKey() { + var result = service.initialize(10001L, 9001L); + + assertFalse(result.created()); + assertEquals("PENDING", result.status()); + assertEquals("iam-customer-create:10001", result.initializationKey()); + verify(repository).enqueue(10001L, 9001L, "iam-customer-create:10001"); + } + + @Test + void committedRegistrationCompensationUsesTheSameStableKey() { + service.compensateRegistration(10001L); + verify(repository).requestCompensation(10001L, 0L, "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/CustomerListExportProviderServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java new file mode 100644 index 000000000..3c4459c92 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java @@ -0,0 +1,166 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportColumnType; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.repository.CustomerListExportProviderRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(CustomerListExportProviderService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=test-internal-service-token-at-least-32-bytes") +class CustomerListExportProviderServiceTest { + @MockBean private CustomerListExportProviderRepository repository; + @Resource private CustomerListExportProviderService service; + + @BeforeEach + void setUp() { + } + + @Test + void firstBatchReturnsTypedSchemaSignedMaximumIdAndBoundedRows() { + when(repository.maximumId("柏悦", "ACTIVE")).thenReturn(103L); + when(repository.estimateRows(103L, "柏悦", "ACTIVE")).thenReturn(3L); + when(repository.findBatch(null, 103L, 3, "柏悦", "ACTIVE")) + .thenReturn(List.of(tenant(101L), tenant(102L), tenant(103L))); + + var response = service.exportBatch(request(Map.of("keyword", " 柏悦 ", "status", "ACTIVE"), + null, null, 2)); + + assertEquals(1, response.schemaVersion()); + assertThat(response.schemaHash()).matches("sha256:[0-9a-f]{64}"); + assertThat(response.snapshotToken()).matches("v1\\.103\\.[A-Za-z0-9_-]+"); + assertEquals(21, response.columns().size()); + assertEquals("tenantCode", response.columns().get(1).key()); + assertEquals(ExportColumnType.DECIMAL, + response.columns().stream().filter(column -> "registeredCapital".equals(column.key())) + .findFirst().orElseThrow().type()); + assertEquals(2, response.rows().size()); + assertEquals("101", response.rows().get(0).get("id")); + assertEquals("柏", response.rows().get(0).get("customerNickname")); + assertEquals(new BigDecimal("1000000.00"), response.rows().get(0).get("registeredCapital")); + assertEquals("启用", response.rows().get(0).get("status")); + assertEquals("102", response.nextCursor()); + assertEquals(true, response.hasMore()); + assertEquals(3L, response.totalRows()); + } + + @Test + void laterBatchReusesVerifiedSnapshotAndOmitsSchemaAndEstimate() { + when(repository.maximumId("柏悦", "ACTIVE")).thenReturn(103L); + when(repository.estimateRows(103L, "柏悦", "ACTIVE")).thenReturn(3L); + when(repository.findBatch(null, 103L, 3, "柏悦", "ACTIVE")) + .thenReturn(List.of(tenant(101L), tenant(102L), tenant(103L))); + var first = service.exportBatch(request(Map.of("keyword", "柏悦", "status", "ACTIVE"), + null, null, 2)); + + when(repository.findBatch(102L, 103L, 3, "柏悦", "ACTIVE")) + .thenReturn(List.of(tenant(103L))); + var later = service.exportBatch(request(Map.of("keyword", "柏悦", "status", "ACTIVE"), + "102", first.snapshotToken(), 2)); + + assertThat(later.columns()).isEmpty(); + assertEquals(first.schemaHash(), later.schemaHash()); + assertEquals(first.snapshotToken(), later.snapshotToken()); + assertEquals(1, later.rows().size()); + assertEquals("103", later.nextCursor()); + assertEquals(false, later.hasMore()); + assertEquals(null, later.totalRows()); + verify(repository).findBatch(102L, 103L, 3, "柏悦", "ACTIVE"); + } + + @Test + void snapshotIsBoundToTaskIdentityAndFilters() { + when(repository.maximumId("柏悦", "ACTIVE")).thenReturn(103L); + when(repository.estimateRows(103L, "柏悦", "ACTIVE")).thenReturn(0L); + when(repository.findBatch(null, 103L, 2, "柏悦", "ACTIVE")).thenReturn(List.of()); + String token = service.exportBatch(request(Map.of("keyword", "柏悦", "status", "ACTIVE"), + null, null, 1)).snapshotToken(); + + BizException changedFilter = assertThrows(BizException.class, () -> service.exportBatch( + request(Map.of("keyword", "其他", "status", "ACTIVE"), "0", token, 1))); + String tampered = token.substring(0, token.length() - 1) + + (token.endsWith("A") ? "B" : "A"); + BizException tamperedToken = assertThrows(BizException.class, () -> service.exportBatch( + request(Map.of("keyword", "柏悦", "status", "ACTIVE"), "0", tampered, 1))); + + assertEquals(ResultCode.VALIDATION_FAILED, changedFilter.getResultCode()); + assertEquals(ResultCode.VALIDATION_FAILED, tamperedToken.getResultCode()); + } + + @Test + void rejectsMissingScopeInvalidLimitsAndUntrustedParametersBeforeQuerying() { + BizException tenant = assertThrows(BizException.class, () -> service.exportBatch(new ExportBatchRequest( + "190000000000000001", "9", "PLATFORM", "190000000000000009", + 1, Map.of(), null, null, 500))); + BizException app = assertThrows(BizException.class, () -> service.exportBatch(new ExportBatchRequest( + "190000000000000001", "0", "MERCHANT", "190000000000000009", + 1, Map.of(), null, null, 500))); + BizException actor = assertThrows(BizException.class, () -> service.exportBatch(new ExportBatchRequest( + "190000000000000001", "0", "PLATFORM", null, + 1, Map.of(), null, null, 500))); + BizException limit = assertThrows(BizException.class, () -> service.exportBatch( + request(Map.of(), null, null, 501))); + BizException untrusted = assertThrows(BizException.class, () -> service.exportBatch( + request(Map.of("tenantId", "9"), null, null, 500))); + BizException cursor = assertThrows(BizException.class, () -> service.exportBatch( + request(Map.of(), "1", null, 500))); + + assertThat(List.of(tenant, app, actor, limit, untrusted, cursor)) + .allMatch(error -> error.getResultCode() == ResultCode.VALIDATION_FAILED); + verify(repository, never()).maximumId(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + private ExportBatchRequest request(Map parameters, String cursor, + String snapshotToken, int limit) { + return new ExportBatchRequest("190000000000000001", "0", "PLATFORM", + "190000000000000009", 1, parameters, cursor, snapshotToken, limit); + } + + private IamTenant tenant(long id) { + IamTenant tenant = new IamTenant(); + tenant.setId(id); + tenant.setTenantCode(String.format("%05d", id)); + tenant.setTenantName("柏悦物流" + id); + tenant.setCustomerNickname("柏"); + tenant.setLegalName("柏悦物流有限公司"); + tenant.setUnifiedSocialCreditCode("91440000123456789X"); + tenant.setEnterpriseType("有限责任公司"); + tenant.setEstablishedDate(LocalDate.of(2020, 1, 2)); + tenant.setRegisteredCapital(new BigDecimal("1000000.00")); + tenant.setRegisteredCapitalCurrency("CNY"); + tenant.setRegisteredAddress("深圳市"); + tenant.setContactName("张三"); + tenant.setContactPhone("13800138000"); + tenant.setContactEmail("customer@example.com"); + tenant.setContactWechat("wechat"); + tenant.setSalesOwnerName("销售甲"); + tenant.setServicePackage("基础版"); + tenant.setPaymentTermDays(30); + tenant.setStatus("ACTIVE"); + tenant.setCreatedAt(LocalDateTime.of(2026, 8, 1, 10, 0)); + tenant.setApprovedAt(LocalDateTime.of(2026, 8, 2, 10, 0)); + return tenant; + } +} 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 a9219af34..b190038a9 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -6,23 +6,37 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.dto.MemberDtos.*; import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.mapper.IamRoleMapper; 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; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import java.time.LocalDateTime; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +@SpringJUnitConfig(MemberService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class MemberServiceTest { - private final MemberRepository repository = mock(MemberRepository.class); - private final IamRoleMapper roleMapper = mock(IamRoleMapper.class); - private final AuthAccountClient auth = mock(AuthAccountClient.class); - private final MemberService service = new MemberService(repository, roleMapper, auth, "internal-token"); + @MockBean private MemberRepository repository; + @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; @@ -33,23 +47,51 @@ class MemberServiceTest { when(repository.findByTenantAndId(100L, 20L)).thenReturn(member); when(repository.findUser(30L)).thenReturn(user); when(repository.findRoleIds(100L, 20L)).thenReturn(List.of(50L)); + when(auth.summary("internal-token", 40L)).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(40L, "member01", "测试成员", "ACTIVE", + LocalDateTime.of(2026, 8, 17, 10, 30), 100L, 20L, "MERCHANT"))); } - @Test void 成员详情返回联系方式和已有角色() { + @Test void shouldHandleCase01() { user.setPhone("13800138000"); user.setEmail("member@example.com"); MemberResponse result = service.detail(100L, 20L); assertEquals("测试成员", result.displayName()); + assertEquals("member01", result.username()); assertEquals("13800138000", result.phone()); + assertEquals(LocalDateTime.of(2026, 8, 17, 10, 30), result.lastLoginAt()); assertEquals(List.of(50L), result.roleIds()); - verifyNoInteractions(auth); + verify(auth).summary("internal-token", 40L); } - @Test void 跨租户查询被拒绝() { + @Test void shouldReturnOwnerAccountForPlatformCustomerDetail() { + IamTenant tenant = new IamTenant(); + tenant.setId(100L); tenant.setTenantType("MERCHANT"); + member.setMemberType("OWNER"); + user.setPhone("13800138000"); user.setEmail("owner@example.com"); + when(tenantRepository.findTenant(100L)).thenReturn(tenant); + when(repository.findByTenant(100L)).thenReturn(List.of(member)); + IamRole ownerRole = new IamRole(); + ownerRole.setId(50L); ownerRole.setTenantId(0L); ownerRole.setRoleName("企业管理员"); + ownerRole.setAppCode("MERCHANT"); + when(roleMapper.selectById(50L)).thenReturn(ownerRole); + when(auth.summary("internal-token", 40L)).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(40L, "merchant_owner", "企业管理员", "ACTIVE"))); + + var result = service.platformList(100L); + + assertEquals(1, result.size()); + assertEquals("merchant_owner", result.get(0).username()); + assertEquals("OWNER", result.get(0).memberType()); + assertEquals("ACTIVE", result.get(0).status()); + assertEquals(List.of("企业管理员"), result.get(0).roleNames()); + } + + @Test void shouldHandleCase02() { when(repository.findByTenantAndId(200L, 20L)).thenReturn(null); assertThrows(BizException.class, () -> service.detail(200L, 20L)); } - @Test void 企业所有者不可停用() { + @Test void shouldHandleCase03() { member.setMemberType("OWNER"); BizException exception = assertThrows(BizException.class, () -> service.changeStatus(100L, 20L, new ChangeMemberStatusRequest("DISABLED"), 20L)); @@ -57,33 +99,139 @@ class MemberServiceTest { verify(auth, never()).status(anyString(), anyLong(), any()); } - @Test void 停用成员同步认证账号并保存本地状态() { + @Test void shouldHandleCase04() { when(auth.status(anyString(), eq(40L), any())).thenReturn(ApiResponse.success(null)); MemberResponse result = service.changeStatus(100L, 20L, new ChangeMemberStatusRequest("DISABLED"), 99L); assertEquals("DISABLED", result.status()); verify(repository).save(member); verify(repository).saveUser(user); + verify(securityEvents).userStatus(40L, 100L, "MERCHANT", "DISABLED"); + verify(securityEvents).permissionChanged(40L, 100L, "MERCHANT", "MEMBER_STATUS_CHANGED"); } - @Test void 认证服务失败时不得写入本地状态() { + @Test void shouldHandleCase05() { when(auth.status(anyString(), eq(40L), any())).thenThrow(new RuntimeException("网络失败")); assertThrows(RuntimeException.class, () -> service.changeStatus(100L, 20L, new ChangeMemberStatusRequest("DISABLED"), 99L)); verify(repository, never()).save(member); } - @Test void 企业所有者不能通过成员编辑接口降级或变更角色() { + @Test void shouldHandleCase06() { member.setMemberType("OWNER"); UpdateMemberRequest request = new UpdateMemberRequest("所有者", null, null, "EMPLOYEE", null, List.of()); - assertThrows(BizException.class, () -> service.update(100L, 20L, request)); + assertThrows(BizException.class, () -> service.update(100L, 20L, request, Set.of())); verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } - @Test void 企业所有者密码不能被成员管理员重置() { + @Test void shouldHandleCase07() { + CreateMemberRequest request = new CreateMemberRequest("member01", "Password1", "成员", + null, null, "EMPLOYEE", null, List.of(50L)); + + BizException exception = assertThrows(BizException.class, + () -> service.create(100L, request, Set.of("merchant:member:create"))); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + verifyNoInteractions(auth); + 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); + when(auth.phone(eq("internal-token"), eq(40L), any())) + .thenReturn(ApiResponse.success(null)); + UpdateMemberRequest request = new UpdateMemberRequest("新姓名", "13800138000", null, + "EMPLOYEE", "M-001", List.of(50L)); + + MemberResponse result = service.update(100L, 20L, request, Set.of("merchant:member:edit")); + + assertEquals("新姓名", result.displayName()); + verify(repository).saveUser(user); + verify(repository).replaceRoles(100L, 20L, List.of(50L)); + verify(auth).phone(eq("internal-token"), eq(40L), any()); + } + + @Test void shouldHandleCase09() { + UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, + "EMPLOYEE", null, List.of(60L)); + + BizException exception = assertThrows(BizException.class, + () -> service.update(100L, 20L, request, Set.of("merchant:member:edit"))); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + verify(repository, never()).save(any()); + verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); + } + + @Test void shouldHandleCase10() { + UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, + "EMPLOYEE", null, List.of()); + + BizException exception = assertThrows(BizException.class, + () -> service.update(100L, 20L, request, Set.of("merchant:member:edit"))); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); + } + + @Test void shouldHandleCase11() { + UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, + "EMPLOYEE", null, List.of()); + when(auth.revokeSessions(anyString(), any())).thenReturn(ApiResponse.success(null)); + + service.update(100L, 20L, request, + Set.of("merchant:member:edit", "merchant:member:grant-role")); + + verify(repository).replaceRoles(100L, 20L, List.of()); + verify(auth).revokeSessions(eq("internal-token"), + eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(40L)))); + } + + @Test void shouldHandleCase12() { + when(repository.findByTenantAndId(200L, 20L)).thenReturn(null); + UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, + "EMPLOYEE", null, List.of(50L)); + + BizException exception = assertThrows(BizException.class, + () -> service.update(200L, 20L, request, Set.of("merchant:member:grant-role"))); + + assertEquals(ResultCode.NOT_FOUND, exception.getResultCode()); + verify(repository, never()).findRoleIds(200L, 20L); + } + + private IamRole activeMerchantRole(long id, long tenantId) { + IamRole role = new IamRole(); + role.setId(id); role.setTenantId(tenantId); role.setAppCode("MERCHANT"); + role.setRoleCode("MERCHANT_EMPLOYEE"); role.setStatus("ACTIVE"); + return role; + } + + @Test void shouldHandleCase13() { member.setMemberType("OWNER"); assertThrows(BizException.class, () -> service.resetPassword(100L, 20L, diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java new file mode 100644 index 000000000..339dc050c --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -0,0 +1,260 @@ +package com.baiyue.iam.service; + +import com.baiyue.iam.dto.OnboardingDtos.MerchantRegistrationRequest; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamMembershipRole; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamMembershipMapper; +import com.baiyue.iam.mapper.IamMembershipRoleMapper; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.mapper.IamTenantAddressMapper; +import com.baiyue.iam.mapper.IamTenantMapper; +import com.baiyue.iam.mapper.IamTenantQualificationMapper; +import com.baiyue.iam.mapper.IamTenantReviewMapper; +import com.baiyue.iam.mapper.IamUserMapper; +import com.baiyue.iam.repository.CustomerCodeRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(MerchantOnboardingService.class) +class MerchantOnboardingServiceTest { + @MockBean private IamTenantMapper tenants; + @MockBean private IamUserMapper users; + @MockBean private IamMembershipMapper memberships; + @MockBean private IamRoleMapper roles; + @MockBean private IamMembershipRoleMapper membershipRoles; + @MockBean private IamTenantAddressMapper addresses; + @MockBean private IamTenantQualificationMapper qualifications; + @MockBean private IamTenantReviewMapper reviews; + @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; + @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; + @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private CustomerCodeRepository customerCodes; + @Resource private MerchantOnboardingService service; + + @BeforeEach + void allocateCustomerCode() { + when(customerCodes.nextCode()).thenReturn("00001"); + } + + @Test + void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() { + + IamRole ownerRole = new IamRole(); + ownerRole.setId(4000100L); + ownerRole.setTenantId(0L); + ownerRole.setAppCode("MERCHANT"); + ownerRole.setRoleCode("MERCHANT_OWNER"); + ownerRole.setStatus("ACTIVE"); + when(roles.selectOne(any())).thenReturn(ownerRole); + + var result = service.register(new MerchantRegistrationRequest( + 9001L, "注册人", "测试企业", "张三", "13800138000", + "owner@example.com", "CN", "深圳市宝安区")); + + ArgumentCaptor tenant = ArgumentCaptor.forClass(IamTenant.class); + ArgumentCaptor user = ArgumentCaptor.forClass(IamUser.class); + ArgumentCaptor membership = ArgumentCaptor.forClass(IamMembership.class); + ArgumentCaptor roleGrant = ArgumentCaptor.forClass(IamMembershipRole.class); + verify(tenants).insert(tenant.capture()); + verify(users).insert(user.capture()); + verify(memberships).insert(membership.capture()); + 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("00001", tenant.getValue().getTenantCode()); + assertEquals(result.tenantId(), tenant.getValue().getId()); + assertEquals("测试企业", tenant.getValue().getLegalName()); + assertEquals("张三", tenant.getValue().getLegalRepresentative()); + assertEquals("注册人", tenant.getValue().getContactName()); + assertEquals(9001L, user.getValue().getAuthUserId()); + assertEquals("ACTIVE", user.getValue().getStatus()); + assertEquals(result.tenantId(), membership.getValue().getTenantId()); + assertEquals(user.getValue().getId(), membership.getValue().getUserId()); + assertEquals("OWNER", membership.getValue().getMemberType()); + assertEquals("ACTIVE", membership.getValue().getStatus()); + assertEquals(result.membershipId(), membership.getValue().getId()); + assertEquals(result.membershipId(), roleGrant.getValue().getMembershipId()); + assertEquals(4000100L, roleGrant.getValue().getRoleId()); + assertEquals(result.tenantId(), roleGrant.getValue().getTenantId()); + assertNotNull(result.tenantId()); + } + + @Test + void clientRegistrationAcceptsAnOmittedLegalRepresentative() { + IamRole ownerRole = new IamRole(); + ownerRole.setId(4000100L); + ownerRole.setTenantId(0L); + ownerRole.setAppCode("MERCHANT"); + ownerRole.setRoleCode("MERCHANT_OWNER"); + ownerRole.setStatus("ACTIVE"); + when(roles.selectOne(any())).thenReturn(ownerRole); + + service.register(new MerchantRegistrationRequest( + 9001L, "注册人", "测试企业", null, "13800138000", + "owner@example.com", "CN", "深圳市宝安区")); + + ArgumentCaptor tenant = ArgumentCaptor.forClass(IamTenant.class); + verify(tenants).insert(tenant.capture()); + assertNull(tenant.getValue().getLegalRepresentative()); + } + + @Test + void registrationLookupReturnsTheExistingQualificationDraftState() { + IamUser user = new IamUser(); + user.setId(20001L); + user.setAuthUserId(9001L); + IamMembership membership = new IamMembership(); + membership.setId(30001L); + membership.setTenantId(10001L); + membership.setUserId(20001L); + membership.setMemberType("OWNER"); + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setTenantType("MERCHANT"); + tenant.setStatus("PROFILE_REQUIRED"); + tenant.setDimensionInitializationStatus("READY"); + when(users.selectOne(any())).thenReturn(user); + when(memberships.selectOne(any())).thenReturn(membership); + when(tenants.selectById(10001L)).thenReturn(tenant); + + var result = service.registration(9001L); + + assertEquals(10001L, result.tenantId()); + assertEquals(30001L, result.membershipId()); + assertEquals("PROFILE_REQUIRED", result.tenantStatus()); + assertEquals("READY", result.dimensionInitializationStatus()); + } + + @Test + void compensationRemovesOnlyAnUnstartedRegistration() { + + IamUser user = new IamUser(); + user.setId(20001L); + user.setAuthUserId(9001L); + IamMembership membership = new IamMembership(); + membership.setId(30001L); + membership.setTenantId(10001L); + membership.setUserId(20001L); + membership.setMemberType("OWNER"); + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setStatus("PROFILE_REQUIRED"); + when(users.selectOne(any())).thenReturn(user); + when(memberships.selectList(any())).thenReturn(java.util.List.of(membership)); + when(tenants.selectOne(any())).thenReturn(tenant); + when(qualifications.selectCount(any())).thenReturn(0L); + when(reviews.selectCount(any())).thenReturn(0L); + + service.compensateRegistration(9001L); + + verify(dimensionRuleInitialization).compensateRegistration(10001L); + verify(membershipRoles).delete(any()); + verify(memberships).deleteById(30001L); + verify(addresses).delete(any()); + verify(users).deleteById((java.io.Serializable) Long.valueOf(20001L)); + verify(tenants).deleteById((java.io.Serializable) Long.valueOf(10001L)); + } + + @Test + void clientRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { + IamRole ownerRole = new IamRole(); + ownerRole.setId(4000100L); + ownerRole.setTenantId(0L); + ownerRole.setAppCode("MERCHANT"); + ownerRole.setRoleCode("MERCHANT_OWNER"); + ownerRole.setStatus("ACTIVE"); + when(roles.selectOne(any())).thenReturn(ownerRole); + org.mockito.Mockito.doThrow(new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) + .when(dimensionRuleInitialization).initialize( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); + + var failure = assertThrows(com.baiyue.common.core.exception.BizException.class, + () -> service.register(new MerchantRegistrationRequest( + 9001L, "注册人", "测试企业", "张三", "13800138000", + "owner@example.com", "CN", "深圳市宝安区"))); + + assertEquals(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); + } + + @Test + void compensationRefusesAfterQualificationUpload() { + + IamUser user = new IamUser(); + user.setId(20001L); + IamMembership membership = new IamMembership(); + membership.setId(30001L); + membership.setTenantId(10001L); + membership.setUserId(20001L); + membership.setMemberType("OWNER"); + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setStatus("PROFILE_REQUIRED"); + when(users.selectOne(any())).thenReturn(user); + when(memberships.selectList(any())).thenReturn(java.util.List.of(membership)); + when(tenants.selectOne(any())).thenReturn(tenant); + when(qualifications.selectCount(any())).thenReturn(1L); + when(reviews.selectCount(any())).thenReturn(0L); + + assertThrows(com.baiyue.common.core.exception.BizException.class, + () -> service.compensateRegistration(9001L)); + verify(membershipRoles, never()).delete(any()); + verify(users, never()).deleteById((java.io.Serializable) any()); + verify(tenants, never()).deleteById((java.io.Serializable) any()); + } + + @Test + void compensationKeepsIamRegistrationWhenDimensionRuleCompensationFails() { + IamUser user = new IamUser(); + user.setId(20001L); + user.setAuthUserId(9001L); + IamMembership membership = new IamMembership(); + membership.setId(30001L); + membership.setTenantId(10001L); + membership.setUserId(20001L); + membership.setMemberType("OWNER"); + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setStatus("PROFILE_REQUIRED"); + when(users.selectOne(any())).thenReturn(user); + when(memberships.selectList(any())).thenReturn(java.util.List.of(membership)); + when(tenants.selectOne(any())).thenReturn(tenant); + when(qualifications.selectCount(any())).thenReturn(0L); + when(reviews.selectCount(any())).thenReturn(0L); + org.mockito.Mockito.doThrow(new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "补偿失败")) + .when(dimensionRuleInitialization).compensateRegistration(10001L); + + assertThrows(com.baiyue.common.core.exception.BizException.class, + () -> service.compensateRegistration(9001L)); + + verify(membershipRoles, never()).delete(any()); + verify(users, never()).deleteById((java.io.Serializable) any()); + verify(tenants, never()).deleteById((java.io.Serializable) any()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java new file mode 100644 index 000000000..ef40a7b22 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java @@ -0,0 +1,222 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; +import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamTenantQualification; +import com.baiyue.iam.repository.ProfileResourceRepository; +import com.baiyue.iam.repository.TenantRepository; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; + +@SpringJUnitConfig(ProfileResourceService.class) +class MerchantQualificationUploadTest { + @MockBean private TenantRepository tenantRepository; + @MockBean private ProfileResourceRepository profileRepository; + @Resource private ProfileResourceService service; + + @Test + void uploadsOneOptionalDraftQualificationWithoutCreatingAReview() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setTenantType("MERCHANT"); + tenant.setStatus("PROFILE_REQUIRED"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of()); + + var response = service.submitQualification(new QualificationSubmissionRequest( + 10001L, "FREIGHT_FORWARDER_CERTIFICATE", null, + "/api/merchant/v1/files/qualifications/freight-forwarder.png", "freight-forwarder.png", + LocalDate.of(2030, 1, 1), false)); + + assertEquals("PROFILE_REQUIRED", response.tenantStatus()); + assertNull(response.reviewId()); + verify(profileRepository).insertQualification(any()); + verify(tenantRepository, never()).insertReview(any()); + } + + @Test + void replacesOnlyTheLegalPersonIdWithoutTouchingTheBusinessLicense() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setTenantType("MERCHANT"); + tenant.setStatus("REJECTED"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + IamTenantQualification businessLicense = qualification( + "BUSINESS_LICENSE", "/api/merchant/v1/files/qualifications/license.png"); + IamTenantQualification oldLegalPersonId = qualification( + "LEGAL_PERSON_ID", "/api/merchant/v1/files/qualifications/old-legal-person.png"); + when(profileRepository.findQualificationsForUpdate(10001L)) + .thenReturn(List.of(businessLicense, oldLegalPersonId)); + + var response = service.submitQualification(new QualificationSubmissionRequest( + 10001L, "LEGAL_PERSON_ID", null, + "/api/merchant/v1/files/qualifications/new-legal-person.png", + "new-legal-person.png", + LocalDate.of(2035, 12, 31), true)); + + assertEquals("/api/merchant/v1/files/qualifications/old-legal-person.png", + response.replacedDocumentUrl()); + assertEquals("SUPERSEDED", oldLegalPersonId.getStatus()); + assertEquals("PENDING", businessLicense.getStatus()); + ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class); + verify(profileRepository, times(2)).saveQualification(captor.capture()); + IamTenantQualification replacement = captor.getAllValues().get(1); + assertEquals("LEGAL_PERSON_ID", replacement.getQualificationType()); + assertEquals(LocalDate.of(2035, 12, 31), replacement.getValidTo()); + assertNull(replacement.getCertificateNo()); + verify(tenantRepository, never()).saveTenant(any()); + } + + @Test + void requiredQualificationsRejectTheLegacySingleFilePath() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setTenantType("MERCHANT"); + tenant.setStatus("PROFILE_REQUIRED"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + assertThrows(BizException.class, () -> service.submitQualification(new QualificationSubmissionRequest( + 10001L, "BUSINESS_LICENSE", "91310101abcde12345", + "/api/merchant/v1/files/qualifications/license.png", "license.png", null, false))); + + verify(profileRepository, never()).insertQualification(any()); + } + + @Test + void requiredPackagePersistsBothFilesAndCreditCodeTogether() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setTenantType("MERCHANT"); + tenant.setStatus("PROFILE_REQUIRED"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of()); + + var response = service.submitRequiredQualificationPackage(new RequiredQualificationPackageRequest( + 10001L, "91310101abcde12345", + "/api/merchant/v1/files/qualifications/license.png", "license.png", null, false, + "/api/merchant/v1/files/qualifications/legal-person.png", "legal-person.png", null, false)); + + assertEquals("PROFILE_REQUIRED", response.tenantStatus()); + assertEquals("91310101ABCDE12345", tenant.getUnifiedSocialCreditCode()); + verify(profileRepository, times(2)).insertQualification(any()); + verify(tenantRepository).saveTenant(tenant); + } + + @Test + void requiredPackageAcceptsAnEmptyOptionalLegalPersonId() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setTenantType("MERCHANT"); + tenant.setStatus("PROFILE_REQUIRED"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of()); + + var response = service.submitRequiredQualificationPackage(new RequiredQualificationPackageRequest( + 10001L, "91310101abcde12345", + "/api/merchant/v1/files/qualifications/license.png", "license.png", null, false, + null, null, null, null)); + + assertNull(response.legalPersonIdQualificationId()); + verify(profileRepository, times(1)).insertQualification(any()); + verify(tenantRepository).saveTenant(tenant); + } + + @Test + void requiredPackageCannotOverwriteAnEstablishedCreditCode() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setTenantType("MERCHANT"); + tenant.setStatus("REJECTED"); + tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + + assertThrows(BizException.class, () -> service.submitRequiredQualificationPackage( + new RequiredQualificationPackageRequest(10001L, "91310101ABCDE12345", + "/api/merchant/v1/files/qualifications/license.png", "license.png", null, true, + "/api/merchant/v1/files/qualifications/legal-person.png", "legal-person.png", null, true))); + + verify(profileRepository, never()).insertQualification(any()); + } + + @Test + void qualificationListReturnsPersistedOriginalFileNameAndUpdatedTime() { + IamTenantQualification item = qualification("LEGAL_PERSON_ID", "/files/qualification/1"); + item.setOriginalFileName("法人身份证.png"); + item.setUpdatedAt(LocalDateTime.of(2026, 9, 11, 10, 30)); + when(profileRepository.findQualifications(10001L)).thenReturn(List.of(item)); + + var response = service.qualifications(10001L); + + assertEquals("法人身份证.png", response.get(0).originalFileName()); + assertEquals(LocalDateTime.of(2026, 9, 11, 10, 30), response.get(0).updatedAt()); + } + + @Test + void deletesDraftQualificationWithTenantLock() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); + IamTenantQualification item = qualification("LEGAL_PERSON_ID", "/files/qualification/1"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + when(profileRepository.findQualificationForUpdate(10001L, 1L)).thenReturn(item); + + var response = service.deleteQualification(10001L, 1L); + + assertEquals(1L, response.qualificationId()); + assertEquals("/files/qualification/1", response.deletedDocumentUrl()); + verify(profileRepository).deleteQualification(item); + } + + @Test + void repeatedOrCrossTenantDeleteIsIdempotentAndDoesNotLeakOwnership() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("REJECTED"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + when(profileRepository.findQualificationForUpdate(10001L, 99L)).thenReturn(null); + + var response = service.deleteQualification(10001L, 99L); + + assertEquals(99L, response.qualificationId()); + assertNull(response.deletedDocumentUrl()); + verify(profileRepository, never()).deleteQualification(any()); + } + + @Test + void rejectsDeleteAfterReviewSubmission() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PENDING"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + + assertThrows(BizException.class, () -> service.deleteQualification(10001L, 1L)); + + verify(profileRepository, never()).findQualificationForUpdate(10001L, 1L); + verify(profileRepository, never()).deleteQualification(any()); + } + + private static IamTenantQualification qualification(String type, String documentUrl) { + IamTenantQualification qualification = new IamTenantQualification(); + qualification.setId(1L); + qualification.setTenantId(10001L); + qualification.setQualificationType(type); + qualification.setDocumentUrl(documentUrl); + qualification.setStatus("PENDING"); + return qualification; + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/OverseasStorageFeeInitializationServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/OverseasStorageFeeInitializationServiceTest.java new file mode 100644 index 000000000..169056e8f --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/OverseasStorageFeeInitializationServiceTest.java @@ -0,0 +1,42 @@ +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.OverseasStorageFeeClient; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +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.TransactionSynchronizationManager; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(OverseasStorageFeeInitializationService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class OverseasStorageFeeInitializationServiceTest { + @MockBean private OverseasStorageFeeClient client; + @Resource private OverseasStorageFeeInitializationService service; + + @AfterEach void clearTransaction() { + if (TransactionSynchronizationManager.isSynchronizationActive()) TransactionSynchronizationManager.clearSynchronization(); + } + + @Test void retriesThreeTimesWithDeterministicKey() { + when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("timeout")); + when(client.compensate(anyString(), any(OverseasStorageFeeClient.CompensationRequest.class))).thenReturn(ApiResponse.success(null)); + BizException error = assertThrows(BizException.class, () -> service.initialize(208L, 9L, "客户")); + assertEquals(ResultCode.SERVICE_UNAVAILABLE, error.getResultCode()); + verify(client, times(3)).initialize(anyString(), any(OverseasStorageFeeClient.InitializationRequest.class)); + verify(client).compensate("internal-token", new OverseasStorageFeeClient.CompensationRequest(208L, "iam-customer-create-overseas-fee:208")); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java index ce88c4f79..287c962af 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java @@ -2,30 +2,60 @@ package com.baiyue.iam.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest; +import com.baiyue.iam.dto.ProfileDtos.ChangeLoginIdentifiersRequest; +import com.baiyue.iam.client.AuthAccountClient; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.repository.MemberRepository; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.TestPropertySource; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +@DisplayName("个人资料服务") +@SpringJUnitConfig(PersonalProfileService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=test-internal-token") class PersonalProfileServiceTest { - private final MemberRepository repository = mock(MemberRepository.class); - private final PersonalProfileService service = new PersonalProfileService(repository); + @MockBean private MemberRepository repository; + @MockBean private AuthAccountClient authClient; + @Resource private PersonalProfileService service; - @Test void 只能修改当前租户中的本人资料() { + @Test void canOnlyModifyOwnProfileWithinCurrentTenant() { IamMembership membership = new IamMembership(); membership.setUserId(3L); membership.setStatus("ACTIVE"); IamUser user = new IamUser(); user.setId(3L); user.setAuthUserId(2L); when(repository.findByTenantAndAuthUser(1L, 2L)).thenReturn(membership); when(repository.findUser(3L)).thenReturn(user); var result = service.update(1L, 2L, "tester", - new UpdatePersonalProfileRequest("新姓名", "13800138000", "a@example.com")); + new UpdatePersonalProfileRequest("新姓名", "a@example.com")); assertEquals("新姓名", result.displayName()); verify(repository).saveUser(user); } - @Test void 跨租户身份被拒绝() { + + @Test void sensitiveLoginIdentifiersAreChangedOnlyThroughAuthReauthentication() { + IamMembership membership = new IamMembership(); membership.setUserId(3L); membership.setStatus("ACTIVE"); + IamUser user = new IamUser(); user.setId(3L); user.setAuthUserId(2L); user.setDisplayName("测试用户"); + when(repository.findByTenantAndAuthUser(1L, 2L)).thenReturn(membership); + when(repository.findUser(3L)).thenReturn(user); + when(authClient.changeLoginIdentifiers(any(), eq(2L), any())).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(2L, "new.user", "13900139000", "测试用户", "ACTIVE", + null, 1L, 4L, "MERCHANT"))); + + var result = service.changeLoginIdentifiers(1L, 2L, + new ChangeLoginIdentifiersRequest("new.user", "13900139000", "CurrentPass1")); + + assertEquals("new.user", result.username()); + assertEquals("13900139000", result.phone()); + verify(repository).saveUser(user); + } + + @Test void crossTenantIdentityIsRejected() { assertThrows(BizException.class, () -> service.get(9L, 2L, "tester")); } } diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java new file mode 100644 index 000000000..ede6e98a7 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -0,0 +1,228 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.client.AuthAccountClient; +import com.baiyue.iam.dto.PlatformInternalUserDtos.*; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamUser; +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; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(PlatformInternalUserService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +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; + + @BeforeEach + void setUp() { + membership = new IamMembership(); + membership.setId(30L); membership.setTenantId(0L); membership.setUserId(20L); + membership.setMemberType("EMPLOYEE"); membership.setMemberNo("PLATFORM-30"); + membership.setStatus("ACTIVE"); + user = new IamUser(); + user.setId(20L); user.setAuthUserId(10L); user.setUserType("INTERNAL"); + user.setDisplayName("平台运营"); user.setPhone("13800138000"); user.setStatus("ACTIVE"); + when(repository.findByTenantAndId(0L, 30L)).thenReturn(membership); + when(repository.findUser(20L)).thenReturn(user); + when(repository.findRoleIds(0L, 30L)).thenReturn(List.of(40L)); + when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "ACTIVE"))); + } + + @Test + void shouldHandleCase01() { + when(repository.findByTenant(0L)).thenReturn(List.of(membership)); + + InternalUserResponse result = service.list().get(0); + + assertEquals(30L, result.membershipId()); + assertEquals("operator", result.username()); + assertEquals("13800138000", result.phone()); + assertEquals(List.of(40L), result.roleIds()); + } + + @Test + void shouldHandleCase02() { + IamRole role = new IamRole(); + role.setId(40L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE"); + when(roleMapper.selectById(40L)).thenReturn(role); + when(auth.create(anyString(), any())).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(99L, "new.operator", "新运营", "ACTIVE"))); + CreateInternalUserRequest request = new CreateInternalUserRequest(null, null, "新运营", + "13900139000", "operator@example.com", "new.operator", "Password1", + "PLATFORM-NEW", List.of(40L)); + + InternalUserResponse result = service.create(request); + + assertEquals("new.operator", result.username()); + ArgumentCaptor captor = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), captor.capture()); + assertEquals("PLATFORM", captor.getValue().appCode()); + assertEquals(0L, captor.getValue().tenantId()); + 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 + void shouldHandleCase03() { + when(auth.create(anyString(), any())).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(99L, "new.operator", "新运营", "ACTIVE"))); + when(auth.compensate(anyString(), anyLong())).thenReturn(ApiResponse.success(null)); + doThrow(new RuntimeException("IAM 写入失败")).when(repository).insertUser(any()); + CreateInternalUserRequest request = new CreateInternalUserRequest(null, null, "新运营", + null, null, "new.operator", "Password1", null, List.of()); + + assertThrows(RuntimeException.class, () -> service.create(request)); + + verify(auth).compensate(eq("internal-token"), anyLong()); + } + + @Test + void shouldHandleCase04() { + assertThrows(BizException.class, () -> service.changeStatus(30L, + new ChangeInternalUserStatusRequest("DISABLED"), 30L)); + + verify(auth, never()).status(anyString(), anyLong(), any()); + } + + @Test + void statusChangePublishesMatchingAuthorizationSnapshot() { + when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); + + InternalUserResponse result = service.changeStatus(30L, + new ChangeInternalUserStatusRequest("DISABLED"), 99L); + + assertEquals("DISABLED", result.status()); + verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "DISABLED"); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", + "PLATFORM_INTERNAL_USER_STATUS_CHANGED"); + } + + @Test + void shouldHandleCase05() { + when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); + doThrow(new RuntimeException("IAM 写入失败")).when(repository).saveUser(user); + + assertThrows(RuntimeException.class, () -> service.changeStatus(30L, + new ChangeInternalUserStatusRequest("DISABLED"), 99L)); + + verify(auth).status(eq("internal-token"), eq(10L), + argThat(value -> "DISABLED".equals(value.status()))); + verify(auth).status(eq("internal-token"), eq(10L), + argThat(value -> "ACTIVE".equals(value.status()))); + verify(auth, times(1)).summary("internal-token", 10L); + } + + @Test + void shouldHandleCase06() { + when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "DISABLED"))); + when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); + doThrow(new RuntimeException("IAM 写入失败")).when(repository).saveUser(user); + + assertThrows(RuntimeException.class, () -> service.changeStatus(30L, + new ChangeInternalUserStatusRequest("ACTIVE"), 99L)); + + verify(auth).status(eq("internal-token"), eq(10L), + argThat(value -> "ACTIVE".equals(value.status()))); + verify(auth).status(eq("internal-token"), eq(10L), + argThat(value -> "DISABLED".equals(value.status()))); + } + + @Test + void shouldHandleCase07() { + when(auth.reset(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); + + service.resetPassword(30L, new ResetInternalUserPasswordRequest("ResetPass1")); + + verify(auth).reset(eq("internal-token"), eq(10L), + argThat(value -> "ResetPass1".equals(value.newPassword()))); + verify(repository, never()).saveUser(any()); + } + + @Test + void shouldHandleCase08() { + IamRole role = new IamRole(); + role.setId(41L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE"); + when(roleMapper.selectById(41L)).thenReturn(role); + when(auth.revokeSessions(anyString(), any())).thenReturn(ApiResponse.success(null)); + + InternalUserResponse result = service.assignRoles(30L, + new AssignInternalUserRolesRequest(List.of(41L, 41L))); + + assertEquals(List.of(41L), result.roleIds()); + verify(repository).replaceRoles(0L, 30L, List.of(41L)); + verify(auth).revokeSessions(eq("internal-token"), + eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L)))); + } + + @Test + void shouldHandleCase09() { + IamRole merchantRole = new IamRole(); + merchantRole.setId(42L); merchantRole.setTenantId(10001L); + merchantRole.setAppCode("MERCHANT"); merchantRole.setStatus("ACTIVE"); + when(roleMapper.selectById(42L)).thenReturn(merchantRole); + + assertThrows(BizException.class, () -> service.assignRoles(30L, + new AssignInternalUserRolesRequest(List.of(42L)))); + + verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java new file mode 100644 index 000000000..38c2d7ec3 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java @@ -0,0 +1,72 @@ +package com.baiyue.iam.service; + +import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamTenantQualification; +import com.baiyue.iam.repository.ProfileResourceRepository; +import com.baiyue.iam.repository.TenantRepository; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(ProfileResourceService.class) +class PlatformQualificationRegistrationTest { + @MockBean private ProfileResourceRepository repository; + @MockBean private TenantRepository tenantRepository; + @Resource private ProfileResourceService service; + @Test + void platformUploadRegistersOptionalMetadataWithoutCreatingReviewTicket() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of()); + + var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest( + 10001L, "BANK_ACCOUNT_LICENSE", null, "/api/merchant/v1/files/qualifications/file.pdf", + "file.pdf", LocalDate.of(2030, 12, 31), false)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class); + verify(repository).insertQualification(captor.capture()); + verify(repository).saveQualification(captor.getValue()); + assertEquals("BANK_ACCOUNT_LICENSE", captor.getValue().getQualificationType()); + assertEquals(LocalDate.of(2030, 12, 31), captor.getValue().getValidTo()); + assertEquals("PROFILE_REQUIRED", response.tenantStatus()); + assertNull(response.reviewId()); + assertNull(response.replacedDocumentUrl()); + } + + @Test + void platformCanReplaceAnUploadedQualificationBeforeReview() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); + IamTenantQualification existing = new IamTenantQualification(); + existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("BANK_ACCOUNT_LICENSE"); + existing.setStatus("PENDING"); + existing.setDocumentUrl("/api/merchant/v1/files/qualifications/old-file.pdf"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of(existing)); + + var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest( + 10001L, "BANK_ACCOUNT_LICENSE", null, "/api/merchant/v1/files/qualifications/new-file.pdf", + "new-file.pdf", LocalDate.of(2031, 12, 31), true)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class); + verify(repository, times(2)).saveQualification(captor.capture()); + assertEquals("SUPERSEDED", existing.getStatus()); + assertEquals("平台在送审前替换文件", existing.getReviewComment()); + assertEquals(existing.getDocumentUrl(), response.replacedDocumentUrl()); + assertEquals("PENDING", captor.getAllValues().get(1).getStatus()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java new file mode 100644 index 000000000..67b7cfc39 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -0,0 +1,266 @@ +package com.baiyue.iam.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.iam.client.AuthAccountClient; +import com.baiyue.iam.dto.PlatformUserManagementDtos.*; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamUser; +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; +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.*; +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(PlatformUserManagementService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +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; + private IamUser user; + + @BeforeEach + void setUp() { + platformRole = new IamRole(); + platformRole.setId(40001L); platformRole.setTenantId(0L); platformRole.setAppCode("PLATFORM"); + platformRole.setRoleName("平台运营"); platformRole.setStatus("ACTIVE"); + membership = new IamMembership(); + membership.setId(30L); membership.setTenantId(0L); membership.setUserId(20L); + membership.setMemberType("EMPLOYEE"); membership.setMemberNo("PLATFORM-30"); membership.setStatus("ACTIVE"); + user = new IamUser(); + user.setId(20L); user.setAuthUserId(10L); user.setUserType("INTERNAL"); + user.setDisplayName("张伟"); user.setPhone("13800138000"); user.setStatus("ACTIVE"); + + when(roles.selectById(40001L)).thenReturn(platformRole); + when(roles.selectList(any())).thenReturn(List.of(platformRole)); + when(members.findById(30L)).thenReturn(membership); + when(members.findUser(20L)).thenReturn(user); + when(members.findRoleIds(0L, 30L)).thenReturn(List.of(40001L)); + 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 + void pageReturnsOnlyRepositoryScopedPlatformUsersWithMetadata() { + when(members.pageByTenant(0L, 2, 10, "张", "ACTIVE")) + .thenReturn(new PageResult<>(List.of(membership), 2, 10, 21, 3)); + + PageResult result = service.page(2, 10, "张", "ACTIVE"); + UserResponse response = result.records().get(0); + + assertEquals("zhangwei", response.username()); + assertEquals("平台", response.tenantName()); + assertEquals(List.of("平台运营"), response.roleNames()); + assertEquals(LocalDateTime.of(2026, 6, 1, 10, 30), response.lastLoginAt()); + assertEquals(21, result.total()); + verify(members).pageByTenant(0L, 2, 10, "张", "ACTIVE"); + } + + @Test + void createRejectsMerchantTenantBeforeCreatingAuthAccount() { + CreateUserRequest request = new CreateUserRequest("newuser", "新用户", "13900139000", + "new@example.com", 10001L, List.of(40001L), "Password1", "DISABLED"); + + assertThrows(BizException.class, () -> service.create(request)); + 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(); + + assertEquals(List.of(new TenantOption(0L, "平台", "ACTIVE")), result.tenants()); + assertEquals(1, result.roles().size()); + assertEquals(0L, result.roles().get(0).tenantId()); + verifyNoInteractions(members); + } + + @Test + void updateRejectsMovingPlatformUserToMerchantTenant() { + UpdateUserRequest request = new UpdateUserRequest("张伟新", "13800138000", null, + 10002L, List.of(40001L)); + + assertThrows(BizException.class, + () -> service.update(30L, request, Set.of("platform:user:grant-role"))); + verify(auth, never()).scope(anyString(), anyLong(), any()); + } + + @Test + void tenantOrRoleChangeRequiresGrantRolePermission() { + IamRole otherRole = new IamRole(); + otherRole.setId(40003L); otherRole.setTenantId(0L); otherRole.setAppCode("PLATFORM"); + otherRole.setRoleName("操作员"); otherRole.setStatus("ACTIVE"); + when(roles.selectById(40003L)).thenReturn(otherRole); + UpdateUserRequest request = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40003L)); + + assertThrows(BizException.class, () -> service.update(30L, request, Set.of())); + verify(auth, never()).scope(anyString(), anyLong(), any()); + } + + @Test + void warehousePickerRequiresAndPersistsSingleOverseasWarehouseBinding() { + platformRole.setRoleCode("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); + 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()); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", + "PLATFORM_USER_AUTHORIZATION_CHANGED"); + } + + @Test + void domesticAndOverseasPdaRolesAreMutuallyExclusive() { + IamRole domestic = new IamRole(); + domestic.setId(40007L); domestic.setTenantId(0L); domestic.setAppCode("PLATFORM"); + domestic.setRoleCode("CN_FIRSTLEG_PDA_OPERATOR"); domestic.setRoleName("国内头程 PDA 作业员"); + domestic.setStatus("ACTIVE"); + IamRole overseas = new IamRole(); + overseas.setId(40005L); overseas.setTenantId(0L); overseas.setAppCode("PLATFORM"); + overseas.setRoleCode("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); overseas.setRoleName("海外仓 PDA 作业员"); + overseas.setStatus("ACTIVE"); + when(roles.selectById(40007L)).thenReturn(domestic); + when(roles.selectById(40005L)).thenReturn(overseas); + + UpdateUserRequest request = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40007L, 40005L), 900L); + + BizException error = assertThrows(BizException.class, + () -> service.update(30L, request, Set.of("platform:user:grant-role"))); + assertTrue(error.getMessage().contains("不能同时分配")); + verify(auth, never()).scope(anyString(), anyLong(), any()); + } + + @Test + void changingWarehouseBindingRevokesAllAuthSessionsSoAccountVersionIsBumped() { + platformRole.setRoleCode("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); + 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)))); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", + "PLATFORM_USER_AUTHORIZATION_CHANGED"); + } + + @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)))); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", + "PLATFORM_USER_AUTHORIZATION_CHANGED"); + } + + @Test + void currentPlatformOperatorCannotDisableSelf() { + assertThrows(BizException.class, + () -> service.changeStatus(30L, new ChangeStatusRequest("DISABLED"), 30L)); + verify(auth, never()).status(anyString(), anyLong(), any()); + } + + @Test + void statusChangePublishesMatchingAuthorizationSnapshot() { + when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); + when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "ACTIVE"))); + when(members.findRoleIds(0L, 30L)).thenReturn(List.of(40001L)); + + UserResponse result = service.changeStatus(30L, new ChangeStatusRequest("ACTIVE"), 99L); + + assertEquals("ACTIVE", result.status()); + verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE"); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "PLATFORM_USER_STATUS_CHANGED"); + } + + @Test + void builtInPlatformAdministratorCannotBeDisabled() { + IamRole platformAdmin = new IamRole(); + platformAdmin.setId(40000L); platformAdmin.setTenantId(0L); platformAdmin.setAppCode("PLATFORM"); + platformAdmin.setRoleCode("PLATFORM_ADMIN"); platformAdmin.setRoleName("平台超级管理员"); + platformAdmin.setStatus("ACTIVE"); + when(roles.selectById(40000L)).thenReturn(platformAdmin); + when(members.findRoleIds(0L, 30L)).thenReturn(List.of(40000L)); + + assertThrows(BizException.class, + () -> service.changeStatus(30L, new ChangeStatusRequest("DISABLED"), 99L)); + verify(auth, never()).status(anyString(), anyLong(), any()); + } + + @Test + void merchantMembershipCannotBeChangedThroughInternalUserEndpoints() { + membership.setTenantId(10001L); + + assertThrows(BizException.class, + () -> service.changeStatus(30L, new ChangeStatusRequest("ACTIVE"), 99L)); + assertThrows(BizException.class, + () -> service.resetPassword(30L, new ResetPasswordRequest("Password2"))); + verify(auth, never()).status(anyString(), anyLong(), any()); + verify(auth, never()).reset(anyString(), anyLong(), any()); + } +} 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 3e8800d72..9854bc23e 100644 --- a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java @@ -1,60 +1,65 @@ 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.AuthAccountClient; import com.baiyue.iam.dto.RbacDtos.AssignRolesRequest; import com.baiyue.iam.dto.RbacDtos.GrantPermissionsRequest; import com.baiyue.iam.dto.RbacDtos.CreateRoleRequest; import com.baiyue.iam.dto.RbacDtos.UpdateRoleRequest; import com.baiyue.iam.dto.RbacDtos.ChangeRoleStatusRequest; import com.baiyue.iam.dto.RbacDtos.CreatePermissionRequest; +import com.baiyue.iam.dto.RbacDtos.UpdatePermissionRequest; +import com.baiyue.iam.dto.RbacDtos.ChangePermissionStatusRequest; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamPermission; import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.RbacRepository; -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.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.TestPropertySource; + +import jakarta.annotation.Resource; import java.util.List; +import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -@ExtendWith(MockitoExtension.class) +@SpringJUnitConfig(RbacService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=test-internal-token") class RbacServiceTest { - @Mock private RbacRepository repository; - @Mock private MemberRepository memberRepository; - private RbacService service; - - @BeforeEach - void setUp() { - service = new RbacService(); - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "memberRepository", memberRepository); - } + @MockBean private RbacRepository repository; + @MockBean private MemberRepository memberRepository; + @MockBean private AuthAccountClient authClient; + @MockBean private SecurityEventOutboxService securityEvents; + @Resource private RbacService service; @Test - void 角色列表返回当前权限用于安全回显() { + void shouldHandleCase01() { IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM"); when(repository.findRoles(100L, "MERCHANT")).thenReturn(List.of(role)); when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L, 22L)); + when(repository.findRoleMemberCounts(100L, List.of(11L))).thenReturn(Map.of(11L, 3L)); var result = service.roles(100L, "MERCHANT"); assertEquals(Set.of(21L, 22L), result.get(0).permissionIds()); + assertEquals(3L, result.get(0).memberCount()); assertEquals(100L, result.get(0).tenantId()); + verify(repository, never()).countRoleMembers(anyLong(), anyLong()); } @Test - void 权限列表同码时优先当前租户并去除全局模板重复项() { + void shouldHandleCase02() { IamPermission global = permission(21L, 0L, "merchant:member:list"); IamPermission tenant = permission(22L, 100L, "merchant:member:list"); IamPermission fallback = permission(23L, 0L, "merchant:member:status"); @@ -69,7 +74,7 @@ class RbacServiceTest { } @Test - void 跨租户角色访问被拒绝() { + void shouldHandleCase03() { when(repository.findRole(11L, 200L, "MERCHANT")).thenReturn(null); BizException exception = assertThrows(BizException.class, @@ -79,7 +84,7 @@ class RbacServiceTest { } @Test - void 系统角色禁止删除() { + void shouldHandleCase04() { when(repository.findRole(11L, 0L, "PLATFORM")) .thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM")); @@ -91,7 +96,7 @@ class RbacServiceTest { } @Test - void 已分配成员的自定义角色禁止删除() { + void shouldHandleCase05() { when(repository.findRole(11L, 100L, "MERCHANT")) .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); when(repository.isRoleAssigned(100L, 11L)).thenReturn(true); @@ -104,7 +109,7 @@ class RbacServiceTest { } @Test - void 空权限集合可以明确清空授权() { + void shouldHandleCase06() { when(repository.findRole(11L, 100L, "MERCHANT")) .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); @@ -114,7 +119,7 @@ class RbacServiceTest { } @Test - void 空角色集合可以明确清空成员授权() { + void shouldHandleCase07() { IamMembership membership = new IamMembership(); membership.setId(31L); membership.setTenantId(100L); @@ -126,7 +131,7 @@ class RbacServiceTest { } @Test - void 成员角色回显只返回当前应用域角色() { + void shouldHandleCase08() { IamMembership membership = new IamMembership(); membership.setId(31L); membership.setTenantId(100L); @@ -142,7 +147,7 @@ class RbacServiceTest { } @Test - void 同一租户同一应用角色编码重复时返回冲突() { + void shouldHandleCase09() { when(repository.existsRoleCode(100L, "MERCHANT", "ORDER_ADMIN", null)).thenReturn(true); BizException exception = assertThrows(BizException.class, () -> service.createRole( @@ -153,7 +158,7 @@ class RbacServiceTest { } @Test - void 停用角色不得继续分配给成员() { + void shouldHandleCase10() { IamMembership membership = new IamMembership(); membership.setId(31L); membership.setTenantId(100L); @@ -170,7 +175,7 @@ class RbacServiceTest { } @Test - void 自定义角色可以编辑和停用并保持权限回显() { + void shouldHandleCase11() { IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM"); when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(role); when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L)); @@ -187,7 +192,7 @@ class RbacServiceTest { } @Test - void 未使用的自定义角色可以安全删除() { + void shouldHandleCase12() { when(repository.findRole(11L, 100L, "MERCHANT")) .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); when(repository.isRoleAssigned(100L, 11L)).thenReturn(false); @@ -198,7 +203,7 @@ class RbacServiceTest { } @Test - void 企业所有者角色不能通过普通成员授权接口修改() { + void shouldHandleCase13() { IamMembership owner = new IamMembership(); owner.setId(31L); owner.setTenantId(100L); @@ -213,7 +218,7 @@ class RbacServiceTest { } @Test - void 系统角色权限不能通过普通授权接口修改() { + void shouldHandleCase14() { when(repository.findRole(11L, 0L, "PLATFORM")) .thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM")); @@ -225,7 +230,7 @@ class RbacServiceTest { } @Test - void 企业所有者系统角色不能分配给普通成员() { + void shouldHandleCase15() { IamMembership member = new IamMembership(); member.setId(31L); member.setTenantId(100L); @@ -243,7 +248,7 @@ class RbacServiceTest { } @Test - void 权限编码重复时返回稳定冲突且不写数据库() { + void shouldHandleCase16() { when(repository.existsPermissionCode(100L, "MERCHANT", "merchant:role:test")) .thenReturn(true); CreatePermissionRequest request = new CreatePermissionRequest( @@ -256,6 +261,171 @@ class RbacServiceTest { verify(repository, never()).insertPermission(any()); } + @Test + void shouldHandleCase17() { + IamPermission builtIn = permission(21L, 0L, "platform:permission:list"); + builtIn.setAppCode("PLATFORM"); + builtIn.setBuiltIn(true); + when(repository.findOwnedPermission(21L, 0L, "PLATFORM")).thenReturn(builtIn); + + BizException exception = assertThrows(BizException.class, () -> service.changePermissionStatus( + 0L, "PLATFORM", 21L, new ChangePermissionStatusRequest("DISABLED"))); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(repository, never()).updatePermission(any()); + verify(repository, never()).deletePermission(any()); + } + + @Test + void shouldHandleCase18() { + IamPermission custom = permission(21L, 100L, "merchant:custom:edit"); + when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom); + + var updated = service.updatePermission(100L, "MERCHANT", 21L, + new UpdatePermissionRequest("新名称", 0L, "/custom", "PUT", 10)); + var disabled = service.changePermissionStatus(100L, "MERCHANT", 21L, + new ChangePermissionStatusRequest("DISABLED")); + + assertEquals("merchant:custom:edit", updated.getPermissionCode()); + assertEquals("新名称", updated.getPermissionName()); + assertEquals("DISABLED", disabled.getStatus()); + verify(repository, times(2)).updatePermission(custom); + } + + @Test + void shouldHandleCase19() { + IamPermission custom = permission(21L, 100L, "merchant:custom:edit"); + when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom); + when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(true); + + BizException childFailure = assertThrows(BizException.class, + () -> service.deletePermission(100L, "MERCHANT", 21L)); + assertEquals(ResultCode.CONFLICT, childFailure.getResultCode()); + + when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(false); + when(repository.isPermissionGranted(100L, 21L)).thenReturn(true); + BizException bindingFailure = assertThrows(BizException.class, + () -> service.deletePermission(100L, "MERCHANT", 21L)); + assertEquals(ResultCode.CONFLICT, bindingFailure.getResultCode()); + verify(repository, never()).deletePermission(any()); + } + + @Test + void shouldHandleCase20() { + IamPermission custom = permission(21L, 100L, "merchant:custom:edit"); + when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom); + when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(false); + when(repository.isPermissionGranted(100L, 21L)).thenReturn(false); + + service.deletePermission(100L, "MERCHANT", 21L); + + verify(repository).deletePermission(custom); + } + + @Test + void roleDisableAndEnableRevokeAllAssignedAccountSessions() { + IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM"); + when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(role); + when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L)); + when(repository.findAssignedAuthUserIds(100L, 11L)).thenReturn(List.of(501L, 502L)); + when(authClient.revokeSessions(any(), any())).thenReturn(ApiResponse.success(null)); + + service.changeStatus(100L, "MERCHANT", 11L, new ChangeRoleStatusRequest("DISABLED")); + service.changeStatus(100L, "MERCHANT", 11L, new ChangeRoleStatusRequest("ACTIVE")); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AuthAccountClient.RevokeAccountSessionsRequest.class); + verify(authClient, times(2)).revokeSessions(eq("test-internal-token"), captor.capture()); + assertEquals(Set.of(501L, 502L), captor.getAllValues().get(0).accountIds()); + assertEquals(Set.of(501L, 502L), captor.getAllValues().get(1).accountIds()); + } + + @Test + void rolePermissionReplacementRevokesAllAssignedAccountSessions() { + when(repository.findRole(11L, 100L, "MERCHANT")) + .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); + when(repository.findAssignedAuthUserIds(100L, 11L)).thenReturn(List.of(501L)); + when(authClient.revokeSessions(any(), any())).thenReturn(ApiResponse.success(null)); + + service.grantPermissions(100L, "MERCHANT", new GrantPermissionsRequest(11L, Set.of())); + + verify(repository).replaceRolePermissions(100L, 11L, Set.of()); + verify(authClient).revokeSessions(eq("test-internal-token"), + eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(501L)))); + } + + @Test + void permissionStatusChangeRevokesMembersOfEveryGrantedRole() { + IamPermission custom = permission(21L, 100L, "merchant:custom:edit"); + when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom); + when(repository.findRoleIdsByPermission(100L, 21L)).thenReturn(Set.of(11L, 12L)); + when(repository.findAssignedAuthUserIds(100L, 11L)).thenReturn(List.of(501L, 502L)); + when(repository.findAssignedAuthUserIds(100L, 12L)).thenReturn(List.of(502L, 503L)); + when(authClient.revokeSessions(any(), any())).thenReturn(ApiResponse.success(null)); + + service.changePermissionStatus(100L, "MERCHANT", 21L, + new ChangePermissionStatusRequest("DISABLED")); + + verify(authClient).revokeSessions(eq("test-internal-token"), + eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(501L, 502L, 503L)))); + } + + @Test + void authFailureRejectsRoleStatusChangeSoIamTransactionCanRollBack() { + IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM"); + when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(role); + when(repository.findAssignedAuthUserIds(100L, 11L)).thenReturn(List.of(501L)); + when(authClient.revokeSessions(any(), any())).thenReturn(ApiResponse.failure( + ResultCode.SERVICE_UNAVAILABLE, "auth unavailable", null, null)); + + BizException exception = assertThrows(BizException.class, () -> service.changeStatus( + 100L, "MERCHANT", 11L, new ChangeRoleStatusRequest("DISABLED"))); + + assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode()); + assertEquals("认证会话撤销失败,角色变更未生效", exception.getMessage()); + } + + @Test + void clearingMemberRolesRevokesThatMembersExistingSessions() { + IamMembership membership = new IamMembership(); + membership.setId(31L); + membership.setTenantId(100L); + membership.setUserId(41L); + membership.setMemberType("EMPLOYEE"); + IamUser user = new IamUser(); + user.setId(41L); + user.setAuthUserId(501L); + when(memberRepository.findById(31L)).thenReturn(membership); + when(memberRepository.findUser(41L)).thenReturn(user); + when(repository.findMemberRoleIds(100L, 31L)).thenReturn(Set.of(11L)); + when(repository.findRole(11L, 100L, "MERCHANT")) + .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); + when(authClient.revokeSessions(any(), any())).thenReturn(ApiResponse.success(null)); + + service.assignRoles(100L, "MERCHANT", new AssignRolesRequest(31L, Set.of())); + + verify(repository).replaceMemberRoles(100L, "MERCHANT", 31L, Set.of()); + verify(authClient).revokeSessions(eq("test-internal-token"), + eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(501L)))); + } + + @Test + void savingUnchangedMemberRolesDoesNotRevokeSessions() { + IamMembership membership = new IamMembership(); + membership.setId(31L); + membership.setTenantId(100L); + membership.setMemberType("EMPLOYEE"); + IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM"); + when(memberRepository.findById(31L)).thenReturn(membership); + when(repository.findMemberRoleIds(100L, 31L)).thenReturn(Set.of(11L)); + when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(role); + + service.assignRoles(100L, "MERCHANT", new AssignRolesRequest(31L, Set.of(11L))); + + verify(repository).replaceMemberRoles(100L, "MERCHANT", 31L, Set.of(11L)); + verifyNoInteractions(authClient); + } + private IamRole role(Long id, Long tenantId, String appCode, String roleType) { IamRole role = new IamRole(); role.setId(id); 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..9ceef4efc --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java @@ -0,0 +1,93 @@ +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"), + java.util.Set.of("PLATFORM_ADMIN"), + java.util.Set.of("platform:overseas-warehouse:last-mile-order:list")); + 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..b7e7f89c9 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java @@ -0,0 +1,103 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.IamSecurityEventsV1; +import com.baiyue.iam.mapper.AuthorizationQueryMapper; +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 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.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SecurityEventOutboxServiceTest { + @Mock JdbcTemplate jdbc; + @Mock AuthorizationQueryMapper authorizationQueries; + 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); + ReflectionTestUtils.setField(service, "authorizationQueries", authorizationQueries); + } + + @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); + } + + @Test + void permissionEventCarriesOnlyTheOverseasAuthorizationSnapshot() throws Exception { + when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(7L); + when(authorizationQueries.findRolesByIdentity(101L, 0L, "ADMIN")) + .thenReturn(List.of("PLATFORM_ADMIN")); + when(authorizationQueries.findPermissionsByIdentity(101L, 0L, "ADMIN")) + .thenReturn(List.of("platform:overseas-warehouse:orders:view", "platform:iam:users:view", + "merchant:overseas-warehouse:return-inventory:list", "lastleg:orders:view")); + + service.permissionChanged(101L, 0L, "ADMIN", "ROLE_CHANGED"); + + 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.PermissionVersionChanged payload = objectMapper.treeToValue( + envelope.payload(), IamSecurityEventsV1.PermissionVersionChanged.class); + assertThat(payload.roles()).containsExactly("PLATFORM_ADMIN"); + assertThat(payload.authorities()).containsExactlyInAnyOrder( + "platform:overseas-warehouse:orders:view", + "merchant:overseas-warehouse:return-inventory:list", "lastleg:orders:view"); + assertThat(payload.authorities()).doesNotContain("platform:iam:users:view"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java new file mode 100644 index 000000000..bb874cce6 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java @@ -0,0 +1,280 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.iam.client.AuthAccountClient; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.repository.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +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; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.security.SecureRandom; +import java.util.List; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +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; + +@SpringJUnitConfig(TenantAdministratorProvisioningService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +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; + + @AfterEach + void clearSynchronization() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void springContextCreatesBean() { + assertNotNull(service); + } + + @BeforeEach + void setUp() { + tenant = new IamTenant(); + tenant.setId(190000000000000001L); + tenant.setCreatedBy(7000L); + tenant.setContactName("张三"); + tenant.setContactPhone("13800138000"); + tenant.setContactEmail("owner@example.com"); + IamRole ownerRole = new IamRole(); + ownerRole.setId(4000100L); + ownerRole.setTenantId(0L); + ownerRole.setAppCode("MERCHANT"); + ownerRole.setRoleCode("MERCHANT_OWNER"); + ownerRole.setStatus("ACTIVE"); + when(roles.selectOne(any())).thenReturn(ownerRole); + when(auth.create(anyString(), any())).thenAnswer(invocation -> { + AuthAccountClient.CreateAccountRequest request = invocation.getArgument(1); + return ApiResponse.success(new AuthAccountClient.AccountSummary( + request.accountId(), request.username(), request.displayName(), "ACTIVE")); + }); + when(auth.compensate(anyString(), anyLong())).thenReturn(ApiResponse.success(null)); + } + + @Test + void missingOwnerCreatesAccountProfileMembershipAndOwnerRole() { + var result = service.initialize(context()); + + assertTrue(result.created()); + assertEquals("merchant_" + result.authUserId(), result.username()); + assertNotNull(result.temporaryPassword()); + assertEquals(16, result.temporaryPassword().length()); + assertTrue(result.temporaryPassword().matches("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z0-9]+$")); + + ArgumentCaptor account = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), account.capture()); + assertEquals(result.username(), account.getValue().username()); + assertEquals("13800138000", account.getValue().phone()); + assertEquals(result.temporaryPassword(), account.getValue().password()); + assertEquals("MERCHANT", account.getValue().appCode()); + assertEquals(tenant.getId(), account.getValue().tenantId()); + 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 + void platformCreationRollbackCompensatesTheNewAuthAccount() { + TransactionSynchronizationManager.initSynchronization(); + + var result = service.provisionPlatformCreatedTenant(tenant, 7000L); + List synchronizations = + TransactionSynchronizationManager.getSynchronizations(); + TransactionSynchronizationManager.clearSynchronization(); + synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); + + assertTrue(result.created()); + verify(auth).compensate("internal-token", result.authUserId()); + } + + @Test + void existingOwnerSkipsProvisioningAndDoesNotReturnCredentials() { + IamMembership owner = activeOwner(); + IamUser user = activeUser(); + when(members.findOwnerByTenant(tenant.getId())).thenReturn(owner); + when(members.findUser(owner.getUserId())).thenReturn(user); + when(auth.summary("internal-token", user.getAuthUserId())).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(user.getAuthUserId(), "existing.owner", "张三", "ACTIVE", + null, tenant.getId(), owner.getId(), "MERCHANT"))); + when(members.findRoleIds(tenant.getId(), owner.getId())).thenReturn(List.of(4000100L)); + + var result = service.initialize(context()); + + assertFalse(result.created()); + assertEquals(null, result.username()); + assertEquals(null, result.temporaryPassword()); + verify(auth, never()).create(anyString(), any()); + verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); + verifyNoInteractions(securityEvents); + } + + @Test + void existingRegisteredOwnerKeepsAccountAndRepairsMissingAdministratorRole() { + IamMembership owner = activeOwner(); + IamUser user = activeUser(); + when(members.findOwnerByTenant(tenant.getId())).thenReturn(owner); + when(members.findUser(owner.getUserId())).thenReturn(user); + when(auth.summary("internal-token", user.getAuthUserId())).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(user.getAuthUserId(), "registered.owner", "张三", "ACTIVE", + null, tenant.getId(), owner.getId(), "MERCHANT"))); + when(members.findRoleIds(tenant.getId(), owner.getId())).thenReturn(List.of(4000200L)); + + var result = service.initialize(context()); + + 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 + void clientRegistrationWithoutOriginalOwnerIsRejectedInsteadOfCreatingAnotherAdministrator() { + tenant.setCreatedBy(null); + tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); + + assertThrows(com.baiyue.common.core.exception.BizException.class, + () -> service.initialize(context())); + + verify(auth, never()).create(anyString(), any()); + verify(members, never()).insertUser(any()); + verify(members, never()).insert(any()); + } + + @Test + void ownerWithoutAuthAccountRepairsAccountUsingExistingIdentity() { + IamMembership owner = activeOwner(); + IamUser user = activeUser(); + when(members.findOwnerByTenant(tenant.getId())).thenReturn(owner); + when(members.findUser(owner.getUserId())).thenReturn(user); + when(auth.summary("internal-token", user.getAuthUserId())).thenReturn( + ApiResponse.failure(com.baiyue.common.core.api.ResultCode.NOT_FOUND, + "账号不存在", null, null)); + + var result = service.initialize(context()); + + assertTrue(result.created()); + ArgumentCaptor account = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), account.capture()); + assertEquals(user.getAuthUserId(), account.getValue().accountId()); + assertEquals(owner.getId(), account.getValue().membershipId()); + assertEquals(tenant.getId(), account.getValue().tenantId()); + 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 + void disabledOwnerPreventsApprovalInsteadOfSilentlySkippingProvisioning() { + IamMembership owner = activeOwner(); + owner.setStatus("DISABLED"); + when(members.findOwnerByTenant(tenant.getId())).thenReturn(owner); + + assertThrows(com.baiyue.common.core.exception.BizException.class, + () -> service.initialize(context())); + + verify(auth, never()).summary(anyString(), anyLong()); + verify(auth, never()).create(anyString(), any()); + } + + @Test + void mismatchedAuthScopePreventsApproval() { + IamMembership owner = activeOwner(); + IamUser user = activeUser(); + when(members.findOwnerByTenant(tenant.getId())).thenReturn(owner); + when(members.findUser(owner.getUserId())).thenReturn(user); + when(auth.summary("internal-token", user.getAuthUserId())).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(user.getAuthUserId(), "existing.owner", "张三", "ACTIVE", + null, tenant.getId() + 1, owner.getId(), "MERCHANT"))); + + assertThrows(com.baiyue.common.core.exception.BizException.class, + () -> service.initialize(context())); + + verify(auth, never()).create(anyString(), any()); + } + + @Test + void localWriteFailureCompensatesCreatedAuthAccount() { + doThrow(new RuntimeException("IAM write failed")).when(members).insertUser(any()); + + assertThrows(RuntimeException.class, () -> service.initialize(context())); + + verify(auth).compensate(eq("internal-token"), anyLong()); + verify(members, never()).insert(any(IamMembership.class)); + } + + @Test + void loginPhoneConflictStopsProvisioningWithoutCouplingCompanyContactToUsername() { + org.mockito.Mockito.doReturn(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.CONFLICT, + "登录账号已存在", null, null)).when(auth).create(anyString(), any()); + + var exception = assertThrows(com.baiyue.common.core.exception.BizException.class, + () -> service.initialize(context())); + + assertEquals(com.baiyue.common.core.api.ResultCode.CONFLICT, exception.getResultCode()); + verify(auth).create(eq("internal-token"), any()); + verify(members, never()).insertUser(any()); + } + + private TenantApprovalInitializer.ApprovalContext context() { + return new TenantApprovalInitializer.ApprovalContext(tenant, 71L, 9001L, "资料完整"); + } + + private IamMembership activeOwner() { + IamMembership owner = new IamMembership(); + owner.setId(7001L); + owner.setTenantId(tenant.getId()); + owner.setUserId(8001L); + owner.setMemberType("OWNER"); + owner.setStatus("ACTIVE"); + return owner; + } + + private IamUser activeUser() { + IamUser user = new IamUser(); + user.setId(8001L); + user.setAuthUserId(9001L); + user.setStatus("ACTIVE"); + return user; + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinatorTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinatorTest.java new file mode 100644 index 000000000..1325e23e5 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinatorTest.java @@ -0,0 +1,107 @@ +package com.baiyue.iam.service; + +import com.baiyue.iam.entity.IamTenant; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.core.annotation.Order; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class TenantApprovalInitializationCoordinatorTest { + @Test + void initializerFailureCompensatesCompletedStepsInReverseOrder() { + TenantApprovalInitializer first = initializer("rules"); + TenantApprovalInitializer second = initializer("fees"); + TenantApprovalInitializer failing = initializer("roles"); + TenantApprovalInitializer.InitializationResult firstResult = new TestResult("rules"); + TenantApprovalInitializer.InitializationResult secondResult = new TestResult("fees"); + var context = context(); + when(first.initialize(context)).thenReturn(firstResult); + when(second.initialize(context)).thenReturn(secondResult); + when(failing.initialize(context)).thenThrow(new RuntimeException("role initialization failed")); + var coordinator = coordinator(List.of(first, second, failing)); + + assertThrows(RuntimeException.class, () -> coordinator.initialize(context)); + + InOrder compensationOrder = inOrder(second, first); + compensationOrder.verify(second).compensate(context, secondResult); + compensationOrder.verify(first).compensate(context, firstResult); + } + + @Test + void completedBatchExposesTypedResultsForApprovalResponseAssembly() { + TenantApprovalInitializer initializer = initializer("administrator"); + TestResult result = new TestResult("credential-result"); + var context = context(); + when(initializer.initialize(context)).thenReturn(result); + var coordinator = coordinator(List.of(initializer)); + + var batch = coordinator.initialize(context); + + assertEquals(result, batch.result(TestResult.class).orElseThrow()); + } + + @Test + void springOrderControlsInitializerExecutionSequence() { + List calls = new ArrayList<>(); + var coordinator = coordinator(List.of(new LateInitializer(calls), new EarlyInitializer(calls))); + + coordinator.initialize(context()); + + assertEquals(List.of("early", "late"), calls); + } + + private TenantApprovalInitializer initializer(String name) { + TenantApprovalInitializer initializer = mock(TenantApprovalInitializer.class); + when(initializer.name()).thenReturn(name); + return initializer; + } + + private TenantApprovalInitializationCoordinator coordinator(List initializers) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + for (int index = 0; index < initializers.size(); index++) { + TenantApprovalInitializer initializer = initializers.get(index); + context.registerBean("initializer" + index, TenantApprovalInitializer.class, () -> initializer); + } + context.registerBean("initializers", List.class, () -> initializers); + context.registerBean(TenantApprovalInitializationCoordinator.class); + context.refresh(); + return context.getBean(TenantApprovalInitializationCoordinator.class); + } + + private TenantApprovalInitializer.ApprovalContext context() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + return new TenantApprovalInitializer.ApprovalContext(tenant, 71L, 9001L, "资料完整"); + } + + private record TestResult(String value) implements TenantApprovalInitializer.InitializationResult { } + + @Order(10) + private static final class EarlyInitializer implements TenantApprovalInitializer { + private final List calls; + private EarlyInitializer(List calls) { this.calls = calls; } + public String name() { return "early"; } + public InitializationResult initialize(ApprovalContext context) { + calls.add("early"); return new TestResult("early"); + } + } + + @Order(200) + private static final class LateInitializer implements TenantApprovalInitializer { + private final List calls; + private LateInitializer(List calls) { this.calls = calls; } + public String name() { return "late"; } + public InitializationResult initialize(ApprovalContext context) { + calls.add("late"); return new TestResult("late"); + } + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java new file mode 100644 index 000000000..aa1f23fd3 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java @@ -0,0 +1,55 @@ +package com.baiyue.iam.service; + +import com.baiyue.iam.client.AuthAccountClient; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.repository.ProfileResourceRepository; +import com.baiyue.iam.repository.MemberRepository; +import com.baiyue.iam.repository.TenantRepository; +import com.baiyue.iam.repository.CustomerCodeRepository; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(TenantService.class) +class TenantCustomerExportTest { + @MockBean private TenantRepository repository; + @MockBean private CustomerCodeRepository customerCodes; + @MockBean private ProfileResourceRepository profileRepository; + @MockBean private MemberRepository memberRepository; + @MockBean private AuthAccountClient authAccountClient; + @MockBean private TenantApprovalInitializationCoordinator approvalInitialization; + @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; + @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; + @MockBean private TenantAdministratorProvisioningService administratorProvisioning; + @MockBean private SecurityEventOutboxService securityEvents; + @Resource private TenantService service; + @Test + void returnsUnmaskedCustomerRowsAndAdvancingCursor() { + IamTenant tenant = new IamTenant(); + tenant.setId(101L); + tenant.setTenantCode("00101"); + tenant.setTenantName("柏悦物流"); + tenant.setContactPhone("13800138000"); + tenant.setContactEmail("ops@example.com"); + tenant.setStatus("ACTIVE"); + when(repository.exportMerchants(100L, 50, "柏悦", "ACTIVE")).thenReturn(List.of(tenant)); + + var result = service.exportMerchants(100L, 50, "柏悦", "ACTIVE"); + + assertEquals("101", result.records().get(0).id()); + assertEquals("13800138000", result.records().get(0).contactPhone()); + assertEquals("ops@example.com", result.records().get(0).contactEmail()); + assertEquals(101L, result.nextCursor()); + assertFalse(result.hasMore()); + verify(repository).exportMerchants(100L, 50, "柏悦", "ACTIVE"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantGuardTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantGuardTest.java new file mode 100644 index 000000000..170a61ef4 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/TenantGuardTest.java @@ -0,0 +1,38 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TenantGuardTest { + @Test + void acceptsPlatformOnlyAtZeroTenant() { + assertEquals(0L, TenantGuard.tenant(user(0L, "PLATFORM"), "PLATFORM")); + + BizException exception = assertThrows(BizException.class, + () -> TenantGuard.tenant(user(10001L, "PLATFORM"), "PLATFORM")); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + } + + @Test + void acceptsMerchantOnlyAtPositiveCustomerTenant() { + assertEquals(10001L, TenantGuard.tenant(user(10001L, "MERCHANT"), "MERCHANT")); + + BizException exception = assertThrows(BizException.class, + () -> TenantGuard.tenant(user(0L, "MERCHANT"), "MERCHANT")); + + assertEquals(ResultCode.FORBIDDEN, exception.getResultCode()); + } + + private AuthenticatedUser user(long tenantId, String appCode) { + return new AuthenticatedUser(42L, tenantId, tenantId, 9L, + "operator", appCode, appCode, Set.of()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java new file mode 100644 index 000000000..4b8e6074e --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -0,0 +1,567 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; +import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; +import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest; +import com.baiyue.iam.dto.TenantDtos.RegisterRequest; +import com.baiyue.iam.entity.IamTenantQualification; +import com.baiyue.iam.entity.IamTenantReview; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest; +import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.iam.repository.TenantRepository; +import com.baiyue.iam.repository.CustomerCodeRepository; +import com.baiyue.iam.repository.ProfileResourceRepository; +import com.baiyue.iam.repository.MemberRepository; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.client.AuthAccountClient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doReturn; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.junit.jupiter.api.Assertions.assertThrows; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; + +@SpringJUnitConfig(classes = {TenantService.class, TenantApprovalInitializationCoordinator.class, + TenantAdministratorProvisioningService.class}) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class TenantServiceTest { + @MockBean private TenantRepository repository; + @MockBean private CustomerCodeRepository customerCodes; + @MockBean private ProfileResourceRepository profileRepository; + @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; + @Resource private TenantService service; + + @BeforeEach + void allowUnusedAdministratorPhone() { + when(customerCodes.nextCode()).thenReturn("00001"); + when(authClient.availability(eq("internal-token"), any())) + .thenReturn(com.baiyue.common.core.api.ApiResponse.success(true)); + } + + @Test + void pageMerchantsMapsTenantEntitiesToApiResponses() { + + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setTenantCode("10001"); + tenant.setTenantName("Example Merchant"); + tenant.setStatus("ACTIVE"); + tenant.setLegalName("Example Merchant Ltd"); + tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); + tenant.setContactName("Alice"); + tenant.setContactPhone("13800138000"); + tenant.setContactEmail("alice@example.com"); + tenant.setSalesOwnerName("Sales Zhang"); + tenant.setPaymentTermDays(30); + tenant.setEnterpriseType("LIMITED_COMPANY"); + tenant.setCreatedAt(LocalDateTime.of(2026, 7, 1, 9, 30)); + tenant.setApprovedAt(LocalDateTime.of(2026, 7, 2, 10, 0)); + when(repository.pageMerchants(1, 10, "Example", "ACTIVE")) + .thenReturn(new PageResult<>(List.of(tenant), 1, 10, 1, 1)); + + var result = service.pageMerchants(1, 10, "Example", "ACTIVE"); + + assertEquals(1, result.total()); + assertEquals("10001", result.records().get(0).tenantCode()); + assertEquals("Alice", result.records().get(0).contactName()); + assertEquals("Sales Zhang", result.records().get(0).salesOwnerName()); + assertEquals(30, result.records().get(0).paymentTermDays()); + assertEquals(LocalDateTime.of(2026, 7, 1, 9, 30), result.records().get(0).createdAt()); + assertEquals(LocalDateTime.of(2026, 7, 2, 10, 0), result.records().get(0).approvedAt()); + assertEquals("138****8000", result.records().get(0).contactPhone()); + assertEquals("a***@example.com", result.records().get(0).contactEmail()); + verify(repository).pageMerchants(1, 10, "Example", "ACTIVE"); + } + + @Test + void pendingReviewsIncludeCustomerSummaryWithoutPerReviewQueries() { + IamTenantReview review = review(71L, 10001L); + review.setReviewType("REGISTER"); + review.setCreatedAt(LocalDateTime.of(2026, 8, 17, 10, 27)); + IamTenant tenant = merchant(10001L, "PENDING"); + tenant.setTenantCode("10001"); + tenant.setTenantName("Example Merchant"); + tenant.setContactName("Alice"); + tenant.setContactPhone("13800138000"); + tenant.setContactEmail("alice@example.com"); + when(repository.findPendingReviews()).thenReturn(List.of(review)); + when(repository.findMerchantsByIds(Set.of(10001L))).thenReturn(List.of(tenant)); + + var result = service.pendingReviews(); + + assertEquals(1, result.size()); + assertEquals(71L, result.get(0).id()); + assertEquals(10001L, result.get(0).tenantId()); + assertEquals("REGISTER", result.get(0).reviewType()); + assertEquals("PENDING", result.get(0).status()); + assertEquals("Example Merchant", result.get(0).tenantName()); + assertEquals("10001", result.get(0).tenantCode()); + assertEquals("Alice", result.get(0).contactName()); + assertEquals("13800138000", result.get(0).contactPhone()); + assertEquals("alice@example.com", result.get(0).contactEmail()); + assertEquals(LocalDateTime.of(2026, 8, 17, 10, 27), result.get(0).submittedAt()); + verify(repository).findMerchantsByIds(Set.of(10001L)); + verify(repository, never()).findTenant(10001L); + } + + @Test + void pageFinanceCustomersKeepsFullContactSummaryForInternalFinance() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + tenant.setTenantCode("10001"); + tenant.setTenantName("Example Merchant"); + tenant.setContactName("Alice"); + tenant.setContactPhone("13800138000"); + when(repository.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null)) + .thenReturn(new PageResult<>(List.of(tenant), 1, 10, 1, 1)); + + var result = service.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null); + + assertEquals("10001", result.records().get(0).tenantCode()); + assertEquals("Alice", result.records().get(0).contactName()); + assertEquals("13800138000", result.records().get(0).contactPhone()); + verify(repository).pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null); + } + + @Test + void platformCanMaintainAndDisableOnlyMerchantTenant() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + when(repository.findTenant(10001L)).thenReturn(tenant); + + var updated = service.updateMerchant(10001L, + new UpdateTenantRequest("New name", "新", "New legal", "Bob", "Alice", "13800138000", "alice@example.com", + "专业版", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), + new BigDecimal("5000000.00"), "CNY", "深圳市南山区示例路 1 号", "一期重点客户")); + var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED")); + + assertEquals("New name", updated.tenantName()); + assertEquals("Bob", updated.legalRepresentative()); + assertEquals("alice_wechat", updated.contactWechat()); + assertEquals("专业版", updated.servicePackage()); + assertEquals("Sales Zhang", updated.salesOwnerName()); + assertEquals(45, updated.paymentTermDays()); + assertEquals("LIMITED_COMPANY", updated.enterpriseType()); + assertEquals(LocalDate.of(2020, 1, 2), updated.establishedDate()); + assertEquals(new BigDecimal("5000000.00"), updated.registeredCapital()); + assertEquals("CNY", updated.registeredCapitalCurrency()); + assertEquals("深圳市南山区示例路 1 号", updated.registeredAddress()); + assertEquals("一期重点客户", updated.internalRemark()); + assertEquals("DISABLED", disabled.status()); + verify(repository, org.mockito.Mockito.times(2)).saveTenant(tenant); + } + + @Test + void activeCustomerCanChangeCompanyContactPhoneWithoutChangingAnyLoginIdentity() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + when(repository.findTenant(10001L)).thenReturn(tenant); + + var updated = service.updateMerchant(10001L, + new UpdateTenantRequest("New name", "新", "New legal", "Bob", "Alice", "13900139000", null, + "基础版", null, null, 30, null, null, null, "CNY", null, null)); + + assertEquals("13900139000", updated.contactPhone()); + verify(repository).saveTenant(tenant); + verify(authClient, never()).phone(any(), any(), any()); + } + + @Test + void administratorPhoneAvailabilityChecksOnlyTheAuthLoginFactSource() { + when(authClient.availability("internal-token", "13800138000")) + .thenReturn(com.baiyue.common.core.api.ApiResponse.success(false)); + + assertEquals(false, service.administratorPhoneAvailable("13800138000")); + verify(authClient).availability("internal-token", "13800138000"); + } + + @Test + void resetOwnerPasswordResolvesOwnerAuthAccountAndForwardsForceChangeFlag() { + when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ACTIVE")); + IamMembership owner = new IamMembership(); owner.setId(70L); owner.setTenantId(10001L); + owner.setUserId(71L); owner.setMemberType("OWNER"); owner.setStatus("ACTIVE"); + IamUser user = new IamUser(); user.setId(71L); user.setAuthUserId(72L); user.setStatus("ACTIVE"); + when(members.findOwnerByTenant(10001L)).thenReturn(owner); + when(members.findUser(71L)).thenReturn(user); + when(authClient.reset(eq("internal-token"), eq(72L), any(AuthAccountClient.PasswordRequest.class))) + .thenReturn(com.baiyue.common.core.api.ApiResponse.success(null)); + + service.resetOwnerPassword(10001L, new ResetCustomerOwnerPasswordRequest("NewPass2", null)); + + verify(authClient).reset(eq("internal-token"), eq(72L), + argThat(request -> request.forceChangePassword() && "NewPass2".equals(request.newPassword()))); + } + + @Test + void archiveIsIdempotentForArchivedCustomerWithoutCallingAuth() { + IamTenant tenant = merchant(10001L, "ARCHIVED"); + when(repository.findTenantForUpdate(10001L)).thenReturn(tenant); + + assertEquals("ARCHIVED", service.archiveMerchant(10001L).status()); + verify(authClient, never()).archiveTenant(any(), any()); + verify(repository, never()).saveTenant(any()); + } + + @Test + void impersonationTicketUsesActiveOwnerAndCurrentPlatformActor() { + when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ACTIVE")); + IamMembership owner = new IamMembership(); owner.setId(70L); owner.setTenantId(10001L); + owner.setUserId(71L); owner.setMemberType("OWNER"); owner.setStatus("ACTIVE"); + IamUser user = new IamUser(); user.setId(71L); user.setAuthUserId(72L); user.setStatus("ACTIVE"); + when(members.findOwnerByTenant(10001L)).thenReturn(owner); + when(members.findUser(71L)).thenReturn(user); + when(authClient.createImpersonationTicket(eq("internal-token"), any(AuthAccountClient.ImpersonationTicketRequest.class))) + .thenReturn(com.baiyue.common.core.api.ApiResponse.success( + new AuthAccountClient.ImpersonationTicketResponse("one-time-code", "ticket-1", 60))); + AuthenticatedUser actor = new AuthenticatedUser(9001L, 0L, 0L, 9002L, + "operator", "PLATFORM", "WEB", java.util.Set.of("platform:customer:impersonate")); + + var result = service.createImpersonationTicket(10001L, + new CustomerImpersonationRequest("support investigation"), actor); + + assertEquals("one-time-code", result.code()); + verify(authClient).createImpersonationTicket(eq("internal-token"), argThat(request -> + request.targetAuthUserId().equals(72L) && request.tenantId().equals(10001L) + && request.membershipId().equals(70L) && request.actorUserId().equals(9001L) + && request.actorMembershipId().equals(9002L))); + } + + @Test + void platformCannotBypassReviewToActivatePendingTenant() { + when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "PENDING")); + + BizException exception = assertThrows(BizException.class, + () -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("ACTIVE"))); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + } + + @Test + void archivedCustomerCannotBeReactivated() { + when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ARCHIVED")); + + BizException exception = assertThrows(BizException.class, + () -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("ACTIVE"))); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(repository, never()).saveTenant(any()); + } + + @Test + void archivedCustomerIsReadOnlyForPlatformAndMerchantProfileUpdates() { + IamTenant archived = merchant(10001L, "ARCHIVED"); + when(repository.findTenant(10001L)).thenReturn(archived); + + BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L, + new UpdateTenantRequest("New name", "新", "New legal", "Bob", "Alice", "13800138000", null, + "基础版", null, null, 30, null, null, null, "CNY", null, null))); + BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L, + new UpdateProfileRequest("New name", "New legal", "Bob", "Alice", + "13800138000", "alice@example.com"))); + + assertEquals(ResultCode.CONFLICT, platformFailure.getResultCode()); + assertEquals(ResultCode.CONFLICT, merchantFailure.getResultCode()); + verify(repository, never()).saveTenant(any()); + } + + @Test + void merchantProfileCannotReadOrUpdatePlatformTenant() { + IamTenant platform = new IamTenant(); + platform.setId(0L); + platform.setTenantType("PLATFORM"); + when(repository.findTenant(0L)).thenReturn(platform); + + BizException readFailure = assertThrows(BizException.class, () -> service.profile(0L)); + BizException updateFailure = assertThrows(BizException.class, () -> service.update(0L, + new UpdateProfileRequest("Platform", "Platform", "Owner", "Contact", + "13800138000", "platform@example.com"))); + + assertEquals(ResultCode.NOT_FOUND, readFailure.getResultCode()); + assertEquals(ResultCode.NOT_FOUND, updateFailure.getResultCode()); + verify(repository, never()).saveTenant(platform); + } + + @Test + void platformRegistrationCreatesDraftWithoutCreatingReviewTicket() { + doReturn(TenantAdministratorProvisioningService.ProvisioningResult.created( + 81L, "merchant_81", "Password1234")) + .when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); + + var created = service.register(new RegisterRequest( + "Example Merchant", null, "Example Merchant Ltd", "Bob", "Alice", "13800138000", + "alice@example.com", "基础版", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY", + LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"), + 9001L); + + assertEquals("PROFILE_REQUIRED", created.tenant().status()); + assertEquals("00001", created.tenant().tenantCode()); + assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); + assertEquals("E", created.tenant().customerNickname()); + assertEquals("Bob", created.tenant().legalRepresentative()); + assertEquals("基础版", created.tenant().servicePackage()); + assertEquals("merchant_81", created.administratorUsername()); + assertEquals("Password1234", created.temporaryPassword()); + 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 customerNicknameDefaultsToUppercasePinyinInitialsAndAcceptsManualValue() { + assertEquals("SZWLYXGS", TenantService.resolveCustomerNickname(null, "ABC 深圳物流有限公司")); + assertEquals("SZWL", TenantService.resolveCustomerNickname(null, "深圳物流")); + assertEquals("A", TenantService.resolveCustomerNickname(" ", "ABC Logistics")); + assertEquals("虫虫", TenantService.resolveCustomerNickname(" 虫虫 ", "深圳物流有限公司")); + } + + @Test + void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { + doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) + .when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class)); + + BizException failure = assertThrows(BizException.class, () -> service.register(new RegisterRequest( + "Example Merchant", null, "Example Merchant Ltd", "Bob", "Alice", "13800138000", + "alice@example.com", "基础版", null, null, 30, null, + null, null, "CNY", null, null), 9001L)); + + assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); + } + + @Test + void platformRegistrationAlwaysCreatesTheDraftWithoutACreditCode() { + doReturn(TenantAdministratorProvisioningService.ProvisioningResult.created( + 81L, "merchant_81", "Password1234")) + .when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); + + var created = service.register(new RegisterRequest( + "Example Merchant", null, "Example Merchant Ltd", "Bob", "Alice", "13800138000", + "alice@example.com", "基础版", null, null, 30, null, + null, null, "CNY", null, null), 9001L); + + assertEquals(null, created.tenant().unifiedSocialCreditCode()); + verify(repository).insertTenant(argThat(tenant -> tenant.getUnifiedSocialCreditCode() == null)); + } + + @Test + void profileRequiredDraftArchiveDisablesAuthAccountsAndCustomer() { + IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED"); + when(repository.findTenantForUpdate(10001L)).thenReturn(tenant); + when(authClient.archiveTenant("internal-token", 10001L)) + .thenReturn(com.baiyue.common.core.api.ApiResponse.success(null)); + + var archived = service.archiveMerchant(10001L); + + assertEquals("ARCHIVED", archived.status()); + verify(authClient).archiveTenant("internal-token", 10001L); + verify(repository).saveTenant(tenant); + } + + @Test + void explicitSubmissionRequiresBusinessLicenseAndCreatesReview() { + IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED"); + when(repository.findTenantForUpdate(10001L)).thenReturn(tenant); + when(profileRepository.findQualifications(10001L)).thenReturn(List.of( + qualification("BUSINESS_LICENSE"))); + + var submitted = service.submitMerchantReview(10001L); + + assertEquals("PENDING", submitted.status()); + verify(repository).insertReview(any()); + verify(repository).saveTenant(tenant); + } + + @Test + void explicitSubmissionRejectsMissingBusinessLicense() { + when(repository.findTenantForUpdate(10001L)).thenReturn(merchant(10001L, "PROFILE_REQUIRED")); + when(profileRepository.findQualifications(10001L)).thenReturn(List.of( + qualification("LEGAL_PERSON_ID"))); + + BizException exception = assertThrows(BizException.class, () -> service.submitMerchantReview(10001L)); + + assertEquals(ResultCode.BAD_REQUEST, exception.getResultCode()); + verify(repository, never()).insertReview(any()); + } + + @Test + void explicitSubmissionRejectsMissingUnifiedSocialCreditCodeBeforeCreatingReview() { + IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED"); + tenant.setUnifiedSocialCreditCode(null); + when(repository.findTenantForUpdate(10001L)).thenReturn(tenant); + + BizException exception = assertThrows(BizException.class, () -> service.submitMerchantReview(10001L)); + + assertEquals(ResultCode.BAD_REQUEST, exception.getResultCode()); + verify(repository, never()).insertReview(any()); + verify(profileRepository, never()).findQualifications(10001L); + } + + @Test + void approvalProvisionsMissingAdministratorAndReturnsCredentialsOnce() { + when(provisioning.name()).thenReturn("tenant-administrator"); + IamTenantReview review = review(71L, 10001L); + IamTenant tenant = merchant(10001L, "PENDING"); + when(repository.findReviewForUpdate(71L)).thenReturn(review); + when(repository.findTenant(10001L)).thenReturn(tenant); + when(profileRepository.findQualifications(10001L)).thenReturn(List.of( + qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"))); + doReturn( + TenantAdministratorProvisioningService.ProvisioningResult.created( + 81L, "merchant_10001", "Password1234")).when(provisioning).initialize(any()); + + var result = service.review(71L, 9001L, true, "资料完整"); + + assertEquals(10001L, result.tenantId()); + assertEquals(true, result.administratorCreated()); + assertEquals("merchant_10001", result.administratorUsername()); + assertEquals("Password1234", result.temporaryPassword()); + assertEquals("APPROVED", review.getStatus()); + assertEquals("ACTIVE", tenant.getStatus()); + verify(repository).saveReview(review); + verify(repository).saveTenant(tenant); + verify(securityEvents).tenantStatus(10001L, "ACTIVE"); + } + + @Test + void approvalKeepsClientRegisteredAccountAsAdministratorWithoutNewCredentials() { + when(provisioning.name()).thenReturn("tenant-administrator"); + IamTenantReview review = review(71L, 10001L); + IamTenant tenant = merchant(10001L, "PENDING"); + when(repository.findReviewForUpdate(71L)).thenReturn(review); + when(repository.findTenant(10001L)).thenReturn(tenant); + when(profileRepository.findQualifications(10001L)).thenReturn(List.of( + qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"), + qualification("FREIGHT_FORWARDER_CERTIFICATE"), qualification("BANK_ACCOUNT_LICENSE"))); + doReturn(TenantAdministratorProvisioningService.ProvisioningResult.existing()) + .when(provisioning).initialize(any()); + + var result = service.review(71L, 9001L, true, "资料完整"); + + assertEquals(false, result.administratorCreated()); + assertEquals(null, result.administratorUsername()); + assertEquals(null, result.temporaryPassword()); + assertEquals("APPROVED", review.getStatus()); + assertEquals("ACTIVE", tenant.getStatus()); + } + + @Test + void failureAfterProvisioningCompensatesAdministratorAndKeepsApprovalFailed() { + when(provisioning.name()).thenReturn("tenant-administrator"); + IamTenantReview review = review(71L, 10001L); + IamTenant tenant = merchant(10001L, "PENDING"); + var administrator = TenantAdministratorProvisioningService.ProvisioningResult.created( + 81L, "merchant_10001", "Password1234"); + when(repository.findReviewForUpdate(71L)).thenReturn(review); + when(repository.findTenant(10001L)).thenReturn(tenant); + when(profileRepository.findQualifications(10001L)).thenReturn(List.of( + qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"), + qualification("FREIGHT_FORWARDER_CERTIFICATE"), qualification("BANK_ACCOUNT_LICENSE"))); + doReturn(administrator).when(provisioning).initialize(any()); + doThrow(new RuntimeException("review write failed")).when(repository).saveReview(review); + + assertThrows(RuntimeException.class, () -> service.review(71L, 9001L, true, "资料完整")); + + verify(provisioning).compensate(any(), eq(administrator)); + verify(repository, never()).saveTenant(tenant); + } + + @Test + void shouldHandleCase01() { + when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ACTIVE")); + when(profileRepository.findQualifications(10001L)).thenReturn( + List.of(), + List.of(approvedQualification("BUSINESS_LICENSE"), + rejectedQualification("FREIGHT_FORWARDER_CERTIFICATE"))); + + var withoutQualification = service.orderEligibility(10001L); + var withQualification = service.orderEligibility(10001L); + + assertEquals(false, withoutQualification.eligible()); + assertEquals("ACTIVE", withoutQualification.tenantStatus()); + 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(String.format("%05d", id)); + tenant.setTenantType("MERCHANT"); + tenant.setStatus(status); + tenant.setDimensionInitializationStatus("READY"); + tenant.setTenantName("Example Merchant"); + tenant.setCustomerNickname("E"); + tenant.setLegalName("Example Merchant Ltd"); + tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); + tenant.setContactName("Alice"); + tenant.setContactPhone("13800138000"); + return tenant; + } + + private IamTenantQualification qualification(String type) { + IamTenantQualification item = new IamTenantQualification(); + item.setQualificationType(type); item.setStatus("PENDING"); + return item; + } + + private IamTenantQualification approvedQualification(String type) { + IamTenantQualification item = qualification(type); + item.setStatus("APPROVED"); + return item; + } + + private IamTenantQualification rejectedQualification(String type) { + IamTenantQualification item = qualification(type); + item.setStatus("REJECTED"); + return item; + } + + private IamTenantReview review(long id, long tenantId) { + IamTenantReview review = new IamTenantReview(); + review.setId(id); review.setTenantId(tenantId); review.setStatus("PENDING"); + return review; + } +} 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 b9a2df7b1..cc5f64327 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,6 +38,31 @@ 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')); +SELECT 'missing_order_declaration_edit_permission' +WHERE NOT EXISTS ( + SELECT 1 FROM baiyue_iam.iam_permission + WHERE id = 5102027 AND tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code = 'platform:order:declaration:edit' AND status = 'ACTIVE' AND deleted = 0 +) + OR (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission + WHERE tenant_id = 0 AND permission_id = 5102027 AND role_id IN (4000001, 4000003)) <> 2 +UNION ALL +SELECT 'missing_global_merchant_owner_template' +WHERE NOT EXISTS ( + SELECT 1 FROM baiyue_iam.iam_role + WHERE tenant_id = 0 AND app_code = 'MERCHANT' AND role_code = 'MERCHANT_OWNER' + AND role_type = 'SYSTEM' AND status = 'ACTIVE' AND deleted = 0 +) +UNION ALL +SELECT 'global_merchant_owner_not_fully_granted' +WHERE ( + SELECT COUNT(*) + FROM baiyue_iam.iam_role_permission role_permission + JOIN baiyue_iam.iam_role owner_role ON owner_role.id = role_permission.role_id + WHERE owner_role.tenant_id = 0 AND owner_role.app_code = 'MERCHANT' + AND owner_role.role_code = 'MERCHANT_OWNER' +) <> ( + SELECT COUNT(*) FROM baiyue_iam.iam_permission + WHERE tenant_id = 0 AND app_code = 'MERCHANT' + AND permission_type IN ('MENU', 'BUTTON') AND status = 'ACTIVE' AND deleted = 0 +); diff --git a/integration-service/AGENTS.md b/integration-service/AGENTS.md new file mode 100644 index 000000000..e880358b8 --- /dev/null +++ b/integration-service/AGENTS.md @@ -0,0 +1,23 @@ +# Integration Service 模块指引 + +## 职责与边界 + +- 负责百度 OCR、Amazon Logistics/SP-API、海外仓业务船司/船舶轨迹查询等第三方适配和能力开关;不保存业务主数据。 +- 入库测量与计费重量 OCR 由 `file/` 调用;本服务只返回识别结果,不保存 `file_object`、入库单或重量确认记录。 + +## 模块入口与验证 + +- 应用/配置:`IntegrationServiceApplication.java`、`config/IntegrationProperties.java` +- 内部接口:`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/国内货柜履约/`、`../../docs/海外仓业务/`。 diff --git a/integration-service/pom.xml b/integration-service/pom.xml index b032012af..9bd856074 100644 --- a/integration-service/pom.xml +++ b/integration-service/pom.xml @@ -13,11 +13,26 @@ integration-service + + com.baiyue.shared + common-event-contract + ${project.version} + + + com.baiyue.shared + baiyue-common-core + ${project.version} + com.baiyue.shared baiyue-common-web ${project.version} + + com.baiyue.shared + baiyue-common-openapi + ${project.version} + com.alibaba.cloud spring-cloud-starter-alibaba-nacos-discovery diff --git a/integration-service/src/main/java/com/baiyue/integration/IntegrationServiceApplication.java b/integration-service/src/main/java/com/baiyue/integration/IntegrationServiceApplication.java index 670ad361c..fccecbc0b 100644 --- a/integration-service/src/main/java/com/baiyue/integration/IntegrationServiceApplication.java +++ b/integration-service/src/main/java/com/baiyue/integration/IntegrationServiceApplication.java @@ -4,7 +4,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; -@SpringBootApplication +@SpringBootApplication(scanBasePackages = {"com.baiyue.integration", "com.baiyue.common.web"}) @ConfigurationPropertiesScan public class IntegrationServiceApplication { public static void main(String[] args) { 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 09c4eec94..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 @@ -1,12 +1,69 @@ package com.baiyue.integration.config; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.unit.DataSize; + +import java.net.URI; +import java.nio.file.Path; +import java.time.Duration; @ConfigurationProperties(prefix = "baiyue.integration") -public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi) { - public record BaiduOcr(boolean enabled, String apiKey, String secretKey) { +public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi, + CarrierSchedule carrierSchedule) { + public record BaiduOcr(boolean enabled, + String apiKey, + String secretKey, + URI tokenUrl, + URI meterUrl, + Duration connectTimeout, + Duration requestTimeout, + Path inputRoot, + Path outputRoot, + DataSize maxImageSize, + long maxImagePixels, + int maxBatchSize, + Duration minRequestInterval, + int maxQpsRetries) { public boolean configured() { - return enabled && hasText(apiKey) && hasText(secretKey); + return enabled + && hasText(apiKey) + && hasText(secretKey) + && tokenUrl != null + && meterUrl != null; + } + + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(60); + } + + public DataSize effectiveMaxImageSize() { + return maxImageSize != null && maxImageSize.toBytes() > 0 + ? maxImageSize : DataSize.ofMegabytes(3); + } + + public long effectiveMaxImagePixels() { + return maxImagePixels > 0 ? maxImagePixels : 40_000_000L; + } + + public int effectiveMaxBatchSize() { + return maxBatchSize > 0 ? maxBatchSize : 50; + } + + public Duration effectiveMinRequestInterval() { + return minRequestInterval != null && !minRequestInterval.isNegative() + ? minRequestInterval : Duration.ofMillis(600); + } + + public int effectiveMaxQpsRetries() { + return Math.min(10, Math.max(0, maxQpsRetries)); + } + + private boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); } } @@ -28,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/controller/WeightOcrController.java b/integration-service/src/main/java/com/baiyue/integration/controller/WeightOcrController.java new file mode 100644 index 000000000..cfb4bd972 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/controller/WeightOcrController.java @@ -0,0 +1,58 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest; +import com.baiyue.integration.dto.ocr.WeightOcrBatchResult; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import com.baiyue.integration.service.ocr.WeightOcrBatchService; +import com.baiyue.integration.service.ocr.WeightOcrSingleImageService; +import com.baiyue.integration.dto.ocr.WeightOcrImageResult; +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.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 org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.MediaType; + +import java.io.IOException; + +/** + * 供平台内部服务调用的称重图片 OCR 接口。 + */ +@Tag(name = "重量 OCR", description = "受内部服务令牌保护的称重图片批量识别能力") +@RestController +@RequestMapping("/internal/integrations/weight-ocr") +public class WeightOcrController { + @Resource + private WeightOcrBatchService batchService; + + @Resource + private WeightOcrSingleImageService singleImageService; + + @Resource + private InternalServiceTokenGuard tokenGuard; + + @Operation(summary = "批量识别称重图片") + @PostMapping("/batches") + public ApiResponse recognize( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody WeightOcrBatchRequest request) throws IOException { + tokenGuard.verify(token); + return ApiResponse.success(batchService.recognize(request)); + } + + @Operation(summary = "识别单张称重图片") + @PostMapping(value = "/single", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse recognizeSingle( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestPart("file") MultipartFile file) throws IOException { + tokenGuard.verify(token); + return ApiResponse.success(singleImageService.recognize(file)); + } +} 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/dto/ocr/OcrCandidate.java b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrCandidate.java new file mode 100644 index 000000000..128df4b83 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrCandidate.java @@ -0,0 +1,26 @@ +package com.baiyue.integration.dto.ocr; + +import java.math.BigDecimal; + +/** + * 百度 OCR 返回的文字候选及其识别框。 + */ +public record OcrCandidate( + String text, + BigDecimal confidence, + Integer left, + Integer top, + Integer width, + Integer height +) { + public OcrCandidate { + confidence = OcrConfidence.normalize(confidence); + } + + public long area() { + if (width == null || height == null || width <= 0 || height <= 0) { + return 0; + } + return (long) width * height; + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrConfidence.java b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrConfidence.java new file mode 100644 index 000000000..3d8dbf4ee --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrConfidence.java @@ -0,0 +1,27 @@ +package com.baiyue.integration.dto.ocr; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** Canonical confidence representation shared by OCR response models. */ +public final class OcrConfidence { + private static final int SCALE = 5; + private static final BigDecimal ZERO = BigDecimal.ZERO.setScale(SCALE); + private static final BigDecimal ONE = BigDecimal.ONE.setScale(SCALE); + + private OcrConfidence() { + } + + public static BigDecimal normalize(BigDecimal value) { + if (value == null) { + return null; + } + if (value.compareTo(BigDecimal.ZERO) < 0) { + return ZERO; + } + if (value.compareTo(BigDecimal.ONE) > 0) { + return ONE; + } + return value.setScale(SCALE, RoundingMode.HALF_UP); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrBatchRequest.java b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrBatchRequest.java new file mode 100644 index 000000000..bc0142ee2 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrBatchRequest.java @@ -0,0 +1,32 @@ +package com.baiyue.integration.dto.ocr; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; + +/** + * 重量 OCR 批次请求。目录必须位于服务配置的允许根目录内。 + */ +public record WeightOcrBatchRequest( + @NotBlank(message = "输入目录不能为空") String inputDirectory, + String outputDirectory, + @DecimalMin(value = "0.0", message = "纵向裁剪起始比例不能小于 0") + @DecimalMax(value = "0.95", message = "纵向裁剪起始比例不能大于 0.95") + Double cropStartRatio, + @DecimalMin(value = "0.0", message = "横向裁剪比例不能小于 0") + @DecimalMax(value = "0.49", message = "横向裁剪比例不能大于 0.49") + Double horizontalTrimRatio, + Boolean recursive +) { + public double effectiveCropStartRatio() { + return cropStartRatio == null ? 0.5 : cropStartRatio; + } + + public double effectiveHorizontalTrimRatio() { + return horizontalTrimRatio == null ? 0.25 : horizontalTrimRatio; + } + + public boolean effectiveRecursive() { + return Boolean.TRUE.equals(recursive); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrBatchResult.java b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrBatchResult.java new file mode 100644 index 000000000..fc7574cdc --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrBatchResult.java @@ -0,0 +1,21 @@ +package com.baiyue.integration.dto.ocr; + +import java.time.Instant; +import java.util.List; + +/** + * 重量 OCR 批次汇总结果。 + */ +public record WeightOcrBatchResult( + String batchId, + Instant startedAt, + Instant finishedAt, + String inputDirectory, + String outputDirectory, + int total, + int recognized, + int noWeightFound, + int failed, + List results +) { +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java new file mode 100644 index 000000000..4588f7ddd --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java @@ -0,0 +1,32 @@ +package com.baiyue.integration.dto.ocr; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 单张称重图片识别结果。路径均为相对于受控根目录的相对路径。 + */ +public record WeightOcrImageResult( + String sourceImage, + String croppedImage, + String rawResponseFile, + Status status, + BigDecimal weight, + BigDecimal confidence, + List candidates, + String ocrRawText, + String error +) { + public WeightOcrImageResult { + confidence = OcrConfidence.normalize(confidence); + } + + public enum Status { + /** 已成功识别到有效重量。 */ + RECOGNIZED, + /** 图片已处理,但未识别到可用重量。 */ + NO_WEIGHT_FOUND, + /** 图片读取、裁切、调用供应商或解析过程失败。 */ + FAILED + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java index 759db0b21..507b50b67 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java @@ -1,16 +1,14 @@ package com.baiyue.integration.provider; +import jakarta.annotation.Resource; + import com.baiyue.integration.config.IntegrationProperties; import com.baiyue.integration.dto.ProviderStatus; import org.springframework.stereotype.Component; @Component public class AmazonLogisticsAdapter implements IntegrationProviderAdapter { - private final IntegrationProperties properties; - - public AmazonLogisticsAdapter(IntegrationProperties properties) { - this.properties = properties; - } + @Resource private IntegrationProperties properties; @Override public ProviderStatus status() { diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java index 651d5d0e6..5d4287c4e 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java @@ -1,16 +1,14 @@ package com.baiyue.integration.provider; +import jakarta.annotation.Resource; + import com.baiyue.integration.config.IntegrationProperties; import com.baiyue.integration.dto.ProviderStatus; import org.springframework.stereotype.Component; @Component public class BaiduOcrAdapter implements IntegrationProviderAdapter { - private final IntegrationProperties properties; - - public BaiduOcrAdapter(IntegrationProperties properties) { - this.properties = properties; - } + @Resource private IntegrationProperties properties; @Override public ProviderStatus status() { 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/provider/ocr/BaiduMeterClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java new file mode 100644 index 000000000..2c047dc3a --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java @@ -0,0 +1,225 @@ +package com.baiyue.integration.provider.ocr; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.ocr.OcrCandidate; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.math.BigDecimal; +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.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +/** + * 百度仪器仪表盘读数识别客户端,负责令牌缓存、限速和受控重试。 + */ +@Component +public class BaiduMeterClient { + private static final int MAX_FORM_BYTES = 4 * 1024 * 1024; + private static final int QPS_LIMIT_ERROR_CODE = 18; + private static final long INITIAL_RETRY_DELAY_MILLIS = 1_000; + + @Resource private IntegrationProperties properties; + @Resource private ObjectMapper objectMapper; + private IntegrationProperties.BaiduOcr config; + private HttpClient httpClient; + private final Object requestRateLock = new Object(); + + private volatile CachedToken cachedToken; + private long nextRequestAtMillis; + + @PostConstruct + void initializeClient() { + config = properties.baiduOcr(); + httpClient = HttpClient.newBuilder() + .connectTimeout(config == null ? java.time.Duration.ofSeconds(10) + : config.effectiveConnectTimeout()) + .build(); + } + + public void validateReady() { + if (config == null || !config.enabled()) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "重量 OCR 能力未启用"); + } + if (!config.configured()) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "百度 OCR 密钥或接口地址未配置"); + } + } + + public MeterResponse recognize(Path image) throws IOException, InterruptedException { + validateReady(); + long imageBytes = Files.size(image); + if (imageBytes <= 0 || imageBytes > config.effectiveMaxImageSize().toBytes()) { + throw new IOException("裁剪图片超过允许大小: " + image.getFileName()); + } + String base64 = Base64.getEncoder().encodeToString(Files.readAllBytes(image)); + String form = "image=" + encode(base64) + "&probability=true&poly_location=true"; + if (form.getBytes(StandardCharsets.UTF_8).length > MAX_FORM_BYTES) { + throw new IOException("裁剪图片编码后超过百度接口 4MB 限制: " + image.getFileName()); + } + + for (int retry = 0; ; retry++) { + waitForRequestSlot(); + try { + return sendRecognitionRequest(form); + } catch (BaiduApiException exception) { + if (exception.errorCode() != QPS_LIMIT_ERROR_CODE + || retry >= config.effectiveMaxQpsRetries()) { + throw exception; + } + Thread.sleep(INITIAL_RETRY_DELAY_MILLIS << retry); + } + } + } + + private MeterResponse sendRecognitionRequest(String form) throws IOException, InterruptedException { + String separator = config.meterUrl().toString().contains("?") ? "&" : "?"; + URI uri = URI.create(config.meterUrl() + separator + "access_token=" + encode(accessToken())); + HttpRequest request = HttpRequest.newBuilder(uri) + .timeout(config.effectiveRequestTimeout()) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build(); + HttpResponse response = httpClient.send( + request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + ensureSuccessfulHttpStatus(response.statusCode()); + + JsonNode root = objectMapper.readTree(response.body()); + ensureSuccessfulBaiduResponse(root); + return new MeterResponse(response.body(), parseCandidates(root.path("words_result"))); + } + + private void waitForRequestSlot() throws InterruptedException { + synchronized (requestRateLock) { + long waitMillis = nextRequestAtMillis - System.currentTimeMillis(); + if (waitMillis > 0) { + Thread.sleep(waitMillis); + } + nextRequestAtMillis = System.currentTimeMillis() + + config.effectiveMinRequestInterval().toMillis(); + } + } + + private String accessToken() throws IOException, InterruptedException { + CachedToken current = cachedToken; + if (usable(current)) { + return current.value(); + } + synchronized (this) { + current = cachedToken; + if (usable(current)) { + return current.value(); + } + String form = "grant_type=client_credentials" + + "&client_id=" + encode(config.apiKey()) + + "&client_secret=" + encode(config.secretKey()); + HttpRequest request = HttpRequest.newBuilder(config.tokenUrl()) + .timeout(config.effectiveRequestTimeout()) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build(); + HttpResponse response = httpClient.send( + request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + ensureSuccessfulHttpStatus(response.statusCode()); + + JsonNode root = objectMapper.readTree(response.body()); + String token = root.path("access_token").asText(); + if (token.isBlank()) { + throw new IOException("百度 OAuth 获取 access_token 失败: " + safeError(root)); + } + long expiresIn = Math.max(120, root.path("expires_in").asLong(2_592_000)); + cachedToken = new CachedToken(token, Instant.now().plusSeconds(expiresIn)); + return token; + } + } + + private boolean usable(CachedToken token) { + return token != null && token.expiresAt().isAfter(Instant.now().plusSeconds(60)); + } + + private List parseCandidates(JsonNode wordsResult) { + List candidates = new ArrayList<>(); + if (!wordsResult.isArray()) { + return candidates; + } + for (JsonNode item : wordsResult) { + JsonNode probability = item.path("probability"); + JsonNode location = item.path("location"); + candidates.add(new OcrCandidate( + item.path("words").asText(), + nullableDecimal(probability.get("average")), + nullableInt(location.get("left")), + nullableInt(location.get("top")), + nullableInt(location.get("width")), + nullableInt(location.get("height")) + )); + } + return List.copyOf(candidates); + } + + private void ensureSuccessfulHttpStatus(int statusCode) throws IOException { + if (statusCode < 200 || statusCode >= 300) { + throw new IOException("百度接口 HTTP 状态异常: " + statusCode); + } + } + + private void ensureSuccessfulBaiduResponse(JsonNode root) throws IOException { + if (root.has("error_code")) { + int errorCode = root.path("error_code").asInt(-1); + throw new BaiduApiException(errorCode, "百度 OCR 调用失败: " + safeError(root)); + } + } + + private String safeError(JsonNode root) { + return root.path("error_code").asText("unknown") + " - " + + root.path("error_msg").asText("unknown error"); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static BigDecimal nullableDecimal(JsonNode node) { + return node == null || !node.isNumber() ? null : node.decimalValue(); + } + + private static Integer nullableInt(JsonNode node) { + return node == null || !node.isNumber() ? null : node.intValue(); + } + + public record MeterResponse(String rawJson, List candidates) { + } + + private record CachedToken(String value, Instant expiresAt) { + } + + private static final class BaiduApiException extends IOException { + private final int errorCode; + + private BaiduApiException(int errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + private int errorCode() { + return errorCode; + } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java b/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java index dd0519417..7739b1fb9 100644 --- a/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java +++ b/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java @@ -1,20 +1,30 @@ package com.baiyue.integration.security; +import jakarta.annotation.PostConstruct; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; 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[] expectedToken; + private static final String INSECURE_PLACEHOLDER = + "change-me-to-an-internal-token-at-least-32-bytes-long"; - public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token}") String token) { - if (token == null || token.getBytes(StandardCharsets.UTF_8).length < 32) { - throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + @Value("${baiyue.security.internal-service-token}") private String token; + private byte[] expectedToken; + + @PostConstruct + void initializeToken() { + if (token == null + || token.isBlank() + || INSECURE_PLACEHOLDER.equals(token) + || token.getBytes(StandardCharsets.UTF_8).length < 32) { + throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 必须配置为至少 32 字节的安全令牌"); } this.expectedToken = token.getBytes(StandardCharsets.UTF_8); } @@ -22,7 +32,7 @@ public class InternalServiceTokenGuard { public void verify(String actualToken) { byte[] actual = actualToken == null ? new byte[0] : actualToken.getBytes(StandardCharsets.UTF_8); if (!MessageDigest.isEqual(expectedToken, actual)) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "内部服务令牌无效"); + throw new BizException(ResultCode.UNAUTHORIZED, "内部服务令牌无效"); } } } 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..187e357dc --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java @@ -0,0 +1,60 @@ +package com.baiyue.integration.service; + +import com.baiyue.common.event.ContainerNumbers; +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) { + if (!"CONTAINER_NO".equals(request.trackingType())) { + throw new IllegalArgumentException("trackingType仅支持CONTAINER_NO"); + } + String containerNo; + try { + containerNo = ContainerNumbers.requireCanonical(request.trackingNo()); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("trackingNo必须是规范化真实集装箱号"); + } + QueryRequest normalized = new QueryRequest(request.carrierCode(), "CONTAINER_NO", containerNo); + QueryResult result = adapters.stream().filter(adapter -> adapter.supports(normalized.carrierCode())).findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "没有支持该船司的轨迹供应商适配器:" + normalized.carrierCode())) + .query(normalized); + 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/java/com/baiyue/integration/service/ProviderStatusService.java b/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java index 63b23feaf..4c0aba2f3 100644 --- a/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java +++ b/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java @@ -1,5 +1,7 @@ package com.baiyue.integration.service; +import jakarta.annotation.Resource; + import com.baiyue.integration.dto.ProviderStatus; import com.baiyue.integration.provider.IntegrationProviderAdapter; import org.springframework.stereotype.Service; @@ -8,11 +10,7 @@ import java.util.List; @Service public class ProviderStatusService { - private final List adapters; - - public ProviderStatusService(List adapters) { - this.adapters = adapters; - } + @Resource private List adapters; public List providers() { return adapters.stream().map(IntegrationProviderAdapter::status).toList(); diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/ImageCropper.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/ImageCropper.java new file mode 100644 index 000000000..13cb0f68d --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/ImageCropper.java @@ -0,0 +1,81 @@ +package com.baiyue.integration.service.ocr; + +import org.springframework.stereotype.Component; + +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.ImageOutputStream; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Iterator; + +/** + * 裁剪称重图片的下方中央区域,并统一输出为 JPEG。 + */ +@Component +public class ImageCropper { + private static final float JPEG_QUALITY = 0.90f; + + public void cropBottomCenter(Path source, Path target, double startRatio, + double horizontalTrimRatio, long maxImagePixels) throws IOException { + validateRatios(startRatio, horizontalTrimRatio); + BufferedImage sourceImage = ImageIO.read(source.toFile()); + if (sourceImage == null) { + throw new IOException("无法解析图片: " + source.getFileName()); + } + long pixels = (long) sourceImage.getWidth() * sourceImage.getHeight(); + if (pixels <= 0 || pixels > maxImagePixels) { + throw new IOException("图片像素超过允许上限: " + source.getFileName()); + } + + int startY = (int) Math.floor(sourceImage.getHeight() * startRatio); + int startX = (int) Math.floor(sourceImage.getWidth() * horizontalTrimRatio); + int croppedWidth = sourceImage.getWidth() - startX * 2; + int croppedHeight = sourceImage.getHeight() - startY; + if (croppedWidth <= 0 || croppedHeight <= 0) { + throw new IOException("图片裁剪区域无效: " + source.getFileName()); + } + + BufferedImage cropped = new BufferedImage(croppedWidth, croppedHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = cropped.createGraphics(); + try { + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, croppedWidth, croppedHeight); + graphics.drawImage(sourceImage, 0, 0, croppedWidth, croppedHeight, + startX, startY, startX + croppedWidth, startY + croppedHeight, null); + } finally { + graphics.dispose(); + } + + Files.createDirectories(target.getParent()); + Iterator writers = ImageIO.getImageWritersByFormatName("jpeg"); + if (!writers.hasNext()) { + throw new IOException("当前 Java 环境不支持 JPEG 编码"); + } + ImageWriter writer = writers.next(); + try (ImageOutputStream output = ImageIO.createImageOutputStream(target.toFile())) { + writer.setOutput(output); + ImageWriteParam parameters = writer.getDefaultWriteParam(); + parameters.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + parameters.setCompressionQuality(JPEG_QUALITY); + writer.write(null, new IIOImage(cropped, null, null), parameters); + } finally { + writer.dispose(); + } + } + + private void validateRatios(double startRatio, double horizontalTrimRatio) { + if (startRatio < 0 || startRatio >= 1) { + throw new IllegalArgumentException("cropStartRatio 必须在 [0, 1) 范围内"); + } + if (horizontalTrimRatio < 0 || horizontalTrimRatio >= 0.5) { + throw new IllegalArgumentException("horizontalTrimRatio 必须在 [0, 0.5) 范围内"); + } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightCandidateSelector.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightCandidateSelector.java new file mode 100644 index 000000000..4a0c0332a --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightCandidateSelector.java @@ -0,0 +1,61 @@ +package com.baiyue.integration.service.ocr; + +import com.baiyue.integration.dto.ocr.OcrCandidate; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * 从 OCR 候选内容中选择最可能的重量数值。 + */ +@Component +public class WeightCandidateSelector { + private static final Pattern WEIGHT_PATTERN = Pattern.compile("^[+-]?\\d{1,8}(?:\\.\\d{1,4})?$"); + + public Optional select(List candidates) { + if (candidates == null || candidates.isEmpty()) { + return Optional.empty(); + } + return candidates.stream() + .map(this::toSelectedWeight) + .flatMap(Optional::stream) + .max(Comparator + .comparingLong((SelectedWeight selected) -> selected.candidate().area()) + .thenComparing(selected -> selected.confidence() == null + ? BigDecimal.valueOf(-1) : selected.confidence())); + } + + private Optional toSelectedWeight(OcrCandidate candidate) { + if (candidate == null) { + return Optional.empty(); + } + String normalized = normalize(candidate.text()); + if (!WEIGHT_PATTERN.matcher(normalized).matches()) { + return Optional.empty(); + } + try { + return Optional.of(new SelectedWeight( + new BigDecimal(normalized), candidate.confidence(), candidate)); + } catch (NumberFormatException ignored) { + return Optional.empty(); + } + } + + private String normalize(String value) { + if (value == null) { + return ""; + } + return value.strip() + .replace(" ", "") + .replace(',', '.') + .replace('+', '+') + .replace('-', '-'); + } + + public record SelectedWeight(BigDecimal weight, BigDecimal confidence, OcrCandidate candidate) { + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java new file mode 100644 index 000000000..6aa42a878 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java @@ -0,0 +1,223 @@ +package com.baiyue.integration.service.ocr; + +import jakarta.annotation.Resource; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest; +import com.baiyue.integration.dto.ocr.WeightOcrBatchResult; +import com.baiyue.integration.dto.ocr.WeightOcrImageResult; +import com.baiyue.integration.dto.ocr.WeightOcrImageResult.Status; +import com.baiyue.integration.provider.ocr.BaiduMeterClient; +import com.baiyue.integration.service.ocr.WeightOcrStoragePolicy.BatchPaths; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Semaphore; +import java.util.stream.Stream; + +/** + * 编排称重图片批量裁剪、百度 OCR 调用和结果落盘。 + */ +@Service +public class WeightOcrBatchService { + private static final Set SUPPORTED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "bmp"); + private static final int RAW_TEXT_MAX_LENGTH = 1000; + + @Resource private IntegrationProperties properties; + @Resource private WeightOcrStoragePolicy storagePolicy; + @Resource private ImageCropper imageCropper; + @Resource private BaiduMeterClient baiduMeterClient; + @Resource private WeightCandidateSelector candidateSelector; + @Resource private ObjectMapper objectMapper; + private final Semaphore batchSlot = new Semaphore(1); + + public WeightOcrBatchResult recognize(WeightOcrBatchRequest request) throws IOException { + baiduMeterClient.validateReady(); + if (!batchSlot.tryAcquire()) { + throw new BizException(ResultCode.TOO_MANY_REQUESTS, "已有重量 OCR 批次正在执行"); + } + try { + return doRecognize(request); + } finally { + batchSlot.release(); + } + } + + private WeightOcrBatchResult doRecognize(WeightOcrBatchRequest request) throws IOException { + String batchId = UUID.randomUUID().toString(); + Instant startedAt = Instant.now(); + BatchPaths paths = storagePolicy.resolve( + request.inputDirectory(), request.outputDirectory(), batchId); + List images = findImages(paths.inputDirectory(), paths.outputDirectory(), + request.effectiveRecursive(), config().effectiveMaxBatchSize()); + if (images.isEmpty()) { + throw new BizException(ResultCode.BAD_REQUEST, "输入目录中没有支持的图片"); + } + + List results = new ArrayList<>(images.size()); + for (Path image : images) { + results.add(recognizeOne(paths, image, request)); + } + WeightOcrBatchResult result = buildResult(batchId, startedAt, paths, results); + objectMapper.writerWithDefaultPrettyPrinter() + .writeValue(paths.outputDirectory().resolve("batch-results.json").toFile(), result); + return result; + } + + private WeightOcrImageResult recognizeOne(BatchPaths paths, Path source, + WeightOcrBatchRequest request) { + Path relative = paths.inputDirectory().relativize(source); + Path cropped = paths.outputDirectory().resolve("cropped") + .resolve(replaceExtension(relative, ".jpg")); + Path rawResponse = paths.outputDirectory().resolve("raw") + .resolve(replaceExtension(relative, ".json")); + try { + long sourceBytes = Files.size(source); + if (sourceBytes <= 0 || sourceBytes > config().effectiveMaxImageSize().toBytes()) { + throw new IOException("原始图片超过允许大小"); + } + imageCropper.cropBottomCenter(source, cropped, + request.effectiveCropStartRatio(), request.effectiveHorizontalTrimRatio(), + config().effectiveMaxImagePixels()); + BaiduMeterClient.MeterResponse response = baiduMeterClient.recognize(cropped); + Files.createDirectories(rawResponse.getParent()); + Files.writeString(rawResponse, response.rawJson(), StandardCharsets.UTF_8); + + return candidateSelector.select(response.candidates()) + .map(selected -> success(relative, paths, cropped, rawResponse, + response, selected)) + .orElseGet(() -> noWeight(relative, paths, cropped, rawResponse, response)); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return failed(relative, paths, cropped, rawResponse, "调用百度 OCR 时线程被中断"); + } catch (Exception exception) { + return failed(relative, paths, cropped, rawResponse, safeMessage(exception)); + } + } + + private WeightOcrImageResult success(Path source, BatchPaths paths, Path cropped, + Path rawResponse, BaiduMeterClient.MeterResponse response, + WeightCandidateSelector.SelectedWeight selected) { + return new WeightOcrImageResult( + source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse), + Status.RECOGNIZED, selected.weight(), selected.confidence(), + response.candidates(), summarizeCandidates(response.candidates()), null); + } + + private WeightOcrImageResult noWeight(Path source, BatchPaths paths, Path cropped, + Path rawResponse, BaiduMeterClient.MeterResponse response) { + return new WeightOcrImageResult( + source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse), + Status.NO_WEIGHT_FOUND, null, null, response.candidates(), + summarizeCandidates(response.candidates()), + "百度响应中没有符合重量格式的候选值"); + } + + private WeightOcrImageResult failed(Path source, BatchPaths paths, Path cropped, + Path rawResponse, String error) { + return new WeightOcrImageResult( + source.toString(), Files.exists(cropped) ? outputRelative(paths, cropped) : null, + Files.exists(rawResponse) ? outputRelative(paths, rawResponse) : null, + Status.FAILED, null, null, List.of(), null, error); + } + + private String summarizeCandidates(List candidates) { + if (candidates == null || candidates.isEmpty()) { + return null; + } + StringBuilder summary = new StringBuilder(); + for (var candidate : candidates) { + if (candidate == null || candidate.text() == null) { + continue; + } + String text = candidate.text().replaceAll("[\\p{Cc}\\p{Cf}]+", " ").strip(); + if (text.isBlank()) { + continue; + } + int separatorLength = summary.isEmpty() ? 0 : 1; + int remaining = RAW_TEXT_MAX_LENGTH - summary.length() - separatorLength; + if (remaining <= 0) { + break; + } + if (separatorLength > 0) { + summary.append('\n'); + } + summary.append(text, 0, Math.min(text.length(), remaining)); + } + return summary.isEmpty() ? null : summary.toString(); + } + + private WeightOcrBatchResult buildResult(String batchId, Instant startedAt, BatchPaths paths, + List results) { + return new WeightOcrBatchResult( + batchId, startedAt, Instant.now(), paths.inputDisplayPath(), paths.outputDisplayPath(), + results.size(), count(results, Status.RECOGNIZED), + count(results, Status.NO_WEIGHT_FOUND), count(results, Status.FAILED), + List.copyOf(results)); + } + + private int count(List results, Status status) { + return (int) results.stream().filter(result -> result.status() == status).count(); + } + + private List findImages(Path input, Path output, boolean recursive, int maxBatchSize) + throws IOException { + int maxDepth = recursive ? Integer.MAX_VALUE : 1; + List images; + try (Stream paths = Files.walk(input, maxDepth)) { + images = paths.filter(Files::isRegularFile) + .filter(path -> !path.startsWith(output)) + .filter(this::isSupportedImage) + .sorted(Comparator.comparing(Path::toString)) + .limit((long) maxBatchSize + 1) + .toList(); + } + if (images.size() > maxBatchSize) { + throw new BizException(ResultCode.BAD_REQUEST, + "单批图片数量不能超过 " + maxBatchSize + " 张"); + } + return images; + } + + private boolean isSupportedImage(Path path) { + String name = path.getFileName().toString(); + int dot = name.lastIndexOf('.'); + return dot >= 0 && dot < name.length() - 1 + && SUPPORTED_EXTENSIONS.contains(name.substring(dot + 1).toLowerCase(Locale.ROOT)); + } + + private Path replaceExtension(Path relative, String extension) { + String name = relative.getFileName().toString(); + int dot = name.lastIndexOf('.'); + String baseName = dot > 0 ? name.substring(0, dot) : name; + return relative.getParent() == null + ? Path.of(baseName + extension) + : relative.getParent().resolve(baseName + extension); + } + + private String outputRelative(BatchPaths paths, Path path) { + return paths.outputDirectory().relativize(path).toString(); + } + + private String safeMessage(Exception exception) { + String message = exception.getMessage(); + return message == null || message.isBlank() ? "OCR 识别失败" : message; + } + + private IntegrationProperties.BaiduOcr config() { + return properties.baiduOcr(); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageService.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageService.java new file mode 100644 index 000000000..dde470ecf --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageService.java @@ -0,0 +1,74 @@ +package com.baiyue.integration.service.ocr; + +import jakarta.annotation.Resource; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest; +import com.baiyue.integration.dto.ocr.WeightOcrImageResult; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +@Service +public class WeightOcrSingleImageService { + private static final Set CONTENT_TYPES = Set.of("image/jpeg", "image/png", "image/bmp"); + @Resource private IntegrationProperties properties; + @Resource private WeightOcrBatchService batches; + + public WeightOcrImageResult recognize(MultipartFile file) throws IOException { + IntegrationProperties.BaiduOcr config = properties.baiduOcr(); + if (config == null || config.inputRoot() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "OCR输入目录未配置"); + } + if (file == null || file.isEmpty()) { + throw new BizException(ResultCode.BAD_REQUEST, "称重图片不能为空"); + } + String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); + if (!CONTENT_TYPES.contains(contentType)) { + throw new BizException(ResultCode.BAD_REQUEST, "称重图片仅支持 JPG、PNG、BMP"); + } + if (file.getSize() > config.effectiveMaxImageSize().toBytes()) { + throw new BizException(ResultCode.BAD_REQUEST, "称重图片超过允许大小"); + } + String extension = "image/png".equals(contentType) ? ".png" + : ("image/bmp".equals(contentType) ? ".bmp" : ".jpg"); + String requestId = UUID.randomUUID().toString(); + Path root = config.inputRoot().toAbsolutePath().normalize(); + Path requestDirectory = root.resolve("single").resolve(requestId).normalize(); + if (!requestDirectory.startsWith(root)) { + throw new BizException(ResultCode.FORBIDDEN, "OCR输入路径越界"); + } + Files.createDirectories(requestDirectory); + Path image = requestDirectory.resolve("weight" + extension); + try { + file.transferTo(image); + var batch = batches.recognize(new WeightOcrBatchRequest( + "single/" + requestId, "single-" + requestId, null, null, false)); + if (batch.results() == null || batch.results().size() != 1) { + throw new BizException(ResultCode.INTERNAL_ERROR, "OCR单图识别结果数量异常"); + } + WeightOcrImageResult result = batch.results().get(0); + return new WeightOcrImageResult(null, null, null, result.status(), result.weight(), + result.confidence(), java.util.List.of(), result.ocrRawText(), result.error()); + } finally { + deleteRequestDirectory(requestDirectory); + } + } + + private void deleteRequestDirectory(Path directory) { + try (var paths = Files.walk(directory)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { Files.deleteIfExists(path); } catch (IOException ignored) { } + }); + } catch (IOException ignored) { } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicy.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicy.java new file mode 100644 index 000000000..f003da7f1 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicy.java @@ -0,0 +1,87 @@ +package com.baiyue.integration.service.ocr; + +import jakarta.annotation.Resource; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.integration.config.IntegrationProperties; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * 将 OCR 文件访问限制在配置的输入、输出根目录内。 + */ +@Component +public class WeightOcrStoragePolicy { + @Resource private IntegrationProperties properties; + + public BatchPaths resolve(String inputValue, String outputValue, String batchId) throws IOException { + IntegrationProperties.BaiduOcr config = requireConfig(); + Path inputRoot = prepareRoot(config.inputRoot(), "OCR 输入根目录"); + Path outputRoot = prepareRoot(config.outputRoot(), "OCR 输出根目录"); + + Path input = resolveRequested(inputRoot, inputValue).toRealPath(); + requireInside(inputRoot, input, "输入目录超出允许范围"); + if (!Files.isDirectory(input) || !Files.isReadable(input)) { + throw new BizException(ResultCode.BAD_REQUEST, "输入目录不存在或不可读"); + } + + String outputName = outputValue == null || outputValue.isBlank() ? batchId : outputValue; + Path output = resolveRequested(outputRoot, outputName); + requireInside(outputRoot, output, "输出目录超出允许范围"); + Files.createDirectories(output); + output = output.toRealPath(); + requireInside(outputRoot, output, "输出目录超出允许范围"); + return new BatchPaths(inputRoot, outputRoot, input, output); + } + + private IntegrationProperties.BaiduOcr requireConfig() { + IntegrationProperties.BaiduOcr config = properties.baiduOcr(); + if (config == null || config.inputRoot() == null || config.outputRoot() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "重量 OCR 存储目录未配置"); + } + return config; + } + + private Path prepareRoot(Path configured, String name) throws IOException { + Path root = configured.toAbsolutePath().normalize(); + Files.createDirectories(root); + if (!Files.isDirectory(root) || !Files.isReadable(root) || !Files.isWritable(root)) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, name + "不可用"); + } + return root.toRealPath(); + } + + private Path resolveRequested(Path root, String value) { + try { + Path requested = Path.of(value); + return (requested.isAbsolute() ? requested : root.resolve(requested)).normalize(); + } catch (RuntimeException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "目录格式无效"); + } + } + + private void requireInside(Path root, Path path, String message) { + if (!path.startsWith(root)) { + throw new BizException(ResultCode.FORBIDDEN, message); + } + } + + public record BatchPaths(Path inputRoot, Path outputRoot, Path inputDirectory, Path outputDirectory) { + public String inputDisplayPath() { + return display(inputRoot, inputDirectory); + } + + public String outputDisplayPath() { + return display(outputRoot, outputDirectory); + } + + private String display(Path root, Path path) { + String relative = root.relativize(path).toString(); + return relative.isBlank() ? "." : relative; + } + } +} diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index 6416a8b68..f6d42fe07 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -2,8 +2,14 @@ server: port: ${INTEGRATION_PORT:9210} spring: + profiles: + active: ${SPRING_PROFILES_ACTIVE:dev} application: name: integration-service + servlet: + multipart: + max-file-size: 5MB + max-request-size: 6MB autoconfigure: exclude: - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration @@ -27,12 +33,23 @@ spring: baiyue: security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:} integration: baidu-ocr: enabled: ${BAIDU_OCR_ENABLED:false} api-key: ${BAIDU_OCR_API_KEY:} secret-key: ${BAIDU_OCR_SECRET_KEY:} + token-url: ${BAIDU_OCR_TOKEN_URL:https://aip.baidubce.com/oauth/2.0/token} + meter-url: ${BAIDU_OCR_METER_URL:https://aip.baidubce.com/rest/2.0/ocr/v1/meter} + connect-timeout: ${BAIDU_OCR_CONNECT_TIMEOUT:10s} + request-timeout: ${BAIDU_OCR_REQUEST_TIMEOUT:60s} + input-root: ${WEIGHT_OCR_INPUT_ROOT:${java.io.tmpdir}/baiyue-weight-ocr/input} + output-root: ${WEIGHT_OCR_OUTPUT_ROOT:${java.io.tmpdir}/baiyue-weight-ocr/output} + max-image-size: ${WEIGHT_OCR_MAX_IMAGE_SIZE:5MB} + max-image-pixels: ${WEIGHT_OCR_MAX_IMAGE_PIXELS:40000000} + max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50} + min-request-interval: ${BAIDU_OCR_MIN_REQUEST_INTERVAL:600ms} + max-qps-retries: ${BAIDU_OCR_MAX_QPS_RETRIES:3} amazon-sp-api: enabled: ${AMAZON_SP_API_ENABLED:false} client-id: ${AMAZON_SP_API_CLIENT_ID:} @@ -41,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..dcbda342d --- /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: [CONTAINER_NO]} + trackingNo: {type: string, minLength: 1, maxLength: 128, description: Uppercase normalized physical containerNo supplied by Firstleg; no cabinet/order/booking/BOL fallback.} + 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/config/IntegrationPropertiesTest.java b/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java index 2d23af5ee..5467823ba 100644 --- a/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java @@ -1,20 +1,36 @@ package com.baiyue.integration.config; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.net.URI; +import java.nio.file.Path; +import java.time.Duration; + import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("第三方集成配置功能") class IntegrationPropertiesTest { @Test void providerIsConfiguredOnlyWhenEnabledAndAllSecretsExist() { - var baidu = new IntegrationProperties.BaiduOcr(true, "key", "secret"); + var baidu = baidu(true, "key", "secret"); var amazon = new IntegrationProperties.AmazonSpApi( true, "client", "secret", "refresh", "access", "aws-secret", "us-east-1"); assertThat(baidu.configured()).isTrue(); assertThat(amazon.configured()).isTrue(); - assertThat(new IntegrationProperties.BaiduOcr(false, "key", "secret").configured()).isFalse(); + assertThat(baidu(false, "key", "secret").configured()).isFalse(); assertThat(new IntegrationProperties.AmazonSpApi( true, "client", "secret", "", "access", "aws-secret", "us-east-1").configured()).isFalse(); } + + private IntegrationProperties.BaiduOcr baidu(boolean enabled, String apiKey, String secretKey) { + return new IntegrationProperties.BaiduOcr( + enabled, apiKey, secretKey, + URI.create("https://example.com/token"), URI.create("https://example.com/meter"), + Duration.ofSeconds(10), Duration.ofSeconds(60), + Path.of("input"), Path.of("output"), null, + 40_000_000L, 50, Duration.ofMillis(600), 3 + ); + } } 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/security/InternalServiceTokenGuardTest.java b/integration-service/src/test/java/com/baiyue/integration/security/InternalServiceTokenGuardTest.java new file mode 100644 index 000000000..7e4f3fdb6 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/security/InternalServiceTokenGuardTest.java @@ -0,0 +1,49 @@ +package com.baiyue.integration.security; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InternalServiceTokenGuardTest { + private static final String VALID_TOKEN = "integration-service-test-token-32-bytes"; + + @Test + void rejectsMissingEmptyAndShortTokens() { + assertInvalidConfiguration(null); + assertInvalidConfiguration(""); + assertInvalidConfiguration(" "); + assertInvalidConfiguration("too-short"); + } + + @Test + void rejectsKnownInsecurePlaceholder() { + assertInvalidConfiguration("change-me-to-an-internal-token-at-least-32-bytes-long"); + } + + @Test + void acceptsConfiguredTokenWithAtLeastThirtyTwoBytes() { + InternalServiceTokenGuard guard = guardWithToken(VALID_TOKEN); + + assertThatCode(guard::initializeToken).doesNotThrowAnyException(); + assertThatCode(() -> guard.verify(VALID_TOKEN)).doesNotThrowAnyException(); + assertThatThrownBy(() -> guard.verify("different-internal-service-token")) + .isInstanceOf(BizException.class); + } + + private static void assertInvalidConfiguration(String token) { + InternalServiceTokenGuard guard = guardWithToken(token); + + assertThatThrownBy(guard::initializeToken) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("INTERNAL_SERVICE_TOKEN"); + } + + private static InternalServiceTokenGuard guardWithToken(String token) { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard(); + ReflectionTestUtils.setField(guard, "token", token); + return guard; + } +} 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..1b6355b3a --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java @@ -0,0 +1,79 @@ +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", "ZCSU7881970")); + assertThat(result.outcome()).isEqualTo("MANUAL_REQUIRED"); + assertThat(result.errorCode()).isEqualTo("PROVIDER_API_NOT_CONFIGURED"); + assertThat(result.observations()).isEmpty(); + assertThat(result.exceptions()).isEmpty(); + } + + @Test + void onlyDispatchesNormalizedContainerNumberTracking() { + CarrierScheduleProviderAdapter capturing = new CarrierScheduleProviderAdapter() { + @Override public boolean supports(String carrierCode) { return true; } + @Override public QueryResult query(QueryRequest request) { + assertThat(request.trackingType()).isEqualTo("CONTAINER_NO"); + assertThat(request.trackingNo()).isEqualTo("ZCSU7881970"); + return new QueryResult("TEST", "NO_CHANGE", List.of(), List.of(), null, null, LocalDateTime.now()); + } + }; + var service = new CarrierScheduleProviderService(List.of(capturing)); + service.query(new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881970")); + assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", "zcsu7881970"))) + .hasMessageContaining("规范化真实集装箱号"); + assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881971"))) + .hasMessageContaining("规范化真实集装箱号"); + assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "BILL_OF_LADING", "BL-1"))) + .hasMessageContaining("CONTAINER_NO"); + } + + @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", "ZCSU7881970"))) + .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", "ZCSU7881970"))) + .hasMessageContaining("无效异常事实"); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java new file mode 100644 index 000000000..1397eb059 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java @@ -0,0 +1,54 @@ +package com.baiyue.integration.service.ocr; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("OCR 图像裁剪功能") +class ImageCropperTest { + @TempDir + Path tempDirectory; + + @Test + void cropsConfiguredBottomCenterRegion() throws Exception { + Path source = createImage(); + Path target = tempDirectory.resolve("cropped/result.jpg"); + + new ImageCropper().cropBottomCenter(source, target, 0.5, 0.25, 10_000); + + BufferedImage cropped = ImageIO.read(target.toFile()); + assertThat(cropped.getWidth()).isEqualTo(50); + assertThat(cropped.getHeight()).isEqualTo(40); + assertThat(new Color(cropped.getRGB(10, 10))).isEqualTo(Color.BLACK); + } + + @Test + void rejectsImageOverPixelLimit() throws Exception { + Path source = createImage(); + assertThatThrownBy(() -> new ImageCropper() + .cropBottomCenter(source, tempDirectory.resolve("result.jpg"), 0.5, 0.25, 100)) + .hasMessageContaining("像素超过"); + } + + private Path createImage() throws Exception { + BufferedImage image = new BufferedImage(100, 80, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, 100, 40); + graphics.setColor(Color.BLACK); + graphics.fillRect(0, 40, 100, 40); + graphics.dispose(); + Path source = tempDirectory.resolve("source.png"); + ImageIO.write(image, "png", source.toFile()); + return source; + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java new file mode 100644 index 000000000..e85c67661 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java @@ -0,0 +1,46 @@ +package com.baiyue.integration.service.ocr; + +import com.baiyue.integration.dto.ocr.OcrCandidate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("OCR 重量候选值筛选功能") +class WeightCandidateSelectorTest { + private final WeightCandidateSelector selector = new WeightCandidateSelector(); + + @Test + void selectsLargestNumericCandidateAndNormalizesDecimalSeparator() { + List candidates = List.of( + new OcrCandidate("10:38", new BigDecimal("0.99"), 10, 10, 40, 10), + new OcrCandidate(" 12,35 ", new BigDecimal("0.97"), 20, 30, 120, 60), + new OcrCandidate("29", new BigDecimal("0.99"), 160, 10, 20, 10), + new OcrCandidate("kg", new BigDecimal("0.99"), 150, 70, 30, 15)); + + WeightCandidateSelector.SelectedWeight selected = selector.select(candidates).orElseThrow(); + + assertThat(selected.weight()).isEqualByComparingTo("12.35"); + assertThat(selected.confidence()).isEqualByComparingTo("0.97000"); + } + + @Test + void rejectsNonNumericCandidates() { + assertThat(selector.select(List.of( + new OcrCandidate("kg", new BigDecimal("0.99"), 0, 0, 20, 10), + new OcrCandidate("10:38", new BigDecimal("0.99"), 0, 0, 20, 10)))).isEmpty(); + } + + @Test + void canonicalizesConfidenceToRangeAndFiveDecimalPlaces() { + assertThat(new OcrCandidate("1", new BigDecimal("0.5097191334"), 0, 0, 1, 1) + .confidence()).isEqualByComparingTo("0.50972"); + assertThat(new OcrCandidate("1", new BigDecimal("1.2"), 0, 0, 1, 1) + .confidence()).isEqualByComparingTo("1.00000"); + assertThat(new OcrCandidate("1", new BigDecimal("-0.1"), 0, 0, 1, 1) + .confidence()).isEqualByComparingTo("0.00000"); + } +} 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 new file mode 100644 index 000000000..bade2d63b --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java @@ -0,0 +1,93 @@ +package com.baiyue.integration.service.ocr; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.ocr.OcrCandidate; +import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest; +import com.baiyue.integration.dto.ocr.WeightOcrImageResult.Status; +import com.baiyue.integration.provider.ocr.BaiduMeterClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.util.unit.DataSize; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.math.BigDecimal; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("OCR 重量批量识别功能") +@SpringJUnitConfig(classes = {WeightOcrBatchService.class, WeightOcrStoragePolicy.class, + ImageCropper.class, WeightCandidateSelector.class, WeightOcrBatchServiceTest.TestConfig.class}) +class WeightOcrBatchServiceTest { + @TempDir + Path tempDirectory; + @MockBean private IntegrationProperties properties; + @MockBean private BaiduMeterClient client; + @Resource private WeightOcrBatchService service; + + @Test + void completesBatchAndWritesArtifactsWithoutCallingRealProvider() throws Exception { + Path inputRoot = Files.createDirectories(tempDirectory.resolve("input")); + Path outputRoot = tempDirectory.resolve("output"); + Path batchInput = Files.createDirectories(inputRoot.resolve("batch-a")); + BufferedImage image = new BufferedImage(100, 80, BufferedImage.TYPE_INT_RGB); + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + image.setRGB(x, y, Color.BLACK.getRGB()); + } + } + ImageIO.write(image, "png", batchInput.resolve("weight.png").toFile()); + + when(properties.baiduOcr()).thenReturn(properties(inputRoot, outputRoot).baiduOcr()); + when(client.recognize(any())).thenReturn(new BaiduMeterClient.MeterResponse( + "{\"words_result\":[]}", + List.of( + new OcrCandidate("12.35", new BigDecimal("0.5097191334"), 10, 10, 80, 30), + new OcrCandidate("KG\u0000", new BigDecimal("0.98"), 10, 50, 30, 10)))); + + var result = service.recognize(new WeightOcrBatchRequest("batch-a", null, null, null, false)); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.recognized()).isEqualTo(1); + assertThat(result.results().get(0).status()).isEqualTo(Status.RECOGNIZED); + assertThat(result.results().get(0).confidence()).isEqualByComparingTo("0.50972"); + assertThat(result.results().get(0).ocrRawText()).isEqualTo("12.35\nKG"); + Path batchOutput = outputRoot.resolve(result.outputDirectory()); + assertThat(batchOutput.resolve("cropped/weight.jpg")).isRegularFile(); + assertThat(batchOutput.resolve("raw/weight.json")).isRegularFile(); + assertThat(batchOutput.resolve("batch-results.json")).isRegularFile(); + } + + private IntegrationProperties properties(Path inputRoot, Path outputRoot) { + IntegrationProperties.BaiduOcr baidu = new IntegrationProperties.BaiduOcr( + true, "key", "secret", URI.create("https://example.com/token"), + 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, null); + } + + @Configuration + static class TestConfig { + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper().findAndRegisterModules(); + } + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageServiceTest.java new file mode 100644 index 000000000..fe2317f3d --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageServiceTest.java @@ -0,0 +1,58 @@ +package com.baiyue.integration.service.ocr; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.ocr.OcrCandidate; +import com.baiyue.integration.dto.ocr.WeightOcrBatchResult; +import com.baiyue.integration.dto.ocr.WeightOcrImageResult; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.util.unit.DataSize; + +import java.math.BigDecimal; +import java.net.URI; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(WeightOcrSingleImageService.class) +class WeightOcrSingleImageServiceTest { + @TempDir Path tempDirectory; + @MockBean IntegrationProperties properties; + @MockBean WeightOcrBatchService batches; + @Resource WeightOcrSingleImageService service; + + @Test + void returnsSafeContractWithoutTemporaryPathsOrCandidateDetails() throws Exception { + var config = new IntegrationProperties.BaiduOcr(true, "key", "secret", + URI.create("https://example.com/token"), URI.create("https://example.com/meter"), + Duration.ofSeconds(1), Duration.ofSeconds(1), tempDirectory, tempDirectory.resolve("out"), + DataSize.ofMegabytes(3), 40_000_000L, 50, Duration.ZERO, 0); + when(properties.baiduOcr()).thenReturn(config); + var imageResult = new WeightOcrImageResult("weight.jpg", "cropped/weight.jpg", "raw/weight.json", + WeightOcrImageResult.Status.RECOGNIZED, new BigDecimal("285.5"), + new BigDecimal("0.5097191334"), + List.of(new OcrCandidate("285.5", new BigDecimal("0.5097191334"), 0, 0, 10, 10)), + "285.5", null); + when(batches.recognize(any())).thenReturn(new WeightOcrBatchResult("batch", Instant.now(), + Instant.now(), "input", "output", 1, 1, 0, 0, List.of(imageResult))); + + var result = service.recognize(new MockMultipartFile( + "file", "weight.jpg", "image/jpeg", new byte[]{1, 2, 3})); + + assertThat(result.confidence()).isEqualByComparingTo("0.50972"); + assertThat(result.ocrRawText()).isEqualTo("285.5"); + assertThat(result.sourceImage()).isNull(); + assertThat(result.croppedImage()).isNull(); + assertThat(result.rawResponseFile()).isNull(); + assertThat(result.candidates()).isEmpty(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java new file mode 100644 index 000000000..206f5ef4e --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java @@ -0,0 +1,66 @@ +package com.baiyue.integration.service.ocr; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.integration.config.IntegrationProperties; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.util.unit.DataSize; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@DisplayName("OCR 文件存储策略功能") +@SpringJUnitConfig(WeightOcrStoragePolicy.class) +class WeightOcrStoragePolicyTest { + @TempDir + Path tempDirectory; + @MockBean private IntegrationProperties properties; + @Resource private WeightOcrStoragePolicy policy; + + @Test + void resolvesDirectoriesInsideConfiguredRoots() throws Exception { + Path input = Files.createDirectories(tempDirectory.resolve("input/batch-a")); + Path outputRoot = tempDirectory.resolve("output"); + when(properties.baiduOcr()).thenReturn(config(input.getParent(), outputRoot)); + + WeightOcrStoragePolicy.BatchPaths paths = policy.resolve("batch-a", null, "batch-id"); + + assertThat(paths.inputDirectory()).isEqualTo(input.toRealPath()); + assertThat(paths.outputDirectory()).isEqualTo(outputRoot.resolve("batch-id").toRealPath()); + } + + @Test + void rejectsInputAndOutputOutsideConfiguredRoots() throws Exception { + Path inputRoot = Files.createDirectories(tempDirectory.resolve("input")); + Path outputRoot = tempDirectory.resolve("output"); + Path outside = Files.createDirectories(tempDirectory.resolve("outside")); + when(properties.baiduOcr()).thenReturn(config(inputRoot, outputRoot)); + + assertThatThrownBy(() -> policy.resolve(outside.toString(), null, "batch-id")) + .isInstanceOfSatisfying(BizException.class, + exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); + assertThatThrownBy(() -> policy.resolve(".", "../outside", "batch-id")) + .isInstanceOfSatisfying(BizException.class, + exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); + } + + private IntegrationProperties.BaiduOcr config(Path inputRoot, Path outputRoot) { + IntegrationProperties.BaiduOcr baidu = new IntegrationProperties.BaiduOcr( + true, "key", "secret", URI.create("https://example.com/token"), + 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 baidu; + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md new file mode 100644 index 000000000..4a7c20eff --- /dev/null +++ b/lastleg-service/AGENTS.md @@ -0,0 +1,144 @@ +# Lastleg Service 模块指引 + +## 职责与边界 + +- Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueLastlegApplication.java` 对 `com.baiyue.lastleg` 根包按 `annotationClass = Mapper.class` 扫描;各业务切片不得新增 `*MapperConfiguration` 或维护包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内门禁见 `MapperRegistrationArchitectureTest.java`。 +- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert-container` 后才按柜内每票生成独立计划并推进订单。 +- 负责尾程订单列表、详情、导出、完整入库计划、暂扣/解除、人工节点和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 +- 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 + +## 快速索引 + +退货预报、签收、库存、合箱与退货出库: + +- 客户预报 `returnreceipt/controller/MerchantReturnOrderController.java` → `returnreceipt/service/ReturnReceiptService.java`,路径 `/api/merchant/v1/last-leg/return-orders`;客户范围取认证身份,预报按 SKU 保存期望数量。状态 `PENDING_RECEIPT → PARTIALLY_RECEIVED → COMPLETED` 表示签收进度,不等于换标或出库完成。 +- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量,幂等重放不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。 +- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。 +- 退货出库继续进入 `delivery/service/DeliveryTransferService.java` 的 `RETURN_OUTBOUND`,按退货库存可用箱唛创建与拣货;不能与 `exceptionorder/` 的派送退回恢复库存混用。契约见 `src/main/resources/openapi/overseas-business-v1.yaml`,表入口 `src/main/resources/db/lastleg.sql` 的 `ll_return_*`。 +- 客户箱号管理使用 `GET /api/merchant/v1/last-leg/return-inventory/cartons` 按实物箱分页,库存箱内 SKU 明细仍使用根查询;两者均按登录客户精确隔离。合箱 `sourceBatchNo/sourceReturnNo` 保存实际消耗来源的去重业务编号(多个以逗号分隔),不得写状态或包含未取货订单。`ReturnCartonBarcode` 按箱号生成 Code 128 SVG,打印内容不得使用内部客户/仓库 ID。 + +人工计划自动生成托盘与资源重试:`inboundplan/controller/InboundPlanController.java` → `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;保存单票后重读全柜预览,全员为 `PLANNED/RESOURCE_PENDING`、预览就绪且存在待绑定资源时自动调用柜级 `confirm`。一票柜立即触发,多票柜在最后一票齐全后触发;资源异常沿 `resourcePendingPlanCount` 和原柜级预览/确认入口重试,不新增独立正常人工步骤。回归入口为 `src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java`。 + +柜级预览同时返回 `sourcePalletCount/supplementPalletCount/targetPalletCount`:头程冻结托盘码原样保留,海外仓最终托数较多时按同柜同 FBA 编码序列补足差额,不因头尾装托密度不同阻断;页面必须分别展示头程已有、海外补充和最终托数,不能把最终总数误称为“本次新增”。 + +头程来源托盘快照:`service/LoadingCompletedConsumer.java`、`mapper/LastlegSourcePalletMapper.java`;来源标签随装柜完成事件落入 `ll_order_source_pallet`,后续由 `inboundplan/service/InboundPlanService.java` 与 `ContainerInboundPlanConversionCoordinator.java` 传递给海外资源批量预留。排查标签跨区丢失时沿此链路核对,不能在尾程页面重新生成头程托盘码。 + +| 功能 | 入口 | +|---|---| +| 内部模块化基线与依赖分析 | `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`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | +| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | +| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | +| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 | +| 尾程单号生成 | `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/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 | +| 托盘分配算法 | `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 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 | +| 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` | +| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 | +| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | +| 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 | +| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 | +| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 | +| 入仓后 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 Controller | +| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 | +| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token | +| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`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`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” | +| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` | +| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | +| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | +| 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | +| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | +| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等;人工动作不再提供完成派送,签收资料保存后自动完成当前目的FBA仓派送单 | +| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、按目的FBA仓拆分的派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载。`RECEIPT` 成功保存进权威快照时事务内自动完成当前派送单,单纯上传不触发完成 | +| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 | +| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | +| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 | +| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` | +| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口要求 `merchant:cross-border:domestic-trucking` | +| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口要求 `merchant:cross-border:pallet-shipping`;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 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 +- 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 +- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。 +- 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成一张 `WAIT_OUTBOUND` 出库单,并按目的FBA仓生成多张派送单,但必须早于发车;BOL和签收资料均绑定拆分后的单仓派送单,派送费用仍只发布一次。PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。 +- 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。 +- 仓库 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 分配,不得覆盖来源托盘快照。 +- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,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 投影;AUTHORIZATION 必须携带当前 `PLATFORM_ADMIN` 角色及按 IAM 跨区前缀筛选的完整权限快照,统一 SQL 重放只修正仍处于固定 baseline version=0 的该行,后续 IAM version>=1 事件单调覆盖。不得扩大为其他历史账号运行时补齐或缺失投影默认放行。 +- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 +- 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选按来源柜跨客户聚合,同柜允许多个客户;选柜会话、三类照片及整柜完成事实只绑定 `sourceContainerBookingId + containerNo`,文件与柜级跨区事件使用 `tenantId=0` 平台技术范围。签收后按每个入仓计划自身客户生成任务,扫码、SKU 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。 + +## 查询与租户约束 + +- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 +- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。 +- 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。 +- 托盘是 PDA 出库的作业单位,但不是库存归属单位:`ll_inventory_position` 通过余额和托盘保存“托盘包含哪些入仓单、各多少箱”的组成明细。合托后的同一实物托盘允许对应多个入仓单,换标冻结和后续出库核算必须按这些明细分别记账;整托出库可扫描托盘一次完成,部分出库先拆托或按明细核平,不得用 FIFO 猜测来源入仓单。普通尾程入库只确认某入仓单在托盘上的箱数,没有逐箱唛到托盘的快照;贴标下单由客户填写我方箱唛,库存仓位只作为冻结记账锚点和现场大概位置,不能据此声称箱唛位于某个具体托盘。贴标任务以我方箱唛为主键,同箱多 SKU 只存为子明细且不重复冻结。 +- 换标箱唛经现场确认后执行 `pallet-operations/relabel-disassemble`:允许仅移动该换标单对应的 `hold_packages`,不得解冻为普通可用库存;事务内同步更新 `ll_relabel_item.source_inventory_position_id`。若客户确认后已生成换标出库单,还必须在拣货开始前同步拆分或改写 `ll_outbound_pallet_item` 的托盘、箱唛、箱数与来源仓位快照,使 PDA 后续按新旧托盘分别扫描且每个指定箱唛只出库一次。 +- `FIRST_LEG` 在途、计划转换与 PDA 入账统一使用尾程订单冻结的实际接柜海外仓 ID/编码/名称,禁止使用国内来源仓或 FBA 目的仓冒充;接柜仓变更必须在同一事务内修订尚未形成实物仓位的在途余额并写零差额审计流水。余额分页、汇总和 CSV 默认排除所有数量桶均为零的维度,跨仓入账清空的无仓位来源余额必须原子删除。 +- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 +- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 +- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。 +- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购编辑可维护入库数量和采购出库明细,出库量为明细数量汇总,审核通过且有入库量后按 `PENDING_OUTBOUND`、`PARTIALLY_OUTBOUND`、`OUTBOUNDED` 派生展示状态。采购出库只记录采购单据流转,不得写入或冒充 `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`。 + +## 修改与验证 + +- 混合换标 `CARTON_AND_SKU` 使用现有 PDA 历史保存 `MIXED_CARTON/MIXED_SKU` 分项事实(itemId + 退回轮次);同一项防重复,两项全部完成才写一条箱事实并增加整箱计数。详情返回 `cartonProcessedPackages/skuProcessedPackages`;不得将单项完成当作整箱完成,返工后旧轮次不计入当前分项进度。 + +- 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。 + +- 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `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-service -am test`。 +- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 diff --git a/lastleg-service/docs/delivery-node-handoff-20260909.md b/lastleg-service/docs/delivery-node-handoff-20260909.md new file mode 100644 index 000000000..d920d6161 --- /dev/null +++ b/lastleg-service/docs/delivery-node-handoff-20260909.md @@ -0,0 +1,48 @@ +# 派送节点交付与六按钮现状核查 + +范围:测试项目当前 `test` 分支;后端16派送业务。仅源码和离线测试,不代表真实环境已部署或人工验收通过。 + +## 状态错误结论 + +用户请求仅提交 `status=DELIVERING, remark=123`,缺少必传 `expectedVersion`,因此返回参数错误。后端 `CustomerVisitRequests.DeliveryStatus` 保留版本必填;列表/详情 `DeliveryRow` 已返回 `version/allowedActions`。当前 Admin 的编辑状态选项将 `DELIVERING` 标为“派送中”,`DELIVERED` 标为“派送完成”,提交代码遗漏版本;不能据用户口述把 DELIVERING 改成完成。 + +状态命令保持 `{status,expectedVersion,remark}`,不新增幂等头。`START_DELIVERY→DELIVERING` 记录首次派送时间;`COMPLETE_DELIVERY→DELIVERED` 记录签收时间、更新尾程子单状态并生成待回访。相同状态重放成功,不重复状态写入;非法回退及版本冲突拒绝。未新增资料齐全、签名识别或电子签署门禁。 + +## 三类资料最终契约 + +完整机器契约:`src/main/resources/openapi/delivery-documents-v1.yaml`。七类方案已撤销,旧类别不接受。 + +| 顺序与业务类别 | 显示名称 | File类型 | +|---|---|---| +| LOADING_PHOTO | 装车照片 | DELIVERY_LOADING_PHOTO | +| BOL | BOL文件(含签名) | DELIVERY_BOL | +| RECEIPT | 签收文件(POD签收/快递签收) | DELIVERY_RECEIPT | + +每类0至20个文件,沿用 File 任意格式且单文件非空、不超过50MiB规则,按有已保存文件的类别计数0/3至3/3。POD与快递签收合为同一类别,文件数量不改变类别计数。 + +- 读取/下载权限:`platform:overseas-warehouse:delivery:list`;上传/保存权限:`platform:overseas-warehouse:delivery:documents`。 +- GET/PUT `/delivery-orders/{deliveryNo}/documents?customerId=...`;PUT全量保存 `{expectedVersion,idempotencyKey,bolNo,documents:[{category,fileIds}]}`,遗漏类别清空。 +- 同一派送锁内先检查版本与全部文件归属,再原子写当前快照、增加派送版本、写不可变历史及操作日志。文件保存使用 File 的真实文件名、格式、大小、创建时间,不信任前端元数据。 +- 上传不绑定、不修改版本或摘要。未保存/取消弹框不落绑定。替换和删除仅改当前引用,已保存历史和物理对象保留。 +- 当前下载仅允许当前绑定文件;指定 `revisionVersion` 仅可下载该派送单对应历史版本已绑定文件。两者均复核客户、派送内部ID、类别和fileId。 +- BOL号独立保存,未建立与其他单据的权威关联,不按相同BOL文本或运单号复用其他文件。 +- 列表和详情行新增 `bolNo/documentSummary`,摘要从本地保存快照读取,不逐文件调用File。 + +## 六按钮现状 + +| 按钮 | 当前实现与权限 | 判定与边界 | +|---|---|---| +| 编辑节点 | `CustomerVisitController` 的 PUT `/delivery-orders/{deliveryNo}/status`、POST `/complete`;`delivery:status`;版本与状态机 | 已实现状态命令,前端漏版本交Admin修复。当前策略只校验派送状态,未联查出库实物门禁;若“编辑节点”意为补录节点时间或修改完成前置规则,需进一步确认,不自动实现 | +| 打印BOL | 派送Controller未发现生成打印接口;Print现有海运提单确认件不能直接证明派送BOL实现 | 未实现派送专属生成链。上传已签名BOL及下载不等于生成打印;模板、字段、签名来源需确认 | +| 详情 | GET `/delivery-orders/{deliveryNo}?customerId=...`,`delivery:list`,返回派送行/货物/费用/操作历史 | 已实现,真实环境待验收 | +| 取消 | 通用状态仅允许WAIT_DELIVERY→CANCELLED,更新派送状态/版本/日志。另出库 `/outbound-orders/{outboundNo}/cancel` 属出库专项,校验未执行并取消关联派送、PDA任务和明细 | 部分实现。单改派送状态不等于撤销履约关联;出库取消还会将尾程主状态写RECEIVED,已报测试总控。需确认派送取消与出库/排车的业务关系,不能擅自互相替代 | +| 编辑车队 | 派送创建保存carrier/driver/vehicle快照,未发现派送单独立维护接口 | 未实现;可改状态、是否联动排车和来源主档规则需确认 | +| 标记退货 | `ExceptionOrderController` `/return-inbound-options`、`/mark-return`;`delivery:return`。从原出库明细选择,冻结可退上限,幂等生成异常;异常处理使用version/幂等与证据 | 已实现后端。退回海外仓必须先按来源恢复实收库存,再同事务完成异常并置派送RETURNED;不能直接改派送状态代替库存。Admin旧自由SKU入参交其收敛 | + +## 验证与部署前提 + +- 本次定向69项通过:包含资料版本/重放/异请求冲突、客户/单据/类别/fileId错配、文件计数与保存后重读、未保存下载拒绝、历史解绑下载、旧枚举拒绝、嵌套校验、权限分离、状态开始/完成/重放、现有出库/退货和SQL合同;只读用户的详情/重放/历史不返回管理动作,按当前授权而非历史权限裁剪。 +- Mapper扫描通过。AGENTS索引检查有两项既存柜费契约缺口:金额守恒、单行拒绝处理分摊组,未修改该域。 +- 模块基线和获准的海外统一入口同步 `ll_delivery_order.bol_no/documents_json`、`ll_delivery_document_revision`。只维护当前初始化定义,没有旧类别迁移或清理脚本;既有运行库应用方式交测试总控按环境授权处理。 +- 单元测试验证保存前全部校验无部分写入和快照重读;真实MySQL事务故障注入、跨服务真实上传下载、页面刷新与人工截图尚未验收。 +- 配套最终三类File提交 `81b72992` 和IAM权限 `cde8acf1` 已进入 `test`;不能使用早期七类File提交作为最终类别合同。 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..3025abff5 --- /dev/null +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -0,0 +1,218 @@ +# 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 | dropship | 5 | +| 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 | dispatch-delivery | 8 | +| tailleg-handoff | dropship | 2 | +| tailleg-handoff | inbound-pda | 6 | +| tailleg-handoff | inventory | 2 | +| tailleg-handoff | shared-technical | 2 | + +当前 28 条切片边冻结在 `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 或应用服务。 + +## 12. 海外仓出库与派送出库派送稳定边界 + +- `dispatch-delivery -> dropship` 是一件代发出库的稳定业务依赖:出库应用服务按客户范围读取一件代发托盘、箱唛和 SKU 库存,并在 PDA 出库事务中完成扣减。 +- `tailleg-handoff -> dispatch-delivery` 是异常退回与客户回访的稳定业务依赖:异常单和回访应用服务按派送业务号读取派送单及操作记录,不引入反向兼容入口或跨服务共享模型。 +- 两条边均属于同一 `lastleg-service` 部署单元内的海外仓出库与派送正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。 diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml new file mode 100644 index 000000000..1314eb0d7 --- /dev/null +++ b/lastleg-service/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml + lastleg-service + + com.google.zxingcore3.5.3 + 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 + com.belerwebpinyin4j2.5.1 + + 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..fba80bf56 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg; + +import org.apache.ibatis.annotations.Mapper; +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(basePackages = "com.baiyue.lastleg", annotationClass = Mapper.class) +@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/DomesticWarehouseDirectoryClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java new file mode 100644 index 000000000..048206462 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.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; + +/** 国内 Warehouse Service 平台公共仓库只读目录。 */ +@FeignClient(name = "warehouse-service", contextId = "domesticWarehouseDirectoryClient", + url = "${baiyue.clients.domestic-warehouse.url:}") +public interface DomesticWarehouseDirectoryClient { + @GetMapping("/internal/warehouses/0/{warehouseId}/active") + ApiResponse activeWarehouse( + @PathVariable("warehouseId") long warehouseId, + @RequestHeader("X-Internal-Service-Token") String internalToken); + + record Detail(Warehouse warehouse, List
addresses) { } + + record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName, + String warehouseType, String countryCode, String status) { } + + record Address(String addressType, String countryCode, String stateProvince, String city, + String district, String postalCode, String addressLine1, String addressLine2) { } +} 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/client/PrintPdfClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/PrintPdfClient.java new file mode 100644 index 000000000..ed4ef5325 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/PrintPdfClient.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.client; + +import java.math.BigDecimal; +import java.util.List; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(name = "print-service", contextId = "lastlegPrintPdfClient", + url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs") +public interface PrintPdfClient { + @PostMapping(value = "/dispatch-bol-documents", consumes = "application/json") + ResponseEntity generateDispatchBol( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @RequestBody Request request); + + record Request(String templateCode, PageSpec pageSpec, String downloadFileName, List documents) {} + record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {} + record Document(String shipFromName, String shipFromAddress, String carrierName, String isaNumber, + String appointmentTime, String trailerNumber, String sealNumber, String fbaWarehouseCode, + String shipToName, String shipToAddress, String dispatchNo, String fbaShipmentIds, + String poReferenceNos, int totalPackages, int pageNumber, int pageCount, List cargoRows) {} + record CargoRow(String containerNo, String palletNo, String fbaWarehouseCode, String warehouseNo, + String fbaShipmentId, String poReferenceNo, int packageCount) {} +} 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..b647f3a9b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java @@ -0,0 +1,62 @@ +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 customerVisitFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + private ClientTimeouts procurementFile = 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)); + private ClientTimeouts domesticLoadingEvidence = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + + 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 getCustomerVisitFile() { return customerVisitFile; } + public void setCustomerVisitFile(ClientTimeouts value) { customerVisitFile = required(value, "customer-visit-file"); } + public ClientTimeouts getProcurementFile() { return procurementFile; } + public void setProcurementFile(ClientTimeouts value) { procurementFile = required(value, "procurement-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"); } + public ClientTimeouts getDomesticLoadingEvidence() { return domesticLoadingEvidence; } + public void setDomesticLoadingEvidence(ClientTimeouts value) { domesticLoadingEvidence = required(value, "domestic-loading-evidence"); } + + 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..5a494a7ea --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -0,0 +1,299 @@ +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.dto.LastlegOrderCommandDtos; +import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; +import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; +import com.baiyue.lastleg.dto.ContainerArrivalTimeView; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.baiyue.lastleg.security.OverseasPermissions; +import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.service.LastlegOrderCommandService; +import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; +import com.baiyue.common.event.EventTypes; +import com.baiyue.lastleg.wallet.WalletCommandCompletedConsumer; +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; +import java.util.List; + +@RestController +@Tag(name = "Tail-leg orders") +public class LastlegOrderController { + @Resource private LoadingCompletedConsumer consumer; + @Resource private ContainerShippingNodeConsumer shippingNodeConsumer; + @Resource private LastlegOrderService orderService; + @Resource private LastlegOrderCommandService commandService; + @Resource private InternalServiceTokenGuard tokenGuard; + @Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer; + + @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.WALLET_COMMAND_COMPLETED.equals(event.eventType())) { + consumed = walletCommandCompletedConsumer.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, + @RequestParam(required = false) String relabelNo, + @Valid LastlegOrderRequests.Query query) { + return ApiResponse.success(orderService.page(platformScope(user, customerId), query, relabelNo)); + } + + /** Compatibility overload used by non-Spring callers and existing contract tests. */ + public ApiResponse> list( + AuthenticatedUser user, String customerId, LastlegOrderRequests.Query query) { + return ApiResponse.success(orderService.page(platformScope(user, customerId), query)); + } + + @GetMapping("/api/platform/v1/last-leg/orders/container-summaries") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "分页查询待分配柜级摘要", + description = "按稳定 sourceContainerBookingId 聚合待分配尾程订单;平台跨客户查询。Required authority: " + + OverseasPermissions.LAST_MILE_ORDER_LIST) + public ApiResponse> containerSummaries( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid LastlegOrderRequests.ContainerProgressQuery query) { + platformScope(user, null); + return ApiResponse.success(orderService.containerProgressPage(query)); + } + + @GetMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "查询柜级入库计划进度详情", + description = "按稳定 sourceContainerBookingId 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页影响。Required authority: " + + OverseasPermissions.LAST_MILE_ORDER_LIST) + public ApiResponse containerSummaryDetail( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceContainerBookingId) { + platformScope(user, null); + return ApiResponse.success(orderService.containerProgressDetail(sourceContainerBookingId)); + } + + @PostMapping("/api/platform/v1/last-leg/orders/container-arrival-times/query") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "批量查询整柜预计与实际到仓时间", + description = "预计到仓取平台人工维护的整柜冻结值;实际到仓取海外PDA首次开始收货时间。") + public ApiResponse> containerArrivalTimes( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody LastlegOrderRequests.ContainerArrivalTimeQuery request) { + platformScope(user, null); + return ApiResponse.success(orderService.containerArrivalTimes(request)); + } + + @PutMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/expected-arrival") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "修改整柜预计到仓时间", + description = "在入库计划生成前原子更新来源柜全部尾程订单;入库计划统一读取该柜时间。") + public ApiResponse changeExpectedWarehouseArrival( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceContainerBookingId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderRequests.ChangeExpectedWarehouseArrival request) { + platformScope(user, null); + return ApiResponse.success(orderService.changeExpectedWarehouseArrival(sourceContainerBookingId, + idempotencyKey, request, operatorName(user))); + } + + @PutMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "修改整柜海外接柜仓", + description = "仅在海外PDA建立接柜会话前允许修改;按 expectedVersion 乐观锁原子同步柜内订单与入库计划。") + public ApiResponse changeReceivingWarehouse( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceContainerBookingId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderRequests.ChangeReceivingWarehouse request) { + platformScope(user, null); + return ApiResponse.success(orderService.changeReceivingWarehouse(sourceContainerBookingId, + idempotencyKey, request, operatorName(user))); + } + + @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)); + } + + @PostMapping("/api/platform/v1/last-leg/orders/{id}/fees") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Add a fee to one tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse createFee( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderRequests.CreateFee request) { + return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, + operatorName(user), idempotencyKey)); + } + + @PutMapping("/api/platform/v1/last-leg/orders/{id}/hold") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "暂扣或解除单票尾程订单", + description = "要求版本与幂等键;暂扣只作用于该票并阻断其出库推进。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse hold( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderCommandDtos.HoldRequest request) { + return ApiResponse.success(commandService.hold(platformScope(user, customerId), id, idempotencyKey, + request, operatorId(user), operatorName(user))); + } + + @PutMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "保存单票人工节点覆盖", + description = "首次保存后该票永久使用 MANUAL 节点,不再回退柜级节点;当前无清除或恢复继承接口。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse saveManualNodes( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderCommandDtos.ManualNodesRequest request) { + return ApiResponse.success(commandService.saveManualNodes(platformScope(user, customerId), id, + idempotencyKey, request, operatorId(user), operatorName(user))); + } + + @GetMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "查询单票人工节点覆盖", + description = "只返回人工填写节点及 MANUAL 来源。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) + public ApiResponse manualNodes( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id) { + return ApiResponse.success(commandService.manualNodes(platformScope(user, customerId), id)); + } + + @PutMapping("/api/platform/v1/last-leg/orders/{id}/pallet-merge-required") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Set the PDA pallet-merge reminder for one tail-leg order", + description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse setPalletMergeRequired( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @Valid @RequestBody LastlegOrderRequests.SetPalletMergeRequired request) { + orderService.setPalletMergeRequired(platformScope(user, customerId), id, request, operatorName(user)); + return ApiResponse.success(null); + } + + @PutMapping("/api/platform/v1/last-leg/orders/pallet-merge-required/batch") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Mark a selected tail-leg order group for PDA pallet merge") + public ApiResponse setPalletMergeBatch( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid @RequestBody LastlegOrderRequests.SetPalletMergeBatch request) { + orderService.setPalletMergeBatch(platformScope(user, customerId), request, operatorName(user)); + return ApiResponse.success(null); + } + + @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 = "将单票尾程订单转入 FBA 目的仓", + description = "目标仓必须来自国内 Warehouse Service 的平台公共 ACTIVE FBA 主档,ID/编码/名称必须一致。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 = "批量将尾程订单转入 FBA 目的仓", + description = "目标仓必须来自国内 Warehouse Service 的平台公共 ACTIVE FBA 主档,ID/编码/名称必须一致。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(); + } + + private String operatorName(AuthenticatedUser user) { + return user.username() == null || user.username().isBlank() ? String.valueOf(user.userId()) : user.username(); + } + + private String operatorId(AuthenticatedUser user) { + return user.userId() == null ? operatorName(user) : String.valueOf(user.userId()); + } +} 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/customervisit/controller/CustomerVisitController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java new file mode 100644 index 000000000..ba27ae938 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java @@ -0,0 +1,92 @@ +package com.baiyue.lastleg.customervisit.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests; +import com.baiyue.lastleg.customervisit.dto.CustomerVisitResponses; +import com.baiyue.lastleg.customervisit.service.CustomerVisitService; +import com.baiyue.lastleg.security.OverseasPermissions; +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.util.StringUtils; +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.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg") +public class CustomerVisitController { + @Resource private CustomerVisitService service; + + @GetMapping("/customer-visits") + @PreAuthorize("hasAuthority('" + OverseasPermissions.CUSTOMER_VISIT_LIST + "')") + public ApiResponse page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid CustomerVisitRequests.Query query) { + return ApiResponse.success(service.page(scope(user), query)); + } + + @GetMapping("/customer-visits/{visitId}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.CUSTOMER_VISIT_LIST + "')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long visitId) { + return ApiResponse.success(service.detail(requiredScope(user, customerId), visitId)); + } + + @PutMapping("/customer-visits/{visitId}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.CUSTOMER_VISIT_EDIT + "')") + public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long visitId, + @Valid @RequestBody CustomerVisitRequests.Save request) { + return ApiResponse.success(service.save(requiredScope(user, customerId), operator(user), visitId, request)); + } + + @PostMapping(value = "/customer-visits/{visitId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.CUSTOMER_VISIT_EDIT + "')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long visitId, + @RequestPart("file") MultipartFile file) { + if (user.userId() == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "当前用户缺少有效ID"); + return ApiResponse.success(service.upload(requiredScope(user, customerId), user.userId(), visitId, file)); + } + + @PutMapping("/delivery-orders/{deliveryNo}/status") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_STATUS + "')") + public ApiResponse updateDeliveryStatus( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String deliveryNo, @Valid @RequestBody CustomerVisitRequests.DeliveryStatus request) { + return ApiResponse.success(service.updateDeliveryStatus( + requiredScope(user, customerId), operator(user), deliveryNo, request)); + } + + private String scope(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + return "PLATFORM".equals(user.appCode()) && user.tenantId() == 0L ? null : String.valueOf(user.tenantId()); + } + + private String requiredScope(AuthenticatedUser user, String requestedCustomer) { + String own = scope(user); + if (own != null) return own; + if (!StringUtils.hasText(requestedCustomer)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择客户"); + return requestedCustomer.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/customervisit/dto/CustomerVisitRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java new file mode 100644 index 000000000..2153d08fd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.customervisit.dto; + +import jakarta.validation.Valid; +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.time.LocalDateTime; +import java.util.List; + +public final class CustomerVisitRequests { + private CustomerVisitRequests() { } + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, + String keyword, String status, String customerId, + LocalDateTime completedFrom, LocalDateTime completedTo) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record Save(@NotNull Long selectedInboundOrderId, + @NotBlank @Size(max = 32) String visitResult, + @Size(max = 2000) String customerFeedback, + @Size(max = 1000) String remark, + @Valid @Size(max = 20) List attachments) { } + + public record Attachment(@NotNull Long fileId, + @NotBlank @Size(max = 255) String fileName, + @Size(max = 128) String contentType, + @NotNull @Min(0) Long sizeBytes) { } + + public record DeliveryStatus(@NotBlank @Size(max = 32) String status, + @NotNull @Min(0) Long expectedVersion, + @Size(max = 500) String remark) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitResponses.java new file mode 100644 index 000000000..943fb2f28 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitResponses.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.customervisit.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class CustomerVisitResponses { + private CustomerVisitResponses() { } + + public record Row(Long id, String customerId, String deliveryNo, String customerName, + String contactName, String contactPhone, String destination, + LocalDateTime deliveryCompletedAt, String status, + String selectedInboundCode, String visitResult, LocalDateTime visitedAt, + int visitCount, List attachments) { } + + public record Page(List records, long current, long size, long total, long pages, + long visitable, long pending, long completed) { } + + public record InboundOption(Long inboundOrderId, String inboundCode, Long lastMileOrderId, + String lastMileOrderNo, String destinationWarehouseCode, + String destinationWarehouseName, int packageCount) { } + + public record Attachment(Long fileId, String fileName, String contentType, long sizeBytes) { } + + public record Detail(Row visit, List inboundOrders, + String customerFeedback, String remark, String visitedBy) { } + + public record UploadedFile(Long fileId, String fileName, String contentType, long sizeBytes) { } + + public record Completed(Long visitId, String deliveryNo, String deliveryStatus, + String visitStatus, boolean replayed) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/entity/CustomerVisit.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/entity/CustomerVisit.java new file mode 100644 index 000000000..d750d39c1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/entity/CustomerVisit.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.customervisit.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_customer_visit") +public class CustomerVisit { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private Long deliveryOrderId; + private String deliveryNo; + private String customerName; + private String contactName; + private String contactPhone; + private String destination; + private LocalDateTime deliveryCompletedAt; + private String status; + private Long selectedLastMileOrderId; + private String selectedLastMileOrderNo; + private Long selectedInboundOrderId; + private String selectedInboundCode; + private String visitResult; + private String customerFeedback; + private String remark; + private String attachmentJson; + private String visitedBy; + private LocalDateTime visitedAt; + private Integer visitCount; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/CustomerVisitFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/CustomerVisitFileGateway.java new file mode 100644 index 000000000..b3d81e99d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/CustomerVisitFileGateway.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.customervisit.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface CustomerVisitFileGateway { + UploadedFile upload(String customerId, long visitId, long operatorId, MultipartFile file); + void validate(String customerId, long visitId, long fileId); + + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java new file mode 100644 index 000000000..a301603e2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.customervisit.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 HttpCustomerVisitFileGateway implements CustomerVisitFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_CUSTOMER_VISIT"; + @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.getCustomerVisitFile()); + } + + @Override + public UploadedFile upload(String customerId, long visitId, long operatorId, MultipartFile file) { + 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 ResponseStatusException(HttpStatus.BAD_REQUEST, "无法读取回访资料", exception); + } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId(customerId), BUSINESS_TYPE, visitId) + .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, "回访资料上传失败"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "回访资料上传失败"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null) throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回回访资料元数据"); + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), + data.sizeBytes() == null ? 0L : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long visitId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, visitId, 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, "回访资料不存在或归属不正确"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "回访资料不存在或归属不正确"); + 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(visitId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "回访资料与当前记录不匹配"); + } + } + + private static long tenantId(String customerId) { + try { return Long.parseLong(customerId); } + catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "客户ID格式无效", 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; + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + 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/customervisit/mapper/CustomerVisitMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/mapper/CustomerVisitMapper.java new file mode 100644 index 000000000..f52254c71 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/mapper/CustomerVisitMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.customervisit.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.customervisit.entity.CustomerVisit; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface CustomerVisitMapper extends BaseMapper { + @Select("SELECT * FROM ll_customer_visit WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} LIMIT 1") + CustomerVisit findByDelivery(@Param("customerId") String customerId, + @Param("deliveryOrderId") Long deliveryOrderId); + + @Select("SELECT * FROM ll_customer_visit WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE") + CustomerVisit lockById(@Param("customerId") String customerId, @Param("id") Long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java new file mode 100644 index 000000000..801207e6c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java @@ -0,0 +1,294 @@ +package com.baiyue.lastleg.customervisit.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.lastleg.customervisit.dto.CustomerVisitRequests; +import com.baiyue.lastleg.customervisit.dto.CustomerVisitResponses; +import com.baiyue.lastleg.customervisit.entity.CustomerVisit; +import com.baiyue.lastleg.customervisit.gateway.CustomerVisitFileGateway; +import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryOperation; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy; +import com.baiyue.lastleg.entity.LastlegInboundOrder; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +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.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +@Service +public class CustomerVisitService { + private static final Set RESULTS = Set.of("SATISFIED", "NORMAL", "FOLLOW_UP", "NO_CONTACT"); + private static final long MAX_FILE_BYTES = 20L * 1024L * 1024L; + private static final Set FILE_EXTENSIONS = Set.of( + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "jpg", "jpeg", "png", "webp"); + + @Resource private CustomerVisitMapper visits; + @Resource private DeliveryOrderMapper deliveryOrders; + @Resource private DeliveryOperationMapper deliveryOperations; + @Resource private LastlegOrderMapper lastMileOrders; + @Resource private LastlegInboundOrderMapper inboundOrders; + @Resource private CustomerVisitFileGateway files; + @Resource private ObjectMapper objectMapper; + + public CustomerVisitResponses.Page page(String scopedCustomerId, CustomerVisitRequests.Query query) { + String customerId = customerScope(scopedCustomerId, query.customerId()); + LambdaQueryWrapper filter = filter(customerId, query); + Page result = visits.selectPage(new Page<>(query.page(), query.size()), + filter.orderByDesc(CustomerVisit::getDeliveryCompletedAt).orderByDesc(CustomerVisit::getId)); + LambdaQueryWrapper countScope = new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), CustomerVisit::getCustomerId, customerId); + long visitable = visits.selectCount(countScope); + long pending = visits.selectCount(new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), CustomerVisit::getCustomerId, customerId) + .eq(CustomerVisit::getStatus, "PENDING")); + long completed = visits.selectCount(new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), CustomerVisit::getCustomerId, customerId) + .eq(CustomerVisit::getStatus, "COMPLETED")); + return new CustomerVisitResponses.Page(result.getRecords().stream().map(this::row).toList(), + result.getCurrent(), result.getSize(), result.getTotal(), result.getPages(), visitable, pending, completed); + } + + public CustomerVisitResponses.Detail detail(String customerId, long visitId) { + CustomerVisit visit = required(customerId, visitId); + return detail(visit); + } + + @Transactional + public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId, + String deliveryNo, + CustomerVisitRequests.DeliveryStatus request) { + String target = trim(request.status()).toUpperCase(Locale.ROOT); + if (!DeliveryStatusPolicy.isEditableTarget(target)) { + throw badRequest("不支持的派送状态"); + } + DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("派送单不存在"); + if (target.equals(delivery.getStatus())) { + CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId()); + return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo, + delivery.getStatus(), existing == null ? null : existing.getStatus(), true); + } + requireVersion(delivery, request.expectedVersion()); + requireDeliveryTransition(delivery.getStatus(), target); + String from = delivery.getStatus(); + LocalDateTime now = LocalDateTime.now(); + delivery.setStatus(target); + if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now); + delivery.setUpdatedAt(now); + delivery.setVersion(version(delivery) + 1); + deliveryOrders.updateById(delivery); + String result = "派送状态由 " + from + " 修改为 " + target; + if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark()); + recordOperation(delivery, operatorId, "UPDATE_DELIVERY_STATUS", result, now); + CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId()); + return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo, + delivery.getStatus(), existing == null ? null : existing.getStatus(), false); + } + + @Transactional + public CustomerVisitResponses.Detail save(String customerId, String operatorId, long visitId, + CustomerVisitRequests.Save request) { + CustomerVisit visit = visits.lockById(customerId, visitId); + if (visit == null) throw notFound("回访记录不存在"); + CustomerVisitResponses.InboundOption selected = inboundOptions(visit).stream() + .filter(option -> Objects.equals(option.inboundOrderId(), request.selectedInboundOrderId())) + .findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单")); + String result = trim(request.visitResult()).toUpperCase(Locale.ROOT); + if (!RESULTS.contains(result)) throw badRequest("不支持的回访结果"); + List attachments = new ArrayList<>(); + Map unique = new LinkedHashMap<>(); + if (request.attachments() != null) { + for (CustomerVisitRequests.Attachment attachment : request.attachments()) { + files.validate(customerId, visitId, attachment.fileId()); + unique.put(attachment.fileId(), new CustomerVisitResponses.Attachment(attachment.fileId(), + trim(attachment.fileName()), trim(attachment.contentType()), attachment.sizeBytes())); + } + } + attachments.addAll(unique.values()); + LocalDateTime now = LocalDateTime.now(); + visit.setSelectedInboundOrderId(selected.inboundOrderId()); + visit.setSelectedInboundCode(selected.inboundCode()); + visit.setSelectedLastMileOrderId(selected.lastMileOrderId()); + visit.setSelectedLastMileOrderNo(selected.lastMileOrderNo()); + visit.setVisitResult(result); + visit.setCustomerFeedback(trim(request.customerFeedback())); + visit.setRemark(trim(request.remark())); + visit.setAttachmentJson(writeAttachments(attachments)); + visit.setStatus("COMPLETED"); + visit.setVisitedBy(operatorId); + visit.setVisitedAt(now); + visit.setVisitCount(Objects.requireNonNullElse(visit.getVisitCount(), 0) + 1); + visit.setUpdatedAt(now); + visits.updateById(visit); + DeliveryOrder delivery = deliveryOrders.findByNo(customerId, visit.getDeliveryNo()); + if (delivery != null) recordOperation(delivery, operatorId, "CUSTOMER_VISIT", + "已完成第" + visit.getVisitCount() + "次客户回访,入仓单 " + selected.inboundCode(), now); + return detail(visit); + } + + public CustomerVisitResponses.UploadedFile upload(String customerId, long operatorId, long visitId, + MultipartFile file) { + required(customerId, visitId); + validateFile(file); + CustomerVisitFileGateway.UploadedFile uploaded = files.upload(customerId, visitId, operatorId, file); + return new CustomerVisitResponses.UploadedFile(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes()); + } + + public void ensureForCompletedDelivery(DeliveryOrder delivery, LocalDateTime completedAt) { + ensureVisit(delivery, completedAt); + } + + private CustomerVisit ensureVisit(DeliveryOrder delivery, LocalDateTime completedAt) { + CustomerVisit existing = visits.findByDelivery(delivery.getCustomerId(), delivery.getId()); + if (existing != null) return existing; + List orders = orders(delivery); + CustomerVisit value = new CustomerVisit(); + value.setId(IdWorker.getId()); value.setCustomerId(delivery.getCustomerId()); + value.setDeliveryOrderId(delivery.getId()); value.setDeliveryNo(delivery.getDeliveryNo()); + value.setCustomerName(delivery.getCustomerName()); + value.setDestination(orders.stream().map(LastlegOrder::getFbaWarehouseCode) + .filter(StringUtils::hasText).distinct().reduce((left, right) -> left + ", " + right).orElse(null)); + value.setDeliveryCompletedAt(completedAt); value.setStatus("PENDING"); value.setVisitCount(0); + value.setAttachmentJson("[]"); value.setCreatedAt(LocalDateTime.now()); value.setUpdatedAt(value.getCreatedAt()); + try { visits.insert(value); } + catch (DuplicateKeyException exception) { + CustomerVisit concurrent = visits.findByDelivery(delivery.getCustomerId(), delivery.getId()); + if (concurrent != null) return concurrent; + throw exception; + } + return value; + } + + private CustomerVisitResponses.Detail detail(CustomerVisit visit) { + return new CustomerVisitResponses.Detail(row(visit), inboundOptions(visit), visit.getCustomerFeedback(), + visit.getRemark(), visit.getVisitedBy()); + } + + private List inboundOptions(CustomerVisit visit) { + List orders = lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, visit.getCustomerId()) + .eq(LastlegOrder::getDeliveryOrderNo, visit.getDeliveryNo()) + .orderByAsc(LastlegOrder::getInboundCode)); + if (orders.isEmpty()) return List.of(); + List codes = orders.stream().map(LastlegOrder::getInboundCode).filter(StringUtils::hasText).distinct().toList(); + Map byCode = new LinkedHashMap<>(); + if (!codes.isEmpty()) inboundOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegInboundOrder::getCustomerId, visit.getCustomerId()).in(LastlegInboundOrder::getInboundCode, codes)) + .forEach(value -> byCode.putIfAbsent(value.getInboundCode(), value)); + return orders.stream().map(order -> { + LastlegInboundOrder inbound = byCode.get(order.getInboundCode()); + return new CustomerVisitResponses.InboundOption(inbound == null ? null : inbound.getId(), + order.getInboundCode(), order.getId(), order.getOrderNo(), order.getFbaWarehouseCode(), + inbound == null ? null : inbound.getDestinationWarehouseName(), + Objects.requireNonNullElse(order.getPackageCount(), 0)); + }).filter(option -> option.inboundOrderId() != null).toList(); + } + + private List orders(DeliveryOrder delivery) { + return lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, delivery.getCustomerId()) + .eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo())); + } + + private CustomerVisitResponses.Row row(CustomerVisit value) { + return new CustomerVisitResponses.Row(value.getId(), value.getCustomerId(), value.getDeliveryNo(), + value.getCustomerName(), value.getContactName(), value.getContactPhone(), value.getDestination(), + value.getDeliveryCompletedAt(), value.getStatus(), value.getSelectedInboundCode(), value.getVisitResult(), + value.getVisitedAt(), Objects.requireNonNullElse(value.getVisitCount(), 0), readAttachments(value.getAttachmentJson())); + } + + private LambdaQueryWrapper filter(String customerId, CustomerVisitRequests.Query query) { + return new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), CustomerVisit::getCustomerId, customerId) + .and(StringUtils.hasText(query.keyword()), wrapper -> wrapper + .like(CustomerVisit::getDeliveryNo, trim(query.keyword())) + .or().like(CustomerVisit::getCustomerName, trim(query.keyword())) + .or().like(CustomerVisit::getContactName, trim(query.keyword())) + .or().like(CustomerVisit::getContactPhone, trim(query.keyword()))) + .eq(StringUtils.hasText(query.status()), CustomerVisit::getStatus, trim(query.status())) + .ge(query.completedFrom() != null, CustomerVisit::getDeliveryCompletedAt, query.completedFrom()) + .le(query.completedTo() != null, CustomerVisit::getDeliveryCompletedAt, query.completedTo()); + } + + private CustomerVisit required(String customerId, long visitId) { + CustomerVisit value = visits.selectOne(new LambdaQueryWrapper() + .eq(CustomerVisit::getCustomerId, customerId).eq(CustomerVisit::getId, visitId)); + if (value == null) throw notFound("回访记录不存在"); + return value; + } + + private void recordOperation(DeliveryOrder delivery, String operatorId, String node, String result, LocalDateTime now) { + DeliveryOperation operation = new DeliveryOperation(); + operation.setCustomerId(delivery.getCustomerId()); operation.setDeliveryOrderId(delivery.getId()); + operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); + operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); + } + + private String writeAttachments(List values) { + try { return objectMapper.writeValueAsString(values); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存回访资料", exception); } + } + + private List readAttachments(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { return objectMapper.readValue(value, new TypeReference<>() { }); } + catch (JsonProcessingException exception) { throw new IllegalStateException("回访资料数据损坏", exception); } + } + + private static void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) throw badRequest("请选择回访资料文件"); + if (file.getSize() > MAX_FILE_BYTES) throw badRequest("单个回访资料不能超过20MB"); + String name = Objects.requireNonNullElse(file.getOriginalFilename(), ""); + int dot = name.lastIndexOf('.'); + String extension = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT); + if (!FILE_EXTENSIONS.contains(extension)) throw badRequest("仅支持PDF、Office、JPG、PNG和WEBP文件"); + } + + private static String customerScope(String scopedCustomerId, String requestedCustomerId) { + if (StringUtils.hasText(scopedCustomerId)) { + if (StringUtils.hasText(requestedCustomerId) && !scopedCustomerId.equals(trim(requestedCustomerId))) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "不能访问其他客户数据"); + } + return scopedCustomerId; + } + return trim(requestedCustomerId); + } + + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static void requireDeliveryTransition(String from, String target) { + if (!DeliveryStatusPolicy.canTransition(from, target)) { + throw conflict("派送状态不允许由 " + from + " 变更为 " + target); + } + } + private static void requireVersion(DeliveryOrder delivery, long expectedVersion) { + if (version(delivery) != expectedVersion) throw conflict("派送单版本已变化,请刷新后重试"); + } + private static long version(DeliveryOrder delivery) { return Objects.requireNonNullElse(delivery.getVersion(), 0L); } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + 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/delivery/controller/DeliveryDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryDocumentController.java new file mode 100644 index 000000000..54ed94ca9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryDocumentController.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.delivery.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; +import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory; +import com.baiyue.lastleg.delivery.service.DeliveryDocumentService; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents") +public class DeliveryDocumentController { + @Resource private DeliveryDocumentService service; + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String deliveryNo, @RequestParam String customerId) { + scope(user, customerId); return ApiResponse.success(authorized(service.detail(customerId, deliveryNo))); + } + @PutMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_DOCUMENTS + "')") + public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String deliveryNo, @RequestParam String customerId, @Valid @RequestBody Save request) { + scope(user, customerId); return ApiResponse.success(authorized(service.save(customerId, deliveryNo, operator(user), request))); + } + @PostMapping(value="/{category}/files", consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_DOCUMENTS + "')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String deliveryNo, @RequestParam String customerId, + @PathVariable DeliveryDocumentCategory category, @RequestPart("file") MultipartFile file) { + scope(user, customerId); return ApiResponse.success(service.upload(customerId, deliveryNo, category, operator(user), file)); + } + @GetMapping("/history") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')") + public ApiResponse> history(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String deliveryNo, @RequestParam String customerId) { + scope(user, customerId); return ApiResponse.success(service.history(customerId, deliveryNo).stream() + .map(h -> new History(h.version(), h.operatorId(), h.savedAt(), authorized(h.snapshot()))).toList()); + } + @GetMapping("/{category}/files/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')") + public ResponseEntity download(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String deliveryNo, @RequestParam String customerId, + @PathVariable DeliveryDocumentCategory category, @PathVariable long fileId, + @RequestParam(required=false) Long revisionVersion) { + scope(user, customerId); + DeliveryDocumentService.Download downloaded = service.download(customerId, deliveryNo, category, fileId, revisionVersion); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(downloaded.file().fileName(), StandardCharsets.UTF_8).build().toString()) + .body(downloaded.content()); + } + private static Detail authorized(Detail detail) { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + boolean canManage = authentication != null && authentication.isAuthenticated() + && authentication.getAuthorities().stream().anyMatch(a -> OverseasPermissions.DELIVERY_DOCUMENTS.equals(a.getAuthority())); + return new Detail(detail.deliveryNo(), detail.customerId(), detail.version(), detail.bolNo(), + detail.uploadedCategoryCount(), detail.totalCategoryCount(), detail.categories(), + canManage ? detail.allowedActions() : List.of(), detail.replayed()); + } + private static void scope(AuthenticatedUser user, String customerId) { + if (user == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台可管理派送资料"); + try { if (Long.parseLong(customerId) <= 0) throw new NumberFormatException(); } + catch (NumberFormatException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请指定有效客户"); } + } + private static String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : user.userId().toString(); } +} 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..e8ab8e2ff --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -0,0 +1,138 @@ +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 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.access.prepost.PreAuthorize; +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") +@Tag(name = "Outbound and delivery") +public class DeliveryTransferController { + @Resource private DeliveryTransferService service; + + @PostMapping("/dispatches/{dispatchNo}/transfer-delivery") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')") + @Operation(summary = "Create one pending outbound order and one delivery order per destination FBA warehouse") + public ApiResponse transfer( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, + @Valid @RequestBody DeliveryRequests.Transfer request) { + requirePlatform(user); + return ApiResponse.success(service.platformTransfer(dispatchNo, operator(user), request)); + } + + @PostMapping("/outbound-orders/one-click-dropship") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") + @Operation(summary = "Create one-click dropship outbound and push a PDA picking task") + public ApiResponse createOneClick( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @Valid @RequestBody DeliveryRequests.OneClickOutbound request) { + return ApiResponse.success(service.createOneClick(requiredCustomer(user, customerId), operator(user), request)); + } + + @GetMapping("/outbound-orders/one-click-dropship/candidates") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") + @Operation(summary = "List server-authoritative one-click dropship outbound candidates") + public ApiResponse> oneClickCandidates( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @RequestParam(required = false) String warehouseId) { + requirePlatform(user); + return ApiResponse.success(service.oneClickCandidates(customerId, warehouseId)); + } + + @GetMapping("/outbound-orders/return/candidates") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") + @Operation(summary = "List available return cartons for outbound") + public ApiResponse> returnOutboundCandidates( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @RequestParam String warehouseId) { + return ApiResponse.success(service.returnOutboundCandidates(requiredCustomer(user, customerId), warehouseId)); + } + + @PostMapping("/outbound-orders/return") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") + @Operation(summary = "Create return outbound by carton marks and push PDA task") + public ApiResponse createReturnOutbound( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @Valid @RequestBody DeliveryRequests.ReturnOutbound request) { + return ApiResponse.success(service.createReturnOutbound(requiredCustomer(user, customerId), operator(user), request)); + } + + @GetMapping("/outbound-orders") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')") + public ApiResponse> outboundPage( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @Valid DeliveryRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(service.outboundPage(listScope(user, customerId), query)); + } + + @GetMapping("/outbound-orders/{outboundNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')") + public ApiResponse outboundDetail( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String outboundNo) { + requirePlatform(user); + return ApiResponse.success(service.outboundDetail(outboundNo)); + } + + @PostMapping("/outbound-orders/{outboundNo}/cancel") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CANCEL + "')") + @Operation(summary = "Cancel an unexecuted outbound order and its linked delivery") + public ApiResponse cancelOutbound(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String outboundNo, + @Valid @RequestBody DeliveryRequests.Cancel request) { + requirePlatform(user); + service.cancelOutbound(outboundNo, request); + return ApiResponse.success(null); + } + + @GetMapping("/delivery-orders") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')") + public ApiResponse> deliveryPage( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @Valid DeliveryRequests.Query query) { + return ApiResponse.success(service.deliveryPage(listScope(user, customerId), query)); + } + + @GetMapping("/delivery-orders/{deliveryNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')") + public ApiResponse deliveryDetail( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String deliveryNo) { + return ApiResponse.success(service.deliveryDetail(requiredCustomer(user, customerId), deliveryNo)); + } + + private String requiredCustomer(AuthenticatedUser user, String requestedCustomer) { + requirePlatform(user); + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target customer"); + } + return requestedCustomer.trim(); + } + 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 String listScope(AuthenticatedUser user, String requestedCustomer) { + requirePlatform(user); + return requestedCustomer == null || requestedCustomer.isBlank() ? null : requestedCustomer.trim(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java new file mode 100644 index 000000000..5adc2ebe3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java @@ -0,0 +1,51 @@ +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 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.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/delivery-orders") +public class MerchantDeliveryController { + @Resource private DeliveryTransferService service; + + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')") + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid DeliveryRequests.Query query) { + return ApiResponse.success(service.deliveryPage(customer(user), query)); + } + + @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_CREATE + "')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String dispatchNo, + @Valid @RequestBody DeliveryRequests.Transfer request) { + return ApiResponse.success(service.merchantTransfer(customer(user), dispatchNo, operator(user), request)); + } + + @GetMapping("/{deliveryNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String deliveryNo) { + return ApiResponse.success(service.deliveryDetail(customer(user), deliveryNo)); + } + + 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/delivery/controller/OutboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java new file mode 100644 index 000000000..2ba0c1e93 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.delivery.controller; + +import com.baiyue.common.core.api.ApiResponse; +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 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.*; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/outbound-tasks") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +public class OutboundPdaController { + @Resource private DeliveryTransferService service; + + @PostMapping("/{taskNo}/pick") + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo, + @Valid @RequestBody DeliveryRequests.PdaPick request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaPick(String.valueOf(identity.overseasWarehouseId()), + identity.operatorKey(), taskNo, request)); + } + + @PostMapping("/{taskNo}/pallets/{palletNo}/pick") + public ApiResponse pickPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo, @PathVariable String palletNo, + @Valid @RequestBody DeliveryRequests.PdaPalletPick request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaPickPallet(String.valueOf(identity.overseasWarehouseId()), + identity.operatorKey(), taskNo, palletNo, request)); + } + + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required=false) String status) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaTasks(String.valueOf(identity.overseasWarehouseId()), status)); + } + + @GetMapping("/resolve/{businessNo}") + public ApiResponse resolve( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String businessNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.resolvePdaTask(String.valueOf(identity.overseasWarehouseId()), businessNo)); + } + + @GetMapping("/{taskNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaTaskDetail(String.valueOf(identity.overseasWarehouseId()), taskNo)); + } + + @PostMapping("/{taskNo}/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo, @Valid @RequestBody DeliveryRequests.PdaComplete request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.completePda(String.valueOf(identity.overseasWarehouseId()), taskNo, request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java new file mode 100644 index 000000000..9d3e12abf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.delivery.dto; + +import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.time.LocalDateTime; +import java.util.List; + +public final class DeliveryDocumentDtos { + private DeliveryDocumentDtos() { } + public record Save(@NotNull @Min(0) Long expectedVersion, + @NotBlank @Size(max=128) String idempotencyKey, + @Size(max=128) String bolNo, + @NotNull @Size(max=3) List<@NotNull @Valid Binding> documents) { } + public record Binding(@NotNull DeliveryDocumentCategory category, + @NotNull @Size(max=20) List<@NotNull @Min(1) Long> fileIds) { } + public record File(Long fileId, String fileName, String contentType, long sizeBytes, + LocalDateTime uploadedAt) { } + public record Category(DeliveryDocumentCategory category, String status, int fileCount, List files) { } + public record CategorySummary(DeliveryDocumentCategory category, String status, int fileCount) { } + public record Summary(int uploadedCategoryCount, int totalCategoryCount, List categories) { } + public record Detail(String deliveryNo, String customerId, long version, String bolNo, + int uploadedCategoryCount, int totalCategoryCount, List categories, + List allowedActions, boolean replayed) { } + public record History(long version, String operatorId, LocalDateTime savedAt, Detail snapshot) { } +} 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..659777745 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -0,0 +1,95 @@ +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.NotNull; +import jakarta.validation.constraints.Size; +import jakarta.validation.Valid; +import java.util.List; +import java.util.Collections; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.LocalDate; + +public final class DeliveryRequests { + private DeliveryRequests() {} + + public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey, + @NotNull LocalDateTime plannedDeliveryAt, + @NotBlank @Size(max = 32) String deliveryMethod, + @Size(max = 128) String waybillNo, + @Size(max = 255) String carrierName, + @NotBlank @Size(max = 64) String pickupMethod, + @NotBlank @Size(max = 64) String loadingType, + @Size(max = 64) String vehicleType, + @jakarta.validation.constraints.Pattern(regexp = "15托|30托") String vehicleSize, + @Size(max = 128) String driverName, + @Size(max = 64) String driverPhone, + @Size(max = 64) String vehiclePlate, + @Size(max = 128) String appointmentAccount, + @Size(max = 128) String sellerName, + @Size(max = 1000) String remark, + @NotNull @Valid @Size(min = 1, max = 20) List fees) {} + + public record FeeItem(@NotBlank @Size(max = 64) String feeType, + @NotBlank @Size(max = 128) String feeName, + @NotBlank @jakarta.validation.constraints.Pattern(regexp = "RECEIVABLE|PAYABLE") String direction, + @NotBlank @jakarta.validation.constraints.Pattern(regexp = "CNY|USD") String currency, + @NotNull @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal amount, + @Min(1) Long supplierId, + @Size(max = 200) String supplierName, + @Size(max = 32) String supplierCategory, + @jakarta.validation.constraints.Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode, + LocalDateTime paidAt, + @jakarta.validation.constraints.Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod, + @Size(max = 128) String paymentReferenceNo, + @Size(max = 20) List<@NotBlank String> paymentVoucherFileIds, + @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity, + @Size(max = 32) String quantityUnit, + @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice, + LocalDate periodStart, + LocalDate periodEnd, + @Size(max = 500) String remark) {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, + String documentNo, String lxCode, String warehouseId, String status, + String dispatchNo, String waybillNo, String vehiclePlate, + String driverName, String deliveryMethod, + LocalDateTime plannedFrom, LocalDateTime plannedTo, String outboundType) { + 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; } + } + + public record OneClickOutbound(@NotBlank @Size(max=1000) String recipient, + @NotNull @Size(min=1, max=200) List<@Valid OneClickItem> items, + @Size(max=1000) String remark, + @NotBlank @Size(max=255) String idempotencyKey) {} + public record ReturnOutbound(@NotBlank @Size(max=1000) String recipient, + @NotBlank @Size(max=128) String overseasWarehouseId, + @NotBlank @Size(max=128) String returnNo, + @NotNull @Size(min=1, max=200) List<@NotBlank @Size(max=128) String> cartonMarks, + @Size(max=1000) String remark, + @NotBlank @Size(max=255) String idempotencyKey) {} + public record OneClickItem(@NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages, + @NotBlank @Size(max=128) String sku, + @Size(max=200) List<@NotBlank @Size(max=128) String> cartonMarks) { + public OneClickItem(Long inventoryPositionId, Long packages, String sku) { + this(inventoryPositionId, packages, sku, Collections.emptyList()); + } + } + public record PdaPick(@NotNull Long outboundPalletItemId, Long inventoryPositionId, + @NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey, + @Size(max=500) List<@NotBlank @Size(max=255) String> cartonMarks) { + public PdaPick(Long outboundPalletItemId, Long inventoryPositionId, Long packages, String idempotencyKey) { + this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, List.of()); + } + } + public record PdaPalletPick(@NotBlank @Size(max=255) String idempotencyKey) {} + public record PdaComplete(@NotBlank @Size(max=255) String idempotencyKey) {} + public record Cancel(@NotBlank @Size(max=1000) String reason) {} +} 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..df4ae6169 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.delivery.dto; + +import java.time.LocalDateTime; +import java.math.BigDecimal; +import java.util.List; + +public final class DeliveryResponses { + private DeliveryResponses() {} + + public record TransferDelivery(String deliveryNo, String deliveryStatus, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, int palletCount, int packageCount) {} + + public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus, + int palletCount, int packageCount, + List deliveries, boolean replayed) {} + + public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, String lxCodes, + String status, int palletCount, int packageCount, LocalDateTime createdAt, String outboundType) {} + + public record DeliveryRow(Long id, String customerId, String customerName, String deliveryNo, + String outboundNo, String dispatchNo, String overseasWarehouseId, + String overseasWarehouseName, String status, String waybillNo, + String deliveryMethod, String carrierName, String pickupMethod, + String loadingType, String vehicleType, String vehicleSize, + String vehiclePlate, String driverName, String driverPhone, + String appointmentAccount, String sellerName, int palletCount, + int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, + LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt, + LocalDateTime expectedArrivalAt, LocalDateTime signedAt, + String remark, LocalDateTime createdAt, long version, List allowedActions, + String bolNo, DeliveryDocumentDtos.Summary documentSummary, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName) {} + + public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode, + String receivingAddress, String shipmentNo, String fbaTrackingNo, + String skuCode, String productName, int packageCount, + BigDecimal weightKg, BigDecimal volumeCbm) {} + + public record DeliveryFeeRow(Long id, String feeDirection, String feeType, String currency, + BigDecimal amount, String remark) {} + + public record DeliveryOperationRow(Long id, LocalDateTime operatedAt, String operationNode, + String operatorName, String result) {} + + public record DeliveryDetail(DeliveryRow order, List skus, + List fees, List operations) {} + + /** palletNo is the warehouse-authoritative physical pallet code after inbound assignment. */ + public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String inboundCode, + String destinationWarehouseCode, String destinationWarehouseName, + String skuCode, int packageCount, int pickedPackageCount, String status) {} + public record OutboundDetail(OutboundRow outbound, List deliveries, List pallets) {} + public record OneClickCandidate(Long inventoryPositionId, String orderNo, String customerId, String customerName, String overseasWarehouseId, + String overseasWarehouseName, String palletNo, String inboundCode, + String skuCode, String productName, long availablePackages) {} + public record ReturnOutboundCandidate(String returnNo, String overseasWarehouseId, + String cartonMark, String fbaCartonMark, + String destinationWarehouseCode, String destinationWarehouseName, + long totalQuantity, String status) {} + public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {} + public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {} + public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark, + String skuCode, String inboundCode, long availablePackages, + long pickedPackages) {} + public record PdaTaskItem(Long outboundPalletItemId, String palletNo, String skuCode, String inboundCode, + int packageCount, int pickedPackageCount, String status, + List positions) {} + public record PdaTaskDetail(PdaTask task, List items) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryDocumentRevision.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryDocumentRevision.java new file mode 100644 index 000000000..1b3d5788d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryDocumentRevision.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.delivery.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_delivery_document_revision") +public class DeliveryDocumentRevision { + @TableId(type=IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long deliveryOrderId; + private Long version; + private String idempotencyKey; + private String requestHash; + private String snapshotJson; + private String operatorId; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOperation.java new file mode 100644 index 000000000..8cd075215 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOperation.java @@ -0,0 +1,21 @@ +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; + +@Data +@TableName("ll_delivery_operation") +public class DeliveryOperation { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long deliveryOrderId; + private String operationNode; + private String operatorId; + private String operatorName; + private String result; + private LocalDateTime createdAt; +} 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..54ff1a699 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -0,0 +1,59 @@ +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.math.BigDecimal; +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 overseasWarehouseName; + private String customerName; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private String deliveryGroupKey; + private String status; + private String waybillNo; + private String bolNo; + private String documentsJson; + private String deliveryMethod; + private String carrierName; + private String pickupMethod; + private String loadingType; + private String vehicleType; + private String vehicleSize; + private String vehiclePlate; + private String driverName; + private String driverPhone; + private String appointmentAccount; + private String sellerName; + private Integer packageCount; + private Integer palletCount; + private BigDecimal totalWeightKg; + private BigDecimal totalVolumeCbm; + private LocalDateTime plannedDeliveryAt; + private LocalDateTime dispatchedAt; + private LocalDateTime expectedArrivalAt; + private LocalDateTime signedAt; + private String remark; + private String idempotencyKey; + private String createdBy; + private Long version; + 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..0c088baaf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java @@ -0,0 +1,32 @@ +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 outboundType; + private String lxCodes; + private String recipientInfo; + private String status; + private Integer packageCount; + private Integer palletCount; + 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/delivery/entity/OutboundPalletItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java new file mode 100644 index 000000000..658c97bfa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java @@ -0,0 +1,31 @@ +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 Integer pickedPackageCount; + private String status; + private String skuCode; + private String cartonMarks; + private String orderRefs; + private String inboundCode; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaPick.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaPick.java new file mode 100644 index 000000000..64d4b091b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaPick.java @@ -0,0 +1,20 @@ +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; + +@Data +@TableName("ll_outbound_pda_pick") +public class OutboundPdaPick { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long taskId; + private Long outboundPalletItemId; + private Long inventoryPositionId; + private Long packages; + private String idempotencyKey; + private LocalDateTime pickedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaTask.java new file mode 100644 index 000000000..a715e0cd6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaTask.java @@ -0,0 +1,22 @@ +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; + +@Data +@TableName("ll_outbound_pda_task") +public class OutboundPdaTask { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long outboundOrderId; + private String taskNo; + private String status; + private String completionIdempotencyKey; + private LocalDateTime pushedAt; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/DeliveryDocumentFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/DeliveryDocumentFileGateway.java new file mode 100644 index 000000000..a66622a0b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/DeliveryDocumentFileGateway.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.delivery.gateway; + +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.File; +import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory; +import org.springframework.web.multipart.MultipartFile; + +public interface DeliveryDocumentFileGateway { + File upload(String customerId, long deliveryId, DeliveryDocumentCategory category, String operatorId, MultipartFile file); + File metadata(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId); + byte[] download(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java new file mode 100644 index 000000000..ef04202c3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java @@ -0,0 +1,85 @@ +package com.baiyue.lastleg.delivery.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.File; +import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory; +import jakarta.annotation.*; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Objects; + +@Component +public class HttpDeliveryDocumentFileGateway implements DeliveryDocumentFileGateway { + private static final String PATH = "/internal/file/v1/business-files/{customer}/{type}/{delivery}"; + @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.getCustomerVisitFile()); + } + @Override public File upload(String customerId, long deliveryId, DeliveryDocumentCategory category, + String operatorId, MultipartFile file) { + if (file == null || file.isEmpty() || file.getSize() > 52428800L) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "派送资料须为非空且不超过50MiB的文件"); + 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 e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "无法读取派送资料", e); } + Envelope response = client.post().uri(PATH, customerId, category.businessType(), deliveryId) + .header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", operatorId) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); }) + .body(Envelope.class); + return verified(response, customerId, deliveryId, category, null); + } + @Override public File metadata(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId) { + Envelope response = client.get().uri(PATH + "/{file}", customerId, category.businessType(), deliveryId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); }) + .body(Envelope.class); + return verified(response, customerId, deliveryId, category, fileId); + } + @Override public byte[] download(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId) { + return client.get().uri(PATH + "/{file}/content", customerId, category.businessType(), deliveryId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); }) + .body(byte[].class); + } + private static File verified(Envelope response, String customerId, long deliveryId, + DeliveryDocumentCategory category, Long expectedFileId) { + if (response == null) throw failure(HttpStatus.SERVICE_UNAVAILABLE); + if (!"200".equals(response.code())) throw new ResponseStatusException( + response.code() != null && response.code().startsWith("A04") ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "派送资料文件不可用或归属校验失败"); + Metadata file = response.data(); + if (file == null || file.fileId() == null || file.fileId() <= 0 || file.createdAt() == null + || file.fileName() == null || file.sizeBytes() == null || file.sizeBytes() <= 0 + || file.sizeBytes() > 52428800L || !Objects.equals(String.valueOf(file.tenantId()), customerId) + || !category.businessType().equals(file.businessType()) || !String.valueOf(deliveryId).equals(file.businessId()) + || (expectedFileId != null && !expectedFileId.equals(file.fileId()))) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "文件不属于当前客户、派送单或资料类别"); + return new File(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes(), file.createdAt()); + } + private static ResponseStatusException failure(HttpStatusCode status) { + return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "派送资料文件请求失败"); + } + private record Envelope(String code, String message, Metadata data) { } + private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes, LocalDateTime createdAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryDocumentRevisionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryDocumentRevisionMapper.java new file mode 100644 index 000000000..b9f6462e9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryDocumentRevisionMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryDocumentRevision; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface DeliveryDocumentRevisionMapper extends BaseMapper { + @Select("SELECT * FROM ll_delivery_document_revision WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryId} AND idempotency_key=#{key}") + DeliveryDocumentRevision findCommand(@Param("customerId") String customerId, @Param("deliveryId") long deliveryId, @Param("key") String key); + @Select("SELECT * FROM ll_delivery_document_revision WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryId} ORDER BY version DESC") + List history(@Param("customerId") String customerId, @Param("deliveryId") long deliveryId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java new file mode 100644 index 000000000..1b34e5973 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryOperation; +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 DeliveryOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_delivery_operation WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY created_at,id") + List findByDelivery(@Param("customerId") String customerId, + @Param("deliveryOrderId") Long deliveryOrderId); +} 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..e3606c3af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java @@ -0,0 +1,20 @@ +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; +import java.util.List; + +@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 delivery_no=#{deliveryNo} LIMIT 1 FOR UPDATE") + DeliveryOrder lockByNo(@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} ORDER BY id FOR UPDATE") + List 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..a71e718ce --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java @@ -0,0 +1,35 @@ +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; +import java.util.List; + +@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 outbound_no=#{outboundNo} LIMIT 1") + OutboundOrder findGlobalByNo(@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 idempotency_key=#{key} LIMIT 1 FOR UPDATE") + OutboundOrder lockByIdempotency(@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") + OutboundOrder findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); + @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); + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1 FOR UPDATE") + OutboundOrder lockByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); + @Select("SELECT * FROM ll_outbound_order WHERE outbound_no=#{outboundNo} LIMIT 1 FOR UPDATE") + OutboundOrder lockGlobalByNo(@Param("outboundNo") String outboundNo); + + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{warehouseId} AND outbound_type='RELABEL_OUTBOUND' " + + "AND outbound_no LIKE CONCAT('%',#{keyword},'%') ORDER BY created_at DESC,id DESC LIMIT 100") + List listRelabelOutbounds(@Param("customerId") String customerId, + @Param("warehouseId") String warehouseId, + @Param("keyword") String keyword); +} 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/mapper/OutboundPdaPickMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaPickMapper.java new file mode 100644 index 000000000..c4d30223b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaPickMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.OutboundPdaPick; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface OutboundPdaPickMapper extends BaseMapper { + @Select("SELECT * FROM ll_outbound_pda_pick WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + OutboundPdaPick findByKey(@Param("customerId") String customerId, @Param("key") String key); + @Select("SELECT * FROM ll_outbound_pda_pick WHERE customer_id=#{customerId} AND task_id IN (SELECT id FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundId})") + List findByOutbound(@Param("customerId") String customerId, @Param("outboundId") Long outboundId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java new file mode 100644 index 000000000..063871975 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface OutboundPdaTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE") + OutboundPdaTask lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId); + @Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND task_no=#{taskNo} LIMIT 1 FOR UPDATE") + OutboundPdaTask lockByNo(@Param("customerId") String customerId, @Param("taskNo") String taskNo); + @Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND (#{status} IS NULL OR status=#{status}) ORDER BY pushed_at DESC LIMIT 200") + List list(@Param("customerId") String customerId, @Param("status") String status); + + @Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " + + "AND o.customer_id=t.customer_id WHERE t.task_no=#{taskNo} AND o.overseas_warehouse_id=#{warehouseId} " + + "LIMIT 1 FOR UPDATE") + OutboundPdaTask lockByNoAndWarehouse(@Param("taskNo") String taskNo, @Param("warehouseId") String warehouseId); + + @Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " + + "AND o.customer_id=t.customer_id WHERE t.task_no=#{taskNo} AND o.overseas_warehouse_id=#{warehouseId} LIMIT 1") + OutboundPdaTask findByNoAndWarehouse(@Param("taskNo") String taskNo, @Param("warehouseId") String warehouseId); + + @Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " + + "AND o.customer_id=t.customer_id WHERE t.customer_id=#{customerId} AND t.outbound_order_id=#{outboundOrderId} " + + "AND o.overseas_warehouse_id=#{warehouseId} LIMIT 1") + OutboundPdaTask findByOutboundAndWarehouse(@Param("customerId") String customerId, + @Param("outboundOrderId") Long outboundOrderId, @Param("warehouseId") String warehouseId); + + @Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " + + "AND o.customer_id=t.customer_id WHERE o.overseas_warehouse_id=#{warehouseId} " + + "AND (#{status} IS NULL OR t.status=#{status}) ORDER BY t.pushed_at DESC LIMIT 200") + List listByWarehouse(@Param("warehouseId") String warehouseId, @Param("status") String status); + + @Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} " + + "AND completion_idempotency_key=#{key} LIMIT 1") + OutboundPdaTask findByCompletionKey(@Param("customerId") String customerId, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentCategory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentCategory.java new file mode 100644 index 000000000..8390588e7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentCategory.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.delivery.model; + +public enum DeliveryDocumentCategory { + LOADING_PHOTO("DELIVERY_LOADING_PHOTO"), BOL("DELIVERY_BOL"), RECEIPT("DELIVERY_RECEIPT"); + + private final String businessType; + DeliveryDocumentCategory(String businessType) { this.businessType = businessType; } + public String businessType() { return businessType; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentSnapshot.java new file mode 100644 index 000000000..55316ee5d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentSnapshot.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.delivery.model; + +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.*; + +/** Saved metadata only; listing never calls File and never counts pending uploads. */ +public final class DeliveryDocumentSnapshot { + private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules(); + private DeliveryDocumentSnapshot() { } + public static List categories(String json) { + if (json == null || json.isBlank()) return Arrays.stream(DeliveryDocumentCategory.values()) + .map(category -> new Category(category, "MISSING", 0, List.of())).toList(); + try { return JSON.readValue(json, new TypeReference>() { }); } + catch (JsonProcessingException e) { throw new IllegalStateException("派送资料快照损坏", e); } + } + public static Summary summary(String json) { + List categories = categories(json); + return new Summary((int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, + categories.stream().map(c -> new CategorySummary(c.category(), c.status(), c.fileCount())).toList()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java new file mode 100644 index 000000000..1b6b01368 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.delivery.model; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class DeliveryStatusPolicy { + private static final Set EDITABLE_TARGETS = Set.of( + "WAIT_DELIVERY", "DELIVERING", "REJECTED", "CANCELLED"); + private static final Map> TRANSITIONS = Map.of( + "WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"), + "DELIVERING", Set.of("DELIVERED", "REJECTED"), + "DELIVERED", Set.of(), + "REJECTED", Set.of(), + "RETURN_PENDING", Set.of(), + "RETURNED", Set.of(), + "CANCELLED", Set.of()); + + private DeliveryStatusPolicy() { } + + public static boolean isEditableTarget(String status) { + return EDITABLE_TARGETS.contains(status); + } + + public static boolean canTransition(String from, String target) { + return TRANSITIONS.getOrDefault(from, Set.of()).contains(target); + } + + public static List allowedActions(String status) { + return switch (status == null ? "" : status) { + case "WAIT_DELIVERY" -> List.of( + "START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); + case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN"); + case "DELIVERED", "REJECTED" -> List.of("MARK_RETURN"); + default -> List.of(); + }; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java new file mode 100644 index 000000000..26a7db8fd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java @@ -0,0 +1,95 @@ +package com.baiyue.lastleg.delivery.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.OverseasDeliveryCompletedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +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.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** 在派送完成事务内按来源入仓单冻结海外到国内的完成事件。 */ +@Service +public class DeliveryCompletedOutboxService { + @Resource private LastlegEventOutboxMapper outbox; + @Resource private ObjectMapper objectMapper; + + public void publish(DeliveryOrder delivery, List orders, Instant completedAt) { + Objects.requireNonNull(delivery, "delivery"); + Objects.requireNonNull(completedAt, "completedAt"); + Map bySourceInbound = new LinkedHashMap<>(); + for (LastlegOrder order : orders == null ? List.of() : orders) { + if (!StringUtils.hasText(order.getSourceInboundId())) continue; + requireText(order.getSourceOrderId(), "sourceOrderId"); + requireText(order.getInboundCode(), "inboundCode"); + requireText(order.getCustomerId(), "customerId"); + LastlegOrder existing = bySourceInbound.putIfAbsent(order.getSourceInboundId(), order); + if (existing != null && (!Objects.equals(existing.getSourceOrderId(), order.getSourceOrderId()) + || !Objects.equals(existing.getInboundCode(), order.getInboundCode()))) { + throw new IllegalStateException("同一来源入仓单对应的派送完成快照冲突"); + } + } + for (LastlegOrder order : bySourceInbound.values()) publishOne(delivery, order, completedAt); + } + + private void publishOne(DeliveryOrder delivery, LastlegOrder order, Instant completedAt) { + long aggregateVersion = Math.max(1L, delivery.getVersion() == null ? 1L : delivery.getVersion()); + String eventKey = EventTypes.OVERSEAS_DELIVERY_COMPLETED + ':' + delivery.getDeliveryNo() + + ':' + order.getSourceInboundId(); + var payload = new OverseasDeliveryCompletedV1(String.valueOf(delivery.getId()), delivery.getDeliveryNo(), + String.valueOf(order.getId()), order.getOrderNo(), order.getSourceInboundId(), order.getInboundCode(), + order.getSourceOrderId(), order.getSourceOrderNo(), order.getRootOrderId(), + order.getFbaWarehouseCode(), completedAt); + JsonNode node = objectMapper.valueToTree(payload); + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), + EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, completedAt, RegionCode.OVERSEAS, RegionCode.CN, + order.getCustomerId(), "INBOUND_ORDER", order.getSourceInboundId(), aggregateVersion, 1L, + EventIdentifiers.newUuid(), eventKey, + EventIdentifiers.isSupported(order.getSourceEventId()) ? order.getSourceEventId() : null, + node, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))).requireValid(); + LastlegEventOutbox row = new LastlegEventOutbox(); + row.setId(IdWorker.getId()); row.setCustomerId(order.getCustomerId()); + row.setAggregateType(envelope.aggregateType()); row.setAggregateId(envelope.aggregateId()); + row.setEventType(envelope.eventType()); row.setEventVersion(envelope.eventVersion()); + row.setEventKey(eventKey); row.setPayloadJson(json(envelope)); row.setStatus("PENDING"); + row.setRetryCount(0); row.setCreatedAt(LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC)); + try { + outbox.insert(row); + } catch (DuplicateKeyException duplicate) { + LastlegEventOutbox existing = outbox.findByEventKey(eventKey); + if (existing == null || !Objects.equals(existing.getAggregateId(), order.getSourceInboundId()) + || !Objects.equals(existing.getCustomerId(), order.getCustomerId())) throw duplicate; + } + } + + private static void requireText(String value, String field) { + if (!StringUtils.hasText(value)) throw new IllegalStateException(field + " is required"); + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize delivery-completed event", exception); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java new file mode 100644 index 000000000..9c31d7c67 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -0,0 +1,170 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.model.*; +import com.baiyue.lastleg.customervisit.service.CustomerVisitService; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.JsonProcessingException; +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.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.*; + +@Service +public class DeliveryDocumentService { + @Resource private DeliveryOrderMapper orders; + @Resource private DeliveryDocumentRevisionMapper revisions; + @Resource private DeliveryOperationMapper operations; + @Resource private DeliveryDocumentFileGateway files; + @Resource private ObjectMapper objectMapper; + @Resource private LastlegOrderMapper lastlegOrders; + @Resource private DeliveryCompletedOutboxService deliveryCompletedOutbox; + @Resource private CustomerVisitService customerVisits; + + public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); } + + public DeliveryDocumentDtos.File upload(String customerId, String deliveryNo, DeliveryDocumentCategory category, + String operatorId, MultipartFile file) { + DeliveryOrder order = require(customerId, deliveryNo); + return files.upload(customerId, order.getId(), category, operatorId, file); + } + + /** Lock the same aggregate as status commands, validate the whole set, then commit one snapshot. */ + @Transactional + public Detail save(String customerId, String deliveryNo, String operatorId, Save request) { + DeliveryOrder order = orders.lockByNo(customerId, deliveryNo); + if (order == null) throw missing(); + String hash = hash(request); + DeliveryDocumentRevision replay = revisions.findCommand(customerId, order.getId(), request.idempotencyKey()); + if (replay != null) { + if (!hash.equals(replay.getRequestHash())) throw conflict("相同幂等键的派送资料内容不同"); + Detail saved = read(replay.getSnapshotJson()); + return new Detail(saved.deliveryNo(), saved.customerId(), saved.version(), saved.bolNo(), + saved.uploadedCategoryCount(), 3, saved.categories(), saved.allowedActions(), true); + } + if (!Objects.equals(version(order), request.expectedVersion())) throw conflict("派送单版本已变化,请刷新后重试"); + Map> bindings = new EnumMap<>(DeliveryDocumentCategory.class); + Set unique = new HashSet<>(); + for (Binding binding : request.documents()) { + if (bindings.containsKey(binding.category())) throw bad("资料类别不能重复"); + List metadata = new ArrayList<>(); + for (Long fileId : binding.fileIds()) { + if (!unique.add(fileId)) throw bad("同一文件不能重复绑定"); + metadata.add(files.metadata(customerId, order.getId(), binding.category(), fileId)); + } + bindings.put(binding.category(), List.copyOf(metadata)); + } + List categories = Arrays.stream(DeliveryDocumentCategory.values()).map(category -> { + List values = bindings.getOrDefault(category, List.of()); + return new Category(category, values.isEmpty() ? "MISSING" : "UPLOADED", values.size(), values); + }).toList(); + LocalDateTime now = LocalDateTime.now(); + order.setBolNo(request.bolNo() == null ? "" : request.bolNo().trim()); + order.setDocumentsJson(write(categories)); + order.setVersion(version(order) + 1); + order.setUpdatedAt(now); + boolean receiptSaved = !bindings.getOrDefault(DeliveryDocumentCategory.RECEIPT, List.of()).isEmpty(); + boolean autoCompleted = receiptSaved && !"DELIVERED".equals(order.getStatus()); + if (autoCompleted) { + if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED")) { + throw conflict("当前派送状态不允许通过签收文件完成"); + } + order.setStatus("DELIVERED"); + order.setSignedAt(now); + } + orders.updateById(order); + if (autoCompleted) { + List completedOrders = completeRelatedOrders(order, now); + deliveryCompletedOutbox.publish(order, completedOrders, now.toInstant(ZoneOffset.UTC)); + customerVisits.ensureForCompletedDelivery(order, now); + } + Detail result = detail(order, false); + DeliveryDocumentRevision revision = new DeliveryDocumentRevision(); + revision.setCustomerId(customerId); revision.setDeliveryOrderId(order.getId()); revision.setVersion(order.getVersion()); + revision.setIdempotencyKey(request.idempotencyKey()); revision.setRequestHash(hash); revision.setSnapshotJson(write(result)); + revision.setOperatorId(operatorId); revision.setCreatedAt(now); revisions.insert(revision); + DeliveryOperation operation = new DeliveryOperation(); + operation.setCustomerId(customerId); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId); + operation.setOperationNode("SAVE_DOCUMENTS"); operation.setResult("保存派送资料:" + result.uploadedCategoryCount() + "/3类"); + operation.setCreatedAt(now); operations.insert(operation); + if (autoCompleted) recordAutomaticCompletion(order, operatorId, now); + return result; + } + public List history(String customerId, String deliveryNo) { + DeliveryOrder order = require(customerId, deliveryNo); + return revisions.history(customerId, order.getId()).stream().map(revision -> new History(revision.getVersion(), + revision.getOperatorId(), revision.getCreatedAt(), read(revision.getSnapshotJson()))).toList(); + } + public Download download(String customerId, String deliveryNo, DeliveryDocumentCategory category, long fileId, Long revisionVersion) { + DeliveryOrder order = require(customerId, deliveryNo); + Detail snapshot = detail(order, false); + if (revisionVersion != null) { + snapshot = revisions.history(customerId, order.getId()).stream().filter(r -> revisionVersion.equals(r.getVersion())) + .findFirst().map(r -> read(r.getSnapshotJson())).orElseThrow(DeliveryDocumentService::missing); + } + DeliveryDocumentDtos.File file = snapshot.categories().stream().filter(c -> c.category() == category) + .flatMap(c -> c.files().stream()).filter(f -> f.fileId() == fileId).findFirst().orElseThrow(DeliveryDocumentService::missing); + files.metadata(customerId, order.getId(), category, fileId); + return new Download(file, files.download(customerId, order.getId(), category, fileId)); + } + public record Download(DeliveryDocumentDtos.File file, byte[] content) { } + private DeliveryOrder require(String customerId, String deliveryNo) { + DeliveryOrder order = orders.findByNo(customerId, deliveryNo); + if (order == null) throw missing(); + return order; + } + private Detail detail(DeliveryOrder order, boolean replayed) { + List categories = DeliveryDocumentSnapshot.categories(order.getDocumentsJson()); + return new Detail(order.getDeliveryNo(), order.getCustomerId(), version(order), order.getBolNo(), + (int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed); + } + private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); } + private List completeRelatedOrders(DeliveryOrder delivery, LocalDateTime now) { + List completedOrders = lastlegOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, delivery.getCustomerId()) + .eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo())); + for (LastlegOrder order : completedOrders) { + order.setSubOrderStatus("DELIVERY:DELIVERED"); + order.setUpdatedAt(now); + lastlegOrders.updateById(order); + } + return completedOrders; + } + private void recordAutomaticCompletion(DeliveryOrder order, String operatorId, LocalDateTime now) { + DeliveryOperation operation = new DeliveryOperation(); + operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(order.getId()); + operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); + operation.setOperationNode("RECEIPT_AUTO_COMPLETE"); + operation.setResult("签收文件保存成功,派送单自动完成"); + operation.setCreatedAt(now); operations.insert(operation); + } + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException e) { throw new IllegalStateException("无法保存派送资料", e); } + } + private Detail read(String value) { + try { return objectMapper.readValue(value, Detail.class); } + catch (JsonProcessingException e) { throw new IllegalStateException("派送资料历史损坏", e); } + } + private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "派送单或已绑定资料不存在"); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java new file mode 100644 index 000000000..45b3fe641 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -0,0 +1,1050 @@ +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.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.delivery.entity.DeliveryOperation; +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.model.DeliveryStatusPolicy; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; +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.entity.LastlegOrderDetail; +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.InboundPlanCartonMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; +import com.baiyue.lastleg.delivery.entity.OutboundPdaPick; +import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper; +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.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipCartonSku; +import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +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.service.RelabelApplicationService; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; +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.LocalDate; +import java.time.format.DateTimeFormatter; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import java.math.BigDecimal; +import java.util.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.stream.Collectors; + +@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 OverseasFeeSourceService feeSources; + @Resource private DeliveryOperationMapper deliveryOperations; + @Resource private OutboundPdaTaskMapper pdaTasks; + @Resource private OutboundPdaPickMapper pdaPicks; + @Resource private InventoryPositionMapper inventoryPositions; + @Resource private InventoryBalanceMapper inventoryBalances; + @Resource private InventoryCommandService inventoryCommands; + @Resource private WarehouseTransferPalletMapper warehouseTransferPallets; + @Resource private DropshipOrderProfileMapper dropshipProfiles; + @Resource private DropshipCartonMapper dropshipCartons; + @Resource private DropshipCartonSkuMapper dropshipCartonSkus; + @Resource private DropshipSkuInventoryMapper dropshipSkuInventory; + @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private InboundPlanPalletAllocationMapper planAllocations; + @Resource private InboundPlanLineMapper planLines; + @Resource private InboundPlanCartonMapper inboundCartons; + @Resource private LastlegOrderMapper tailOrders; + @Resource private LastlegOrderStatusHistoryMapper histories; + @Resource private LastlegOrderDetailMapper tailOrderDetails; + @Resource private RelabelApplicationMapper relabelApplications; + @Resource private RelabelItemMapper relabelItems; + @Resource private RelabelApplicationService relabelService; + @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private ObjectMapper objectMapper; + + @Transactional + public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId, + DeliveryRequests.Transfer request) { + DispatchOrder dispatch = dispatchOrders.lockGlobalByDispatchNo(trim(dispatchNo)); + if (dispatch == null) throw notFound("Dispatch order not found"); + return transferResolved(dispatch, operatorId, request); + } + + @Transactional + public DeliveryResponses.TransferResult merchantTransfer(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()); + if (dispatch == null) throw notFound("Dispatch order not found"); + return transferResolved(dispatch, operatorId, request); + } + + private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId, + DeliveryRequests.Transfer request) { + String customerId = dispatch.getCustomerId(); + 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, requireDeliveries(customerId, replay.getId()), true); + } + OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId()); + if (existing != null) return result(existing, requireDeliveries(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"); + } + dispatchItems.bindReceivedPallets(customerId, dispatch.getId()); + 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"); + validateTransferFees(request); + + LocalDateTime now = LocalDateTime.now(); + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); + outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", dispatch.getOverseasWarehouseId(), outbound.getId())); + outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo()); + outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY"); + outbound.setLxCodes(resolveLxCodes(customerId, items)); + 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, requireDeliveries(customerId, concurrent.getId()), true); + } + concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId()); + if (concurrent != null) { + return result(concurrent, requireDeliveries(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.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); + if (StringUtils.hasText(source.getCartonMarks())) { + item.setCartonMarks(source.getCartonMarks()); + } else if (source.getWarehousePalletId() != null) { + String marks = inboundCartons.listByPallet(customerId, source.getWarehousePalletId()).stream() + .map(c -> c.getCartonMark()).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(",")); + item.setCartonMarks(StringUtils.hasText(marks) ? marks : null); + } + item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now); + outboundPallets.insert(item); + } + + Map> destinationGroups = items.stream().collect(java.util.stream.Collectors.groupingBy( + this::deliveryGroupKey, LinkedHashMap::new, java.util.stream.Collectors.toList())); + List deliveries = new ArrayList<>(); + int groupIndex = 0; + for (Map.Entry> entry : destinationGroups.entrySet()) { + List groupItems = entry.getValue(); + DispatchItem destination = groupItems.get(0); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); + delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", dispatch.getOverseasWarehouseId(), delivery.getId())); + delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); + delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); + delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName()); + delivery.setDestinationWarehouseId(trim(destination.getDestinationWarehouseId())); + delivery.setDestinationWarehouseCode(firstText( + firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()), + destination.getDestinationWarehouseId())); + delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName())); + delivery.setDeliveryGroupKey(entry.getKey()); + LastlegOrder firstOrder = firstTailOrder(customerId, groupItems); + delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); + delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim()); + delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod())); + delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize())); + delivery.setVehiclePlate(firstText(request.vehiclePlate(), dispatch.getVehiclePlate())); delivery.setDriverName(firstText(request.driverName(), dispatch.getDriverName())); + delivery.setDriverPhone(firstText(request.driverPhone(), dispatch.getDriverPhone())); delivery.setAppointmentAccount(trim(request.appointmentAccount())); + delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); + delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + delivery.setPalletCount(groupItems.size()); + delivery.setTotalVolumeCbm(groupItems.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + delivery.setTotalWeightKg(totalWeight(customerId, groupItems)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark())); + delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId); + delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); + recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now); + bindTailOrders(customerId, groupItems, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now); + deliveries.add(delivery); + } + DeliveryOrder feeDelivery = deliveries.get(0); + LastlegOrder firstOrder = firstTailOrder(customerId, items); + List feeInputs = request.fees() == null ? List.of() : request.fees(); + for (DeliveryRequests.FeeItem input : feeInputs) { + feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.feeType()), + new OverseasFeeSourceDtos.SaveRequest(customerId, feeDelivery.getCustomerName(), + "OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDelivery.getId(), feeDelivery.getDeliveryNo(), + firstOrder == null ? null : firstOrder.getId(), null, + firstOrder == null ? null : firstOrder.getInboundCode(), null, + firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()), + firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDelivery.getOverseasWarehouseId()), + input.feeType().trim(), input.feeName().trim(), input.direction(), input.supplierId(), + input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(), + input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(), + input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(), + input.periodStart(), input.periodEnd())); + } + return result(outbound, deliveries, false); + } + + private void validateTransferFees(DeliveryRequests.Transfer request) { + List inputs = request.fees() == null ? List.of() : request.fees(); + if (inputs.isEmpty()) throw conflict("Delivery fee is required"); + Set allowed = Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"); + Set seen = new HashSet<>(); + for (DeliveryRequests.FeeItem input : inputs) { + String feeType = input.feeType().trim().toUpperCase(Locale.ROOT); + if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType); + if (!seen.add(feeType)) throw conflict("Duplicate delivery fee type: " + feeType); + if (!Set.of("CNY", "USD").contains(input.currency().trim().toUpperCase(Locale.ROOT))) + throw conflict("Delivery fees only support CNY or USD"); + if (input.amount().signum() <= 0) throw conflict("Delivery fee amount must be greater than zero"); + } + if (!seen.contains("DELIVERY_FEE")) throw conflict("Delivery fee is required"); + if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT)) + && !seen.contains("LOADING_FEE")) { + throw conflict("Loading fee is required for floor or mixed loading"); + } + if ("BUY_APPOINTMENT".equalsIgnoreCase(trim(request.appointmentAccount()))) { + if (!StringUtils.hasText(request.sellerName())) throw conflict("Seller name is required when buying an appointment"); + if (!seen.contains("APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment"); + } + } + + @Transactional + public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) { + String requestHash = oneClickRequestHash(request); + OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); + if (existing != null) return replayOneClick(existing, requestHash); + LocalDateTime now = LocalDateTime.now(); + InventoryPosition firstPosition = inventoryPositions.selectById(request.items().get(0).inventoryPositionId()); + if (firstPosition == null || !customerId.equals(firstPosition.getCustomerId())) { + throw conflict("所选库存不是可用的一件代发库存,或已被其他作业占用"); + } + String selectedWarehouseId = required(firstPosition.getOverseasWarehouseId(), "库存缺少所属海外仓"); + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); + outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP", selectedWarehouseId, outbound.getId())); + outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim()); + outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size()); + outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); + outbound.setRequestHash(requestHash); + // Claim the unique command key before taking inventory locks. A racing insert + // waits for the first transaction; use a current read after it commits, not + // the REPEATABLE READ snapshot established by the initial lookup. + try { + outboundOrders.insert(outbound); + } catch (DuplicateKeyException duplicate) { + OutboundOrder concurrent = outboundOrders.lockByIdempotency(customerId, request.idempotencyKey().trim()); + if (concurrent == null) throw duplicate; + return replayOneClick(concurrent, requestHash); + } + selectedWarehouseId = validateOneClickItems(customerId, request.items()); + Set physicalPalletIds = new HashSet<>(); + for (DeliveryRequests.OneClickItem input : request.items()) { + InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId()); + if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found"); + InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId()); + if (balance == null || !customerId.equals(balance.getCustomerId()) || !StringUtils.hasText(balance.getInboundCode())) { + throw conflict("一件代发出库库存缺少所属入仓码"); + } + OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); + item.setInboundCode(balance.getInboundCode()); + physicalPalletIds.add(position.getPalletId()); + item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCartonMarks(String.join(",", input.cartonMarks() == null ? List.of() : input.cartonMarks())); item.setCreatedAt(now); + outboundPallets.insert(item); + } + outbound.setPalletCount(physicalPalletIds.size()); + outbound.setOverseasWarehouseId(selectedWarehouseId); outboundOrders.updateById(outbound); + OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId()); + task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); + return taskResult(outbound, task); + } + + private DeliveryResponses.PdaTask replayOneClick(OutboundOrder existing, String requestHash) { + if (!"ONE_CLICK_DROPSHIP".equals(existing.getOutboundType()) + || !requestHash.equals(existing.getRequestHash())) { + throw conflict("相同幂等键不能用于不同的一件代发出库请求"); + } + return taskResult(existing, pdaTasks.lockByOutbound(existing.getCustomerId(), existing.getId())); + } + + String oneClickRequestHash(DeliveryRequests.OneClickOutbound request) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8))); + } catch (JsonProcessingException | NoSuchAlgorithmException error) { + throw new IllegalStateException("无法生成一件代发出库请求摘要", error); + } + } + + public List oneClickCandidates(String customerId, String warehouseId) { + return dropshipProfiles.listOutboundCandidates(customerId, + StringUtils.hasText(warehouseId) ? warehouseId.trim() : null).stream() + .map(this::oneClickCandidate).toList(); + } + + @Transactional(readOnly = true) + public List returnOutboundCandidates(String customerId, String warehouseId) { + if (!StringUtils.hasText(warehouseId)) return List.of(); + return returnCartons.findAvailable(required(customerId, "Customer is required"), warehouseId.trim()).stream() + .map(c -> new DeliveryResponses.ReturnOutboundCandidate(c.getSourceReturnNo(), c.getOverseasWarehouseId(), + c.getCartonMark(), c.getFbaCartonMark(), c.getDestinationWarehouseCode(), c.getDestinationWarehouseName(), + value(c.getTotalQuantity()), c.getStatus())).toList(); + } + + @Transactional + public DeliveryResponses.PdaTask createReturnOutbound(String customerId, String operatorId, + DeliveryRequests.ReturnOutbound request) { + OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); + if (existing != null) return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId())); + String warehouseId = request.overseasWarehouseId().trim(); + List marks = request.cartonMarks().stream().map(String::trim).filter(StringUtils::hasText).distinct().toList(); + if (marks.size() != request.cartonMarks().size()) throw conflict("箱唛不能为空且不可重复"); + LocalDateTime now = LocalDateTime.now(); + List locked = new ArrayList<>(); + for (String mark : marks) { + ReturnInventoryCarton carton = returnCartons.lockMark(mark); + if (carton == null || !customerId.equals(carton.getCustomerId()) || !warehouseId.equals(carton.getOverseasWarehouseId()) + || !"AVAILABLE".equals(carton.getStatus()) || !Objects.equals(request.returnNo().trim(), carton.getSourceReturnNo())) { + throw conflict("所选退货箱唛不可出库或不属于该退货单:" + mark); + } + locked.add(carton); + } + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); + outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND", warehouseId, outbound.getId())); + outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK"); + outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(marks.size()); outbound.setPalletCount(0); + outbound.setOverseasWarehouseId(warehouseId); outbound.setIdempotencyKey(request.idempotencyKey().trim()); + outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); + OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); + item.setOutboundOrderId(outbound.getId()); item.setPackageCount(marks.size()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); + item.setCartonMarks(String.join(",", marks)); item.setInboundCode(request.returnNo().trim()); item.setCreatedAt(now); outboundPallets.insert(item); + OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId()); + task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); + return taskResult(outbound, task); + } + + private String validateOneClickItems(String customerId, List items) { + String warehouseId = null; + Map requestedPackages = new LinkedHashMap<>(); + Map candidates = new HashMap<>(); + Set requestedMarks = new HashSet<>(); + for (DeliveryRequests.OneClickItem input : items) { + String sku = input.sku().trim(); + DropshipOutboundCandidate candidate = dropshipProfiles.lockOutboundCandidate(customerId, + input.inventoryPositionId(), sku); + if (candidate == null) { + throw conflict("所选库存不是可用的一件代发库存,或已被其他作业占用"); + } + if (warehouseId == null) warehouseId = candidate.getOverseasWarehouseId(); + else if (!Objects.equals(warehouseId, candidate.getOverseasWarehouseId())) { + throw conflict("一张出库单只能选择同一海外仓的库存"); + } + String key = candidate.getProfileId() + "\u0000" + sku; + requestedPackages.merge(key, input.packages(), Long::sum); + candidates.put(key, candidate); + if (input.cartonMarks() != null && !input.cartonMarks().isEmpty()) { + if (input.cartonMarks().size() != input.packages() || new HashSet<>(input.cartonMarks()).size() != input.cartonMarks().size()) { + throw conflict("Specified carton marks must be unique and equal the package count"); + } + if (input.cartonMarks().stream().map(String::trim).anyMatch(mark -> !requestedMarks.add(mark))) { + throw conflict("同一物理箱不能在一张出库单中重复选择"); + } + List selected = dropshipCartons.listProfile(customerId, candidate.getProfileId()).stream() + .filter(carton -> "AVAILABLE".equals(carton.getStatus()) + && input.cartonMarks().contains(carton.getCartonMark())).toList(); + if (selected.size() != input.cartonMarks().size()) throw conflict("Specified carton marks are unavailable"); + } + } + for (Map.Entry requested : requestedPackages.entrySet()) { + String sku = requested.getKey().substring(requested.getKey().indexOf('\u0000') + 1); + DropshipOutboundCandidate candidate = candidates.get(requested.getKey()); + if (requested.getValue() > value(candidate.getAvailablePackages())) { + throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku); + } + List cartons = dropshipCartons.lockAvailablePhysical(customerId, + candidate.getProfileId(), requested.getValue()); + if (cartons.size() != requested.getValue()) { + throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku); + } + } + return warehouseId; + } + + @Transactional + public DeliveryResponses.PdaTask pdaPick(String warehouseId, String operatorId, String taskNo, + DeliveryRequests.PdaPick request) { + OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"), + required(warehouseId, "PDA warehouse binding is required")); + if (task == null) throw notFound("PDA task not found in the bound warehouse"); + String customerId = task.getCustomerId(); + OutboundPdaPick replay = pdaPicks.findByKey(customerId, request.idempotencyKey().trim()); + if (replay != null) { + if (!task.getId().equals(replay.getTaskId()) + || !request.outboundPalletItemId().equals(replay.getOutboundPalletItemId()) + || !Objects.equals(request.inventoryPositionId(), replay.getInventoryPositionId()) + || !request.packages().equals(replay.getPackages())) { + throw conflict("Idempotency key was reused with a different outbound pick request"); + } + return taskResult(requireOutbound(task), task); + } + OutboundOrder existingOutbound = requireOutbound(task); + OutboundOrder outbound = outboundOrders.lockByNo(customerId, existingOutbound.getOutboundNo()); + if (outbound == null || !required(warehouseId, "PDA warehouse binding is required") + .equals(outbound.getOverseasWarehouseId())) throw notFound("Outbound order not found in the bound warehouse"); + if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) { + throw conflict("PDA task is not available for picking"); + } + OutboundPalletItem item = outboundPallets.selectById(request.outboundPalletItemId()); + if (item == null || !customerId.equals(item.getCustomerId()) + || !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found"); + if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity"); + if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) { + if (request.inventoryPositionId() != null) throw conflict("退货出库无需扫描库存仓位"); + List marks = Arrays.stream(Optional.ofNullable(item.getCartonMarks()).orElse("").split(",")) + .map(String::trim).filter(StringUtils::hasText).toList(); + if (request.packages() != marks.size() || value(item.getPickedPackageCount()) > 0) throw conflict("退货出库必须一次扫描本次箱唛且不可重复"); + for (String mark : marks) { + ReturnInventoryCarton carton = returnCartons.lockMark(mark); + if (carton == null || !customerId.equals(carton.getCustomerId()) || !warehouseId.equals(carton.getOverseasWarehouseId()) || !"AVAILABLE".equals(carton.getStatus())) { + throw conflict("退货箱唛不可出库:" + mark); + } + carton.setStatus("OUTBOUNDED"); carton.setUpdatedAt(LocalDateTime.now()); returnCartons.updateById(carton); + } + OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); + item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item); + updateOutboundPickingStatus(customerId, outbound); + task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); + return taskResult(outbound, task); + } + if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { + if (request.inventoryPositionId() == null) throw conflict("换标出库必须确认实际库存位置"); + InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId()); + if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) + || !warehouseId.equals(selectedPosition.getOverseasWarehouseId()) + || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) { + throw conflict("确认的库存位置不属于该换标出库托盘"); + } + List expectedMarks = Arrays.stream(Optional.ofNullable(item.getCartonMarks()).orElse("").split(",")) + .map(String::trim).filter(StringUtils::hasText).distinct().sorted().toList(); + List submittedMarks = request.cartonMarks() == null ? List.of() : request.cartonMarks(); + List scannedMarks = submittedMarks.stream().map(String::trim).filter(StringUtils::hasText) + .distinct().sorted().toList(); + if (scannedMarks.size() != submittedMarks.size() || !expectedMarks.equals(scannedMarks)) { + throw conflict("必须扫描并提交该换标出库任务的全部指定箱唛"); + } + if (request.packages().intValue() != value(item.getPackageCount()) || value(item.getPickedPackageCount()) > 0) { + throw conflict("换标出库托盘必须按指定箱唛一次完成拆托确认"); + } + RelabelApplication application = relabelApplications.lockByOutbound(customerId, outbound.getOutboundNo()); + if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { + throw conflict("换标单尚未完成客户确认或已不可出库"); + } + List sourceItems = relabelItems.selectList(new LambdaQueryWrapper() + .eq(RelabelItem::getApplicationId, application.getId()) + .eq(RelabelItem::getCustomerId, customerId) + .eq(RelabelItem::getSourceInventoryPositionId, request.inventoryPositionId())); + long sourcePackages = sourceItems.stream().mapToLong(source -> value(source.getNotifiedPackageCount())).sum(); + if (sourcePackages != request.packages()) throw conflict("换标出库来源库存快照与任务箱数不一致"); + int releaseIndex = 0; + for (RelabelItem source : sourceItems) { + inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move( + request.inventoryPositionId(), value(source.getNotifiedPackageCount()), application.getRelabelNo(), null, + "RELABEL:" + application.getRelabelNo(), request.idempotencyKey() + ":RELEASE:" + releaseIndex++, + "换标出库拆托前释放原冻结库存")); + } + inventoryCommands.allocate(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), + request.packages(), application.getRelabelNo(), outbound.getOutboundNo(), null, + request.idempotencyKey() + ":ALLOCATE", "换标出库指定箱唛拆托")); + inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), + request.packages(), application.getRelabelNo(), outbound.getOutboundNo(), null, + request.idempotencyKey() + ":PICK", "换标出库指定箱唛拣货")); + inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), + request.packages(), application.getRelabelNo(), outbound.getOutboundNo(), null, + request.idempotencyKey() + ":SHIP", "换标出库指定箱唛出库")); + OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); + pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(request.inventoryPositionId()); + pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); + item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item); + updateOutboundPickingStatus(customerId, outbound); + task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); + return taskResult(outbound, task); + } + InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId()); + if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet"); + if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) { + throw conflict("Scanned inventory position does not belong to the bound warehouse"); + } + InventoryBalance selectedBalance = inventoryBalances.selectById(selectedPosition.getBalanceId()); + if (selectedBalance == null || !customerId.equals(selectedBalance.getCustomerId()) + || !StringUtils.hasText(item.getInboundCode()) || !item.getInboundCode().equals(selectedBalance.getInboundCode())) { + throw conflict("所选库存不属于出库明细的入仓码"); + } + DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId()); + if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory"); + List submittedMarks = request.cartonMarks() == null ? List.of() : request.cartonMarks(); + List selectedMarks = submittedMarks.stream().map(String::trim).filter(StringUtils::hasText).distinct().sorted().toList(); + Set frozenMarks = new HashSet<>(Arrays.asList(Optional.ofNullable(item.getCartonMarks()).orElse("").split(","))); + if (!submittedMarks.isEmpty() && (selectedMarks.size() != submittedMarks.size() + || selectedMarks.size() != request.packages() + || StringUtils.hasText(item.getCartonMarks()) && !frozenMarks.containsAll(selectedMarks))) { + throw conflict("拣货箱唛必须唯一、数量一致且属于当前出库明细"); + } + if (StringUtils.hasText(item.getCartonMarks()) && selectedMarks.isEmpty()) { + throw conflict("请提交本次实际拣货的指定箱唛"); + } + List cartons = !selectedMarks.isEmpty() + ? selectedMarks.stream() + .map(mark -> dropshipCartons.findByProfileAndMark(customerId, profile.getId(), mark)).filter(Objects::nonNull) + .filter(carton -> "AVAILABLE".equals(carton.getStatus())).toList() + : dropshipCartons.lockAvailablePhysical(customerId, profile.getId(), request.packages()); + if (cartons.size() != request.packages()) throw conflict("Insufficient available one-click dropship cartons"); + InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":ALLOCATE", null); + inventoryCommands.allocate(customerId, operatorId, move); + inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":PICK", null)); + inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":SHIP", null)); + Map shippedSkus = new LinkedHashMap<>(); + for (DropshipCarton carton : cartons) { + if (carton.getProductQuantity() == null || carton.getProductQuantity() <= 0) throw conflict("出库箱唛缺少逐箱实收件数"); + List cartonFacts = dropshipCartonSkus.listCarton(customerId, carton.getId()); + if (cartonFacts.isEmpty()) { + shippedSkus.merge(carton.getSkuCode(), new SkuShipment(1, carton.getProductQuantity()), SkuShipment::add); + } else { + for (DropshipCartonSku fact : cartonFacts) { + shippedSkus.merge(fact.getSkuCode(), new SkuShipment(1, value(fact.getProductQuantity())), SkuShipment::add); + } + } + } + for (Map.Entry shipment : shippedSkus.entrySet()) { + if (dropshipSkuInventory.deduct(customerId, profile.getId(), shipment.getKey(), + shipment.getValue().packages(), shipment.getValue().quantity()) != 1) { + throw conflict("Insufficient one-click dropship SKU inventory: " + shipment.getKey()); + } + } + for (DropshipCarton carton : cartons) { carton.setStatus("SHIPPED"); carton.setOutboundOrderNo(outbound.getOutboundNo()); carton.setVersion((carton.getVersion() == null ? 0 : carton.getVersion()) + 1); carton.setUpdatedAt(LocalDateTime.now()); dropshipCartons.updateById(carton); } + OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(request.inventoryPositionId()); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); + item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue()); item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED"); outboundPallets.updateById(item); + updateOutboundPickingStatus(customerId, outbound); + task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); + return taskResult(outbound, task); + } + + @Transactional + public DeliveryResponses.PdaTask pdaPickPallet(String warehouseId, String operatorId, String taskNo, + String palletNo, DeliveryRequests.PdaPalletPick request) { + String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); + String code = required(taskNo, "PDA task number is required"); + String palletCode = required(palletNo, "Pallet number is required"); + OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(code, boundWarehouseId); + if (task == null) throw notFound("PDA task not found in the bound warehouse"); + OutboundOrder outbound = outboundOrders.lockByNo(task.getCustomerId(), requireOutbound(task).getOutboundNo()); + if (outbound == null || !boundWarehouseId.equals(outbound.getOverseasWarehouseId())) { + throw notFound("Outbound order not found in the bound warehouse"); + } + if (!"ONE_CLICK_DROPSHIP".equals(outbound.getOutboundType())) { + throw conflict("整托扫码仅适用于一件代发出库"); + } + if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) { + throw conflict("PDA task is not available for picking"); + } + List palletItems = outboundPallets.findByOutbound(task.getCustomerId(), outbound.getId()) + .stream().filter(item -> palletCode.equals(item.getPalletNo())).toList(); + if (palletItems.isEmpty()) throw notFound("当前出库任务没有该托盘"); + if (palletItems.stream().allMatch(item -> value(item.getPickedPackageCount()) >= value(item.getPackageCount()))) { + return taskResult(outbound, task); + } + + List plans = new ArrayList<>(); + int sequence = 0; + for (OutboundPalletItem item : palletItems) { + long remaining = value(item.getPackageCount()) - value(item.getPickedPackageCount()); + if (remaining <= 0) continue; + Map> positionsById = pdaPickPositions( + task.getCustomerId(), boundWarehouseId, item).stream().collect(Collectors.groupingBy( + DeliveryResponses.PdaPickPosition::inventoryPositionId, LinkedHashMap::new, Collectors.toList())); + for (Map.Entry> entry : positionsById.entrySet()) { + if (remaining <= 0) break; + List marks = entry.getValue().stream().map(DeliveryResponses.PdaPickPosition::cartonMark) + .filter(StringUtils::hasText).distinct().toList(); + long available = marks.isEmpty() + ? entry.getValue().stream().mapToLong(DeliveryResponses.PdaPickPosition::availablePackages).sum() + : marks.size(); + long packages = Math.min(remaining, available); + if (packages <= 0) continue; + List selectedMarks = marks.isEmpty() ? List.of() : marks.stream().limit(packages).toList(); + plans.add(new PalletPickPlan(item.getId(), entry.getKey(), packages, selectedMarks, sequence++)); + remaining -= packages; + } + if (remaining > 0) throw conflict("托盘可用库存不足,整托拣货未执行:" + palletCode); + } + + DeliveryResponses.PdaTask result = taskResult(outbound, task); + for (PalletPickPlan plan : plans) { + String operationId = palletPickOperationId(request.idempotencyKey(), code, palletCode, plan); + result = pdaPick(boundWarehouseId, operatorId, code, new DeliveryRequests.PdaPick( + plan.itemId(), plan.positionId(), plan.packages(), operationId, plan.cartonMarks())); + } + return result; + } + + @Transactional + public DeliveryResponses.PdaTask completePda(String warehouseId, String taskNo, + DeliveryRequests.PdaComplete request) { + OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"), + required(warehouseId, "PDA warehouse binding is required")); + if (task == null) throw notFound("PDA task not found in the bound warehouse"); + String customerId = task.getCustomerId(); + OutboundOrder outbound = requireOutbound(task); + String completionKey = request.idempotencyKey().trim(); + OutboundPdaTask completionReplay = pdaTasks.findByCompletionKey(customerId, completionKey); + if (completionReplay != null && !task.getId().equals(completionReplay.getId())) { + throw conflict("Idempotency key belongs to another outbound PDA task"); + } + if ("COMPLETED".equals(task.getStatus())) { + if (completionKey.equals(task.getCompletionIdempotencyKey())) { + return taskResult(outbound, task); + } + throw conflict("PDA task was already completed with another idempotency key"); + } + if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) { + throw conflict("PDA task is not available for completion"); + } + List items = outboundPallets.findByOutbound(customerId, outbound.getId()); + if (items.isEmpty() || items.stream().anyMatch(i -> value(i.getPickedPackageCount()) != value(i.getPackageCount()))) { + throw conflict("All outbound pallets and packages must be picked before completion"); + } + LocalDateTime now = LocalDateTime.now(); + task.setStatus("COMPLETED"); task.setCompletionIdempotencyKey(completionKey); + task.setCompletedAt(now); task.setUpdatedAt(now); pdaTasks.updateById(task); + outbound.setStatus("OUTBOUNDED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); + if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { + RelabelApplication application = relabelApplications.lockByOutbound(customerId, outbound.getOutboundNo()); + if (application != null && RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { + relabelService.changeStatus(customerId, application.getRelabelNo(), "PDA_OUTBOUND", + new RelabelRequests.ChangeStatus("COMPLETED", "换标出库PDA作业完成")); + } + } + return taskResult(outbound, task); + } + + @Transactional + public void cancelOutbound(String outboundNo, DeliveryRequests.Cancel request) { + OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo)); + if (outbound == null) throw notFound("Outbound order not found"); + if ("CANCELLED".equals(outbound.getStatus())) return; + if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) { + throw conflict("Only an unexecuted outbound order can be cancelled"); + } + String customerId = outbound.getCustomerId(); + List outboundItems = outboundPallets.findByOutbound(customerId, outbound.getId()); + if (outboundItems.isEmpty() || outboundItems.stream().anyMatch(i -> value(i.getPickedPackageCount()) > 0 + || !Set.of("WAIT_PICK", "WAIT_OUTBOUND").contains(i.getStatus()))) { + throw conflict("Outbound order cannot be cancelled after execution has started"); + } + List existingDeliveries = deliveryOrders.lockByOutbound(customerId, outbound.getId()); + if (existingDeliveries.stream().anyMatch(delivery -> !"WAIT_DELIVERY".equals(delivery.getStatus()))) + throw conflict("Outbound order cannot be cancelled while delivery is in progress"); + LocalDateTime now = LocalDateTime.now(); + for (DeliveryOrder delivery : existingDeliveries) { + delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1); + delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); + } + OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId()); + if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); } + outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); + for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); } + if (!existingDeliveries.isEmpty()) { + for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) { + String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history); + } + } + } + + public List pdaTasks(String warehouseId, String status) { + String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); + return pdaTasks.listByWarehouse(boundWarehouseId, StringUtils.hasText(status) ? status.trim() : null).stream() + .map(task -> taskResult(requireOutboundInWarehouse(task, boundWarehouseId), task)).toList(); + } + + public DeliveryResponses.PdaTaskDetail pdaTaskDetail(String warehouseId, String taskNo) { + String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); + OutboundPdaTask task = pdaTasks.findByNoAndWarehouse(required(taskNo, "PDA task number is required"), + boundWarehouseId); + if (task == null) throw notFound("PDA task not found in the bound warehouse"); + OutboundOrder outbound = requireOutboundInWarehouse(task, boundWarehouseId); + List items = outboundPallets.findByOutbound(task.getCustomerId(), + task.getOutboundOrderId()).stream().map(item -> new DeliveryResponses.PdaTaskItem(item.getId(), + item.getPalletNo(), item.getSkuCode(), item.getInboundCode(), value(item.getPackageCount()), + value(item.getPickedPackageCount()), item.getStatus(), + pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList(); + return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items); + } + + public DeliveryResponses.PdaTaskResolution resolvePdaTask(String warehouseId, String businessNo) { + String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); + String code = required(businessNo, "Outbound task, outbound or dispatch number is required"); + + OutboundPdaTask task = pdaTasks.findByNoAndWarehouse(code, boundWarehouseId); + if (task != null) { + OutboundOrder outbound = requireOutboundInWarehouse(task, boundWarehouseId); + return outboundResolution(outbound, task); + } + + OutboundOrder outbound = outboundOrders.findGlobalByNo(code); + if (outbound != null && boundWarehouseId.equals(outbound.getOverseasWarehouseId())) { + if ("DISPATCH_DELIVERY".equals(outbound.getOutboundType()) && StringUtils.hasText(outbound.getDispatchNo())) { + DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(outbound.getDispatchNo()); + if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) { + return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, outbound.getOutboundNo(), + dispatch.getDispatchNo()); + } + } + task = pdaTasks.findByOutboundAndWarehouse(outbound.getCustomerId(), outbound.getId(), boundWarehouseId); + if (task != null) return outboundResolution(outbound, task); + } + + DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(code); + if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) { + return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, null, dispatch.getDispatchNo()); + } + throw notFound("Outbound or dispatch task not found in the bound warehouse"); + } + + private List pdaPickPositions(String customerId, String warehouseId, + OutboundPalletItem item) { + if (item.getWarehousePalletId() == null) return List.of(); + List result = new ArrayList<>(); + Set frozenMarks = new HashSet<>(Arrays.asList( + Optional.ofNullable(item.getCartonMarks()).orElse("").split(","))); + for (InventoryPosition position : inventoryPositions.listPallet(customerId, item.getWarehousePalletId())) { + long available = value(position.getAvailablePackages()); + if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue; + InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId()); + if (StringUtils.hasText(item.getInboundCode()) && (balance == null + || !item.getInboundCode().equals(balance.getInboundCode()))) continue; + DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, position.getId()); + List marks = profile == null ? List.of() : dropshipCartons.listProfile(customerId, profile.getId()) + .stream().filter(carton -> "AVAILABLE".equals(carton.getStatus())) + .filter(carton -> !StringUtils.hasText(item.getCartonMarks()) || frozenMarks.contains(carton.getCartonMark())) + .map(DropshipCarton::getCartonMark).filter(StringUtils::hasText).distinct().limit(available).toList(); + if (profile != null && marks.isEmpty()) continue; + if (!matchesOutboundItem(customerId, warehouseId, item, balance) && marks.isEmpty()) continue; + if (marks.isEmpty()) { + result.add(pdaPosition(position, item, balance, null, available, value(position.getPickedPackages()))); + } else { + for (String mark : marks) result.add(pdaPosition(position, item, balance, mark, 1, 0)); + } + } + return result; + } + + private DeliveryResponses.PdaPickPosition pdaPosition(InventoryPosition position, OutboundPalletItem item, + InventoryBalance balance, String cartonMark, + long available, long picked) { + return new DeliveryResponses.PdaPickPosition(position.getId(), position.getPalletNo(), cartonMark, + item.getSkuCode(), balance.getInboundCode(), available, picked); + } + + private boolean matchesOutboundItem(String customerId, String warehouseId, OutboundPalletItem item, + InventoryBalance balance) { + return balance != null && customerId.equals(balance.getCustomerId()) + && warehouseId.equals(balance.getOverseasWarehouseId()) + && Objects.equals(item.getSkuCode(), balance.getSkuCode()) + && Objects.equals(item.getInboundCode(), balance.getInboundCode()); + } + + private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), task.getPushedAt(), task.getCompletedAt()); } + private DeliveryResponses.PdaTaskResolution outboundResolution(OutboundOrder outbound, OutboundPdaTask task) { + return new DeliveryResponses.PdaTaskResolution("OUTBOUND", task.getTaskNo(), outbound.getOutboundNo(), + outbound.getDispatchNo()); + } + private OutboundOrder requireOutbound(OutboundPdaTask task) { + OutboundOrder outbound = outboundOrders.selectById(task.getOutboundOrderId()); + if (outbound == null || !task.getCustomerId().equals(outbound.getCustomerId())) { + throw new IllegalStateException("Outbound PDA task has no scoped outbound order"); + } + return outbound; + } + private OutboundOrder requireOutboundInWarehouse(OutboundPdaTask task, String warehouseId) { + OutboundOrder outbound = requireOutbound(task); + if (!warehouseId.equals(outbound.getOverseasWarehouseId())) { + throw notFound("PDA task not found in the bound warehouse"); + } + return outbound; + } + private int value(Integer value) { return value == null ? 0 : value; } + private long value(Long value) { return value == null ? 0L : value; } + + public PageResult outboundPage(String customerId, DeliveryRequests.Query query) { + Page page = outboundOrders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(StringUtils.hasText(customerId), OutboundOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo()) + .like(StringUtils.hasText(query.lxCode()), OutboundOrder::getLxCodes, query.lxCode()) + .eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId()) + .eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status()) + .eq(StringUtils.hasText(query.outboundType()), OutboundOrder::getOutboundType, query.outboundType()) + .orderByDesc(OutboundOrder::getCreatedAt)); + return page(page, page.getRecords().stream().map(this::outboundRow).toList()); + } + + public DeliveryResponses.OutboundDetail outboundDetail(String outboundNo) { + OutboundOrder outbound = outboundOrders.findGlobalByNo(trim(outboundNo)); + if (outbound == null) throw notFound("Outbound order not found"); + String customerId = outbound.getCustomerId(); + List deliveries = deliveryOrders.lockByOutbound(customerId, outbound.getId()); + if (deliveries.isEmpty() && "DISPATCH_DELIVERY".equals(outbound.getOutboundType())) { + throw new IllegalStateException("Delivery document is missing for dispatch outbound order"); + } + List pallets = outboundPallets.findByOutbound(customerId, outbound.getId()).stream() + .map(this::outboundPalletRow).toList(); + return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveries.stream().map(this::deliveryRow).toList(), pallets); + } + + public PageResult deliveryPage(String customerId, DeliveryRequests.Query query) { + Page page = deliveryOrders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(StringUtils.hasText(customerId), 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()) + .like(StringUtils.hasText(query.dispatchNo()), DeliveryOrder::getDispatchNo, query.dispatchNo()) + .like(StringUtils.hasText(query.waybillNo()), DeliveryOrder::getWaybillNo, query.waybillNo()) + .like(StringUtils.hasText(query.vehiclePlate()), DeliveryOrder::getVehiclePlate, query.vehiclePlate()) + .like(StringUtils.hasText(query.driverName()), DeliveryOrder::getDriverName, query.driverName()) + .eq(StringUtils.hasText(query.deliveryMethod()), DeliveryOrder::getDeliveryMethod, query.deliveryMethod()) + .ge(query.plannedFrom() != null, DeliveryOrder::getPlannedDeliveryAt, query.plannedFrom()) + .le(query.plannedTo() != null, DeliveryOrder::getPlannedDeliveryAt, query.plannedTo()) + .orderByDesc(DeliveryOrder::getCreatedAt)); + return page(page, page.getRecords().stream().map(this::deliveryRow).toList()); + } + + public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo) { + DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("Delivery order not found"); + List items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()).stream() + .filter(item -> delivery.getDeliveryGroupKey().equals(deliveryGroupKey(item))).toList(); + List orderIds = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); + Map ordersById = orderIds.isEmpty() ? Map.of() : tailOrders.selectBatchIds(orderIds).stream() + .filter(order -> customerId.equals(order.getCustomerId())).collect(java.util.stream.Collectors.toMap(LastlegOrder::getId, order -> order)); + List details = orderIds.isEmpty() ? List.of() : tailOrderDetails.selectList( + new LambdaQueryWrapper().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds)); + List skus = details.stream().map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList(); + return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus, + feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(), + deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList()); + } + + 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.setCustomerId(customerId); 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 List requireDeliveries(String customerId, Long outboundId) { + List values = deliveryOrders.lockByOutbound(customerId, outboundId); + if (values.isEmpty()) throw new IllegalStateException("Delivery documents are missing for outbound order"); + return values; + } + private DeliveryResponses.TransferResult result(OutboundOrder o, List deliveries, boolean replayed) { + return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(), + o.getPalletCount(), o.getPackageCount(), deliveries.stream().map(d -> new DeliveryResponses.TransferDelivery( + d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), + d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed); + } + private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt(), o.getOutboundType()); } + private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { + return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), + item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), + item.getSkuCode(), value(item.getPackageCount()), value(item.getPickedPackageCount()), item.getStatus()); + } + private DeliveryResponses.OneClickCandidate oneClickCandidate(DropshipOutboundCandidate candidate) { + return new DeliveryResponses.OneClickCandidate(candidate.getInventoryPositionId(), candidate.getOrderNo(), candidate.getCustomerId(), candidate.getCustomerName(), + candidate.getOverseasWarehouseId(), candidate.getOverseasWarehouseName(), candidate.getPalletNo(), + candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(), + value(candidate.getAvailablePackages())); + } + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName()); } + private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } + private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); } + private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } + private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } + private LastlegOrder firstTailOrder(String customerId, List items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); } + private String deliveryGroupKey(DispatchItem item) { + String code = firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()); + if (StringUtils.hasText(code)) return "CODE:" + code.toUpperCase(Locale.ROOT); + if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim(); + throw conflict("Dispatch pallet is missing its destination FBA warehouse"); + } + private String resolveLxCodes(String customerId, List items) { + List inboundCodes = items.stream().map(DispatchItem::getInboundCode) + .filter(StringUtils::hasText).map(String::trim).distinct().toList(); + if (inboundCodes.isEmpty()) return ""; + return tailOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .in(LastlegOrder::getInboundCode, inboundCodes)) + .stream().map(LastlegOrder::getLxCode).filter(StringUtils::hasText) + .map(String::trim).distinct().collect(java.util.stream.Collectors.joining(",")); + } + private void updateOutboundPickingStatus(String customerId, OutboundOrder outbound) { + List items = outboundPallets.findByOutbound(customerId, outbound.getId()); + String nextStatus = !items.isEmpty() && items.stream().allMatch(item -> "PICKED".equals(item.getStatus())) + ? "PICKED" : "PICKING"; + outbound.setStatus(nextStatus); + outbound.setUpdatedAt(LocalDateTime.now()); + outboundOrders.updateById(outbound); + } + private static String palletPickOperationId(String idempotencyKey, String taskNo, String palletNo, + PalletPickPlan plan) { + String source = required(idempotencyKey, "Idempotency key is required") + '\u0000' + taskNo + '\u0000' + + palletNo + '\u0000' + plan.itemId() + '\u0000' + plan.positionId() + '\u0000' + plan.sequence(); + try { + return "PALLET_PICK:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(source.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("无法生成整托拣货幂等号", error); + } + } + private record PalletPickPlan(Long itemId, Long positionId, long packages, + List cartonMarks, int sequence) {} + private record SkuShipment(long packages, long quantity) { + private SkuShipment add(SkuShipment other) { + return new SkuShipment(Math.addExact(packages, other.packages), + Math.addExact(quantity, other.quantity)); + } + } + private BigDecimal totalWeight(String customerId, List items) { + List palletIds = items.stream().map(DispatchItem::getWarehousePalletId) + .filter(Objects::nonNull).distinct().toList(); + List transferPallets = palletIds.isEmpty() ? List.of() + : warehouseTransferPallets.selectList(new LambdaQueryWrapper() + .eq(WarehouseTransferPallet::getCustomerId, customerId) + .in(WarehouseTransferPallet::getWarehousePalletId, palletIds)); + Set measuredPalletIds = transferPallets.stream() + .map(WarehouseTransferPallet::getWarehousePalletId).collect(java.util.stream.Collectors.toSet()); + BigDecimal measuredWeight = transferPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + List orderIds = items.stream() + .filter(item -> item.getWarehousePalletId() == null || !measuredPalletIds.contains(item.getWarehousePalletId())) + .map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); + if (orderIds.isEmpty()) return measuredWeight; + BigDecimal orderWeight = tailOrderDetails.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds)) + .stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + return measuredWeight.add(orderWeight); + } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static Long parseLong(String value) { + try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; } + catch (NumberFormatException ignored) { return null; } + } + private static String childKey(String key, String suffix) { + String prefix = key == null ? "" : key.trim(); + int maxPrefix = Math.max(0, 128 - suffix.length() - 1); + return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix; + } + private static String required(String value, String message) { + String normalized = trim(value); + if (normalized == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + return normalized; + } + private static String firstText(String primary, String fallback) { return StringUtils.hasText(primary) ? primary.trim() : trim(fallback); } + 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/controller/CargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java new file mode 100644 index 000000000..0c6552627 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java @@ -0,0 +1,98 @@ +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)); + } + + @PostMapping("/{discardNo}/cancel") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String discardNo) { + requireApprovalPermission(user); + return ApiResponse.success(service.cancel(scope(user, customerId), discardNo, operator(user), true)); + } + + @PostMapping("/{discardNo}/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String discardNo) { + requireApprovalPermission(user); + return ApiResponse.success(service.complete(scope(user, customerId), discardNo, operator(user))); + } + + 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..c9f493b78 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java @@ -0,0 +1,42 @@ +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)); } + @PostMapping("/{discardNo}/cancel") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String discardNo) { return ApiResponse.success(service.cancel(customer(user), discardNo, operator(user), false)); } + 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..ee1560a1e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java @@ -0,0 +1,29 @@ +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 Integer actualDiscardPackages; + private String pdaStatus; + private String pdaOperatorId; + private LocalDateTime pdaCompletedAt; + 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..3b314f9bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java @@ -0,0 +1,22 @@ +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); + + @Select("SELECT * FROM ll_cargo_discard_application WHERE discard_no=#{discardNo} AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE") + CargoDiscardApplication findNoByWarehouse(@Param("discardNo") String discardNo, @Param("warehouseId") String warehouseId); +} 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..6b642d1da --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java @@ -0,0 +1,77 @@ +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 c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + + "AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " + + "WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode})") + long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("skuCode") String skuCode); + + @Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + + "AND c.profile_id=#{profileId} AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " + + "WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode})") + long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("profileId") Long profileId, @Param("skuCode") String skuCode); + + @Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + + "AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " + + "WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode}) " + + "ORDER BY c.profile_id,c.carton_sequence,c.id LIMIT #{limit} FOR UPDATE") + List lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("skuCode") String skuCode, @Param("limit") int limit); + + @Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND status='AVAILABLE'") + long countAvailableOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); + + @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/pda/controller/PdaCargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/controller/PdaCargoDiscardController.java new file mode 100644 index 000000000..16c3f1400 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/controller/PdaCargoDiscardController.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.discard.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos; +import com.baiyue.lastleg.discard.pda.service.PdaCargoDiscardService; +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.*; +import java.util.List; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/cargo-discards") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +public class PdaCargoDiscardController { + @Resource private PdaCargoDiscardService service; + @GetMapping public ApiResponse> tasks(@AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(service.tasks(PdaWarehouseIdentity.require(user))); + } + @PostMapping("/resolve") public ApiResponse resolve(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaCargoDiscardDtos.Scan request) { + return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request.palletNo())); + } + @PostMapping("/{discardNo}/pallets/{palletNo}/confirm") + public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String discardNo, @PathVariable String palletNo, @Valid @RequestBody PdaCargoDiscardDtos.Confirm request) { + return ApiResponse.success(service.confirm(PdaWarehouseIdentity.require(user), discardNo, palletNo, request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java new file mode 100644 index 000000000..feb1ca451 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.discard.pda.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import java.util.List; + +public final class PdaCargoDiscardDtos { + private PdaCargoDiscardDtos() { } + public record Scan(@NotBlank @Size(max = 128) String palletNo) { } + public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { } + public record Confirm(@NotEmpty List<@Valid ActualLine> lines) { } + public record Sku(Long itemId, String skuCode, String productName, int plannedPackages, + Integer actualPackages, String status) { } + public record PalletTask(String discardNo, String customerId, String overseasWarehouseId, + String palletNo, Long profileId, String status, List skus) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java new file mode 100644 index 000000000..a94ee568a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -0,0 +1,99 @@ +package com.baiyue.lastleg.discard.pda.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; +import com.baiyue.lastleg.discard.entity.*; +import com.baiyue.lastleg.discard.mapper.*; +import com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos; +import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +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.*; +import java.util.stream.Collectors; + +@Service +public class PdaCargoDiscardService { + @Resource private CargoDiscardApplicationMapper applications; + @Resource private CargoDiscardItemMapper items; + @Resource private CargoDiscardCartonMapper cartons; + @Resource private DropshipOrderProfileMapper profiles; + @Resource private CargoDiscardInventoryAdjustmentService adjustments; + @Resource private CargoDiscardService discardService; + + public List tasks(PdaWarehouseIdentity identity) { + return applications.selectList(new LambdaQueryWrapper() + .eq(CargoDiscardApplication::getOverseasWarehouseId, String.valueOf(identity.overseasWarehouseId())) + .eq(CargoDiscardApplication::getStatus, CargoDiscardService.APPROVED) + .orderByAsc(CargoDiscardApplication::getCreatedAt)) + .stream().flatMap(a -> palletTasks(a).stream()).toList(); + } + + public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo) { + return tasks(identity).stream().filter(t -> t.palletNo().equals(palletNo.trim())).findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "该仓库没有此托盘的待废弃任务")); + } + + @Transactional + public PdaCargoDiscardDtos.PalletTask confirm(PdaWarehouseIdentity identity, String discardNo, + String palletNo, PdaCargoDiscardDtos.Confirm request) { + CargoDiscardApplication application = applications.findNoByWarehouse(discardNo, + String.valueOf(identity.overseasWarehouseId())); + if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库"); + if (!CargoDiscardService.APPROVED.equals(application.getStatus())) throw conflict("废弃任务当前不可进行 PDA 作业"); + PdaCargoDiscardDtos.PalletTask task = palletTasks(application).stream().filter(t -> t.palletNo().equals(palletNo.trim())) + .findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单")); + Map actuals = request.lines().stream().collect(Collectors.toMap(PdaCargoDiscardDtos.ActualLine::itemId, + PdaCargoDiscardDtos.ActualLine::actualPackages, (a,b) -> { throw conflict("PDA 明细重复"); })); + if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) { + throw conflict("必须提交该托盘全部 SKU 的实际废弃数量"); + } + LocalDateTime now = LocalDateTime.now(); + for (PdaCargoDiscardDtos.Sku line : task.skus()) { + CargoDiscardItem item = items.selectById(line.itemId()); + if ("COMPLETED".equals(item.getPdaStatus())) { + if (!Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId()))) throw conflict("已完成托盘的实际数量不能修改"); + continue; + } + List links = cartons.listApplication(application.getCustomerId(), application.getId()).stream() + .filter(c -> c.getItemId().equals(item.getId())).toList(); + adjustments.discardPalletItem(application.getCustomerId(), application, item, links, + actuals.get(item.getId()), identity.operatorKey(), now); + item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED"); + item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); items.updateById(item); + } + if (items.listApplication(application.getCustomerId(), application.getId()).stream() + .allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) { + int actualTotal = items.listApplication(application.getCustomerId(), application.getId()).stream() + .mapToInt(i -> i.getActualDiscardPackages() == null ? 0 : i.getActualDiscardPackages()).sum(); + application.setTotalPackages(actualTotal); + application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal))); + discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED, + CargoDiscardService.PDA_COMPLETED, identity.operatorKey(), "全部托盘 PDA 废弃作业完成,待仓库确认"); + } + return task; + } + + private List palletTasks(CargoDiscardApplication application) { + return items.listApplication(application.getCustomerId(), application.getId()).stream() + .collect(Collectors.groupingBy(CargoDiscardItem::getProfileId, LinkedHashMap::new, Collectors.toList())) + .entrySet().stream().map(entry -> { + DropshipOrderProfile profile = profiles.selectById(entry.getKey()); + if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) throw conflict("废弃明细关联托盘不存在"); + List skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku( + i.getId(), i.getSkuCode(), i.getProductName(), i.getDiscardPackages(), i.getActualDiscardPackages(), + i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList(); + String status = skus.stream().allMatch(s -> "COMPLETED".equals(s.status())) ? "COMPLETED" : "PENDING"; + return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), + application.getOverseasWarehouseId(), profile.getPalletNo(), profile.getId(), status, skus); + }).toList(); + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} 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..51f106d68 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java @@ -0,0 +1,165 @@ +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.DropshipCartonSku; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +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.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.LinkedHashMap; +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; + @Resource private DropshipCartonSkuMapper cartonSkus; + @Resource private DropshipSkuInventoryMapper skuInventories; + @Resource private DropshipOrderProfileMapper profiles; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryBalanceMapper balances; + + @Transactional(propagation = Propagation.MANDATORY) + public AdjustmentResult discardPalletItem(String customerId, CargoDiscardApplication application, + CargoDiscardItem item, List links, + int actualPackages, String operatorId, LocalDateTime occurredAt) { + if (actualPackages < 0 || actualPackages > links.size()) throw conflict("实际废弃数量超出该托盘申请数量"); + DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId()); + if (sku == null) throw conflict("托盘 SKU 库存不存在: " + item.getSkuCode()); + long before = value(sku.getPackageCount()); + Map discardedSkus = new LinkedHashMap<>(); + for (int index = 0; index < actualPackages; index++) { + CargoDiscardCarton link = links.get(index); + DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId()); + if (carton == null) throw conflict("托盘预留箱不存在"); + List facts = cartonSkus.listCarton(customerId, carton.getId()); + if (facts.isEmpty()) { + discardedSkus.merge(carton.getSkuCode(), new SkuDiscard(1, value(carton.getProductQuantity())), SkuDiscard::add); + } else { + for (DropshipCartonSku fact : facts) { + discardedSkus.merge(fact.getSkuCode(), new SkuDiscard(1, value(fact.getProductQuantity())), SkuDiscard::add); + } + } + } + for (Map.Entry discarded : discardedSkus.entrySet()) { + if (skuInventories.deduct(customerId, item.getProfileId(), discarded.getKey(), + discarded.getValue().packages(), discarded.getValue().quantity()) != 1) { + throw conflict("托盘 SKU 库存不足: " + discarded.getKey()); + } + } + var profile = profiles.selectById(item.getProfileId()); + var position = profile == null || profile.getInventoryPositionId() == null ? null + : positions.findTenantPosition(customerId, profile.getInventoryPositionId()); + if (profile == null || !customerId.equals(profile.getCustomerId()) || position == null + || positions.deductAvailable(customerId, profile.getInventoryPositionId(), actualPackages) != 1 + || balances.deductAvailable(customerId, position.getBalanceId(), actualPackages) != 1) { + throw conflict("托盘库存明细数量不足或托盘位置不存在"); + } + for (int index = 0; index < links.size(); index++) { + CargoDiscardCarton link = links.get(index); + String target = index < actualPackages ? "DISCARDED" : "AVAILABLE"; + DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId()); + if (carton == null || inventory.changeCartonStatus(customerId, carton.getId(), "DISCARD_PENDING", target) != 1) { + throw conflict("托盘预留箱状态已变化"); + } + if (index < actualPackages) cartonLedgers.insert(discardFact(customerId, application, item, carton, operatorId, occurredAt)); + } + long after = value(inventory.skuPackages(customerId, sku.getId())); + if (after != before - actualPackages) throw conflict("托盘 SKU 库存扣减不守恒"); + return new AdjustmentResult(before, actualPackages, after); + } + + @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 record SkuDiscard(long packages, long quantity) { + private SkuDiscard add(SkuDiscard other) { + return new SkuDiscard(Math.addExact(packages, other.packages), Math.addExact(quantity, other.quantity)); + } + } + 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..969cf160a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -0,0 +1,414 @@ +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.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +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 { + @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; + 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"; + public static final String CANCELLED = "CANCELLED"; + public static final String PDA_COMPLETED = "PDA_COMPLETED"; + public static final String COMPLETED = "COMPLETED"; + private static final Set STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED, CANCELLED, PDA_COMPLETED, COMPLETED); + + @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 LastlegOrderStatusHistoryMapper orderHistories; + @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(value -> value.response(inventory.countAvailableOrder(customerId, value.first.getOrderId()))) + .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(orderNoGenerator.next("DISCARD", warehouseId, 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); + + // A discard request is also a customer-visible tail-leg order. Keep the + // application as the workflow record and link the order through its + // immutable source id so cancellation/status changes stay traceable. + LastlegOrder tailOrder = new LastlegOrder(); + tailOrder.setId(IdWorker.getId()); + tailOrder.setOrderNo(application.getDiscardNo()); + tailOrder.setCustomerId(customerId); + tailOrder.setOrderType("CARGO_DISCARD"); + tailOrder.setInboundCode(application.getInboundCodes()); + tailOrder.setInboundOrderNo(application.getDiscardNo()); + tailOrder.setSourceRegion("LASTLEG_DISCARD"); + tailOrder.setSourceInboundId("CARGO_DISCARD:" + application.getId()); + tailOrder.setSourceOrderId(String.valueOf(application.getId())); + tailOrder.setSourceOrderNo(application.getDiscardNo()); + tailOrder.setOverseasWarehouseId(warehouseId); + tailOrder.setOverseasWarehouseName(warehouseName); + tailOrder.setPackageCount(total); + tailOrder.setCurrentStatus(PENDING_REVIEW); + tailOrder.setVersion(0); + tailOrder.setCreatedAt(now); + tailOrder.setUpdatedAt(now); + tailOrder.setSourceEventId("CARGO_DISCARD:" + application.getId()); + tailOrder.setSourceLoadingPlanId("CARGO_DISCARD"); + tailOrder.setSourcePayloadHash(requestHash); + orders.insert(tailOrder); + LastlegOrderStatusHistory initialOrderHistory = new LastlegOrderStatusHistory(); + initialOrderHistory.setId(IdWorker.getId()); initialOrderHistory.setCustomerId(customerId); initialOrderHistory.setOrderId(tailOrder.getId()); + initialOrderHistory.setFromStatus(null); initialOrderHistory.setToStatus(PENDING_REVIEW); + initialOrderHistory.setEventId("CARGO_DISCARD_CREATE:" + application.getId()); initialOrderHistory.setRemark(trim(request.remark())); initialOrderHistory.setCreatedAt(now); + orderHistories.insert(initialOrderHistory); + + 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 (REJECTED.equals(next)) { + 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); + LastlegOrder linked = orders.findSource(customerId, "LASTLEG_DISCARD", "CARGO_DISCARD:" + application.getId()); + if (linked != null) { + String linkedStatus = mainOrderStatus(next); + linked.setCurrentStatus(linkedStatus); linked.setVersion((linked.getVersion() == null ? 0 : linked.getVersion()) + 1); linked.setUpdatedAt(reviewedAt); + orders.updateById(linked); + LastlegOrderStatusHistory orderHistory = new LastlegOrderStatusHistory(); orderHistory.setId(IdWorker.getId()); + orderHistory.setCustomerId(customerId); orderHistory.setOrderId(linked.getId()); orderHistory.setFromStatus(PENDING_REVIEW); + orderHistory.setToStatus(linkedStatus); orderHistory.setEventId("CARGO_DISCARD_REVIEW:" + application.getId()); orderHistory.setRemark(historyRemark); orderHistory.setCreatedAt(reviewedAt); + orderHistories.insert(orderHistory); + } + history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, historyRemark, reviewedAt); + return detail(customerId, discardNo); + } + + @Transactional + public CargoDiscardResponses.Detail complete(String customerId, String discardNo, String operatorId) { + CargoDiscardApplication application = applications.lockNo(customerId, discardNo); + if (application == null) throw notFound("Cargo discard application not found"); + if (COMPLETED.equals(application.getStatus())) return detail(customerId, discardNo); + if (!PDA_COMPLETED.equals(application.getStatus())) throw conflict("全部托盘 PDA 作业完成后才能确认废弃完成"); + updateLinkedStatus(customerId, application, PDA_COMPLETED, COMPLETED, operatorId, "仓库确认废弃完成"); + return detail(customerId, discardNo); + } + + public void updateLinkedStatus(String customerId, CargoDiscardApplication application, String from, String to, + String operatorId, String remark) { + LocalDateTime at = now(); application.setStatus(to); application.setUpdatedAt(at); applications.updateById(application); + LastlegOrder linked = orders.findSource(customerId, "LASTLEG_DISCARD", "CARGO_DISCARD:" + application.getId()); + if (linked != null) { + String linkedFrom = mainOrderStatus(from); + String linkedTo = mainOrderStatus(to); + linked.setCurrentStatus(linkedTo); linked.setPackageCount(application.getTotalPackages()); + linked.setVersion((linked.getVersion() == null ? 0 : linked.getVersion()) + 1); linked.setUpdatedAt(at); orders.updateById(linked); + LastlegOrderStatusHistory h = new LastlegOrderStatusHistory(); h.setId(IdWorker.getId()); h.setCustomerId(customerId); h.setOrderId(linked.getId()); + h.setFromStatus(linkedFrom); h.setToStatus(linkedTo); h.setEventId("CARGO_DISCARD_STATUS:" + application.getId() + ":" + linkedTo); h.setRemark(remark); h.setCreatedAt(at); orderHistories.insert(h); + } + history(customerId, application.getId(), from, to, operatorId, remark, at); + } + + @Transactional + public CargoDiscardResponses.Detail cancel(String customerId, String discardNo, String operatorId, boolean platform) { + CargoDiscardApplication application = applications.lockNo(customerId, discardNo); + if (application == null) throw notFound("Cargo discard application not found"); + if (CANCELLED.equals(application.getStatus())) return detail(customerId, discardNo); + if (REJECTED.equals(application.getStatus())) throw conflict("已驳回的废弃申请不能取消"); + if (PDA_COMPLETED.equals(application.getStatus()) || COMPLETED.equals(application.getStatus())) throw conflict("PDA 已完成库存扣减,废弃申请不能取消"); + if (items.listApplication(customerId, application.getId()).stream().anyMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) { + throw conflict("已有托盘完成废弃作业,不能取消申请"); + } + if (!platform && !PENDING_REVIEW.equals(application.getStatus())) { + throw conflict("客户只能取消待审核的废弃申请"); + } + List links = discardCartons.listApplication(customerId, application.getId()); + release(customerId, links); + LocalDateTime at = now(); + String from = application.getStatus(); + application.setStatus(CANCELLED); application.setReviewedBy(platform ? operatorId : application.getReviewedBy()); + application.setReviewRemark(platform ? "平台取消" : application.getReviewRemark()); + application.setUpdatedAt(at); applications.updateById(application); + LastlegOrder order = orders.findSource(customerId, "LASTLEG_DISCARD", "CARGO_DISCARD:" + application.getId()); + if (order != null) { + order.setCurrentStatus(CANCELLED); order.setVersion((order.getVersion() == null ? 0 : order.getVersion()) + 1); order.setUpdatedAt(at); + orders.updateById(order); + LastlegOrderStatusHistory h = new LastlegOrderStatusHistory(); h.setId(IdWorker.getId()); h.setCustomerId(customerId); h.setOrderId(order.getId()); + h.setFromStatus(from); h.setToStatus(CANCELLED); h.setEventId("CARGO_DISCARD_CANCEL:" + application.getId()); h.setRemark("废弃申请已取消"); h.setCreatedAt(at); + orderHistories.insert(h); + } + history(customerId, application.getId(), from, CANCELLED, operatorId, "废弃申请已取消", at); + 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"); + } + } + + /** Maps application/PDA-only states to the five states exposed by the tail-leg main order. */ + private static String mainOrderStatus(String status) { + return switch (status) { + case REJECTED -> CANCELLED; + case PDA_COMPLETED -> "PROCESSING"; + default -> status; + }; + } + + 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(long physicalPackages) { + List skuViews = skus.values().stream() + .map(value -> new CargoDiscardResponses.CandidateSku(value.skuCode, value.productName, value.packages)).toList(); + return new CargoDiscardResponses.Candidate(first.getOrderId(), first.getInboundCode(), first.getInboundOrderNo(), + first.getOverseasWarehouseId(), first.getOverseasWarehouseName(), first.getCreatedAt(), physicalPackages, 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/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java new file mode 100644 index 000000000..fe78e7aca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -0,0 +1,71 @@ +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.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.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +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.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; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/dispatches") +@Tag(name = "Tail-leg vehicle dispatch") +public class DispatchController { + @Resource private DispatchService service; + + @PutMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')") + @Operation(summary = "Edit vehicle, driver and appointment data; route is derived from assigned FBA warehouses") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @Valid @RequestBody DispatchRequests.Update request) { + requirePlatform(user); + return ApiResponse.success(service.update(dispatchNo, request)); + } + + @DeleteMapping("/{dispatchNo}/items/{itemId}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')") + @Operation(summary = "Remove one unpicked pallet before an outbound order is generated") + public ApiResponse removeItem( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @PathVariable Long itemId) { + requirePlatform(user); + return ApiResponse.success(service.removeItem(dispatchNo, itemId)); + } + + @DeleteMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_CANCEL + "')") + @Operation(summary = "Cancel a pending dispatch and release its pallet locks") + public ApiResponse cancel( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo) { + requirePlatform(user); + service.cancel(dispatchNo); + return ApiResponse.success(null); + } + + 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/DispatchQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java new file mode 100644 index 000000000..feac81de7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java @@ -0,0 +1,83 @@ +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.dispatch.service.DispatchBolPrintService; +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.http.CacheControl; +import org.springframework.http.ContentDisposition; +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.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import jakarta.servlet.http.HttpServletRequest; +import java.nio.charset.StandardCharsets; + +@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; + @Resource private DispatchBolPrintService printService; + + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_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.DISPATCH_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)); + } + + @GetMapping(value = "/{dispatchNo}/prints/bol", produces = MediaType.APPLICATION_PDF_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") + @Operation(summary = "Generate dispatch BOL as an A4 PDF") + public ResponseEntity printBol(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, HttpServletRequest request) { + requirePlatform(user); + DispatchBolPrintService.GeneratedPdf pdf = printService.generate(dispatchNo, request.getHeader("X-Request-Id")); + ResponseEntity.BodyBuilder response = ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF) + .cacheControl(CacheControl.noStore()).header(HttpHeaders.PRAGMA, "no-cache") + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(pdf.fileName(), StandardCharsets.UTF_8).build().toString()) + .header("X-Content-Type-Options", "nosniff"); + if (pdf.pageCount() != null) response.header("X-Print-Page-Count", pdf.pageCount()); + return response.contentLength(pdf.content().length).body(pdf.content()); + } + + 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..97a78fe5f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -0,0 +1,144 @@ +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 = request.relabelNo() == null || request.relabelNo().isBlank() + ? service.resolveCustomerScopeForPallets(request.planPalletIds()) + : service.resolveCustomerScopeForRelabel(request.relabelNo()); + 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..eb2564c80 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -0,0 +1,59 @@ +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 = 64) String fbaAppointmentNo, + @Size(max = 32) String fbaAppointmentStatus, + @Size(max = 32) String vehicleAppointmentStatus, + @Size(max = 64) String draftToken, + @Size(max = 64) String relabelNo) { + public PreArrange(List planPalletIds, String fbaAppointmentNo, + String fbaAppointmentStatus, String vehicleAppointmentStatus) { + this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null); + } + public PreArrange(List planPalletIds, String fbaAppointmentNo, + String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken) { + this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, 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 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 = 64) String fbaAppointmentNo, + @Size(max = 32) String fbaAppointmentStatus, + @Size(max = 32) String vehicleAppointmentStatus, + LocalDateTime estimatedArrivalAt) { + } +} 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..7de25f0a8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java @@ -0,0 +1,27 @@ +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, List bolLines) { + 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) { + } + + public record BolLine(String containerNo, String palletNo, String fbaWarehouseCode, + String destinationWarehouseName, String warehouseNo, String fbaShipmentId, + String poReferenceNo, int packageCount) { + } +} 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..974f39ea4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java @@ -0,0 +1,41 @@ +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 String cartonMarks; + private String sourceBusinessType; + private String sourceBusinessNo; + 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..57239e4ed --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java @@ -0,0 +1,39 @@ +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; + @TableField(updateStrategy = FieldStrategy.ALWAYS) + 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/DispatchBolMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java new file mode 100644 index 000000000..254dbdded --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +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 DispatchBolMapper { + @Select("SELECT " + + "COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," + + "MAX(NULLIF(di.pallet_no,'')) AS palletNo," + + "COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," + + "MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode," + + "COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName," + + "COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo," + + "GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId," + + "GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo," + + "CASE WHEN MAX(l.id) IS NULL THEN COALESCE(MAX(di.package_count),0) " + + "WHEN COALESCE(MAX(a.picked_package_count),0)>0 THEN MAX(a.picked_package_count) " + + "ELSE COALESCE(MAX(a.planned_package_count),0) END AS packageCount " + + "FROM ll_dispatch_item di " + + "LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " + + "LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " + + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " + + "LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " + + "LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " + + "WHERE di.customer_id=#{customerId} AND di.dispatch_order_id=#{dispatchOrderId} " + + "GROUP BY di.id,l.id ORDER BY di.id,l.id") + List findByDispatchOrder(@Param("customerId") String customerId, + @Param("dispatchOrderId") Long dispatchOrderId); +} 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..e0c93edf6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -0,0 +1,215 @@ +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); + + @Select("") + List relabelAvailable(@Param("customerId") String customerId, + @Param("orderNo") String orderNo, + @Param("relabelNo") String relabelNo); + + @org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='PRE_DISPATCH_CREATED',updated_at=CURRENT_TIMESTAMP(3) " + + "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='WAIT_PRE_DISPATCH'") + int markRelabelPreDispatched(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); + + @org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='WAIT_PRE_DISPATCH',updated_at=CURRENT_TIMESTAMP(3) " + + "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='PRE_DISPATCH_CREATED'") + int restoreRelabelPreDispatch(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); +} 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..210f8eca6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java @@ -0,0 +1,24 @@ +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 d.* FROM ll_dispatch_draft d WHERE d.operator_id=#{operatorId} AND d.status='ACTIVE' " + + "AND (d.expires_at IS NULL OR d.expires_at>CURRENT_TIMESTAMP(3)) AND NOT EXISTS (" + + "SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_outbound_order o ON o.customer_id=l.customer_id " + + "AND o.dispatch_order_id=l.dispatch_order_id WHERE l.draft_token=d.draft_token) AND NOT EXISTS (" + + "SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order x ON x.id=l.dispatch_order_id " + + "AND x.customer_id=l.customer_id WHERE l.draft_token=d.draft_token AND x.status!='PENDING_DEPARTURE') " + + "ORDER BY d.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..8f94757bf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java @@ -0,0 +1,37 @@ +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); + + @Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} " + + "AND id=#{itemId} LIMIT 1 FOR UPDATE") + DispatchItem lockById(@Param("customerId") String customerId, + @Param("dispatchOrderId") Long dispatchOrderId, + @Param("itemId") Long itemId); + + @Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} " + + "AND plan_pallet_id=#{planPalletId} LIMIT 1 FOR UPDATE") + DispatchItem lockByPlanPallet(@Param("customerId") String customerId, + @Param("dispatchOrderId") Long dispatchOrderId, + @Param("planPalletId") Long planPalletId); + + @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..97250cbc5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java @@ -0,0 +1,22 @@ +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; + +@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} LIMIT 1") + DispatchOrder findGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo); + + @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1 FOR UPDATE") + DispatchOrder lockGlobalByDispatchNo(@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..e8efc3b30 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java @@ -0,0 +1,34 @@ +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; + +import java.util.List; + +@Mapper +public interface DispatchPalletLockMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pallet_lock WHERE draft_token=#{draftToken} " + + "AND draft_container_id=#{draftContainerId} AND status='ACTIVE' " + + "AND (expires_at IS NULL OR expires_at>CURRENT_TIMESTAMP(3)) ORDER BY locked_at,id") + List findActiveByDraftContainer(@Param("draftToken") String draftToken, + @Param("draftContainerId") Long draftContainerId); + + @Select("SELECT l.dispatch_order_id FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order d " + + "ON d.id=l.dispatch_order_id AND d.customer_id=l.customer_id AND d.status='PENDING_DEPARTURE' " + + "WHERE l.draft_token=#{draftToken} ORDER BY l.updated_at DESC,l.id DESC LIMIT 1") + Long findDispatchOrderIdByDraft(@Param("draftToken") String draftToken); + + @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..de1133d08 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java @@ -0,0 +1,61 @@ +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, + String relabelCartonMarks +) { + 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, 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, null); + } + public String sourcePalletKey() { + // Once received, the physical pallet is the shared resource even when it contains + // allocations from multiple inbound orders/plan-pallet rows. + return warehousePalletId != null ? "RECEIVED:" + warehousePalletId : "PLAN:" + planPalletId; + } +} 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..bc05e0a53 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -0,0 +1,64 @@ +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 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.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/dispatches") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +public class DispatchPdaController { + @Resource private DispatchPdaWorkflowService service; + + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String status) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.list(String.valueOf(identity.overseasWarehouseId()), status)); + } + + @GetMapping("/{dispatchNo}") + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.task(String.valueOf(identity.overseasWarehouseId()), dispatchNo)); + } + + @PostMapping("/{dispatchNo}/items/{itemId}/pick") + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, @PathVariable long itemId, + @Valid @RequestBody DispatchPdaRequests.Pick request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pick(String.valueOf(identity.overseasWarehouseId()), dispatchNo, itemId, + identity.operatorKey(), identity.operatorName(), request)); + } + + @PostMapping("/{dispatchNo}/complete") + public ApiResponse completePicking( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @Valid @RequestBody DispatchPdaRequests.CompletePicking request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.completePicking(String.valueOf(identity.overseasWarehouseId()), + dispatchNo, request)); + } + + @PostMapping("/{dispatchNo}/depart") + public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @Valid @RequestBody DispatchPdaRequests.Depart request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.depart(String.valueOf(identity.overseasWarehouseId()), dispatchNo, + identity.operatorKey(), request)); + } +} 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..0d5613033 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java @@ -0,0 +1,15 @@ +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 Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity, + @NotBlank String idempotencyKey, String remark) {} + public record Depart(@NotBlank String idempotencyKey, String remark) {} + public record CompletePicking(@NotBlank String idempotencyKey) {} +} 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..ec1d7cd4b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java @@ -0,0 +1,28 @@ +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, + boolean canDepart, String departBlockReason, List items) {} + public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel, + 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..98f84e699 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java @@ -0,0 +1,16 @@ +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); + + @Select("SELECT * FROM ll_dispatch_pda_operation WHERE customer_id=#{customerId} AND task_id=#{taskId} " + + "AND operation_type=#{operationType} AND status='COMPLETED' ORDER BY created_at DESC LIMIT 1") + DispatchPdaOperation findCompleted(@Param("customerId") String customerId, @Param("taskId") Long taskId, + @Param("operationType") String operationType); +} 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..14571382b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java @@ -0,0 +1,33 @@ +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 DISTINCT r.* FROM ll_inventory_position current_position " + + "JOIN ll_inventory_position receipt_position ON receipt_position.balance_id=current_position.balance_id " + + "JOIN ll_inbound_stock_receipt r ON r.inventory_position_id=receipt_position.id " + + "WHERE current_position.id=#{positionId} AND r.customer_id=#{customerId} " + + "AND r.received_packages > (SELECT COALESCE(SUM(a.packages),0) FROM ll_dispatch_pda_pick_order a " + + "WHERE a.customer_id=r.customer_id AND a.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..c92c6232f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java @@ -0,0 +1,21 @@ +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.*; + +import java.util.List; + +@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); + + @Select("SELECT * FROM ll_dispatch_pda_task WHERE warehouse_id=#{warehouseId} " + + "AND (#{status} IS NULL OR status=#{status}) ORDER BY pushed_at DESC LIMIT 200") + List listByWarehouse(@Param("warehouseId") String warehouseId, + @Param("status") String status); +} 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..873af02a4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -0,0 +1,348 @@ +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.transfer.mapper.WarehouseTransferOrderMapper; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +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 RelabelApplicationService relabelApplications; + @Resource private WarehouseTransferOrderMapper warehouseTransfers; + @Resource private DispatchPickOrderAttributionService pickAttribution; + @Resource private ObjectMapper objectMapper; + + @Transactional(readOnly = true) + public List list(String warehouseId, String status) { + String boundWarehouseId = warehouse(warehouseId); + List result = new ArrayList<>(); + for (DispatchPdaTask pdaTask : tasks.listByWarehouse(boundWarehouseId, trim(status))) { + DispatchOrder order = orders.selectById(pdaTask.getDispatchOrderId()); + if (order == null || !pdaTask.getCustomerId().equals(order.getCustomerId()) + || !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) { + throw conflict("Dispatch PDA task scope is inconsistent"); + } + result.add(task(order.getCustomerId(), order, pdaTask, + items.findByDispatchOrder(order.getCustomerId(), order.getId()))); + } + return result; + } + + @Transactional + public DispatchPdaResponses.Task task(String warehouseId, String dispatchNo) { + DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo); + String customerId = order.getCustomerId(); + items.bindReceivedPallets(customerId, order.getId()); + DispatchPdaTask task = tasks.findByDispatch(customerId, order.getId()); + if (task == null) throw notFound("Dispatch PDA task not found"); + requireTaskWarehouse(task, warehouseId); + return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId())); + } + + @Transactional + public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId, + String operatorId, String operatorName, + DispatchPdaRequests.Pick request) { + DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo); + String customerId = order.getCustomerId(); + DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); + if (task == null) throw conflict("Dispatch PDA task is not available for picking"); + if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse"); + DispatchPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request); + if (replay != null) { + DispatchPdaResponses.Picked result = read(replay, DispatchPdaResponses.Picked.class); + if (!task.getId().equals(replay.getTaskId()) || result.dispatchItemId() != itemId) { + throw conflict("Idempotency key was reused with a different dispatch pick request"); + } + return result; + } + requirePdaStatus(order); + requireVehicle(order); + items.bindReceivedPallets(customerId, order.getId()); + if (!("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { + throw conflict("Dispatch PDA task is not available for picking"); + } + bindOperator(task, operatorId, operatorName); + 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"); + 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()); + if ("RELABEL_ORDER".equals(item.getSourceBusinessType())) { + inventory.release(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), + request.packages(), item.getSourceBusinessNo(), order.getDispatchNo(), null, + keyBase + ":RELABEL_RELEASE", "换标排车拣货前释放冻结库存")); + } + 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())); + // A successful PDA scan is the inventory deduction point. Departure only + // changes outbound/dispatch facts and must not deduct the same quantity again. + inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), + request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, + keyBase + ":SHIP", 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, + "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null); + 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 warehouseId, String dispatchNo, + DispatchPdaRequests.CompletePicking request) { + DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); + String customerId = found.getCustomerId(); + DispatchOrder order = orders.lockById(customerId, found.getId()); + if (order == null) throw notFound("Dispatch order not found"); + DispatchPdaOperation replay = replay(customerId, "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 && "COMPLETED".equals(task.getStatus())) { + List completedItems = items.findByDispatchOrder(customerId, order.getId()); + long planned = completedItems.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(); + DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted( + order.getDispatchNo(), order.getStatus(), planned, picked, Math.max(0, planned - picked), false, false); + DispatchPdaOperation completed = operations.findCompleted(customerId, task.getId(), "COMPLETE_PICKING"); + if (completed != null) throw conflict("PDA picking was already completed with another idempotency key"); + save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result); + return result; + } + 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 (planned <= 0 || picked != planned || !allPicked(customerId, task.getId(), source)) { + throw conflict("Every dispatch pallet and package must be fully picked before completion"); + } + LocalDateTime now = LocalDateTime.now(); + task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); tasks.updateById(task); + order.setStatus("READY_TO_DEPART"); + order.setPartialCompletionReason(null); + order.setPickedPackageCount((int) picked); + order.setUpdatedAt(now); orders.updateById(order); + DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), + order.getStatus(), planned, picked, 0, false, 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 warehouseId, String dispatchNo, String operatorId, + DispatchPdaRequests.Depart request) { + DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); + String customerId = found.getCustomerId(); + DispatchOrder order = orders.lockById(customerId, found.getId()); + if (order == null) throw notFound("Dispatch order not found"); + DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay(); + 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())) { + throw conflict("Dispatch must complete PDA picking before departure"); + } + requireTaskWarehouse(task, warehouseId); + + List dispatchItems = items.findByDispatchOrder(customerId, order.getId()); + if (!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 = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum(); + LocalDateTime now = LocalDateTime.now(); + pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now); + for (DispatchItem item : dispatchItems) { + if (item.getOrderId() != null) warehouseTransfers.markOutboundByOrder(customerId, item.getOrderId(), now); + if ("RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getSourceBusinessNo() != null) { + warehouseTransfers.markOutboundByRelabel(customerId, item.getSourceBusinessNo(), now); + } + } + dispatchItems.stream().filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) + .map(DispatchItem::getSourceBusinessNo).filter(Objects::nonNull).distinct().forEach(relabelNo -> + relabelApplications.changeStatus(customerId, relabelNo, operatorId, + new RelabelRequests.ChangeStatus("COMPLETED", "换标排车PDA发车完成"))); + outbound.setStatus("OUTBOUNDED"); + outbound.setUpdatedAt(now); + outboundOrders.updateById(outbound); + order.setShippedPackageCount((int) shipped); + order.setStatus("DEPARTED"); + order.setDepartAt(now); + 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()); + boolean relabel = "RELABEL_ORDER".equals(item.getSourceBusinessType()); + values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), + item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), + warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(), + picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p -> + new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()) + + (relabel ? n(p.getHoldPackages()) : 0), + n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList())); + } + OutboundOrder outbound = outboundOrders.findByDispatch(customerId, order.getId()); + String departBlockReason = departBlockReason(order, task, source, outbound); + return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), + task.getWarehouseId(), task.getStatus(), departBlockReason == null, departBlockReason, values); + } + + private String departBlockReason(DispatchOrder order, DispatchPdaTask task, List source, + OutboundOrder outbound) { + if ("DEPARTED".equals(order.getStatus())) return "该排车已发车,不可重复提交。"; + if (trim(order.getVehiclePlate()) == null) return "请先在平台维护车辆信息。"; + if (!"COMPLETED".equals(task.getStatus()) || !"READY_TO_DEPART".equals(order.getStatus()) + || !allPicked(order.getCustomerId(), task.getId(), source)) { + return "请先完成排车拣货并核平全部箱数。"; + } + if (outbound == null) return "请先在平台完成“转派送”,生成待出库单后才能发车。"; + if (!"WAIT_OUTBOUND".equals(outbound.getStatus())) return "关联出库单不是待出库状态,暂不可发车。"; + return null; + } + + 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 requireWarehouseOrder(String warehouseId, String dispatchNo) { + String boundWarehouseId = warehouse(warehouseId); + DispatchOrder order = orders.findGlobalByDispatchNo(required(dispatchNo, "Dispatch number is required")); + if (order == null || !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) { + throw notFound("Dispatch order not found in the bound warehouse"); + } + 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 requireTaskWarehouse(DispatchPdaTask task, String warehouseId) { + if (!warehouse(warehouseId).equals(trim(task.getWarehouseId()))) { + throw notFound("Dispatch PDA task not found in the bound warehouse"); + } + } + private static void bindOperator(DispatchPdaTask task, String operatorId, String operatorName) { + String normalizedOperator = required(operatorId, "PDA operator is required"); + if (trim(task.getOperatorId()) != null && !normalizedOperator.equals(trim(task.getOperatorId()))) { + throw conflict("Dispatch PDA task is already assigned to another operator"); + } + task.setOperatorId(normalizedOperator); + task.setOperatorName(trim(operatorName)); + } + 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; } + static String warehouseRouteLabel(String originalFbaWarehouseCode, String destinationWarehouseCode) { + String original = trim(originalFbaWarehouseCode); + String destination = trim(destinationWarehouseCode); + return original != null && destination != null && !original.equalsIgnoreCase(destination) + ? original + " -> " + destination : null; + } + 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..7eb300473 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -0,0 +1,119 @@ +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) { + return attribute(customerId, pick, null); + } + + /** + * Relabel inventory created before receipt-detail backfill may have no + * ll_inbound_stock_receipt row. Its dispatch item still carries the + * authoritative tail-leg order, so use that order only for relabel picks. + */ + public List attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId) { + 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 && fallbackOrderId != null && fallbackOrderId > 0) { + pickedByOrder.merge(fallbackOrderId, remainingPackages, Long::sum); + remainingPackages = 0; + } + if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders"); + 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.setCustomerId(customerId); + 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/DispatchBolPrintService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java new file mode 100644 index 000000000..4ec0823cd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java @@ -0,0 +1,144 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; +import com.baiyue.lastleg.client.PrintPdfClient; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import feign.FeignException; +import jakarta.annotation.Resource; +import java.math.BigDecimal; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +@Service +public class DispatchBolPrintService { + private static final String TEMPLATE = "DISPATCH_BILL_OF_LADING_A4_V2"; + private static final int FIRST_PAGE_ROWS = 18; + private static final int CONTINUATION_PAGE_ROWS = 26; + private static final PrintPdfClient.PageSpec A4 = new PrintPdfClient.PageSpec(new BigDecimal("210"), new BigDecimal("297")); + + @Resource private DispatchService dispatches; + @Resource private PrintPdfClient print; + @Resource private DomesticWarehouseDirectoryClient warehouses; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + + public GeneratedPdf generate(String dispatchNo, String requestId) { + DispatchResponse detail = dispatches.platformDetail(dispatchNo); + if (detail.bolLines() == null || detail.bolLines().isEmpty()) { + throw new BizException(ResultCode.VALIDATION_FAILED, "排车单没有可打印的BOL明细"); + } + List documents = documents(detail); + String fileName = safeFilePart(detail.dispatchNo()) + "-BOL.pdf"; + try { + ResponseEntity response = print.generateDispatchBol(internalToken, requestId, + new PrintPdfClient.Request(TEMPLATE, A4, fileName, documents)); + byte[] content = response.getBody(); + MediaType contentType = response.getHeaders().getContentType(); + if (!response.getStatusCode().is2xxSuccessful() || content == null || content.length == 0 + || contentType == null || !MediaType.APPLICATION_PDF.isCompatibleWith(contentType)) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务未返回有效PDF"); + } + return new GeneratedPdf(content, fileName, response.getHeaders().getFirst("X-Print-Page-Count")); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用"); + } + } + + private List documents(DispatchResponse detail) { + String shipFromAddress = warehouseAddress(detail.overseasWarehouseId()); + Map> groups = new LinkedHashMap<>(); + for (DispatchResponse.BolLine line : detail.bolLines()) groups.computeIfAbsent(text(line.fbaWarehouseCode()), ignored -> new ArrayList<>()).add(line); + List documents = new ArrayList<>(); + for (Map.Entry> group : groups.entrySet()) { + List lines = group.getValue(); + int pages = pageCount(lines.size()); + int total = lines.stream().mapToInt(DispatchResponse.BolLine::packageCount).sum(); + String destination = lines.stream().map(DispatchResponse.BolLine::destinationWarehouseName).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—"); + int offset = 0; + for (int page = 0; page < pages; page++) { + int capacity = page == 0 ? FIRST_PAGE_ROWS : CONTINUATION_PAGE_ROWS; + int end = Math.min(lines.size(), offset + capacity); + List rows = lines.subList(offset, end).stream() + .map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()), + text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList(); + documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()), + text(detail.fbaAppointmentNo()), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—", + group.getKey(), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false), + total, page + 1, pages, rows)); + offset = end; + } + } + return List.copyOf(documents); + } + + private String warehouseAddress(String warehouseId) { + long id; + try { + id = Long.parseLong(warehouseId == null ? "" : warehouseId.strip()); + } catch (NumberFormatException exception) { + throw new BizException(ResultCode.VALIDATION_FAILED, "排车单未关联有效的海外仓"); + } + try { + var response = warehouses.activeWarehouse(id, internalToken); + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,无法生成BOL"); + } + List addresses = response.data().addresses(); + if (addresses == null || addresses.isEmpty()) return "—"; + DomesticWarehouseDirectoryClient.Address address = addresses.stream().filter(Objects::nonNull) + .filter(value -> "OPERATING".equals(value.addressType())).findFirst() + .orElseGet(() -> addresses.stream().filter(Objects::nonNull).findFirst().orElse(null)); + if (address == null) return "—"; + List parts = new ArrayList<>(); + add(parts, address.addressLine1()); + add(parts, address.addressLine2()); + String city = trimmed(address.city()); + String state = trimmed(address.stateProvince()); + String postalCode = trimmed(address.postalCode()); + String region = city == null ? state : state == null ? city : city + ", " + state; + if (postalCode != null) region = region == null ? postalCode : region + " " + postalCode; + add(parts, region); + add(parts, address.countryCode()); + return parts.isEmpty() ? "—" : String.join(", ", parts); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,无法生成BOL"); + } + } + + private void add(List parts, String value) { + if (value != null && !value.isBlank()) parts.add(value.strip()); + } + + private String trimmed(String value) { + return value == null || value.isBlank() ? null : value.strip(); + } + + static int pageCount(int rowCount) { + if (rowCount <= FIRST_PAGE_ROWS) return 1; + return 1 + (rowCount - FIRST_PAGE_ROWS + CONTINUATION_PAGE_ROWS - 1) / CONTINUATION_PAGE_ROWS; + } + + private String unique(List lines, boolean shipment) { + LinkedHashSet values = new LinkedHashSet<>(); + for (DispatchResponse.BolLine line : lines) { + String raw = shipment ? line.fbaShipmentId() : line.poReferenceNo(); + if (raw != null) for (String value : raw.split(",")) if (!value.isBlank()) values.add(value.strip()); + } + return values.isEmpty() ? "—" : String.join(", ", values); + } + private String dateTime(java.time.LocalDateTime value) { return value == null ? "—" : value.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } + private String text(String value) { return value == null || value.isBlank() ? "—" : value.strip(); } + private String safeFilePart(String value) { return text(value).replaceAll("[\\\\/:*?\"<>|]", "-"); } + + public record GeneratedPdf(byte[] content, String fileName, String pageCount) {} +} 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..684d3a5e3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -0,0 +1,744 @@ +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.DispatchBolMapper; +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 com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +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 com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashSet; +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 DispatchBolMapper bolLines; + @Resource private DispatchPalletLockMapper locks; + @Resource private DispatchDraftMapper drafts; + @Resource private DispatchDraftContainerMapper draftContainers; + @Resource private DispatchCandidateMapper candidates; + @Resource private DispatchPdaTaskMapper pdaTasks; + @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private LastlegOrderMapper tailOrders; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private RelabelApplicationMapper relabelApplications; + + 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 String resolveCustomerScopeForRelabel(String relabelNo) { + String normalized = trimToNull(relabelNo); + RelabelApplication application = normalized == null ? null : relabelApplications.findAnyByRelabelNo(normalized); + if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "换标单不存在,请检查换标单号"); + if (!RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); + } + if (!"TRUCK".equals(application.getDeliveryMethod())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "该换标单不是卡车派送,无需进行排车"); + } + return application.getCustomerId(); + } + + 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); + List result = candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), + trimToNull(sourceStage), trimToNull(draftToken)); + if (!StringUtils.hasText(sourceStage) || "RECEIVED".equals(sourceStage)) { + List relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null); + if (relabel != null && !relabel.isEmpty()) { + result = java.util.stream.Stream.concat(result.stream(), relabel.stream()).toList(); + } + } + return result; + } + 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.findActiveByDraftContainer(token, c.getId()).stream() + .map(x -> String.valueOf(x.getPlanPalletId())).toList())).toList(); + } + + @Transactional + public DispatchContainerResponse createDraftContainer(String operatorId, DispatchRequests.DraftContainer request) { + if (!Set.of(15, 30).contains(request.capacity())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "车辆大小仅支持15托或30托"); + } + 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()); + if (active.getDispatchOrderId() != null) { + DispatchOrder dispatch = orders.lockById(customerId, active.getDispatchOrderId()); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + requirePalletEditingAllowed(dispatch); + DispatchItem item = items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId()); + if (item != null) removeLockedItem(dispatch, item, List.of(active)); + else releaseLock(active, LocalDateTime.now()); + return; + } + 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") + .isNull(DispatchPalletLock::getDispatchOrderId)); + 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(request.relabelNo())) { + return preArrangeRelabel(customerId, operatorId, 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"); + } + assertNoDetainedOrders(customerId, selected); + boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) + .anyMatch(value -> value == null || value.isBlank()); + if (missingWarehouse) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "所选托盘中存在尚未关联海外仓的托盘"); + } + Set warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) + .map(String::trim).collect(Collectors.toSet()); + if (warehouseIds.size() != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "一次排车只能包含同一海外仓的托盘"); + } + 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(); + Long existingDispatchId = draftToken == null ? null : locks.findDispatchOrderIdByDraft(draftToken); + DispatchOrder dispatch = existingDispatchId == null ? null : orders.lockById(customerId, existingDispatchId); + boolean refreshing = dispatch != null; + if (refreshing) { + requirePalletEditingAllowed(dispatch); + } else { + dispatch = new DispatchOrder(); + dispatch.setId(IdWorker.getId()); + dispatch.setCustomerId(customerId); + dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", selected.get(0).overseasWarehouseId(), dispatch.getId())); + dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId()); + dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); + dispatch.setStatus(PENDING_DEPARTURE); + dispatch.setPickedPackageCount(0); + dispatch.setShippedPackageCount(0); + dispatch.setCreatedBy(trimToNull(operatorId)); + dispatch.setCreatedAt(now); + } + dispatch.setRoute(candidateRoute(selected)); + dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); + dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); + dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); + dispatch.setItemCount(selected.size()); + dispatch.setPalletCount(selected.size()); + dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + dispatch.setUpdatedAt(now); + if (refreshing) orders.updateById(dispatch); else orders.insert(dispatch); + + List previousItems = items.findByDispatchOrder(customerId, dispatch.getId()); + Set selectedPalletIds = selected.stream().map(DispatchPalletCandidate::planPalletId) + .collect(Collectors.toSet()); + if (refreshing) { + List previousLocks = locks.selectList(new LambdaQueryWrapper() + .eq(DispatchPalletLock::getCustomerId, customerId) + .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) + .eq(DispatchPalletLock::getStatus, "ACTIVE")); + previousItems.stream().filter(item -> item.getPlanPalletId() != null + && !selectedPalletIds.contains(item.getPlanPalletId())) + .forEach(item -> { + previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId())) + .forEach(lock -> releaseLock(lock, now)); + items.deleteById(item.getId()); + }); + } + Set existingPalletIds = previousItems.stream() + .filter(item -> item.getPlanPalletId() == null || selectedPalletIds.contains(item.getPlanPalletId())) + .map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).collect(Collectors.toSet()); + + for (DispatchPalletCandidate candidate : selected) { + DispatchPalletLock lock = activeLock(customerId, candidate); + if (draftToken != null) { + requireDraftOwner(lock, operatorId, draftToken); + lock.setDispatchOrderId(dispatch.getId()); + 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); + } + } + if (existingPalletIds.contains(candidate.planPalletId())) continue; + DispatchItem item = new DispatchItem(); + item.setId(IdWorker.getId()); + item.setDispatchOrderId(dispatch.getId()); + item.setCustomerId(customerId); + item.setPlanId(candidate.planId()); + item.setPlanPalletId(candidate.planPalletId()); + item.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.setCartonMarks(mergeCartonMarks(candidate.cartonMarks(), candidate.relabelCartonMarks())); + item.setCreatedAt(now); + items.insert(item); + } + if (!refreshing) { + DispatchPdaTask pdaTask = new DispatchPdaTask(); + pdaTask.setId(IdWorker.getId()); + pdaTask.setCustomerId(customerId); + pdaTask.setDispatchOrderId(dispatch.getId()); + pdaTask.setTaskNo("DPDA" + pdaTask.getId()); + pdaTask.setStatus("PDA_PENDING"); + pdaTask.setWarehouseId(dispatch.getOverseasWarehouseId()); + pdaTask.setPushedAt(now); + pdaTask.setCreatedAt(now); + pdaTask.setUpdatedAt(now); + pdaTasks.insert(pdaTask); + } + if (draftToken != null) { + DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId)); + if (draft != null) { + draft.setExpiresAt(null); + draft.setUpdatedAt(now); + drafts.updateById(draft); + } + } + return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + } + + @Transactional + private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) { + RelabelApplication application = relabelApplications.findNo(customerId, request.relabelNo()); + if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); + } + List selected = candidates.relabelAvailable(customerId, null, request.relabelNo()); + Set requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet()); + selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList(); + if (selected.isEmpty() || selected.size() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试"); + String warehouse = selected.get(0).overseasWarehouseId(); + if (selected.stream().anyMatch(c -> !Objects.equals(warehouse, c.overseasWarehouseId()))) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单只能选择同一海外仓"); + LocalDateTime now = LocalDateTime.now(); + DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); + dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); + dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch); + for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } + DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); + if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); + return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + } + + private void assertNoDetainedOrders(String customerId, List selected) { + List orderIds = selected.stream().map(DispatchPalletCandidate::orderId) + .filter(Objects::nonNull).distinct().sorted().toList(); + if (orderIds.isEmpty()) return; + List scoped = tailOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .in(LastlegOrder::getId, orderIds) + .orderByAsc(LastlegOrder::getId) + .last("FOR UPDATE")); + List detainedNos = scoped.stream().filter(order -> Boolean.TRUE.equals(order.getDetained())) + .map(LastlegOrder::getOrderNo).filter(StringUtils::hasText).distinct().toList(); + if (!detainedNos.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "暂扣订单不可创建排车或出库计划:" + String.join("、", detainedNos)); + } + } + + 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()), + bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); + } + + public DispatchResponse platformDetail(String dispatchNo) { + DispatchOrder dispatch = orders.findGlobalByDispatchNo(trimToNull(dispatchNo)); + if (dispatch == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + } + return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()), + bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); + } + + @Transactional + public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) { + DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo)); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + if (!PENDING_DEPARTURE.equals(dispatch.getStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Only a pending dispatch can be edited"); + } + String customerId = dispatch.getCustomerId(); + List assignedItems = items.findByDispatchOrder(customerId, dispatch.getId()); + dispatch.setVehiclePlate(trimToNull(request.vehiclePlate())); + dispatch.setDriverName(trimToNull(request.driverName())); + dispatch.setDriverPhone(trimToNull(request.driverPhone())); + dispatch.setCarrierName(trimToNull(request.carrierName())); + dispatch.setRoute(itemRoute(assignedItems)); + dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); + dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); + dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); + dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt()); + dispatch.setUpdatedAt(LocalDateTime.now()); + orders.updateById(dispatch); + return response(dispatch, assignedItems); + } + + @Transactional + public DispatchResponse removeItem(String dispatchNo, Long itemId) { + DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo)); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + requirePalletEditingAllowed(dispatch); + DispatchItem item = items.lockById(dispatch.getCustomerId(), dispatch.getId(), itemId); + if (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found"); + + List activeLocks = locks.selectList(new LambdaQueryWrapper() + .eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId()) + .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) + .eq(DispatchPalletLock::getStatus, "ACTIVE")); + List matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) + || (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId()))) + .toList(); + return removeLockedItem(dispatch, item, matchingLocks); + } + + private DispatchResponse removeLockedItem(DispatchOrder dispatch, DispatchItem item, + List matchingLocks) { + LocalDateTime now = LocalDateTime.now(); + matchingLocks.forEach(lock -> releaseLock(lock, now)); + items.deleteById(item.getId()); + + List remaining = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()); + dispatch.setItemCount(remaining.size()); + dispatch.setPalletCount(remaining.size()); + dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) + .mapToInt(Integer::intValue).sum()); + dispatch.setRoute(itemRoute(remaining)); + dispatch.setUpdatedAt(now); + orders.updateById(dispatch); + return response(dispatch, remaining); + } + + private void requirePalletEditingAllowed(DispatchOrder dispatch) { + if (!PENDING_DEPARTURE.equals(dispatch.getStatus()) + || Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Only an unpicked pending dispatch can edit pallets"); + } + if (outboundOrders.findByDispatch(dispatch.getCustomerId(), dispatch.getId()) != null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,不能再修改排车托盘"); + } + } + + @Transactional + public void cancel(String dispatchNo) { + DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo)); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + if (CANCELLED.equals(dispatch.getStatus())) return; + if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled"); + } + String customerId = dispatch.getCustomerId(); + LocalDateTime now = LocalDateTime.now(); + dispatch.setStatus(CANCELLED); + dispatch.setUpdatedAt(now); + orders.updateById(dispatch); + items.findByDispatchOrder(customerId, dispatch.getId()).stream() + .filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) + .map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct() + .forEach(relabelNo -> candidates.restoreRelabelPreDispatch(customerId, relabelNo)); + 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) { + return response(order, itemList, List.of()); + } + + private DispatchResponse response(DispatchOrder order, List itemList, + List bolLineList) { + 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, itemRoute(itemList), + 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(), + bolLineList == null ? List.of() : bolLineList); + } + + private static String candidateRoute(List selected) { + return selected.stream().map(DispatchService::candidateFbaCode).filter(StringUtils::hasText) + .distinct().collect(Collectors.joining(",")); + } + + private static String itemRoute(List assignedItems) { + return assignedItems.stream().map(DispatchService::itemFbaCode).filter(StringUtils::hasText) + .distinct().collect(Collectors.joining(",")); + } + + private static String candidateFbaCode(DispatchPalletCandidate candidate) { + return StringUtils.hasText(candidate.fbaWarehouseCode()) + ? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode()); + } + + private static String itemFbaCode(DispatchItem item) { + return StringUtils.hasText(item.getFbaWarehouseCode()) + ? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode()); + } + + 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 && candidate.planPalletId() != null) { + // Read legacy plan-level locks created before physical-pallet locking was introduced. + active = locks.lockActive(customerId, "PLAN:" + candidate.planPalletId()); + } + 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 String mergeCartonMarks(String regularMarks, String relabelMarks) { + LinkedHashSet values = new LinkedHashSet<>(); + for (String source : List.of(Objects.toString(regularMarks, ""), Objects.toString(relabelMarks, ""))) { + Arrays.stream(source.split(",")).map(String::trim).filter(StringUtils::hasText).forEach(values::add); + } + return values.isEmpty() ? null : String.join(",", values); + } + + 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/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..cd3f0110f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java @@ -0,0 +1,20 @@ +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, + @NotNull @Positive Integer manifestVersion, + @NotNull @Positive Integer manifestPackageCount, + @NotNull @Positive Integer manifestFragmentCount, + @NotBlank @Size(max = 64) String manifestHash) {} + public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount, + @Positive long productQuantity, + @NotEmpty List<@NotBlank String> cartonMarks, + @NotEmpty java.util.Map<@NotBlank String, @Positive Long> cartonQuantities) {} +} 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..1180c36c0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.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") +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 Long productQuantity; + 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/DropshipCartonSku.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonSku.java new file mode 100644 index 000000000..58b581862 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonSku.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dropship.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; + +/** SKU facts inside one physical dropship carton. */ +@Data +@TableName("ll_dropship_carton_sku") +public class DropshipCartonSku { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long orderId; + private Long profileId; + private Long cartonId; + private String skuCode; + private String productName; + private Long productQuantity; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} 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..c91c0aff7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java @@ -0,0 +1,26 @@ +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 Integer manifestVersion; + private Integer manifestPackageCount; + private Integer manifestFragmentCount; + private String manifestHash; + 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..9e714a220 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonLedgerMapper.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.dropship.mapper; + +import org.apache.ibatis.annotations.Mapper; +import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +@Mapper +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..9e58d1a5d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.dropship.mapper; + +import org.apache.ibatis.annotations.Mapper; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +import java.util.List; +@Mapper +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} AND carton_mark=#{cartonMark} LIMIT 1 FOR UPDATE") + DropshipCarton findByProfileAndMark(@Param("customerId") String customerId, @Param("profileId") Long profileId, @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); + @Select(""" + SELECT DISTINCT p.source_pallet_no FROM ll_dropship_carton c + JOIN ll_dropship_order_profile p ON p.id=c.profile_id AND p.customer_id=c.customer_id + JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id + WHERE o.container_no=#{containerNo} AND c.carton_mark=#{cartonMark} + """) + List sourcePalletsInContainer(@Param("containerNo") String containerNo, + @Param("cartonMark") String cartonMark); + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND status='AVAILABLE' ORDER BY carton_sequence LIMIT #{packages} FOR UPDATE") + List lockAvailable(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages); + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND status='AVAILABLE' ORDER BY carton_sequence LIMIT #{packages} FOR UPDATE") + List lockAvailablePhysical(@Param("customerId") String customerId, + @Param("profileId") Long profileId, + @Param("packages") long packages); + @Update("UPDATE ll_dropship_carton SET status='AVAILABLE', outbound_order_no=NULL, updated_at=NOW(3) WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} AND status='SHIPPED'") + int restoreOutbound(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonSkuMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonSkuMapper.java new file mode 100644 index 000000000..9c20f6039 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonSkuMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dropship.mapper; + +import com.baiyue.lastleg.dropship.entity.DropshipCartonSku; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; + +@Mapper +public interface DropshipCartonSkuMapper extends BaseMapper { + @Select("SELECT * FROM ll_dropship_carton_sku WHERE customer_id=#{customerId} AND carton_id=#{cartonId} ORDER BY id") + List listCarton(@Param("customerId") String customerId, + @Param("cartonId") Long cartonId); +} 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..6f434b5d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.dropship.mapper; + +import org.apache.ibatis.annotations.Mapper; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +import java.util.List; +@Mapper +public interface DropshipOrderProfileMapper extends BaseMapper { + String OUTBOUND_CANDIDATE_SELECT = "SELECT pos.id inventory_position_id,p.id profile_id," + + "p.warehouse_pallet_id,pos.overseas_warehouse_id,b.overseas_warehouse_name," + + "p.pallet_no,b.inbound_code,o.order_no,o.customer_id,o.customer_name," + + "(SELECT CASE WHEN COUNT(*)=1 THEN MAX(s.sku_code) ELSE 'MIXED' END FROM ll_dropship_sku_inventory s " + + "WHERE s.customer_id=p.customer_id AND s.profile_id=p.id) sku_code," + + "(SELECT CASE WHEN COUNT(*)=1 THEN MAX(s.product_name) ELSE '混合SKU' END FROM ll_dropship_sku_inventory s " + + "WHERE s.customer_id=p.customer_id AND s.profile_id=p.id) product_name," + + "LEAST(pos.available_packages,(SELECT COUNT(*) FROM ll_dropship_carton c " + + "WHERE c.customer_id=p.customer_id AND c.profile_id=p.id " + + "AND c.status='AVAILABLE')) available_packages FROM ll_dropship_order_profile p " + + "JOIN ll_order o ON o.id=p.order_id AND o.customer_id=p.customer_id " + + "AND o.order_type='ONE_CLICK_DROPSHIP' AND COALESCE(o.detained,0)=0 " + + "JOIN ll_inventory_position pos ON pos.id=p.inventory_position_id AND pos.customer_id=p.customer_id " + + "AND pos.pallet_id=p.warehouse_pallet_id " + + "JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + + ""; + String OUTBOUND_CANDIDATE_WHERE = "WHERE (#{customerId} IS NULL OR #{customerId}='' OR p.customer_id=#{customerId}) AND p.status='ACTIVE' " + + "AND pos.status='OCCUPIED' AND pos.available_packages>0 " + + "AND EXISTS (SELECT 1 FROM ll_dropship_carton c WHERE c.customer_id=p.customer_id " + + "AND c.profile_id=p.id AND c.status='AVAILABLE') " + + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock dl WHERE dl.customer_id=p.customer_id " + + "AND dl.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id) AND dl.status='ACTIVE' " + + "AND (dl.expires_at IS NULL OR dl.expires_at>CURRENT_TIMESTAMP(3))) " + + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + + "WHERE ta.customer_id=p.customer_id AND ta.inventory_position_id=pos.id AND ta.status='RESERVED') " + + "AND NOT EXISTS (SELECT 1 FROM ll_outbound_pallet_item oi JOIN ll_outbound_order oo " + + "ON oo.id=oi.outbound_order_id AND oo.customer_id=oi.customer_id " + + "WHERE oi.customer_id=p.customer_id AND oi.warehouse_pallet_id=p.warehouse_pallet_id " + + "AND oi.inbound_code=b.inbound_code AND oo.status IN ('WAIT_PICK','PICKING','PICKED')) "; + + @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); + @Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} LIMIT 1") + DropshipOrderProfile findPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); + @Select(""" + SELECT p.* FROM ll_dropship_order_profile p + JOIN ll_order o ON o.id=p.order_id AND o.customer_id=p.customer_id + WHERE o.container_no=#{containerNo} AND p.source_pallet_no=#{sourcePalletNo} + ORDER BY p.customer_id,p.order_id,p.id + """) + List listContainerPallet(@Param("containerNo") String containerNo, + @Param("sourcePalletNo") String sourcePalletNo); + + @Select("") + List listOutboundCandidates(@Param("customerId") String customerId, + @Param("warehouseId") String warehouseId); + + @Select(OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE + + "AND pos.id=#{positionId} " + + "AND #{skuCode}=(SELECT CASE WHEN COUNT(*)=1 THEN MAX(s.sku_code) ELSE 'MIXED' END " + + "FROM ll_dropship_sku_inventory s WHERE s.customer_id=p.customer_id AND s.profile_id=p.id) " + + "LIMIT 1 FOR UPDATE") + DropshipOutboundCandidate lockOutboundCandidate(@Param("customerId") String customerId, + @Param("positionId") Long positionId, + @Param("skuCode") String skuCode); +} 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..65ad92d87 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.dropship.mapper; + +import org.apache.ibatis.annotations.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 org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +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); + @Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},product_quantity=product_quantity-#{quantity},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND package_count>=#{packages} AND product_quantity>=#{quantity}") + int deduct(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages, @Param("quantity") long quantity); + @Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count+#{packages},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku}") + int restore(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipOutboundCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipOutboundCandidate.java new file mode 100644 index 000000000..400325958 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipOutboundCandidate.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.dropship.model; + +import lombok.Data; + +/** Server-authoritative one-click dropship inventory dimension eligible for outbound creation. */ +@Data +public class DropshipOutboundCandidate { + private Long inventoryPositionId; + private Long profileId; + private Long warehousePalletId; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String palletNo; + private String inboundCode; + private String orderNo; + private String customerId; + private String customerName; + private String skuCode; + private String productName; + private Long availablePackages; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipPalletSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipPalletSnapshot.java new file mode 100644 index 000000000..105ae33f1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipPalletSnapshot.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.dropship.model; + +import java.util.List; + +public record DropshipPalletSnapshot(String sourcePalletNo, long packageCount, + List skus) { + public record Sku(String skuCode, String productName, long packageCount, long productQuantity) {} +} 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..d3d30e429 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java @@ -0,0 +1,251 @@ +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 com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; +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 DropshipCartonSkuMapper cartonSkus; + @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"); + validateMarks(request); + long totalPackages = request.pallets().stream() + .mapToLong(DropshipRequests.Pallet::manifestPackageCount).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"); + Map byPallet = existing.stream().collect(Collectors.toMap( + DropshipOrderProfile::getSourcePalletNo, value -> value)); + for (DropshipRequests.Pallet requested : request.pallets()) { + DropshipOrderProfile stored = byPallet.get(requested.palletNo().trim()); + if (!java.util.Objects.equals(stored.getManifestVersion(), requested.manifestVersion()) + || !java.util.Objects.equals(stored.getManifestPackageCount(), requested.manifestPackageCount()) + || !java.util.Objects.equals(stored.getManifestFragmentCount(), requested.manifestFragmentCount()) + || !java.util.Objects.equals(stored.getManifestHash(), requested.manifestHash())) { + throw conflict("Dropship pallet manifest conflicts with existing data: " + requested.palletNo()); + } + } + return Math.toIntExact(totalPackages); + } + + LocalDateTime now = LocalDateTime.now(); + for (DropshipRequests.Pallet inputPallet : request.pallets()) { + DropshipOrderProfile profile = profile(request, order, inputPallet, now); + profiles.insert(profile); + Map> factsByMark = new java.util.LinkedHashMap<>(); + 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()) { + factsByMark.computeIfAbsent(mark.trim(), ignored -> new java.util.ArrayList<>()) + .add(new CartonSkuFact(input.skuCode().trim(), input.productName(), + input.cartonQuantities().get(mark))); + } + } + int sequence = 0; + for (Map.Entry> entry : factsByMark.entrySet()) { + List facts = entry.getValue(); + 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(facts.size() == 1 ? facts.get(0).skuCode() : "MIXED"); + carton.setCartonMark(entry.getKey()); carton.setCartonSequence(++sequence); + carton.setProductQuantity(facts.stream().mapToLong(CartonSkuFact::quantity).sum()); + carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now); + cartons.insert(carton); + for (CartonSkuFact fact : facts) { + DropshipCartonSku sku = new DropshipCartonSku(); + sku.setId(IdWorker.getId()); sku.setCustomerId(request.customerId()); sku.setOrderId(order.getId()); + sku.setProfileId(profile.getId()); sku.setCartonId(carton.getId()); sku.setSkuCode(fact.skuCode()); + sku.setProductName(fact.productName()); sku.setProductQuantity(fact.quantity()); + sku.setCreatedAt(now); sku.setUpdatedAt(now); cartonSkus.insert(sku); + } + ledger.insert(stockInFact(request, order, carton, now)); + } + } + return Math.toIntExact(totalPackages); + } + + private void validateMarks(DropshipRequests.Receive request) { + Map palletByMark = new java.util.HashMap<>(); + Set palletNos = new HashSet<>(); + for (DropshipRequests.Pallet pallet : request.pallets()) { + String palletNo = pallet.palletNo().trim(); + if (!palletNos.add(palletNo)) throw conflict("Duplicate pallet number: " + pallet.palletNo()); + Set skus = new HashSet<>(); + Set physicalMarks = 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 (item.cartonQuantities() == null + || !item.cartonQuantities().keySet().equals(new java.util.HashSet<>(item.cartonMarks())) + || item.cartonQuantities().values().stream().anyMatch(value -> value == null || value <= 0) + || item.cartonQuantities().values().stream().mapToLong(Long::longValue).sum() != item.productQuantity()) { + throw conflict("逐箱实收件数必须完整且与 SKU 汇总件数一致"); + } + if (!skus.add(item.skuCode().trim())) throw conflict("Duplicate SKU on pallet: " + item.skuCode()); + Set skuMarks = new HashSet<>(); + for (String mark : item.cartonMarks()) { + String normalized = mark.trim(); + if (!skuMarks.add(normalized)) throw conflict("Duplicate carton mark for SKU: " + mark); + String existingPallet = palletByMark.putIfAbsent(normalized, palletNo); + if (existingPallet != null && !existingPallet.equals(palletNo)) { + throw conflict("Carton mark belongs to multiple pallets: " + mark); + } + physicalMarks.add(normalized); + } + } + if (physicalMarks.size() != pallet.manifestPackageCount()) { + throw conflict("Pallet physical carton total does not equal manifest package count: " + palletNo); + } + } + } + + 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.setManifestVersion(input.manifestVersion()); + profile.setManifestPackageCount(input.manifestPackageCount()); + profile.setManifestFragmentCount(input.manifestFragmentCount()); + profile.setManifestHash(input.manifestHash().trim().toLowerCase(java.util.Locale.ROOT)); + profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L); + profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim()); + profile.setCreatedAt(now); profile.setUpdatedAt(now); + return profile; + } + + @Transactional(readOnly = true) + public List palletSnapshots(String customerId, Long orderId) { + List orderProfiles = profiles.listOrder(customerId, orderId); + Map> skusByProfile = skuInventory.listOrder(customerId, orderId).stream() + .collect(Collectors.groupingBy(DropshipSkuInventory::getProfileId)); + return orderProfiles.stream().map(profile -> { + List skus = skusByProfile.getOrDefault(profile.getId(), List.of()).stream() + .map(sku -> new DropshipPalletSnapshot.Sku(sku.getSkuCode(), sku.getProductName(), + value(sku.getPackageCount()), value(sku.getProductQuantity()))) + .toList(); + return new DropshipPalletSnapshot(profile.getSourcePalletNo(), + value(profile.getManifestPackageCount()), skus); + }).toList(); + } + + @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.getSourcePalletNo() != null) + .collect(Collectors.groupingBy(InboundStockReceipt::getSourcePalletNo)); + if (receiptsByPallet.size() != profilesForOrder.size()) { + throw conflict("PDA pallet count does not match dropship source pallet count"); + } + LocalDateTime now = LocalDateTime.now(); + for (DropshipOrderProfile profile : profilesForOrder) { + List actual = receiptsByPallet.get(profile.getSourcePalletNo()); + if (actual == null || actual.isEmpty()) { + throw conflict("PDA receipt is missing for dropship source pallet " + profile.getSourcePalletNo()); + } + 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 long value(Integer value) { return value == null ? 0L : value.longValue(); } + + private record CartonSkuFact(String skuCode, String productName, long quantity) {} + + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java new file mode 100644 index 000000000..a8071e88b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.dto; + +import java.time.LocalDateTime; + +/** 平台柜子工作台使用的整柜到仓时间事实。 */ +public record ContainerArrivalTimeView( + String sourceContainerBookingId, + LocalDateTime estimatedWarehouseArrivalAt, + LocalDateTime actualWarehouseArrivalAt, + int version +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressDetail.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressDetail.java new file mode 100644 index 000000000..4c97daede --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressDetail.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.dto; + +import java.time.LocalDateTime; +import java.util.List; + +/** 柜级摘要及柜内全部尾程订单、入仓码和计划状态。 */ +public record ContainerPlanProgressDetail( + ContainerPlanProgressSummary summary, + List orders +) { + public ContainerPlanProgressDetail { + orders = orders == null ? List.of() : List.copyOf(orders); + } + + public record Item( + Long orderId, + String customerId, + String orderNo, + String inboundCode, + String orderStatus, + Long planId, + String planNo, + String planStatus, + boolean planValid, + LocalDateTime plannedArrivalAt + ) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java new file mode 100644 index 000000000..5debb2aab --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.dto; + +import java.time.LocalDateTime; + +/** 平台按稳定来源柜查询的入库计划完整度事实。 */ +public record ContainerPlanProgressSummary( + String sourceContainerBookingId, + String containerNo, + String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + int assignmentVersion, + boolean canChangeReceivingWarehouse, + String receivingWarehouseLockReason, + long totalOrderCount, + long inboundCodeCount, + long missingInboundCodeCount, + long planCount, + long plannedInboundCodeCount, + long pendingPlanCount, + long resourcePendingPlanCount, + LocalDateTime plannedArrivalAt, + boolean allPlansReady, + String blockingReason +) { + public ContainerPlanProgressSummary(String sourceContainerBookingId, String containerNo, + long totalOrderCount, long inboundCodeCount, + long missingInboundCodeCount, long planCount, + long plannedInboundCodeCount, long pendingPlanCount, + LocalDateTime plannedArrivalAt, boolean allPlansReady, + String blockingReason) { + this(sourceContainerBookingId, containerNo, null, null, null, -1, false, null, + totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, + plannedInboundCodeCount, pendingPlanCount, 0, plannedArrivalAt, allPlansReady, blockingReason); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java new file mode 100644 index 000000000..d6e4a546f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Min; +import java.time.LocalDateTime; +import java.util.List; + +public final class LastlegOrderCommandDtos { + private LastlegOrderCommandDtos() { } + + public record HoldRequest(@NotNull Boolean held, @NotNull Boolean chargeable, + @NotBlank @Size(max = 500) String reason, + @NotNull @Min(0) Integer expectedVersion) { } + public record HoldResponse(long orderId, boolean held, boolean chargeable, String reason, int version) { } + + public record ManualNode(@NotBlank @Size(max = 32) String nodeCode, + LocalDateTime nodeAt) { } + public record ManualNodesRequest(@NotNull @Min(0) Integer expectedVersion, + @NotEmpty @Size(min = 8, max = 8) List<@Valid ManualNode> nodes) { } + public record ManualNodesResponse(long orderId, boolean manualOverrideEnabled, + List nodes, int version) { } + public record ManualNodeView(String nodeCode, LocalDateTime nodeAt, String source) { } +} 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..60b27bdef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.dto; + +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; + +import java.time.LocalDateTime; + +import java.util.List; + +public record LastlegOrderDetailResponse( + LastlegOrderSummary order, + List manualNodes, + List cargo, + List statusHistory, + List fees, + List operationHistory +) { + public record History(Long id, String fromStatus, String toStatus, String remark, LocalDateTime createdAt) {} + public record Operation(Long id, String operationNode, String operatorName, String result, 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..b24fb456b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -0,0 +1,133 @@ +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 jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.LocalDate; +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 lxCode, + String sourceOrderNo, + String containerNo, + String currentStatus, + String status, + String warehouse, + String transferWarehouseCode, + Boolean abnormal, + Boolean detained, + LocalDateTime createdFrom, + LocalDateTime createdTo + ) { + public Query { + if (page == null || page == 0) page = 1L; + if (size == null || size == 0) size = 20L; + } + } + + public record ContainerProgressQuery( + @Min(1) long page, + @Min(1) @Max(200) long size, + String containerNo, + LocalDateTime createdFrom, + LocalDateTime createdTo + ) { + public ContainerProgressQuery { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } + + public record ChangeReceivingWarehouse( + @NotNull Long overseasWarehouseId, + @NotNull @Min(0) Integer expectedVersion, + @Size(max = 500) String remark + ) { } + + public record ContainerArrivalTimeQuery( + @NotEmpty @Size(max = 200) List<@NotBlank @Size(max = 128) String> sourceContainerBookingIds + ) { } + + public record ChangeExpectedWarehouseArrival( + @NotNull LocalDateTime estimatedWarehouseArrivalAt, + @NotNull @Min(0) Integer expectedVersion, + @Size(max = 500) String remark + ) { } + + 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); + } + } + + public record CreateFee( + @NotBlank @Size(max = 64) String feeItemCode, + @NotBlank @Size(max = 200) String feeItemName, + @NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction, + @NotBlank @Pattern(regexp = "CNY|USD") String currency, + @NotNull @DecimalMin(value = "0.01") BigDecimal amount, + @NotNull LocalDate businessDate, + @Min(1) Long supplierId, + @Size(max = 200) String supplierName, + @Size(max = 32) String supplierCategory, + @Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode, + LocalDateTime paidAt, + @Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod, + @Size(max = 128) String paymentReferenceNo, + @Size(max = 20) List<@NotBlank String> paymentVoucherFileIds, + @DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity, + @Size(max = 32) String quantityUnit, + @DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice, + LocalDate periodStart, + LocalDate periodEnd + ) { + } + + public record SetDetained( + @NotNull Boolean detained, + @NotBlank @Size(max = 500) String remark + ) { + } + + public record SetPalletMergeRequired( + @NotNull Boolean palletMergeRequired + ) { + } + + public record SetPalletMergeBatch( + @NotEmpty @Size(max = 200) List orderIds, + @NotNull Boolean palletMergeRequired + ) { + } +} 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..1fb910d2b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "按入仓单编码粒度展示的尾程订单列表行") +public record LastlegOrderSummary( + Long id, + String orderNo, + String orderType, + String orderTypeName, + @Schema(description = "跨区冻结目的类型", allowableValues = {"FBA", "OVERSEAS"}) + String destinationType, + String sourceInboundId, + @Schema(description = "真实来源入仓码;无入仓关系的海外本土业务为空") + String inboundCode, + String lxCode, + String sourceOrderNo, + String customerId, + String customerName, + @Schema(description = "头程与尾程共享的稳定柜身份;用于加载整柜入库计划预览") + String sourceContainerBookingId, + String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + String transferWarehouseId, + String transferWarehouseCode, + String transferWarehouseName, + String fbaWarehouseCode, + String containerNo, + @Schema(description = "由柜级船节点门禁读取的最新标准货柜节点") + String containerNode, + Boolean abnormal, + Boolean detained, + Boolean heldChargeable, + String holdReason, + boolean manualOverrideEnabled, + String nodeSource, + int version, + Integer palletCount, + Integer packageCount, + BigDecimal volumeCbm, + Integer productQuantity, + BigDecimal volumeWeightKg, + LocalDateTime estimatedArrivalAt, + LocalDateTime actualArrivalAt, + LocalDateTime estimatedWarehouseAt, + String inboundOrderNo, + @Schema(description = "排车状态", allowableValues = {"SCHEDULED", "UNSCHEDULED"}) + String dispatchStatus, + String relabelOrderNo, + String relabelStatus, + String palletMergeFlag, + String palletMergeBatchId, + @Schema(description = "同一合托组内其他来源入仓单的入仓码") + List palletMergeInboundNos, + 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/ContainerArrivalTimeOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ContainerArrivalTimeOperation.java new file mode 100644 index 000000000..8de5ca798 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ContainerArrivalTimeOperation.java @@ -0,0 +1,21 @@ +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_container_arrival_time_operation") +public class ContainerArrivalTimeOperation { + @TableId(type = IdType.INPUT) private Long id; + private String sourceContainerBookingId; + private String idempotencyKey; + private String requestHash; + private Integer resultVersion; + private String operatorName; + private String remark; + private LocalDateTime createdAt; +} 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..0e00e7a44 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java @@ -0,0 +1,44 @@ +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 lxCode; + 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..b56ab8473 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -0,0 +1,76 @@ +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 lxCode; + 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 overseasWarehouseCode; + private String overseasWarehouseName; + private Integer receivingWarehouseVersion; + private String fbaWarehouseCode; + private String containerNo; + private String transferWarehouseId; + private String transferWarehouseCode; + private String transferWarehouseName; + private Boolean abnormal; + private Boolean detained; + private Boolean holdChargeable; + private String holdReason; + private Boolean manualNodeOverride; + private Integer version; + 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 Integer arrivalTimeVersion; + private String inboundOrderNo; + private String outboundOrderNo; + private String deliveryOrderNo; + private String relabelOrderNo; + private String relabelStatus; + private String palletMergeFlag; + private String palletMergeBatchId; + 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/LastlegOrderCommandRecord.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java new file mode 100644 index 000000000..9f026da84 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java @@ -0,0 +1,23 @@ +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_order_command_record") +public class LastlegOrderCommandRecord { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String action; + private String operatorId; + private String idempotencyKey; + private String requestHash; + private String resultRef; + private String resultJson; + private Integer resultVersion; + private LocalDateTime createdAt; +} 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/LastlegOrderHoldHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java new file mode 100644 index 000000000..2abb9b1c9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java @@ -0,0 +1,23 @@ +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_order_hold_history") +public class LastlegOrderHoldHistory { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private Boolean held; + private Boolean chargeable; + private String reason; + private Integer orderVersion; + private String operatorId; + private String operatorName; + private String idempotencyKey; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java new file mode 100644 index 000000000..323fc685c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java @@ -0,0 +1,21 @@ +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_order_manual_node") +public class LastlegOrderManualNode { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String nodeCode; + private LocalDateTime nodeAt; + private String source; + private Integer orderVersion; + private String updatedBy; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java new file mode 100644 index 000000000..cc3ec6899 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java @@ -0,0 +1,21 @@ +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_order_manual_node_history") +public class LastlegOrderManualNodeHistory { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String nodesJson; + private Integer orderVersion; + private String operatorId; + private String operatorName; + private String idempotencyKey; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderOperationLog.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderOperationLog.java new file mode 100644 index 000000000..db8a7b860 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderOperationLog.java @@ -0,0 +1,20 @@ +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_order_operation_log") +public class LastlegOrderOperationLog { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String operationNode; + private String operatorName; + private String result; + private LocalDateTime createdAt; +} 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/ReceivingWarehouseOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ReceivingWarehouseOperation.java new file mode 100644 index 000000000..22b3c0117 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ReceivingWarehouseOperation.java @@ -0,0 +1,21 @@ +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_receiving_warehouse_operation") +public class ReceivingWarehouseOperation { + @TableId(type = IdType.INPUT) private Long id; + private String sourceContainerBookingId; + private String idempotencyKey; + private String requestHash; + private Integer resultVersion; + private String operatorName; + 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/exceptionorder/controller/ExceptionOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java new file mode 100644 index 000000000..718e71837 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java @@ -0,0 +1,127 @@ +package com.baiyue.lastleg.exceptionorder.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.exceptionorder.dto.ExceptionOrderRequests; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses; +import com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService; +import com.baiyue.lastleg.security.OverseasPermissions; +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.*; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg") +public class ExceptionOrderController { + @Resource private ExceptionOrderService service; + + @GetMapping("/exception-orders") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')") + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid ExceptionOrderRequests.Query query) { + return ApiResponse.success(service.page(scope(user), query)); + } + + @GetMapping("/exception-orders/resolve") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')") + public ApiResponse resolve(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @RequestParam(required = false) String inboundCode, + @RequestParam(required = false) String lastMileOrderNo) { + return ApiResponse.success(service.resolve(scope(user), customerId, inboundCode, lastMileOrderNo)); + } + + @PostMapping("/exception-orders") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_CREATE + "')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ExceptionOrderRequests.Create request) { + return ApiResponse.success(service.create(scope(user), operatorId(user), operatorName(user), request)); + } + + @GetMapping("/exception-orders/{exceptionNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo) { + return ApiResponse.success(service.detail(requiredScope(user, customerId), exceptionNo)); + } + + @PostMapping(value = "/exception-orders/{exceptionNo}/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") + public ApiResponse uploadEvidence( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo, @RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.uploadEvidence(requiredScope(user, customerId), operatorId(user), exceptionNo, file)); + } + + @PostMapping("/exception-orders/{exceptionNo}/start-processing") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") + public ApiResponse startProcessing( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.StartProcessing request) { + return ApiResponse.success(service.startProcessing(requiredScope(user, customerId), operatorId(user), exceptionNo, request)); + } + + @PostMapping("/exception-orders/{exceptionNo}/complete") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") + public ApiResponse complete( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.Complete request) { + return ApiResponse.success(service.complete(requiredScope(user, customerId), operatorId(user), exceptionNo, request)); + } + + @PostMapping("/exception-orders/{exceptionNo}/cancel") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") + public ApiResponse cancel( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.Cancel request) { + return ApiResponse.success(service.cancel(requiredScope(user, customerId), operatorId(user), exceptionNo, request)); + } + + @GetMapping("/delivery-orders/{deliveryNo}/return-inbound-options") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')") + public ApiResponse> returnOptions(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String deliveryNo) { + return ApiResponse.success(service.returnCandidates(requiredScope(user, customerId), deliveryNo)); + } + + @PostMapping("/delivery-orders/{deliveryNo}/mark-return") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_RETURN + "')") + public ApiResponse markReturn(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String deliveryNo, + @Valid @RequestBody ExceptionOrderRequests.MarkReturn request) { + return ApiResponse.success(service.markReturn(requiredScope(user, customerId), operatorId(user), operatorName(user), deliveryNo, request)); + } + + private String scope(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + return "PLATFORM".equals(user.appCode()) && user.tenantId() == 0L ? null : String.valueOf(user.tenantId()); + } + + private String requiredScope(AuthenticatedUser user, String requestedCustomer) { + String own = scope(user); + if (own != null) return own; + if (requestedCustomer == null || requestedCustomer.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择客户"); + return requestedCustomer.trim(); + } + + private String operatorId(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } + + private String operatorName(AuthenticatedUser user) { + if (user.displayName() != null && !user.displayName().isBlank()) return user.displayName().trim(); + return user.username(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java new file mode 100644 index 000000000..8aaa2d8b4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.exceptionorder.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.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class ExceptionOrderRequests { + private ExceptionOrderRequests() {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, + String keyword, String exceptionType, String status, + String customerId, String warehouseId, + LocalDateTime createdFrom, LocalDateTime createdTo) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record Create(String customerId, @Size(max = 128) String inboundCode, + @Size(max = 64) String lastMileOrderNo, + @NotBlank @Size(max = 32) String exceptionType, + @NotBlank @Size(max = 1000) String reason, + @Min(1) Integer packageCount, + @Size(max = 128) String skuCode) {} + + public record MarkReturn(@NotNull @Min(1) Long inboundOrderId, + @NotBlank @Size(max = 1000) String reason, + @NotBlank @Size(max = 32) String returnDirection, + @Size(max = 128) String returnLogisticsNo, + @NotNull @Size(min = 1, max = 200) List<@NotNull ReturnItem> items, + @NotBlank @Size(max = 255) String idempotencyKey) {} + + public record ReturnItem(@NotNull @Min(1) Long outboundPalletItemId, + @NotNull @Min(1) Long inventoryPositionId, + @Min(1) long packageCount) {} + + public record StartProcessing(@Min(0) long expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String handlingAction) {} + + public record Complete(@Min(0) long expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotBlank @Size(max = 2000) String handlingResult, + @NotNull @Size(min = 1, max = 20) List<@NotNull @Min(1) Long> evidenceFileIds, + @Size(max = 200) List<@NotNull ReturnReceipt> receipts) {} + + public record ReturnReceipt(@NotNull @Min(1) Long outboundPalletItemId, + @NotNull @Min(1) Long inventoryPositionId, + @Min(1) long receivedPackages, + @NotNull @Min(1) Long targetPalletId, + @NotBlank @Size(max = 128) String targetPalletNo, + @NotNull @Min(1) Long targetLocationId, + @NotBlank @Size(max = 128) String targetLocationCode) {} + + public record Cancel(@Min(0) long expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotBlank @Size(max = 1000) String reason) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java new file mode 100644 index 000000000..f7fa3ddf8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.exceptionorder.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class ExceptionOrderResponses { + private ExceptionOrderResponses() {} + + public record Row(Long id, String exceptionNo, String exceptionType, String status, + String reason, String customerId, String customerName, + String overseasWarehouseId, String overseasWarehouseName, + Long lastMileOrderId, String lastMileOrderNo, + Long inboundOrderId, String inboundCode, + Long deliveryOrderId, String deliveryNo, String outboundNo, + String skuCode, Integer packageCount, String returnDirection, + String returnLogisticsNo, String handlingAction, + String handlingResult, String handlerName, LocalDateTime exceptionAt, + LocalDateTime completedAt, LocalDateTime updatedAt, long version, + List evidenceFileIds, List allowedActions) {} + + public record Relation(Long lastMileOrderId, String lastMileOrderNo, + Long inboundOrderId, String inboundCode, + String customerId, String customerName, + String overseasWarehouseId, String overseasWarehouseName, + String deliveryNo, String outboundNo, Integer availablePackages, + List returnSources) {} + + public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId, + String palletNo, String skuCode, long maxReturnPackages) {} + public record Detail(Row order, List returnSources) {} + public record CommandResult(Long id, String exceptionNo, String status, long version, + Long inventoryAdjustmentId, long restoredPackages, + LocalDateTime completedAt, boolean replayed) {} + public record UploadedEvidence(Long fileId, String fileName, String contentType, long sizeBytes) {} + public record Created(Long id, String exceptionNo, String status, long version, boolean replayed) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java new file mode 100644 index 000000000..653b11372 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.exceptionorder.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_exception_order") +public class ExceptionOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private String exceptionNo; + private String exceptionType; + private String status; + private String reason; + private Long lastMileOrderId; + private String lastMileOrderNo; + private Long inboundOrderId; + private String inboundCode; + private Long deliveryOrderId; + private String deliveryNo; + private String outboundNo; + private String customerName; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String skuCode; + private Integer packageCount; + private String returnDirection; + private String returnLogisticsNo; + private String handlingAction; + private String handlingResult; + private String evidenceFileIdsJson; + private Long inventoryAdjustmentId; + private String handlerId; + private String handlerName; + private String sourceType; + private String createdBy; + private LocalDateTime exceptionAt; + private LocalDateTime completedAt; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrderCommand.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrderCommand.java new file mode 100644 index 000000000..e32ccdecc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrderCommand.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.exceptionorder.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_exception_order_command") +public class ExceptionOrderCommand { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long exceptionOrderId; + 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/exceptionorder/entity/ExceptionReturnItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java new file mode 100644 index 000000000..24bf07152 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.exceptionorder.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_exception_return_item") +public class ExceptionReturnItem { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long exceptionOrderId; + private Long outboundPalletItemId; + private Long inventoryPositionId; + private String palletNo; + private String skuCode; + private Long maxReturnPackages; + private Long approvedReturnPackages; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java new file mode 100644 index 000000000..81629f184 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.exceptionorder.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface ExceptionEvidenceFileGateway { + Uploaded upload(String customerId, long exceptionOrderId, String operatorId, MultipartFile file); + void validate(String customerId, long exceptionOrderId, long fileId); + + record Uploaded(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java new file mode 100644 index 000000000..47ffa58e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.exceptionorder.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 HttpExceptionEvidenceFileGateway implements ExceptionEvidenceFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_EXCEPTION_EVIDENCE"; + @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.getCustomerVisitFile()); + } + + @Override + public Uploaded upload(String customerId, long exceptionOrderId, String operatorId, MultipartFile file) { + 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 ResponseStatusException(HttpStatus.BAD_REQUEST, "无法读取异常处理证据", exception); } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId(customerId), BUSINESS_TYPE, exceptionOrderId) + .header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", 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, + "异常处理证据上传失败"); }).body(MetadataEnvelope.class); + Metadata data = require(response); + return new Uploaded(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long exceptionOrderId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, exceptionOrderId, 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, + "异常处理证据不存在或归属不正确"); }).body(MetadataEnvelope.class); + Metadata data = require(response); + if (data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenantId + || !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(exceptionOrderId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "异常处理证据与当前异常单不匹配"); + } + } + + private static Metadata require(MetadataEnvelope response) { + if (response == null || !"200".equals(response.code()) || response.data() == null || response.data().fileId() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回异常处理证据元数据"); + } + return response.data(); + } + private static long tenantId(String value) { + try { return Long.parseLong(value); } + catch (NumberFormatException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "客户ID格式无效", exception); } + } + 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/exceptionorder/mapper/ExceptionOrderCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderCommandMapper.java new file mode 100644 index 000000000..7ab0e6b3d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderCommandMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.exceptionorder.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ExceptionOrderCommandMapper extends BaseMapper { + @Select("SELECT * FROM ll_exception_order_command WHERE customer_id=#{customerId} AND action=#{action} AND idempotency_key=#{key} LIMIT 1") + ExceptionOrderCommand findByKey(@Param("customerId") String customerId, + @Param("action") String action, + @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java new file mode 100644 index 000000000..be96b379c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.exceptionorder.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ExceptionOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " + + "AND exception_type='RETURN' LIMIT 1") + ExceptionOrder findDeliveryReturn(@Param("customerId") String customerId, + @Param("deliveryOrderId") Long deliveryOrderId); + + @Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND exception_no=#{exceptionNo} LIMIT 1 FOR UPDATE") + ExceptionOrder lockByNo(@Param("customerId") String customerId, @Param("exceptionNo") String exceptionNo); + + @Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND exception_no=#{exceptionNo} LIMIT 1") + ExceptionOrder findByNo(@Param("customerId") String customerId, @Param("exceptionNo") String exceptionNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java new file mode 100644 index 000000000..0b29a66e7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.exceptionorder.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem; +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 ExceptionReturnItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_exception_return_item WHERE customer_id=#{customerId} AND exception_order_id=#{exceptionOrderId} ORDER BY id") + List findByException(@Param("customerId") String customerId, + @Param("exceptionOrderId") Long exceptionOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java new file mode 100644 index 000000000..4bb787b6c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -0,0 +1,620 @@ +package com.baiyue.lastleg.exceptionorder.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.entity.DeliveryOperation; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.entity.OutboundPdaPick; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy; +import com.baiyue.lastleg.entity.LastlegInboundOrder; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem; +import com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import jakarta.annotation.Resource; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +@Service +public class ExceptionOrderService { + @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; + @Resource private ExceptionOrderMapper exceptionOrders; + @Resource private LastlegOrderMapper lastMileOrders; + @Resource private LastlegInboundOrderMapper inboundOrders; + @Resource private DeliveryOrderMapper deliveryOrders; + @Resource private DeliveryOperationMapper deliveryOperations; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private OutboundPalletItemMapper outboundPalletItems; + @Resource private OutboundPdaPickMapper outboundPdaPicks; + @Resource private DispatchItemMapper dispatchItems; + @Resource private DispatchPdaTaskMapper dispatchPdaTasks; + @Resource private DispatchPdaPickMapper dispatchPdaPicks; + @Resource private DispatchPdaPickOrderMapper dispatchPdaPickOrders; + @Resource private ExceptionReturnItemMapper returnItems; + @Resource private ExceptionOrderCommandMapper commands; + @Resource private InventoryCommandService inventoryCommands; + @Resource private ExceptionEvidenceFileGateway evidenceFiles; + @Resource private ObjectMapper objectMapper; + + public PageResult page(String scopedCustomerId, ExceptionOrderRequests.Query query) { + String customerId = customerScope(scopedCustomerId, query.customerId()); + Page result = exceptionOrders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), ExceptionOrder::getCustomerId, customerId) + .and(StringUtils.hasText(query.keyword()), wrapper -> wrapper + .like(ExceptionOrder::getExceptionNo, trim(query.keyword())) + .or().like(ExceptionOrder::getLastMileOrderNo, trim(query.keyword())) + .or().like(ExceptionOrder::getInboundCode, trim(query.keyword())) + .or().like(ExceptionOrder::getDeliveryNo, trim(query.keyword())) + .or().like(ExceptionOrder::getCustomerName, trim(query.keyword())) + .or().like(ExceptionOrder::getReason, trim(query.keyword()))) + .eq(StringUtils.hasText(query.exceptionType()), ExceptionOrder::getExceptionType, trim(query.exceptionType())) + .eq(StringUtils.hasText(query.status()), ExceptionOrder::getStatus, trim(query.status())) + .eq(StringUtils.hasText(query.warehouseId()), ExceptionOrder::getOverseasWarehouseId, trim(query.warehouseId())) + .ge(query.createdFrom() != null, ExceptionOrder::getExceptionAt, query.createdFrom()) + .le(query.createdTo() != null, ExceptionOrder::getExceptionAt, query.createdTo()) + .orderByDesc(ExceptionOrder::getExceptionAt).orderByDesc(ExceptionOrder::getId)); + return new PageResult<>(result.getRecords().stream().map(this::row).toList(), result.getCurrent(), + result.getSize(), result.getTotal(), result.getPages()); + } + + public ExceptionOrderResponses.Relation resolve(String scopedCustomerId, String requestedCustomerId, + String inboundCode, String lastMileOrderNo) { + return relation(scopedCustomerId, requestedCustomerId, inboundCode, lastMileOrderNo); + } + + public ExceptionOrderResponses.Detail detail(String customerId, String exceptionNo) { + ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo); + if (order == null) throw notFound("异常订单不存在"); + return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order)); + } + + public ExceptionOrderResponses.UploadedEvidence uploadEvidence(String customerId, String operatorId, + String exceptionNo, MultipartFile file) { + ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo); + if (order == null) throw notFound("异常订单不存在"); + if (file == null || file.isEmpty()) throw badRequest("请选择异常处理证据文件"); + ExceptionEvidenceFileGateway.Uploaded uploaded = evidenceFiles.upload(customerId, order.getId(), operatorId, file); + return new ExceptionOrderResponses.UploadedEvidence(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes()); + } + + public List returnCandidates(String customerId, String deliveryNo) { + DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("派送单不存在"); + if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) { + throw conflict("当前派送状态不允许标记退回"); + } + return returnCandidates(delivery); + } + + private List returnCandidates(DeliveryOrder delivery) { + String customerId = delivery.getCustomerId(); + String deliveryNo = delivery.getDeliveryNo(); + return lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .eq(LastlegOrder::getDeliveryOrderNo, deliveryNo) + .orderByAsc(LastlegOrder::getOrderNo)).stream() + .map(order -> withReturnSources( + relation(customerId, customerId, order.getInboundCode(), order.getOrderNo()), delivery)) + .distinct().toList(); + } + + @Transactional + public ExceptionOrderResponses.Created create(String scopedCustomerId, String operatorId, String operatorName, + ExceptionOrderRequests.Create request) { + ExceptionOrderResponses.Relation relation = relation(scopedCustomerId, request.customerId(), + request.inboundCode(), request.lastMileOrderNo()); + return insert(relation, null, normalizeType(request.exceptionType()), request.reason(), request.packageCount(), + request.skuCode(), null, null, "MANUAL", operatorId, operatorName); + } + + @Transactional + public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo, + ExceptionOrderRequests.MarkReturn request) { + String requestHash = hash(request); + ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim()); + if (replay != null) return replayCreated(replay, requestHash); + DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("派送单不存在"); + if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) { + throw conflict("当前派送状态不允许标记退回"); + } + ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId()); + if (existing != null) throw conflict("该派送单已存在退回异常单"); + + List candidates = returnCandidates(delivery); + ExceptionOrderResponses.Relation selected = candidates.stream() + .filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId())) + .findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单")); + List available = selected.returnSources(); + List requested = request.items(); + validateReturnItems(available, requested); + int packages = Math.toIntExact(requested.stream().mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).sum()); + Set skus = available.stream().filter(source -> requested.stream().anyMatch(item -> sameSource(source, item))) + .map(ExceptionOrderResponses.ReturnSource::skuCode).filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet()); + String sku = skus.size() == 1 ? skus.iterator().next() : "MIXED"; + String returnDirection = trim(request.returnDirection()).toUpperCase(java.util.Locale.ROOT); + if (!Set.of("OVERSEAS_WAREHOUSE", "CUSTOMER", "OTHER").contains(returnDirection)) { + throw badRequest("不支持的退回方向"); + } + ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(), + packages, sku, returnDirection, request.returnLogisticsNo(), + "DELIVERY_RETURN", operatorId, operatorName); + + for (ExceptionOrderRequests.ReturnItem requestedItem : requested) { + ExceptionOrderResponses.ReturnSource source = available.stream() + .filter(candidate -> sameSource(candidate, requestedItem)).findFirst().orElseThrow(); + ExceptionReturnItem item = new ExceptionReturnItem(); + item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setExceptionOrderId(created.id()); + item.setOutboundPalletItemId(source.outboundPalletItemId()); item.setInventoryPositionId(source.inventoryPositionId()); + item.setPalletNo(source.palletNo()); item.setSkuCode(source.skuCode()); + item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount()); + item.setCreatedAt(LocalDateTime.now()); returnItems.insert(item); + } + + LocalDateTime now = LocalDateTime.now(); + delivery.setStatus("RETURN_PENDING"); + delivery.setUpdatedAt(now); + delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); + deliveryOrders.updateById(delivery); + LastlegOrder tail = lastMileOrders.selectById(selected.lastMileOrderId()); + if (tail != null) { + tail.setAbnormal(true); + tail.setSubOrderStatus("DELIVERY:RETURN_PENDING"); + tail.setUpdatedAt(now); + lastMileOrders.updateById(tail); + } + DeliveryOperation operation = new DeliveryOperation(); + operation.setCustomerId(customerId); operation.setDeliveryOrderId(delivery.getId()); + operation.setOperationNode("MARK_RETURN"); operation.setOperatorId(operatorId); operation.setOperatorName(operatorName); + operation.setResult("已生成异常退货单 " + created.exceptionNo()); operation.setCreatedAt(now); + deliveryOperations.insert(operation); + recordCommand(customerId, created.id(), "MARK_RETURN", request.idempotencyKey(), requestHash, created); + return created; + } + + @Transactional + public ExceptionOrderResponses.CommandResult startProcessing(String customerId, String operatorId, + String exceptionNo, + ExceptionOrderRequests.StartProcessing request) { + String requestHash = hash(request); + ExceptionOrderCommand replay = commands.findByKey(customerId, "START_PROCESSING", request.idempotencyKey().trim()); + if (replay != null) return replayResult(replay, requestHash); + ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING"); + LocalDateTime now = LocalDateTime.now(); + order.setStatus("PROCESSING"); order.setHandlingAction(trim(request.handlingAction())); + order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1); + exceptionOrders.updateById(order); + ExceptionOrderResponses.CommandResult result = commandResult(order, 0, false); + recordCommand(customerId, order.getId(), "START_PROCESSING", request.idempotencyKey(), requestHash, result); + return result; + } + + @Transactional + public ExceptionOrderResponses.CommandResult cancel(String customerId, String operatorId, + String exceptionNo, + ExceptionOrderRequests.Cancel request) { + String requestHash = hash(request); + ExceptionOrderCommand replay = commands.findByKey(customerId, "CANCEL", request.idempotencyKey().trim()); + if (replay != null) return replayResult(replay, requestHash); + ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING"); + LocalDateTime now = LocalDateTime.now(); + order.setStatus("CANCELLED"); order.setHandlingResult(trim(request.reason())); + order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1); + exceptionOrders.updateById(order); + ExceptionOrderResponses.CommandResult result = commandResult(order, 0, false); + recordCommand(customerId, order.getId(), "CANCEL", request.idempotencyKey(), requestHash, result); + return result; + } + + @Transactional + public ExceptionOrderResponses.CommandResult complete(String customerId, String operatorId, + String exceptionNo, + ExceptionOrderRequests.Complete request) { + String requestHash = hash(request); + ExceptionOrderCommand replay = commands.findByKey(customerId, "COMPLETE", request.idempotencyKey().trim()); + if (replay != null) return replayResult(replay, requestHash); + ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING"); + request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId)); + ReturnedInventoryRestoreResponse restored = null; + if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) { + List stored = returnItems.findByException(customerId, order.getId()); + if (stored.isEmpty()) throw conflict("退回异常单缺少已冻结的原出库库存明细"); + if (request.receipts() == null || request.receipts().isEmpty()) throw badRequest("请填写退回实收明细"); + restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId, + new ReturnedInventoryRestoreRequest(order.getExceptionNo(), order.getOutboundNo(), + order.getOverseasWarehouseId(), order.getInboundOrderId(), + stored.stream().map(item -> new ReturnedInventoryRestoreRequest.SourceItem( + item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getSkuCode(), + Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(), + request.receipts().stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt( + receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(), + receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(), + receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(), + request.evidenceFileIds().stream().map(String::valueOf).toList())); + } else if (request.receipts() != null && !request.receipts().isEmpty()) { + throw badRequest("非退回海外仓异常不能提交库存实收明细"); + } + LocalDateTime now = restored == null ? LocalDateTime.now() : restored.completedAt(); + order.setStatus("COMPLETED"); order.setHandlingResult(trim(request.handlingResult())); + order.setEvidenceFileIdsJson(write(request.evidenceFileIds())); + order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId()); + order.setHandlerId(operatorId); order.setCompletedAt(now); order.setUpdatedAt(now); + order.setVersion(version(order) + 1); exceptionOrders.updateById(order); + closeReturnedDelivery(order, operatorId, now); + long restoredPackages = restored == null ? 0 : restored.restoredPackages(); + ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false); + recordCommand(customerId, order.getId(), "COMPLETE", request.idempotencyKey(), requestHash, result); + return result; + } + + private ExceptionOrderResponses.Relation relation(String scopedCustomerId, String requestedCustomerId, + String inboundCode, String lastMileOrderNo) { + String customerId = customerScope(scopedCustomerId, requestedCustomerId); + String inbound = trim(inboundCode); + String tailNo = trim(lastMileOrderNo); + if (!StringUtils.hasText(inbound) && !StringUtils.hasText(tailNo)) { + throw badRequest("入仓码和尾程订单号至少填写一个"); + } + // A tail-order number is not a globally unique business key. When the + // caller also supplies an inbound code (the normal exception/return + // workflow does), resolve the pair together rather than rejecting a + // valid relation because another order has reused the tail number. + LastlegOrder tail = StringUtils.hasText(tailNo) ? uniqueTail(customerId, tailNo, inbound) : null; + LastlegInboundOrder inboundOrder = StringUtils.hasText(inbound) ? uniqueInbound(customerId, inbound) : null; + if (tail != null && inboundOrder != null && !Objects.equals(tail.getInboundCode(), inboundOrder.getInboundCode())) { + throw conflict("尾程订单与入仓单不是同一关联关系"); + } + if (tail == null && inboundOrder != null) tail = optionalTail(inboundOrder.getCustomerId(), inboundOrder.getInboundCode()); + if (inboundOrder == null && tail != null) inboundOrder = optionalInbound(tail.getCustomerId(), tail.getInboundCode()); + if (tail == null && inboundOrder == null) throw notFound("未找到对应单据"); + String resolvedCustomer = tail != null ? tail.getCustomerId() : inboundOrder.getCustomerId(); + if (tail != null && inboundOrder != null && !Objects.equals(tail.getCustomerId(), inboundOrder.getCustomerId())) { + throw conflict("尾程订单与入仓单不属于同一客户"); + } + return new ExceptionOrderResponses.Relation( + tail == null ? null : tail.getId(), tail == null ? null : tail.getOrderNo(), + inboundOrder == null ? null : inboundOrder.getId(), inboundOrder == null ? null : inboundOrder.getInboundCode(), + resolvedCustomer, tail == null ? null : tail.getCustomerName(), + tail == null ? null : tail.getOverseasWarehouseId(), tail == null ? null : tail.getOverseasWarehouseName(), + tail == null ? null : tail.getDeliveryOrderNo(), tail == null ? null : tail.getOutboundOrderNo(), + tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount(), List.of()); + } + + private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery, + String type, String reason, Integer packages, String sku, + String direction, String logisticsNo, String sourceType, + String operatorId, String operatorName) { + if (packages != null && relation.availablePackages() != null && packages > relation.availablePackages()) { + throw conflict("异常箱数不能大于订单箱数 " + relation.availablePackages()); + } + LocalDateTime now = LocalDateTime.now(); + ExceptionOrder value = new ExceptionOrder(); + value.setId(IdWorker.getId()); value.setCustomerId(relation.customerId()); + value.setExceptionNo(orderNoGenerator.next("EXCEPTION", relation.overseasWarehouseId(), value.getId())); + value.setExceptionType(type); value.setStatus("PENDING"); value.setReason(trim(reason)); + value.setLastMileOrderId(relation.lastMileOrderId()); value.setLastMileOrderNo(relation.lastMileOrderNo()); + value.setInboundOrderId(relation.inboundOrderId()); value.setInboundCode(relation.inboundCode()); + value.setDeliveryOrderId(delivery == null ? null : delivery.getId()); + value.setDeliveryNo(delivery == null ? relation.deliveryNo() : delivery.getDeliveryNo()); + value.setOutboundNo(delivery == null ? relation.outboundNo() : delivery.getOutboundNo()); + value.setCustomerName(relation.customerName()); + value.setOverseasWarehouseId(delivery == null ? relation.overseasWarehouseId() : delivery.getOverseasWarehouseId()); + value.setOverseasWarehouseName(delivery == null ? relation.overseasWarehouseName() : delivery.getOverseasWarehouseName()); + value.setSkuCode(trim(sku)); value.setPackageCount(packages); value.setReturnDirection(trim(direction)); + value.setReturnLogisticsNo(trim(logisticsNo)); + if (StringUtils.hasText(direction)) { + value.setHandlingAction("OVERSEAS_WAREHOUSE".equals(direction) ? "退回海外仓" : "CUSTOMER".equals(direction) ? "退回客户" : "其他退货处理"); + value.setHandlerId(operatorId); value.setHandlerName(operatorName); + } + value.setSourceType(sourceType); value.setCreatedBy(operatorId); + value.setVersion(0L); + value.setExceptionAt(now); value.setCreatedAt(now); value.setUpdatedAt(now); + try { exceptionOrders.insert(value); } + catch (DuplicateKeyException exception) { throw conflict("该关联单据已存在异常退货单"); } + return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus(), version(value), false); + } + + private LastlegOrder uniqueTail(String customerId, String orderNo, String inboundCode) { + List values = lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) + .eq(LastlegOrder::getOrderNo, orderNo) + .eq(StringUtils.hasText(inboundCode), LastlegOrder::getInboundCode, inboundCode) + .last("LIMIT 2")); + if (values.isEmpty()) throw notFound("尾程订单不存在"); + if (values.size() > 1) throw conflict("尾程订单号不唯一,请先选择客户"); + return values.get(0); + } + + private LastlegInboundOrder uniqueInbound(String customerId, String inboundCode) { + List values = inboundOrders.selectList(new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), LastlegInboundOrder::getCustomerId, customerId) + .eq(LastlegInboundOrder::getInboundCode, inboundCode).last("LIMIT 2")); + if (values.isEmpty()) throw notFound("入仓单不存在"); + if (values.size() > 1) throw conflict("入仓码不唯一,请先选择客户"); + return values.get(0); + } + + private LastlegOrder optionalTail(String customerId, String inboundCode) { + List values = lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getInboundCode, inboundCode).last("LIMIT 2")); + if (values.size() > 1) throw conflict("该入仓码关联了多个尾程订单,请修复关联关系后重试"); + return values.size() == 1 ? values.get(0) : null; + } + + private LastlegInboundOrder optionalInbound(String customerId, String inboundCode) { + if (!StringUtils.hasText(inboundCode)) return null; + List values = inboundOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegInboundOrder::getCustomerId, customerId).eq(LastlegInboundOrder::getInboundCode, inboundCode).last("LIMIT 2")); + if (values.size() > 1) throw conflict("该尾程订单关联了多个入仓单,请修复关联关系后重试"); + return values.size() == 1 ? values.get(0) : null; + } + + private ExceptionOrderResponses.Row row(ExceptionOrder value) { + return new ExceptionOrderResponses.Row(value.getId(), value.getExceptionNo(), value.getExceptionType(), value.getStatus(), + value.getReason(), value.getCustomerId(), value.getCustomerName(), value.getOverseasWarehouseId(), + value.getOverseasWarehouseName(), value.getLastMileOrderId(), value.getLastMileOrderNo(), + value.getInboundOrderId(), value.getInboundCode(), value.getDeliveryOrderId(), value.getDeliveryNo(), + value.getOutboundNo(), value.getSkuCode(), value.getPackageCount(), value.getReturnDirection(), + value.getReturnLogisticsNo(), value.getHandlingAction(), value.getHandlingResult(), value.getHandlerName(), + value.getExceptionAt(), value.getCompletedAt(), value.getUpdatedAt(), version(value), + readEvidenceFileIds(value.getEvidenceFileIdsJson()), allowedActions(value)); + } + + private ExceptionOrderResponses.Relation withReturnSources(ExceptionOrderResponses.Relation relation, + DeliveryOrder delivery) { + List sources = returnSources(delivery).stream() + .filter(source -> relation.inboundCode() == null || Objects.equals(source.inboundCode(), relation.inboundCode())) + .map(Source::response).toList(); + return new ExceptionOrderResponses.Relation(relation.lastMileOrderId(), relation.lastMileOrderNo(), + relation.inboundOrderId(), relation.inboundCode(), relation.customerId(), relation.customerName(), + relation.overseasWarehouseId(), relation.overseasWarehouseName(), relation.deliveryNo(), + relation.outboundNo(), relation.availablePackages(), sources); + } + + private List returnSources(DeliveryOrder delivery) { + OutboundOrder outbound = outboundOrders.findByNo(delivery.getCustomerId(), delivery.getOutboundNo()); + if (outbound == null) throw conflict("派送单缺少原出库单据"); + Map itemById = new LinkedHashMap<>(); + outboundPalletItems.findByOutbound(delivery.getCustomerId(), outbound.getId()) + .forEach(item -> itemById.put(item.getId(), item)); + Map aggregated = new LinkedHashMap<>(); + for (OutboundPdaPick pick : outboundPdaPicks.findByOutbound(delivery.getCustomerId(), outbound.getId())) { + OutboundPalletItem item = itemById.get(pick.getOutboundPalletItemId()); + if (item == null || pick.getInventoryPositionId() == null) continue; + String key = item.getId() + ":" + pick.getInventoryPositionId(); + aggregated.merge(key, new Source(item.getId(), pick.getInventoryPositionId(), item.getPalletNo(), + item.getSkuCode(), item.getInboundCode(), Objects.requireNonNullElse(pick.getPackages(), 0L)), + (left, right) -> left.withPackages(left.maxReturnPackages() + right.maxReturnPackages())); + } + appendDispatchPickSources(delivery, outbound, itemById, aggregated); + return new ArrayList<>(aggregated.values()); + } + + private void appendDispatchPickSources(DeliveryOrder delivery, OutboundOrder outbound, + Map outboundItemById, + Map aggregated) { + if (outbound.getDispatchOrderId() == null) return; + String customerId = delivery.getCustomerId(); + DispatchPdaTask task = dispatchPdaTasks.findByDispatch(customerId, outbound.getDispatchOrderId()); + if (task == null) return; + Map dispatchItemById = new LinkedHashMap<>(); + dispatchItems.findByDispatchOrder(customerId, outbound.getDispatchOrderId()) + .forEach(item -> dispatchItemById.put(item.getId(), item)); + for (DispatchPdaPick pick : dispatchPdaPicks.listTask(customerId, task.getId())) { + DispatchItem dispatchItem = dispatchItemById.get(pick.getDispatchItemId()); + if (dispatchItem == null || pick.getInventoryPositionId() == null) continue; + List attributions = dispatchPdaPickOrders.listPick(customerId, pick.getId()); + for (DispatchPdaPickOrder attribution : attributions) { + LastlegOrder tail = lastMileOrders.selectById(attribution.getOrderId()); + if (tail == null || !customerId.equals(tail.getCustomerId())) continue; + OutboundPalletItem outboundItem = outboundItemById.values().stream() + .filter(item -> Objects.equals(item.getWarehousePalletId(), dispatchItem.getWarehousePalletId())) + .filter(item -> !StringUtils.hasText(item.getInboundCode()) + || Objects.equals(item.getInboundCode(), tail.getInboundCode())) + .findFirst().orElse(null); + if (outboundItem == null) continue; + String key = outboundItem.getId() + ":" + pick.getInventoryPositionId() + ":" + tail.getInboundCode(); + long packages = Objects.requireNonNullElse(attribution.getPackages(), 0L); + aggregated.merge(key, new Source(outboundItem.getId(), pick.getInventoryPositionId(), + outboundItem.getPalletNo(), outboundItem.getSkuCode(), tail.getInboundCode(), packages), + (left, right) -> left.withPackages(left.maxReturnPackages() + right.maxReturnPackages())); + } + } + } + + private List storedReturnSources(ExceptionOrder order) { + return returnItems.findByException(order.getCustomerId(), order.getId()).stream() + .map(item -> new ExceptionOrderResponses.ReturnSource(item.getOutboundPalletItemId(), + item.getInventoryPositionId(), item.getPalletNo(), item.getSkuCode(), + Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(); + } + + private void validateReturnItems(List available, + List requested) { + Set unique = new java.util.HashSet<>(); + for (ExceptionOrderRequests.ReturnItem item : requested) { + String key = item.outboundPalletItemId() + ":" + item.inventoryPositionId(); + if (!unique.add(key)) throw badRequest("退回库存明细不能重复"); + ExceptionOrderResponses.ReturnSource source = available.stream().filter(value -> sameSource(value, item)) + .findFirst().orElseThrow(() -> conflict("所选退回库存不属于该派送单和入仓单")); + if (item.packageCount() > source.maxReturnPackages()) throw conflict("退回箱数超过原出库箱数"); + } + } + + private boolean sameSource(ExceptionOrderResponses.ReturnSource source, ExceptionOrderRequests.ReturnItem item) { + return Objects.equals(source.outboundPalletItemId(), item.outboundPalletItemId()) + && Objects.equals(source.inventoryPositionId(), item.inventoryPositionId()); + } + + private ExceptionOrder lock(String customerId, String exceptionNo, long expectedVersion, String expectedStatus) { + ExceptionOrder order = exceptionOrders.lockByNo(customerId, exceptionNo); + if (order == null) throw notFound("异常订单不存在"); + if (version(order) != expectedVersion) throw conflict("异常订单版本已变化,请刷新后重试"); + if (!expectedStatus.equals(order.getStatus())) throw conflict("当前异常状态不允许执行该操作"); + return order; + } + + private void closeReturnedDelivery(ExceptionOrder order, String operatorId, LocalDateTime now) { + if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return; + DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo()); + if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) { + throw conflict("关联派送单不是退货处理中状态"); + } + delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now); + delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); + deliveryOrders.updateById(delivery); + for (LastlegOrder tail : lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, order.getCustomerId()) + .eq(LastlegOrder::getDeliveryOrderNo, order.getDeliveryNo()))) { + tail.setAbnormal(false); tail.setSubOrderStatus("DELIVERY:RETURNED"); tail.setUpdatedAt(now); + lastMileOrders.updateById(tail); + } + DeliveryOperation operation = new DeliveryOperation(); + operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(delivery.getId()); + operation.setOperationNode("RETURN_COMPLETED"); operation.setOperatorId(operatorId); + operation.setOperatorName(operatorId); operation.setResult("异常退回处理完成:" + order.getExceptionNo()); + operation.setCreatedAt(now); deliveryOperations.insert(operation); + } + + private List allowedActions(ExceptionOrder order) { + return switch (Objects.requireNonNullElse(order.getStatus(), "")) { + case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION"); + case "PROCESSING" -> List.of("COMPLETE_EXCEPTION"); + default -> List.of(); + }; + } + + private ExceptionOrderResponses.CommandResult commandResult(ExceptionOrder order, long restoredPackages, + boolean replayed) { + return new ExceptionOrderResponses.CommandResult(order.getId(), order.getExceptionNo(), order.getStatus(), + version(order), order.getInventoryAdjustmentId(), restoredPackages, order.getCompletedAt(), replayed); + } + + private void recordCommand(String customerId, Long orderId, String action, String idempotencyKey, + String requestHash, Object result) { + ExceptionOrderCommand command = new ExceptionOrderCommand(); + command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setExceptionOrderId(orderId); + command.setAction(action); command.setIdempotencyKey(idempotencyKey.trim()); + command.setRequestHash(requestHash); command.setResultJson(write(result)); command.setCreatedAt(LocalDateTime.now()); + commands.insert(command); + } + + private ExceptionOrderResponses.CommandResult replayResult(ExceptionOrderCommand command, String requestHash) { + requireReplayHash(command, requestHash); + try { + ExceptionOrderResponses.CommandResult result = objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.CommandResult.class); + return new ExceptionOrderResponses.CommandResult(result.id(), result.exceptionNo(), result.status(), result.version(), + result.inventoryAdjustmentId(), result.restoredPackages(), result.completedAt(), true); + } catch (JsonProcessingException exception) { throw new IllegalStateException("异常命令结果损坏", exception); } + } + + private ExceptionOrderResponses.Created replayCreated(ExceptionOrderCommand command, String requestHash) { + requireReplayHash(command, requestHash); + try { + ExceptionOrderResponses.Created result = objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.Created.class); + return new ExceptionOrderResponses.Created(result.id(), result.exceptionNo(), result.status(), result.version(), true); + } + catch (JsonProcessingException exception) { throw new IllegalStateException("异常命令结果损坏", exception); } + } + + private void requireReplayHash(ExceptionOrderCommand command, String requestHash) { + if (!Objects.equals(command.getRequestHash(), requestHash)) throw conflict("幂等键已用于不同请求"); + } + + 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("无法保存异常处理结果", exception); } + } + + private List readEvidenceFileIds(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { return objectMapper.readValue(value, new TypeReference<>() { }); } + catch (JsonProcessingException exception) { throw new IllegalStateException("异常处理证据数据损坏", exception); } + } + + private static long version(ExceptionOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); } + + private record Source(Long outboundPalletItemId, Long inventoryPositionId, String palletNo, + String skuCode, String inboundCode, long maxReturnPackages) { + Source withPackages(long packages) { return new Source(outboundPalletItemId, inventoryPositionId, palletNo, skuCode, inboundCode, packages); } + ExceptionOrderResponses.ReturnSource response() { + return new ExceptionOrderResponses.ReturnSource(outboundPalletItemId, inventoryPositionId, + palletNo, skuCode, maxReturnPackages); + } + } + + private String customerScope(String scopedCustomerId, String requestedCustomerId) { + if (StringUtils.hasText(scopedCustomerId)) { + if (StringUtils.hasText(requestedCustomerId) && !scopedCustomerId.equals(trim(requestedCustomerId))) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "不能访问其他客户数据"); + } + return scopedCustomerId; + } + return trim(requestedCustomerId); + } + + private static String normalizeType(String value) { + String normalized = trim(value).toUpperCase(); + if (!java.util.Set.of("RETURN", "REJECT", "DAMAGED", "OTHER").contains(normalized)) throw badRequest("不支持的异常类型"); + return normalized; + } + 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 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/financefee/controller/PlatformOverseasFeeSourceController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java new file mode 100644 index 000000000..81cb13590 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java @@ -0,0 +1,99 @@ +package com.baiyue.lastleg.financefee.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ActionRequest; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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.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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/platform/v1/last-leg/finance-fee-sources") +public class PlatformOverseasFeeSourceController { + private final OverseasFeeSourceService service; + + @GetMapping + @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @RequestParam(required = false) String businessType, + @RequestParam(required = false) String businessNo, + @RequestParam(required = false) Long containerBookingId) { + requirePlatform(user); + return ApiResponse.success(service.list(customerId, businessType, businessNo, containerBookingId)); + } + + @GetMapping("/container-customers") + @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") + public ApiResponse> listContainerCustomers( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long containerBookingId) { + requirePlatform(user); + return ApiResponse.success(service.listContainerCustomers(containerBookingId)); + } + + @PostMapping + @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody SaveRequest request) { + requirePlatform(user); return ApiResponse.success(service.create(operator(user), key, request)); + } + + @PostMapping("/{sourceFeeId}/submit") + @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") + public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long sourceFeeId, @Valid @RequestBody ActionRequest request) { + requirePlatform(user); return ApiResponse.success(service.submit(sourceFeeId, operator(user), request.idempotencyKey())); + } + + @PostMapping("/{sourceFeeId}/review") + @PreAuthorize("hasAuthority('platform:finance:fee-source:review')") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long sourceFeeId, @Valid @RequestBody ReviewRequest request) { + requirePlatform(user); return ApiResponse.success(service.review(sourceFeeId, operator(user), request)); + } + + @PostMapping("/{sourceFeeId}/adjust") + @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") + public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long sourceFeeId, @Valid @RequestBody AdjustRequest request) { + requirePlatform(user); return ApiResponse.success(service.adjust(sourceFeeId, operator(user), request)); + } + + @PostMapping("/{sourceFeeId}/void") + @PreAuthorize("hasAuthority('platform:finance:fee-source:review')") + public ApiResponse voidSource(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long sourceFeeId, @Valid @RequestBody VoidRequest request) { + requirePlatform(user); return ApiResponse.success(service.voidSource(sourceFeeId, operator(user), request)); + } + + private static 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 static 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/financefee/dto/OverseasFeeSourceDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java new file mode 100644 index 000000000..3b07a36d4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.financefee.dto; + +import jakarta.validation.constraints.DecimalMin; +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; +import java.time.LocalDateTime; +import java.util.List; + +public final class OverseasFeeSourceDtos { + private OverseasFeeSourceDtos() { } + + public record ContainerCustomerOption(String customerId, String customerName) { } + + public record SaveRequest( + @NotBlank @Size(max = 128) String customerId, + @Size(max = 200) String customerName, + @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE|OVERSEAS_CONTAINER_FEE|OVERSEAS_ORDER_FEE|OVERSEAS_DELIVERY_FEE|OVERSEAS_RELABEL_FEE") String sourceType, + @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|PROCUREMENT_FEE|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER|LASTLEG_ORDER|DELIVERY_ORDER|RELABEL_ORDER") String businessType, + @Min(1) Long businessId, + @NotBlank @Size(max = 128) String businessNo, + @Min(1) Long orderId, + @Min(1) Long inboundId, + @Size(max = 64) String inboundCode, + @Min(1) Long containerId, + @Min(1) Long containerBookingId, + @Size(max = 64) String containerNo, + @Min(1) Long warehouseId, + @NotBlank @Size(max = 64) String feeItemCode, + @NotBlank @Size(max = 200) String feeItemName, + @NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction, + @Min(1) Long supplierId, + @Size(max = 200) String supplierName, + @Size(max = 32) String supplierCategory, + @Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode, + LocalDateTime paidAt, + @Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod, + @Size(max = 128) String paymentReferenceNo, + @Size(max = 20) List<@NotBlank String> paymentVoucherFileIds, + @NotNull LocalDate businessDate, + @NotBlank @Pattern(regexp = "CNY|USD") String currency, + @NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount, + @DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity, + @Size(max = 32) String quantityUnit, + @DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice, + LocalDate periodStart, + LocalDate periodEnd + ) { } + + public record ActionRequest(@NotBlank @Size(max = 128) String idempotencyKey) { } + public record ReviewRequest(@NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision, + @Size(max = 500) String reason, + @NotBlank @Size(max = 128) String idempotencyKey) { } + public record AdjustRequest(@NotNull @Min(1) Integer expectedVersion, + @NotNull SaveRequest fee, + @NotBlank @Size(max = 128) String idempotencyKey) { } + public record VoidRequest(@NotNull @Min(1) Integer expectedVersion, + @NotBlank @Size(max = 500) String reason, + @NotBlank @Size(max = 128) String idempotencyKey) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeCommand.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeCommand.java new file mode 100644 index 000000000..a192dcebc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeCommand.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.financefee.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_finance_fee_command") +public class OverseasFeeCommand { + @TableId(type = IdType.INPUT) private Long id; + private String operatorId; + private String action; + private String idempotencyKey; + private String requestHash; + private Long sourceFeeId; + private Integer sourceVersion; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java new file mode 100644 index 000000000..860dd5991 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.financefee.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 com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@TableName(value = "ll_finance_fee_source", autoResultMap = true) +public class OverseasFeeSource { + @TableId(type = IdType.INPUT) private Long id; + private Long sourceFeeId; + private Integer sourceVersion; + private String customerId; + private String customerName; + private String sourceType; + private String sourceNo; + private String businessType; + private Long businessId; + private String businessNo; + private Long orderId; + private Long inboundId; + private String inboundCode; + private Long containerId; + private Long containerBookingId; + private String containerNo; + private Long warehouseId; + private String feeItemCode; + private String feeItemName; + private String direction; + private Long supplierId; + private String supplierName; + private String supplierCategory; + private String settlementMode; + private LocalDateTime paidAt; + private String paymentMethod; + private String paymentReferenceNo; + @TableField(typeHandler = JacksonTypeHandler.class) private List paymentVoucherFileIds; + private LocalDate businessDate; + private String currency; + private BigDecimal amount; + private String originalCurrency; + private BigDecimal originalAmount; + private BigDecimal exchangeRate; + private String exchangeRateMonth; + private BigDecimal chargeableQuantity; + private String quantityUnit; + private BigDecimal unitPrice; + private LocalDate periodStart; + private LocalDate periodEnd; + private String reviewStatus; + private String publicationStatus; + private String sourceStatus; + private String reviewReason; + private String reviewerId; + private LocalDateTime reviewedAt; + private String createdBy; + private String updatedBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeCommandMapper.java new file mode 100644 index 000000000..d7b65c29b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeCommandMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.financefee.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface OverseasFeeCommandMapper extends BaseMapper { + @Select("SELECT * FROM ll_finance_fee_command WHERE operator_id=#{operatorId} AND action=#{action} AND idempotency_key=#{key} LIMIT 1") + OverseasFeeCommand find(@Param("operatorId") String operatorId, @Param("action") String action, + @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java new file mode 100644 index 000000000..db82e9b59 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.financefee.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface OverseasFeeSourceMapper extends BaseMapper { + @Select("SELECT * FROM ll_finance_fee_source WHERE source_fee_id=#{sourceFeeId} ORDER BY source_version DESC LIMIT 1 FOR UPDATE") + OverseasFeeSource lockCurrent(@Param("sourceFeeId") long sourceFeeId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java new file mode 100644 index 000000000..c1d28ee5a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.financefee.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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class OverseasFeeOutboxRepository { + private final LastlegEventOutboxMapper mapper; + + public List pending(int limit, LocalDateTime now, LocalDateTime stale) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED) + .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) + .or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale))) + .orderByAsc(LastlegEventOutbox::getCreatedAt).last("LIMIT " + limit)); + } + public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime stale) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED) + .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING").or(processing -> + processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale))) + .set(LastlegEventOutbox::getStatus, "PROCESSING").set(LastlegEventOutbox::getLockedBy, worker) + .set(LastlegEventOutbox::getLockedAt, now)) == 1; + } + public LastlegEventOutbox claimed(long id, String worker) { + return mapper.selectOne(new LambdaQueryWrapper().eq(LastlegEventOutbox::getId, id) + .eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker)); + } + public void published(long id, String worker, LocalDateTime now) { + mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now) + .set(LastlegEventOutbox::getNextRetryAt, null).set(LastlegEventOutbox::getLastError, null)); + } + public void retry(long id, String worker, int retryCount, LocalDateTime next, String error) { + mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount) + .set(LastlegEventOutbox::getNextRetryAt, next).set(LastlegEventOutbox::getLastError, truncate(error))); + } + private LambdaUpdateWrapper claimed(long id, String worker, String status) { + return new LambdaUpdateWrapper().eq(LastlegEventOutbox::getId, id) + .eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker) + .set(LastlegEventOutbox::getStatus, status).set(LastlegEventOutbox::getLockedBy, null) + .set(LastlegEventOutbox::getLockedAt, null); + } + private static String truncate(String value) { + String safe = value == null ? "unknown" : value; return safe.length() <= 1000 ? safe : safe.substring(0, 1000); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java new file mode 100644 index 000000000..81e541903 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java @@ -0,0 +1,116 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption; +import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; +import feign.FeignException; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class OverseasFeeBusinessGuard { + private final JdbcTemplate jdbc; + @Resource private DomesticWarehouseDirectoryClient warehouses; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public List listContainerCustomers(long containerBookingId) { + List customers = jdbc.query( + "SELECT customer_id,CASE WHEN COUNT(DISTINCT NULLIF(TRIM(customer_name),''))=1 " + + "THEN MAX(NULLIF(TRIM(customer_name),'')) ELSE NULL END AS customer_name " + + "FROM ll_order WHERE source_container_booking_id=? GROUP BY customer_id ORDER BY customer_id", + (result, rowNum) -> new ContainerCustomerOption( + result.getString("customer_id"), result.getString("customer_name")), + String.valueOf(containerBookingId)); + return customers; + } + + public void requireOwned(SaveRequest fee) { + if ("OVERSEAS_CONTAINER".equals(fee.businessType())) { + requireOwnedContainer(fee); + return; + } + if (fee.businessId() == null) { + if (!"WAREHOUSE_SERVICE".equals(fee.businessType())) + throw bad("业务记录ID不能为空"); + if (fee.warehouseId() == null) + throw bad("海外仓服务费必须关联海外仓;入仓记录和入仓码按实际业务选填"); + requireActiveOverseasWarehouse(fee.warehouseId()); + return; + } + Table table = switch (fee.businessType()) { + case "OVERSEAS_INBOUND" -> new Table("ll_inbound_plan", "plan_no"); + case "OVERSEAS_PROCUREMENT" -> new Table("ll_overseas_procurement_order", "order_no"); + case "PROCUREMENT_FEE" -> new Table("ll_overseas_procurement_fee", "fee_no"); + case "US_TRUCK" -> new Table("ll_us_truck_order", "order_no"); + case "LOCAL_LOOSEBOARD" -> new Table("ll_local_looseboard_order", "order_no"); + case "LASTLEG_ORDER" -> new Table("ll_order", "order_no"); + case "DELIVERY_ORDER" -> new Table("ll_delivery_order", "delivery_no"); + case "RELABEL_ORDER" -> new Table("ll_relabel_application", "relabel_no"); + default -> throw bad("业务类型与业务记录ID不匹配"); + }; + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM " + table.name + + " WHERE id=? AND customer_id=? AND " + table.number + "=?", + Integer.class, fee.businessId(), fee.customerId().trim(), fee.businessNo().trim()); + if (count == null || count != 1) throw bad("费用关联业务不存在或不属于该客户"); + if ("OVERSEAS_INBOUND".equals(fee.businessType()) && !blank(fee.inboundCode())) { + Integer inbound = jdbc.queryForObject("SELECT COUNT(*) FROM ll_inbound_plan_line WHERE plan_id=? AND customer_id=? AND inbound_code=?", + Integer.class, fee.businessId(), fee.customerId().trim(), fee.inboundCode().trim()); + if (inbound == null || inbound < 1) throw bad("入仓码不属于该客户和海外入仓计划"); + } + } + + private void requireOwnedContainer(SaveRequest fee) { + if (fee.businessId() == null || fee.containerBookingId() == null + || !fee.businessId().equals(fee.containerBookingId())) + throw bad("海外货柜费用必须以真实来源柜ID同时关联业务和订舱柜"); + if (blank(fee.containerNo()) || !fee.businessNo().trim().equals(fee.containerNo().trim())) + throw bad("海外货柜费用的业务编号必须与真实集装箱号一致"); + + String bookingId = String.valueOf(fee.containerBookingId()); + Integer total = jdbc.queryForObject( + "SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=?", + Integer.class, bookingId); + if (total == null || total < 1) throw bad("费用关联的真实货柜不存在"); + + Integer inconsistent = jdbc.queryForObject( + "SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=? " + + "AND (container_no IS NULL OR TRIM(container_no)='' OR BINARY TRIM(container_no)<>BINARY ?)", + Integer.class, bookingId, fee.containerNo().trim()); + if (inconsistent == null || inconsistent > 0) + throw bad("真实货柜的集装箱号缺失或不一致"); + + Integer owned = jdbc.queryForObject( + "SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=? AND customer_id=?", + Integer.class, bookingId, fee.customerId().trim()); + if (owned == null || owned < 1) throw bad("该客户不属于费用关联的真实货柜"); + } + + private void requireActiveOverseasWarehouse(long warehouseId) { + try { + var response = warehouses.activeWarehouse(warehouseId, internalToken); + var warehouse = response == null || response.data() == null ? null : response.data().warehouse(); + if (warehouse == null || !response.successful() || !Long.valueOf(warehouseId).equals(warehouse.id()) + || !Long.valueOf(0L).equals(warehouse.tenantId()) || !"ACTIVE".equals(warehouse.status()) + || !List.of("OVERSEAS", "OVERSEAS_PARTNER").contains(warehouse.warehouseType())) { + throw bad("海外仓不存在、已停用或类型不正确"); + } + } catch (FeignException exception) { + if (exception.status() >= 400 && exception.status() < 500) throw bad("海外仓不存在、已停用或类型不正确"); + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "仓库主数据暂不可用", exception); + } + } + + private static boolean blank(String value) { return value == null || value.isBlank(); } + private static ResponseStatusException bad(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + private record Table(String name, String number) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java new file mode 100644 index 000000000..69263374b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate; +import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Component +@RequiredArgsConstructor +public class OverseasFeeCurrencyConverter { + private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("yyyy-MM"); + private final MonthlyExchangeRateMapper rates; + + public Conversion convert(String currency, BigDecimal amount, LocalDate businessDate) { + String originalCurrency = currency == null ? null : currency.trim().toUpperCase(); + BigDecimal originalAmount = amount == null ? null : amount.setScale(2, RoundingMode.HALF_UP); + if ("CNY".equals(originalCurrency)) { + return new Conversion(originalCurrency, originalAmount, BigDecimal.ONE.setScale(6), + businessDate.format(MONTH), originalAmount); + } + if (!"USD".equals(originalCurrency)) throw bad("海外费用原币只支持CNY或USD"); + String month = businessDate.format(MONTH); + MonthlyExchangeRate rate = rates.findMonth(month); + if (rate == null || !"ACTIVE".equals(rate.getStatus()) || rate.getUsdToCnyAverage() == null + || rate.getUsdToCnyAverage().signum() <= 0) { + throw bad("业务月份缺少有效USD兑CNY汇率,不能发布财务费用"); + } + BigDecimal exchangeRate = rate.getUsdToCnyAverage().setScale(6, RoundingMode.HALF_UP); + return new Conversion(originalCurrency, originalAmount, exchangeRate, month, + originalAmount.multiply(exchangeRate).setScale(2, RoundingMode.HALF_UP)); + } + + private static ResponseStatusException bad(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + + public record Conversion(String originalCurrency, BigDecimal originalAmount, + BigDecimal exchangeRate, String exchangeRateMonth, + BigDecimal settlementAmount) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java new file mode 100644 index 000000000..5ec65a694 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.lastleg.client.EventBridgeClient; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.lang.management.ManagementFactory; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.UUID; + +@Component +public class OverseasFeeOutboxRelay { + @Resource private OverseasFeeOutboxRepository repository; + @Resource private EventBridgeClient client; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.security.internal-service-token:}") private String token; + private final String worker = ManagementFactory.getRuntimeMXBean().getName() + ':' + UUID.randomUUID(); + + @Scheduled(fixedDelayString = "${baiyue.finance-fee-relay.fixed-delay:3000}") + public void relay() { + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + for (LastlegEventOutbox candidate : repository.pending(50, now, now.minusSeconds(30))) { + if (!repository.claim(candidate.getId(), worker, now, now.minusSeconds(30))) continue; + LastlegEventOutbox row = repository.claimed(candidate.getId(), worker); + if (row == null) continue; + try { + EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); + var response = client.receive(token, envelope); + if (response == null || !"200".equals(response.code()) || response.data() == null + || !envelope.eventId().equals(response.data().eventId())) + throw new IllegalStateException("Event Bridge未确认海外费用事件"); + repository.published(row.getId(), worker, LocalDateTime.now(ZoneOffset.UTC)); + } catch (Exception exception) { + int retries = row.getRetryCount() == null ? 1 : row.getRetryCount() + 1; + repository.retry(row.getId(), worker, retries, now.plusSeconds(Math.min(900, 5L << Math.min(retries, 7))), exception.getMessage()); + } + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java new file mode 100644 index 000000000..349dab9e1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.BusinessFeeSourceChangedV1; +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.RegionCode; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +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 java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; + +@Service +public class OverseasFeeOutboxService { + @Resource private LastlegEventOutboxMapper mapper; + @Resource private ObjectMapper objectMapper; + + public void publish(OverseasFeeSource source, String action) { + BusinessFeeSourceChangedV1 unsigned = payload(source, null, null); + String sourceHash = EventPayloadHash.sha256( + EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned))); + BusinessFeeSourceChangedV1 payload = payload(source, action, sourceHash); + JsonNode payloadNode = objectMapper.valueToTree(payload); + String eventKey = EventTypes.BUSINESS_FEE_SOURCE_CHANGED + ":LASTLEG:" + + source.getSourceType() + ':' + source.getSourceFeeId() + ':' + source.getSourceVersion() + ':' + action; + if (mapper.findByEventKey(eventKey) != null) return; + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, + source.getCustomerId(), source.getSourceType(), String.valueOf(source.getSourceFeeId()), + source.getSourceVersion(), null, EventIdentifiers.newUuid(), eventKey, null, payloadNode, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); + LastlegEventOutbox row = new LastlegEventOutbox(); + row.setId(IdWorker.getId()); + row.setCustomerId(source.getCustomerId()); + row.setAggregateType(envelope.aggregateType()); + row.setAggregateId(envelope.aggregateId()); + row.setEventType(envelope.eventType()); + row.setEventVersion(envelope.eventVersion()); + row.setEventKey(eventKey); + row.setPayloadJson(json(envelope)); + row.setStatus("PENDING"); + row.setRetryCount(0); + row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); + mapper.insert(row); + } + + private BusinessFeeSourceChangedV1 payload(OverseasFeeSource value, String action, String hash) { + return new BusinessFeeSourceChangedV1(action, "LASTLEG", value.getSourceType(), + String.valueOf(value.getSourceFeeId()), value.getSourceVersion(), value.getCustomerId(), + value.getCustomerName(), value.getSourceNo(), value.getBusinessType(), text(value.getBusinessId()), + value.getBusinessNo(), text(value.getOrderId()), text(value.getInboundId()), value.getInboundCode(), + text(value.getContainerId()), text(value.getContainerBookingId()), value.getContainerNo(), + text(value.getWarehouseId()), value.getFeeItemCode(), value.getFeeItemName(), value.getDirection(), + text(value.getSupplierId()), value.getSupplierName(), value.getSupplierCategory(), + value.getSettlementMode(), instant(value.getPaidAt()), value.getPaymentMethod(), + value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds() == null ? List.of() : value.getPaymentVoucherFileIds(), + value.getBusinessDate(), value.getCurrency(), value.getAmount(), + value.getOriginalCurrency(), value.getOriginalAmount(), value.getExchangeRate(), value.getExchangeRateMonth(), + value.getChargeableQuantity(), value.getQuantityUnit(), value.getUnitPrice(), value.getPeriodStart(), value.getPeriodEnd(), + value.getReviewStatus(), + "PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash); + } + + private static String text(Long value) { return value == null ? null : String.valueOf(value); } + private static Instant instant(LocalDateTime value) { return value == null ? null : value.toInstant(ZoneOffset.UTC); } + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException("海外费用事件序列化失败", exception); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java new file mode 100644 index 000000000..b2fc2496c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java @@ -0,0 +1,264 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest; +import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +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.time.ZoneOffset; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class OverseasFeeSourceService { + static final Set OVERSEAS_CONTAINER_FEE_ITEMS = Set.of( + "PORT_FEE", "STORAGE_FEE", "CONTAINER_REPAIR_FEE", "CRANE_FEE", "TERMINAL_DRAYAGE_FEE", "OTHER"); + + private final OverseasFeeSourceMapper mapper; + private final OverseasFeeCommandMapper commands; + private final OverseasFeeBusinessGuard businessGuard; + private final OverseasFeeOutboxService outbox; + private final OverseasFeeCurrencyConverter currencyConverter; + private final ObjectMapper objectMapper; + + public List list(String customerId, String businessType, String businessNo, + Long containerBookingId) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(text(customerId) != null, OverseasFeeSource::getCustomerId, text(customerId)) + .eq(text(businessType) != null, OverseasFeeSource::getBusinessType, text(businessType)) + .like(text(businessNo) != null, OverseasFeeSource::getBusinessNo, text(businessNo)) + .eq(containerBookingId != null, OverseasFeeSource::getContainerBookingId, containerBookingId) + .inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id") + .orderByDesc(OverseasFeeSource::getCreatedAt).orderByDesc(OverseasFeeSource::getId)); + } + + public List listForBusiness(String customerId, String businessType, long businessId) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(OverseasFeeSource::getCustomerId, customerId) + .eq(OverseasFeeSource::getBusinessType, businessType) + .eq(OverseasFeeSource::getBusinessId, businessId) + .inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id") + .orderByAsc(OverseasFeeSource::getCreatedAt).orderByAsc(OverseasFeeSource::getId)); + } + + public List listContainerCustomers(long containerBookingId) { + return businessGuard.listContainerCustomers(containerBookingId); + } + + @Transactional + public OverseasFeeSource create(String operatorId, String key, SaveRequest request) { + OverseasFeeCommand replay = replay(operatorId, "CREATE", key, request); + if (replay != null) return current(replay.getSourceFeeId()); + validate(request); + businessGuard.requireOwned(request); + long sourceFeeId = IdWorker.getId(); + OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request); + mapper.insert(value); + command(operatorId, "CREATE", key, request, sourceFeeId, 1); + return value; + } + + @Transactional + public OverseasFeeSource publishApproved(String operatorId, String key, SaveRequest request) { + OverseasFeeSource value = create(operatorId, key, request); + if ("DRAFT".equals(value.getReviewStatus()) || "REJECTED".equals(value.getReviewStatus())) { + value = submit(value.getSourceFeeId(), operatorId, childKey(key, "submit")); + } + if ("PENDING".equals(value.getReviewStatus())) { + value = review(value.getSourceFeeId(), operatorId, + new ReviewRequest("APPROVE", "业务完成自动确认费用", childKey(key, "approve"))); + } + if (!"APPROVED".equals(value.getReviewStatus()) || !"PUBLISHED".equals(value.getPublicationStatus())) { + throw conflict("业务费用未能审核发布"); + } + return value; + } + + @Transactional + public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) { + OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId); + if (replay != null) return current(replay.getSourceFeeId()); + OverseasFeeSource value = lock(sourceFeeId); + if (!"DRAFT".equals(value.getReviewStatus()) && !"REJECTED".equals(value.getReviewStatus())) + throw conflict("只有草稿或已驳回费用可以提交"); + value.setReviewStatus("PENDING"); value.setReviewReason(null); + value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value); + command(operatorId, "SUBMIT", key, sourceFeeId, sourceFeeId, value.getSourceVersion()); + return value; + } + + @Transactional + public OverseasFeeSource review(long sourceFeeId, String operatorId, ReviewRequest request) { + OverseasFeeCommand replay = replay(operatorId, "REVIEW", request.idempotencyKey(), request); + if (replay != null) return current(replay.getSourceFeeId()); + OverseasFeeSource value = lock(sourceFeeId); + if (!"PENDING".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus())) + throw conflict("只有待审核的有效费用可以审核"); + boolean approved = "APPROVE".equals(request.decision()); + value.setReviewStatus(approved ? "APPROVED" : "REJECTED"); + value.setPublicationStatus(approved ? "PUBLISHED" : "UNPUBLISHED"); + value.setReviewReason(text(request.reason())); value.setReviewerId(operatorId); value.setReviewedAt(now()); + value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value); + if (approved) outbox.publish(value, value.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED"); + command(operatorId, "REVIEW", request.idempotencyKey(), request, sourceFeeId, value.getSourceVersion()); + return value; + } + + @Transactional + public OverseasFeeSource adjust(long sourceFeeId, String operatorId, AdjustRequest request) { + OverseasFeeCommand replay = replay(operatorId, "ADJUST", request.idempotencyKey(), request); + if (replay != null) return current(replay.getSourceFeeId()); + OverseasFeeSource old = lock(sourceFeeId); + if (!"APPROVED".equals(old.getReviewStatus()) || !"ACTIVE".equals(old.getSourceStatus()) + || old.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以调整"); + validate(request.fee()); businessGuard.requireOwned(request.fee()); + if (!old.getCustomerId().equals(request.fee().customerId().trim())) + throw conflict("调整不得改变费用所属客户"); + if (!old.getSourceType().equals(request.fee().sourceType()) + || !old.getBusinessType().equals(request.fee().businessType()) + || !Objects.equals(old.getBusinessId(), request.fee().businessId()) + || !old.getBusinessNo().equals(request.fee().businessNo().trim()) + || !Objects.equals(old.getContainerBookingId(), request.fee().containerBookingId()) + || !Objects.equals(old.getContainerNo(), text(request.fee().containerNo()))) + throw conflict("调整不得改变费用来源或业务归属"); + old.setSourceStatus("SUPERSEDED"); old.setUpdatedBy(operatorId); old.setUpdatedAt(now()); mapper.updateById(old); + OverseasFeeSource next = build(IdWorker.getId(), sourceFeeId, old.getSourceVersion() + 1, operatorId, request.fee()); + next.setReviewStatus("PENDING"); + mapper.insert(next); + command(operatorId, "ADJUST", request.idempotencyKey(), request, sourceFeeId, next.getSourceVersion()); + return next; + } + + @Transactional + public OverseasFeeSource voidSource(long sourceFeeId, String operatorId, VoidRequest request) { + OverseasFeeCommand replay = replay(operatorId, "VOID", request.idempotencyKey(), request); + if (replay != null) return current(replay.getSourceFeeId()); + OverseasFeeSource value = lock(sourceFeeId); + if (!"APPROVED".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus()) + || value.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以作废"); + value.setSourceStatus("VOIDED"); value.setReviewReason(request.reason().trim()); + value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value); + outbox.publish(value, "VOIDED"); + command(operatorId, "VOID", request.idempotencyKey(), request, sourceFeeId, value.getSourceVersion()); + return value; + } + + private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request) { + OverseasFeeSource value = new OverseasFeeSource(); + value.setId(id); value.setSourceFeeId(sourceFeeId); value.setSourceVersion(version); + value.setCustomerId(request.customerId().trim()); value.setCustomerName(text(request.customerName())); + value.setSourceType(request.sourceType()); value.setSourceNo(text(request.inboundCode()) == null ? request.businessNo().trim() : request.inboundCode().trim()); + value.setBusinessType(request.businessType()); value.setBusinessId(request.businessId()); value.setBusinessNo(request.businessNo().trim()); + value.setOrderId(request.orderId()); value.setInboundId(request.inboundId()); value.setInboundCode(text(request.inboundCode())); + value.setContainerId(request.containerId()); value.setContainerBookingId(request.containerBookingId()); value.setContainerNo(text(request.containerNo())); + value.setWarehouseId(request.warehouseId()); value.setFeeItemCode(request.feeItemCode().trim()); value.setFeeItemName(request.feeItemName().trim()); + value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName())); + value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode())); + value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo())); + value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate()); + OverseasFeeCurrencyConverter.Conversion conversion = currencyConverter.convert(request.currency(), request.amount(), request.businessDate()); + value.setCurrency("CNY"); value.setAmount(conversion.settlementAmount()); + value.setOriginalCurrency(conversion.originalCurrency()); value.setOriginalAmount(conversion.originalAmount()); + value.setExchangeRate(conversion.exchangeRate()); value.setExchangeRateMonth(conversion.exchangeRateMonth()); + value.setChargeableQuantity(request.chargeableQuantity()); value.setQuantityUnit(text(request.quantityUnit())); + value.setUnitPrice(request.unitPrice()); value.setPeriodStart(request.periodStart()); value.setPeriodEnd(request.periodEnd()); + value.setReviewStatus("DRAFT"); + value.setPublicationStatus("UNPUBLISHED"); value.setSourceStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId); + value.setCreatedAt(now()); value.setUpdatedAt(now()); + return value; + } + + private void validate(SaveRequest value) { + String expected = switch (value.businessType()) { + case "OVERSEAS_INBOUND" -> "OVERSEAS_INBOUND_FEE"; + case "OVERSEAS_PROCUREMENT" -> "OVERSEAS_PROCUREMENT_FEE"; + case "PROCUREMENT_FEE" -> "OVERSEAS_PROCUREMENT_FEE"; + case "US_TRUCK" -> "OVERSEAS_TRUCK_FEE"; + case "LOCAL_LOOSEBOARD" -> "OVERSEAS_LOOSEBOARD_FEE"; + case "WAREHOUSE_SERVICE" -> "OVERSEAS_WAREHOUSE_FEE"; + case "OVERSEAS_CONTAINER" -> "OVERSEAS_CONTAINER_FEE"; + case "LASTLEG_ORDER" -> "OVERSEAS_ORDER_FEE"; + case "DELIVERY_ORDER" -> "OVERSEAS_DELIVERY_FEE"; + case "RELABEL_ORDER" -> "OVERSEAS_RELABEL_FEE"; + default -> ""; + }; + if (!expected.equals(value.sourceType())) throw bad("来源类型与业务类型不匹配"); + if ("OVERSEAS_CONTAINER".equals(value.businessType()) + && !OVERSEAS_CONTAINER_FEE_ITEMS.contains(value.feeItemCode().trim())) + throw bad("海外货柜费用项目不受支持"); + if ((value.periodStart() == null) != (value.periodEnd() == null) + || value.periodStart() != null && value.periodStart().isAfter(value.periodEnd())) + throw bad("计费期间必须同时填写且开始日期不能晚于结束日期"); + if ((value.chargeableQuantity() == null) != (text(value.quantityUnit()) == null) + || value.unitPrice() != null && value.chargeableQuantity() == null) + throw bad("计费数量、单位和单价信息不完整"); + boolean payable = "PAYABLE".equals(value.direction()); + boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty(); + if (!payable) { + if (value.supplierId() != null || text(value.supplierName()) != null || text(value.settlementMode()) != null + || value.paidAt() != null || text(value.paymentMethod()) != null || text(value.paymentReferenceNo()) != null || files) + throw bad("应收费用不得携带供应商或付款信息"); + return; + } + if (value.supplierId() == null || text(value.supplierName()) == null || !Set.of("ON_ACCOUNT", "DIRECT_PAID").contains(value.settlementMode())) + throw bad("应付费用必须携带供应商和结算方式"); + if ("ON_ACCOUNT".equals(value.settlementMode())) { + if (value.paidAt() != null || text(value.paymentMethod()) != null || text(value.paymentReferenceNo()) != null || files) + throw bad("账期应付不得携带已付信息"); + } else if (value.paidAt() == null || !Set.of("ONLINE", "BANK_TRANSFER", "CASH", "OTHER").contains(value.paymentMethod()) + || (text(value.paymentReferenceNo()) == null && !files)) throw bad("直接已付信息不完整"); + } + + private OverseasFeeSource current(long sourceFeeId) { + OverseasFeeSource value = mapper.selectOne(new LambdaQueryWrapper() + .eq(OverseasFeeSource::getSourceFeeId, sourceFeeId).orderByDesc(OverseasFeeSource::getSourceVersion).last("LIMIT 1")); + if (value == null) throw notFound(); return value; + } + private OverseasFeeSource lock(long sourceFeeId) { OverseasFeeSource value = mapper.lockCurrent(sourceFeeId); if (value == null) throw notFound(); return value; } + private OverseasFeeCommand replay(String operator, String action, String key, Object request) { + OverseasFeeCommand existing = commands.find(operator, action, key.trim()); + if (existing != null && !existing.getRequestHash().equals(hash(request))) throw conflict("幂等键已被不同请求使用"); + return existing; + } + + private static String childKey(String key, String suffix) { + String prefix = key == null ? "" : key.trim(); + int maxPrefix = Math.max(0, 128 - suffix.length() - 1); + return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix; + } + private void command(String operator, String action, String key, Object request, long sourceFeeId, int version) { + OverseasFeeCommand value = new OverseasFeeCommand(); value.setId(IdWorker.getId()); value.setOperatorId(operator); + value.setAction(action); value.setIdempotencyKey(key.trim()); value.setRequestHash(hash(request)); value.setSourceFeeId(sourceFeeId); + value.setSourceVersion(version); value.setCreatedAt(now()); commands.insert(value); + } + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsBytes(value))); } + catch (NoSuchAlgorithmException | JsonProcessingException exception) { throw new IllegalStateException(exception); } + } + private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); } + private static String text(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "海外费用来源不存在"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java new file mode 100644 index 000000000..76699da30 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -0,0 +1,175 @@ +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.ConvertContainerInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; +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.dto.UpdateInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import com.baiyue.lastleg.inboundplan.service.InboundPlanService; +import com.baiyue.lastleg.inboundplan.service.ContainerInboundPlanConversionCoordinator; +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 ContainerInboundPlanConversionCoordinator containerConversion; + @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("/manual") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") + @Operation(summary = "手工填写并创建单票入仓计划", description = "要求订单版本和 Idempotency-Key;仓库主档、入仓码及客户归属均由服务端校验。整柜最后一票计划保存后,服务端自动生成并绑定全柜托盘码。Required authority: " + + OverseasPermissions.INBOUND_ORDER_CREATE) + public ApiResponse manual(@AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody ManualInboundPlanRequest request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可手工创建入仓计划"); + } + convertScope(user, request.customerId()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(containerConversion.convertManualAndAutoMaterialize( + request, idempotencyKey, operatorId)); + } + + @PostMapping("/convert-container") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") + @Operation(summary = "按整柜批量生成剩余入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;保留已有有效计划,只为剩余入仓码分别生成独立计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) + public ApiResponse> convertContainer( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody ConvertContainerInboundPlanRequest request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可按整柜生成入库计划"); + } + return ApiResponse.success(containerConversion.confirm(request, idempotencyKey)); + } + + @GetMapping("/container-preview") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @Operation(summary = "预览按柜生成入库计划", description = "按稳定 sourceContainerBookingId 返回全柜服务端分组、托数、就绪与阻断事实;确认时必须回传 snapshotVersion/snapshotHash。Required authority: " + + OverseasPermissions.INBOUND_ORDER_LIST) + public ApiResponse previewContainer( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String sourceContainerBookingId) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可预览整柜入库计划"); + } + return ApiResponse.success(service.previewContainer(sourceContainerBookingId)); + } + + @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)); + } + + @PutMapping("/{planNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @Operation(summary = "编辑入仓计划") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @Valid @RequestBody UpdateInboundPlanRequest request) { + return ApiResponse.success(service.update(scope(user, request.customerId()), planNo, request)); + } + + @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()); + } + + private String convertScope(AuthenticatedUser user, String requestedCustomer) { + if (user != null && "PLATFORM".equals(user.appCode()) && user.tenantId() != null && user.tenantId() == 0L + && (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer.trim()))) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台生成入库计划必须指定客户"); + } + return scope(user, requestedCustomer); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/MerchantInboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/MerchantInboundPlanController.java new file mode 100644 index 000000000..9b3f67a2c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/MerchantInboundPlanController.java @@ -0,0 +1,49 @@ +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.InboundPlanListResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.service.InboundPlanService; +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.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/inbound-plans") +@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_INBOUND_LIST + "')") +public class MerchantInboundPlanController { + @Resource private InboundPlanService service; + + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid InboundPlanRequests.Query query) { + return ApiResponse.success(service.page(customer(user), query)); + } + + @GetMapping("/{planNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo) { + return ApiResponse.success(service.detailByPlanNo(customer(user), planNo)); + } + + 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/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/ContainerInboundPlanPreviewResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java new file mode 100644 index 000000000..639b9851e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java @@ -0,0 +1,69 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.math.BigDecimal; +import java.util.List; + +/** Server-calculated cabinet conversion preview; callers must echo version/hash when confirming. */ +public record ContainerInboundPlanPreviewResponse( + String sourceContainerBookingId, + String containerNo, + String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + long snapshotVersion, + String snapshotHash, + int totalOrderCount, + int readyOrderCount, + int blockedOrderCount, + int pendingOrderCount, + int pendingPalletCount, + int sourcePalletCount, + int supplementPalletCount, + int existingPlannedPalletCount, + int totalSuggestedPalletCount, + int totalPlannedPalletCount, + boolean ready, + List groups +) { + public record Group( + String destinationType, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + int orderCount, + int pendingOrderCount, + int pendingPalletCount, + int sourcePalletCount, + int supplementPalletCount, + int existingPlannedPalletCount, + int suggestedPalletCount, + int plannedPalletCount, + List orders + ) { } + + public record Order( + String customerId, + String customerName, + String orderId, + String businessOrderNo, + String inboundCode, + String orderType, + String destinationType, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + String fbaWarehouseCode, + int packageCount, + BigDecimal volumeCbm, + Integer suggestedPalletCount, + int sourcePalletCount, + int targetPalletCount, + int supplementPalletCount, + Integer plannedPalletCount, + String existingPlanNo, + String planStatus, + int orderVersion, + String readiness, + List blockers + ) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertContainerInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertContainerInboundPlanRequest.java new file mode 100644 index 000000000..99dfc9c1d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertContainerInboundPlanRequest.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** 平台按来源头程柜预订身份批量生成入库计划。 */ +public record ConvertContainerInboundPlanRequest( + @NotBlank @Size(max = 128) String sourceContainerBookingId, + @Min(0) long snapshotVersion, + @NotBlank @Pattern(regexp = "^[0-9a-f]{64}$") String snapshotHash +) { +} 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..b4111e85b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java @@ -0,0 +1,37 @@ +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 lxCode, + String orderType, + String overseasWarehouseCode, + String overseasWarehouseName, + String warehouseId, + String warehouseCode, + String warehouseName, + String fbaWarehouseCode, + String customerName, + Integer packageCount, + Integer pickedPackageCount, + BigDecimal volumeCbm, + Integer palletCount, + boolean assigned, + boolean mergeAllowed, + boolean urgent, + String status, + String appointmentNo, + String appointmentStatus, + LocalDateTime appointmentAt, + LocalDateTime expectedLoadingAt, + String dispatchStatus, + LocalDateTime createdAt, + Integer version +) { } 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..a1c844d0d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java @@ -0,0 +1,36 @@ +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 lxCode, + 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..f36ce2706 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.LocalDate; +import java.util.List; + +public record InboundPlanResponse( + Long id, + String planNo, + String customerId, + Long sourceOrderId, + String sourceOrderNo, + String customerName, + String orderType, + String containerNo, + String overseasWarehouseCode, + String overseasWarehouseName, + String status, + int lineCount, + int palletCount, + BigDecimal totalVolumeCbm, + int expectedPackageCount, + int pickedPackageCount, + List lines, + List pallets, + List allocations, + int version, + LocalDate plannedArrivalDate, + String palletType, + boolean assigned, + boolean mergeRequired, + boolean urgent, + String creationMode, + String remark, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + 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 palletType, String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm, + int actualPackageCount) { + } + + public record Allocation(Long lineId, Long palletId, String palletNo, String palletOwnerPlanNo, + boolean palletOwnedByCurrentPlan, 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/ManualInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java new file mode 100644 index 000000000..98e168f95 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inboundplan.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.Size; + +public record ManualInboundPlanRequest( + @NotBlank @Size(max = 128) String customerId, + @NotNull Long orderId, + @NotNull @Min(0) Integer expectedOrderVersion, + @NotNull @Min(1) @Max(999) Integer palletCount, + @NotBlank @Size(max = 32) String palletType, + @NotNull Boolean assigned, + @NotNull Boolean mergeRequired, + @NotNull Boolean urgent, + @Size(max = 1000) String remark +) { } 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/dto/UpdateInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateInboundPlanRequest.java new file mode 100644 index 000000000..77bf05034 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateInboundPlanRequest.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record UpdateInboundPlanRequest( + @NotBlank String customerId, + @NotNull Long overseasWarehouseId, + String warehouseCode, + String warehouseName, + @NotNull Integer version +) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/ContainerPlanConversionCommand.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/ContainerPlanConversionCommand.java new file mode 100644 index 000000000..8130c3593 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/ContainerPlanConversionCommand.java @@ -0,0 +1,28 @@ +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_container_plan_conversion_command") +public class ContainerPlanConversionCommand { + @TableId(type = IdType.INPUT) private Long id; + private String sourceContainerBookingId; + private String idempotencyKey; + private String requestHash; + private Long snapshotVersion; + private String snapshotHash; + private Long warehouseId; + private String batchKey; + private String status; + private String leaseToken; + private String preparationJson; + private String resultJson; + private String lastError; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} 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..5a675332c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java @@ -0,0 +1,38 @@ +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 com.baomidou.mybatisplus.annotation.Version; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.LocalDate; + +@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 LocalDate plannedArrivalDate; + private String palletType; + private Boolean assignedFlag; + private Boolean mergeRequired; + private Boolean urgentFlag; + private String creationMode; + private String planRemark; + private String status; + private Integer lineCount; + private Integer palletCount; + private BigDecimal totalVolumeCbm; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @Version + private Integer version; +} 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..409059fca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java @@ -0,0 +1,37 @@ +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 com.baomidou.mybatisplus.annotation.Version; +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 sourcePalletNo; + private String palletType; + 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; + @Version + private Integer version; +} 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/ContainerPlanConversionCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java new file mode 100644 index 000000000..a4bc87cc3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand; +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; + +@Mapper +public interface ContainerPlanConversionCommandMapper extends BaseMapper { + @Select("SELECT * FROM ll_container_plan_conversion_command WHERE source_container_booking_id=#{bookingId} AND idempotency_key=#{key} LIMIT 1") + ContainerPlanConversionCommand find(@Param("bookingId") String bookingId, @Param("key") String key); + + @Select("SELECT * FROM ll_container_plan_conversion_command WHERE source_container_booking_id=#{bookingId} LIMIT 1") + ContainerPlanConversionCommand findByBooking(@Param("bookingId") String bookingId); + + @Update("UPDATE ll_container_plan_conversion_command SET status=#{status},result_json=COALESCE(#{resultJson},result_json),last_error=#{lastError},updated_at=#{updatedAt} WHERE id=#{id} AND status=#{expectedStatus} AND lease_token=#{leaseToken}") + int updateState(@Param("id") long id, @Param("expectedStatus") String expectedStatus, + @Param("leaseToken") String leaseToken, + @Param("status") String status, + @Param("resultJson") String resultJson, @Param("lastError") String lastError, + @Param("updatedAt") LocalDateTime updatedAt); + + @Update("UPDATE ll_container_plan_conversion_command SET status=#{nextStatus},lease_token=#{leaseToken},last_error=NULL,updated_at=#{updatedAt} WHERE id=#{id} AND status=#{expectedStatus}") + int claim(@Param("id") long id, @Param("expectedStatus") String expectedStatus, + @Param("nextStatus") String nextStatus, @Param("leaseToken") String leaseToken, + @Param("updatedAt") LocalDateTime updatedAt); + + @Update("UPDATE ll_container_plan_conversion_command SET lease_token=#{leaseToken},last_error=NULL,updated_at=#{updatedAt} WHERE id=#{id} AND status='APPLYING' AND updated_at=#{expectedUpdatedAt}") + int reclaimStale(@Param("id") long id, @Param("expectedUpdatedAt") LocalDateTime expectedUpdatedAt, + @Param("leaseToken") String leaseToken, @Param("updatedAt") LocalDateTime updatedAt); + + @Update(""" + UPDATE ll_container_plan_conversion_command + SET idempotency_key=#{key},request_hash=#{requestHash},snapshot_version=#{snapshotVersion}, + snapshot_hash=#{snapshotHash},warehouse_id=#{warehouseId},batch_key=#{batchKey}, + status='PREPARED',lease_token=NULL,preparation_json=#{preparationJson},result_json=NULL,last_error=NULL, + updated_at=#{updatedAt} + WHERE id=#{id} AND status='FAILED' + """) + int replaceFailed(@Param("id") long id, @Param("key") String key, + @Param("requestHash") String requestHash, @Param("snapshotVersion") long snapshotVersion, + @Param("snapshotHash") String snapshotHash, @Param("warehouseId") long warehouseId, + @Param("batchKey") String batchKey, @Param("preparationJson") String preparationJson, + @Param("updatedAt") LocalDateTime updatedAt); +} 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..c8603fe77 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java @@ -0,0 +1,47 @@ +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 * FROM ll_inbound_plan_carton WHERE customer_id=#{customerId} AND (warehouse_pallet_id=#{palletId} OR plan_pallet_id=#{palletId}) ORDER BY id") + List listByPallet(@Param("customerId") String customerId, @Param("palletId") Long palletId); + @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..9ca38ae2d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -0,0 +1,141 @@ +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 { + record ContainerOrderRef(String customerId, Long orderId, String currentStatus, String containerNo, + String orderType) { } + record ReceivingWarehouseSnapshot(String overseasWarehouseId, String overseasWarehouseCode, + String overseasWarehouseName) { } + record DetailOrderSnapshot(String orderNo, String customerName, String orderType) { } + record ContainerWarehouseRef(String customerId, Long orderId, String sourceContainerBookingId, + String overseasWarehouseId) { } + record ContainerPreviewOrderRow( + String customerId, Long orderId, String orderNo, String sourceOrderNo, String customerName, + String orderType, String destinationType, String inboundCode, String currentStatus, + String inboundOrderNo, String containerNo, int recommendedPalletCount, int packageCount, + java.math.BigDecimal volumeCbm, String overseasWarehouseId, String overseasWarehouseCode, + String overseasWarehouseName, String fbaWarehouseCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, int destinationCount, + boolean detained, int version, String planNo, String planStatus, + Integer plannedPalletCount, Integer materializedPalletCount, Integer sourcePalletCount + ) { } + + @Select("SELECT o.customer_id AS customerId,o.id AS orderId,o.order_no AS orderNo," + + "o.source_order_no AS sourceOrderNo,o.customer_name AS customerName,o.order_type AS orderType," + + "o.destination_type AS destinationType,o.inbound_code AS inboundCode,o.current_status AS currentStatus," + + "o.inbound_order_no AS inboundOrderNo,o.container_no AS containerNo,COALESCE(o.pallet_count,0) AS recommendedPalletCount," + + "COALESCE(o.package_count,0) AS packageCount,COALESCE(o.volume_cbm,0) AS volumeCbm," + + "o.overseas_warehouse_id AS overseasWarehouseId,o.overseas_warehouse_code AS overseasWarehouseCode," + + "o.overseas_warehouse_name AS overseasWarehouseName,o.fba_warehouse_code AS fbaWarehouseCode," + + "COALESCE(NULLIF(o.transfer_warehouse_id,''),(SELECT MIN(NULLIF(d.destination_warehouse_id,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseId," + + "COALESCE(NULLIF(o.transfer_warehouse_code,''),(SELECT MIN(NULLIF(d.destination_warehouse_code,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseCode," + + "COALESCE(NULLIF(o.transfer_warehouse_name,''),(SELECT MIN(NULLIF(d.destination_warehouse_name,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseName," + + "CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 ELSE " + + "(SELECT COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) FROM ll_order_detail d WHERE d.order_id=o.id) END AS destinationCount," + + "o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," + + "p.pallet_count AS plannedPalletCount," + + "(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + + "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL) AS materializedPalletCount," + + "(SELECT COUNT(*) FROM ll_order_source_pallet sp WHERE sp.customer_id=o.customer_id " + + "AND sp.order_id=o.id) AS sourcePalletCount " + + "FROM ll_order o LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.source_order_id=o.id " + + "WHERE o.source_container_booking_id=#{sourceContainerBookingId} ORDER BY o.customer_id,o.id") + List containerPreviewOrders( + @Param("sourceContainerBookingId") String sourceContainerBookingId); + + @Select("SELECT c.customer_id AS customerId,c.id AS orderId," + + "c.source_container_booking_id AS sourceContainerBookingId,c.overseas_warehouse_id AS overseasWarehouseId " + + "FROM ll_order target JOIN ll_order c ON c.source_container_booking_id=target.source_container_booking_id " + + "WHERE target.customer_id=#{customerId} AND target.id=#{orderId} " + + "AND target.source_container_booking_id IS NOT NULL AND target.source_container_booking_id!='' " + + "ORDER BY c.customer_id,c.id FOR UPDATE") + List lockContainerWarehouseFacts(@Param("customerId") String customerId, + @Param("orderId") Long orderId); + + @Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus,container_no AS containerNo," + + "order_type AS orderType " + + "FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE") + List lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId); + + @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 source_container_booking_id FROM ll_order " + + "WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") + String findSourceContainerBookingId(@Param("customerId") String customerId, + @Param("orderId") Long orderId); + + @Select("SELECT estimated_warehouse_at FROM ll_order " + + "WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id") + List containerEstimatedWarehouseTimes( + @Param("sourceContainerBookingId") String sourceContainerBookingId); + + @Select("SELECT order_type FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") + String findOrderType(@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," + + "COALESCE(o.pallet_count,0) AS recommendedPalletCount,o.overseas_warehouse_id AS overseasWarehouseId," + + "o.overseas_warehouse_code AS overseasWarehouseCode,o.overseas_warehouse_name AS overseasWarehouseName," + + "NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," + + "COALESCE(NULLIF(o.transfer_warehouse_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," + + "CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " + + "ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," + + "0 AS destinationMismatchCount,o.detained AS detained,o.version AS version " + + "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.pallet_count," + + "o.overseas_warehouse_id,o.overseas_warehouse_code,o.overseas_warehouse_name,o.fba_warehouse_code," + + "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name,o.detained,o.version " + + "ORDER BY o.id FOR UPDATE") + List lockOrder(@Param("customerId") String customerId, + @Param("orderId") Long orderId); + + @Select("SELECT overseas_warehouse_id AS overseasWarehouseId," + + "overseas_warehouse_code AS overseasWarehouseCode,overseas_warehouse_name AS overseasWarehouseName " + + "FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") + ReceivingWarehouseSnapshot findReceivingWarehouse(@Param("customerId") String customerId, + @Param("orderId") Long orderId); + + @Select("SELECT order_no AS orderNo,customer_name AS customerName,order_type AS orderType " + + "FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") + DetailOrderSnapshot findDetailOrder(@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); + + @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + + "pallet_merge_flag=CASE WHEN #{mergeRequired}=1 THEN 'MERGE_REQUIRED' ELSE NULL END," + + "pallet_count=#{palletCount},version=version+1,updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND id=#{orderId} AND version=#{expectedVersion} AND detained=0 " + + "AND current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') AND inbound_order_no IS NULL") + int markPlannedManual(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("planNo") String planNo, @Param("palletCount") int palletCount, + @Param("mergeRequired") boolean mergeRequired, + @Param("expectedVersion") int expectedVersion, + @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..8409e4e1d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -0,0 +1,62 @@ +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 o.lx_code LIKE CONCAT('%',#{q.lxCode},'%')" + + " AND p.status=#{q.status}" + + " AND o.overseas_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.customer_id=#{customerId} AND di.order_id=l.order_id)" + + " AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)" + + " AND 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..dd67b0d6d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java @@ -0,0 +1,38 @@ +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, + int recommendedPalletCount, + String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + String fbaWarehouseCode, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + int destinationCount, + int destinationMismatchCount, + boolean detained, + int version +) { + public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode, + String currentStatus, String inboundOrderNo, BigDecimal volumeCbm, + int packageCount, int recommendedPalletCount, String overseasWarehouseId, + String overseasWarehouseCode, String overseasWarehouseName, String fbaWarehouseCode, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, int destinationCount, int destinationMismatchCount) { + this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm, + packageCount, recommendedPalletCount, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, + fbaWarehouseCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, + destinationCount, destinationMismatchCount, false, 0); + } +} 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..783c55b38 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -0,0 +1,173 @@ +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_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +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()); + } + + @GetMapping("/current/domestic-loading-photos/{evidenceType}/content") + public ResponseEntity downloadDomesticLoadingPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String evidenceType) { + PdaWarehouseIdentity identity = currentIdentity(user); + var download = service.downloadDomesticLoadingPhoto(identity.operatorKey(), evidenceType); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.inline().filename(download.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(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/receipts/pallet") + public ApiResponse receiveWholePallet( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.WholePalletReceive request) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.receiveWholePallet(identity.operatorKey(), identity.operatorName(), request)); + } + + @PostMapping("/current/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.Complete 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..d05ea8f4a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java @@ -0,0 +1,42 @@ +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 WholePalletOperation(@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, Long planPalletId, Integer packageCount, + @NotBlank String idempotencyKey, String operatorId, String operatorName) { + /** Backward-compatible single-carton request with an explicitly selected pallet. */ + public ScanCarton(String cartonMark, long planPalletId, String idempotencyKey, + String operatorId, String operatorName) { + this(cartonMark, planPalletId, 1, idempotencyKey, operatorId, operatorName); + } + } + + public record ForceComplete(@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..a1fce95bd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -0,0 +1,80 @@ +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, int expectedPackageCount, + int pickedPackageCount, String orderType, boolean wholePalletInbound, + List dropshipPallets, String inboundMode, + List pallets, List allocations, + List expectedCartons) { + public Task(long taskId, String taskNo, String planNo, String status, + LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, + List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, + LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount, + int pickedPackageCount) { + this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt, + unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt, + expectedPackageCount, pickedPackageCount, null, false, List.of(), null, + List.of(), List.of(), List.of()); + } + + public Task(long taskId, String taskNo, String planNo, String status, + LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, + List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, + LocalDateTime estimatedEmptyReturnAt) { + this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt, + unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt, 0, 0); + } + } + + public record DropshipPallet(String sourcePalletNo, long planPalletId, long allocationId, + long packageCount, List skus) {} + public record DropshipSku(String skuCode, String productName, long packageCount, long productQuantity) {} + + 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, String palletNo, int scannedPackageCount) { + public Carton(String result, String cartonMark, long planPalletId, long allocationId, + int palletActualPackageCount) { + this(result, cartonMark, planPalletId, allocationId, palletActualPackageCount, null, 1); + } + } +} 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..424d1a0de --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -0,0 +1,36 @@ +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 Complete(boolean allowDifference, + @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) { } + /** packageCount 是该托当前累计实收箱数,不是本次增量。 */ + public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode, + @Positive int packageCount, Integer expectedPalletVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { + public BulkReceive(String inboundCode, String palletCode, int packageCount, String idempotencyKey) { + this(inboundCode, palletCode, packageCount, null, idempotencyKey); + } + } + public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode, + @NotBlank @Size(max = 255) String idempotencyKey) { } + public record WholePalletReceive(@NotBlank String sourcePalletNo, + @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..d1e1cb497 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -0,0 +1,143 @@ +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 sourceContainerBookingId, String containerNo, + String overseasWarehouseCode, String overseasWarehouseName, String status, + int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount, + int planCount, int plannedInboundCodeCount, int pendingPlanCount, + List destinationCodes, int plannedPalletCount, + int totalPackages, int receivedPackages, + boolean operable, boolean allPlansReady, String blockReason, + LocalDateTime plannedArrivalAt, boolean currentAccount) { + public Candidate(String sourceContainerBookingId, String containerNo, String status, + int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount, + int planCount, int plannedInboundCodeCount, int pendingPlanCount, + List destinationCodes, int plannedPalletCount, int totalPackages, + int receivedPackages, boolean operable, boolean allPlansReady, + String blockReason, LocalDateTime plannedArrivalAt, boolean currentAccount) { + this(sourceContainerBookingId, containerNo, null, null, status, totalOrderCount, + inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, + pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, + receivedPackages, operable, allPlansReady, blockReason, plannedArrivalAt, currentAccount); + } + } + + public record WarehouseProfile(String warehouseId, String warehouseCode, String warehouseName) { } + + public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode, + String overseasWarehouseName, + String status, String operatorName, int version, + List photos, List domesticLoadingPhotos, + LocalDateTime preparedAt, LocalDateTime signedAt, + LocalDateTime receivingAt, LocalDateTime completedAt, + int totalPackages, int receivedPackages, int pendingPackages, + boolean hasDifference, List inbounds) { + public 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) { + this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName, + version, photos, List.of(), preparedAt, signedAt, receivingAt, completedAt, totalPackages, + receivedPackages, pendingPackages, false, inbounds); + } + + public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode, + 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) { + this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, + status, operatorName, version, photos, List.of(), preparedAt, signedAt, receivingAt, + completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds); + } + } + + public record SignedPhoto(ReceivingPhotoRole role, String fileId, String downloadPath) { } + + public record DomesticLoadingPhoto(String evidenceType, String label, 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, + boolean assigned, boolean mergeAllowed, boolean urgent, + String mergeHint, String orderType, boolean wholePalletInbound) { } + + 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, + boolean assigned, boolean mergeAllowed, boolean urgent, + String mergeHint, String operationMode, String groupKey, + List inboundCodes, String sourcePalletNo) { + public 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, boolean assigned, boolean mergeAllowed, + boolean urgent, String mergeHint) { + this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName, + destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, + fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages, pendingPackages, + assigned, mergeAllowed, urgent, mergeHint, null, null, List.of(inboundCode), null); + } + public 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) { + this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName, + destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, + fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages, + pendingPackages, false, false, false, null, null, null, List.of(inboundCode), null); + } + } + + public record PalletResolved(String palletCode, long warehouseId, String status, + String fbaWarehouseCode, String inboundCode, + String destinationType, boolean compatible, int version, + int currentReceivedPackages) { + public PalletResolved(String palletCode, long warehouseId, String status, + String fbaWarehouseCode, String inboundCode, + String destinationType, boolean compatible) { + this(palletCode, warehouseId, status, fbaWarehouseCode, inboundCode, + destinationType, compatible, 0, 0); + } + } + + public record Receipt(String result, String containerNo, String inboundCode, + String destinationType, String palletCode, int acceptedPackages, + int receivedPackages, int pendingPackages, String sessionStatus, + boolean assigned, boolean mergeAllowed, boolean urgent, + String mergeHint, String groupKey, List inboundCodes) { + public Receipt(String result, String containerNo, String inboundCode, + String destinationType, String palletCode, int acceptedPackages, + int receivedPackages, int pendingPackages, String sessionStatus, + boolean assigned, boolean mergeAllowed, boolean urgent, String mergeHint) { + this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, + receivedPackages, pendingPackages, sessionStatus, assigned, mergeAllowed, urgent, + mergeHint, null, List.of(inboundCode)); + } + public Receipt(String result, String containerNo, String inboundCode, + String destinationType, String palletCode, int acceptedPackages, + int receivedPackages, int pendingPackages, String sessionStatus) { + this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, + receivedPackages, pendingPackages, sessionStatus, false, false, false, null, + null, List.of(inboundCode)); + } + } + + 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/ContainerLoadingEvidence.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ContainerLoadingEvidence.java new file mode 100644 index 000000000..61b150f51 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ContainerLoadingEvidence.java @@ -0,0 +1,22 @@ +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.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("ll_container_loading_evidence") +public class ContainerLoadingEvidence { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String sourceContainerBookingId; + private String evidenceType; + private Long sourceFileId; + private String fileName; + private LocalDateTime createdAt; +} 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..c3b936225 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java @@ -0,0 +1,38 @@ +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 String sourcePalletNo; + 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..e33c0ac3c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java @@ -0,0 +1,23 @@ +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 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..17508259b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java @@ -0,0 +1,40 @@ +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 sourceContainerBookingId; + private String containerFactHash; + private String containerNo; + private Long overseasWarehouseId; + private String overseasWarehouseCode; + 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/ReceivingFbaPalletCount.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingFbaPalletCount.java new file mode 100644 index 000000000..98baa0053 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingFbaPalletCount.java @@ -0,0 +1,21 @@ +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_fba_pallet_count") +public class ReceivingFbaPalletCount { + @TableId(type = IdType.INPUT) + private Long id; + private Long sessionId; + private String groupKey; + private String palletCode; + private Integer actualPackages; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} 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/DomesticLoadingEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/DomesticLoadingEvidenceGateway.java new file mode 100644 index 000000000..e079b48b6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/DomesticLoadingEvidenceGateway.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +public interface DomesticLoadingEvidenceGateway { + DownloadedFile download(long containerBookingId, long fileId); + + record DownloadedFile(byte[] content, String fileName, String contentType) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpDomesticLoadingEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpDomesticLoadingEvidenceGateway.java new file mode 100644 index 000000000..4f5a92e15 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpDomesticLoadingEvidenceGateway.java @@ -0,0 +1,68 @@ +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.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import org.springframework.web.server.ResponseStatusException; + +@Component +public class HttpDomesticLoadingEvidenceGateway implements DomesticLoadingEvidenceGateway { + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.domestic-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.getDomesticLoadingEvidence()); + } + + @Override + public DownloadedFile download(long containerBookingId, long fileId) { + ResponseEntity response; + try { + response = client.get() + .uri("/internal/file/v1/loading-evidence/{containerBookingId}/{fileId}/content", + containerBookingId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::is4xxClientError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().value() == 404 + ? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST, "国内装柜照片不可用"); + }) + .onStatus(HttpStatusCode::is5xxServerError, (request, result) -> { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "国内装柜照片暂时无法加载"); + }).toEntity(byte[].class); + } catch (ResponseStatusException exception) { + throw exception; + } catch (RestClientException exception) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "国内装柜照片暂时无法加载", exception); + } + byte[] content = response.getBody() == null ? new byte[0] : response.getBody(); + MediaType mediaType = response.getHeaders().getContentType(); + if (content.length == 0 || mediaType == null || !"image".equalsIgnoreCase(mediaType.getType())) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "国内装柜照片返回内容无效"); + } + String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION); + String fileName = null; + try { fileName = disposition == null ? null : ContentDisposition.parse(disposition).getFilename(); } + catch (IllegalArgumentException ignored) { } + String contentType = mediaType.toString(); + return new DownloadedFile(content, fileName == null ? fileId + ".bin" : fileName, contentType); + } +} 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..86caa1c1c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -0,0 +1,324 @@ +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().unitPrice(), 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 warehouseReservationFailure(result.getStatusCode()); + }) + .body(ReserveEnvelope.class); + if (response == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse resource reservation returned no response"); + } + if (response.data() == null) { + HttpStatus status = "A0409".equals(response.code()) ? HttpStatus.CONFLICT + : "A0404".equals(response.code()) ? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() + ? "Warehouse resource reservation was rejected" : response.message()); + } + 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 = lookupPallet(palletCode); + return reference(response.data()); + } + + @Override + public WarehousePalletReference resolveAvailablePallet(String palletCode) { + WarehousePalletResponse response = lookupPallet(palletCode); + requireReservable(response.data()); + return reference(response.data()); + } + + @Override + public WarehousePalletReference inspectPallet(String palletCode) { + return reference(lookupPallet(palletCode).data()); + } + + @Override + public WarehousePalletReference resolveReservedPallet(String palletCode, String customerId, long warehouseId, + long palletId, long locationId, long resourceLockId) { + if (customerId == null || customerId.isBlank() || warehouseId <= 0 || palletId <= 0 + || locationId <= 0 || resourceLockId <= 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Reserved pallet binding is incomplete"); + } + try { + Long.parseLong(customerId); + } catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Reserved pallet customer binding is invalid"); + } + WarehousePalletReference pallet; + try { + pallet = reference(lookupPallet(palletCode).data()); + } catch (ResponseStatusException exception) { + if (exception.getStatusCode().value() != 404) throw exception; + // 已生成计划可能保留早期展示码;确认过的资源绑定仍是唯一可信物理身份。 + pallet = resolvePallet(palletId); + } + if (pallet.palletId() != palletId || pallet.warehouseId() != warehouseId || pallet.locationId() != locationId + || !"OCCUPIED".equals(pallet.status())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Scanned pallet does not match the reserved inbound plan resource"); + } + return pallet; + } + + @Override + public WarehousePalletReference resolvePallet(long palletId) { + WarehousePalletResponse response = client.get().uri("/internal/v1/warehouse-pallets/resolve/id/{id}", palletId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw palletLookupFailure(result.getStatusCode()); }) + .body(WarehousePalletResponse.class); + if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse pallet service returned no usable response"); + return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(), + response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(), + response.data().unitPrice(), response.data().version()); + } + + private ResponseStatusException palletLookupFailure(HttpStatusCode status) { + return new ResponseStatusException(status.value() == 404 ? HttpStatus.NOT_FOUND + : (status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE), + status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed"); + } + + private WarehousePalletResponse lookupPallet(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 palletLookupFailure(result.getStatusCode()); + }).body(WarehousePalletResponse.class); + if (response != null && "A0404".equals(response.code())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found"); + } + if (response == null || response.data() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse pallet service returned no usable response"); + } + return response; + } + + private WarehousePalletReference reference(WarehousePalletData pallet) { + return new WarehousePalletReference(pallet.id(), pallet.palletCode(), pallet.warehouseId(), + pallet.locationId(), pallet.status(), pallet.fbaWarehouseCode(), pallet.unitPrice(), pallet.version()); + } + + private ResponseStatusException warehouseReservationFailure(HttpStatusCode status) { + HttpStatus mapped = status.value() == 409 ? HttpStatus.CONFLICT + : status.value() == 404 ? HttpStatus.NOT_FOUND + : status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE; + return new ResponseStatusException(mapped, "Warehouse resource reservation was rejected"); + } + + private void requireReservable(WarehousePalletData pallet) { + if (pallet.reservable()) return; + String reason = switch (pallet.reservationBlockReason() == null + ? "RESOURCE_STATE_UNKNOWN" : pallet.reservationBlockReason()) { + case "PALLET_NOT_IDLE" -> "Pallet is not idle"; + case "LOCATION_NOT_FOUND" -> "Pallet location was not found"; + case "VIRTUAL_LOCATION_NOT_ACTIVE" -> "Pallet virtual location is not active"; + case "LOCATION_NOT_IDLE" -> "Pallet location is not idle"; + default -> "Pallet resource availability could not be verified"; + }; + throw new ResponseStatusException(HttpStatus.CONFLICT, reason); + } + + @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(); + } + + @Override + public PalletBatchResponse ensurePalletBatch(PalletBatchEnsure request) { + return palletBatch("/internal/v1/warehouse-pallet-batches/ensure-reserved", request); + } + + @Override + public PalletBatchResponse confirmPalletBatch(PalletBatchCommand request) { + return palletBatch("/internal/v1/warehouse-pallet-batches/confirm", request); + } + + @Override + public PalletBatchResponse compensatePalletBatch(PalletBatchCompensation request) { + return palletBatch("/internal/v1/warehouse-pallet-batches/compensate", request); + } + + private PalletBatchResponse palletBatch(String path, Object body) { + PalletBatchEnvelope response = client.post().uri(path) + .header("X-Internal-Service-Token", internalToken).body(body).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is5xxServerError() + ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_REQUEST, + "Warehouse pallet batch request failed"); + }).body(PalletBatchEnvelope.class); + if (response == null || !"200".equals(response.code()) || response.data() == null) { + HttpStatus status = response != null && "A0409".equals(response.code()) ? HttpStatus.CONFLICT + : response != null && "A0404".equals(response.code()) ? HttpStatus.NOT_FOUND + : HttpStatus.SERVICE_UNAVAILABLE; + throw new ResponseStatusException(status, response == null || response.message() == null + ? "Warehouse pallet batch returned no usable response" : response.message()); + } + return response.data(); + } + + private record WarehousePalletResponse(String code, String message, WarehousePalletData data) {} + private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId, + String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version, + String locationType, String locationStatus, boolean reservable, + String reservationBlockReason) {} + private record ReserveEnvelope(String code, String message, 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) {} + private record PalletBatchEnvelope(String code, String message, PalletBatchResponse 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..1d1944f95 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import java.util.List; + +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); + WarehousePalletReference resolvePallet(long palletId); + default WarehousePalletReference inspectPallet(String palletCode) { + return resolvePallet(palletCode); + } + default WarehousePalletReference resolveAvailablePallet(String palletCode) { + return resolvePallet(palletCode); + } + default WarehousePalletReference resolveReservedPallet(String palletCode, String customerId, long warehouseId, + long palletId, long locationId, long resourceLockId) { + throw new UnsupportedOperationException("Reserved warehouse pallet resolution is not configured"); + } + 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"); + } + default PalletBatchResponse ensurePalletBatch(PalletBatchEnsure request) { + throw new UnsupportedOperationException("Warehouse pallet batch reservation is not configured"); + } + default PalletBatchResponse confirmPalletBatch(PalletBatchCommand request) { + throw new UnsupportedOperationException("Warehouse pallet batch confirmation is not configured"); + } + default PalletBatchResponse compensatePalletBatch(PalletBatchCompensation request) { + throw new UnsupportedOperationException("Warehouse pallet batch compensation is not configured"); + } + + record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId, + String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) { + public WarehousePalletReference(long palletId, String palletNo, long locationId) { + this(palletId, palletNo, 0L, locationId, null, null, java.math.BigDecimal.ZERO, 0); + } + public WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) { + this(palletId, palletNo, warehouseId, locationId, status, fbaWarehouseCode, + java.math.BigDecimal.ZERO, version); + } + } + + record WarehouseReference(long warehouseId, String warehouseCode, String warehouseName, + String status) { } + record WarehouseLocationReference(long locationId, String locationCode, String locationType, + java.math.BigDecimal maxLoadKg, String status) { } + record PalletBatchEnsure(long warehouseId, String batchKey, List items) { } + record PalletBatchItemRequest(long customerId, String businessKey, String inboundPlanNo, + int sequenceNo, String palletType, String fbaWarehouseCode, + String palletCode) { } + record PalletBatchCommand(long warehouseId, String batchKey, String commandKey) { } + record PalletBatchCompensation(long warehouseId, String batchKey, String commandKey, String reason) { } + record PalletBatchResponse(long batchId, String batchKey, String status, int attemptNo, + boolean replayed, List items) { } + record PalletBatchItem(long customerId, String businessKey, String inboundPlanNo, int sequenceNo, + long palletId, String palletCode, long locationId, long resourceLockId, + String status) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ContainerLoadingEvidenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ContainerLoadingEvidenceMapper.java new file mode 100644 index 000000000..00bc5111d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ContainerLoadingEvidenceMapper.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence; +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 ContainerLoadingEvidenceMapper extends BaseMapper { + @Select("SELECT * FROM ll_container_loading_evidence WHERE source_container_booking_id=#{bookingId} ORDER BY CASE evidence_type WHEN 'PHOTO_STEP_1' THEN 1 WHEN 'PHOTO_STEP_2' THEN 2 WHEN 'PHOTO_STEP_3' THEN 3 WHEN 'PHOTO_STEP_4' THEN 4 ELSE 5 END") + List listByContainer(@Param("bookingId") String bookingId); + + @Select("SELECT * FROM ll_container_loading_evidence WHERE source_container_booking_id=#{bookingId} AND evidence_type=#{type} LIMIT 1") + ContainerLoadingEvidence find(@Param("bookingId") String bookingId, @Param("type") String type); +} 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..4a73ac204 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import org.apache.ibatis.annotations.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; + +@Mapper +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..0cfc25de9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java @@ -0,0 +1,31 @@ +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; + +import java.util.List; + +@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") + ReceivingContainerSession findActiveByContainer(@Param("activeContainerKey") String activeContainerKey); + + @Select("SELECT * FROM ll_receiving_container_session WHERE active_container_key IS NOT NULL") + List findActiveContainers(); + + @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/ReceivingFbaPalletCountMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingFbaPalletCountMapper.java new file mode 100644 index 000000000..42dc501b2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingFbaPalletCountMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ReceivingFbaPalletCountMapper extends BaseMapper { + @Select("SELECT * FROM ll_receiving_fba_pallet_count WHERE session_id=#{sessionId} " + + "AND group_key=#{groupKey} AND pallet_code=#{palletCode} LIMIT 1") + ReceivingFbaPalletCount find(@Param("sessionId") long sessionId, @Param("groupKey") String groupKey, + @Param("palletCode") String palletCode); + + @Select("SELECT COALESCE(SUM(actual_packages),0) FROM ll_receiving_fba_pallet_count " + + "WHERE session_id=#{sessionId} AND group_key=#{groupKey}") + int sumActual(@Param("sessionId") long sessionId, @Param("groupKey") String groupKey); +} 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..ac1bc3d3a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -0,0 +1,116 @@ +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 com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.springframework.stereotype.Repository; +import jakarta.annotation.Resource; + +import java.util.List; +import java.util.Set; + +@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; + @Resource private LastlegOrderMapper orders; + + 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)); + } + + /** FBA destination groups may allocate several customers onto one logical pallet owned by another plan. */ + public List palletsByIds(Set palletIds) { + return palletIds == null || palletIds.isEmpty() ? List.of() : pallets.selectBatchIds(palletIds); + } + + public List allocations(Long planId) { + return allocations.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, planId)); + } + + public LastlegOrder order(Long orderId) { return orderId == null ? null : orders.selectById(orderId); } + + 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..2f955b56b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -0,0 +1,1235 @@ +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.inboundplan.service.DestuffingPalletCostService; +import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +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; +import java.util.Set; + +@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 DropshipOrderService dropshipOrders; + @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 && terminal(task.getStatus())) { + throw conflict("PDA inbound task is already completed"); + } + 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 = taskResponse(task, plan, 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) { + confirmReservedAssignment(plan, pallet, request.palletId(), request.locationId(), null); + return; + } + 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); + promotePlanWhenAllPalletsAssigned(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 taskResponse(task, plan, 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 (resource.palletNo() == null || resource.palletNo().isBlank()) { + throw conflict("Resolved physical pallet has no pallet code"); + } + if (pallet.getWarehouseResourceLockId() != null) { + confirmReservedAssignment(plan, pallet, resource.palletId(), resource.locationId(), resource.palletNo()); + return null; + } + 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.setPalletNo(resource.palletNo().trim()); + pallet.setWarehouseLocationId(resource.locationId()); + pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED"); + pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet); + promotePlanWhenAllPalletsAssigned(plan); + 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); + requireCountMode(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); + requireCountMode(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.Operation pickWholePallet(String customerId, String planNo, long allocationId, + PdaRequests.WholePalletOperation request) { + InboundPlan plan = requirePlan(customerId, planNo); + requireDropship(plan); + InboundPdaOperation replay = replay(customerId, "PICK_WHOLE_PALLET", 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 planned = value(allocation.getPlannedPackageCount()); + if (planned <= 0) throw conflict("One-click dropship pallet has no planned packages"); + if (value(allocation.getPickedPackageCount()) != 0) { + throw conflict("One-click dropship pallet has already been partially operated"); + } + allocation.setCountedPackageCount(planned); + allocation.setPickedPackageCount(planned); + allocation.setActualVolumeCbm(volume(allocation.getAllocatedVolumeCbm())); + allocation.setStatus("PICKED"); + bump(allocation); + repository.update(allocation); + InboundPlanPallet pallet = requirePallet(plan.getId(), allocation.getPalletId()); + pallet.setActualPackageCount(planned); + pallet.setActualVolumeCbm(volume(allocation.getAllocatedVolumeCbm())); + repository.update(pallet); + startPicking(task, plan, request.operatorId(), request.operatorName()); + PdaResponses.Operation result = operation("WHOLE_PALLET_PICKED", allocation, line, + List.of(), 0, planned, 0); + saveOperation(customerId, task.getId(), allocationId, "PICK_WHOLE_PALLET", + request.idempotencyKey(), request, result); + return result; + } + + /** 海外首次扫描箱唛:仅接受本柜国内冻结且已归属本计划的箱。 */ + @Transactional + public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) { + InboundPlan plan = requirePlan(customerId, planNo); + requireCartonScanMode(plan); + 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"); + } + long requestedPalletId = request.planPalletId() == null ? 0L : request.planPalletId(); + InboundPlanPallet pallet = requestedPalletId > 0 + ? requirePallet(plan.getId(), requestedPalletId) + : autoPallet(plan, carton); + 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"); + int requestedCount = request.packageCount() == null ? 1 : request.packageCount(); + if (requestedCount != 1) throw conflict("Overseas warehouse carton scanning accepts exactly one carton per scan"); + int alreadyScanned = (int) repository.scannedCartons(plan.getId(), line.getId()); + if (alreadyScanned + requestedCount > value(line.getRequiredPackageCount())) { + throw conflict("This inbound plan line has reached its required carton count"); + } + List cartonsToScan = List.of(carton); + 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); + } else if (value(allocation.getPlannedPackageCount()) > 0 + && value(allocation.getPickedPackageCount()) + requestedCount > value(allocation.getPlannedPackageCount())) { + throw conflict("Requested carton quantity exceeds the recommended pallet package count"); + } + BigDecimal perPackage = perPackageVolume(line); + LocalDateTime now = LocalDateTime.now(); + for (InboundPlanCarton item : cartonsToScan) { + item.setOrderId(line.getOrderId()); item.setPlanId(plan.getId()); item.setLineId(line.getId()); + item.setPlanPalletId(pallet.getId()); item.setWarehousePalletId(pallet.getWarehousePalletId()); + item.setWarehouseLocationId(pallet.getWarehouseLocationId()); item.setPdaTaskId(task.getId()); + item.setStatus("ON_PALLET"); item.setBoundAt(now); item.setUpdatedAt(now); + item.setVersion(value(item.getVersion()) + 1); repository.update(item); + } + allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + requestedCount); + allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + requestedCount); + allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()).add(perPackage.multiply(BigDecimal.valueOf(requestedCount))).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()) + requestedCount); + pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(perPackage.multiply(BigDecimal.valueOf(requestedCount))).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(), pallet.getPalletNo(), requestedCount); + 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); + requireCartonScanMode(plan); + 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(List.of(plan)).get(0); + } + + /** Completes every plan of one physical container and emits exactly one container-level outbox event. */ + @Transactional + public List completeContainer(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 -> + plan.getCustomerId() == null || plan.getCustomerId().isBlank() || !containerNo.equals(plan.getContainerNo()))) { + throw conflict("Inbound plans must have an owning customer and belong to one physical container"); + } + List contexts = orderedPlans.stream() + .map(plan -> completionContext(plan.getCustomerId(), 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(context.plan().getCustomerId(), 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(sourceContainerBookingId, containerNo, + now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion); + return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList(); + } + + /** + * 现场允许带差异结束:核平计划正常入库;差异计划只冻结为待核平,继续占用实物托盘和库位, + * 不生成客户可用库存。柜级卸货事实仍只发布一次。 + */ + @Transactional + public void completeContainerWithDifferences(List containerPlans, + List reconciledPlans) { + if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans"); + Set reconciledIds = reconciledPlans.stream().map(InboundPlan::getId).collect(java.util.stream.Collectors.toSet()); + LocalDateTime now = LocalDateTime.now(); + List allTasks = new ArrayList<>(); + for (InboundPlan plan : containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList()) { + InboundPdaTask task = repository.lockTask(plan.getCustomerId(), plan.getId()); + if (task == null || unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null) { + throw conflict("Every plan must have the signed unload evidence before onsite completion"); + } + if (reconciledIds.contains(plan.getId())) { + CompletionContext context = completionContext(plan.getCustomerId(), plan); + completePlan(plan.getCustomerId(), context, now); + allTasks.add(context.task()); + } else { + task.setStatus("PENDING_RECONCILIATION"); task.setCompletedAt(now); task.setUpdatedAt(now); + task.setVersion(value(task.getVersion()) + 1); repository.update(task); + plan.setStatus("PENDING_RECONCILIATION"); plan.setUpdatedAt(now); repository.update(plan); + allTasks.add(task); + } + } + String containerNo = containerPlans.get(0).getContainerNo(); + if (containerPlans.stream().anyMatch(plan -> !Objects.equals(containerNo, plan.getContainerNo()))) { + throw conflict("Inbound plans must belong to one physical container"); + } + String sourceContainerBookingId = sourceContainerBookingIdForPlans(containerPlans); + LocalDateTime photoUploadedAt = allTasks.stream().map(InboundPdaTask::getUnloadPhotoUploadedAt) + .max(LocalDateTime::compareTo).orElseThrow(); + long aggregateVersion = allTasks.stream().map(InboundPdaTask::getVersion).filter(Objects::nonNull) + .mapToLong(Integer::longValue).max().orElse(1L); + unloadCompletedOutbox.publishContainerCompleted(sourceContainerBookingId, containerNo, + now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion); + } + + private String sourceContainerBookingIdForPlans(List plans) { + List values = plans.stream().map(plan -> { + LastlegOrder order = orders.selectById(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 (values.size() != 1) throw conflict("Container plans reference different source container bookings"); + return values.get(0); + } + + 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()); + } + if ("PARTIAL_INBOUND".equals(task.getStatus())) throw conflict("PDA inbound task was partially completed"); + 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(); + List receiptPallets = new ArrayList<>(repository.pallets(plan.getId())); + Set presentPalletIds = receiptPallets.stream().map(InboundPlanPallet::getId) + .collect(java.util.stream.Collectors.toSet()); + Set sharedPalletIds = context.allocations().stream().map(InboundPlanPalletAllocation::getPalletId) + .filter(Objects::nonNull).filter(id -> !presentPalletIds.contains(id)) + .collect(java.util.stream.Collectors.toSet()); + receiptPallets.addAll(repository.palletsByIds(sharedPalletIds)); + stockReceipts.stockIn(customerId, plan, task, context.lines(), context.allocations(), + receiptPallets, 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); + } + + /** Explicit operator decision to receive only the cartons actually picked. */ + @Transactional + public PdaResponses.Task partialComplete(String customerId, String planNo, PdaRequests.ForceComplete request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "PARTIAL_COMPLETE", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Task.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + List unloadPhotoFileIds = unloadPhotoFileIds(task); + if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null + || task.getEstimatedEmptyReturnAt() == null) { + throw conflict("Upload unload photos before partially completing the PDA inbound task"); + } + List lines = repository.lines(plan.getId()); + List allocations = repository.allocations(plan.getId()); + int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(); + int picked = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum(); + if (picked <= 0) throw conflict("At least one carton must be picked before partial inbound completion"); + if (picked >= expected) throw conflict("All cartons are picked; use normal inbound completion"); + LocalDateTime now = LocalDateTime.now(); + stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now, true); + for (InboundPlanCarton carton : repository.cartons(plan.getId())) { + if ("ON_PALLET".equals(carton.getStatus())) { + carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now); + carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton); + } + } + task.setStatus("PARTIAL_INBOUND"); task.setCompletedAt(now); task.setUpdatedAt(now); + task.setVersion(value(task.getVersion()) + 1); repository.update(task); + plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan); + unloadCompletedOutbox.publish(plan, task, unloadPhotoFileIds); + PdaResponses.Task result = taskResponse(task, plan, planNo); + saveOperation(customerId, task.getId(), null, "PARTIAL_COMPLETE", request.idempotencyKey(), request, result); + return result; + } + + @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 = taskResponse(task, plan, 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 requireCountMode(InboundPlan plan) { + InboundMode mode = mode(plan); + if (mode == InboundMode.DROPSHIP) { + throw conflict("One-click dropship inbound must be picked by complete source pallet"); + } + if (mode == InboundMode.OWN_OVERSEAS) { + throw conflict("Overseas warehouse inbound must be operated by one-carton scanning"); + } + } + + /** Backward-compatible guard for pallet merge, which remains a legacy quantity workflow. */ + private void requireLegacyCounting(InboundPlan plan) { + if (isDropship(plan)) { + throw conflict("One-click dropship inbound must be picked by complete source pallet"); + } + if (repository.hasCartonLabelPool(plan.getId())) { + throw conflict("This inbound plan has a carton manifest and must be operated by carton scanning"); + } + } + + private void requireCartonScanMode(InboundPlan plan) { + if (mode(plan) != InboundMode.OWN_OVERSEAS) { + throw conflict(mode(plan) == InboundMode.FBA + ? "FBA inbound must be operated by package quantity, not carton scanning" + : "One-click dropship inbound must be operated by complete source pallet"); + } + } + + private boolean isNotAssigned(InboundPlanPallet pallet) { + return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null + || pallet.getWarehouseResourceLockId() == null + || !("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus())); + } + + private void confirmReservedAssignment(InboundPlan plan, InboundPlanPallet pallet, + long palletId, long locationId, String palletNo) { + boolean sameReservedResource = pallet.getWarehouseResourceLockId() != null + && pallet.getWarehouseResourceLockId() > 0 + && Objects.equals(pallet.getWarehousePalletId(), palletId) + && Objects.equals(pallet.getWarehouseLocationId(), locationId); + if (!sameReservedResource) { + throw conflict("Planned pallet is already assigned to another physical pallet or location"); + } + if ("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus())) return; + if (!"RESERVED".equals(pallet.getAssignmentStatus())) { + throw conflict("Reserved physical pallet has an invalid assignment status"); + } + if (palletNo != null && !palletNo.isBlank()) pallet.setPalletNo(palletNo.trim()); + pallet.setAssignmentStatus("ASSIGNED"); + if (pallet.getLockedAt() == null) pallet.setLockedAt(LocalDateTime.now()); + repository.update(pallet); + promotePlanWhenAllPalletsAssigned(plan); + } + + private void promotePlanWhenAllPalletsAssigned(InboundPlan plan) { + List plannedPallets = repository.pallets(plan.getId()); + if (plannedPallets.isEmpty() || plannedPallets.stream().anyMatch(this::isNotAssigned)) return; + if (!java.util.Set.of("PENDING_ASSIGNMENT", "RESERVED").contains(plan.getStatus())) return; + plan.setStatus("ASSIGNED"); + plan.setUpdatedAt(LocalDateTime.now()); + repository.update(plan); + } + + 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 (terminal(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 InboundPlanPallet autoPallet(InboundPlan plan, InboundPlanCarton carton) { + List candidates = repository.pallets(plan.getId()).stream() + .filter(pallet -> sameWarehouse(carton, pallet)) + .filter(pallet -> !isNotAssigned(pallet)) + .sorted(Comparator.comparingInt(pallet -> value(pallet.getSequenceNo()))) + .toList(); + if (candidates.isEmpty()) throw conflict("No assigned pallet exists for the carton destination warehouse"); + Map plannedByPallet = new HashMap<>(); + for (InboundPlanPalletAllocation allocation : repository.allocations(plan.getId())) { + plannedByPallet.merge(allocation.getPalletId(), value(allocation.getPlannedPackageCount()), Integer::sum); + } + InboundPlanPallet selected = candidates.stream() + .filter(pallet -> value(pallet.getActualPackageCount()) < plannedByPallet.getOrDefault(pallet.getId(), Integer.MAX_VALUE)) + .findFirst().orElse(null); + if (selected == null) throw conflict("All assigned pallets for the carton destination warehouse are full"); + return repository.lockPallet(plan.getId(), selected.getId()); + } + + 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 taskResponse(InboundPdaTask task, InboundPlan plan, String planNo) { + int expected = repository.lines(plan.getId()).stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(); + List allocations = repository.allocations(plan.getId()); + int picked = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum(); + LastlegOrder order = repository.order(plan.getSourceOrderId()); + String orderType = order == null ? null : order.getOrderType(); + boolean wholePalletInbound = "ONE_CLICK_DROPSHIP".equals(orderType); + Map palletsByNo = wholePalletInbound + ? repository.pallets(plan.getId()).stream().collect(java.util.stream.Collectors.toMap( + InboundPlanPallet::getSourcePalletNo, value -> value)) : Map.of(); + Map allocationsByPallet = wholePalletInbound + ? allocations.stream().collect(java.util.stream.Collectors.toMap( + InboundPlanPalletAllocation::getPalletId, value -> value)) : Map.of(); + List dropshipPallets = wholePalletInbound + ? dropshipOrders.palletSnapshots(task.getCustomerId(), order.getId()).stream() + .map(pallet -> dropshipPallet(pallet, palletsByNo, allocationsByPallet)).toList() : List.of(); + PdaResponses.Task workInstructions = task(task, planNo); + return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), + task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task), + task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), expected, picked, + orderType, wholePalletInbound, dropshipPallets, mode(plan).name(), + workInstructions.pallets(), workInstructions.allocations(), workInstructions.expectedCartons()); + } + + 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(), + lines.values().stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(), + planAllocations.stream().mapToInt(value -> InboundPdaWorkflowService.value(value.getPickedPackageCount())).sum(), + null, false, List.of(), null, 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 PdaResponses.DropshipPallet dropshipPallet(DropshipPalletSnapshot pallet, + Map palletsByNo, + Map allocationsByPallet) { + InboundPlanPallet planPallet = palletsByNo.get(pallet.sourcePalletNo()); + if (planPallet == null) throw conflict("Dropship source pallet is missing from the inbound plan"); + InboundPlanPalletAllocation allocation = allocationsByPallet.get(planPallet.getId()); + if (allocation == null) throw conflict("Dropship source pallet has no inbound allocation"); + return new PdaResponses.DropshipPallet(pallet.sourcePalletNo(), planPallet.getId(), allocation.getId(), + pallet.packageCount(), pallet.skus().stream() + .map(sku -> new PdaResponses.DropshipSku(sku.skuCode(), sku.productName(), + sku.packageCount(), sku.productQuantity())).toList()); + } + + private void requireDropship(InboundPlan plan) { + if (!isDropship(plan)) { + throw conflict("Whole-pallet picking is only available for one-click dropship inbound plans"); + } + } + + private boolean isDropship(InboundPlan plan) { + LastlegOrder order = repository.order(plan.getSourceOrderId()); + return order != null && "ONE_CLICK_DROPSHIP".equals(order.getOrderType()); + } + + private InboundMode mode(InboundPlan plan) { + if (isDropship(plan)) return InboundMode.DROPSHIP; + List lines = repository.lines(plan.getId()); + return lines.stream().anyMatch(line -> line.getFbaWarehouseCode() != null + && !line.getFbaWarehouseCode().isBlank()) ? InboundMode.FBA : InboundMode.OWN_OVERSEAS; + } + + private enum InboundMode { DROPSHIP, OWN_OVERSEAS, FBA } + + 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 boolean terminal(String status) { + return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status) + || "PENDING_RECONCILIATION".equals(status); + } + 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..9a080980d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -0,0 +1,383 @@ +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.inboundplan.pda.gateway.WarehouseResourceGateway; +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 com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; +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 WarehouseResourceGateway warehouseGateway; + @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) { + stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false); + } + + @Transactional + public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, + List lines, List allocations, + List pallets, LocalDateTime receivedAt, boolean allowPartial) { + 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)) { + if (!allowPartial) activateDropship(customerId, task.getId(), orderById, existing); + completeOrders(customerId, plan, orderById, receivedAt, allowPartial); + 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 == 0 && allowPartial) continue; + if ((!allowPartial && pickedPackages != value(line.getRequiredPackageCount())) + || (allowPartial && 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(), value(line.getRequiredPackageCount())); + Map dropshipPallets = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) + ? dropshipOrders.palletSnapshots(customerId, order.getId()).stream() + .collect(Collectors.toMap(DropshipPalletSnapshot::sourcePalletNo, value -> value)) + : Map.of(); + for (InboundPlanPalletAllocation allocation : lineAllocations) { + InboundPlanPallet pallet = requirePallet(palletById, allocation); + if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { + if (value(allocation.getPickedPackageCount()) != value(allocation.getPlannedPackageCount())) { + throw conflict("One-click dropship inbound must receive a complete source pallet"); + } + DropshipPalletSnapshot sourcePallet = dropshipPallets.get(pallet.getSourcePalletNo()); + if (sourcePallet == null || sourcePallet.packageCount() != value(allocation.getPickedPackageCount())) { + throw conflict("One-click dropship source pallet physical carton total does not match PDA receipt"); + } + DropshipPalletSnapshot.Sku single = sourcePallet.skus().size() == 1 + ? sourcePallet.skus().get(0) : null; + created.add(persist(customerId, plan, task, line, allocation, pallet, order, + new SkuSnapshot(single == null ? "MIXED" : single.skuCode(), + single == null ? "混合SKU" : single.productName(), null, + sourcePallet.packageCount()), 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 (!allowPartial && skuRemainders.stream().anyMatch(value -> value.remaining > 0)) { + throw conflict("Inbound SKU package snapshots were not fully received"); + } + } + if (!allowPartial) activateDropship(customerId, task.getId(), orderById, created); + completeOrders(customerId, plan, orderById, receivedAt, allowPartial); + } + + 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, boolean partial) { + String subOrderStatus = partial ? "INBOUND_PLAN:PARTIAL_INBOUND" : "INBOUND_PLAN:COMPLETED"; + 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()) + && subOrderStatus.equals(order.getSubOrderStatus())) continue; + String before = order.getCurrentStatus(); + order.setCurrentStatus("INBOUND_COMPLETED"); + order.setSubOrderStatus(subOrderStatus); + 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((partial ? "PDA_PARTIAL_INBOUND_COMPLETE:" : "PDA_INBOUND_COMPLETE:") + + plan.getPlanNo()); + history.setRemark(partial + ? "PDA inbound partially completed and actual inventory stocked in" + : "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 customerName = required(order.getCustomerName(), + "Customer name snapshot is required for stock-in"); + String overseasWarehouseId = required(order.getOverseasWarehouseId(), + "Actual overseas warehouse is required for stock-in"); + String overseasWarehouseName = required(order.getOverseasWarehouseName(), + "Actual overseas warehouse name is required for stock-in"); + String overseasWarehouseCode = required(order.getOverseasWarehouseCode(), + "Actual overseas warehouse code is required for stock-in"); + if (plan.getOverseasWarehouseId() == null + || !overseasWarehouseId.equals(String.valueOf(plan.getOverseasWarehouseId()))) { + throw conflict("Inbound plan and tail-leg order overseas warehouses must match for stock-in"); + } + if (overseasWarehouseId.equals(trim(order.getSourceWarehouseId()))) { + throw conflict("Actual overseas warehouse must not use the domestic source warehouse"); + } + WarehouseResourceGateway.WarehouseLocationReference location = warehouseGateway.resolveLocation( + Long.parseLong(overseasWarehouseId), pallet.getWarehouseLocationId()); + if (location == null || location.locationId() != pallet.getWarehouseLocationId()) { + throw conflict("Assigned warehouse location could not be resolved for stock-in"); + } + String warehouseLocationCode = required(location.locationCode(), + "Assigned warehouse location code is required for stock-in"); + InventoryCommandRequests.StockIn stockIn = new InventoryCommandRequests.StockIn(null, customerName, + overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, + 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(), warehouseLocationCode, packages, order.getOrderNo(), + order.getSourceEventId(), operationKey, + "PDA inbound completion for " + line.getInboundCode()); + InventoryCommandResponse result = Boolean.TRUE.equals(order.getDetained()) + ? inventory.stockInHeld(customerId, task.getOperatorId(), stockIn) + : inventory.stockIn(customerId, task.getOperatorId(), stockIn); + + 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.setSourcePalletNo(pallet.getSourcePalletNo()); + 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) {} +} 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..b7988f67d --- /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..60de90bae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java @@ -0,0 +1,126 @@ +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(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 sourceContainerBookingId, + String containerNo, Instant unloadingCompletedAt, + Instant unloadingPhotoUploadedAt, long aggregateVersion) { + 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, 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, "0", "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("0"); + 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, sourceContainerBookingId, containerNo); + } + } + + private void requireSameContainer(LastlegEventOutbox existing, + String sourceContainerBookingId, String containerNo) { + try { + EventEnvelope envelope = objectMapper.readValue(existing.getPayloadJson(), EventEnvelope.class); + OverseasContainerUnloadedV1 frozen = objectMapper.treeToValue( + envelope.payload(), OverseasContainerUnloadedV1.class); + if (!"0".equals(existing.getCustomerId()) + || !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..b08d07b9d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -0,0 +1,1402 @@ +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.ReceivingFbaPalletCount; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; +import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.DomesticLoadingEvidenceGateway; +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.ReceivingFbaPalletCountMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +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 PLATFORM_SCOPE = "0"; + private static final String PHOTO_DOWNLOAD_PATH = + "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download"; + private static final String DOMESTIC_PHOTO_DOWNLOAD_PATH = + "/api/pda/v1/last-leg/receiving-containers/current/domestic-loading-photos/{type}/content"; + private static final String PALLET_MERGE_HINT = + "该入库计划可选择合托:合托时扫描同目的仓已使用的托盘码,独立收货时扫描新托盘码;不强制合托"; + + @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 ReceivingFbaPalletCountMapper fbaPalletCounts; + @Resource private ReceivingSkuReceiptMapper skuReceipts; + @Resource private ContainerShippingGateMapper shippingGates; + @Resource private DropshipCartonMapper dropshipCartons; + @Resource private DropshipOrderProfileMapper dropshipProfiles; + @Resource private WarehouseResourceGateway warehouse; + @Resource private UnloadPhotoFileGateway unloadPhotos; + @Resource private DomesticLoadingEvidenceGateway domesticEvidenceFiles; + @Resource private ContainerLoadingEvidenceMapper domesticEvidence; + @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); + List orderFacts = orders.selectList(new LambdaQueryWrapper() + .isNotNull(LastlegOrder::getSourceContainerBookingId) + .isNotNull(LastlegOrder::getContainerNo) + .orderByAsc(LastlegOrder::getSourceContainerBookingId, LastlegOrder::getId)); + List planFacts = plans.selectList(new LambdaQueryWrapper().orderByAsc(InboundPlan::getId)); + List lineFacts = lines.selectList(new LambdaQueryWrapper().orderByAsc(InboundPlanLine::getId)); + Map> grouped = orderFacts.stream() + .filter(order -> trim(order.getSourceContainerBookingId()) != null && trim(order.getContainerNo()) != null) + .collect(Collectors.groupingBy(LastlegOrder::getSourceContainerBookingId, LinkedHashMap::new, Collectors.toList())); + String operatorLockKey = activeOperatorKey(operatorKey); + Set reservedByOthers = sessions.findActiveContainers().stream() + .filter(session -> !operatorLockKey.equals(session.getActiveOperatorKey())) + .map(ReceivingContainerSession::getActiveContainerKey).filter(Objects::nonNull).collect(Collectors.toSet()); + return grouped.entrySet().stream() + .map(entry -> readiness(entry.getKey(), entry.getValue(), planFacts, lineFacts, overseasWarehouseId)) + .filter(Objects::nonNull) + .filter(ContainerReadiness::shippingReady) + .filter(value -> !value.allPlansCompleted()) + .filter(value -> !reservedByOthers.contains(activeContainerKey(value.containerNo()))) + .sorted(Comparator.comparing(ContainerReadiness::operable).reversed() + .thenComparing(ContainerReadiness::plannedArrivalAt, + Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(ContainerReadiness::sourceContainerBookingId)) + .map(value -> candidate(value, current)).toList(); + } + + public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { + requireReadyContainer(containerNo, overseasWarehouseId, false); + List containerPlans = requirePlans(containerNo); + requireMaterializedPlans(containerPlans); + if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); + 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"); + } + ReceivingContainerSession reserved = sessions.findActiveByContainer(activeContainerKey(containerNo)); + if (reserved != null && !activeOperatorKey(operatorKey).equals(reserved.getActiveOperatorKey())) { + throw conflict("Container is already signed or prepared by another account"); + } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + warehouse.requireActiveSharedWarehouse(overseasWarehouseId); + return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( + 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()); + ContainerReadiness readiness = requireReadyContainer(containerNo, overseasWarehouseId, true); + 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); + requireMaterializedPlans(containerPlans); + if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); + String activeOperatorKey = activeOperatorKey(operatorKey); + ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); + requireSessionWarehouse(active, overseasWarehouseId); + if (active != null && !containerNo.equals(active.getContainerNo())) { + throw conflict("Finish the current container before selecting another one"); + } + if (active == null && sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) { + throw conflict("Container is already signed or prepared by another account"); + } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + warehouse.requireActiveSharedWarehouse(overseasWarehouseId); + bindReceivingWarehouse(containerPlans, receivingWarehouse.warehouseId()); + if (active != null) { + ReceivingContainerResponses.Session result = snapshot(active, containerPlans); + save(active, "PREPARE", request.idempotencyKey(), command, result); return result; + } + LocalDateTime now = LocalDateTime.now(); + ReceivingContainerSession session = new ReceivingContainerSession(); + session.setId(IdWorker.getId()); session.setContainerNo(containerNo); + session.setSourceContainerBookingId(readiness.sourceContainerBookingId()); + session.setContainerFactHash(readiness.factHash()); + session.setOverseasWarehouseId(overseasWarehouseId); + session.setOverseasWarehouseCode(receivingWarehouse.warehouseCode()); + 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); + if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing"); + UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(PLATFORM_SCOPE, 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); + 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(PLATFORM_SCOPE, session.getId(), fileId)); + List containerPlans = requirePlans(session.getContainerNo()); + requireMaterializedPlans(containerPlans); + 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 : containerPlans) { + workflow.ensureSignedTask(plan.getCustomerId(), plan, operatorKey, operatorName, photoFileIds, now); + } + ReceivingContainerResponses.Session result = snapshot(session, containerPlans); + 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.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(PLATFORM_SCOPE, session.getId(), parsedFileId); + } + + public DomesticLoadingEvidenceGateway.DownloadedFile downloadDomesticLoadingPhoto( + String operatorKey, String evidenceType) { + ReceivingContainerSession session = requireCurrent(operatorKey, false); + String type = requiredEvidenceType(evidenceType); + var evidence = domesticEvidence.find(session.getSourceContainerBookingId(), type); + if (evidence == null) throw notFound("当前柜没有该国内装柜照片"); + long bookingId; + try { bookingId = Long.parseLong(session.getSourceContainerBookingId()); } + catch (NumberFormatException exception) { throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, "当前柜国内照片来源无效", exception); } + return domesticEvidenceFiles.download(bookingId, evidence.getSourceFileId()); + } + + 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); + List currentRefs = refs(session.getContainerNo()); + String sourcePalletNo = dropshipSourcePallet(currentRefs, session.getContainerNo(), request.cartonMark()); + if (sourcePalletNo != null) { + List palletRefs = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo); + requireCompleteManifest(session.getContainerNo(), sourcePalletNo); + List group = palletRefs.stream().map(DropshipPalletRef::ref).distinct().toList(); + InboundRef first = group.get(0); + List inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList(); + int total = palletRefs.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum(); + int received = palletRefs.stream().mapToInt(value -> value(value.allocation().getPickedPackageCount())).sum(); + ReceivingContainerResponses.Inbound inbound = inbound(first); + return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(), + inboundCodes.get(0), inbound.destinationType(), inbound.overseasWarehouseId(), + inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), + inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), 1, total, received, + Math.max(0, total-received), true, false, urgent(first.plan()), null, + "DROPSHIP_PALLET", "DP:" + sourcePalletNo, inboundCodes, sourcePalletNo); + } + String inboundCode = cartonInboundCode(request.cartonMark()); + List matches = currentRefs.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()))) { + if (request.cartonMark().trim().equals(inboundCode)) { + throw conflict("Scan one FBA carton code, not the inbound code"); + } + carton = new CartonRef(matches.get(0)); + } else if (matches.size() == 1) { + carton = requireCartonRef(matches.get(0).order().getCustomerId(), session.getContainerNo(), request.cartonMark()); + } else { + carton = requireUnmatchedFbaCartonPrefix(inboundCode); + } + InboundRef ref = carton.ref(); + if ("FBA".equals(destinationType(ref.line(), ref.order())) + && !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) { + List group = requireFbaPlanRefs(session.getContainerNo(), ref); + List inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList(); + int total = group.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum(); + int received = group.stream().mapToInt(this::receivedLinePackages).sum(); + ReceivingContainerResponses.Inbound inbound = inbound(ref); + int palletCount = (int) group.stream().flatMap(value -> pallets.selectList( + new LambdaQueryWrapper().eq(InboundPlanPallet::getPlanId, value.plan().getId())).stream()) + .map(InboundPlanPallet::getWarehousePalletId).filter(Objects::nonNull).distinct().count(); + return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(), inboundCodes.get(0), + "FBA", inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), + inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), + palletCount, total, received, Math.max(0,total-received), true, false, urgent(ref.plan()), null, + "FBA_GROUP", fbaGroupKey(session.getContainerNo(), ref), inboundCodes, null); + } + 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(), + inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint()); + } + + public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, + ReceivingContainerRequests.PalletResolve request) { + ReceivingContainerSession session = requireSigned(operatorKey); + InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode()); + List group = "FBA".equals(destinationType(ref.line(), ref.order())) + && !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()) + ? requireFbaPlanRefs(session.getContainerNo(), ref) : List.of(ref); + PalletSelection selection = group.size() == 1 && !"FBA".equals(destinationType(ref.line(), ref.order())) + ? resolveReceivingPallet(ref, request.palletCode(), false) : resolveFbaGroupPallet(group, request.palletCode()); + WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource(); + int received = selection.pallet() == null ? 0 : group.stream().mapToInt(value -> allocations.selectList( + new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, value.plan().getId()) + .eq(InboundPlanPalletAllocation::getLineId, value.line().getId()) + .eq(InboundPlanPalletAllocation::getPalletId, selection.pallet().getId())).stream() + .mapToInt(item -> value(item.getPickedPackageCount())).sum()).sum(); + if (selection.pallet() != null && "FBA".equals(destinationType(ref.line(), ref.order()))) { + ReceivingFbaPalletCount physical = fbaPalletCounts.find(session.getId(), + fbaGroupKey(session.getContainerNo(), ref), pallet.palletNo()); + if (physical != null) received = value(physical.getActualPackages()); + } + return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(), + pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true, + selection.pallet() == null ? 0 : value(selection.pallet().getVersion()), received); + } + + @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); + InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode()); + if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) { + throw conflict("One-click dropship inbound must be operated by complete source pallet"); + } + 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(session.getContainerNo(), ref); + PalletSelection selection = resolveFbaGroupPallet(fbaPlanRefs, request.palletCode()); + WarehouseResourceGateway.WarehousePalletReference resource = selection.resource(); + InboundPlanPallet pallet = selection.pallet(); + if (request.expectedPalletVersion() != null && !Objects.equals(request.expectedPalletVersion(), pallet.getVersion())) { + throw conflict("Pallet receiving version changed; refresh the group before saving"); + } + int allocatedOnPallet = fbaPlanRefs.stream().mapToInt(value -> allocations.selectList( + new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, value.plan().getId()) + .eq(InboundPlanPalletAllocation::getLineId, value.line().getId()) + .eq(InboundPlanPalletAllocation::getPalletId, pallet.getId())).stream() + .mapToInt(item -> value(item.getPickedPackageCount())).sum()).sum(); + String groupKey = fbaGroupKey(session.getContainerNo(), ref); + ReceivingFbaPalletCount physicalCount = fbaPalletCounts.find(session.getId(), groupKey, resource.palletNo()); + int previous = physicalCount == null ? allocatedOnPallet : value(physicalCount.getActualPackages()); + if (request.packageCount() < previous) { + throw conflict("Cumulative pallet quantity cannot be reduced after saving; use correction operation"); + } + int physicalDelta = request.packageCount() - previous; + List inboundCodes = fbaPlanRefs.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList(); + if (physicalDelta == 0) { + int total = fbaPlanRefs.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum(); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("ALREADY_RECEIVED", + session.getContainerNo(), inboundCodes.get(0), "FBA", resource.palletNo(), 0, + fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum(), + Math.max(0,total-fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum()), session.getStatus(), + true, mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()), groupKey, inboundCodes); + save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); + return result; + } + int groupTotal = fbaPlanRefs.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum(); + int groupReceived = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum(); + int allocatable = Math.min(physicalDelta, Math.max(0, groupTotal - groupReceived)); + List skuAllocations = allocateFbaSkuReceipts(fbaPlanRefs, allocatable); + skuAllocations.forEach(allocation -> workflow.validateBulkResolved(allocation.ref().order().getCustomerId(), allocation.ref().plan(), + allocation.ref().line(), allocation.packages())); + String palletCustomerId = pallet.getCustomerId(); + InboundRef palletOwner = fbaPlanRefs.stream().filter(value -> Objects.equals(value.plan().getId(), pallet.getPlanId())) + .findFirst().orElseThrow(() -> conflict("FBA pallet does not belong to the current destination group")); + Long resourceLockId = allocatable == 0 ? null + : workflow.assignResolvedPallet(palletCustomerId, palletOwner.plan(), pallet, resource); + try { + int accepted = 0; + for (FbaSkuAllocation allocation : skuAllocations) { + String allocationCustomerId = allocation.ref().order().getCustomerId(); + accepted += workflow.receiveBulkResolved(allocationCustomerId, allocation.ref().plan(), allocation.ref().line(), pallet, + allocation.packages(), operatorKey, operatorName); + recordSkuReceipt(session, allocation, pallet); + } + savePhysicalFbaCount(session.getId(), groupKey, resource.palletNo(), request.packageCount(), physicalCount); + markReceiving(session); + int received = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum(); + int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum(); + boolean overReceived = fbaPalletCounts.sumActual(session.getId(), groupKey) > total; + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt( + overReceived ? "DIFFERENCE_RECORDED" : "RECEIVED", + session.getContainerNo(), inboundCodes.get(0), "FBA", resource.palletNo(), physicalDelta, + received, Math.max(0, total - received), session.getStatus(), assigned(ref.plan()), + mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()), + groupKey, inboundCodes); + save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); + return result; + } catch (RuntimeException failure) { + workflow.compensateReservation(palletCustomerId, 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); + List currentRefs = refs(session.getContainerNo()); + if (currentRefs.stream().noneMatch(value -> + "OVERSEAS".equals(destinationType(value.line(), value.order())))) { + throw conflict("FBA destination must use bulk count"); + } + String inboundCode = cartonInboundCode(request.cartonMark()); + InboundRef inboundRef = requireInbound(session.getContainerNo(), inboundCode); + String customerId = inboundRef.order().getCustomerId(); + 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"); + } + PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true); + WarehouseResourceGateway.WarehousePalletReference resource = selection.resource(); + InboundPlanPallet pallet = selection.pallet(); + 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(), + inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint()); + save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result); + return result; + } catch (RuntimeException failure) { + workflow.compensateReservation(customerId, resourceLockId, failure); + throw failure; + } + } + + @Transactional + public ReceivingContainerResponses.Receipt receiveWholePallet(String operatorKey, String operatorName, + ReceivingContainerRequests.WholePalletReceive request) { + ReceivingContainerOperation replay = replay(operatorKey, "PALLET_RECEIVE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); + ReceivingContainerSession session = requireSigned(operatorKey); + String sourcePalletNo = dropshipSourcePallet(session.getContainerNo(), request.sourcePalletNo()); + if (sourcePalletNo == null) throw notFound("DP or carton is not part of the current container"); + requireCompleteManifest(session.getContainerNo(), sourcePalletNo); + List matches = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo); + boolean alreadyReceived = matches.stream().allMatch(match -> + value(match.allocation().getPickedPackageCount()) == value(match.allocation().getPlannedPackageCount())); + int accepted = 0; + if (!alreadyReceived) { + if (matches.stream().anyMatch(match -> value(match.allocation().getPickedPackageCount()) != 0)) { + throw conflict("DP pallet is only partially received; reconcile it before retrying"); + } + for (DropshipPalletRef match : matches) { + int packages = value(match.allocation().getPlannedPackageCount()); + workflow.pickWholePallet(match.ref().order().getCustomerId(), match.ref().plan().getPlanNo(), + match.allocation().getId(), new PdaRequests.WholePalletOperation( + request.idempotencyKey() + ":PICK:" + match.allocation().getId(), operatorKey, operatorName)); + accepted += packages; + match.allocation().setPickedPackageCount(packages); + } + markReceiving(session); + } + List group = matches.stream().map(DropshipPalletRef::ref).distinct().toList(); + List inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList(); + int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum(); + int received = matches.stream().mapToInt(value -> value(value.allocation().getPickedPackageCount())).sum(); + InboundRef first = group.get(0); + ReceivingContainerResponses.Inbound inbound = inbound(first); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt( + alreadyReceived ? "ALREADY_RECEIVED" : "RECEIVED", session.getContainerNo(), inboundCodes.get(0), + inbound.destinationType(), sourcePalletNo, accepted, received, Math.max(0,total-received), session.getStatus(), + true, false, urgent(first.plan()), null, "DP:" + sourcePalletNo, inboundCodes); + save(session, "PALLET_RECEIVE", request.idempotencyKey(), request, result); + return result; + } + + @Transactional(isolation = Isolation.READ_COMMITTED) + public ReceivingContainerResponses.Session complete(String operatorKey, + ReceivingContainerRequests.Complete request) { + ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + ReceivingContainerSession session = requireSigned(operatorKey); + List containerPlans = requirePlans(session.getContainerNo()); + Totals totals = totals(containerPlans); + boolean hasDifference = totals.total() != totals.received() || hasFbaOverReceipt(session, containerPlans); + if (hasDifference && !request.allowDifference()) { + throw conflict("Container has a receiving difference; confirm onsite completion with difference"); + } + if (!hasDifference) { + validateFbaSkuBalance(session.getContainerNo()); + workflow.completeContainer(containerPlans); + } else { + workflow.completeContainerWithDifferences(containerPlans, reconciledPlans(containerPlans)); + } + LocalDateTime now = LocalDateTime.now(); + session.setStatus(hasDifference ? "COMPLETED_WITH_DIFFERENCE" : "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 List reconciledPlans(List containerPlans) { + Set complete = refs(containerPlans.get(0).getContainerNo()).stream() + .collect(Collectors.groupingBy(value -> value.plan().getId(), LinkedHashMap::new, Collectors.toList())) + .entrySet().stream().filter(entry -> entry.getValue().stream().allMatch(ref -> + receivedLinePackages(ref) == value(ref.line().getRequiredPackageCount()))) + .map(Map.Entry::getKey).collect(Collectors.toSet()); + Set incompleteFbaGroups = refs(containerPlans.get(0).getContainerNo()).stream() + .filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) + .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) + .filter(ref -> !complete.contains(ref.plan().getId())) + .map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).collect(Collectors.toSet()); + refs(containerPlans.get(0).getContainerNo()).stream() + .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) + .filter(ref -> fbaGroupHasOverReceipt(findSession(containerPlans.get(0).getContainerNo()), ref)) + .map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).forEach(incompleteFbaGroups::add); + return containerPlans.stream().filter(plan -> complete.contains(plan.getId())) + .filter(plan -> refs(plan.getContainerNo()).stream().filter(ref -> Objects.equals(ref.plan().getId(), plan.getId())) + .noneMatch(ref -> "FBA".equals(destinationType(ref.line(), ref.order())) + && incompleteFbaGroups.contains(normalizedFba(ref.line().getFbaWarehouseCode())))) + .toList(); + } + + 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"); + requireStableSessionFacts(session, lock); + return session; + } + + private ContainerReadiness requireReadyContainer(String containerNo, long overseasWarehouseId, boolean lock) { + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(LastlegOrder::getContainerNo, containerNo).orderByAsc(LastlegOrder::getId); + if (lock) query.last("FOR UPDATE"); + List orderFacts = orders.selectList(query); + Set bookingIds = orderFacts.stream().map(LastlegOrder::getSourceContainerBookingId) + .map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet()); + if (orderFacts.isEmpty() || bookingIds.size() != 1) { + throw conflict("Container must resolve exactly one sourceContainerBookingId"); + } + List planFacts = plans.selectList(new LambdaQueryWrapper() + .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); + List lineFacts = lines.selectList(new LambdaQueryWrapper() + .in(InboundPlanLine::getOrderId, orderFacts.stream().map(LastlegOrder::getId).toList()) + .orderByAsc(InboundPlanLine::getId)); + ContainerReadiness value = readiness(bookingIds.iterator().next(), orderFacts, planFacts, lineFacts, + overseasWarehouseId); + if (value == null || !value.operable()) { + throw conflict(value == null ? "当前柜已分配至其他海外接柜仓,无法在本仓操作" : value.blockReason()); + } + return value; + } + + private void requireStableSessionFacts(ReceivingContainerSession session, boolean lock) { + if (trim(session.getContainerFactHash()) == null) return; + ContainerReadiness current = requireReadyContainer(session.getContainerNo(), session.getOverseasWarehouseId(), lock); + if (!Objects.equals(session.getSourceContainerBookingId(), current.sourceContainerBookingId()) + || !Objects.equals(session.getContainerFactHash(), current.factHash())) { + throw conflict("Container orders or inbound plans changed after prepare; restart the receiving session"); + } + } + + private ContainerReadiness readiness(String sourceContainerBookingId, List orderFacts, + List planFacts, List lineFacts, + long overseasWarehouseId) { + Set orderIds = orderFacts.stream().map(LastlegOrder::getId).collect(Collectors.toSet()); + Map> linesByOrder = lineFacts.stream() + .filter(line -> orderIds.contains(line.getOrderId())) + .collect(Collectors.groupingBy(InboundPlanLine::getOrderId)); + Map plansById = planFacts.stream().collect(Collectors.toMap(InboundPlan::getId, + value -> value, (left, right) -> left)); + List matchedPlans = linesByOrder.values().stream().flatMap(List::stream) + .map(line -> plansById.get(line.getPlanId())).filter(Objects::nonNull).distinct().toList(); + Set assignedWarehouses = matchedPlans.stream().map(InboundPlan::getOverseasWarehouseId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + orderFacts.stream().map(LastlegOrder::getOverseasWarehouseId).map(ReceivingContainerService::trim) + .filter(Objects::nonNull).forEach(value -> { + try { assignedWarehouses.add(Long.valueOf(value)); } + catch (NumberFormatException ignored) { assignedWarehouses.add(Long.MIN_VALUE); } + }); + if (assignedWarehouses.size() > 1) return null; + if (assignedWarehouses.size() != 1 || !assignedWarehouses.contains(overseasWarehouseId)) return null; + Set warehouseCodes = orderFacts.stream().map(LastlegOrder::getOverseasWarehouseCode) + .map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet()); + Set warehouseNames = orderFacts.stream().map(LastlegOrder::getOverseasWarehouseName) + .map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet()); + boolean warehouseSnapshotConsistent = warehouseCodes.size() == 1 && warehouseNames.size() == 1; + String containerNo = orderFacts.stream().map(LastlegOrder::getContainerNo).filter(Objects::nonNull).findFirst().orElse(null); + int totalOrders = orderFacts.size(); + int inboundCodes = (int) orderFacts.stream().map(LastlegOrder::getInboundCode).map(ReceivingContainerService::trim) + .filter(Objects::nonNull).distinct().count(); + int plannedOrders = (int) orderFacts.stream().filter(order -> { + List values = linesByOrder.getOrDefault(order.getId(), List.of()); + return values.size() == 1 && plansById.containsKey(values.get(0).getPlanId()); + }).count(); + int planCount = matchedPlans.size(); + boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders + && plannedOrders == totalOrders && planCount == totalOrders; + List containerPlans = planFacts.stream() + .filter(plan -> containerNo.equals(plan.getContainerNo())).toList(); + boolean allPlansCompleted = !containerPlans.isEmpty() + && containerPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") + .contains(plan.getStatus())); + boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1; + boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent; + int pending = Math.max(0, totalOrders - plannedOrders); + String blockReason = operable ? null : (!warehouseSnapshotConsistent + ? "柜内接柜仓业务快照不一致" + : !allPlansReady ? plannedOrders + "个入仓码已生成计划," + pending + "个订单未操作/缺入仓码" + : "柜号尚未达到海外收货可操作节点"); + LocalDateTime arrival = orderFacts.stream().map(LastlegOrder::getEstimatedWarehouseAt) + .filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null); + String factHash = hash(orderFacts.stream().sorted(Comparator.comparing(LastlegOrder::getId)).map(order -> { + List values = linesByOrder.getOrDefault(order.getId(), List.of()); + return order.getId() + ":" + trim(order.getInboundCode()) + ":" + values.stream() + .map(InboundPlanLine::getPlanId).sorted().toList(); + }).toList()); + Map ordersById = orderFacts.stream().collect(Collectors.toMap(LastlegOrder::getId, + value -> value, (left, right) -> left)); + List matchedRefs = lineFacts.stream().filter(line -> orderIds.contains(line.getOrderId())) + .map(line -> new InboundRef(plansById.get(line.getPlanId()), line, ordersById.get(line.getOrderId()))) + .filter(ref -> ref.plan() != null && ref.order() != null).toList(); + List inboundValues = matchedRefs.stream().map(this::inbound).toList(); + List destinationCodes = inboundValues.stream().map(this::destinationCode) + .filter(Objects::nonNull).distinct().sorted().toList(); + Totals totals = new Totals(inboundValues.size(), destinationCodes, + inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::plannedPalletCount).sum(), + inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(), + inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum()); + return new ContainerReadiness(sourceContainerBookingId, containerNo, + warehouseCodes.size() == 1 ? warehouseCodes.iterator().next() : null, + warehouseNames.size() == 1 ? warehouseNames.iterator().next() : null, totalOrders, inboundCodes, + totalOrders - inboundCodes, planCount, plannedOrders, pending, allPlansReady, allPlansCompleted, shippingReady, operable, + blockReason, arrival, factHash, totals); + } + + private ReceivingContainerResponses.Candidate candidate(ContainerReadiness value, + ReceivingContainerSession current) { + boolean active = current != null && value.containerNo().equals(current.getContainerNo()); + Totals totals = value.totals(); + return new ReceivingContainerResponses.Candidate(value.sourceContainerBookingId(), value.containerNo(), + value.overseasWarehouseCode(), value.overseasWarehouseName(), + active ? current.getStatus() : "WAIT_SIGN", value.totalOrderCount(), value.inboundCodeCount(), + value.missingInboundCodeCount(), value.planCount(), value.plannedInboundCodeCount(), + value.pendingPlanCount(), totals.destinationCodes(), totals.plannedPalletCount(), totals.total(), + totals.received(), value.operable(), value.allPlansReady(), value.blockReason(), + value.plannedArrivalAt(), active); + } + + 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"); + if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { + throw conflict("Every inbound plan in the receiving container must have an owning customer"); + } + if (values.stream().allMatch(value -> List.of("COMPLETED", "PENDING_RECONCILIATION") + .contains(value.getStatus()))) { + throw conflict("Receiving container is already completed"); + } + return values; + } + + private List plansForContainer(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"); + return values; + } + + private List refs(String containerNo) { + List result = new ArrayList<>(); + for (InboundPlan plan : plansForContainer(containerNo)) { + String customerId = trim(plan.getCustomerId()); + if (customerId == null) throw conflict("Inbound plan has no owning customer"); + 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 containerNo, String inboundCode) { + List matches = refs(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(containerNo).stream() + .filter(value -> customerId.equals(value.order().getCustomerId())) + .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 inboundCode) { + boolean exists = lines.selectList(new LambdaQueryWrapper() + .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 containerNo, InboundRef selected) { + List values = refs(containerNo).stream() + .filter(value -> !"ONE_CLICK_DROPSHIP".equals(value.order().getOrderType())) + .filter(value -> "FBA".equals(destinationType(value.line(), value.order()))) + .filter(value -> Objects.equals(normalizedFba(value.line().getFbaWarehouseCode()), + normalizedFba(selected.line().getFbaWarehouseCode()))) + .sorted(Comparator.comparing((InboundRef value) -> value.line().getInboundCode()) + .thenComparing(value -> value.line().getId())).toList(); + if (values.isEmpty() || values.stream().anyMatch(value -> + !"FBA".equals(destinationType(value.line(), value.order())))) { + throw conflict("FBA receiving group is invalid"); + } + Set destinations = values.stream().map(value -> trim(value.line().getFbaWarehouseCode())) + .collect(Collectors.toSet()); + if (destinations.size() != 1 || destinations.contains(null)) { + throw conflict("FBA receiving group requires one destination warehouse"); + } + 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 PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode, boolean requirePlanPallet) { + String normalizedPalletCode = palletCode.trim(); + List reserved = 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 -> matchesPlannedPalletCode(value, normalizedPalletCode)) + .filter(this::hasConfirmedReservation) + .toList(); + if (reserved.size() > 1) { + throw conflict("Pallet code matches multiple reserved resources in this inbound plan"); + } + WarehouseResourceGateway.WarehousePalletReference resource; + InboundPlanPallet pallet; + if (reserved.size() == 1) { + pallet = reserved.get(0); + long warehouseId = ref.plan().getOverseasWarehouseId() == null + ? Long.parseLong(ref.order().getOverseasWarehouseId()) : ref.plan().getOverseasWarehouseId(); + resource = warehouse.resolveReservedPallet(pallet.getPalletNo(), ref.order().getCustomerId(), warehouseId, + pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId()); + } else { + try { + resource = warehouse.resolveAvailablePallet(normalizedPalletCode); + } catch (IllegalArgumentException exception) { + throw notFound("Pallet was not found"); + } + if (resource == null) throw notFound("Pallet was not found"); + pallet = requirePlanPallet ? choosePallet(ref, resource) : null; + } + validatePallet(ref, resource); + return new PalletSelection(pallet, resource); + } + + private PalletSelection resolveFbaGroupPallet(List refs, String palletCode) { + String normalized = palletCode.trim(); + List matches = refs.stream().flatMap(ref -> pallets.selectList( + new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream()) + .filter(value -> matchesPlannedPalletCode(value, normalized)).filter(this::hasConfirmedReservation) + .collect(Collectors.toMap(InboundPlanPallet::getId, value -> value, (left,right)->left, + LinkedHashMap::new)).values().stream().toList(); + if (matches.isEmpty()) throw notFound("Pallet code is not part of the current FBA destination group"); + if (matches.size() != 1) throw conflict("Pallet code is duplicated in the current FBA destination group"); + InboundPlanPallet pallet = matches.get(0); + InboundRef owner = refs.stream().filter(ref -> Objects.equals(ref.plan().getId(), pallet.getPlanId())) + .findFirst().orElseThrow(() -> conflict("Pallet owner plan is not part of the FBA destination group")); + long warehouseId = owner.plan().getOverseasWarehouseId() == null + ? Long.parseLong(owner.order().getOverseasWarehouseId()) : owner.plan().getOverseasWarehouseId(); + WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolveReservedPallet( + pallet.getPalletNo(), owner.order().getCustomerId(), warehouseId, pallet.getWarehousePalletId(), + pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId()); + validatePallet(owner, resource); + return new PalletSelection(pallet, resource); + } + + private boolean matchesPlannedPalletCode(InboundPlanPallet pallet, String scannedCode) { + return scannedCode.equals(trim(pallet.getPalletNo())) + || scannedCode.equals(trim(pallet.getSourcePalletNo())); + } + + private boolean hasConfirmedReservation(InboundPlanPallet pallet) { + return pallet.getWarehousePalletId() != null && pallet.getWarehousePalletId() > 0 + && pallet.getWarehouseLocationId() != null && pallet.getWarehouseLocationId() > 0 + && pallet.getWarehouseResourceLockId() != null && pallet.getWarehouseResourceLockId() > 0 + && ("RESERVED".equals(pallet.getAssignmentStatus()) + || "ASSIGNED".equals(pallet.getAssignmentStatus()) + || "LOCKED".equals(pallet.getAssignmentStatus())); + } + + private void requireMaterializedPlans(List containerPlans) { + for (InboundPlan plan : containerPlans) { + int expected = plan.getPalletCount() == null ? 0 : plan.getPalletCount(); + long materialized = pallets.selectCount(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, plan.getCustomerId()) + .eq(InboundPlanPallet::getPlanId, plan.getId()) + .isNotNull(InboundPlanPallet::getWarehousePalletId) + .isNotNull(InboundPlanPallet::getWarehouseLocationId) + .isNotNull(InboundPlanPallet::getWarehouseResourceLockId)); + if (expected <= 0 || materialized < expected) { + throw conflict("入库计划尚未完整绑定真实托盘,请先在平台按柜生成入库计划"); + } + } + } + + 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 String dropshipSourcePallet(String containerNo, String scanCode) { + return dropshipSourcePallet(refs(containerNo), containerNo, scanCode); + } + + private String dropshipSourcePallet(List currentRefs, String containerNo, String scanCode) { + String normalized = trim(scanCode); + if (normalized == null) return null; + List dropshipRefs = currentRefs.stream() + .filter(ref -> "ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())).toList(); + if (dropshipRefs.isEmpty()) return null; + boolean direct = normalized.toUpperCase(java.util.Locale.ROOT).startsWith("DP") && dropshipRefs.stream() + .anyMatch(ref -> pallets.selectCount(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getPlanId, ref.plan().getId()) + .eq(InboundPlanPallet::getSourcePalletNo, normalized)) > 0); + if (direct) return normalized; + List resolved = dropshipCartons.sourcePalletsInContainer(containerNo, normalized); + if (resolved.size() > 1) throw conflict("Carton belongs to more than one DP in the current container"); + return resolved.isEmpty() ? null : resolved.get(0); + } + + private List dropshipPalletRefs(String containerNo, String sourcePalletNo) { + List result = new ArrayList<>(); + for (InboundRef ref : refs(containerNo)) { + if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue; + List sourcePallets = pallets.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPallet::getPlanId, ref.plan().getId()) + .eq(InboundPlanPallet::getSourcePalletNo, sourcePalletNo)); + for (InboundPlanPallet pallet : sourcePallets) { + allocations.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId()) + .eq(InboundPlanPalletAllocation::getLineId, ref.line().getId()) + .eq(InboundPlanPalletAllocation::getPalletId, pallet.getId())) + .forEach(allocation -> result.add(new DropshipPalletRef(ref, pallet, allocation))); + } + } + if (result.isEmpty()) throw notFound("DP is not part of the current container"); + Set physicalPallets = result.stream().map(value -> value.pallet().getWarehousePalletId()) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (physicalPallets.size() != 1) throw conflict("DP customer fragments do not share one physical pallet"); + return result; + } + + private void requireCompleteManifest(String containerNo, String sourcePalletNo) { + List profiles = dropshipProfiles.listContainerPallet(containerNo, sourcePalletNo); + if (profiles.isEmpty()) throw notFound("DP manifest was not found in the current container"); + Set versions = profiles.stream().map(DropshipOrderProfile::getManifestVersion).collect(Collectors.toSet()); + Set totals = profiles.stream().map(DropshipOrderProfile::getManifestPackageCount).collect(Collectors.toSet()); + Set fragments = profiles.stream().map(DropshipOrderProfile::getManifestFragmentCount).collect(Collectors.toSet()); + Set hashes = profiles.stream().map(DropshipOrderProfile::getManifestHash).collect(Collectors.toSet()); + if (versions.size()!=1 || totals.size()!=1 || fragments.size()!=1 || hashes.size()!=1) { + throw conflict("DP manifest fragments are inconsistent"); + } + long customerFragments = profiles.stream().map(DropshipOrderProfile::getCustomerId).distinct().count(); + long cartons = profiles.stream().mapToLong(profile -> + dropshipCartons.listProfile(profile.getCustomerId(), profile.getId()).size()).sum(); + if (customerFragments != fragments.iterator().next() || cartons != totals.iterator().next()) { + throw conflict("DP manifest fragments are incomplete; wait for all customer fragments"); + } + } + + private record PalletSelection(InboundPlanPallet pallet, + WarehouseResourceGateway.WarehousePalletReference resource) { } + + private record DropshipPalletRef(InboundRef ref, InboundPlanPallet pallet, + InboundPlanPalletAllocation allocation) { } + + private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { + List inbounds = refs(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.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(), + value(session.getVersion()), signPhotos(session), domesticLoadingPhotos(session), + session.getPreparedAt(), session.getSignedAt(), + session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), + hasFbaOverReceipt(session, containerPlans), inbounds); + } + + private List domesticLoadingPhotos( + ReceivingContainerSession session) { + String bookingId = trim(session.getSourceContainerBookingId()); + if (bookingId == null) return List.of(); + return domesticEvidence.listByContainer(bookingId).stream() + .map(value -> new ReceivingContainerResponses.DomesticLoadingPhoto(value.getEvidenceType(), + evidenceLabel(value.getEvidenceType()), DOMESTIC_PHOTO_DOWNLOAD_PATH.replace("{type}", value.getEvidenceType()))) + .toList(); + } + + private static String requiredEvidenceType(String value) { + String type = trim(value); + if (type == null || !Set.of("PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL").contains(type)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "不支持的国内装柜照片类型"); + } + return type; + } + + private static String evidenceLabel(String type) { + return switch (type) { + case "PHOTO_STEP_1" -> "空仓"; + case "PHOTO_STEP_2" -> "半仓"; + case "PHOTO_STEP_3" -> "满仓"; + case "PHOTO_STEP_4" -> "锁仓"; + case "SEAL" -> "带锁码"; + default -> type; + }; + } + + private void savePhysicalFbaCount(long sessionId, String groupKey, String palletCode, int actualPackages, + ReceivingFbaPalletCount existing) { + LocalDateTime now = LocalDateTime.now(); + if (existing == null) { + ReceivingFbaPalletCount value = new ReceivingFbaPalletCount(); + value.setId(IdWorker.getId()); value.setSessionId(sessionId); value.setGroupKey(groupKey); + value.setPalletCode(palletCode); value.setActualPackages(actualPackages); + value.setCreatedAt(now); value.setUpdatedAt(now); fbaPalletCounts.insert(value); + } else { + existing.setActualPackages(actualPackages); existing.setUpdatedAt(now); fbaPalletCounts.updateById(existing); + } + } + + private boolean hasFbaOverReceipt(ReceivingContainerSession session, List containerPlans) { + if (session == null || containerPlans == null || containerPlans.isEmpty()) return false; + return refs(session.getContainerNo()).stream() + .filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) + .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) + .anyMatch(ref -> fbaGroupHasOverReceipt(session, ref)); + } + + private boolean fbaGroupHasOverReceipt(ReceivingContainerSession session, InboundRef ref) { + if (session == null || session.getId() == null) return false; + int planned = requireFbaPlanRefs(session.getContainerNo(), ref).stream() + .mapToInt(value -> value(value.line().getRequiredPackageCount())).sum(); + return fbaPalletCounts.sumActual(session.getId(), fbaGroupKey(session.getContainerNo(), ref)) > planned; + } + + private ReceivingContainerSession findSession(String containerNo) { + return sessions.findActiveByContainer(activeContainerKey(containerNo)); + } + + 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), + assigned(ref.plan()), mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()), + ref.order().getOrderType(), "ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())); + } + + private boolean assigned(InboundPlan plan) { + return plan != null && Boolean.TRUE.equals(plan.getAssignedFlag()); + } + + private boolean mergeAllowed(InboundPlan plan) { + return plan != null && Boolean.TRUE.equals(plan.getMergeRequired()); + } + + private boolean urgent(InboundPlan plan) { + return plan != null && Boolean.TRUE.equals(plan.getUrgentFlag()); + } + + private String mergeHint(InboundPlan plan) { + return mergeAllowed(plan) ? PALLET_MERGE_HINT : null; + } + + private Totals totals(List containerPlans) { + List values = refs(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(List refs, int packageCount) { + int remaining = packageCount; + List result = new ArrayList<>(); + for (InboundRef ref : refs) { + String customerId = ref.order().getCustomerId(); + 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) { + String customerId = sku.ref().order().getCustomerId(); + List existing = skuReceipts.lockLineSku(customerId, + 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(customerId); 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 containerNo) { + for (InboundRef ref : refs(containerNo)) { + if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; + if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue; + String customerId = ref.order().getCustomerId(); + 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 String fbaGroupKey(String containerNo, InboundRef ref) { + return "FBA:" + containerNo + ":" + normalizedFba(ref.line().getFbaWarehouseCode()); + } + + private String normalizedFba(String value) { + String normalized = trim(value); + return normalized == null ? null : normalized.toUpperCase(java.util.Locale.ROOT); + } + + 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 operatorKey, + WarehouseResourceGateway.WarehouseReference warehouse, + String containerNo) { + ReceivingContainerSession value = new ReceivingContainerSession(); + value.setOverseasWarehouseId(warehouse.warehouseId()); + value.setOverseasWarehouseCode(warehouse.warehouseCode()); + value.setOverseasWarehouseName(warehouse.warehouseName()); + value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0); + return value; + } + + private boolean warehouseEligible(List containerPlans, long overseasWarehouseId) { + Set assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (assigned.size() > 1) { + throw conflict("Container plans have inconsistent receiving warehouse assignments"); + } + return assigned.isEmpty() || assigned.size() == 1 && assigned.contains(overseasWarehouseId); + } + + private void bindReceivingWarehouse(List containerPlans, long overseasWarehouseId) { + for (InboundPlan plan : containerPlans) { + if (plan.getOverseasWarehouseId() != null) { + if (!Objects.equals(plan.getOverseasWarehouseId(), overseasWarehouseId)) { + throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); + } + continue; + } + plan.setOverseasWarehouseId(overseasWarehouseId); + plan.setUpdatedAt(LocalDateTime.now()); + if (plans.updateById(plan) != 1) { + throw conflict("Receiving warehouse assignment changed concurrently"); + } + } + } + + 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.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 ContainerReadiness(String sourceContainerBookingId, String containerNo, + String overseasWarehouseCode, String overseasWarehouseName, + int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount, + int planCount, int plannedInboundCodeCount, int pendingPlanCount, + boolean allPlansReady, boolean allPlansCompleted, boolean shippingReady, boolean operable, String blockReason, + LocalDateTime plannedArrivalAt, String factHash, Totals totals) { } + private record PrepareCommand(String containerNo, String idempotencyKey) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java new file mode 100644 index 000000000..c95a433ac --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java @@ -0,0 +1,506 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse; +import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand; +import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +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.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.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Service +public class ContainerInboundPlanConversionCoordinator { + private static final java.time.Duration APPLYING_LEASE = java.time.Duration.ofMinutes(2); + @Resource private InboundPlanService plans; + @Resource private WarehouseResourceGateway warehouse; + @Resource private ContainerPlanConversionCommandMapper commands; + @Resource private LastlegOrderNoGenerator numbers; + @Resource private ObjectMapper objectMapper; + @Resource private PlatformTransactionManager transactionManager; + + /** + * Saves one manually adjusted plan, then materializes every plan in the container as soon as + * the last logical plan is complete. The manual endpoint is therefore never a separate pallet + * creation workflow: for a one-order container this happens immediately; for a multi-order + * container the final manual save closes the whole container. + */ + public InboundPlanResponse convertManualAndAutoMaterialize(ManualInboundPlanRequest request, + String idempotencyKey, + String operatorId) { + InboundPlanResponse manual = plans.convertManual(request, idempotencyKey, operatorId); + String bookingId = plans.sourceContainerBookingId(manual.customerId(), manual.sourceOrderId()); + ContainerInboundPlanPreviewResponse preview = plans.previewContainer(bookingId); + List orders = preview.groups().stream() + .flatMap(group -> group.orders().stream()).toList(); + boolean allLogicalPlansComplete = !orders.isEmpty() && orders.stream().allMatch(order -> + "PLANNED".equals(order.readiness()) || "RESOURCE_PENDING".equals(order.readiness())); + boolean needsMaterialization = orders.stream() + .anyMatch(order -> "RESOURCE_PENDING".equals(order.readiness())); + if (!preview.ready() || !allLogicalPlansComplete || !needsMaterialization) return manual; + + List materialized = confirm(new ConvertContainerInboundPlanRequest( + bookingId, preview.snapshotVersion(), preview.snapshotHash()), + "AUTO-MANUAL:" + hash(List.of(bookingId, idempotencyKey))); + return materialized.stream() + .filter(plan -> Objects.equals(plan.customerId(), manual.customerId()) + && Objects.equals(plan.sourceOrderId(), manual.sourceOrderId())) + .findFirst().orElse(manual); + } + + public List confirm(ConvertContainerInboundPlanRequest request, String idempotencyKey) { + String bookingId = required(request.sourceContainerBookingId(), 128, "来源柜预订 ID 不能为空"); + String key = required(idempotencyKey, 128, "Idempotency-Key 必须为1至128个字符"); + String requestHash = hash(List.of(bookingId, request.snapshotVersion(), request.snapshotHash())); + ContainerPlanConversionCommand direct = commands.find(bookingId, key); + ContainerPlanConversionCommand resolved = direct == null ? commands.findByBooking(bookingId) : direct; + if (resolved == null) { + resolved = createCommand(bookingId, key, request, requestHash); + } else if ("COMPLETED".equals(resolved.getStatus())) { + if (direct != null) requireSameRequest(resolved, requestHash); + return result(resolved); + } else if ("FAILED".equals(resolved.getStatus())) { + resolved = replaceFailedCommand(resolved, bookingId, key, request, requestHash); + } else { + requireSameRequest(resolved, requestHash); + } + if ("COMPLETED".equals(resolved.getStatus())) { + requireSameRequest(resolved, requestHash); + return result(resolved); + } + final ContainerPlanConversionCommand command = resolved; + Preparation preparation = read(command.getPreparationJson(), Preparation.class); + + boolean applyingLeaseOwned = false; + if ("APPLYING".equals(command.getStatus())) { + LocalDateTime now = LocalDateTime.now(); + String leaseToken = IdWorker.getIdStr(); + if (command.getUpdatedAt() == null || command.getUpdatedAt().plus(APPLYING_LEASE).isAfter(now) + || commands.reclaimStale(command.getId(), command.getUpdatedAt(), leaseToken, now) != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交"); + } + command.setLeaseToken(leaseToken); + command.setUpdatedAt(now); + applyingLeaseOwned = true; + } + if ("PREPARED".equals(command.getStatus())) { + String leaseToken = IdWorker.getIdStr(); + if (commands.claim(command.getId(), "PREPARED", "APPLYING", leaseToken, LocalDateTime.now()) != 1) { + ContainerPlanConversionCommand current = commands.findByBooking(bookingId); + if (current != null && "COMPLETED".equals(current.getStatus())) return result(current); + throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交"); + } + command.setStatus("APPLYING"); + command.setLeaseToken(leaseToken); + applyingLeaseOwned = true; + } + if (!applyingLeaseOwned && !"LOCAL_COMMITTED".equals(command.getStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级转换命令状态无效"); + } + if (preparation.items().isEmpty()) { + try { + List existing = inTransaction(() -> { + List converted = plans.convertContainerReserved(bookingId, + request.snapshotVersion(), request.snapshotHash(), List.of()); + state(command, "APPLYING", "COMPLETED", write(converted), null); + return converted; + }); + command.setStatus("COMPLETED"); + return existing; + } catch (RuntimeException failure) { + ContainerPlanConversionCommand current = commands.findByBooking(bookingId); + if (current != null && "COMPLETED".equals(current.getStatus())) return result(current); + failWithoutResources(command, failure); + throw failure; + } + } + + List localResult; + if ("LOCAL_COMMITTED".equals(command.getStatus())) { + localResult = result(command); + } else { + WarehouseResourceGateway.PalletBatchEnsure ensure = new WarehouseResourceGateway.PalletBatchEnsure( + preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item -> + new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()), + item.businessKey(), item.planNo(), item.globalSequenceNo(), "WOOD", + item.fbaWarehouseCode(), item.palletCode())).toList()); + WarehouseResourceGateway.PalletBatchResponse reservation; + try { + reservation = warehouse.ensurePalletBatch(ensure); + requireBatch(reservation, command.getBatchKey(), preparation.items().size(), "RESERVED", "CONFIRMED"); + } catch (RuntimeException failure) { + compensateAndFail(command, preparation, failure); + throw failure; + } + try { + List preparedPlans = bind(preparation, reservation); + localResult = inTransaction(() -> { + List created = plans.convertContainerReserved(bookingId, + request.snapshotVersion(), request.snapshotHash(), preparedPlans); + state(command, "APPLYING", "LOCAL_COMMITTED", write(created), null); + return created; + }); + command.setStatus("LOCAL_COMMITTED"); + } catch (RuntimeException failure) { + ContainerPlanConversionCommand current = commands.findByBooking(bookingId); + if (current != null && "COMPLETED".equals(current.getStatus())) { + return result(current); + } + if (current != null && "LOCAL_COMMITTED".equals(current.getStatus())) { + command.setStatus(current.getStatus()); + command.setResultJson(current.getResultJson()); + localResult = result(current); + } else { + compensateAndFail(command, preparation, failure); + throw failure; + } + } + } + + WarehouseResourceGateway.PalletBatchResponse confirmed = warehouse.confirmPalletBatch( + new WarehouseResourceGateway.PalletBatchCommand(preparation.warehouseId(), command.getBatchKey(), + "CONFIRM:" + command.getId())); + requireBatch(confirmed, command.getBatchKey(), preparation.items().size(), "CONFIRMED"); + complete(command, localResult); + return localResult; + } + + private ContainerPlanConversionCommand createCommand(String bookingId, String key, + ConvertContainerInboundPlanRequest request, + String requestHash) { + long commandId = IdWorker.getId(); + ContainerPlanConversionCommand command = prepareCommand(commandId, commandId, bookingId, key, request, requestHash); + try { inTransaction(() -> { commands.insert(command); return null; }); } + catch (DuplicateKeyException concurrent) { + ContainerPlanConversionCommand existing = commands.findByBooking(bookingId); + if (existing == null) throw concurrent; + return existing; + } + return command; + } + + private ContainerPlanConversionCommand replaceFailedCommand(ContainerPlanConversionCommand previous, + String bookingId, String key, + ConvertContainerInboundPlanRequest request, + String requestHash) { + ContainerPlanConversionCommand replacement = prepareCommand(previous.getId(), IdWorker.getId(), + bookingId, key, request, requestHash); + int updated = commands.replaceFailed(previous.getId(), key, requestHash, request.snapshotVersion(), + request.snapshotHash(), replacement.getWarehouseId(), replacement.getBatchKey(), + replacement.getPreparationJson(), replacement.getUpdatedAt()); + if (updated != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在刷新入库计划,请勿重复提交"); + } + return replacement; + } + + private ContainerPlanConversionCommand prepareCommand(long commandId, long generationId, String bookingId, + String key, ConvertContainerInboundPlanRequest request, + String requestHash) { + ContainerInboundPlanPreviewResponse preview = plans.previewContainer(bookingId); + if (preview.snapshotVersion() != request.snapshotVersion() + || !Objects.equals(preview.snapshotHash(), request.snapshotHash()) || !preview.ready()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级预览已变化或存在阻断,请刷新预览后重试"); + } + long warehouseId; + try { warehouseId = Long.parseLong(preview.overseasWarehouseId()); } + catch (RuntimeException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception); + } + List preparedOrders = new ArrayList<>(); + Map prefixByFba = new LinkedHashMap<>(); + Map nextIndexByFba = new LinkedHashMap<>(); + Set usedPalletCodes = new HashSet<>(); + List convertibleOrders = preview.groups().stream() + .flatMap(group -> group.orders().stream()) + .filter(item -> "READY".equals(item.readiness()) || "RESOURCE_PENDING".equals(item.readiness())) + .toList(); + for (ContainerInboundPlanPreviewResponse.Order order : convertibleOrders) { + List sourcePalletNos = plans.sourcePalletNos( + order.customerId(), Long.parseLong(order.orderId()), order.orderType()); + int targetPalletCount = "RESOURCE_PENDING".equals(order.readiness()) + ? Objects.requireNonNullElse(order.plannedPalletCount(), 0) + : Objects.requireNonNullElse(order.suggestedPalletCount(), 0); + if (sourcePalletNos.size() > targetPalletCount) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "计划托数不能少于已生成的头程托盘码数量"); + } + String fbaCode = trimToNull(order.fbaWarehouseCode()); + if (fbaCode != null && !"ONE_CLICK_DROPSHIP".equals(order.orderType())) { + for (String palletCode : sourcePalletNos) { + PalletCodeParts parts = palletCodeParts(preview.containerNo(), palletCode); + String previousPrefix = prefixByFba.putIfAbsent(fbaCode, parts.prefix()); + if (previousPrefix != null && !previousPrefix.equals(parts.prefix())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "同一 FBA 仓的头程托盘码内部编码不一致"); + } + nextIndexByFba.merge(fbaCode, parts.index(), Math::max); + if (!usedPalletCodes.add(palletCode)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码存在重复"); + } + } + if (sourcePalletNos.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "散货订单缺少头程托盘码快照"); + } + } + InboundPlanService.PlanIdentity existing = "RESOURCE_PENDING".equals(order.readiness()) + ? plans.existingPlanIdentity(order.customerId(), Long.parseLong(order.orderId())) : null; + long planId = existing == null ? IdWorker.getId() : existing.planId(); + String planNo = existing == null + ? numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId) : existing.planNo(); + if (existing != null && existing.palletCount() != targetPalletCount) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托数与柜级预览已不一致"); + } + preparedOrders.add(new PreparedOrder(order, planId, planNo, targetPalletCount, + List.copyOf(sourcePalletNos))); + } + + List items = new ArrayList<>(); + int globalSequence = 0; + for (PreparedOrder preparedOrder : preparedOrders) { + ContainerInboundPlanPreviewResponse.Order order = preparedOrder.order(); + List sourcePalletNos = new ArrayList<>(preparedOrder.sourcePalletNos()); + boolean warehouseGeneratedPallets = trimToNull(order.fbaWarehouseCode()) == null + && !"ONE_CLICK_DROPSHIP".equals(order.orderType()); + if (!warehouseGeneratedPallets && !"ONE_CLICK_DROPSHIP".equals(order.orderType())) { + String fbaCode = order.fbaWarehouseCode().trim(); + String prefix = prefixByFba.get(fbaCode); + while (sourcePalletNos.size() < preparedOrder.targetPalletCount()) { + int next = nextIndexByFba.compute(fbaCode, (ignored, current) -> current == null ? 1 : current + 1); + String palletCode = prefix + "-" + String.format("%03d", next); + if (!usedPalletCodes.add(palletCode)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "补生成托盘码发生重复"); + } + sourcePalletNos.add(palletCode); + } + } else if (!warehouseGeneratedPallets && sourcePalletNos.size() != preparedOrder.targetPalletCount()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "一件代发托盘码数量与柜级预览不一致"); + } + for (int localSequence = 1; localSequence <= preparedOrder.targetPalletCount(); localSequence++) { + items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), preparedOrder.planId(), + preparedOrder.planNo(), + localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence, + trimToNull(order.fbaWarehouseCode()), warehouseGeneratedPallets + ? null : sourcePalletNos.get(localSequence - 1))); + } + } + Preparation preparation = new Preparation(warehouseId, List.copyOf(items)); + ContainerPlanConversionCommand command = new ContainerPlanConversionCommand(); + command.setId(commandId); command.setSourceContainerBookingId(bookingId); command.setIdempotencyKey(key); + command.setRequestHash(requestHash); command.setSnapshotVersion(request.snapshotVersion()); + command.setSnapshotHash(request.snapshotHash()); command.setWarehouseId(warehouseId); + command.setBatchKey("CIP:" + hash(bookingId) + ":" + generationId); command.setStatus("PREPARED"); + command.setPreparationJson(write(preparation)); command.setCreatedAt(LocalDateTime.now()); + command.setUpdatedAt(command.getCreatedAt()); + return command; + } + + private List bind(Preparation preparation, + WarehouseResourceGateway.PalletBatchResponse response) { + Map byKey = new LinkedHashMap<>(); + for (WarehouseResourceGateway.PalletBatchItem item : response.items()) { + if (item.businessKey() == null || byKey.putIfAbsent(item.businessKey(), item) != null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次包含重复业务键"); + } + } + validateSharedPallets(preparation.items(), byKey); + return preparation.items().stream().collect(Collectors.groupingBy(PreparedItem::orderId)).values().stream() + .map(items -> { + PreparedItem first = items.get(0); + return new InboundPlanService.PreparedPlan(first.planId(), first.planNo(), first.customerId(), + first.orderId(), items.stream().sorted(Comparator.comparingInt(PreparedItem::localSequenceNo)) + .map(item -> new InboundPlanService.PreparedPallet(item.localSequenceNo(), item.businessKey(), + requiredReservation(byKey.get(item.businessKey()), item))).toList()); + }).toList(); + } + + private WarehouseResourceGateway.PalletBatchItem requiredReservation( + WarehouseResourceGateway.PalletBatchItem value, PreparedItem expected) { + if (value == null || value.customerId() != Long.parseLong(expected.customerId()) + || !Objects.equals(value.businessKey(), expected.businessKey()) + || !Objects.equals(value.inboundPlanNo(), expected.planNo()) + || value.sequenceNo() != expected.globalSequenceNo() || value.palletId() <= 0 + || value.locationId() <= 0 || value.resourceLockId() <= 0 + || value.palletCode() == null || value.palletCode().isBlank()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘预留响应与计划快照不一致"); + } + return value; + } + + private void validateSharedPallets(List expected, + Map actual) { + Map> byPallet = expected.stream().collect(Collectors.groupingBy(item -> + requiredReservation(actual.get(item.businessKey()), item).palletId())); + for (List values : byPallet.values()) { + if (values.size() == 1) continue; + Set codes = values.stream().map(PreparedItem::palletCode).collect(Collectors.toSet()); + Set locks = values.stream().map(item -> requiredReservation(actual.get(item.businessKey()), item).resourceLockId()) + .collect(Collectors.toSet()); + if (codes.size() != 1 || codes.iterator().next() == null + || !codes.iterator().next().toUpperCase(java.util.Locale.ROOT).startsWith("DP") || locks.size() != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "只有同一码的一件代发 DP 才能共享实物托盘"); + } + } + } + + private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size, + String... acceptedStatuses) { + if (response == null || response.batchId() <= 0 || response.attemptNo() <= 0 + || !Objects.equals(batchKey, response.batchKey()) || response.items() == null + || response.items().size() != size || java.util.Arrays.stream(acceptedStatuses) + .noneMatch(status -> status.equals(response.status()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次响应不完整或状态不一致"); + } + String expectedItemStatus = switch (response.status()) { + case "RESERVED" -> "RESERVED"; + case "CONFIRMED" -> "CONFIRMED"; + case "COMPENSATED" -> "RELEASED"; + default -> null; + }; + boolean invalidItem = expectedItemStatus == null || response.items().stream().anyMatch(item -> + item.customerId() <= 0 || item.sequenceNo() <= 0 || item.palletId() <= 0 || item.locationId() <= 0 + || item.resourceLockId() <= 0 || trimToNull(item.businessKey()) == null + || trimToNull(item.inboundPlanNo()) == null || trimToNull(item.palletCode()) == null + || item.palletCode().length() > 128 || !expectedItemStatus.equals(item.status())); + if (invalidItem) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次明细不完整或状态不一致"); + } + } + + private void requireSameRequest(ContainerPlanConversionCommand command, String requestHash) { + if (!MessageDigest.isEqual(command.getRequestHash().getBytes(StandardCharsets.UTF_8), + requestHash.getBytes(StandardCharsets.UTF_8))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "同一幂等键不能用于不同柜级确认请求"); + } + } + + private List result(ContainerPlanConversionCommand command) { + if (command.getResultJson() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "柜级转换结果尚未可用"); + } + return read(command.getResultJson(), new TypeReference>() { }); + } + + private void complete(ContainerPlanConversionCommand command, List result) { + inTransaction(() -> { state(command, "LOCAL_COMMITTED", "COMPLETED", write(result), null); return null; }); + command.setStatus("COMPLETED"); + } + + private void failWithoutResources(ContainerPlanConversionCommand command, RuntimeException failure) { + if ("APPLYING".equals(command.getStatus())) { + state(command, "APPLYING", "FAILED", null, safeMessage(failure)); + command.setStatus("FAILED"); + } + } + + private void compensateAndFail(ContainerPlanConversionCommand command, Preparation preparation, + RuntimeException failure) { + try { + WarehouseResourceGateway.PalletBatchResponse compensated = warehouse.compensatePalletBatch( + new WarehouseResourceGateway.PalletBatchCompensation(preparation.warehouseId(), + command.getBatchKey(), "COMPENSATE:" + command.getBatchKey(), + "Lastleg local transaction did not commit")); + requireBatch(compensated, command.getBatchKey(), preparation.items().size(), "COMPENSATED"); + failWithoutResources(command, failure); + } catch (ResponseStatusException compensationFailure) { + if (compensationFailure.getStatusCode().value() == 404) { + failWithoutResources(command, failure); + return; + } + failure.addSuppressed(compensationFailure); + } catch (RuntimeException compensationFailure) { + failure.addSuppressed(compensationFailure); + } + } + + private void state(ContainerPlanConversionCommand command, String expectedStatus, String status, + String resultJson, String lastError) { + if (commands.updateState(command.getId(), expectedStatus, command.getLeaseToken(), status, + resultJson, lastError, LocalDateTime.now()) != 1) { + throw new IllegalStateException("无法更新柜级转换命令状态"); + } + } + + private T inTransaction(java.util.function.Supplier action) { + return new TransactionTemplate(transactionManager).execute(status -> action.get()); + } + + 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("无法序列化柜级转换快照", exception); } + } + private T read(String value, Class type) { + try { return objectMapper.readValue(value, type); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取柜级转换快照", exception); } + } + private T read(String value, TypeReference type) { + try { return objectMapper.readValue(value, type); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取柜级转换结果", exception); } + } + private static String required(String value, int max, String message) { + String normalized = value == null || value.isBlank() ? null : value.trim(); + if (normalized == null || normalized.length() > max) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + return normalized; + } + private static String safeMessage(Throwable value) { + String message = value.getMessage(); + return message == null ? value.getClass().getSimpleName() : message.substring(0, Math.min(1000, message.length())); + } + private static String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + public record Preparation(long warehouseId, List items) { } + private record PreparedOrder(ContainerInboundPlanPreviewResponse.Order order, long planId, String planNo, + int targetPalletCount, List sourcePalletNos) { } + public record PreparedItem(String customerId, long orderId, long planId, String planNo, + int localSequenceNo, int globalSequenceNo, String businessKey, + String fbaWarehouseCode, String palletCode) { } + + private static PalletCodeParts palletCodeParts(String containerNo, String palletCode) { + String stableContainerNo = trimToNull(containerNo); + String stablePalletCode = trimToNull(palletCode); + if (stableContainerNo == null || stablePalletCode == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "集装箱号或头程托盘码缺失"); + } + Matcher matcher = Pattern.compile("^(" + Pattern.quote(stableContainerNo) + "-.+)-(\\d{3})$") + .matcher(stablePalletCode); + if (!matcher.matches()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "头程托盘码不符合‘集装箱号-FBA内部编码-下标’规则"); + } + return new PalletCodeParts(matcher.group(1), Integer.parseInt(matcher.group(2))); + } + + private record PalletCodeParts(String prefix, int index) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostJob.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostJob.java new file mode 100644 index 000000000..690564395 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostJob.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DestuffingPalletCostJob { + private final DestuffingPalletCostService service; + + @Scheduled(fixedDelayString = "${baiyue.destuff-pallet-cost.fixed-delay-ms:10000}") + public void allocateReadyContainers() { + for (String bookingId : service.readyBookingIds()) { + try { + service.generateIfReady(bookingId, "SYSTEM"); + } catch (RuntimeException exception) { + log.warn("Destuff pallet cost allocation will retry bookingId={}", bookingId, exception); + } + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java new file mode 100644 index 000000000..e491cbd24 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java @@ -0,0 +1,97 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.financefee.service.OverseasFeeOutboxService; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class DestuffingPalletCostService { + static final String DESTUFF_BUSINESS_TYPE = "OVERSEAS_CONTAINER"; + static final String READY_BOOKINGS_SQL = "SELECT o.source_container_booking_id FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id JOIN ll_inbound_pda_task t ON t.plan_id=p.id AND t.customer_id=p.customer_id LEFT JOIN ll_destuff_pallet_cost c ON c.source_container_booking_id=o.source_container_booking_id WHERE o.source_container_booking_id IS NOT NULL AND c.id IS NULL GROUP BY o.source_container_booking_id HAVING COUNT(*)=SUM(CASE WHEN t.status='COMPLETED' THEN 1 ELSE 0 END) ORDER BY MAX(t.completed_at) DESC LIMIT 50"; + static final String RECEIVED_PALLET_IDS_SQL = "SELECT DISTINCT pp.warehouse_pallet_id FROM ll_inbound_plan_pallet pp JOIN ll_inbound_plan p ON p.id=pp.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE o.source_container_booking_id=? AND pp.warehouse_pallet_id IS NOT NULL AND pp.actual_package_count>0 AND pp.actual_volume_cbm>0 ORDER BY pp.warehouse_pallet_id"; + private final JdbcTemplate jdbc; + private final PalletPhysicalGateway pallets; + private final OverseasFeeSourceMapper feeSources; + private final OverseasFeeOutboxService feeOutbox; + + public List readyBookingIds() { + return jdbc.queryForList(READY_BOOKINGS_SQL, String.class); + } + + @Transactional + public void generateIfReady(String bookingId, String operatorId) { + Integer exists = jdbc.queryForObject("SELECT COUNT(*) FROM ll_destuff_pallet_cost WHERE source_container_booking_id=?", Integer.class, bookingId); + if (exists != null && exists > 0) return; + List> plans = jdbc.queryForList("SELECT p.id,p.customer_id,p.container_no,p.overseas_warehouse_id,t.status FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id LEFT JOIN ll_inbound_pda_task t ON t.plan_id=p.id AND t.customer_id=p.customer_id WHERE o.source_container_booking_id=? ORDER BY p.customer_id,p.id", bookingId); + if (plans.isEmpty() || plans.stream().anyMatch(row -> !"COMPLETED".equals(row.get("status")))) return; + List palletIds = jdbc.queryForList(RECEIVED_PALLET_IDS_SQL, Long.class, bookingId); + BigDecimal totalCost = BigDecimal.ZERO; + for (Long palletId : palletIds) totalCost = totalCost.add(pallets.resolve(palletId).unitPrice()); + totalCost = totalCost.setScale(2, RoundingMode.HALF_UP); + if (palletIds.isEmpty() || totalCost.signum() <= 0) return; + List volumes = jdbc.query("SELECT a.customer_id,SUM(a.actual_volume_cbm) actual_volume FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE o.source_container_booking_id=? GROUP BY a.customer_id ORDER BY a.customer_id", + (rs, n) -> new CustomerVolume(rs.getString("customer_id"), rs.getBigDecimal("actual_volume")), bookingId); + BigDecimal totalVolume = volumes.stream().map(CustomerVolume::volume).reduce(BigDecimal.ZERO, BigDecimal::add); + if (totalVolume.signum() <= 0) throw new IllegalStateException("拆柜打托费用缺少客户实收体积,不能分摊"); + long costId = IdWorker.getId(); String containerNo = String.valueOf(plans.get(0).get("container_no")); + Long warehouseId = plans.get(0).get("overseas_warehouse_id") == null ? null : ((Number) plans.get(0).get("overseas_warehouse_id")).longValue(); + jdbc.update("INSERT INTO ll_destuff_pallet_cost(id,source_container_booking_id,container_no,warehouse_id,pallet_count,total_actual_volume_cbm,total_cost,currency,status,created_at) VALUES(?,?,?,?,?,?,?,'CNY','ALLOCATED',NOW(3))", + costId, bookingId, containerNo, warehouseId, palletIds.size(), totalVolume, totalCost); + List ordered = new ArrayList<>(volumes); + ordered.sort(Comparator.comparing(CustomerVolume::customerId)); + List amounts = allocate(totalCost, ordered.stream().map(CustomerVolume::volume).toList()); + for (int i=0; i allocate(BigDecimal totalCost, List volumes) { + BigDecimal totalVolume = volumes.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + if (totalVolume.signum() <= 0) throw new IllegalArgumentException("实收体积必须大于零"); + List result = new ArrayList<>(); BigDecimal allocated = BigDecimal.ZERO; + for (int i = 0; i < volumes.size(); i++) { + BigDecimal amount = i == volumes.size() - 1 ? totalCost.subtract(allocated) + : totalCost.multiply(volumes.get(i)).divide(totalVolume, 2, RoundingMode.HALF_UP); + result.add(amount); allocated = allocated.add(amount); + } + return result; + } + + private OverseasFeeSource fee(long costId, String bookingId, String containerNo, Long warehouseId, CustomerVolume volume, BigDecimal amount, String operator) { + LocalDateTime now=LocalDateTime.now(ZoneOffset.UTC); OverseasFeeSource v=new OverseasFeeSource(); + v.setId(IdWorker.getId()); v.setSourceFeeId(IdWorker.getId()); v.setSourceVersion(1); v.setCustomerId(volume.customerId()); + v.setSourceType("OVERSEAS_CONTAINER_FEE"); v.setSourceNo("DESTUFF-"+bookingId); v.setBusinessType(DESTUFF_BUSINESS_TYPE); + v.setBusinessId(costId); v.setBusinessNo("DESTUFF-"+bookingId); v.setContainerBookingId(parseLong(bookingId)); v.setContainerNo(containerNo); + v.setWarehouseId(warehouseId); v.setFeeItemCode("DESTUFF_PALLET"); v.setFeeItemName("拆柜打托费"); v.setDirection("RECEIVABLE"); + v.setPaymentVoucherFileIds(List.of()); v.setBusinessDate(LocalDate.now()); v.setCurrency("CNY"); v.setAmount(amount); + v.setOriginalCurrency("CNY"); v.setOriginalAmount(amount); v.setExchangeRate(BigDecimal.ONE.setScale(6)); + v.setExchangeRateMonth(LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM"))); + v.setChargeableQuantity(volume.volume()); v.setQuantityUnit("CBM"); + v.setUnitPrice(volume.volume().signum() > 0 ? amount.divide(volume.volume(), 6, RoundingMode.HALF_UP) : null); + v.setReviewStatus("APPROVED"); v.setPublicationStatus("PUBLISHED"); v.setSourceStatus("ACTIVE"); v.setReviewerId(operator); + v.setReviewedAt(now); v.setCreatedBy(operator); v.setUpdatedBy(operator); v.setCreatedAt(now); v.setUpdatedAt(now); return v; + } + private static Long parseLong(String value){try{return Long.parseLong(value);}catch(Exception ignored){return null;}} + private record CustomerVolume(String customerId, BigDecimal volume){} +} 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..ea3d4f117 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -0,0 +1,1187 @@ +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.ContainerInboundPlanPreviewResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; +import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest; +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 com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.common.event.ContainerNumbers; +import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; +import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; +import com.baiyue.lastleg.mapper.LastlegSourcePalletMapper; +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.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.LocalDate; +import java.time.LocalDateTime; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.stream.Collectors; + +@Service +public class InboundPlanService { + @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; + @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; + @Resource private DropshipOrderService dropshipOrders; + @Resource private WarehouseResourceGateway warehouseGateway; + @Resource private LastlegOrderCommandRecordMapper commandMapper; + @Resource private LastlegSourcePalletMapper sourcePalletMapper; + @Resource private ObjectMapper objectMapper; + + public List sourcePalletNos(String customerId, long orderId, String orderType) { + return "ONE_CLICK_DROPSHIP".equals(orderType) + ? dropshipOrders.palletSnapshots(customerId, orderId).stream() + .map(DropshipPalletSnapshot::sourcePalletNo).toList() + : sourcePalletMapper.palletNos(customerId, orderId); + } + + @Transactional(readOnly = true) + public String sourceContainerBookingId(String customerId, long orderId) { + String bookingId = trimToNull(orderMapper.findSourceContainerBookingId(customerId, orderId)); + if (bookingId == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在或未绑定来源柜"); + } + return bookingId; + } + + @Transactional + public InboundPlanResponse convertManual(ManualInboundPlanRequest request, String idempotencyKey, + String operatorId) { + String customerId = trimToNull(request.customerId()); + if (customerId == null || "0".equals(customerId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台人工计划必须指定客户"); + } + String key = trimToNull(idempotencyKey); + if (key == null || key.length() > 128) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key 必须为1至128个字符"); + } + List containerFacts = + orderMapper.lockContainerWarehouseFacts(customerId, request.orderId()); + if (containerFacts == null || containerFacts.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在或未绑定来源柜"); + } + String containerWarehouseId = requireSingleWarehouseId(containerFacts.stream() + .map(InboundPlanOrderMapper.ContainerWarehouseRef::overseasWarehouseId).toList()); + LocalDate plannedArrivalDate = requiredContainerPlannedArrivalDate( + containerFacts.get(0).sourceContainerBookingId()); + List snapshots = orderMapper.lockOrder(customerId, request.orderId()); + if (snapshots == null || snapshots.size() != 1) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在"); + } + SourceOrderSnapshot order = snapshots.get(0); + String requestHash = sha256(request); + LastlegOrderCommandRecord replay = commandMapper.find(customerId, request.orderId(), "MANUAL_PLAN", operatorId, key); + if (replay != null) { + if (!MessageDigest.isEqual(replay.getRequestHash().getBytes(StandardCharsets.UTF_8), + requestHash.getBytes(StandardCharsets.UTF_8))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "同一幂等键不能用于不同请求"); + } + return detailByPlanNo(customerId, replay.getResultRef()); + } + if (planMapper.findBySourceOrder(customerId, request.orderId()) != null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单已生成入库计划"); + } + if (order.version() != request.expectedOrderVersion()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单版本已变化,请刷新后重试"); + } + if (order.detained()) throw new ResponseStatusException(HttpStatus.CONFLICT, "暂扣尾程订单不能生成入库计划"); + if (!Objects.equals(containerWarehouseId, trimToNull(order.overseasWarehouseId()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜接柜仓冻结事实已变化,请刷新后重试"); + } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + requiredReceivingWarehouse(containerWarehouseId); + validateOrders(snapshots); + String orderType = trimToNull(orderMapper.findOrderType(customerId, request.orderId())); + List dropshipPalletSnapshots = "ONE_CLICK_DROPSHIP".equals(orderType) + ? dropshipOrders.palletSnapshots(customerId, request.orderId()) : List.of(); + int sourcePalletCount = "ONE_CLICK_DROPSHIP".equals(orderType) + ? dropshipPalletSnapshots.size() + : Objects.requireNonNullElse( + sourcePalletMapper.palletNos(customerId, request.orderId()), List.of()).size(); + if ("ONE_CLICK_DROPSHIP".equals(orderType)) { + if (sourcePalletCount <= 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "一件代发订单缺少不可变来源托盘快照,不能生成入库计划"); + } + if (request.palletCount() != sourcePalletCount) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "一件代发人工计划托数必须与头程封托托盘数一致:" + sourcePalletCount); + } + } + int minimumPalletCount = "ONE_CLICK_DROPSHIP".equals(orderType) + ? sourcePalletCount : Math.max(suggestedPalletCount(order), sourcePalletCount); + if (request.palletCount() < minimumPalletCount) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount); + } + List wholePallets = "ONE_CLICK_DROPSHIP".equals(orderType) + ? dropshipPalletSnapshots.stream().map(this::wholePallet).toList() : null; + InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), wholePallets, + request, receivingWarehouse, plannedArrivalDate); + LastlegOrderCommandRecord command = new LastlegOrderCommandRecord(); + command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setOrderId(request.orderId()); + command.setAction("MANUAL_PLAN"); command.setOperatorId(operatorId); command.setIdempotencyKey(key); + command.setRequestHash(requestHash); command.setResultRef(response.planNo()); + command.setResultJson(json(response)); command.setResultVersion(request.expectedOrderVersion() + 1); + command.setCreatedAt(LocalDateTime.now()); commandMapper.insert(command); + return response; + } + + @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(readOnly = true) + public ContainerInboundPlanPreviewResponse previewContainer(String sourceContainerBookingId) { + String bookingId = trimToNull(sourceContainerBookingId); + if (bookingId == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空"); + } + List rows = orderMapper.containerPreviewOrders(bookingId); + if (rows == null || rows.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单"); + } + String containerNo = rows.stream().map(InboundPlanOrderMapper.ContainerPreviewOrderRow::containerNo) + .map(InboundPlanService::trimToNull).filter(Objects::nonNull).findFirst().orElse(null); + String warehouseId = singleValue(rows.stream() + .map(InboundPlanOrderMapper.ContainerPreviewOrderRow::overseasWarehouseId).toList()); + String warehouseCode = singleValue(rows.stream() + .map(InboundPlanOrderMapper.ContainerPreviewOrderRow::overseasWarehouseCode).toList()); + String warehouseName = singleValue(rows.stream() + .map(InboundPlanOrderMapper.ContainerPreviewOrderRow::overseasWarehouseName).toList()); + + List orders = rows.stream() + .map(row -> previewOrder(row, containerNo, warehouseId)).toList(); + Comparator orderComparator = Comparator + .comparing((ContainerInboundPlanPreviewResponse.Order item) -> typeRank(item.destinationType())) + .thenComparing(item -> nullSafe(item.destinationWarehouseCode())) + .thenComparing(item -> nullSafe(item.destinationWarehouseName())) + .thenComparing(item -> nullSafe(item.destinationWarehouseId())) + .thenComparing(ContainerInboundPlanPreviewResponse.Order::customerId) + .thenComparing(ContainerInboundPlanPreviewResponse.Order::orderId); + orders = orders.stream().sorted(orderComparator).toList(); + + Map> grouped = new LinkedHashMap<>(); + for (ContainerInboundPlanPreviewResponse.Order order : orders) { + String key = order.destinationType() + "|" + nullSafe(order.destinationWarehouseId()) + "|" + + nullSafe(order.destinationWarehouseCode()) + "|" + nullSafe(order.destinationWarehouseName()); + grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(order); + } + List groups = grouped.values().stream().map(items -> { + ContainerInboundPlanPreviewResponse.Order first = items.get(0); + return new ContainerInboundPlanPreviewResponse.Group(first.destinationType(), + first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), + items.size(), (int) items.stream().filter(InboundPlanService::requiresContainerConversion).count(), + items.stream().filter(InboundPlanService::requiresContainerConversion) + .map(InboundPlanService::pendingPalletCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), + items.stream().filter(InboundPlanService::requiresContainerConversion) + .mapToInt(ContainerInboundPlanPreviewResponse.Order::sourcePalletCount).sum(), + items.stream().filter(InboundPlanService::requiresContainerConversion) + .mapToInt(ContainerInboundPlanPreviewResponse.Order::supplementPalletCount).sum(), + items.stream().filter(item -> "PLANNED".equals(item.readiness())) + .map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), + items.stream().map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), + items.stream().map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), List.copyOf(items)); + }).toList(); + int blocked = (int) orders.stream().filter(item -> "BLOCKED".equals(item.readiness())).count(); + int pendingOrders = (int) orders.stream().filter(InboundPlanService::requiresContainerConversion).count(); + int pendingPallets = orders.stream().filter(InboundPlanService::requiresContainerConversion) + .map(InboundPlanService::pendingPalletCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + int sourcePallets = orders.stream().filter(InboundPlanService::requiresContainerConversion) + .mapToInt(ContainerInboundPlanPreviewResponse.Order::sourcePalletCount).sum(); + int supplementPallets = orders.stream().filter(InboundPlanService::requiresContainerConversion) + .mapToInt(ContainerInboundPlanPreviewResponse.Order::supplementPalletCount).sum(); + int existingPallets = orders.stream().filter(item -> "PLANNED".equals(item.readiness())) + .map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + long snapshotVersion = rows.stream().mapToLong(InboundPlanOrderMapper.ContainerPreviewOrderRow::version).max().orElse(0); + String snapshotHash = sha256(java.util.Arrays.asList( + bookingId, containerNo, warehouseId, snapshotVersion, orders)); + return new ContainerInboundPlanPreviewResponse(bookingId, containerNo, warehouseId, warehouseCode, + warehouseName, snapshotVersion, snapshotHash, orders.size(), orders.size() - blocked, blocked, + pendingOrders, pendingPallets, sourcePallets, supplementPallets, existingPallets, + orders.stream().map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), + orders.stream().map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), blocked == 0, groups); + } + + private ContainerInboundPlanPreviewResponse.Order previewOrder( + InboundPlanOrderMapper.ContainerPreviewOrderRow row, String containerNo, String warehouseId) { + List blockers = new ArrayList<>(); + boolean planned = trimToNull(row.planNo()) != null; + if (!planned) { + if (trimToNull(row.inboundCode()) == null) blockers.add("MISSING_INBOUND_CODE"); + if (row.detained()) blockers.add("ORDER_DETAINED"); + if (!("PENDING_ASSIGNMENT".equals(row.currentStatus()) + || "DESTINATION_ASSIGNED".equals(row.currentStatus()))) blockers.add("ORDER_STATUS_NOT_READY"); + if (containerNo == null || !Objects.equals(containerNo, trimToNull(row.containerNo()))) { + blockers.add("CONTAINER_NO_INCONSISTENT"); + } + if (warehouseId == null || !Objects.equals(warehouseId, trimToNull(row.overseasWarehouseId()))) { + blockers.add("RECEIVING_WAREHOUSE_INCONSISTENT"); + } + boolean fba = isFbaDestination(row); + if (fba ? trimToNull(row.fbaWarehouseCode()) == null + : row.destinationCount() != 1 || trimToNull(row.destinationWarehouseId()) == null + && trimToNull(row.destinationWarehouseCode()) == null) blockers.add("DESTINATION_NOT_READY"); + if (row.packageCount() < 0 || row.volumeCbm() == null || row.volumeCbm().signum() < 0 + || row.packageCount() > 0 && row.volumeCbm().signum() == 0) blockers.add("CARGO_FACT_INVALID"); + } + Integer suggested = suggestedPalletCount(row, blockers); + int sourcePalletCount = "ONE_CLICK_DROPSHIP".equals(row.orderType()) + ? Objects.requireNonNullElse(suggested, 0) + : Objects.requireNonNullElse(row.sourcePalletCount(), 0); + int targetPalletCount = Objects.requireNonNullElse(row.plannedPalletCount(), + Objects.requireNonNullElse(suggested, 0)); + int supplementPalletCount = Math.max(0, targetPalletCount - sourcePalletCount); + if (planned && "ONE_CLICK_DROPSHIP".equals(row.orderType()) + && targetPalletCount != sourcePalletCount) { + blockers.add("DROPSHIP_PALLET_COUNT_MISMATCH"); + } + if (!planned && (suggested == null || suggested <= 0)) blockers.add("PALLET_COUNT_NOT_READY"); + String destinationType = destinationType(row); + String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null; + String destinationCode = fbaWarehouseCode != null + ? row.fbaWarehouseCode().trim() : row.destinationWarehouseCode(); + boolean materialized = planned && row.plannedPalletCount() != null + && row.materializedPalletCount() != null + && row.materializedPalletCount() >= row.plannedPalletCount(); + if (planned && !materialized && row.materializedPalletCount() != null + && row.materializedPalletCount() > 0) { + blockers.add("PLAN_PALLET_BINDING_INCOMPLETE"); + } + if (planned && !materialized && !("PENDING_ASSIGNMENT".equals(row.planStatus()) + || "SIGNED".equals(row.planStatus()))) { + blockers.add("PLAN_ALREADY_STARTED"); + } + String readiness = planned ? materialized ? "PLANNED" + : blockers.isEmpty() ? "RESOURCE_PENDING" : "BLOCKED" + : blockers.isEmpty() ? "READY" : "BLOCKED"; + return new ContainerInboundPlanPreviewResponse.Order(row.customerId(), row.customerName(), + String.valueOf(row.orderId()), trimToNull(row.orderNo()) == null ? row.sourceOrderNo() : row.orderNo(), + row.inboundCode(), row.orderType(), destinationType, row.destinationWarehouseId(), + destinationCode, row.destinationWarehouseName(), fbaWarehouseCode, row.packageCount(), row.volumeCbm(), + suggested, sourcePalletCount, targetPalletCount, supplementPalletCount, + row.plannedPalletCount(), row.planNo(), row.planStatus(), row.version(), readiness, + List.copyOf(blockers)); + } + + private Integer suggestedPalletCount(InboundPlanOrderMapper.ContainerPreviewOrderRow row, List blockers) { + if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) { + List pallets = dropshipOrders.palletSnapshots(row.customerId(), row.orderId()); + if (pallets == null || pallets.isEmpty()) { + if (trimToNull(row.planNo()) == null) blockers.add("DROPSHIP_PALLET_SNAPSHOT_MISSING"); + return null; + } + return pallets.size(); + } + if (row.volumeCbm() == null || row.volumeCbm().signum() < 0 || row.packageCount() < 0 + || row.packageCount() > 0 && row.volumeCbm().signum() == 0) return null; + try { + return PalletAllocator.allocate(List.of(new PalletAllocator.LineInput(row.orderId(), + nullSafe(row.destinationWarehouseId()) + "|" + nullSafe(isFbaDestination(row) + ? row.fbaWarehouseCode() : row.destinationWarehouseCode()), + row.volumeCbm(), row.packageCount(), row.recommendedPalletCount()))).pallets().size(); + } catch (IllegalArgumentException exception) { + return null; + } + } + + private int suggestedPalletCount(SourceOrderSnapshot order) { + try { + return PalletAllocator.allocate(List.of(new PalletAllocator.LineInput(order.orderId(), + warehouseKey(order), volume(order), order.packageCount(), order.recommendedPalletCount()))) + .pallets().size(); + } catch (IllegalArgumentException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "订单货量不能计算预览建议托数", exception); + } + } + + private static boolean requiresContainerConversion(ContainerInboundPlanPreviewResponse.Order order) { + return "READY".equals(order.readiness()) || "RESOURCE_PENDING".equals(order.readiness()); + } + + private static Integer pendingPalletCount(ContainerInboundPlanPreviewResponse.Order order) { + return order.targetPalletCount(); + } + + private static String destinationType(InboundPlanOrderMapper.ContainerPreviewOrderRow row) { + if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) return "ONE_CLICK_DROPSHIP"; + if (isFbaDestination(row)) return "FBA"; + return "PRIVATE_ADDRESS"; + } + + private static boolean isFbaDestination(InboundPlanOrderMapper.ContainerPreviewOrderRow row) { + String explicit = trimToNull(row.destinationType()); + if (explicit != null) return "FBA".equalsIgnoreCase(explicit); + return trimToNull(row.fbaWarehouseCode()) != null; + } + + private static int typeRank(String type) { + return switch (type) { case "FBA" -> 0; case "ONE_CLICK_DROPSHIP" -> 1; default -> 2; }; + } + + private static String singleValue(List values) { + List normalized = values.stream().map(InboundPlanService::trimToNull).distinct().toList(); + return normalized.size() == 1 ? normalized.get(0) : null; + } + + private static String nullSafe(String value) { return value == null ? "" : value; } + + @Transactional + public InboundPlanResponse convert(String customerId, Long selectedOrderId) { + return convert(customerId, selectedOrderId, List.of()); + } + + @Transactional + public InboundPlanResponse convert(String customerId, Long selectedOrderId, List sourcePalletNos) { + return convert(customerId, selectedOrderId, sourcePalletNos, null); + } + + @Transactional + public InboundPlanResponse convertDropship(String customerId, Long selectedOrderId, + List sourcePallets) { + if (sourcePallets == null || sourcePallets.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "One-click dropship inbound requires source pallets"); + } + HashSet palletNos = new HashSet<>(); + for (WholePalletInput pallet : sourcePallets) { + if (pallet == null || trimToNull(pallet.palletNo()) == null || pallet.packageCount() <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "One-click dropship source pallet number and package count are required"); + } + if (!palletNos.add(pallet.palletNo().trim())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Duplicate one-click dropship source pallet number: " + pallet.palletNo()); + } + } + return convert(customerId, selectedOrderId, List.of(), List.copyOf(sourcePallets)); + } + + private InboundPlanResponse convert(String customerId, Long selectedOrderId, List sourcePalletNos, + List wholePallets) { + if (customerId == null || customerId.isBlank() || "0".equals(customerId.trim())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台生成入库计划必须指定客户"); + } + List orders = orderMapper.lockOrder(customerId, selectedOrderId); + if (orders == null || orders.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found"); + } + String containerNo = requiredContainerNo(orders.get(0).containerNo()); + InboundPlan existing = planMapper.findBySourceOrder(customerId, selectedOrderId); + if (existing != null) { + requirePlanContainer(existing, containerNo); + return detail(customerId, existing); + } + validateOrders(orders); + String sourceContainerBookingId = trimToNull(orderMapper.findSourceContainerBookingId(customerId, selectedOrderId)); + return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null, null, + requiredContainerPlannedArrivalDate(sourceContainerBookingId)); + } + + private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, + List orders, List sourcePalletNos, + List wholePallets, ManualInboundPlanRequest manual, + WarehouseResourceGateway.WarehouseReference receivingWarehouse, + LocalDate plannedArrivalDate) { + return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, manual, + receivingWarehouse, plannedArrivalDate, null, Map.of()); + } + + private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, + List orders, List sourcePalletNos, + List wholePallets, ManualInboundPlanRequest manual, + WarehouseResourceGateway.WarehouseReference receivingWarehouse, + LocalDate plannedArrivalDate, + PreparedPlan preparedPlan, + Map reservedPallets) { + String containerNo = requiredContainerNo(orders.get(0).containerNo()); + LocalDateTime now = LocalDateTime.now(); + InboundPlan plan = new InboundPlan(); + plan.setId(preparedPlan == null ? IdWorker.getId() : preparedPlan.planId()); + plan.setPlanNo(preparedPlan == null + ? orderNoGenerator.next("INBOUND_PLAN", orders.get(0).destinationWarehouseCode(), plan.getId()) + : preparedPlan.planNo()); + plan.setCustomerId(customerId); + plan.setSourceOrderId(selectedOrderId); + plan.setContainerNo(containerNo); + String receivingWarehouseId = trimToNull(orders.get(0).overseasWarehouseId()); + if (receivingWarehouseId == null || orders.stream().anyMatch(order -> + !Objects.equals(receivingWarehouseId, trimToNull(order.overseasWarehouseId())))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓未预分配或存在不一致"); + } + try { plan.setOverseasWarehouseId(Long.parseLong(receivingWarehouseId)); } + catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓ID格式无效", exception); + } + plan.setStatus("PENDING_ASSIGNMENT"); + plan.setPlannedArrivalDate(plannedArrivalDate); + plan.setPalletType(manual == null ? null : manual.palletType().trim().toUpperCase()); + plan.setAssignedFlag(manual != null && manual.assigned()); + plan.setMergeRequired(manual != null && manual.mergeRequired()); + plan.setUrgentFlag(manual != null && manual.urgent()); + plan.setCreationMode(manual == null ? "AUTO" : "MANUAL"); + plan.setPlanRemark(manual == null ? null : trimToNull(manual.remark())); + 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 = wholePallets == null && manual == null ? PalletAllocator.allocate(orders.stream() + .map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order), + order.packageCount(), order.recommendedPalletCount())).toList()) : null; + if (allocation != null && sourcePalletNos != null && !sourcePalletNos.isEmpty() + && sourcePalletNos.size() != allocation.pallets().size()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与尾程计划托盘数不一致"); + } + if (wholePallets != null) validateWholePallets(orders, wholePallets); + plan.setPalletCount(manual != null ? manual.palletCount() + : wholePallets == null ? allocation.pallets().size() : wholePallets.size()); + planMapper.insert(plan); + + lineByOrder.values().forEach(lineMapper::insert); + Map palletCounts; + if (manual != null && wholePallets == null) { + createManualPallets(plan, orders.get(0), lineByOrder.get(orders.get(0).orderId()), manual); + palletCounts = Map.of(orders.get(0).orderId(), manual.palletCount()); + } else if (wholePallets == null) { + Map persistedPallets = createPallets(plan, allocation, + orderById, sourcePalletNos == null ? List.of() : sourcePalletNos, reservedPallets); + createAllocations(plan, allocation, lineByOrder, persistedPallets); + palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy( + PalletAllocator.AllocationDraft::orderId, Collectors.collectingAndThen(Collectors.mapping( + item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size))); + } else { + SourceOrderSnapshot order = orders.get(0); + createWholePallets(plan, order, lineByOrder.get(order.orderId()), wholePallets, reservedPallets, + manual == null ? null : manual.palletType()); + palletCounts = Map.of(order.orderId(), wholePallets.size()); + } + for (SourceOrderSnapshot order : orders) { + int count = palletCounts.getOrDefault(order.orderId(), 0); + InboundPlanLine line = lineByOrder.get(order.orderId()); + line.setPlannedPalletCount(count); + lineMapper.updateById(line); + int updated = manual == null + ? orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) + : orderMapper.markPlannedManual(customerId, order.orderId(), plan.getPlanNo(), count, + manual.mergeRequired(), manual.expectedOrderVersion(), now); + if (updated != 1) { + throw new IllegalStateException("Failed to update order " + order.orderId()); + } + inventoryBalanceMapper.bindInboundPlan(customerId, receivingWarehouseId, 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, receivingWarehouse); + } + + /** 同一来源柜的每个尾程订单独立建计划;整个柜级批次共用一个事务。 */ + @Transactional + public List convertContainer(String sourceContainerBookingId) { + return convertContainerInternal(sourceContainerBookingId, null, null, Map.of()); + } + + @Transactional + public List convertContainerReserved(String sourceContainerBookingId, + long snapshotVersion, String snapshotHash, + List preparedPlans) { + Map prepared = preparedPlans.stream().collect(Collectors.toMap( + PreparedPlan::orderId, value -> value)); + return convertContainerInternal(sourceContainerBookingId, snapshotVersion, snapshotHash, prepared); + } + + private List convertContainerInternal(String sourceContainerBookingId, + Long snapshotVersion, String snapshotHash, + Map preparedPlans) { + String bookingId = trimToNull(sourceContainerBookingId); + if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空"); + List refs = orderMapper.lockContainerOrders(bookingId); + if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单"); + LocalDate plannedArrivalDate = requiredContainerPlannedArrivalDate(bookingId); + if (snapshotVersion != null) { + ContainerInboundPlanPreviewResponse current = previewContainer(bookingId); + if (current.snapshotVersion() != snapshotVersion || !Objects.equals(current.snapshotHash(), snapshotHash) + || !current.ready()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级预览已变化或存在阻断,请刷新预览后重试"); + } + } + String containerNo = null; + for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) { + String current = requiredContainerNo(ref.containerNo()); + if (containerNo == null) containerNo = current; + if (!Objects.equals(containerNo, current)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致"); + } + } + List existingPlans = refs.stream() + .map(ref -> planMapper.findBySourceOrder(ref.customerId(), ref.orderId())) + .toList(); + List warehouseIds = new ArrayList<>(refs.size()); + List pendingIndexes = new ArrayList<>(); + List> snapshots = new ArrayList<>(); + List> wholePallets = new ArrayList<>(); + List> standardPalletNos = new ArrayList<>(); + for (int index = 0; index < refs.size(); index++) { + InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index); + InboundPlan existing = existingPlans.get(index); + if (existing != null) { + requireValidExistingPlan(existing, containerNo); + InboundPlanOrderMapper.ReceivingWarehouseSnapshot warehouse = + orderMapper.findReceivingWarehouse(ref.customerId(), ref.orderId()); + warehouseIds.add(warehouse == null ? null : warehouse.overseasWarehouseId()); + continue; + } + if (!("PENDING_ASSIGNMENT".equals(ref.currentStatus()) + || "DESTINATION_ASSIGNED".equals(ref.currentStatus()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单"); + } + List orders = orderMapper.lockOrder(ref.customerId(), ref.orderId()); + if (orders == null || orders.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜订单在锁定后不存在"); + } + validateOrders(orders); + String currentContainerNo = requiredContainerNo(orders.get(0).containerNo()); + if (!Objects.equals(containerNo, currentContainerNo)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致"); + } + pendingIndexes.add(index); + snapshots.add(orders); + warehouseIds.addAll(orders.stream().map(SourceOrderSnapshot::overseasWarehouseId).toList()); + List sourcePallets = "ONE_CLICK_DROPSHIP".equals(ref.orderType()) + ? dropshipOrders.palletSnapshots(ref.customerId(), ref.orderId()).stream() + .map(this::wholePallet).toList() + : null; + if (sourcePallets != null) { + if (sourcePallets.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "一件代发订单缺少不可变来源托盘快照"); + } + validateWholePallets(orders, sourcePallets); + } + wholePallets.add(sourcePallets); + List standardNos = "ONE_CLICK_DROPSHIP".equals(ref.orderType()) ? List.of() + : sourcePalletMapper.palletNos(ref.customerId(), ref.orderId()); + boolean standardFba = sourcePallets == null && orders.stream() + .anyMatch(order -> trimToNull(order.fbaWarehouseCode()) != null); + if (standardFba && standardNos.isEmpty()) throw new ResponseStatusException( + HttpStatus.CONFLICT, "散货订单缺少头程托盘码快照"); + standardPalletNos.add(standardNos); + } + requireSingleWarehouseId(warehouseIds); + + Map created = new HashMap<>(); + for (int pendingIndex = 0; pendingIndex < pendingIndexes.size(); pendingIndex++) { + int refIndex = pendingIndexes.get(pendingIndex); + InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(refIndex); + PreparedPlan prepared = preparedPlans.get(ref.orderId()); + if (snapshotVersion != null && (prepared == null || !Objects.equals(prepared.customerId(), ref.customerId()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级托盘预留与待生成计划不一致"); + } + Map reserved = prepared == null ? Map.of() + : prepared.pallets().stream().collect(Collectors.toMap(PreparedPallet::sequenceNo, + PreparedPallet::reservation)); + List effectivePalletNos = standardPalletNos.get(pendingIndex); + if (prepared != null && !effectivePalletNos.isEmpty()) { + effectivePalletNos = prepared.pallets().stream() + .sorted(Comparator.comparingInt(PreparedPallet::sequenceNo)) + .map(value -> value.reservation().palletCode()).toList(); + } + created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), effectivePalletNos, + wholePallets.get(pendingIndex), null, null, plannedArrivalDate, prepared, reserved)); + } + for (int index = 0; index < refs.size(); index++) { + InboundPlan existing = existingPlans.get(index); + PreparedPlan prepared = preparedPlans.get(refs.get(index).orderId()); + if (existing != null && prepared != null) { + materializeExistingPlan(existing, refs.get(index), prepared); + } + } + List result = new ArrayList<>(refs.size()); + for (int index = 0; index < refs.size(); index++) { + InboundPlan existing = existingPlans.get(index); + result.add(existing == null ? created.get(index) : detail(refs.get(index).customerId(), existing)); + } + return List.copyOf(result); + } + + public record PreparedPlan(long planId, String planNo, String customerId, long orderId, + List pallets) { } + public record PreparedPallet(int sequenceNo, String businessKey, + WarehouseResourceGateway.PalletBatchItem reservation) { } + + public PlanIdentity existingPlanIdentity(String customerId, long orderId) { + InboundPlan plan = planMapper.findBySourceOrder(customerId, orderId); + if (plan == null) return null; + return new PlanIdentity(plan.getId(), plan.getPlanNo(), Objects.requireNonNullElse(plan.getPalletCount(), 0)); + } + + public record PlanIdentity(long planId, String planNo, int palletCount) { } + + private void materializeExistingPlan(InboundPlan plan, InboundPlanOrderMapper.ContainerOrderRef ref, + PreparedPlan prepared) { + if (!Objects.equals(plan.getId(), prepared.planId()) || !Objects.equals(plan.getPlanNo(), prepared.planNo()) + || !Objects.equals(plan.getCustomerId(), prepared.customerId()) + || !Objects.equals(ref.orderId(), prepared.orderId())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划与柜级托盘预留快照不一致"); + } + if (!("PENDING_ASSIGNMENT".equals(plan.getStatus()) || "SIGNED".equals(plan.getStatus()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "仅待入库或已签收但未实收的已有计划可由整柜生成补齐托盘"); + } + List pallets = palletMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, plan.getCustomerId()) + .eq(InboundPlanPallet::getPlanId, plan.getId()) + .orderByAsc(InboundPlanPallet::getSequenceNo).last("FOR UPDATE")); + if (pallets.size() != prepared.pallets().size() || pallets.size() != plan.getPalletCount()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托数与柜级托盘预留数量不一致"); + } + Map preparedBySequence = prepared.pallets().stream().collect(Collectors.toMap( + PreparedPallet::sequenceNo, value -> value)); + for (InboundPlanPallet pallet : pallets) { + if (pallet.getWarehousePalletId() != null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划已存在真实托盘,不能重复补齐"); + } + PreparedPallet value = preparedBySequence.get(pallet.getSequenceNo()); + if (value == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托盘序号不完整"); + pallet.setSourcePalletNo(value.reservation().palletCode()); + applyReservedPallet(pallet, value.reservation()); + palletMapper.updateById(pallet); + } + plan.setUpdatedAt(LocalDateTime.now()); + planMapper.updateById(plan); + } + + private WholePalletInput wholePallet(DropshipPalletSnapshot pallet) { + return new WholePalletInput(pallet.sourcePalletNo(), Math.toIntExact(pallet.packageCount())); + } + + private void createManualPallets(InboundPlan plan, SourceOrderSnapshot order, InboundPlanLine line, + ManualInboundPlanRequest request) { + if (order.packageCount() > 0 && request.palletCount() > order.packageCount()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "人工计划托盘数不能大于订单箱数"); + } + BigDecimal remainingVolume = volume(order); + int remainingPackages = order.packageCount(); + for (int index = 0; index < request.palletCount(); index++) { + int slots = request.palletCount() - index; + int packages = slots == 1 ? remainingPackages : remainingPackages / slots; + BigDecimal palletVolume = slots == 1 ? remainingVolume + : remainingVolume.divide(BigDecimal.valueOf(slots), 4, java.math.RoundingMode.HALF_UP); + InboundPlanPallet pallet = new InboundPlanPallet(); + pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId()); + pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", index + 1)); + pallet.setPalletType(request.palletType().trim().toUpperCase()); + pallet.setDestinationWarehouseId(order.destinationWarehouseId()); + pallet.setDestinationWarehouseCode(order.destinationWarehouseCode()); + pallet.setDestinationWarehouseName(order.destinationWarehouseName()); + pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(palletVolume); + pallet.setSequenceNo(index + 1); pallet.setAssignmentStatus("UNASSIGNED"); + pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet); + + InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); + allocation.setId(IdWorker.getId()); allocation.setCustomerId(plan.getCustomerId()); allocation.setPlanId(plan.getId()); + allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); + allocation.setAllocatedVolumeCbm(palletVolume); allocation.setPlannedPackageCount(packages); + allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0); + allocation.setActualVolumeCbm(BigDecimal.ZERO); allocation.setStatus("PENDING_COUNT"); allocation.setVersion(0); + allocationMapper.insert(allocation); + remainingPackages -= packages; remainingVolume = remainingVolume.subtract(palletVolume); + } + if (remainingPackages != 0 || remainingVolume.signum() != 0) { + throw new IllegalStateException("人工计划托盘分配未保持箱数或体积守恒"); + } + } + + private String requiredContainerNo(String value) { + try { + return ContainerNumbers.requireCanonical(value); + } catch (IllegalArgumentException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "来源柜真实集装箱号缺失或格式无效", exception); + } + } + + private LocalDate requiredContainerPlannedArrivalDate(String sourceContainerBookingId) { + String bookingId = trimToNull(sourceContainerBookingId); + if (bookingId == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单未绑定来源柜,无法取得预计到仓时间"); + } + List values = orderMapper.containerEstimatedWarehouseTimes(bookingId); + if (values == null || values.isEmpty() || values.stream().anyMatch(Objects::isNull)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜预计到仓时间缺失,请先补齐柜级时间"); + } + LocalDateTime expected = values.get(0); + if (values.stream().anyMatch(value -> !Objects.equals(expected, value))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜预计到仓时间不一致,请先修正柜级时间"); + } + return expected.toLocalDate(); + } + + private void requirePlanContainer(InboundPlan plan, String containerNo) { + String frozen = requiredContainerNo(plan.getContainerNo()); + if (!Objects.equals(containerNo, frozen)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划柜号快照与来源柜不一致"); + } + } + + private void requireValidExistingPlan(InboundPlan plan, String containerNo) { + requirePlanContainer(plan, containerNo); + if (trimToNull(plan.getPlanNo()) == null || trimToNull(plan.getStatus()) == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在无效入库计划,不能继续按柜生成"); + } + } + + private void validateWholePallets(List orders, List pallets) { + if (orders.size() != 1) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "One-click dropship inbound plan must belong to one inbound order"); + } + int packages = pallets.stream().mapToInt(WholePalletInput::packageCount).sum(); + if (packages != orders.get(0).packageCount()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "One-click dropship pallet package total must equal inbound order package count"); + } + } + + private void createWholePallets(InboundPlan plan, SourceOrderSnapshot order, InboundPlanLine line, + List sourcePallets, + Map reservedPallets, + String palletType) { + BigDecimal remainingVolume = volume(order); + int remainingPackages = order.packageCount(); + for (int index = 0; index < sourcePallets.size(); index++) { + WholePalletInput source = sourcePallets.get(index); + BigDecimal palletVolume = index == sourcePallets.size() - 1 ? remainingVolume + : volume(order).multiply(BigDecimal.valueOf(source.packageCount())) + .divide(BigDecimal.valueOf(order.packageCount()), 4, java.math.RoundingMode.HALF_UP); + if (palletVolume.compareTo(remainingVolume) > 0) palletVolume = remainingVolume; + InboundPlanPallet pallet = new InboundPlanPallet(); + pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId()); + pallet.setPalletNo(source.palletNo().trim()); pallet.setSourcePalletNo(source.palletNo().trim()); + pallet.setPalletType(trimToNull(palletType) == null ? null : palletType.trim().toUpperCase()); + pallet.setDestinationWarehouseId(order.destinationWarehouseId()); + pallet.setDestinationWarehouseCode(order.destinationWarehouseCode()); + pallet.setDestinationWarehouseName(order.destinationWarehouseName()); + pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(palletVolume); + pallet.setSequenceNo(index + 1); pallet.setAssignmentStatus("UNASSIGNED"); + applyReservedPallet(pallet, reservedPallets.get(index + 1)); + pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet); + + InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); + allocation.setId(IdWorker.getId()); allocation.setCustomerId(plan.getCustomerId()); allocation.setPlanId(plan.getId()); + allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); + allocation.setAllocatedVolumeCbm(palletVolume); allocation.setPlannedPackageCount(source.packageCount()); + allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0); + allocation.setActualVolumeCbm(BigDecimal.ZERO); allocation.setStatus("PENDING_COUNT"); allocation.setVersion(0); + allocationMapper.insert(allocation); + remainingVolume = remainingVolume.subtract(palletVolume); + remainingPackages -= source.packageCount(); + } + if (remainingPackages != 0 || remainingVolume.signum() != 0) { + throw new IllegalStateException("Failed to conserve one-click dropship pallet totals"); + } + } + + public record WholePalletInput(String palletNo, int packageCount) {} + + @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 update(String customerId, String planNo, UpdateInboundPlanRequest request) { + InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getPlanNo, planNo).eq(customerId != null, InboundPlan::getCustomerId, customerId).last("FOR UPDATE")); + if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + if (!java.util.Set.of("PENDING_ASSIGNMENT", "ASSIGNED").contains(plan.getStatus())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan has started and cannot be edited"); + int currentVersion = plan.getVersion() == null ? 0 : plan.getVersion(); + if (request.version() != currentVersion) + throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan version is stale; reload before editing"); + plan.setOverseasWarehouseId(request.overseasWarehouseId()); + plan.setUpdatedAt(LocalDateTime.now()); + planMapper.updateById(plan); + // Keep the denormalized destination snapshot used by list queries in sync with the plan. + List lines = lineMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getPlanId, plan.getId()) + .eq(InboundPlanLine::getCustomerId, plan.getCustomerId())); + for (InboundPlanLine line : lines) { + line.setDestinationWarehouseId(String.valueOf(request.overseasWarehouseId())); + if (trimToNull(request.warehouseCode()) != null) line.setDestinationWarehouseCode(request.warehouseCode().trim()); + if (trimToNull(request.warehouseName()) != null) line.setDestinationWarehouseName(request.warehouseName().trim()); + lineMapper.updateById(line); + } + // Pallets carry the same destination snapshot and are consumed by PDA/resource assignment. + // Update them in this transaction so a plan edit cannot leave lines and pallets pointing at + // different warehouses. + List pallets = palletMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getPlanId, plan.getId()) + .eq(InboundPlanPallet::getCustomerId, plan.getCustomerId())); + for (InboundPlanPallet pallet : pallets) { + pallet.setDestinationWarehouseId(String.valueOf(request.overseasWarehouseId())); + if (trimToNull(request.warehouseCode()) != null) pallet.setDestinationWarehouseCode(request.warehouseCode().trim()); + if (trimToNull(request.warehouseName()) != null) pallet.setDestinationWarehouseName(request.warehouseName().trim()); + palletMapper.updateById(pallet); + } + 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 (List.of("COMPLETED", "PENDING_RECONCILIATION").contains(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.getPalletType(), 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, + List sourcePalletNos, Map reservedPallets) { + 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()); + int palletIndex = result.size(); + String sourcePalletNo = palletIndex < sourcePalletNos.size() ? sourcePalletNos.get(palletIndex) : null; + pallet.setPalletNo(sourcePalletNo == null || sourcePalletNo.isBlank() + ? plan.getPlanNo() + "-P" + String.format("%03d", palletIndex + 1) : sourcePalletNo.trim()); + pallet.setSourcePalletNo(sourcePalletNo == null || sourcePalletNo.isBlank() ? null : sourcePalletNo.trim()); + 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"); + applyReservedPallet(pallet, reservedPallets.get(pallet.getSequenceNo())); + pallet.setActualVolumeCbm(BigDecimal.ZERO); + pallet.setActualPackageCount(0); + palletMapper.insert(pallet); + result.put(draft, pallet); + } + return result; + } + + private void applyReservedPallet(InboundPlanPallet pallet, + WarehouseResourceGateway.PalletBatchItem reserved) { + if (reserved == null) return; + if (trimToNull(reserved.palletCode()) == null || reserved.palletCode().length() > 128) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓返回的真实托盘码无效"); + } + pallet.setPalletNo(reserved.palletCode()); + pallet.setWarehousePalletId(reserved.palletId()); + pallet.setWarehouseLocationId(reserved.locationId()); + pallet.setWarehouseResourceLockId(reserved.resourceLockId()); + pallet.setAssignmentStatus("RESERVED"); + pallet.setLockedAt(LocalDateTime.now()); + } + + 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 || 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) { + return detail(customerId, plan, null); + } + + private InboundPlanResponse detail(String customerId, InboundPlan plan, + WarehouseResourceGateway.WarehouseReference resolvedWarehouse) { + if (customerId != null && !customerId.equals(plan.getCustomerId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + } + InboundPlanOrderMapper.ReceivingWarehouseSnapshot receivingWarehouse = orderMapper.findReceivingWarehouse( + plan.getCustomerId(), plan.getSourceOrderId()); + if (receivingWarehouse == null || trimToNull(receivingWarehouse.overseasWarehouseId()) == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划关联订单缺少接柜仓预分配事实"); + } + WarehouseResourceGateway.WarehouseReference authoritativeWarehouse = resolvedWarehouse == null + ? requiredReceivingWarehouse(receivingWarehouse.overseasWarehouseId()) : resolvedWarehouse; + InboundPlanOrderMapper.DetailOrderSnapshot sourceOrder = plan.getSourceOrderId() == null ? null + : orderMapper.findDetailOrder(plan.getCustomerId(), plan.getSourceOrderId()); + if (plan.getSourceOrderId() != null && sourceOrder == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划关联订单不存在"); + } + 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)); + List referencedPalletIds = allocations.stream() + .map(InboundPlanPalletAllocation::getPalletId) + .filter(Objects::nonNull) + .distinct() + .toList(); + Map referencedPallets = referencedPalletIds.isEmpty() + ? Map.of() + : palletMapper.selectBatchIds(referencedPalletIds).stream() + .collect(Collectors.toMap(InboundPlanPallet::getId, pallet -> pallet)); + if (referencedPallets.size() != referencedPalletIds.size()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划分配引用的托盘不存在"); + } + List externalOwnerPlanIds = referencedPallets.values().stream() + .map(InboundPlanPallet::getPlanId) + .filter(Objects::nonNull) + .filter(ownerPlanId -> !Objects.equals(ownerPlanId, plan.getId())) + .distinct() + .toList(); + Map palletOwnerPlanNos = new HashMap<>(); + palletOwnerPlanNos.put(plan.getId(), plan.getPlanNo()); + if (!externalOwnerPlanIds.isEmpty()) { + palletOwnerPlanNos.putAll(planMapper.selectBatchIds(externalOwnerPlanIds).stream() + .collect(Collectors.toMap(InboundPlan::getId, InboundPlan::getPlanNo))); + } + if (referencedPallets.values().stream().anyMatch(pallet -> + pallet.getPlanId() == null || !palletOwnerPlanNos.containsKey(pallet.getPlanId()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划分配引用的托盘所属计划不存在"); + } + return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(), + sourceOrder == null ? null : sourceOrder.orderNo(), + sourceOrder == null ? null : sourceOrder.customerName(), + sourceOrder == null ? null : sourceOrder.orderType(), plan.getContainerNo(), + authoritativeWarehouse.warehouseCode(), authoritativeWarehouse.warehouseName(), + plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(), + lines.stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum(), + allocations.stream().mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum(), + 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.getPalletType(), pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(), + pallet.getActualPackageCount())).toList(), + allocations.stream().map(item -> { + InboundPlanPallet referencedPallet = referencedPallets.get(item.getPalletId()); + return new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(), + referencedPallet.getPalletNo(), palletOwnerPlanNos.get(referencedPallet.getPlanId()), + Objects.equals(referencedPallet.getPlanId(), plan.getId()), + item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(), + item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion()); + }).toList(), + plan.getVersion() == null ? 0 : plan.getVersion(), plan.getPlannedArrivalDate(), plan.getPalletType(), + Boolean.TRUE.equals(plan.getAssignedFlag()), Boolean.TRUE.equals(plan.getMergeRequired()), + Boolean.TRUE.equals(plan.getUrgentFlag()), plan.getCreationMode(), plan.getPlanRemark(), + plan.getCreatedAt(), plan.getUpdatedAt()); + } + + private WarehouseResourceGateway.WarehouseReference requiredReceivingWarehouse(String warehouseId) { + String stableId = trimToNull(warehouseId); + if (stableId == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓未预分配"); + } + try { + return warehouseGateway.requireActiveSharedWarehouse(Long.parseLong(stableId)); + } catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓ID格式无效", exception); + } + } + + private String requireSingleWarehouseId(List warehouseIds) { + List normalized = warehouseIds.stream().map(InboundPlanService::trimToNull).toList(); + if (normalized.stream().anyMatch(Objects::isNull)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在未分配接柜仓的尾程订单"); + } + java.util.Set distinct = new java.util.HashSet<>(normalized); + if (distinct.size() != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单接柜仓不一致"); + } + return distinct.iterator().next(); + } + + 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(); + } + + private String sha256(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsBytes(value))); + } catch (NoSuchAlgorithmException | JsonProcessingException exception) { + throw new IllegalStateException("无法生成命令摘要", exception); + } + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存命令结果", exception); } + } +} 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..0726c1d08 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java @@ -0,0 +1,150 @@ +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) { + if (line.recommendedPalletCount() > 0) { + int minimumByVolume = line.volumeCbm().divide(PALLET_CAPACITY_CBM, 0, RoundingMode.CEILING).intValue(); + int count = Math.max(line.recommendedPalletCount(), minimumByVolume); + BigDecimal remaining = line.volumeCbm(); + for (int index = 0; index < count; index++) { + PalletDraft recommended = new PalletDraft(line.warehouseKey(), ++sequence, BigDecimal.ZERO); + pallets.add(recommended); + int remainingPallets = count - index; + BigDecimal allocated = remainingPallets == 1 ? remaining + : remaining.divide(BigDecimal.valueOf(remainingPallets), 4, RoundingMode.HALF_UP); + recommended.add(allocated); + allocations.add(new AllocationDraft(line.orderId(), recommended, allocated, 0)); + remaining = remaining.subtract(allocated); + } + current = null; + continue; + } + 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, + int recommendedPalletCount) { + public LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) { + this(orderId, warehouseKey, volumeCbm, packageCount, 0); + } + } + + 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/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..6f7357a46 --- /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, + @NotBlank @Size(max = 255) 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( + @NotBlank @Size(max = 255) 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/dto/ReturnedInventoryRestoreRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreRequest.java new file mode 100644 index 000000000..e79f3bdb9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreRequest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.inventory.command.dto; + +import java.util.List; + +public record ReturnedInventoryRestoreRequest( + String exceptionNo, + String outboundNo, + String overseasWarehouseId, + Long inboundOrderId, + List sources, + List receipts, + String idempotencyKey, + List evidenceFileIds +) { + public record SourceItem(Long outboundPalletItemId, Long inventoryPositionId, + String skuCode, long maxReturnPackages) { } + + public record Receipt(Long outboundPalletItemId, Long inventoryPositionId, long receivedPackages, + Long targetPalletId, String targetPalletNo, + Long targetLocationId, String targetLocationCode) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreResponse.java new file mode 100644 index 000000000..ba9d4e691 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreResponse.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inventory.command.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record ReturnedInventoryRestoreResponse( + long adjustmentId, + List ledgerIds, + long beforePackages, + long restoredPackages, + long afterPackages, + boolean replayed, + LocalDateTime completedAt +) { + public ReturnedInventoryRestoreResponse asReplay() { + return new ReturnedInventoryRestoreResponse(adjustmentId, ledgerIds, beforePackages, + restoredPackages, afterPackages, true, completedAt); + } +} 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..c55c2d00c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -0,0 +1,68 @@ +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.Delete; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@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 pallet_id=#{palletId}") + long palletPackages(@Param("palletId") Long palletId); + + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " + + "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} " + + "AND in_transit_packages>0 ORDER BY id FOR UPDATE") + List lockInboundTransitBalances(@Param("customerId") String customerId, + @Param("inboundOrderId") Long inboundOrderId, + @Param("inboundCode") String inboundCode); + + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " + + "AND inbound_order_id=#{inboundOrderId} ORDER BY id FOR UPDATE") + List lockInboundBalances(@Param("customerId") String customerId, + @Param("inboundOrderId") Long inboundOrderId); + + @Select("SELECT COUNT(*) FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId}") + long countPositions(@Param("customerId") String customerId, @Param("balanceId") Long balanceId); + + @Delete("DELETE FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} " + + "AND available_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 " + + "AND NOT EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id)") + int deleteEmptyBalance(@Param("customerId") String customerId, @Param("balanceId") Long balanceId); + + @Select("SELECT COALESCE(-SUM(delta_packages),0) FROM ll_inventory_ledger " + + "WHERE customer_id=#{customerId} AND position_id=#{positionId} AND outbound_order_no=#{outboundNo} " + + "AND sku_code=#{skuCode} AND business_type='SHIP' AND delta_packages<0") + long shippedPackages(@Param("customerId") String customerId, @Param("positionId") Long positionId, + @Param("outboundNo") String outboundNo, @Param("skuCode") String skuCode); + + @Select("SELECT COALESCE(SUM(delta_packages),0) FROM ll_inventory_ledger " + + "WHERE customer_id=#{customerId} AND source_event_id=#{sourceIdentity} " + + "AND sku_code=#{skuCode} AND business_type='RETURN_STOCK_IN' AND delta_packages>0") + long restoredPackages(@Param("customerId") String customerId, + @Param("sourceIdentity") String sourceIdentity, + @Param("skuCode") String skuCode); + + @Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " + + "WHERE p.customer_id=#{customerId} AND b.inbound_order_id=#{inboundOrderId} " + + "AND p.hold_packages>0 ORDER BY p.id FOR UPDATE") + List lockInboundHeldPositions(@Param("customerId") String customerId, + @Param("inboundOrderId") Long inboundOrderId); +} 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..e4f703b6b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -0,0 +1,913 @@ +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.dto.ReturnedInventoryRestoreRequest; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse; +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; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@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 void rebindReceivingWarehouse(String customerId, Long inboundOrderId, long warehouseId, + String warehouseCode, String warehouseName, + String operatorId, String idempotencyKey) { + String targetId = Long.toString(warehouseId); + String targetCode = required(warehouseCode, "Receiving warehouse code is required"); + String targetName = required(warehouseName, "Receiving warehouse name is required"); + Long auditOperationId = IdWorker.getId(); + for (InventoryBalance balance : commands.lockInboundBalances(customerId, inboundOrderId)) { + if (value(balance.getAvailablePackages()) != 0 || value(balance.getHoldPackages()) != 0 + || value(balance.getAllocatedPackages()) != 0 || value(balance.getPickedPackages()) != 0 + || value(balance.getReceivingPackages()) != 0 || value(balance.getProcurementPackages()) != 0 + || commands.countPositions(customerId, balance.getId()) != 0) { + throw conflict("Receiving warehouse cannot change after physical inventory exists"); + } + InventoryBalance target = balances.lockDimension(customerId, targetId, + balance.getDestinationWarehouseCode(), balance.getInboundOrderId(), balance.getInboundCode()); + if (target != null && !target.getId().equals(balance.getId())) { + throw conflict("Receiving warehouse change would create a duplicate inventory dimension"); + } + String sourceId = balance.getOverseasWarehouseId(); + if (Objects.equals(sourceId, targetId) + && Objects.equals(balance.getOverseasWarehouseCode(), targetCode) + && Objects.equals(balance.getOverseasWarehouseName(), targetName)) continue; + long packages = value(balance.getInTransitPackages()); + balance.setOverseasWarehouseId(targetId); + balance.setOverseasWarehouseCode(targetCode); + balance.setOverseasWarehouseName(targetName); + touch(balance); + balances.updateById(balance); + ledger(customerId, operatorId, auditOperationId, balance, null, balance.getSkuCode(), + "RECEIVING_WAREHOUSE_CHANGED", "IN_TRANSIT", "IN_TRANSIT", + packages, 0, packages, null, balance.getInboundOrderNo(), null, null, + idempotencyKey + ":BALANCE:" + balance.getId(), + "In-transit receiving warehouse changed from " + sourceId + " to " + targetId); + } + } + + @Transactional + public InventoryCommandResponse stockIn(String customerId, String operatorId, + InventoryCommandRequests.StockIn request) { + return stockIn(customerId, operatorId, request, false); + } + + @Transactional + public InventoryCommandResponse stockInHeld(String customerId, String operatorId, + InventoryCommandRequests.StockIn request) { + return stockIn(customerId, operatorId, request, true); + } + + private InventoryCommandResponse stockIn(String customerId, String operatorId, + InventoryCommandRequests.StockIn request, boolean held) { + return execute(customerId, operatorId, "STOCK_IN", request.idempotencyKey(), request, operation -> { + List transitBalances = commands.lockInboundTransitBalances(customerId, + request.inboundOrderId(), request.inboundCode()); + InventoryBalance balance = balance(customerId, request); + InventoryPosition position = position(customerId, balance, request); + long before = held ? value(position.getHoldPackages()) : value(position.getAvailablePackages()); + if (held) position.setHoldPackages(before + request.packages()); + else position.setAvailablePackages(before + request.packages()); + position.setStatus("OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + + long transferred = transferInTransit(customerId, operatorId, operation.getId(), balance, + transitBalances, request); + if (held) balance.setHoldPackages(value(balance.getHoldPackages()) + request.packages()); + else 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", held ? "HOLD" : "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 void releaseInboundOrderHold(String customerId, String operatorId, long inboundOrderId, + String businessOrderNo, String remark) { + for (InventoryPosition position : commands.lockInboundHeldPositions(customerId, inboundOrderId)) { + long packages = value(position.getHoldPackages()); + if (packages == 0) continue; + release(customerId, operatorId, new InventoryCommandRequests.Move(position.getId(), packages, + businessOrderNo, null, null, "ORDER_HOLD_RELEASE:" + inboundOrderId + ":" + position.getId(), remark)); + } + } + + private long transferInTransit(String customerId, String operatorId, Long operationId, + InventoryBalance target, List sources, + InventoryCommandRequests.StockIn request) { + long pending = request.packages(); + long transferred = 0; + for (InventoryBalance source : sources) { + if (pending == 0) break; + boolean targetDimension = source.getId().equals(target.getId()); + long sourceBefore = value(targetDimension ? target.getInTransitPackages() : source.getInTransitPackages()); + long moved = Math.min(sourceBefore, pending); + if (moved == 0) continue; + if (targetDimension) target.setInTransitPackages(sourceBefore - moved); + else source.setInTransitPackages(sourceBefore - moved); + transferred += moved; + pending -= moved; + if (targetDimension) continue; + if (commands.countPositions(customerId, source.getId()) != 0) { + throw conflict("In-transit source balance unexpectedly has physical positions"); + } + touch(source); + balances.updateById(source); + ledger(customerId, operatorId, operationId, source, null, sku(request.skuCode()), + "STOCK_IN_TRANSIT_RELEASE", "IN_TRANSIT", "STOCK_IN", sourceBefore, -moved, + sourceBefore - moved, request.businessOrderNo(), request.inboundOrderNo(), null, + request.sourceEventId(), request.idempotencyKey() + ":TRANSIT:" + source.getId(), + "In-transit inventory transferred to overseas warehouse " + target.getOverseasWarehouseId()); + if (empty(source) && commands.deleteEmptyBalance(customerId, source.getId()) != 1) { + throw conflict("Empty in-transit source balance changed concurrently"); + } + } + return transferred; + } + + @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(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 ReturnedInventoryRestoreResponse restoreReturnedInventory(String customerId, String operatorId, + ReturnedInventoryRestoreRequest request) { + if (request == null) throw badRequest("Returned inventory restore request is required"); + String exceptionNo = required(request.exceptionNo(), "Exception number is required"); + String outboundNo = required(request.outboundNo(), "Outbound number is required"); + String warehouseId = required(request.overseasWarehouseId(), "Overseas warehouse ID is required"); + long warehouseResourceId; + try { + warehouseResourceId = Long.parseLong(warehouseId); + } catch (NumberFormatException exception) { + throw badRequest("Overseas warehouse ID must be numeric"); + } + if (request.inboundOrderId() == null || request.inboundOrderId() <= 0) { + throw badRequest("Inbound order ID is required"); + } + if (request.sources() == null || request.sources().isEmpty()) { + throw badRequest("Returned inventory sources are required"); + } + if (request.receipts() == null || request.receipts().isEmpty()) { + throw badRequest("Returned inventory receipts are required"); + } + if (request.evidenceFileIds() == null || request.evidenceFileIds().isEmpty() + || request.evidenceFileIds().stream().anyMatch(value -> trim(value) == null)) { + throw badRequest("Return evidence file IDs are required"); + } + + Map sources = new LinkedHashMap<>(); + for (ReturnedInventoryRestoreRequest.SourceItem source : request.sources()) { + if (source == null || source.outboundPalletItemId() == null || source.outboundPalletItemId() <= 0 + || source.inventoryPositionId() == null || source.inventoryPositionId() <= 0 + || source.maxReturnPackages() <= 0) { + throw badRequest("A valid returned inventory source is required"); + } + String sourceSku = required(source.skuCode(), "Returned inventory source SKU is required"); + ReturnedInventoryRestoreRequest.SourceItem normalized = new ReturnedInventoryRestoreRequest.SourceItem( + source.outboundPalletItemId(), source.inventoryPositionId(), sourceSku, source.maxReturnPackages()); + if (sources.putIfAbsent(source.outboundPalletItemId(), normalized) != null) { + throw badRequest("Returned inventory source must be unique"); + } + } + Map requestedBySource = new LinkedHashMap<>(); + Map receiptTargets = new LinkedHashMap<>(); + for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) { + ReturnedInventoryRestoreRequest.SourceItem source = receipt == null ? null + : sources.get(receipt.outboundPalletItemId()); + if (source == null || !Objects.equals(source.inventoryPositionId(), receipt.inventoryPositionId())) { + throw badRequest("Return receipt must match an immutable outbound inventory source"); + } + if (receipt.receivedPackages() <= 0 || receipt.targetPalletId() == null || receipt.targetPalletId() <= 0 + || receipt.targetLocationId() == null || receipt.targetLocationId() <= 0) { + throw badRequest("A valid returned inventory receipt is required"); + } + required(receipt.targetPalletNo(), "Target pallet number is required"); + required(receipt.targetLocationCode(), "Target location code is required"); + String receiptTarget = receipt.outboundPalletItemId() + ":" + receipt.targetPalletId(); + if (receiptTargets.putIfAbsent(receiptTarget, Boolean.TRUE) != null) { + throw badRequest("Return receipt source and target pallet must be unique"); + } + requestedBySource.merge(source.outboundPalletItemId(), receipt.receivedPackages(), Long::sum); + } + for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) { + long requested = requestedBySource.getOrDefault(source.outboundPalletItemId(), 0L); + if (requested <= 0 || requested > source.maxReturnPackages()) { + throw conflict("Returned packages exceed the approved source limit"); + } + } + + String normalizedKey = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + InventoryOperation existing = operations.findIdempotency(customerId, normalizedKey); + if (existing != null) return replayReturned(existing, requestHash); + + InventoryOperation operation = new InventoryOperation(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(customerId); + operation.setIdempotencyKey(normalizedKey); + operation.setOperationType("RETURN_STOCK_IN"); + 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 replayReturned(concurrent, requestHash); + throw exception; + } + + Map lockedSources = new LinkedHashMap<>(); + Map balanceBefore = new LinkedHashMap<>(); + for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) { + Locked locked = locked(customerId, source.inventoryPositionId()); + InventoryBalance balance = locked.balance(); + if (!Objects.equals(balance.getInboundOrderId(), request.inboundOrderId())) { + throw conflict("Returned inventory source inbound order does not match"); + } + if (!warehouseId.equals(trim(balance.getOverseasWarehouseId()))) { + throw conflict("Returned inventory source warehouse does not match"); + } + long shipped = commands.shippedPackages(customerId, source.inventoryPositionId(), outboundNo, + source.skuCode()); + long restored = commands.restoredPackages(customerId, returnSourceIdentity(source), source.skuCode()); + long requested = requestedBySource.get(source.outboundPalletItemId()); + if (shipped <= 0) throw conflict("Immutable outbound shipment source was not found"); + if (restored + requested > shipped) { + throw conflict("Returned packages exceed the remaining shipped packages"); + } + lockedSources.put(source.outboundPalletItemId(), locked); + balanceBefore.putIfAbsent(balance.getId(), value(balance.getAvailablePackages())); + } + + long restoredPackages = 0; + List ledgerIds = new java.util.ArrayList<>(); + for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) { + ReturnedInventoryRestoreRequest.SourceItem source = sources.get(receipt.outboundPalletItemId()); + Locked sourceLocked = lockedSources.get(receipt.outboundPalletItemId()); + InventoryBalance balance = sourceLocked.balance(); + WarehouseResourceGateway.WarehousePalletReference pallet = warehouseGateway.resolvePallet( + receipt.targetPalletId()); + if (pallet == null || pallet.palletId() != receipt.targetPalletId() + || pallet.warehouseId() != warehouseResourceId + || pallet.locationId() != receipt.targetLocationId() + || !required(receipt.targetPalletNo(), "Target pallet number is required").equals(pallet.palletNo())) { + throw conflict("Target pallet does not match the returned inventory warehouse and location"); + } + WarehouseResourceGateway.WarehouseLocationReference location = warehouseGateway.resolveLocation( + warehouseResourceId, receipt.targetLocationId()); + if (location == null || location.locationId() != receipt.targetLocationId() + || !required(receipt.targetLocationCode(), "Target location code is required") + .equals(location.locationCode())) { + throw conflict("Target location does not match the returned inventory warehouse"); + } + + InventoryPosition target = returnedPosition(customerId, balance, exceptionNo, receipt, location); + long before = value(target.getAvailablePackages()); + target.setAvailablePackages(before + receipt.receivedPackages()); + target.setStatus("OCCUPIED"); + target.setVersion(value(target.getVersion()) + 1); + target.setUpdatedAt(LocalDateTime.now()); + positions.updateById(target); + + balance.setAvailablePackages(value(balance.getAvailablePackages()) + receipt.receivedPackages()); + touch(balance); + balances.updateById(balance); + InventoryLedger ledger = ledger(customerId, operatorId, operation.getId(), balance, target, + source.skuCode(), "RETURN_STOCK_IN", "SHIPPED", "AVAILABLE", before, + receipt.receivedPackages(), before + receipt.receivedPackages(), exceptionNo, + balance.getInboundOrderNo(), outboundNo, returnSourceIdentity(source), + normalizedKey + ":" + receipt.outboundPalletItemId() + ":" + target.getId(), + "Exception return received with evidence " + request.evidenceFileIds().size()); + ledgerIds.add(ledger.getId()); + restoredPackages += receipt.receivedPackages(); + } + long beforePackages = balanceBefore.values().stream().mapToLong(Long::longValue).sum(); + long afterPackages = balanceBefore.keySet().stream() + .mapToLong(balanceId -> value(lockedSources.values().stream() + .map(Locked::balance).filter(balance -> balance.getId().equals(balanceId)).findFirst().orElseThrow() + .getAvailablePackages())).sum(); + ReturnedInventoryRestoreResponse response = new ReturnedInventoryRestoreResponse(operation.getId(), + List.copyOf(ledgerIds), beforePackages, restoredPackages, afterPackages, false, LocalDateTime.now()); + operation.setStatus("COMPLETED"); + operation.setResultJson(write(response)); + operation.setUpdatedAt(response.completedAt()); + operations.updateById(operation); + return response; + } + + @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 frozenCustomerName = required(customerName, "Customer name snapshot is required"); + String frozenWarehouseId = required(overseasWarehouseId, "Overseas warehouse ID snapshot is required"); + String frozenWarehouseCode = required(overseasWarehouseCode, "Overseas warehouse code snapshot is required"); + String frozenWarehouseName = required(overseasWarehouseName, "Overseas warehouse name snapshot is required"); + 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, frozenWarehouseId, + destinationCode, inboundOrderId, inboundCode); + if (balance != null) { + requireCustomerSnapshot(balance, frozenCustomerName); + requireWarehouseSnapshot(balance, frozenWarehouseId, frozenWarehouseCode, frozenWarehouseName); + mergeSkuSnapshot(balance, skuCode); + return balance; + } + balance = new InventoryBalance(); + balance.setId(IdWorker.getId()); + balance.setCustomerId(customerId); + balance.setCustomerName(frozenCustomerName); + balance.setOverseasWarehouseId(frozenWarehouseId); + balance.setOverseasWarehouseCode(frozenWarehouseCode); + balance.setOverseasWarehouseName(frozenWarehouseName); + 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, frozenWarehouseId, + destinationCode, inboundOrderId, inboundCode); + if (concurrent != null) { + requireCustomerSnapshot(concurrent, frozenCustomerName); + requireWarehouseSnapshot(concurrent, frozenWarehouseId, frozenWarehouseCode, frozenWarehouseName); + 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 InventoryPosition returnedPosition(String customerId, InventoryBalance balance, String exceptionNo, + ReturnedInventoryRestoreRequest.Receipt receipt, + WarehouseResourceGateway.WarehouseLocationReference location) { + String batchNo = "RETURN:" + exceptionNo; + InventoryPosition position = positions.lockPosition(customerId, balance.getId(), receipt.targetPalletId(), batchNo); + if (position != null) { + if (!Objects.equals(position.getWarehouseLocationId(), location.locationId()) + || !Objects.equals(position.getWarehouseLocationCode(), location.locationCode()) + || !Objects.equals(position.getPalletNo(), receipt.targetPalletNo())) { + throw conflict("Returned inventory target position snapshot does not match"); + } + 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(receipt.targetPalletId()); + position.setPalletNo(receipt.targetPalletNo().trim()); + position.setBatchNo(batchNo); + position.setWarehouseLocationId(location.locationId()); + position.setWarehouseLocationCode(location.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()); + positions.insert(position); + return position; + } + + private ReturnedInventoryRestoreResponse replayReturned(InventoryOperation operation, String requestHash) { + if (!"RETURN_STOCK_IN".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(), ReturnedInventoryRestoreResponse.class).asReplay(); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read returned inventory idempotency result", exception); + } + } + + private static String returnSourceIdentity(ReturnedInventoryRestoreRequest.SourceItem source) { + return "RETURN_SOURCE:" + source.outboundPalletItemId() + ":" + source.inventoryPositionId(); + } + + private InventoryLedger 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.setCustomerName(balance.getCustomerName()); + 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); + return 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 boolean empty(InventoryBalance balance) { + return value(balance.getAvailablePackages()) == 0 && value(balance.getHoldPackages()) == 0 + && value(balance.getAllocatedPackages()) == 0 && value(balance.getPickedPackages()) == 0 + && value(balance.getInTransitPackages()) == 0 && value(balance.getReceivingPackages()) == 0 + && value(balance.getProcurementPackages()) == 0; + } + + 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 void requireCustomerSnapshot(InventoryBalance balance, String requestedCustomerName) { + String frozenCustomerName = trim(balance.getCustomerName()); + if (frozenCustomerName == null) { + throw conflict("Inventory balance customer name snapshot is missing"); + } + if (!frozenCustomerName.equals(requestedCustomerName)) { + throw conflict("Inventory balance customer name snapshot does not match"); + } + } + + private static void requireWarehouseSnapshot(InventoryBalance balance, String requestedWarehouseId, + String requestedWarehouseCode, String requestedWarehouseName) { + if (!requestedWarehouseId.equals(trim(balance.getOverseasWarehouseId())) + || !requestedWarehouseCode.equals(trim(balance.getOverseasWarehouseCode())) + || !requestedWarehouseName.equals(trim(balance.getOverseasWarehouseName()))) { + throw conflict("Inventory balance overseas warehouse snapshot does not match"); + } + } + + 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..cdcb2d5e3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java @@ -0,0 +1,55 @@ +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 String customerName; + 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..6f29a1004 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.inventory.mapper; + +import org.apache.ibatis.annotations.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; + +@Mapper +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 overseas_warehouse_id=#{overseasWarehouseId} " + + "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}") + int bindInboundPlan(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @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); + + @Update("UPDATE ll_inventory_balance SET available_packages=available_packages-#{packages},version=version+1,updated_at=NOW(3) " + + "WHERE customer_id=#{customerId} AND id=#{balanceId} AND available_packages>=#{packages}") + int deductAvailable(@Param("customerId") String customerId, @Param("balanceId") Long balanceId, + @Param("packages") int packages); +} 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..cb39de2de --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.inventory.mapper; + +import org.apache.ibatis.annotations.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; + +@Mapper +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..7f936e92f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inventory.mapper; + +import org.apache.ibatis.annotations.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; + +@Mapper +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..6c39fe4e8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.inventory.mapper; + +import org.apache.ibatis.annotations.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 org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +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 id=#{positionId} AND customer_id=#{customerId} LIMIT 1") + InventoryPosition findTenantPosition(@Param("customerId") String customerId, + @Param("positionId") Long positionId); + + @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); + + @Update("UPDATE ll_inventory_position SET available_packages=available_packages-#{packages}," + + "status=CASE WHEN available_packages-#{packages}=0 THEN 'EMPTY' ELSE status END,version=version+1,updated_at=NOW(3) " + + "WHERE customer_id=#{customerId} AND id=#{positionId} AND available_packages>=#{packages}") + int deductAvailable(@Param("customerId") String customerId, @Param("positionId") Long positionId, + @Param("packages") int packages); +} 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/controller/InboundCodeRelationshipController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InboundCodeRelationshipController.java new file mode 100644 index 000000000..c983a74a6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InboundCodeRelationshipController.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.inventory.query.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inventory.query.dto.InboundCodeRelationshipResponse; +import com.baiyue.lastleg.inventory.query.service.InboundCodeRelationshipService; +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 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.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@Tag(name = "Inventory queries") +public class InboundCodeRelationshipController { + static final String PATH = "/api/platform/v1/last-leg/inventory/inbound-code-relationships/{inboundCode}"; + + @Resource private InboundCodeRelationshipService service; + + @GetMapping(PATH) + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") + @Operation(summary = "Resolve overseas relationships by inbound code", + description = "Platform-only Lastleg relationship read model. Required authority: " + + OverseasPermissions.INVENTORY_LIST) + public ApiResponse find( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String inboundCode) { + requirePlatform(user); + return ApiResponse.success(service.find(inboundCode)); + } + + private static 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/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..eec531970 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -0,0 +1,129 @@ +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 = "Zero-balance dimensions are excluded. 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 = "Returns only non-zero dimensions with the frozen receiving overseas warehouse ID/code/name. 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}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") + @Operation(summary = "Get one inventory balance", description = "Returns one non-zero inventory balance by ID and optional customer scope. Required authority: " + OverseasPermissions.INVENTORY_LIST) + public ApiResponse balance( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long balanceId, + @RequestParam(required = false) String customerId) { + return ApiResponse.success(service.balance(platformScope(user, customerId), balanceId)); + } + + @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 = "Uses the same non-zero balance filter and frozen receiving warehouse snapshots as the page API. Required authority: " + OverseasPermissions.INVENTORY_EXPORT) + public ResponseEntity exportBalances( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.BalanceQuery query) { + String scope = platformScope(user, customerId); + return csv(service.exportBalances(scope, 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) { + String scope = platformScope(user, customerId); + return csv(service.exportLedgers(scope, query), "inventory-ledger.csv"); + } + + private static ResponseEntity csv(byte[] content, String filename) { + return ResponseEntity.ok() + .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename) + .body(content); + } + + 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/InboundCodeRelationshipResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InboundCodeRelationshipResponse.java new file mode 100644 index 000000000..65b4e028c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InboundCodeRelationshipResponse.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.util.List; + +public record InboundCodeRelationshipResponse( + String inboundCode, + boolean found, + Customer customer, + OverseasWarehouse overseasWarehouse, + String overseasReceivingStatus, + List taillegOrders, + List inboundPlans, + List palletCodes, + List inventory +) { + public record Customer(String customerId, String customerName) { } + public record OverseasWarehouse(String warehouseId, String warehouseCode, String warehouseName) { } + public record TaillegOrder(String orderNo, String orderStatus) { } + public record InboundPlan(String planNo, String planStatus, String receivingStatus) { } + public record InventorySku(String skuCode, String productNameZh, String productNameEn, long packageCount) { } +} 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..f4f60e6d7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.time.LocalDateTime; + +public record InventoryLedgerView( + Long id, + String customerId, + String customerName, + 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/InboundCodeRelationshipMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InboundCodeRelationshipMapper.java new file mode 100644 index 000000000..a7788ba18 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InboundCodeRelationshipMapper.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.inventory.query.mapper; + +import com.baiyue.lastleg.inventory.query.model.InboundCodeRelationshipRows; +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 InboundCodeRelationshipMapper { + @Select("SELECT o.customer_id AS customerId,o.customer_name AS customerName,o.order_no AS orderNo," + + "o.current_status AS orderStatus,p.plan_no AS planNo,p.status AS planStatus," + + "COALESCE(p.status,o.current_status) AS receivingStatus," + + "CAST(p.overseas_warehouse_id AS CHAR) AS overseasWarehouseId " + + "FROM ll_order o " + + "LEFT JOIN ll_inbound_plan_line l ON l.customer_id=o.customer_id AND l.order_id=o.id AND l.inbound_code=o.inbound_code " + + "LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.id=l.plan_id " + + "WHERE o.inbound_code=#{inboundCode} ORDER BY o.customer_id,o.id,p.id") + List bases(@Param("inboundCode") String inboundCode); + + @Select("SELECT DISTINCT r.pallet_no FROM ll_inbound_stock_receipt r " + + "JOIN ll_order o ON o.customer_id=r.customer_id AND o.id=r.order_id " + + "WHERE o.inbound_code=#{inboundCode} AND o.customer_id=#{customerId} " + + "AND r.pallet_no IS NOT NULL AND TRIM(r.pallet_no)<>'' ORDER BY r.pallet_no") + List palletCodes(@Param("customerId") String customerId, + @Param("inboundCode") String inboundCode); + + @Select("SELECT r.sku_code AS skuCode,MAX(r.product_name_zh) AS productNameZh," + + "MAX(r.product_name_en) AS productNameEn,SUM(r.received_packages) AS packageCount " + + "FROM ll_inbound_stock_receipt r " + + "JOIN ll_order o ON o.customer_id=r.customer_id AND o.id=r.order_id " + + "WHERE o.inbound_code=#{inboundCode} AND o.customer_id=#{customerId} " + + "GROUP BY r.sku_code ORDER BY r.sku_code") + List inventory( + @Param("customerId") String customerId, + @Param("inboundCode") String inboundCode); +} 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..6386caf43 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -0,0 +1,149 @@ +package com.baiyue.lastleg.inventory.query.mapper; + +import org.apache.ibatis.annotations.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; + +@Mapper +public interface InventoryQueryMapper { + String BALANCE_FILTER = " WHERE (available_packages+hold_packages+allocated_packages+picked_packages+" + + "in_transit_packages+receiving_packages+procurement_packages)>0" + + " 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 (b.available_packages+b.hold_packages+b.allocated_packages+" + + "b.picked_packages+b.in_transit_packages+b.receiving_packages+b.procurement_packages)>0" + + " 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); + + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} " + + "AND (available_packages+hold_packages+allocated_packages+picked_packages+in_transit_packages+" + + "receiving_packages+procurement_packages)>0 LIMIT 1") + InventoryBalance ownedVisibleBalance(@Param("customerId") String customerId, + @Param("balanceId") long balanceId); + + @Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} " + + "AND (available_packages+hold_packages+allocated_packages+picked_packages+in_transit_packages+" + + "receiving_packages+procurement_packages)>0 LIMIT 1") + InventoryBalance visibleBalance(@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/InboundCodeRelationshipRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InboundCodeRelationshipRows.java new file mode 100644 index 000000000..49cf48766 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InboundCodeRelationshipRows.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inventory.query.model; + +import lombok.Data; + +public final class InboundCodeRelationshipRows { + private InboundCodeRelationshipRows() { } + + @Data + public static class Base { + private String customerId; + private String customerName; + private String orderNo; + private String orderStatus; + private String planNo; + private String planStatus; + private String receivingStatus; + private String overseasWarehouseId; + } + + @Data + public static class InventorySku { + private String skuCode; + private String productNameZh; + private String productNameEn; + private Long packageCount; + } +} 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/InboundCodeRelationshipService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InboundCodeRelationshipService.java new file mode 100644 index 000000000..612837483 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InboundCodeRelationshipService.java @@ -0,0 +1,95 @@ +package com.baiyue.lastleg.inventory.query.service; + +import com.baiyue.lastleg.inventory.query.dto.InboundCodeRelationshipResponse; +import com.baiyue.lastleg.inventory.query.mapper.InboundCodeRelationshipMapper; +import com.baiyue.lastleg.inventory.query.model.InboundCodeRelationshipRows; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Objects; + +@Service +public class InboundCodeRelationshipService { + @Resource private InboundCodeRelationshipMapper mapper; + @Resource private WarehouseResourceGateway warehouse; + + public InboundCodeRelationshipResponse find(String inboundCode) { + String normalized = required(inboundCode); + List bases = mapper.bases(normalized); + if (bases.isEmpty()) return empty(normalized); + List customerIds = bases.stream().map(InboundCodeRelationshipRows.Base::getCustomerId) + .filter(Objects::nonNull).distinct().toList(); + if (customerIds.size() != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Inbound code resolves multiple customer scopes"); + } + String customerId = customerIds.get(0); + String customerName = unique(bases.stream().map(InboundCodeRelationshipRows.Base::getCustomerName).toList(), + "customer name"); + String warehouseId = unique(bases.stream().map(InboundCodeRelationshipRows.Base::getOverseasWarehouseId).toList(), + "overseas warehouse ID"); + List orders = bases.stream() + .filter(value -> trim(value.getOrderNo()) != null) + .map(value -> new InboundCodeRelationshipResponse.TaillegOrder( + value.getOrderNo().trim(), trim(value.getOrderStatus()))) + .distinct().sorted(java.util.Comparator.comparing(InboundCodeRelationshipResponse.TaillegOrder::orderNo)) + .toList(); + List plans = bases.stream() + .filter(value -> trim(value.getPlanNo()) != null) + .map(value -> new InboundCodeRelationshipResponse.InboundPlan( + value.getPlanNo().trim(), trim(value.getPlanStatus()), trim(value.getReceivingStatus()))) + .distinct().sorted(java.util.Comparator.comparing(InboundCodeRelationshipResponse.InboundPlan::planNo)).toList(); + List inventory = mapper.inventory(customerId, normalized).stream() + .map(value -> new InboundCodeRelationshipResponse.InventorySku(value.getSkuCode(), + value.getProductNameZh(), value.getProductNameEn(), number(value.getPackageCount()))).toList(); + String status = unique(bases.stream().map(InboundCodeRelationshipRows.Base::getReceivingStatus).toList(), + "overseas receiving status"); + InboundCodeRelationshipResponse.OverseasWarehouse warehouseView = resolveWarehouse(warehouseId); + return new InboundCodeRelationshipResponse(normalized, true, + new InboundCodeRelationshipResponse.Customer(customerId, customerName), warehouseView, status, + orders, plans, mapper.palletCodes(customerId, normalized), inventory); + } + + private InboundCodeRelationshipResponse.OverseasWarehouse resolveWarehouse(String warehouseId) { + if (warehouseId == null) return null; + final long id; + try { + id = Long.parseLong(warehouseId); + } catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Inbound code has invalid overseas warehouse ID snapshot"); + } + WarehouseResourceGateway.WarehouseReference value = warehouse.requireActiveSharedWarehouse(id); + return new InboundCodeRelationshipResponse.OverseasWarehouse( + String.valueOf(value.warehouseId()), value.warehouseCode(), value.warehouseName()); + } + + private static InboundCodeRelationshipResponse empty(String inboundCode) { + return new InboundCodeRelationshipResponse(inboundCode, false, null, null, null, + List.of(), List.of(), List.of(), List.of()); + } + + private static String unique(List values, String field) { + List normalized = values.stream().map(InboundCodeRelationshipService::trim) + .filter(Objects::nonNull).distinct().toList(); + if (normalized.size() > 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Inbound code has inconsistent " + field + " snapshots"); + } + return normalized.isEmpty() ? null : normalized.get(0); + } + + private static String required(String value) { + String normalized = trim(value); + if (normalized == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "inboundCode is required"); + if (normalized.length() > 128) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "inboundCode is too long"); + return normalized; + } + + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static long number(Long value) { return value == null ? 0L : value; } +} 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..06eb0c14f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java @@ -0,0 +1,229 @@ +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.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; +import java.util.Set; + +@Service +public class InventoryQueryService { + 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 InventoryBalanceView balance(String customerId, long balanceId) { + InventoryBalance balance = customerId == null + ? mapper.visibleBalance(balanceId) + : mapper.ownedVisibleBalance(customerId, balanceId); + if (balance == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found"); + } + return balanceView(balance); + } + + 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) { + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + writeBalancesCsv(customerId, query, output); + return output.toByteArray(); + } catch (IOException e) { throw new IllegalStateException("Unable to export inventory balances", e); } + } + + public void writeBalancesCsv(String customerId, InventoryQueryRequests.BalanceQuery query, OutputStream output) + throws IOException { + validateStatus(query.inventoryStatus(), BALANCE_STATUSES); + output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," + + "destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages," + + "holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8)); + final int batchSize = 1000; + long offset = 0; + while (true) { + List batch = mapper.balances(customerId, query, offset, batchSize).stream().map(this::balanceView).toList(); + if (batch.isEmpty()) break; + for (InventoryBalanceView v : batch) output.write(( + 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())) + "\r\n").getBytes(StandardCharsets.UTF_8)); + output.flush(); + if (batch.size() < batchSize) break; + offset += batchSize; + } + } + + public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) { + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + writeLedgersCsv(customerId, query, output); + return output.toByteArray(); + } catch (IOException e) { throw new IllegalStateException("Unable to export inventory ledger", e); } + } + + public void writeLedgersCsv(String customerId, InventoryQueryRequests.LedgerQuery query, OutputStream output) + throws IOException { + validateDateRange(query); + output.write(("\uFEFFoccurredAt,customerId,customerName,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," + + "inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," + + "beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8)); + final int batchSize = 1000; + long offset = 0; + while (true) { + List batch = mapper.ledgers(customerId, query, offset, batchSize).stream().map(this::ledgerView).toList(); + if (batch.isEmpty()) break; + for (InventoryLedgerView v : batch) output.write(( + String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.customerName()), 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())) + "\r\n").getBytes(StandardCharsets.UTF_8)); + output.flush(); + if (batch.size() < batchSize) break; + offset += batchSize; + } + } + + 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.getCustomerName(), 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/controller/MerchantLocalLooseboardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/MerchantLocalLooseboardController.java new file mode 100644 index 000000000..97c997a88 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/MerchantLocalLooseboardController.java @@ -0,0 +1,67 @@ +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.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; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/local-looseboard-orders") +@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD + "')") +public class MerchantLocalLooseboardController { + @Resource private LocalLooseboardService service; + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody LocalLooseboardRequests.Create request) { + return ApiResponse.success(service.create(customer(user), operator(user), request)); + } + + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, @Valid LocalLooseboardRequests.MerchantQuery 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)); + } + + @PutMapping("/{orderNo}") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String orderNo, @Valid @RequestBody LocalLooseboardRequests.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/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..beef08211 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java @@ -0,0 +1,83 @@ +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 MerchantQuery( + @Min(1) long page, + @Min(1) @Max(200) long size, + 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..19e63fcac --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -0,0 +1,400 @@ +package com.baiyue.lastleg.looseboard.service; + +import com.baiyue.common.core.container.ContainerSpecificationCatalog; + +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(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), 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()); + } + + /** Customer-scoped query that ignores any client-supplied customer filter. */ + public PageResult page(String customerId, + LocalLooseboardRequests.MerchantQuery query) { + return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId, + query.orderNo(), query.routeKeyword(), query.status(), query.transportMode(), + query.requestedFrom(), query.requestedTo())); + } + + 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(StringUtils.hasText(containerType) + ? ContainerSpecificationCatalog.require(containerType).containerType() + : ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE); + 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("LOOSEBOARD", source.getDestination(), order.getId())); + order.setCustomerId(source.getCustomerId()); + order.setOrderType("LOCAL_LOOSEBOARD"); + order.setInboundCode(null); + 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/ContainerArrivalTimeOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/ContainerArrivalTimeOperationMapper.java new file mode 100644 index 000000000..7dc028481 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/ContainerArrivalTimeOperationMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ContainerArrivalTimeOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_container_arrival_time_operation WHERE idempotency_key=#{key} LIMIT 1") + ContainerArrivalTimeOperation findByKey(@Param("key") String key); +} 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/LastlegOrderCommandRecordMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderCommandRecordMapper.java new file mode 100644 index 000000000..45929d17a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderCommandRecordMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface LastlegOrderCommandRecordMapper extends BaseMapper { + @Select("SELECT * FROM ll_order_command_record WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND action=#{action} AND operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1") + LastlegOrderCommandRecord find(@Param("customerId") String customerId, @Param("orderId") long orderId, + @Param("action") String action, @Param("operatorId") String operatorId, + @Param("key") String key); +} 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/LastlegOrderHoldHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderHoldHistoryMapper.java new file mode 100644 index 000000000..16f0dc36f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderHoldHistoryMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderHoldHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LastlegOrderHoldHistoryMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeHistoryMapper.java new file mode 100644 index 000000000..3ec9721b9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeHistoryMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LastlegOrderManualNodeHistoryMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeMapper.java new file mode 100644 index 000000000..66910511f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderManualNode; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface LastlegOrderManualNodeMapper extends BaseMapper { + @Delete("DELETE FROM ll_order_manual_node WHERE customer_id=#{customerId} AND order_id=#{orderId}") + int deleteCurrent(@Param("customerId") String customerId, @Param("orderId") long orderId); +} 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..0e9bcf6c7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -0,0 +1,234 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; +import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; +import com.baiyue.lastleg.dto.ContainerArrivalTimeView; +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 LastlegOrderMapper extends BaseMapper { + String WAREHOUSE_FILTER_SQL = "(LOWER(TRIM(overseas_warehouse_code)) LIKE CONCAT('%',LOWER({0}),'%') " + + "OR LOWER(TRIM(overseas_warehouse_name)) LIKE CONCAT('%',LOWER({0}),'%'))"; + + @Select("") + List findDispatchStatuses(@Param("orderIds") List orderIds); + + record DispatchStatusRow(Long orderId, String dispatchStatus) { } + + String CONTAINER_PROGRESS_FILTER = " FROM ll_order o LEFT JOIN ll_inbound_plan p " + + "ON p.customer_id=o.customer_id AND p.source_order_id=o.id " + + "WHERE o.source_container_booking_id IS NOT NULL AND o.source_container_booking_id<>'' " + + "AND o.container_no LIKE CONCAT('%',#{q.containerNo},'%') " + + "AND o.created_at >=#{q.createdFrom} " + + "AND o.created_at <=#{q.createdTo} " + + "GROUP BY o.source_container_booking_id "; + + String CONTAINER_PROGRESS_COLUMNS = "SELECT o.source_container_booking_id AS sourceContainerBookingId," + + "CASE WHEN COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " + + "THEN MAX(NULLIF(o.container_no,'')) ELSE NULL END AS containerNo," + + "CASE WHEN COUNT(DISTINCT NULLIF(o.overseas_warehouse_id,''))=1 THEN MAX(o.overseas_warehouse_id) ELSE NULL END AS overseasWarehouseId," + + "CASE WHEN COUNT(DISTINCT NULLIF(o.overseas_warehouse_code,''))=1 THEN MAX(o.overseas_warehouse_code) ELSE NULL END AS overseasWarehouseCode," + + "CASE WHEN COUNT(DISTINCT NULLIF(o.overseas_warehouse_name,''))=1 THEN MAX(o.overseas_warehouse_name) ELSE NULL END AS overseasWarehouseName," + + "CASE WHEN COUNT(DISTINCT o.receiving_warehouse_version)=1 THEN MAX(o.receiving_warehouse_version) ELSE -1 END AS assignmentVersion," + + "CASE WHEN COUNT(DISTINCT o.receiving_warehouse_version)=1 AND NOT EXISTS (SELECT 1 FROM ll_receiving_container_session s WHERE s.source_container_booking_id=o.source_container_booking_id) THEN TRUE ELSE FALSE END AS canChangeReceivingWarehouse," + + "CASE WHEN COUNT(DISTINCT o.receiving_warehouse_version)<>1 THEN '柜内接柜仓分配版本不一致' " + + "WHEN EXISTS (SELECT 1 FROM ll_receiving_container_session s WHERE s.source_container_booking_id=o.source_container_booking_id) THEN '海外PDA已建立接柜会话,接柜仓已永久锁定' ELSE NULL END AS receivingWarehouseLockReason," + + "COUNT(*) AS totalOrderCount,COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),'')) AS inboundCodeCount," + + "SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END) AS missingInboundCodeCount," + + "COUNT(DISTINCT p.id) AS planCount," + + "COUNT(DISTINCT CASE WHEN p.id IS NOT NULL AND NULLIF(TRIM(p.plan_no),'') IS NOT NULL " + + "AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN NULLIF(TRIM(o.inbound_code),'') END) AS plannedInboundCodeCount," + + "SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL OR NULLIF(TRIM(p.status),'') IS NULL " + + "THEN 1 ELSE 0 END) AS pendingPlanCount," + + "SUM(CASE WHEN p.id IS NOT NULL AND p.status IN ('PENDING_ASSIGNMENT','SIGNED') " + + "AND COALESCE(p.pallet_count,0)>0 AND " + + "(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + + "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL)<COALESCE(p.pallet_count,0) " + + "THEN 1 ELSE 0 END) AS resourcePendingPlanCount," + + "CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) " + + "AND COUNT(DISTINCT o.estimated_warehouse_at)=1 THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS plannedArrivalAt," + + "CASE WHEN SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END)=0 " + + "AND SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL OR NULLIF(TRIM(p.status),'') IS NULL " + + "THEN 1 ELSE 0 END)=0 AND COUNT(DISTINCT p.id)=COUNT(*) " + + "AND COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " + + "THEN TRUE ELSE FALSE END AS allPlansReady," + + "CASE WHEN COUNT(NULLIF(o.container_no,''))<>COUNT(*) " + + "OR COUNT(DISTINCT NULLIF(o.container_no,''))<>1 THEN '柜号缺失或不一致' " + + "WHEN SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END)>0 " + + "THEN CONCAT('存在',SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END),'票缺少入仓码') " + + "WHEN SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL OR NULLIF(TRIM(p.status),'') IS NULL " + + "THEN 1 ELSE 0 END)>0 THEN CONCAT('存在',SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL " + + "OR NULLIF(TRIM(p.status),'') IS NULL THEN 1 ELSE 0 END),'票尚未生成有效入库计划') " + + "WHEN COUNT(DISTINCT p.id)<>COUNT(*) THEN '入库计划与尾程订单关联不一致' ELSE NULL END AS blockingReason "; + + @Select("") + List pageContainerProgress( + @Param("q") LastlegOrderRequests.ContainerProgressQuery query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long countContainerProgress(@Param("q") LastlegOrderRequests.ContainerProgressQuery query); + + @Select("") + ContainerPlanProgressSummary findContainerProgress( + @Param("sourceContainerBookingId") String sourceContainerBookingId); + + @Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.order_no AS orderNo,o.inbound_code AS inboundCode," + + "o.current_status AS orderStatus,p.id AS planId,p.plan_no AS planNo,p.status AS planStatus," + + "CASE WHEN p.id IS NOT NULL AND NULLIF(TRIM(p.plan_no),'') IS NOT NULL " + + "AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN TRUE ELSE FALSE END AS planValid," + + "o.estimated_warehouse_at AS plannedArrivalAt FROM ll_order o LEFT JOIN ll_inbound_plan p " + + "ON p.customer_id=o.customer_id AND p.source_order_id=o.id " + + "WHERE o.source_container_booking_id=#{sourceContainerBookingId} " + + "ORDER BY CASE WHEN o.estimated_warehouse_at IS NULL THEN 1 ELSE 0 END," + + "o.estimated_warehouse_at ASC,o.customer_id ASC,o.id ASC") + List findContainerProgressItems( + @Param("sourceContainerBookingId") String sourceContainerBookingId); + + record PalletMergeRelationRow(Long orderId, String relatedInboundNo, String mergeGroupNo) {} + + @Select("") + List findPalletMergeRelations(@Param("orderIds") List orderIds); + + @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 container_no FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} " + + "ORDER BY customer_id,id FOR UPDATE") + List lockContainerNumbers( + @Param("sourceContainerBookingId") String sourceContainerBookingId); + + @Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE") + List lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId); + + @Select("") + List findContainerArrivalTimes(@Param("bookingIds") List bookingIds); + + @Update("UPDATE ll_order SET estimated_warehouse_at=#{estimatedAt},arrival_time_version=arrival_time_version+1," + + "version=version+1,updated_at=NOW(3) WHERE source_container_booking_id=#{bookingId} " + + "AND arrival_time_version=#{expectedVersion}") + int updateContainerExpectedWarehouseArrival(@Param("bookingId") String bookingId, + @Param("estimatedAt") java.time.LocalDateTime estimatedAt, + @Param("expectedVersion") int expectedVersion); + + @Select("SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order o " + + "ON o.id=p.source_order_id AND o.customer_id=p.customer_id " + + "WHERE o.source_container_booking_id=#{bookingId}") + long countContainerPlans(@Param("bookingId") String bookingId); + + @Select("SELECT COUNT(*) FROM ll_receiving_container_session WHERE source_container_booking_id=#{sourceContainerBookingId}") + long countReceivingSessions(@Param("sourceContainerBookingId") String sourceContainerBookingId); + + @Update("UPDATE ll_order SET overseas_warehouse_id=#{warehouseId},overseas_warehouse_code=#{warehouseCode}," + + "overseas_warehouse_name=#{warehouseName},receiving_warehouse_version=receiving_warehouse_version+1,updated_at=NOW(3) " + + "WHERE source_container_booking_id=#{sourceContainerBookingId} AND receiving_warehouse_version=#{expectedVersion}") + int updateContainerReceivingWarehouse(@Param("sourceContainerBookingId") String sourceContainerBookingId, + @Param("warehouseId") String warehouseId, @Param("warehouseCode") String warehouseCode, + @Param("warehouseName") String warehouseName, @Param("expectedVersion") int expectedVersion); + + @Update("UPDATE ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id " + + "SET p.overseas_warehouse_id=#{warehouseId},p.version=p.version+1,p.updated_at=NOW(3) " + + "WHERE o.source_container_booking_id=#{sourceContainerBookingId}") + int updateContainerPlanReceivingWarehouse(@Param("sourceContainerBookingId") String sourceContainerBookingId, + @Param("warehouseId") long warehouseId); + + @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 detained=#{held},hold_chargeable=#{chargeable}," + + "hold_reason=CASE WHEN #{held}=1 THEN #{reason} ELSE NULL END,version=version+1,updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND id=#{id} AND version=#{expectedVersion}") + int updateHold(@Param("customerId") String customerId, @Param("id") long id, + @Param("held") boolean held, @Param("chargeable") boolean chargeable, + @Param("reason") String reason, @Param("expectedVersion") int expectedVersion, + @Param("updatedAt") java.time.LocalDateTime updatedAt); + + @Update("UPDATE ll_order SET manual_node_override=1,estimated_arrival_at=#{estimatedArrivalAt}," + + "actual_arrival_at=#{actualArrivalAt},estimated_warehouse_at=#{estimatedWarehouseAt}," + + "version=version+1,updated_at=#{updatedAt} WHERE customer_id=#{customerId} AND id=#{id} " + + "AND version=#{expectedVersion}") + int updateManualNodes(@Param("customerId") String customerId, @Param("id") long id, + @Param("estimatedArrivalAt") java.time.LocalDateTime estimatedArrivalAt, + @Param("actualArrivalAt") java.time.LocalDateTime actualArrivalAt, + @Param("estimatedWarehouseAt") java.time.LocalDateTime estimatedWarehouseAt, + @Param("expectedVersion") int expectedVersion, + @Param("updatedAt") java.time.LocalDateTime updatedAt); + + @Update("UPDATE ll_order SET picked_package_count=picked_package_count+#{packages},updated_at=NOW() " + + "WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 " + + "AND package_count-shipped_package_count-picked_package_count >= #{packages}") + 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 detained=0 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 pallet_merge_flag=#{flag}," + + "pallet_merge_batch_id=CASE WHEN #{flag} IS NULL THEN NULL ELSE pallet_merge_batch_id END," + + "updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND id=#{id}") + int updatePalletMergeFlag(@Param("customerId") String customerId, @Param("id") long id, + @Param("flag") String flag, + @Param("updatedAt") java.time.LocalDateTime updatedAt); + + @Update("UPDATE ll_order SET pallet_merge_flag=#{flag},pallet_merge_batch_id=#{batchId},updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND id=#{id}") + int updatePalletMergeBatch(@Param("customerId") String customerId, @Param("id") long id, + @Param("flag") String flag, @Param("batchId") String batchId, + @Param("updatedAt") java.time.LocalDateTime updatedAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java new file mode 100644 index 000000000..7f18718cd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.mapper; + +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 java.time.LocalDate; + +@Mapper +public interface LastlegOrderNoDailySequenceMapper { + @Insert("INSERT INTO ll_order_no_daily_sequence(business_date,last_sequence) " + + "VALUES(#{businessDate},LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)") + int nextValue(@Param("businessDate") LocalDate businessDate); + + @Select("SELECT LAST_INSERT_ID()") + long currentValue(); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderOperationLogMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderOperationLogMapper.java new file mode 100644 index 000000000..c5b9fb76d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderOperationLogMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderOperationLog; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LastlegOrderOperationLogMapper extends BaseMapper { +} 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/LastlegSourcePalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegSourcePalletMapper.java new file mode 100644 index 000000000..7304ff5bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegSourcePalletMapper.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.mapper; + +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 java.util.List; + +@Mapper +public interface LastlegSourcePalletMapper { + @Insert(""" + INSERT INTO ll_order_source_pallet + (id,customer_id,order_id,source_pallet_no,sequence_no,source_event_id,created_at) + VALUES (#{id},#{customerId},#{orderId},#{palletNo},#{sequenceNo},#{eventId},CURRENT_TIMESTAMP(3)) + """) + int insert(@Param("id") long id, @Param("customerId") String customerId, + @Param("orderId") long orderId, @Param("palletNo") String palletNo, + @Param("sequenceNo") int sequenceNo, @Param("eventId") String eventId); + + @Select(""" + SELECT source_pallet_no FROM ll_order_source_pallet + WHERE customer_id=#{customerId} AND order_id=#{orderId} + ORDER BY sequence_no,id + """) + List palletNos(@Param("customerId") String customerId, @Param("orderId") long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/ReceivingWarehouseOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/ReceivingWarehouseOperationMapper.java new file mode 100644 index 000000000..b6d81a670 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/ReceivingWarehouseOperationMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.ReceivingWarehouseOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ReceivingWarehouseOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_receiving_warehouse_operation WHERE idempotency_key=#{key} LIMIT 1") + ReceivingWarehouseOperation findByKey(@Param("key") String key); +} 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/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..fce09ab63 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java @@ -0,0 +1,38 @@ +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("一件代发"), + /** Cargo discard order created from an approved cargo discard application. */ + CARGO_DISCARD("货物废弃订单"); + + 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..069b72eb2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java @@ -0,0 +1,72 @@ +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_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +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("/relabel-pallet-label") + public ApiResponse createRelabelPallet( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.NewRelabelPallet request) { + return ApiResponse.success(service.createRelabelPallet(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("/relabel-disassemble") + public ApiResponse relabelDisassemble( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.RelabelDisassemble request) { + return ApiResponse.success(service.relabelDisassemble(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..2a2922edd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java @@ -0,0 +1,52 @@ +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 NewRelabelPallet(@NotBlank @Size(max = 64) String relabelNo, + @NotBlank @Size(max = 128) String idempotencyKey) { } + 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 RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey, + @NotBlank @Size(max = 64) String relabelNo, + @Valid PalletExpectation source, + @Valid PalletExpectation target, + @NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks, + @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..462c13fab --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java @@ -0,0 +1,25 @@ +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 PrintablePallet(long palletId, String palletCode, int palletVersion, + String relabelNo, String printableHtml) { } + 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..3ff6e36f3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java @@ -0,0 +1,123 @@ +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.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.server.ResponseStatusException; + +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 ensureRelabelPallet(long warehouseId, String businessId) { + Map body = Map.of("warehouseId", warehouseId, "businessId", businessId); + ResolveEnvelope response = client.post().uri("/internal/v1/warehouse-pallets/ensure-relabel") + .header("X-Internal-Service-Token", internalToken).body(body).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw palletLookupFailure(result.getStatusCode()); + }).body(ResolveEnvelope.class); + if (response == null || response.data() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse pallet service returned no usable response"); + } + return response.data(); + } + + @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 palletLookupFailure(result.getStatusCode()); + }).body(ResolveEnvelope.class); + if (response != null && "A0404".equals(response.code())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found"); + } + if (response == null || response.data() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse pallet service returned no usable response"); + } + return response.data(); + } + + @Override + public PalletReference resolve(long palletId) { + ResolveEnvelope response = client.get().uri("/internal/v1/warehouse-pallets/resolve/id/{id}", palletId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw palletLookupFailure(result.getStatusCode()); }) + .body(ResolveEnvelope.class); + if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse pallet service returned no usable response"); + return response.data(); + } + + private ResponseStatusException palletLookupFailure(HttpStatusCode status) { + return new ResponseStatusException(status.value() == 404 ? HttpStatus.NOT_FOUND + : (status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE), + status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed"); + } + + @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); + if (targetLocationId != null) 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(String code, String message, 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..9ef546a51 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.palletops.gateway; + +public interface PalletPhysicalGateway { + PalletReference ensureRelabelPallet(long warehouseId, String businessId); + PalletReference resolve(String palletCode); + PalletReference resolve(long palletId); + 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, java.math.BigDecimal unitPrice, int version) { + public PalletReference(long palletId, String palletCode, long warehouseId, Long locationId, + String status, String fbaWarehouseCode, int version) { + this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode, + java.math.BigDecimal.ZERO, 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..d399282b4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -0,0 +1,663 @@ +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.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.service.RelabelPalletMutationService; +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.util.HtmlUtils; +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; + @Resource private RelabelApplicationMapper relabelApplications; + @Resource private RelabelItemMapper relabelItems; + @Resource private RelabelPalletMutationService relabelPalletMutations; + 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.PrintablePallet createRelabelPallet(PdaWarehouseIdentity identity, + PalletOperationRequests.NewRelabelPallet request) { + String relabelNo = required(request.relabelNo(), "换标单号不能为空"); + RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo); + if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) { + throw conflict("换标单尚未完成客户确认,不能生成新托盘"); + } + if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { + throw conflict("换标单不属于 Token 冻结的海外仓"); + } + String key = required(request.idempotencyKey(), "幂等键不能为空"); + PalletPhysicalGateway.PalletReference pallet = physical.ensureRelabelPallet(identity.overseasWarehouseId(), + relabelNo + ":" + key); + String code = HtmlUtils.htmlEscape(pallet.palletCode()); + String order = HtmlUtils.htmlEscape(relabelNo); + String html = "" + code + + "

托盘标签

" + + code + "
换标单:" + order + "
"; + return new PalletOperationResponses.PrintablePallet(pallet.palletId(), pallet.palletCode(), pallet.version(), + relabelNo, html); + } + + 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 relabelDisassemble(PdaWarehouseIdentity identity, + PalletOperationRequests.RelabelDisassemble request) { + PalletOperationResponses.Operation replay = replayIfPresent(identity, "RELABEL_PALLET_DISASSEMBLE", + request.idempotencyKey(), request); + if (replay != null) return replay; + String relabelNo = required(request.relabelNo(), "换标单号不能为空"); + RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo); + if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) { + throw conflict("换标单尚未完成客户确认,不能执行拆托"); + } + if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { + throw conflict("换标单不属于 Token 冻结的海外仓"); + } + 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 (source.palletId() == target.palletId()) throw conflict("原托盘和目标托盘不能相同"); + List marks = request.cartonMarks().stream().map(String::trim).filter(value -> !value.isEmpty()).toList(); + if (marks.size() != request.cartonMarks().size() || new HashSet<>(marks).size() != marks.size()) { + throw badRequest("箱唛不能为空且不能重复"); + } + List selected = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("application_id", application.getId()).eq("customer_id", application.getCustomerId()) + .in("carton_mark", marks)); + if (selected.size() != marks.size() + || selected.stream().map(RelabelItem::getCartonMark).distinct().count() != marks.size()) { + throw conflict("提交的箱唛不属于当前换标单"); + } + Map packagesByPosition = new LinkedHashMap<>(); + Map> itemsByPosition = new LinkedHashMap<>(); + for (RelabelItem item : selected) { + if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null + || item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照"); + InventoryPosition position = positions.selectById(item.getSourceInventoryPositionId()); + if (position == null || !Objects.equals(position.getCustomerId(), application.getCustomerId()) + || !Objects.equals(position.getPalletId(), source.palletId()) + || !Objects.equals(position.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { + throw conflict("扫描的原托盘与换标箱唛实际库存位置不一致"); + } + packagesByPosition.merge(position.getId(), item.getNotifiedPackageCount().longValue(), Long::sum); + itemsByPosition.computeIfAbsent(position.getId(), ignored -> new ArrayList<>()).add(item); + } + List moves = packagesByPosition.entrySet().stream().map(entry -> { + InventoryPosition position = positions.selectById(entry.getKey()); + if (value(position.getHoldPackages()) < entry.getValue()) throw conflict("换标冻结库存箱数不足"); + return new PlannedMove(entry.getKey(), entry.getValue(), value(position.getVersion()), source.palletId(), + source.palletCode(), target.palletId(), target.palletCode(), target.locationId(), + locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.HOLD); + }).toList(); + requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target)); + Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false); + Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(), + new RelabelMutation(application, itemsByPosition)); + String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank() + ? "" : ":" + request.reason().trim()); + return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request, + reason, relabelPlan); + } + + 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()), InventoryBucket.AVAILABLE); + }).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()), InventoryBucket.AVAILABLE); + }).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) { + if (plan.relabelMutation() != null) lockRelabelMutation(plan.relabelMutation(), identity.overseasWarehouseId()); + Set seen = new HashSet<>(); + LinkedHashSet customers = new LinkedHashSet<>(); + Map relabelTargets = new LinkedHashMap<>(); + 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("库存版本已变化,请重新扫码"); + if (move.bucket() == InventoryBucket.HOLD) requireHeld(locked, move.packages()); + else requireMovable(locked, move.packages()); + customers.add(locked.getCustomerId()); + InventoryPosition target = moveInventory(identity, operation, locked, move, key, reason); + if (plan.relabelMutation() != null) relabelTargets.put(move.positionId(), target); + } + if (plan.relabelMutation() != null) relabelPalletMutations.moveSelectedItems( + plan.relabelMutation().application(), plan.relabelMutation().itemsByPosition(), relabelTargets); + 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 lockRelabelMutation(RelabelMutation mutation, long warehouseId) { + RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo()); + if (locked == null || !"READY_FOR_OUTBOUND".equals(locked.getStatus()) + || !Objects.equals(locked.getCustomerId(), mutation.application().getCustomerId()) + || !Objects.equals(locked.getOverseasWarehouseId(), String.valueOf(warehouseId)) + || !Objects.equals(locked.getOutboundOrderNo(), mutation.application().getOutboundOrderNo())) { + throw conflict("换标单状态或履约单据已变化,请重新扫码"); + } + } + + private InventoryPosition 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 = bucketValue(source, move.bucket()); + setBucketValue(source, move.bucket(), 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 = bucketValue(target, move.bucket()); + setBucketValue(target, move.bucket(), 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); + return target; + } + + 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.setCustomerName(balance.getCustomerName()); + 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.comparingInt((PalletChange change) -> "IDLE".equals(change.targetStatus()) ? 0 : 1) + .thenComparingLong(change -> change.pallet().palletId())); + return new Plan(pallets, moves, changes, null); + } + + 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()), InventoryBucket.AVAILABLE)); + } + 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 == null) throw notFound("未找到托盘"); + 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) { + if (locationId == null) return null; + PalletPhysicalGateway.LocationReference location = physical.resolveLocation(warehouseId, locationId); + if (location == null || location.locationId() != locationId.longValue() || "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 requireHeld(PalletInventoryRow row, long packages) { + if (packages <= 0 || value(row.getHoldPackages()) < packages) throw conflict("换标冻结库存箱数不足"); + if (value(row.getAllocatedPackages()) > 0 || value(row.getPickedPackages()) > 0 + || value(row.getReceivingPackages()) > 0) { + throw conflict("已分配、已拣或收货中的库存不能执行换标拆托"); + } + } + private static long bucketValue(InventoryPosition position, InventoryBucket bucket) { + return bucket == InventoryBucket.HOLD ? value(position.getHoldPackages()) + : value(position.getAvailablePackages()); + } + private static void setBucketValue(InventoryPosition position, InventoryBucket bucket, long packages) { + if (bucket == InventoryBucket.HOLD) position.setHoldPackages(packages); + else position.setAvailablePackages(packages); + } + 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 int value(Integer value) { return value == null ? 0 : 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 static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + + private record PlannedMove(long positionId, long packages, long expectedVersion, + long sourcePalletId, String sourcePalletCode, + long targetPalletId, String targetPalletCode, Long targetLocationId, + String targetLocationCode, InventoryBucket bucket) { } + private record PalletChange(PalletPhysicalGateway.PalletReference pallet, String targetStatus, + Long targetLocationId) { } + private record Plan(Map pallets, + List moves, List changes, + RelabelMutation relabelMutation) { } + private record RelabelMutation(RelabelApplication application, + Map> itemsByPosition) { } + private enum InventoryBucket { AVAILABLE, HOLD } +} 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..3d9c5ef94 --- /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_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +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..f5d5263f9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java @@ -0,0 +1,194 @@ +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.inspectPallet(required(request.sourcePalletCode(), "Source pallet code is required")); + if (pallet == null) throw notFound("Source pallet was not found"); + 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.setCustomerName(balance.getCustomerName()); 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 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/pda/security/PdaWarehouseIdentity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java new file mode 100644 index 000000000..6d4f653e6 --- /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_OVERSEAS_WAREHOUSE_PDA_OPERATOR"; + + 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, "OVERSEAS_WAREHOUSE_PDA_OPERATOR 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..e95b37819 --- /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_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +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..1da30169f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java @@ -0,0 +1,324 @@ +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.inspectPallet(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.setCustomerName(balance.getCustomerName()); 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..439a11fb7 --- /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_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +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..47af11c35 --- /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..4c58f9f7e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java @@ -0,0 +1,102 @@ +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.inspectPallet(code(request.code())); + if (pallet == null) throw notFound("Warehouse pallet was not found"); + 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/controller/MerchantOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java new file mode 100644 index 000000000..e4a2c254a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java @@ -0,0 +1,59 @@ +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 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.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") +@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT + "')") +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.MerchantQuery query) { + return ApiResponse.success(service.page(customer(user), query.scoped())); + } + + @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..a444577ea --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java @@ -0,0 +1,143 @@ +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.bind.annotation.RequestPart; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; +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)); + } + + @PostMapping(value = "/{orderNo}/files/{category}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_EDIT + "')") + public ApiResponse uploadFile( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @PathVariable String category, @RequestPart("file") MultipartFile file) { + requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION); + return ApiResponse.success(service.uploadFile(requiredCustomer(customerId), orderNo, + String.valueOf(user.userId()), category.trim().toUpperCase(), file)); + } + + @PostMapping("/{orderNo}/fees") + @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_EDIT + "')") + public ApiResponse createFee( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.CreateFee request) { + requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION); + return ApiResponse.success(service.createFee(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..453e9a0bd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -0,0 +1,88 @@ +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 MerchantQuery( + @Min(1) long page, @Min(1) @Max(200) long size, + String orderNo, String purchaseCategory, String productName, + String status, String purchaseWarehouseId, + LocalDateTime createdFrom, LocalDateTime createdTo) { + public Query scoped() { + return new Query(page, size, orderNo, null, purchaseCategory, productName, + status, purchaseWarehouseId, createdFrom, 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 CreateFee( + @NotBlank @Size(max = 32) String feeItemCode, + @NotBlank @Size(max = 128) String feeItemName, + @NotNull @DecimalMin(value = "0.01") BigDecimal amount, + @NotNull @Positive Long voucherFileId, + @Size(max = 500) 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, + JsonNode outboundDetails) { + public Update(Long version, String purchaseCategory, String productName, JsonNode productSpecification, + JsonNode productUrl, Long inboundQuantity, JsonNode purchaseWarehouseId, JsonNode purchaseWarehouseName, + JsonNode domesticCarrier, JsonNode domesticTrackingNo, JsonNode overseasCarrier, JsonNode overseasTrackingNo, + JsonNode estimatedArrivalAt, JsonNode remark) { + this(version, purchaseCategory, productName, productSpecification, productUrl, inboundQuantity, + purchaseWarehouseId, purchaseWarehouseName, domesticCarrier, domesticTrackingNo, overseasCarrier, + overseasTrackingNo, estimatedArrivalAt, remark, null); + } + } +} 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..9a48f9b4a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java @@ -0,0 +1,47 @@ +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 OutboundDetail(String id, long quantity, String carrier, String trackingNo, LocalDateTime shippedAt, String remark) {} + + 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 outboundQuantity, 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 FileInfo(Long id, String category, Long fileId, String fileName, + String contentType, long sizeBytes, LocalDateTime createdAt) {} + + public record Fee(Long id, String feeNo, String feeItemCode, String feeItemName, + BigDecimal amount, String currency, Long voucherFileId, + String voucherFileName, String remark, String walletStatus, + String walletErrorMessage, 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, List outboundDetails, + List files, List fees) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java new file mode 100644 index 000000000..89a289562 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java @@ -0,0 +1,31 @@ +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.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_overseas_procurement_fee") +public class OverseasProcurementFee { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String feeNo; + private String feeItemCode; + private String feeItemName; + private BigDecimal amount; + private String currency; + private Long voucherFileId; + private String voucherFileName; + private String remark; + private String walletStatus; + private String walletErrorCode; + private String walletErrorMessage; + private String walletCommandEventId; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFile.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFile.java new file mode 100644 index 000000000..6c23977e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFile.java @@ -0,0 +1,22 @@ +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_file") +public class OverseasProcurementFile { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String category; + private Long fileId; + private String fileName; + private String contentType; + private Long sizeBytes; + private String createdBy; + private LocalDateTime createdAt; +} 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..df14cd381 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java @@ -0,0 +1,52 @@ +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 outboundDetails; + @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/gateway/HttpProcurementFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java new file mode 100644 index 000000000..b054a93f7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.procurement.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +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; + +@Component +@RequiredArgsConstructor +public class HttpProcurementFileGateway implements ProcurementFileGateway { + private final RestClient.Builder restClientBuilder; + private final LastlegRestClientFactory restClientFactory; + private final LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String token; + private RestClient client; + @PostConstruct void initialize() { + client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getProcurementFile()); + } + + @Override + public UploadedFile upload(String customerId, long orderId, long operatorId, String category, MultipartFile file) { + if (file == null || file.isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择文件"); + 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 (Exception exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "采购文件无法读取", exception); } + String businessType = "RECEIPT".equals(category) ? "OVERSEAS_PROCUREMENT_RECEIPT" : "OVERSEAS_PROCUREMENT_VOUCHER"; + Envelope response = client.post().uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + Long.parseLong(customerId), businessType, orderId) + .header("X-Internal-Service-Token", token).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, + "采购文件上传失败"); }).body(Envelope.class); + if (response == null || !"200".equals(response.code()) || response.data() == null || response.data().fileId() == null) + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回采购文件元数据"); + Metadata data = response.data(); + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); + } + private record Envelope(String code, String message, Metadata data) { } + private record Metadata(Long fileId, String fileName, String contentType, Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java new file mode 100644 index 000000000..07eddfe5e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java @@ -0,0 +1,6 @@ +package com.baiyue.lastleg.procurement.gateway; +import org.springframework.web.multipart.MultipartFile; +public interface ProcurementFileGateway { + UploadedFile upload(String customerId, long orderId, long operatorId, String category, MultipartFile file); + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java new file mode 100644 index 000000000..57db8b5fe --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.lastleg.procurement.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface OverseasProcurementFeeMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFileMapper.java new file mode 100644 index 000000000..41a7b46d0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFileMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.lastleg.procurement.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFile; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface OverseasProcurementFileMapper extends BaseMapper { } 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..dc93177b3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -0,0 +1,532 @@ +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.entity.OverseasProcurementFile; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper; +import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway; +import com.baiyue.lastleg.wallet.WalletCommandOutboxService; +import com.baiyue.lastleg.wallet.WalletResultHandler; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.common.event.WalletCommandCompletedV1; +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.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.LocalDate; +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; +import org.springframework.web.multipart.MultipartFile; + +@Service +public class OverseasProcurementService implements WalletResultHandler { + @Resource private ProcurementOrderNumberGenerator orderNoGenerator; + 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; + @Resource private OverseasProcurementFileMapper procurementFiles; + @Resource private OverseasProcurementFeeMapper procurementFees; + @Resource private ProcurementFileGateway fileGateway; + @Resource private WalletCommandOutboxService walletCommands; + @Resource private OverseasFeeSourceService feeSources; + + @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(orderNoGenerator.next(customerId, request.customerName())); + 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(), outboundDetails(order), files(order), fees(order)); + } + + @Transactional + public OverseasProcurementResponses.FileInfo uploadFile(String customerId, String orderNo, String operatorId, + String category, MultipartFile file) { + OverseasProcurementOrder order = lock(customerId, orderNo); + if (!Set.of("RECEIPT", "VOUCHER").contains(category)) throw badRequest("File category must be RECEIPT or VOUCHER"); + ProcurementFileGateway.UploadedFile uploaded = fileGateway.upload(customerId, order.getId(), Long.parseLong(operatorId), category, file); + OverseasProcurementFile row = new OverseasProcurementFile(); + row.setId(IdWorker.getId()); row.setOrderId(order.getId()); row.setCustomerId(customerId); row.setCategory(category); + row.setFileId(uploaded.fileId()); row.setFileName(uploaded.fileName()); row.setContentType(uploaded.contentType()); + row.setSizeBytes(uploaded.sizeBytes()); row.setCreatedBy(operatorId); row.setCreatedAt(LocalDateTime.now()); + procurementFiles.insert(row); + return file(row); + } + + @Transactional + public OverseasProcurementResponses.Fee createFee(String customerId, String orderNo, String operatorId, + OverseasProcurementRequests.CreateFee request) { + OverseasProcurementOrder order = lock(customerId, orderNo); + if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can add fees"); + OverseasProcurementFile voucher = procurementFiles.selectOne(new LambdaQueryWrapper() + .eq(OverseasProcurementFile::getOrderId, order.getId()).eq(OverseasProcurementFile::getCustomerId, customerId) + .eq(OverseasProcurementFile::getCategory, "VOUCHER").eq(OverseasProcurementFile::getFileId, request.voucherFileId())); + if (voucher == null) throw badRequest("Fee voucher does not belong to this procurement order"); + LocalDateTime now = LocalDateTime.now(); + OverseasProcurementFee row = new OverseasProcurementFee(); + row.setId(IdWorker.getId()); row.setOrderId(order.getId()); row.setCustomerId(customerId); + row.setFeeNo("PF" + row.getId()); row.setFeeItemCode(request.feeItemCode().trim().toUpperCase()); + row.setFeeItemName(request.feeItemName().trim()); row.setAmount(request.amount()); row.setCurrency("CNY"); + row.setVoucherFileId(voucher.getFileId()); row.setVoucherFileName(voucher.getFileName()); row.setRemark(trim(request.remark())); + row.setWalletStatus("FREEZE_PENDING"); row.setCreatedBy(operatorId); row.setCreatedAt(now); row.setUpdatedAt(now); + procurementFees.insert(row); + String eventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "PROCUREMENT_FEE", row.getFeeNo(), + row.getAmount(), "海外采购费用冻结:" + orderNo, 1L); + row.setWalletCommandEventId(eventId); procurementFees.updateById(row); + return fee(row); + } + + @Override public boolean supports(String businessType) { return "PROCUREMENT_FEE".equals(businessType); } + + @Override + @Transactional + public void handle(WalletCommandCompletedV1 result) { + OverseasProcurementFee row = procurementFees.selectOne(new LambdaQueryWrapper() + .eq(OverseasProcurementFee::getCustomerId, String.valueOf(result.customerId())) + .eq(OverseasProcurementFee::getFeeNo, result.businessNo())); + if (row == null) throw notFound("Procurement fee not found for wallet result"); + if ("SUCCEEDED".equals(result.result())) { + row.setWalletStatus(switch (result.operation()) { case "FREEZE" -> "FROZEN"; case "SETTLE" -> "SETTLED"; case "RELEASE" -> "RELEASED"; default -> result.holdStatus(); }); + row.setWalletErrorCode(null); row.setWalletErrorMessage(null); + } else { + row.setWalletStatus(result.operation() + "_FAILED"); row.setWalletErrorCode(result.errorCode()); row.setWalletErrorMessage(result.errorMessage()); + } + row.setUpdatedAt(LocalDateTime.now()); procurementFees.updateById(row); + if ("FREEZE".equals(result.operation()) && "SUCCEEDED".equals(result.result())) { + OverseasProcurementOrder order = orders.selectById(row.getOrderId()); + if (order != null && order.getInboundQuantity() >= order.getPurchaseQuantity()) { + publishFeeSource(order, row); + walletCommands.publish("SETTLE", result.customerId(), "PROCUREMENT_FEE", row.getFeeNo(), null, + "海外采购完成入库,结算费用", 2L); + row.setWalletStatus("SETTLE_PENDING"); procurementFees.updateById(row); + } + } + } + + private List files(OverseasProcurementOrder order) { + return procurementFiles.selectList(new LambdaQueryWrapper() + .eq(OverseasProcurementFile::getOrderId, order.getId()).orderByAsc(OverseasProcurementFile::getCreatedAt)) + .stream().map(this::file).toList(); + } + private OverseasProcurementResponses.FileInfo file(OverseasProcurementFile value) { + return new OverseasProcurementResponses.FileInfo(value.getId(), value.getCategory(), value.getFileId(), value.getFileName(), + value.getContentType(), Objects.requireNonNullElse(value.getSizeBytes(), 0L), value.getCreatedAt()); + } + private List fees(OverseasProcurementOrder order) { + return procurementFees.selectList(new LambdaQueryWrapper() + .eq(OverseasProcurementFee::getOrderId, order.getId()).orderByAsc(OverseasProcurementFee::getCreatedAt)) + .stream().map(this::fee).toList(); + } + private OverseasProcurementResponses.Fee fee(OverseasProcurementFee value) { + return new OverseasProcurementResponses.Fee(value.getId(), value.getFeeNo(), value.getFeeItemCode(), value.getFeeItemName(), + value.getAmount(), value.getCurrency(), value.getVoucherFileId(), value.getVoucherFileName(), value.getRemark(), + value.getWalletStatus(), value.getWalletErrorMessage(), value.getCreatedAt()); + } + + @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"); + if (request.inboundQuantity() > order.getPurchaseQuantity()) throw badRequest("Inbound quantity cannot exceed purchase quantity"); + 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); + if (request.outboundDetails() != null) order.setOutboundDetails(normalizeOutbound(request.outboundDetails(), order.getInboundQuantity())); + 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); + if (order.getInboundQuantity() >= order.getPurchaseQuantity()) { + for (OverseasProcurementFee fee : procurementFees.selectList(new LambdaQueryWrapper() + .eq(OverseasProcurementFee::getOrderId, order.getId()).eq(OverseasProcurementFee::getWalletStatus, "FROZEN"))) { + publishFeeSource(order, fee); + walletCommands.publish("SETTLE", Long.parseLong(customerId), "PROCUREMENT_FEE", fee.getFeeNo(), null, + "海外采购完成入库,结算费用", 2L); + fee.setWalletStatus("SETTLE_PENDING"); fee.setUpdatedAt(now); procurementFees.updateById(fee); + } + } + return detail(customerId, orderNo); + } + + private void publishFeeSource(OverseasProcurementOrder order, OverseasProcurementFee fee) { + feeSources.publishApproved(StringUtils.hasText(fee.getCreatedBy()) ? fee.getCreatedBy() : "SYSTEM", "PROCUREMENT:" + fee.getFeeNo(), + new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(), + "OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", fee.getId(), fee.getFeeNo(), + order.getId(), null, null, null, null, null, parseLong(order.getPurchaseWarehouseId()), + fee.getFeeItemCode(), fee.getFeeItemName(), "RECEIVABLE", null, null, null, null, + null, null, null, List.of(), LocalDate.now(), fee.getCurrency(), fee.getAmount(), + BigDecimal.ONE, "ITEM", fee.getAmount(), null, null)); + } + + 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()); + long outbound = outboundDetails(order).stream().mapToLong(OverseasProcurementResponses.OutboundDetail::quantity).sum(); + String displayStatus = order.getStatus(); + if (APPROVED.equals(order.getStatus()) && inbound > 0) { + displayStatus = outbound == 0 ? "PENDING_OUTBOUND" : outbound < inbound ? "PARTIALLY_OUTBOUND" : "OUTBOUNDED"; + } + 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, outbound, Math.max(0, inbound - outbound), + displayStatus, 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"); + } + // Keep the warehouse snapshot already frozen on a historical order. A later master-data + // deactivation must not block unrelated edits such as recording outbound details. + if (Long.toString(numericId).equals(order.getPurchaseWarehouseId())) return; + 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()); + result.put("outboundDetails", order.getOutboundDetails()); + return result; + } + + private List outboundDetails(OverseasProcurementOrder order) { + if (!StringUtils.hasText(order.getOutboundDetails())) return List.of(); + try { return objectMapper.readValue(order.getOutboundDetails(), objectMapper.getTypeFactory().constructCollectionType(List.class, OverseasProcurementResponses.OutboundDetail.class)); } + catch (JsonProcessingException e) { return List.of(); } + } + private String normalizeOutbound(JsonNode node, Long inbound) { + if (!node.isArray()) throw badRequest("outboundDetails must be an array"); + long total = 0; + for (JsonNode item : node) { + if (!item.isObject() || item.path("quantity").asLong(-1) < 0) throw badRequest("outboundDetails quantity must be non-negative"); + total += item.path("quantity").asLong(); + } + if (total > value(inbound)) throw badRequest("outbound quantity cannot exceed inbound quantity"); + try { return objectMapper.writeValueAsString(node); } catch (JsonProcessingException e) { throw badRequest("Invalid outboundDetails"); } + } + + 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 Long parseLong(String value) { + try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; } + catch (NumberFormatException ignored) { return null; } + } + 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/procurement/service/ProcurementOrderNumberGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGenerator.java new file mode 100644 index 000000000..da15190a1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGenerator.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.procurement.service; + +import net.sourceforge.pinyin4j.PinyinHelper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +@Component +public class ProcurementOrderNumberGenerator { + private final JdbcTemplate jdbc; + public ProcurementOrderNumberGenerator(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + @Transactional + public String next(String customerId, String customerName) { + LocalDate date = LocalDate.now(); + jdbc.update("INSERT INTO ll_procurement_daily_sequence(customer_id,business_date,sequence_value) VALUES(?,?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE sequence_value=LAST_INSERT_ID(sequence_value+1)", + customerId, date); + Integer value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Integer.class); + if (value == null || value < 1 || value > 9999) throw new IllegalStateException("客户采购订单当日序号已耗尽"); + return initials(customerName) + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format("%04d", value); + } + + static String initials(String customerName) { + if (!StringUtils.hasText(customerName)) return "KH"; + StringBuilder result = new StringBuilder(); + boolean latinWord = false; + for (char value : customerName.trim().toCharArray()) { + if (result.length() >= 8) break; + if (Character.UnicodeScript.of(value) == Character.UnicodeScript.HAN) { + String[] pinyin = PinyinHelper.toHanyuPinyinStringArray(value); + if (pinyin != null && pinyin.length > 0 && !pinyin[0].isBlank()) result.append(Character.toUpperCase(pinyin[0].charAt(0))); + latinWord = false; + } else if (Character.isLetterOrDigit(value)) { + if (!latinWord) result.append(Character.toUpperCase(value)); + latinWord = true; + } else { + latinWord = false; + } + } + String normalized = result.toString().replaceAll("[^A-Z0-9]", "").toUpperCase(Locale.ROOT); + return normalized.isBlank() ? "KH" : normalized.substring(0, Math.min(normalized.length(), 8)); + } +} 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..8c15edf5f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java @@ -0,0 +1,91 @@ +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; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.ContentDisposition; +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/relabel-applications") +public class MerchantRelabelApplicationController { + @Resource private RelabelApplicationService service; + @GetMapping("/inventory-candidates") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String operationWarehouseId, + @RequestParam(required = false) String inboundCode) { + return ApiResponse.success(service.candidates(customer(user), operationWarehouseId, inboundCode)); + } + @GetMapping("/historical-orders") + public ApiResponse> historicalOrders( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String operationWarehouseId, + @RequestParam(required = false, defaultValue = "") String keyword) { + return ApiResponse.success(service.historicalOrders(customer(user), operationWarehouseId, keyword)); + } + @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)); } + @PostMapping(value = "/{relabelNo}/shipping-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.uploadShippingLabel(customer(user), relabelNo, operatorLong(user), file)); + } + @PostMapping(value = "/{relabelNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadFile(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @RequestParam String scope, @RequestParam(required = false) Long itemId, + @RequestPart("file") MultipartFile file) { + String kind = "SKU_TOTAL".equalsIgnoreCase(scope) ? "SKU_TOTAL" : "CARTON_TOTAL"; + if ("ITEM".equalsIgnoreCase(scope)) return ApiResponse.success(service.uploadItemFile(customer(user), relabelNo, itemId == null ? 0L : itemId, operatorLong(user), file)); + return ApiResponse.success(service.uploadTotalFile(customer(user), relabelNo, operatorLong(user), file, kind)); + } + @GetMapping("/{relabelNo}/shipping-label/{fileId}/download") + public ResponseEntity downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @PathVariable long fileId) { + var file = service.downloadShippingLabel(customer(user), relabelNo, fileId); + return download(file.content(), file.fileName(), file.contentType()); + } + @GetMapping("/{relabelNo}/completion-photos/{fileId}/download") + public ResponseEntity downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @PathVariable long fileId) { + var file = service.downloadCompletionPhoto(customer(user), relabelNo, fileId); + return download(file.content(), file.fileName(), file.contentType()); + } + @PostMapping("/{relabelNo}/confirm-completion") + public ApiResponse confirmCompletion(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.CustomerConfirm request) { + return ApiResponse.success(service.confirmCompletion(customer(user), relabelNo, operator(user), request)); + } + private ResponseEntity download(byte[] content, String name, String type) { + return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .header("Content-Disposition", ContentDisposition.inline().filename(name, StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(type)).contentLength(content.length).body(content); + } + 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()); } + private long operatorLong(AuthenticatedUser user) { return user.userId() == null ? 0L : 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..88a51cd6f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -0,0 +1,152 @@ +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.multipart.MultipartFile; +import org.springframework.http.ContentDisposition; +import org.springframework.http.ResponseEntity; +import org.springframework.http.MediaType; +import java.nio.charset.StandardCharsets; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/relabel-applications") +public class RelabelApplicationController { + @Resource private RelabelApplicationService service; + @Resource private com.baiyue.lastleg.relabel.pda.service.PdaRelabelService pdaService; + + @PostMapping("/{relabelNo}/rework") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')") + public ApiResponse rework( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, + @Valid @RequestBody com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests.Review request) { + requirePlatform(user); + return ApiResponse.success(pdaService.returnForRework(operator(user), user.username(), relabelNo, request)); + } + + @GetMapping("/{relabelNo}/review-task") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") + public ApiResponse reviewTask( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo) { + requirePlatform(user); + var detail = service.detail(null, relabelNo); + return ApiResponse.success(pdaService.detail(operator(user), Long.parseLong(detail.application().overseasWarehouseId()), relabelNo)); + } + + @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)); + } + + @PostMapping("/{relabelNo}/wallet-freeze/retry") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')") + @Operation(summary = "Retry a failed relabel wallet freeze", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS) + public ApiResponse retryWalletFreeze(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, + @Valid @RequestBody RelabelRequests.WalletFreezeRetry request) { + requirePlatform(user); + return ApiResponse.success(service.retryWalletFreeze(relabelNo, operator(user), request)); + } + + @PostMapping("/{relabelNo}/wallet-settlement/retry") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')") + @Operation(summary = "Retry a failed relabel wallet settlement", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS) + public ApiResponse retryWalletSettlement(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, + @Valid @RequestBody RelabelRequests.WalletFreezeRetry request) { + requirePlatform(user); + return ApiResponse.success(service.retryWalletSettlement(relabelNo, operator(user), request)); + } + + @PostMapping(value = "/{relabelNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')") + public ApiResponse uploadFile(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @RequestParam String scope, @RequestParam(required = false) Long itemId, + @RequestPart("file") MultipartFile file) { + requirePlatform(user); + if (!"TOTAL".equalsIgnoreCase(scope) && !"ITEM".equalsIgnoreCase(scope)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "资料范围必须为 TOTAL 或 ITEM"); + if ("TOTAL".equalsIgnoreCase(scope) && itemId != null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "总资料不能指定明细"); + String customerId = service.customerId(relabelNo); + return ApiResponse.success("ITEM".equalsIgnoreCase(scope) + ? service.uploadItemFile(customerId, relabelNo, itemId == null ? 0L : itemId, operatorLong(user), file) + : service.uploadTotalFile(customerId, relabelNo, operatorLong(user), file)); + } + + @GetMapping("/{relabelNo}/files/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") + public ResponseEntity downloadFile(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @PathVariable long fileId, @RequestParam String scope, + @RequestParam(required = false) Long itemId) { + requirePlatform(user); + var file = service.downloadFile(service.customerId(relabelNo), relabelNo, scope, itemId, fileId); + return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()).header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content()); + } + @GetMapping("/{relabelNo}/completion-photos/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") + public ResponseEntity downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @PathVariable long fileId) { + requirePlatform(user); + var file = service.downloadCompletionPhoto(service.customerId(relabelNo), relabelNo, fileId); + return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content()); + } + 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()); } + private long operatorLong(AuthenticatedUser user) { return user.userId() == null ? 0L : 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..d0d73eacc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -0,0 +1,123 @@ +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; +import java.math.BigDecimal; +import jakarta.validation.constraints.DecimalMin; + +public final class RelabelRequests { + private RelabelRequests() {} + + public record Create( + @NotBlank @Size(max = 128) String customerOrderNo, + @NotBlank @Size(max = 128) String operationWarehouseId, + @NotNull LocalDateTime labelDeadline, + @NotBlank @Size(max = 128) String contactName, + @NotBlank @Size(max = 64) String contactPhone, + @DecimalMin(value = "0.01") BigDecimal chargeAmount, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 500) List<@Valid Item> items, + @Size(max = 32) String deliveryMethod, + Boolean printShippingLabel + ) { + public Create(String customerOrderNo, String operationWarehouseId, LocalDateTime labelDeadline, + String contactName, String contactPhone, BigDecimal chargeAmount, String remark, + String idempotencyKey, List items) { + this(customerOrderNo, operationWarehouseId, labelDeadline, contactName, contactPhone, + chargeAmount, remark, idempotencyKey, items, "TRUCK", false); + } + } + + public record Item( + @NotNull Long sourceInventoryBalanceId, + @NotNull Long sourceInventoryPositionId, + @NotNull Long sourceInboundOrderId, + @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, + @Size(max = 128) String newSkuCode, + @Size(max = 1000) String labelRequirement, + @Size(max = 1000) String remark, + @Size(max = 128) String relabelFileId, + @Size(max = 255) String relabelFileName, + @Size(max = 1000) String relabelFileUrl, + @NotBlank @Size(max = 255) String cartonMark, + @Size(max = 255) String fbaCartonMark, + @Size(max = 128) String destinationFbaWarehouseCode, + @Size(max = 32) String operationType, + @Size(max = 100) List<@Valid SkuItem> skuItems + ) { + /** 保持既有箱唛换标调用方的二进制参数形态。 */ + public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId, + String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, + String productNameZh, int totalPackageCount, int notifiedPackageCount, String shipmentNo, + String trackingCode, String newSkuCode, String labelRequirement, String remark, + String relabelFileId, String relabelFileName, String relabelFileUrl, String cartonMark, + String fbaCartonMark, String destinationFbaWarehouseCode) { + this(sourceInventoryBalanceId, sourceInventoryPositionId, sourceInboundOrderId, skuCode, + productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount, + notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark, + relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark, + destinationFbaWarehouseCode, "CARTON_LABEL", List.of()); + } + public Item(String skuCode, String productImageFileId, String productImageFileName, + String productImageUrl, String productNameZh, int totalPackageCount, + int notifiedPackageCount, String shipmentNo, String trackingCode, String newSkuCode, + String labelRequirement, String remark, String relabelFileId, String relabelFileName, + String relabelFileUrl, String cartonMark, String fbaCartonMark, + String destinationFbaWarehouseCode) { + this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, + productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, + newSkuCode, labelRequirement, remark, relabelFileId, relabelFileName, relabelFileUrl, + cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of()); + } + public Item(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) { + this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh, + totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, + labelRequirement, null, relabelFileId, relabelFileName, relabelFileUrl, skuCode, shipmentNo, "ONT8", + "CARTON_LABEL", List.of()); + } + } + + /** 同一我方箱唛内可同时更换的多个 SKU;数量以件为单位。 */ + public record SkuItem( + @NotBlank @Size(max = 128) String skuCode, + @NotBlank @Size(max = 128) String newSkuCode, + @Positive int quantity, + @Size(max = 1000) String remark + ) {} + + 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) {} + + public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record WalletFreezeRetry(@NotBlank @Size(max = 255) String idempotencyKey, + @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..66f7fff17 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.relabel.dto; + +import java.time.LocalDateTime; +import java.util.List; +import java.math.BigDecimal; + +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, + String totalFileId, String totalFileName, String totalFileUrl, + String cartonTotalFileId, String cartonTotalFileName, String cartonTotalFileUrl, + String skuTotalFileId, String skuTotalFileName, String skuTotalFileUrl, + BigDecimal chargeAmount, String chargeCurrency, String walletStatus, + String walletErrorMessage, String deliveryMethod, boolean printShippingLabel, + String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl, + String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt, + 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 remark, String relabelFileId, + String relabelFileName, String relabelFileUrl, + String cartonMark, String fbaCartonMark, String destinationFbaWarehouseCode, + String operationType, List skuItems, + Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId, + String sourceInboundOrderNo, String sourceInboundCode, String sourceWarehouseId, + String sourceWarehouseCode, String sourceWarehouseName) {} + public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) {} + public record InventoryPosition(Long positionId, String palletNo, String batchNo, + String warehouseLocationCode, long availablePackages) {} + public record InventoryCandidate(Long inventoryBalanceId, Long inboundOrderId, String inboundOrderNo, + String inboundCode, String overseasWarehouseId, String overseasWarehouseCode, + String overseasWarehouseName, String destinationFbaWarehouseCode, + String destinationFbaWarehouseName, String skuCode, String productNameZh, + long availablePackages, long version, List positions) {} + public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo, + String relabelOrderNo, String orderType, String status, Integer packageCount, + LocalDateTime createdAt) {} + public record TailOrder(Long id, String orderNo, String overseasWarehouseId, int packageCount, + String status) {} + public record History(String fromStatus, String toStatus, String operatorId, + String remark, LocalDateTime createdAt) {} + public record CompletionEvidence(Long taskId, List photoFileIds, String completionRemark, + String reviewerName, String reviewRemark, LocalDateTime completedAt) {} + public record Detail(Summary application, String remark, List items, List history, + List tailOrders, CompletionEvidence completionEvidence) {} +} 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..581d736bf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java @@ -0,0 +1,57 @@ +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; +import java.math.BigDecimal; + +@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 deliveryMethod; + private Boolean printShippingLabel; + private String shippingLabelFileId; + private String shippingLabelFileName; + private String shippingLabelFileUrl; + private String fulfillmentStatus; + private String outboundOrderNo; + private String customerConfirmationKey; + private LocalDateTime customerConfirmedAt; + private String remark; + private String totalFileId; + private String totalFileName; + private String totalFileUrl; + private String cartonTotalFileId; + private String cartonTotalFileName; + private String cartonTotalFileUrl; + private String skuTotalFileId; + private String skuTotalFileName; + private String skuTotalFileUrl; + private String status; + private Integer itemCount; + private Integer totalPackageCount; + private Integer notifiedPackageCount; + private BigDecimal chargeAmount; + private String chargeCurrency; + private String walletStatus; + private String walletErrorCode; + private String walletErrorMessage; + private String walletCommandEventId; + 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..5fedcca2f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java @@ -0,0 +1,49 @@ +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 remark; + private String relabelFileId; + private String relabelFileName; + private String relabelFileUrl; + private String cartonMark; + private String fbaCartonMark; + private String destinationWarehouseId; + private String destinationFbaWarehouseCode; + private String operationType; + private String skuRelabelItemsJson; + private Long sourceInventoryBalanceId; + private Long sourceInventoryPositionId; + private Long sourceInboundOrderId; + private String sourceInboundOrderNo; + private String sourceInboundCode; + private String sourceWarehouseId; + private String sourceWarehouseCode; + private String sourceWarehouseName; + private LocalDateTime createdAt; + private Long returnCartonId; + private String sourceCartonMark; + private String newCartonMark; +} 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/gateway/HttpRelabelDocumentFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java new file mode 100644 index 000000000..66c40bd81 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.relabel.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.*; +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 HttpRelabelDocumentFileGateway implements RelabelDocumentFileGateway { + @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, String businessType, String businessId, long operatorId, MultipartFile file) { + 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_PDF : MediaType.parseMediaType(file.getContentType())); } + catch (IOException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "无法读取换标PDF", e); } + MetadataEnvelope response = client.post().uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", tenantId(customerId), businessType, businessId) + .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(HttpStatus.BAD_REQUEST, "换标PDF上传失败"); }) + .body(MetadataEnvelope.class); + requireSuccess(response, "换标PDF上传失败"); Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null || data.fileId() <= 0) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回换标PDF信息"); + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); + } + + @Override public DownloadedFile download(String customerId, String businessType, String businessId, long fileId) { + ResponseEntity response = client.get().uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content", tenantId(customerId), businessType, businessId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() ? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, "换标PDF读取失败"); + }).toEntity(byte[].class); + String name = response.getHeaders().getContentDisposition().getFilename(); + return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(), name == null ? "换标资料.pdf" : name, + response.getHeaders().getContentType() == null ? MediaType.APPLICATION_PDF_VALUE : response.getHeaders().getContentType().toString()); + } + + private long tenantId(String value) { try { return Long.parseLong(value); } catch (NumberFormatException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "客户ID格式无效", e); } } + private static void requireSuccess(MetadataEnvelope response, String message) { if (response == null || !"200".equals(response.code())) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, message); } + private record MetadataEnvelope(String code, String message, Metadata data) { } + private record Metadata(Long fileId, String fileName, String contentType, Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/RelabelDocumentFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/RelabelDocumentFileGateway.java new file mode 100644 index 000000000..0c4ff86a7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/RelabelDocumentFileGateway.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.relabel.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface RelabelDocumentFileGateway { + UploadedFile upload(String customerId, String businessType, String businessId, long operatorId, MultipartFile file); + DownloadedFile download(String customerId, String businessType, String businessId, 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/relabel/mapper/RelabelApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java new file mode 100644 index 000000000..7184b7048 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java @@ -0,0 +1,36 @@ +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; +import org.apache.ibatis.annotations.Update; + +@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); + + @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} LIMIT 1 FOR UPDATE") + RelabelApplication lockByOutbound(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); + + @Update("UPDATE ll_relabel_application SET wallet_status=#{status}, wallet_error_code=#{errorCode}, " + + "wallet_error_message=#{errorMessage}, wallet_command_event_id=COALESCE(#{eventId},wallet_command_event_id), " + + "updated_at=NOW(3) WHERE id=#{id}") + int updateWalletState(@Param("id") Long id, @Param("status") String status, + @Param("errorCode") String errorCode, @Param("errorMessage") String errorMessage, + @Param("eventId") String eventId); +} 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/controller/PdaRelabelController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java new file mode 100644 index 000000000..7bc4d4595 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java @@ -0,0 +1,163 @@ +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_OVERSEAS_WAREHOUSE_PDA_OPERATOR"; + + @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}/cartons/scan") + public ApiResponse scanCarton(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.CartonScan request) { + Identity identity = identity(user); + return ApiResponse.success(service.scanCarton(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @GetMapping("/{relabelNo}/documents") + public ApiResponse> documents(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo) { + return ApiResponse.success(service.documents(identity(user).overseasWarehouseId(), relabelNo)); + } + + @GetMapping("/{relabelNo}/documents/{fileId}/download") + public org.springframework.http.ResponseEntity download(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @PathVariable long fileId, + @org.springframework.web.bind.annotation.RequestParam String scope, + @org.springframework.web.bind.annotation.RequestParam(required = false) Long itemId) { + var file = service.downloadDocument(identity(user).overseasWarehouseId(), relabelNo, scope, itemId, fileId); + return org.springframework.http.ResponseEntity.ok() + .contentType(MediaType.parseMediaType(file.contentType())) + .header("Content-Disposition", org.springframework.http.ContentDisposition.inline() + .filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString()) + .body(file.content()); + } + + @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)); + } + + @GetMapping("/{relabelNo}/photos/files/{fileId}/download") + public org.springframework.http.ResponseEntity downloadWorkPhoto( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @PathVariable long fileId) { + Identity identity = identity(user); + var file = service.downloadWorkPhoto(identity.operatorKey(), identity.overseasWarehouseId(), relabelNo, fileId); + return org.springframework.http.ResponseEntity.ok().contentType(MediaType.parseMediaType(file.contentType())) + .header("Cache-Control", "no-store").body(file.content()); + } + + @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, + "OVERSEAS_WAREHOUSE_PDA_OPERATOR 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..e402ee3fb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.relabel.pda.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.Valid; +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 CartonScan(@NotBlank @Size(max = 255) String cartonMark, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record Scan(@Positive Long itemId, + @NotBlank @Size(max = 255) String oldLabel, + @Size(max = 255) String newLabel, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @Valid List skuVerifications) { + public Scan(Long itemId, String oldLabel, String newLabel, Integer expectedVersion, String idempotencyKey) { + this(itemId, oldLabel, newLabel, expectedVersion, idempotencyKey, List.of()); + } + } + + public record SkuVerification(@NotBlank String skuCode, @NotBlank String newSkuCode, + @Positive int quantity) { } + + 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, boolean manualCompletion) { + public Complete(List photoFileIds, Integer expectedVersion, String idempotencyKey, String remark) { + this(photoFileIds, expectedVersion, idempotencyKey, remark, false); + } + } + + 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..92b660d53 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -0,0 +1,40 @@ +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, String operationType, 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 operationType, + 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, + String cartonMark, String fbaCartonMark, String operationType, + List skuItems, + int expectedPackages, int relabelledPackages, int skippedPackages, + int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages) { } + public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { } + + 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..1e53232f7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java @@ -0,0 +1,135 @@ +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.http.ResponseEntity; +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"); + } + } + + @Override + public DownloadedFile download(String customerId, long taskId, long fileId) { + validate(customerId, taskId, fileId); + ResponseEntity response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content", + tenantId(customerId), BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, "Relabel photo download failed"); + }).toEntity(byte[].class); + String fileName = response.getHeaders().getContentDisposition().getFilename(); + String contentType = response.getHeaders().getContentType() == null + ? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString(); + return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(), + fileName == null ? "relabel-photo" : fileName, contentType); + } + + 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..1cccc2344 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java @@ -0,0 +1,12 @@ +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); + 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/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..66f504d4b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java @@ -0,0 +1,33 @@ +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); + + @Select("SELECT * FROM ll_relabel_pda_task WHERE operator_key=#{operatorKey} AND status<>'COMPLETED' ORDER BY claimed_at ASC LIMIT 1 FOR UPDATE") + PdaRelabelTask lockUnfinishedByOperator(@Param("operatorKey") String operatorKey); + + @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..a4a5c9f73 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -0,0 +1,709 @@ +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.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +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 ReturnInventoryCartonMapper returnCartons; + @Resource private PdaRelabelOperationMapper operations; + @Resource private PdaRelabelHistoryMapper histories; + @Resource private RelabelApplicationService applicationService; + @Resource private RelabelPhotoFileGateway photoFiles; + @Resource private ObjectMapper sourceObjectMapper; + @Resource private com.baiyue.lastleg.mapper.LastlegOrderMapper tailOrders; + @Resource private com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService exceptionOrders; + 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, + RelabelApplicationService.CUSTOMER_CONFIRM_PENDING) + .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()); + PdaRelabelTask activeTask = tasks.lockUnfinishedByOperator(operatorKey); + if (activeTask != null && !Objects.equals(activeTask.getApplicationId(), application.getId())) { + throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单"); + } + 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) { + Long itemId = request.itemId(); + if (itemId == null) { + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false); + itemId = requireItemByCartonMark(application, request.oldLabel()).getId(); + } + return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "RELABEL", request.idempotencyKey(), + request, itemId, request.oldLabel(), request.newLabel(), "RELABELLED", null, null, + request.expectedVersion(), item -> { + validateSkuVerification(item, request.skuVerifications()); + if (StringUtils.hasText(item.getCartonMark()) && !item.getCartonMark().trim().equalsIgnoreCase(request.oldLabel().trim())) { + throw conflict("Scanned old carton mark does not match the relabel item"); + } + String expectedFbaLabel = trim(item.getFbaCartonMark()); + if ("CARTON_LABEL".equals(operationType(item)) && StringUtils.hasText(expectedFbaLabel) + && !StringUtils.hasText(request.newLabel())) { + throw conflict("请扫描新 FBA 箱唛确认箱唛换标"); + } + if ("SKU_LABEL".equals(operationType(item)) && StringUtils.hasText(request.newLabel())) { + throw conflict("SKU 换标不能修改 FBA 箱唛"); + } + if (StringUtils.hasText(request.newLabel()) && StringUtils.hasText(expectedFbaLabel) + && !expectedFbaLabel.equalsIgnoreCase(request.newLabel().trim())) { + throw conflict("Scanned new label does not match the approved FBA carton mark"); + } + if (StringUtils.hasText(expectedFbaLabel) && request.oldLabel().trim().equalsIgnoreCase(expectedFbaLabel)) { + throw conflict("Old and new labels must be different"); + } + }); + } + + @Transactional + public PdaRelabelResponses.Detail scanCarton(String operatorKey, String operatorName, long warehouseId, + String relabelNo, PdaRelabelRequests.CartonScan request) { + RelabelApplication application = requireApplication(relabelNo, warehouseId, false); + RelabelItem item = requireItemByCartonMark(application, request.cartonMark()); + if ("SKU_LABEL".equals(operationType(item))) throw conflict("该箱为 SKU 换标,请进入 SKU 作业"); + if (!StringUtils.hasText(item.getFbaCartonMark())) throw conflict("该箱未维护对应 FBA 箱唛"); + return unitAction(operatorKey, operatorName, warehouseId, relabelNo, "SCAN_CARTON", request.idempotencyKey(), + request, item.getId(), item.getCartonMark(), item.getFbaCartonMark(), "RELABELLED", null, null, + request.expectedVersion(), current -> { + if (!Objects.equals(current.getCartonMark(), item.getCartonMark()) + || !Objects.equals(current.getFbaCartonMark(), item.getFbaCartonMark())) { + throw conflict("箱唛绑定已变化,请重新扫描"); + } + }); + } + + public record Document(String scope, String fileId, String fileName, Long itemId) { } + + @Transactional(readOnly = true) + public List documents(long warehouseId, String relabelNo) { + RelabelApplication app = requireApplication(relabelNo, warehouseId, false); + List result = new ArrayList<>(); + if (StringUtils.hasText(app.getCartonTotalFileId())) result.add(new Document("CARTON_TOTAL", app.getCartonTotalFileId(), app.getCartonTotalFileName(), null)); + if (StringUtils.hasText(app.getSkuTotalFileId())) result.add(new Document("SKU_TOTAL", app.getSkuTotalFileId(), app.getSkuTotalFileName(), null)); + for (RelabelItem item : itemList(app)) if (StringUtils.hasText(item.getRelabelFileId())) + result.add(new Document("ITEM", item.getRelabelFileId(), item.getRelabelFileName(), item.getId())); + return result; + } + + public com.baiyue.lastleg.relabel.gateway.RelabelDocumentFileGateway.DownloadedFile downloadDocument( + long warehouseId, String relabelNo, String scope, Long itemId, long fileId) { + RelabelApplication app = requireApplication(relabelNo, warehouseId, false); + return applicationService.downloadFile(app.getCustomerId(), relabelNo, scope, itemId, fileId); + } + + @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()); + } + + public RelabelPhotoFileGateway.DownloadedFile downloadWorkPhoto(String operatorKey, long warehouseId, + String relabelNo, long fileId) { + RelabelApplication application = requireApplication(relabelNo, warehouseId, false); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, false); + if (fileId <= 0) throw notFound("作业照片不存在"); + photoFiles.validate(application.getCustomerId(), task.getId(), fileId); + return photoFiles.download(application.getCustomerId(), task.getId(), fileId); + } + + @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); + boolean incomplete = detailItems.stream().anyMatch(item -> facts.stream() + .filter(fact -> item.getId().equals(fact.getItemId())).count() < value(item.getNotifiedPackageCount())); + if ((incomplete || request.manualCompletion()) && !StringUtils.hasText(request.remark())) + throw conflict("手动完成必须填写仓库异常作业备注"); + if (incomplete && !request.manualCompletion()) throw conflict("仍有未处理箱,请选择手动完成并填写作业备注"); + if (request.photoFileIds() == null || request.photoFileIds().isEmpty() || request.photoFileIds().size() > 20) throw conflict("完成换标必须上传 1 至 20 张作业照片"); + if (request.manualCompletion() && request.remark().trim().length() > 900) throw conflict("手动作业备注不能超过 900 字"); + 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"); + } + for (long i = actual; i < value(item.getNotifiedPackageCount()); i++) { + PdaRelabelUnitFact fact = new PdaRelabelUnitFact(); + fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId()); + fact.setItemId(item.getId()); fact.setCustomerId(application.getCustomerId()); + fact.setResult("EXCEPTION"); fact.setExceptionCode("MANUAL_COMPLETE"); + fact.setReason(request.remark()); fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); + fact.setOccurredAt(LocalDateTime.now()); unitFacts.insert(fact); + task.setExceptionPackages(value(task.getExceptionPackages()) + 1); + } + } + 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)); + if (request.manualCompletion()) { + var relatedOrders = tailOrders.selectList(new LambdaQueryWrapper() + .eq(com.baiyue.lastleg.entity.LastlegOrder::getCustomerId, application.getCustomerId()) + .eq(com.baiyue.lastleg.entity.LastlegOrder::getRelabelOrderNo, relabelNo)); + if (relatedOrders.isEmpty()) throw conflict("未找到关联尾程订单,无法登记异常完成"); + for (var order : relatedOrders) { + String note = "换标单 " + relabelNo + " 手动完成:" + request.remark().trim(); + exceptionOrders.create(application.getCustomerId(), operatorKey, operatorName, + new com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests.Create( + application.getCustomerId(), null, order.getOrderNo(), "OTHER", note, null, null)); + tailOrders.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(com.baiyue.lastleg.entity.LastlegOrder::getId, order.getId()) + .eq(com.baiyue.lastleg.entity.LastlegOrder::getCustomerId, application.getCustomerId()) + .set(com.baiyue.lastleg.entity.LastlegOrder::getAbnormal, true) + .set(com.baiyue.lastleg.entity.LastlegOrder::getWarehouseRemark, + StringUtils.hasText(order.getWarehouseRemark()) ? order.getWarehouseRemark() + "\n" + note : note)); + } + } + 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()), ""), + "manualCompletion", request.manualCompletion())); + applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey, + new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, request.remark())); + application.setStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING); + 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) { + throw conflict("复核由客户确认完成;需要重新贴标时请由平台填写备注退回"); + } + + @Transactional + public PdaRelabelResponses.Detail returnForRework(String operatorKey, String operatorName, String relabelNo, + PdaRelabelRequests.Review request) { + if (!"REWORK".equals(request.decision()) || !StringUtils.hasText(request.remark())) + throw badRequest("退回重新贴标必须填写原因"); + RelabelApplication scoped = applications.findAnyByRelabelNo(relabelNo); + if (scoped == null) throw notFound("换标单不存在"); + long overseasWarehouseId = Long.parseLong(scoped.getOverseasWarehouseId()); + 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 (!RelabelApplicationService.CUSTOMER_CONFIRM_PENDING.equals(application.getStatus()) + && !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()); + // Preserve the previous round before freeing the active-label uniqueness constraint. + List previousFacts = factList(task); + insertHistory(task, "REWORK_ARCHIVE", WAIT_REVIEW, PROCESSING, operatorKey, operatorName, + Map.of("facts", previousFacts, "photoFileIds", Objects.toString(task.getCompletionPhotoFileIds(), "[]"), + "completionRemark", Objects.toString(task.getCompletionRemark(), ""), "remark", request.remark())); + for (PdaRelabelUnitFact fact : previousFacts) { + unitFacts.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(PdaRelabelUnitFact::getId, fact.getId()).eq(PdaRelabelUnitFact::getTaskId, task.getId()) + .set(PdaRelabelUnitFact::getResult, "REWORK_ARCHIVED").set(PdaRelabelUnitFact::getOldLabel, null)); + } + task.setRelabelledPackages(0); task.setSkippedPackages(0); task.setExceptionPackages(0); + task.setCompletionPhotoFileIds("[]"); task.setCompletionRemark(null); task.setSubmittedAt(null); + applicationService.resetForRework(application.getCustomerId(), relabelNo, operatorKey, request.remark()); + application.setStatus(PROCESSING); + String before = task.getStatus(); + String after = 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()); + 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); + if ("CARTON_AND_SKU".equals(operationType(item)) && "RELABELLED".equals(resultCode)) { + String stage = "SCAN_CARTON".equals(action) ? "CARTON" : "SKU"; + if (mixedStageDone(task, itemId, stage)) throw conflict("本箱该项作业已完成,请勿重复扫描"); + boolean otherDone = mixedStageDone(task, itemId, "CARTON".equals(stage) ? "SKU" : "CARTON"); + if (factList(task).stream().anyMatch(fact -> itemId.equals(fact.getItemId()))) + throw conflict("本箱已经处理完成"); + if (unitFacts.findOldLabel(trim(oldLabel)) != null) throw conflict("该仓库唛头已被其他作业处理"); + insertHistory(task, "MIXED_" + stage, PROCESSING, PROCESSING, operatorKey, operatorName, + Map.of("itemId", itemId.toString(), "round", mixedRound(task), "oldLabel", oldLabel, + "stage", stage, "request", command)); + if (!otherDone) { + updateTask(task, expectedVersion); + PdaRelabelResponses.Detail partial = detail(application, task); + saveOperation(task, operatorKey, action, idempotencyKey, replayCommand, partial); + return partial; + } + } + 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()); + String resolvedNewLabel = trim(newLabel); + if ("RELABELLED".equals(resultCode) && !StringUtils.hasText(resolvedNewLabel)) { + resolvedNewLabel = trim(item.getFbaCartonMark()); + } + fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(resolvedNewLabel); 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) && !"SKU_LABEL".equals(operationType(item))) { + item.setNewCartonMark(resolvedNewLabel); + item.setFbaCartonMark(resolvedNewLabel); + items.updateById(item); + if (item.getReturnCartonId() != null && returnCartons != null) { + ReturnInventoryCarton carton = returnCartons.selectById(item.getReturnCartonId()); + if (carton != null && application.getCustomerId().equals(carton.getCustomerId())) { + carton.setNewCartonMark(resolvedNewLabel); + carton.setFbaCartonMark(resolvedNewLabel); + carton.setRelabelStatus(PROCESSING); + carton.setUpdatedAt(LocalDateTime.now()); + returnCartons.updateById(carton); + } + } + } + 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 RelabelItem requireItemByCartonMark(RelabelApplication application, String cartonMark) { + if (!StringUtils.hasText(cartonMark)) throw badRequest("Carton mark is required when itemId is omitted"); + List matches = items.selectList(new LambdaQueryWrapper() + .eq(RelabelItem::getApplicationId, application.getId()).eq(RelabelItem::getCustomerId, application.getCustomerId()) + .eq(RelabelItem::getCartonMark, cartonMark.trim())); + if (matches.size() != 1) throw notFound("Scanned carton mark does not belong to the current relabel application"); + return matches.get(0); + } + + 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() + .ne(PdaRelabelUnitFact::getResult, "REWORK_ARCHIVED") + .eq(PdaRelabelUnitFact::getCustomerId, task.getCustomerId()) + .eq(PdaRelabelUnitFact::getTaskId, task.getId()) + .orderByAsc(PdaRelabelUnitFact::getOccurredAt).orderByAsc(PdaRelabelUnitFact::getId)); + } + + private String mixedRound(PdaRelabelTask task) { + return Objects.toString(task.getReviewedAt(), "INITIAL"); + } + + private boolean mixedStageDone(PdaRelabelTask task, Long itemId, String stage) { + if (task == null) return false; + return histories.selectList(new LambdaQueryWrapper() + .eq(PdaRelabelHistory::getTaskId, task.getId()) + .eq(PdaRelabelHistory::getCustomerId, task.getCustomerId()) + .eq(PdaRelabelHistory::getAction, "MIXED_" + stage)).stream().anyMatch(history -> { + if (!("MIXED_" + stage).equals(history.getAction())) return false; + try { + var data = objectMapper.readTree(history.getDetailJson()); + return itemId.toString().equals(data.path("itemId").asText()) + && mixedRound(task).equals(data.path("round").asText()); + } catch (Exception invalid) { throw conflict("混合换标作业记录无法读取,请联系平台处理"); } + }); + } + + 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(), + operationType(application), 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(), + item.getCartonMark(), item.getFbaCartonMark(), operationType(item), readSkuItems(item.getSkuRelabelItemsJson()), + value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"), + count(itemFacts, "EXCEPTION"), + count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0), + count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0)); + }).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(), operationType(application), 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 List readSkuItems(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { + return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType( + List.class, PdaRelabelResponses.SkuItem.class)); + } catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel SKU details", exception); } + } + private static String operationType(RelabelItem item) { + return StringUtils.hasText(item.getOperationType()) ? item.getOperationType() : "CARTON_LABEL"; + } + + private String operationType(RelabelApplication application) { + List items = itemList(application); + boolean sku = items.stream().anyMatch(item -> Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))); + boolean carton = items.stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); + return sku && carton ? "CARTON_AND_SKU" : sku ? "SKU_LABEL" : "CARTON_LABEL"; + } + + private void validateSkuVerification(RelabelItem item, List scanned) { + if (!Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))) return; + List expected = readSkuItems(item.getSkuRelabelItemsJson()); + if (expected.isEmpty()) throw conflict("该箱缺少 SKU 换标及数量明细,请补齐后作业"); + List remaining = new java.util.ArrayList<>(scanned == null ? List.of() : scanned); + if (remaining.size() != expected.size()) throw conflict("请完成该箱全部 SKU 换标核对"); + for (PdaRelabelResponses.SkuItem sku : expected) { + boolean matched = remaining.remove(new PdaRelabelRequests.SkuVerification( + sku.skuCode(), sku.newSkuCode(), sku.quantity())); + if (!matched) throw conflict("扫描的旧 SKU、新 SKU 或数量与换标明细不一致"); + } + } + + 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..6bf25b776 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -0,0 +1,957 @@ +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.financefee.dto.OverseasFeeSourceDtos; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +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.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.gateway.RelabelDocumentFileGateway; +import org.springframework.web.multipart.MultipartFile; +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.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.wallet.WalletCommandOutboxService; +import com.baiyue.lastleg.wallet.WalletResultHandler; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.baiyue.common.event.WalletCommandCompletedV1; +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.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.HashSet; +import java.util.stream.Collectors; + +@Service +public class RelabelApplicationService implements WalletResultHandler { + public static final String PENDING_PROCESSING = "PENDING_PROCESSING"; + public static final String CUSTOMER_CONFIRM_PENDING = "CUSTOMER_CONFIRM_PENDING"; + public static final String READY_FOR_OUTBOUND = "READY_FOR_OUTBOUND"; + private static final Set STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING, + READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED"); + private static final Set DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "TRUCK_PICKUP", "EXPRESS_PICKUP", "PICKUP"); + + @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; + @Resource private WalletCommandOutboxService walletCommands; + @Resource private InventoryCommandService inventoryCommands; + @Resource private InventoryBalanceMapper inventoryBalances; + @Resource private InventoryPositionMapper inventoryPositions; + @Resource private RelabelDocumentFileGateway documentFiles; + @Resource private PdaRelabelTaskMapper pdaTasks; + @Resource private RelabelPhotoFileGateway photoFiles; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private OutboundPalletItemMapper outboundItems; + @Resource private OutboundPdaTaskMapper outboundTasks; + @Resource private OverseasFeeSourceService feeSources; + @Resource private WarehouseTransferOrderMapper warehouseTransfers; + + @Transactional(readOnly = true) + public String customerId(String relabelNo) { return require(null, relabelNo).getCustomerId(); } + + @Transactional + public RelabelResponses.Detail uploadTotalFile(String customerId, String relabelNo, long operatorId, MultipartFile file) { + return uploadTotalFile(customerId, relabelNo, operatorId, file, "TOTAL"); + } + + @Transactional + public RelabelResponses.Detail uploadTotalFile(String customerId, String relabelNo, long operatorId, MultipartFile file, String kind) { + RelabelApplication application = lockForDocument(customerId, relabelNo); + requirePdf(file); + String businessType = "SKU_TOTAL".equals(kind) ? "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT" : "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT"; + var uploaded = documentFiles.upload(application.getCustomerId(), businessType, String.valueOf(application.getId()), operatorId, file); + String fileId = String.valueOf(uploaded.fileId()); String url = documentUrl(relabelNo, uploaded.fileId(), null); + if ("SKU_TOTAL".equals(kind)) { application.setSkuTotalFileId(fileId); application.setSkuTotalFileName(uploaded.fileName()); application.setSkuTotalFileUrl(url); } + else { application.setCartonTotalFileId(fileId); application.setCartonTotalFileName(uploaded.fileName()); application.setCartonTotalFileUrl(url); } + application.setTotalFileId(fileId); application.setTotalFileName(uploaded.fileName()); application.setTotalFileUrl(url); application.setUpdatedAt(LocalDateTime.now()); + applications.updateById(application); + return detail(application.getCustomerId(), relabelNo); + } + + @Transactional + public RelabelResponses.Detail uploadItemFile(String customerId, String relabelNo, long itemId, long operatorId, MultipartFile file) { + RelabelApplication application = lockForDocument(customerId, relabelNo); + RelabelItem item = requireDocumentItem(application, itemId); + requirePdf(file); + var uploaded = documentFiles.upload(application.getCustomerId(), "OVERSEAS_RELABEL_ITEM_DOCUMENT", String.valueOf(item.getId()), operatorId, file); + item.setRelabelFileId(String.valueOf(uploaded.fileId())); item.setRelabelFileName(uploaded.fileName()); + item.setRelabelFileUrl(documentUrl(relabelNo, uploaded.fileId(), itemId)); + items.updateById(item); + application.setUpdatedAt(LocalDateTime.now()); applications.updateById(application); + return detail(application.getCustomerId(), relabelNo); + } + + @Transactional + public RelabelResponses.Detail uploadShippingLabel(String customerId, String relabelNo, long operatorId, + MultipartFile file) { + RelabelApplication application = lockForDocument(customerId, relabelNo); + if (!Boolean.TRUE.equals(application.getPrintShippingLabel())) { + throw conflict("This relabel order does not require a shipping label"); + } + requireShippingLabel(file); + var uploaded = documentFiles.upload(application.getCustomerId(), "OVERSEAS_RELABEL_SHIPPING_LABEL", + String.valueOf(application.getId()), operatorId, file); + application.setShippingLabelFileId(String.valueOf(uploaded.fileId())); + application.setShippingLabelFileName(uploaded.fileName()); + application.setShippingLabelFileUrl("/api/merchant/v1/last-leg/relabel-applications/" + + java.net.URLEncoder.encode(relabelNo, StandardCharsets.UTF_8) + "/shipping-label/" + + uploaded.fileId() + "/download"); + application.setUpdatedAt(LocalDateTime.now()); + applications.updateById(application); + return detail(application.getCustomerId(), relabelNo); + } + + @Transactional(readOnly = true) + public RelabelDocumentFileGateway.DownloadedFile downloadShippingLabel(String customerId, String relabelNo, + long fileId) { + RelabelApplication application = require(customerId, relabelNo); + if (!String.valueOf(fileId).equals(application.getShippingLabelFileId())) { + throw notFound("Shipping label does not belong to this relabel order"); + } + return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_SHIPPING_LABEL", + String.valueOf(application.getId()), fileId); + } + + @Transactional(readOnly = true) + public RelabelPhotoFileGateway.DownloadedFile downloadCompletionPhoto(String customerId, String relabelNo, + long fileId) { + RelabelApplication application = require(customerId, relabelNo); + PdaRelabelTask task = pdaTasks.find(application.getCustomerId(), application.getId()); + if (task == null || !readIds(task.getCompletionPhotoFileIds()).contains(fileId)) { + throw notFound("Completion photo does not belong to this relabel order"); + } + return photoFiles.download(application.getCustomerId(), task.getId(), fileId); + } + + @Transactional(readOnly = true) + public RelabelDocumentFileGateway.DownloadedFile downloadFile(String customerId, String relabelNo, String scope, Long itemId, long fileId) { + RelabelApplication application = require(customerId, relabelNo); + if(fileId <= 0) throw notFound("换标资料不存在"); + if ("ITEM".equalsIgnoreCase(scope)) { + if (itemId == null) throw badRequest("明细资料必须指定明细"); + RelabelItem item = requireDocumentItem(application, itemId); + if (!String.valueOf(fileId).equals(item.getRelabelFileId())) throw notFound("换标资料未绑定当前明细"); + return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_ITEM_DOCUMENT", String.valueOf(item.getId()), fileId); + } + if (itemId == null && ("CARTON_TOTAL".equalsIgnoreCase(scope) || "TOTAL".equalsIgnoreCase(scope)) + && String.valueOf(fileId).equals(application.getCartonTotalFileId())) { + return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT", String.valueOf(application.getId()), fileId); + } + if (itemId == null && ("SKU_TOTAL".equalsIgnoreCase(scope) || "TOTAL".equalsIgnoreCase(scope)) + && String.valueOf(fileId).equals(application.getSkuTotalFileId())) { + return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT", String.valueOf(application.getId()), fileId); + } + if (!"TOTAL".equalsIgnoreCase(scope) || itemId != null) throw badRequest("资料未绑定当前申请单"); + if (!String.valueOf(fileId).equals(application.getTotalFileId())) throw notFound("换标资料未绑定当前申请单"); + return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_TOTAL_DOCUMENT", String.valueOf(application.getId()), fileId); + } + + private RelabelApplication lockForDocument(String customerId, String relabelNo) { + if (!StringUtils.hasText(customerId)) throw badRequest("客户不能为空"); + RelabelApplication application = applications.lockNo(customerId, relabelNo); + if (application == null) throw notFound("Relabel application not found"); + if (!Set.of(PENDING_PROCESSING, "PROCESSING").contains(application.getStatus())) throw conflict("当前换标状态不允许修改资料"); + return application; + } + + private RelabelItem requireDocumentItem(RelabelApplication application, long itemId) { + RelabelItem item = itemId > 0 ? items.selectById(itemId) : null; + if (item == null || !Objects.equals(item.getApplicationId(), application.getId()) || !Objects.equals(item.getCustomerId(), application.getCustomerId())) throw notFound("换标明细不属于当前申请单"); + return item; + } + + private static void requirePdf(MultipartFile file) { + if (file == null || file.isEmpty() || file.getSize() > 10L * 1024 * 1024) throw badRequest("请上传不超过10MB的PDF文件"); + if (file.getOriginalFilename() == null || !file.getOriginalFilename().toLowerCase(java.util.Locale.ROOT).endsWith(".pdf") || !"application/pdf".equalsIgnoreCase(file.getContentType())) throw badRequest("换标资料仅支持PDF文件"); + try (var stream = file.getInputStream()) { + if (!java.util.Arrays.equals(stream.readNBytes(5), "%PDF-".getBytes(StandardCharsets.US_ASCII))) throw badRequest("文件内容不是PDF"); + } catch (java.io.IOException exception) { throw badRequest("无法读取PDF文件"); } + } + + private static void requireShippingLabel(MultipartFile file) { + if (file == null || file.isEmpty()) throw badRequest("Shipping label file is required"); + if (file.getSize() > 20L * 1024 * 1024) throw badRequest("Shipping label file exceeds 20 MB"); + String contentType = Objects.toString(file.getContentType(), "").toLowerCase(); + if (!(contentType.equals("application/pdf") || contentType.startsWith("image/"))) { + throw badRequest("Shipping label must be a PDF or image"); + } + } + + private static String documentUrl(String relabelNo, long fileId, Long itemId) { + return "/api/platform/v1/last-leg/relabel-applications/" + java.net.URLEncoder.encode(relabelNo, StandardCharsets.UTF_8) + "/files/" + fileId + "/download?scope=" + (itemId == null ? "TOTAL" : "ITEM&itemId=" + itemId); + } + + @Transactional(readOnly = true) + public List candidates(String customerId, String operationWarehouseId, + String inboundCode) { + if (!StringUtils.hasText(operationWarehouseId)) throw badRequest("Operation warehouse is required"); + List balances = inventoryBalances.selectList(new LambdaQueryWrapper() + .eq(InventoryBalance::getCustomerId, customerId) + .eq(InventoryBalance::getOverseasWarehouseId, operationWarehouseId.trim()) + .gt(InventoryBalance::getAvailablePackages, 0) + .like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode)) + .orderByDesc(InventoryBalance::getUpdatedAt)); + return balances.stream().map(balance -> { + List positions = inventoryPositions.listBalance(customerId, balance.getId()) + .stream().filter(position -> value(position.getAvailablePackages()) > 0) + .map(position -> new RelabelResponses.InventoryPosition(position.getId(), position.getPalletNo(), + position.getBatchNo(), position.getWarehouseLocationCode(), value(position.getAvailablePackages()))) + .toList(); + long available = positions.stream().mapToLong(RelabelResponses.InventoryPosition::availablePackages).sum(); + return new RelabelResponses.InventoryCandidate(balance.getId(), balance.getInboundOrderId(), + balance.getInboundOrderNo(), balance.getInboundCode(), balance.getOverseasWarehouseId(), + balance.getOverseasWarehouseCode(), balance.getOverseasWarehouseName(), + balance.getDestinationWarehouseCode(), balance.getDestinationWarehouseName(), balance.getSkuCode(), + balance.getProductNameZh(), available, value(balance.getVersion()), positions); + }).filter(candidate -> candidate.availablePackages() > 0).toList(); + } + + @Transactional(readOnly = true) + public List historicalOrders(String customerId, String operationWarehouseId, + String keyword) { + if (!StringUtils.hasText(operationWarehouseId)) throw badRequest("Operation warehouse is required"); + String q = StringUtils.hasText(keyword) ? keyword.trim() : ""; + List orders = this.orders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .eq(LastlegOrder::getOverseasWarehouseId, operationWarehouseId.trim()) + .and(w -> w.like(StringUtils.hasText(q), LastlegOrder::getOrderNo, q) + .or().like(StringUtils.hasText(q), LastlegOrder::getSourceOrderNo, q) + .or().like(StringUtils.hasText(q), LastlegOrder::getRelabelOrderNo, q) + .or().like(StringUtils.hasText(q), LastlegOrder::getOutboundOrderNo, q)) + .orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId) + .last("LIMIT 100")); + Map result = new java.util.LinkedHashMap<>(); + orders.forEach(order -> result.put("ORDER:" + order.getId(), new RelabelResponses.HistoricalOrder(order.getId(), + order.getOrderNo(), order.getSourceOrderNo(), order.getOutboundOrderNo(), order.getRelabelOrderNo(), + order.getOrderType(), order.getCurrentStatus(), order.getPackageCount(), order.getCreatedAt()))); + for (OutboundOrder outbound : outboundOrders.listRelabelOutbounds(customerId, operationWarehouseId.trim(), q)) { + result.putIfAbsent("OUTBOUND:" + outbound.getId(), new RelabelResponses.HistoricalOrder(null, + outbound.getOutboundNo(), null, outbound.getOutboundNo(), null, outbound.getOutboundType(), + outbound.getStatus(), outbound.getPackageCount(), outbound.getCreatedAt())); + } + return result.values().stream().limit(100).toList(); + } + @Transactional + public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) { + if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required"); + boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); + String deliveryMethod = trim(request.deliveryMethod()); + if (requiresOutbound && (!StringUtils.hasText(deliveryMethod) || !DELIVERY_METHODS.contains(deliveryMethod))) { + throw badRequest("Delivery method must be TRUCK, PICKUP or EXPRESS when carton labels are changed"); + } + if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) { + throw badRequest("SKU-only relabel does not create an outbound fulfillment"); + } + if (Boolean.TRUE.equals(request.printShippingLabel()) && !Set.of("EXPRESS", "EXPRESS_PICKUP").contains(deliveryMethod)) { + throw badRequest("Shipping-label printing is only available for express delivery"); + } + 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()); + } + int total = 0; + int notified = 0; + List sources = new java.util.ArrayList<>(); + Set selectedCartonMarks = new HashSet<>(); + for (RelabelRequests.Item item : request.items()) { + validateOperation(item); + 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()); + if (!selectedCartonMarks.add(item.cartonMark().trim().toUpperCase())) { + throw badRequest("The same carton mark cannot create more than one relabel task"); + } + InventoryPosition position = inventoryPositions.findTenantPosition(customerId, item.sourceInventoryPositionId()); + if (position == null || !Objects.equals(position.getBalanceId(), item.sourceInventoryBalanceId())) { + throw conflict("Selected inventory position is no longer available"); + } + InventoryBalance balance = inventoryBalances.selectById(item.sourceInventoryBalanceId()); + if (balance == null || !customerId.equals(balance.getCustomerId()) + || !request.operationWarehouseId().trim().equals(balance.getOverseasWarehouseId()) + || !Objects.equals(balance.getInboundOrderId(), item.sourceInboundOrderId())) { + throw conflict("Selected inventory does not belong to this customer, operation warehouse and inbound order"); + } + if (value(position.getAvailablePackages()) < item.notifiedPackageCount()) { + throw conflict("Available inventory changed for inbound " + balance.getInboundOrderNo()); + } + sources.add(new SourceInventory(item, balance)); + } + LocalDateTime now = LocalDateTime.now(); + long applicationId = IdWorker.getId(); + String operationWarehouseId = request.operationWarehouseId().trim(); + String relabelNo = orderNoGenerator.next("RELABEL", operationWarehouseId, applicationId); + BigDecimal chargeAmount = Objects.requireNonNullElse(request.chargeAmount(), BigDecimal.ZERO); + boolean requiresWallet = chargeAmount.signum() > 0; + int holdIndex = 0; + for (SourceInventory source : sources) { + inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( + source.request().sourceInventoryPositionId(), source.request().notifiedPackageCount(), relabelNo, + null, "RELABEL:" + relabelNo, "RELABEL:" + applicationId + ":HOLD:" + holdIndex++, + "贴标申请冻结原入仓单库存")); + } + LastlegOrder tailOrder = insertTailOrder(customerId, request, requestHash, relabelNo, + operationWarehouseId, total, now); + List tailOrders = List.of(tailOrder); + + RelabelApplication application = new RelabelApplication(); + application.setId(applicationId); + application.setCustomerId(customerId); + application.setRelabelNo(relabelNo); + application.setLastlegOrderId(tailOrders.get(0).getId()); + application.setCustomerOrderNo(request.customerOrderNo().trim()); + application.setOverseasWarehouseId(operationWarehouseId); + application.setLabelDeadline(request.labelDeadline()); + application.setContactName(request.contactName().trim()); + application.setContactPhone(request.contactPhone().trim()); + application.setDeliveryMethod(deliveryMethod); + application.setPrintShippingLabel(Boolean.TRUE.equals(request.printShippingLabel())); + application.setFulfillmentStatus(requiresOutbound ? "WAIT_RELABEL" : "NOT_REQUIRED"); + application.setRemark(trim(request.remark())); + application.setStatus(PENDING_PROCESSING); + application.setItemCount(request.items().size()); + application.setTotalPackageCount(total); + application.setNotifiedPackageCount(notified); + application.setChargeAmount(chargeAmount); + application.setChargeCurrency("CNY"); + application.setWalletStatus(requiresWallet ? "FREEZE_PENDING" : "NOT_REQUIRED"); + application.setIdempotencyKey(request.idempotencyKey().trim()); + application.setRequestHash(requestHash); + application.setCreatedBy(trim(operatorId)); + application.setCreatedAt(now); + application.setUpdatedAt(now); + applications.insert(application); + + if (requiresWallet) { + String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER", + relabelNo, chargeAmount, "换标订单费用冻结", 1L); + application.setWalletCommandEventId(walletEventId); + applications.updateById(application); + } + + for (SourceInventory source : sources) { + insertItem(customerId, applicationId, source, now); + } + insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now); + tailOrders.forEach(order -> 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"); + } + String requestedStatus = trim(query.status()); + 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.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId, + trim(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); + if (PENDING_PROCESSING.equals(requestedStatus)) { + wrapper.in(RelabelApplication::getStatus, PENDING_PROCESSING, CUSTOMER_CONFIRM_PENDING, + "REJECTED", "CANCELLED"); + } else if ("COMPLETED".equals(requestedStatus)) { + wrapper.in(RelabelApplication::getStatus, READY_FOR_OUTBOUND, "COMPLETED"); + } else if (StringUtils.hasText(requestedStatus)) { + wrapper.eq(RelabelApplication::getStatus, requestedStatus); + } + 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)); + List tailOrders = tailOrders(application); + PdaRelabelTask pdaTask = pdaTasks == null ? null : pdaTasks.find(recordCustomerId, application.getId()); + RelabelResponses.CompletionEvidence evidence = pdaTask == null ? null + : new RelabelResponses.CompletionEvidence(pdaTask.getId(), readIds(pdaTask.getCompletionPhotoFileIds()), + pdaTask.getCompletionRemark(), pdaTask.getReviewerName(), pdaTask.getReviewRemark(), + pdaTask.getReviewedAt()); + 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(), tailOrders.stream().map(value -> + new RelabelResponses.TailOrder(value.getId(), value.getOrderNo(), value.getOverseasWarehouseId(), + Objects.requireNonNullElse(value.getPackageCount(), 0), value.getCurrentStatus())).toList(), evidence); + } + + @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); + if ("PROCESSING".equals(next) + && !Set.of("FROZEN", "NOT_REQUIRED").contains(application.getWalletStatus())) { + throw conflict("Relabel funds must be frozen before processing"); + } + String before = application.getStatus(); + LocalDateTime now = LocalDateTime.now(); + application.setStatus(next); + application.setUpdatedAt(now); + applications.updateById(application); + List linkedOrders = tailOrders(application); + if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing"); + if (CUSTOMER_CONFIRM_PENDING.equals(next)) { + application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION"); + applications.updateById(application); + } else if ("COMPLETED".equals(next)) { + boolean preDispatched = "PRE_DISPATCH_CREATED".equals(application.getFulfillmentStatus()); + application.setFulfillmentStatus("OUTBOUNDED"); + if (!StringUtils.hasText(application.getOutboundOrderNo()) + && !preDispatched) { + releaseInventory(customerId, operatorId, application); + } + if (!"NOT_REQUIRED".equals(application.getWalletStatus())) { + BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0)); + feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo, + new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(), + "OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo, + application.getLastlegOrderId(), null, null, null, null, null, + parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE", + null, null, null, null, null, null, null, List.of(), LocalDate.now(), + application.getChargeCurrency(), application.getChargeAmount(), + quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null, + quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null, + null, null)); + walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null, + "换标完成,结算费用", 2L); + application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application); + } + } else if (Set.of("REJECTED", "CANCELLED").contains(next)) { + releaseInventory(customerId, operatorId, application); + if ("FROZEN".equals(application.getWalletStatus())) { + walletCommands.publish("RELEASE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null, + "换标取消,释放冻结", 2L); + application.setWalletStatus("RELEASE_PENDING"); applications.updateById(application); + } + } + for (LastlegOrder order : linkedOrders) { + String orderBefore = order.getCurrentStatus(); + String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore; + String orderStatus = switch (next) { + case CUSTOMER_CONFIRM_PENDING -> mainStatus; + 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(businessRelabelStatus(next)); + order.setUpdatedAt(now); + orders.updateById(order); + if (!Objects.equals(orderBefore, orderStatus)) { + insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now); + } + } + insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now); + return detail(customerId, relabelNo); + } + + @Transactional + public RelabelResponses.Detail retryWalletFreeze(String relabelNo, String operatorId, + RelabelRequests.WalletFreezeRetry request) { + RelabelApplication application = applications.lockAnyByRelabelNo(relabelNo); + if (application == null) throw notFound("Relabel application not found"); + String customerId = application.getCustomerId(); + if ("FROZEN".equals(application.getWalletStatus()) || "FREEZE_PENDING".equals(application.getWalletStatus())) { + return detail(customerId, relabelNo); + } + if (!PENDING_PROCESSING.equals(application.getStatus()) || !"FREEZE_FAILED".equals(application.getWalletStatus())) { + throw conflict("Only a pending relabel application with failed funds freeze can be retried"); + } + String retryKey = request.idempotencyKey().trim(); + long walletVersion = 100L + Long.parseLong(hash(retryKey).substring(0, 15), 16); + int holdIndex = 0; + for (RelabelItem item : itemList(application.getId())) { + if (item.getSourceInventoryPositionId() == null + || Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0) <= 0) continue; + inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( + item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), relabelNo, + null, "RELABEL:" + relabelNo, "RELABEL:" + application.getId() + ":RETRY_HOLD:" + + walletVersion + ":" + holdIndex++, "补款后重试冻结换标库存")); + } + String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER", + relabelNo, application.getChargeAmount(), trim(request.remark()), walletVersion); + application.setWalletStatus("FREEZE_PENDING"); + application.setWalletErrorCode(null); application.setWalletErrorMessage(null); + application.setWalletCommandEventId(walletEventId); application.setUpdatedAt(LocalDateTime.now()); + applications.updateWalletState(application.getId(), application.getWalletStatus(), null, null, walletEventId); + insertHistory(customerId, application.getId(), application.getStatus(), application.getStatus(), operatorId, + "Wallet freeze retry requested", LocalDateTime.now()); + return detail(customerId, relabelNo); + } + + @Transactional + public RelabelResponses.Detail retryWalletSettlement(String relabelNo, String operatorId, + RelabelRequests.WalletFreezeRetry request) { + RelabelApplication application = applications.lockAnyByRelabelNo(relabelNo); + if (application == null) throw notFound("Relabel application not found"); + String customerId = application.getCustomerId(); + if ("SETTLED".equals(application.getWalletStatus()) || "SETTLE_PENDING".equals(application.getWalletStatus())) { + return detail(customerId, relabelNo); + } + if (!"COMPLETED".equals(application.getStatus()) || !"SETTLE_FAILED".equals(application.getWalletStatus())) { + throw conflict("Only a completed relabel application with failed funds settlement can be retried"); + } + String retryKey = request.idempotencyKey().trim(); + long walletVersion = 200L + Long.parseLong(hash(retryKey).substring(0, 15), 16); + String walletEventId = walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", + relabelNo, null, trim(request.remark()), walletVersion); + applications.updateWalletState(application.getId(), "SETTLE_PENDING", null, null, walletEventId); + insertHistory(customerId, application.getId(), application.getStatus(), application.getStatus(), operatorId, + "Wallet settlement retry requested", LocalDateTime.now()); + return detail(customerId, relabelNo); + } + + @Transactional + public void resetForRework(String customerId, String relabelNo, String operatorId, String remark) { + RelabelApplication application = applications.lockNo(customerId, relabelNo); + if (application == null || !Set.of(CUSTOMER_CONFIRM_PENDING, "PROCESSING").contains(application.getStatus()) + || application.getCustomerConfirmedAt() != null) throw conflict("客户已确认或当前状态不允许退回"); + String before = application.getStatus(); + application.setStatus("PROCESSING"); application.setFulfillmentStatus("WAIT_RELABEL"); + application.setUpdatedAt(LocalDateTime.now()); applications.updateById(application); + for (LastlegOrder order : tailOrders(application)) { + order.setRelabelStatus("PROCESSING"); order.setSubOrderStatus("RELABEL:PROCESSING"); + order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order); + } + insertHistory(customerId, application.getId(), before, "PROCESSING", operatorId, remark, LocalDateTime.now()); + } + + @Transactional + public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId, + RelabelRequests.CustomerConfirm request) { + RelabelApplication application = applications.lockNo(customerId, relabelNo); + if (application == null) throw notFound("Relabel application not found"); + String key = request.idempotencyKey().trim(); + if (key.equals(application.getCustomerConfirmationKey())) return detail(customerId, relabelNo); + if (!CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())) { + throw conflict("Relabel order is not awaiting customer confirmation"); + } + PdaRelabelTask reviewTask = pdaTasks.lock(customerId, application.getId()); + if (reviewTask == null || !Set.of("WAIT_REVIEW", "COMPLETED").contains(reviewTask.getStatus())) + throw conflict("仓库尚未提交换标完工复核"); + reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CUSTOMER_CONFIRMED"); + reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认"); + reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now()); + reviewTask.setUpdatedAt(LocalDateTime.now()); + if (pdaTasks.updateWithVersion(reviewTask, reviewTask.getVersion()) != 1) throw conflict("复核状态已变化,请刷新"); + if (Boolean.TRUE.equals(application.getPrintShippingLabel()) + && !StringUtils.hasText(application.getShippingLabelFileId())) { + throw conflict("Upload the shipping label before confirming relabel completion"); + } + LocalDateTime now = LocalDateTime.now(); + String before = application.getStatus(); + application.setStatus(READY_FOR_OUTBOUND); + application.setCustomerConfirmationKey(key); + application.setCustomerConfirmedAt(now); + application.setFulfillmentStatus("TRUCK".equals(application.getDeliveryMethod()) + ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION"); + if (!"TRUCK".equals(application.getDeliveryMethod())) { + createDirectOutbound(application, operatorId, now); + application.setFulfillmentStatus("OUTBOUND_CREATED"); + } + application.setUpdatedAt(now); + applications.updateById(application); + for (LastlegOrder order : tailOrders(application)) { + String orderBefore = order.getCurrentStatus(); + order.setCurrentStatus("PENDING_ASSIGNMENT"); + order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus()); + order.setRelabelStatus("COMPLETED"); + order.setUpdatedAt(now); + orders.updateById(order); + if (!Objects.equals(orderBefore, order.getCurrentStatus())) { + insertOrderHistory(customerId, order.getId(), orderBefore, order.getCurrentStatus(), + request.remark(), now); + } + } + insertHistory(customerId, application.getId(), before, READY_FOR_OUTBOUND, operatorId, + request.remark(), now); + // 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。 + if (warehouseTransfers != null) { + WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo); + if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) { + transfer.setStatus("READY_FOR_DISPATCH"); + transfer.setRelabelCompletedAt(now); + transfer.setUpdatedAt(now); + warehouseTransfers.updateById(transfer); + // 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。 + LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId()); + if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) { + String transferOrderBefore = transferOrder.getCurrentStatus(); + transferOrder.setCurrentStatus("READY_FOR_DISPATCH"); + transferOrder.setRelabelStatus("COMPLETED"); + transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:READY_FOR_DISPATCH;RELABEL:" + + "COMPLETED"); + transferOrder.setUpdatedAt(now); + orders.updateById(transferOrder); + if (!Objects.equals(transferOrderBefore, transferOrder.getCurrentStatus())) { + insertOrderHistory(customerId, transferOrder.getId(), transferOrderBefore, + transferOrder.getCurrentStatus(), "Warehouse transfer relabel completed", now); + } + } + } + } + return detail(customerId, relabelNo); + } + + private void createDirectOutbound(RelabelApplication application, String operatorId, LocalDateTime now) { + String key = "RELABEL:" + application.getId() + ":OUTBOUND"; + OutboundOrder existing = outboundOrders.findByIdempotency(application.getCustomerId(), key); + if (existing != null) { + application.setOutboundOrderNo(existing.getOutboundNo()); + return; + } + List relabelItems = itemList(application.getId()); + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId()); + outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); + outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND"); + outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> intValue(item.getNotifiedPackageCount())).sum()); + outbound.setPalletCount((int) relabelItems.stream().map(RelabelItem::getSourceInventoryPositionId).filter(Objects::nonNull).distinct().count()); + outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key); + outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); + Map> byPosition = relabelItems.stream().collect(Collectors.groupingBy( + RelabelItem::getSourceInventoryPositionId, LinkedHashMap::new, Collectors.toList())); + for (Map.Entry> entry : byPosition.entrySet()) { + InventoryPosition position = inventoryPositions.selectById(entry.getKey()); + if (position == null || !application.getCustomerId().equals(position.getCustomerId())) { + throw conflict("Relabel source inventory position no longer exists"); + } + List group = entry.getValue(); + OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); + item.setCustomerId(application.getCustomerId()); item.setOutboundOrderId(outbound.getId()); + item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); + item.setPackageCount(group.stream().mapToInt(value -> intValue(value.getNotifiedPackageCount())).sum()); + item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); + item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); + item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode) + .filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); + item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); + item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), entry.getKey(), group)); + item.setCreatedAt(now); outboundItems.insert(item); + } + OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId()); + task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); + task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); outboundTasks.insert(task); + application.setOutboundOrderNo(outbound.getOutboundNo()); + } + + private String writeOutboundRefs(String relabelNo, Long positionId, List values) { + try { + return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo, + "sourceInventoryPositionId", positionId, "items", values.stream().map(item -> Map.of( + "itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""), + "destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""), + "packages", intValue(item.getNotifiedPackageCount()))).toList())); + } catch (Exception exception) { throw new IllegalStateException("Cannot save relabel outbound snapshot", exception); } + } + + private void insertItem(String customerId, long applicationId, SourceInventory sourceInventory, + LocalDateTime now) { + RelabelRequests.Item source = sourceInventory.request(); + InventoryBalance balance = sourceInventory.balance(); + RelabelItem item = new RelabelItem(); + item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId); + item.setSkuCode(trim(source.skuCode())); 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(trim(source.newSkuCode())); + item.setLabelRequirement(trim(source.labelRequirement())); item.setRemark(trim(source.remark())); item.setRelabelFileId(trim(source.relabelFileId())); + item.setRelabelFileName(trim(source.relabelFileName())); item.setRelabelFileUrl(trim(source.relabelFileUrl())); + item.setCartonMark(source.cartonMark().trim()); item.setFbaCartonMark(trim(source.fbaCartonMark())); + item.setDestinationWarehouseId(trim(source.destinationFbaWarehouseCode())); + item.setDestinationFbaWarehouseCode(trim(source.destinationFbaWarehouseCode())); + item.setOperationType(operationType(source)); + item.setSkuRelabelItemsJson(writeSkuItems(source.skuItems())); + item.setSourceInventoryBalanceId(balance.getId()); + item.setSourceInventoryPositionId(source.sourceInventoryPositionId()); + item.setSourceInboundOrderId(balance.getInboundOrderId()); + item.setSourceInboundOrderNo(balance.getInboundOrderNo()); + item.setSourceInboundCode(balance.getInboundCode()); + item.setSourceWarehouseId(balance.getOverseasWarehouseId()); + item.setSourceWarehouseCode(balance.getOverseasWarehouseCode()); + item.setSourceWarehouseName(balance.getOverseasWarehouseName()); + 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.getTotalFileId(), value.getTotalFileName(), value.getTotalFileUrl(), + value.getCartonTotalFileId(), value.getCartonTotalFileName(), value.getCartonTotalFileUrl(), + value.getSkuTotalFileId(), value.getSkuTotalFileName(), value.getSkuTotalFileUrl(), + value.getChargeAmount(), value.getChargeCurrency(), value.getWalletStatus(), value.getWalletErrorMessage(), + value.getDeliveryMethod(), Boolean.TRUE.equals(value.getPrintShippingLabel()), + value.getShippingLabelFileId(), value.getShippingLabelFileName(), value.getShippingLabelFileUrl(), + value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(), + value.getCreatedAt()); + } + + @Override public boolean supports(String businessType) { return "RELABEL_ORDER".equals(businessType); } + + @Override + @Transactional + public void handle(WalletCommandCompletedV1 result) { + RelabelApplication value = applications.findNo(String.valueOf(result.customerId()), result.businessNo()); + if (value == null) throw notFound("Relabel application not found for wallet result"); + if ("SUCCEEDED".equals(result.result())) { + value.setWalletStatus(switch (result.operation()) { case "FREEZE" -> "FROZEN"; case "SETTLE" -> "SETTLED"; case "RELEASE" -> "RELEASED"; default -> result.holdStatus(); }); + value.setWalletErrorCode(null); value.setWalletErrorMessage(null); + if ("FREEZE".equals(result.operation()) && Set.of("REJECTED", "CANCELLED").contains(value.getStatus())) { + walletCommands.publish("RELEASE", result.customerId(), "RELABEL_ORDER", value.getRelabelNo(), null, + "换标已取消,冻结成功后立即释放", 2L); + value.setWalletStatus("RELEASE_PENDING"); + } + } else { + value.setWalletStatus(result.operation() + "_FAILED"); value.setWalletErrorCode(result.errorCode()); + value.setWalletErrorMessage(result.errorMessage()); + if ("FREEZE".equals(result.operation())) { + releaseInventory(value.getCustomerId(), "WALLET_CALLBACK", value); + } + } + applications.updateWalletState(value.getId(), value.getWalletStatus(), value.getWalletErrorCode(), + value.getWalletErrorMessage(), null); + } + + 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.getRemark(), value.getRelabelFileId(), + value.getRelabelFileName(), value.getRelabelFileUrl(), value.getCartonMark(), value.getFbaCartonMark(), + StringUtils.hasText(value.getDestinationFbaWarehouseCode()) ? value.getDestinationFbaWarehouseCode() : value.getDestinationWarehouseId(), + operationType(value), readSkuItems(value.getSkuRelabelItemsJson()), + value.getSourceInventoryBalanceId(), value.getSourceInventoryPositionId(), value.getSourceInboundOrderId(), + value.getSourceInboundOrderNo(), value.getSourceInboundCode(), value.getSourceWarehouseId(), + value.getSourceWarehouseCode(), value.getSourceWarehouseName()); + } + + private LastlegOrder insertTailOrder(String customerId, RelabelRequests.Create request, String requestHash, + String relabelNo, String warehouseId, int packageCount, + LocalDateTime now) { + String customerOrderNo = request.customerOrderNo().trim(); + LastlegOrder existingReturn = orders.selectOne(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOrderType, "RETURN_RELABEL") + .and(w -> w.eq(LastlegOrder::getSourceOrderNo, customerOrderNo).or().eq(LastlegOrder::getOrderNo, customerOrderNo)) + .last("LIMIT 1")); + if (existingReturn != null) { + existingReturn.setRelabelOrderNo(relabelNo); existingReturn.setRelabelStatus(PENDING_PROCESSING); + existingReturn.setSubOrderStatus("RETURN_RELABEL:" + PENDING_PROCESSING); existingReturn.setPackageCount(packageCount); + existingReturn.setUpdatedAt(now); orders.updateById(existingReturn); return existingReturn; + } + LastlegOrder order = new LastlegOrder(); + order.setId(IdWorker.getId()); + order.setOrderNo(orderNoGenerator.next("RELABEL", warehouseId, order.getId())); + order.setCustomerId(customerId); order.setOrderType("RELABEL_APPLICATION"); + order.setInboundCode(null); order.setSourceRegion("CUSTOMER"); + order.setSourceInboundId(relabelNo + ":" + warehouseId); + order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId); + order.setPalletCount(0); order.setPackageCount(packageCount); 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 + ":" + warehouseId); + order.setSourceLoadingPlanId("RELABEL_APPLICATION"); order.setSourcePayloadHash("sha256:" + requestHash); + order.setCreatedAt(now); order.setUpdatedAt(now); orders.insert(order); + return order; + } + + private List tailOrders(RelabelApplication application) { + List linked = orders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, application.getCustomerId()) + .eq(LastlegOrder::getRelabelOrderNo, application.getRelabelNo()) + .orderByAsc(LastlegOrder::getId)); + if (!linked.isEmpty()) return linked; + LastlegOrder legacy = orders.selectById(application.getLastlegOrderId()); + return legacy == null ? List.of() : List.of(legacy); + } + + private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance) {} + private static long value(Long value) { return value == null ? 0L : value; } + private static int intValue(Integer value) { return value == null ? 0 : value; } + + private void releaseInventory(String customerId, String operatorId, RelabelApplication application) { + for (RelabelItem item : itemList(application.getId())) { + if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null) continue; + inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move( + item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), application.getRelabelNo(), null, + "RELABEL:" + application.getRelabelNo(), application.getIdempotencyKey() + ":RELEASE:" + + item.getSourceInventoryPositionId(), "贴标结束释放库存至原入仓单")); + } + } + + private List itemList(Long applicationId) { + return items.selectList(new LambdaQueryWrapper().eq(RelabelItem::getApplicationId, applicationId) + .orderByAsc(RelabelItem::getId)); + } + private void validateOperation(RelabelRequests.Item item) { + String type = operationType(item); + if (!Set.of("CARTON_LABEL", "SKU_LABEL", "CARTON_AND_SKU").contains(type)) { + throw badRequest("Unsupported relabel operation type"); + } + if (item.notifiedPackageCount() > item.totalPackageCount()) { + throw badRequest("Notified relocation packages cannot exceed total packages for carton " + item.cartonMark()); + } + if (("CARTON_LABEL".equals(type) || "CARTON_AND_SKU".equals(type)) + && (!StringUtils.hasText(item.fbaCartonMark()) || !StringUtils.hasText(item.destinationFbaWarehouseCode()))) { + throw badRequest("Carton label changes require both FBA carton mark and destination warehouse"); + } + if (("SKU_LABEL".equals(type) || "CARTON_AND_SKU".equals(type)) + && (item.skuItems() == null || item.skuItems().isEmpty())) { + throw badRequest("SKU label changes require at least one SKU detail"); + } + } + private static String operationType(RelabelRequests.Item item) { + return StringUtils.hasText(item.operationType()) ? item.operationType().trim().toUpperCase() : "CARTON_LABEL"; + } + private static String operationType(RelabelItem item) { + return StringUtils.hasText(item.getOperationType()) ? item.getOperationType() : "CARTON_LABEL"; + } + private String writeSkuItems(List values) { + if (values == null || values.isEmpty()) return null; + try { return objectMapper.writeValueAsString(values); } + catch (Exception exception) { throw new IllegalStateException("Cannot save relabel SKU details", exception); } + } + private List readSkuItems(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { + return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType( + List.class, RelabelResponses.SkuItem.class)); + } catch (Exception exception) { throw new IllegalStateException("Cannot read relabel SKU details", 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 (Exception exception) { throw conflict("Relabel completion photo snapshot is invalid"); } + } + 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(CUSTOMER_CONFIRM_PENDING, "REJECTED", "CANCELLED").contains(to) + || CUSTOMER_CONFIRM_PENDING.equals(from) && Set.of(READY_FOR_OUTBOUND, "REJECTED", "CANCELLED").contains(to) + || READY_FOR_OUTBOUND.equals(from) && Set.of("COMPLETED", "CANCELLED").contains(to); + if (!allowed) throw conflict("Relabel status cannot change from " + from + " to " + to); + } + + private static String businessRelabelStatus(String workflowStatus) { + if ("PROCESSING".equals(workflowStatus)) return "PROCESSING"; + return Set.of(READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus) + ? "COMPLETED" : PENDING_PROCESSING; + } + 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 Long parseLong(String value) { + try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; } + catch (NumberFormatException ignored) { return null; } + } + 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/relabel/service/RelabelPalletMutationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMutationService.java new file mode 100644 index 000000000..cff188fa9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMutationService.java @@ -0,0 +1,129 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +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.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Keeps relabel ownership and its direct-outbound snapshot aligned after a physical pallet split. */ +@Service +public class RelabelPalletMutationService { + @Resource private RelabelItemMapper relabelItems; + @Resource private InventoryPositionMapper positions; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private OutboundPalletItemMapper outboundItems; + @Resource private ObjectMapper objectMapper; + + public void moveSelectedItems(RelabelApplication application, + Map> itemsBySourcePosition, + Map targetBySourcePosition) { + for (Map.Entry> entry : itemsBySourcePosition.entrySet()) { + InventoryPosition target = targetBySourcePosition.get(entry.getKey()); + if (target == null) throw conflict("换标拆托目标库存位置不存在"); + for (RelabelItem item : entry.getValue()) { + item.setSourceInventoryPositionId(target.getId()); + relabelItems.updateById(item); + } + } + syncDirectOutbound(application, itemsBySourcePosition, targetBySourcePosition); + } + + private void syncDirectOutbound(RelabelApplication application, + Map> itemsBySourcePosition, + Map targetBySourcePosition) { + if (application.getOutboundOrderNo() == null || application.getOutboundOrderNo().isBlank()) return; + OutboundOrder outbound = outboundOrders.lockByNo(application.getCustomerId(), application.getOutboundOrderNo()); + if (outbound == null || !"RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { + throw conflict("换标出库单不存在或类型不正确"); + } + if (!"WAIT_PICK".equals(outbound.getStatus())) throw conflict("换标出库已开始拣货,不能再拆托"); + List rows = new ArrayList<>(outboundItems.findByOutbound( + application.getCustomerId(), outbound.getId())); + for (Map.Entry> entry : itemsBySourcePosition.entrySet()) { + List selected = entry.getValue(); + LinkedHashSet selectedMarks = selected.stream().map(RelabelItem::getCartonMark) + .filter(Objects::nonNull).map(String::trim) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + long selectedPackages = selected.stream().mapToLong(item -> value(item.getNotifiedPackageCount())).sum(); + OutboundPalletItem sourceRow = rows.stream().filter(row -> value(row.getPickedPackageCount()) == 0) + .filter(row -> cartonMarks(row).containsAll(selectedMarks)).findFirst() + .orElseThrow(() -> conflict("换标出库箱唛快照与拆托任务不一致")); + LinkedHashSet remaining = cartonMarks(sourceRow); + remaining.removeAll(selectedMarks); + InventoryPosition target = targetBySourcePosition.get(entry.getKey()); + if (remaining.isEmpty()) { + sourceRow.setWarehousePalletId(target.getPalletId()); sourceRow.setPalletNo(target.getPalletNo()); + sourceRow.setOrderRefs(outboundRefs(application.getRelabelNo(), target.getId(), selected)); + outboundItems.updateById(sourceRow); + continue; + } + int remainingPackages = value(sourceRow.getPackageCount()) - Math.toIntExact(selectedPackages); + if (remainingPackages <= 0) throw conflict("换标出库剩余箱唛与箱数快照不一致"); + sourceRow.setPackageCount(remainingPackages); sourceRow.setCartonMarks(String.join(",", remaining)); + List remainingItems = relabelItems.selectList(new LambdaQueryWrapper() + .eq(RelabelItem::getApplicationId, application.getId()) + .eq(RelabelItem::getCustomerId, application.getCustomerId()) + .eq(RelabelItem::getSourceInventoryPositionId, entry.getKey())); + sourceRow.setOrderRefs(outboundRefs(application.getRelabelNo(), entry.getKey(), remainingItems)); + outboundItems.updateById(sourceRow); + + OutboundPalletItem targetRow = new OutboundPalletItem(); + targetRow.setId(IdWorker.getId()); targetRow.setCustomerId(sourceRow.getCustomerId()); + targetRow.setOutboundOrderId(sourceRow.getOutboundOrderId()); targetRow.setWarehousePalletId(target.getPalletId()); + targetRow.setPalletNo(target.getPalletNo()); targetRow.setDestinationWarehouseId(sourceRow.getDestinationWarehouseId()); + targetRow.setDestinationWarehouseCode(sourceRow.getDestinationWarehouseCode()); + targetRow.setDestinationWarehouseName(sourceRow.getDestinationWarehouseName()); + targetRow.setPackageCount(Math.toIntExact(selectedPackages)); targetRow.setPickedPackageCount(0); + targetRow.setStatus("WAIT_PICK"); targetRow.setSkuCode(sourceRow.getSkuCode()); + targetRow.setCartonMarks(String.join(",", selectedMarks)); targetRow.setInboundCode(sourceRow.getInboundCode()); + targetRow.setOrderRefs(outboundRefs(application.getRelabelNo(), target.getId(), selected)); + targetRow.setCreatedAt(LocalDateTime.now()); outboundItems.insert(targetRow); rows.add(targetRow); + } + outbound.setPalletCount((int) rows.stream().map(OutboundPalletItem::getWarehousePalletId) + .filter(Objects::nonNull).distinct().count()); + outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound); + } + + private static LinkedHashSet cartonMarks(OutboundPalletItem item) { + LinkedHashSet result = new LinkedHashSet<>(); + if (item.getCartonMarks() == null) return result; + for (String value : item.getCartonMarks().split(",")) if (!value.isBlank()) result.add(value.trim()); + return result; + } + + private String outboundRefs(String relabelNo, Long positionId, List items) { + try { + return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo, + "sourceInventoryPositionId", positionId, "items", items.stream().map(item -> Map.of( + "itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""), + "destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""), + "packages", value(item.getNotifiedPackageCount()))).toList())); + } catch (Exception exception) { + throw new IllegalStateException("无法保存换标出库托盘快照", exception); + } + } + + private static int 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/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java new file mode 100644 index 000000000..279b9e728 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -0,0 +1,89 @@ +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 { + private static final List OVERSEAS_TO_CN_TYPES = List.of( + EventTypes.OVERSEAS_CONTAINER_UNLOADED, + EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, + EventTypes.OVERSEAS_DELIVERY_COMPLETED, + EventTypes.WALLET_COMMAND_REQUESTED); + @Resource private LastlegEventOutboxMapper mapper; + + public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + return mapper.selectList(new LambdaQueryWrapper() + .in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES) + .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt) + .or().le(LastlegEventOutbox::getNextRetryAt, now)) + .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") + .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) + .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) + .in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES) + .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt) + .or().le(LastlegEventOutbox::getNextRetryAt, now)) + .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") + .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) + .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) + .in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES) + .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) + .in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES) + .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/returnreceipt/controller/MerchantReturnInventoryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnInventoryController.java new file mode 100644 index 000000000..0713b49bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnInventoryController.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.returnreceipt.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.returnreceipt.dto.ReturnInventoryRequests; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses; +import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +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.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/return-inventory") +public class MerchantReturnInventoryController { + public static final String LIST_PERMISSION = "merchant:overseas-warehouse:return-inventory:list"; + @Resource private ReturnInventoryService service; + + @GetMapping + @PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')") + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid ReturnInventoryRequests.Query request) { + return ApiResponse.success(service.page(customer(user), request)); + } + + @GetMapping("/summary") + @PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')") + public ApiResponse> summary(@AuthenticationPrincipal AuthenticatedUser user, + @Valid ReturnInventoryRequests.SummaryQuery request) { + return ApiResponse.success(service.summaryPage(customer(user), request)); + } + + @GetMapping("/cartons") + @PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')") + public ApiResponse> cartons(@AuthenticationPrincipal AuthenticatedUser user, + @Valid ReturnInventoryRequests.Query request) { + return ApiResponse.success(service.cartonPage(customer(user), request)); + } + + @GetMapping("/cartons/{cartonMark}") + @PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')") + public ApiResponse carton(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String cartonMark) { + return ApiResponse.success(service.carton(customer(user), cartonMark)); + } + + @GetMapping("/cartons/{cartonMark}/label") + @PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')") + public ApiResponse label(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String cartonMark) { + return ApiResponse.success(service.label(customer(user), cartonMark)); + } + + 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/returnreceipt/controller/MerchantReturnOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java new file mode 100644 index 000000000..b014751d4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.returnreceipt.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.returnreceipt.dto.ReturnOrderRequests; +import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses; +import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService; +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/return-orders") +public class MerchantReturnOrderController { + @Resource private ReturnReceiptService service; + @PostMapping public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } + @GetMapping public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } + @GetMapping("/{returnNo}") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String returnNo) { return ApiResponse.success(service.detail(customer(user), returnNo)); } + @GetMapping("/{returnNo}/relabel-candidates") public ApiResponse> relabelCandidates( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo) { + return ApiResponse.success(service.relabelCandidates(customer(user), returnNo)); + } + @PostMapping("/{returnNo}/relabel") public ApiResponse createRelabel( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo, + @Valid @RequestBody ReturnOrderRequests.RelabelCreate request) { + return ApiResponse.success(service.createRelabel(customer(user), operator(user), returnNo, 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/returnreceipt/controller/PdaReturnInventoryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java new file mode 100644 index 000000000..98a8da122 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.returnreceipt.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses; +import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +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/return-inventory") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +public class PdaReturnInventoryController { + @Resource private ReturnInventoryService service; + + @GetMapping("/packing/orders") + public ApiResponse packingOrders(@AuthenticationPrincipal AuthenticatedUser user, + @Valid ReturnInventoryRequests.PackingQuery request) { + return ApiResponse.success(service.packingPreview(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/cartons/merge") + public ApiResponse merge(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReturnInventoryRequests.Merge request) { + return ApiResponse.success(service.merge(PdaWarehouseIdentity.require(user), request)); + } + + @GetMapping("/cartons/{cartonMark}/label") + public ApiResponse label(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String cartonMark) { + return ApiResponse.success(service.label(PdaWarehouseIdentity.require(user), cartonMark)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnReceiptController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnReceiptController.java new file mode 100644 index 000000000..4b348547e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnReceiptController.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.returnreceipt.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests; +import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptResponses; +import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService; +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.*; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/return-receipts") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +public class PdaReturnReceiptController { + @Resource private ReturnReceiptService service; + @PostMapping("/start") public ApiResponse start(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaReturnReceiptRequests.Start request) { + return ApiResponse.success(service.start(PdaWarehouseIdentity.require(user), request)); + } + @PostMapping("/sku-candidates") public ApiResponse skuCandidates(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaReturnReceiptRequests.SkuLookup request) { + return ApiResponse.success(service.skuCandidates(PdaWarehouseIdentity.require(user), request)); + } + @PostMapping("/confirm") public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaReturnReceiptRequests.Confirm request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.confirm(identity, request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnInventoryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnInventoryController.java new file mode 100644 index 000000000..58e99a99e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnInventoryController.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.returnreceipt.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses; +import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/return-inventory") +public class PlatformReturnInventoryController { + @Resource private ReturnInventoryService service; + + @GetMapping + @PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list')") + public ApiResponse> stock(@Valid ReturnInventoryRequests.PlatformQuery request) { + return ApiResponse.success(service.platformStockPage(request)); + } + + @GetMapping("/cartons") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:return-cartons:list')") + public ApiResponse> cartons(@Valid ReturnInventoryRequests.PlatformQuery request) { + return ApiResponse.success(service.platformCartonPage(request)); + } + + @GetMapping("/movements") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list')") + public ApiResponse> movements(@Valid ReturnInventoryRequests.PlatformQuery request) { + return ApiResponse.success(service.platformMovementPage(request)); + } + + @GetMapping("/cartons/{cartonMark}") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:return-cartons:list')") + public ApiResponse carton(@PathVariable String cartonMark) { + return ApiResponse.success(service.platformCarton(cartonMark)); + } + + @GetMapping("/cartons/{cartonMark}/label") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:return-cartons:list')") + public ApiResponse label(@PathVariable String cartonMark) { + return ApiResponse.success(service.platformLabel(cartonMark)); + } + +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java new file mode 100644 index 000000000..4952832b6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.returnreceipt.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public final class PdaReturnReceiptRequests { + private PdaReturnReceiptRequests() { } + public record Start(@NotBlank @Size(max = 128) String returnNo) { } + public record SkuLookup(@NotBlank @Size(max = 128) String skuCode) { } + public record Confirm(@NotBlank @Size(max = 128) String returnNo, + @Size(max = 128) String trackingNo, + @NotBlank @Size(max = 16) String startedBy, + @Size(max = 128) String cartonMark, + @NotEmpty @Size(max = 500) List<@Valid Item> items, + @NotBlank @Size(max = 255) String idempotencyKey) { } + public record Item(@NotBlank @Size(max = 128) String skuCode, + @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class) + @Positive long receivedQuantity) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptResponses.java new file mode 100644 index 000000000..eecb4b1c9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptResponses.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.returnreceipt.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaReturnReceiptResponses { + private PdaReturnReceiptResponses() { } + public record Start(String returnNo, String status, List expectedItems) { } + public record SkuCandidates(String skuCode, List candidates) { } + public record Candidate(String returnNo, String customerId, String customerReferenceNo, String status, + long skuExpectedQuantity, long skuReceivedQuantity, long orderExpectedQuantity, + long orderReceivedQuantity) { } + public record Item(String skuCode, String productNameZh, long expectedQuantity, long receivedQuantity) { } + public record Confirmed(String returnNo, String receiptNo, String receiptBatchNo, String cartonMark, String orderStatus, + boolean fullyMatched, long expectedQuantity, long receivedQuantity, + LocalDateTime signedAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java new file mode 100644 index 000000000..693bf18f7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.returnreceipt.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.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import jakarta.validation.Valid; + +import java.math.BigDecimal; +import java.util.List; + +public final class ReturnInventoryRequests { + private ReturnInventoryRequests() { } + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String overseasWarehouseId, + String skuCode, String cartonMark, String status, String returnNo) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record SummaryQuery(@Min(1) long page, @Min(1) @Max(200) long size, String overseasWarehouseId, + String skuCode) { + public SummaryQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record PlatformQuery(@Min(1) long page, @Min(1) @Max(200) long size, String customerId, + String overseasWarehouseId, String skuCode, String cartonMark, + String returnNo, String status) { + public PlatformQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record PackingQuery(@Size(max = 128) String returnNo) { } + + public record Merge(@NotEmpty @Size(min = 1, max = 100) List<@NotBlank @Size(max = 128) String> returnNos, + @NotEmpty @Size(max = 500) List<@Valid ValidItem> items, + @NotNull @Positive BigDecimal length, + @NotNull @Positive BigDecimal width, + @NotNull @Positive BigDecimal height, + @NotNull @Positive BigDecimal weight, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record ValidItem(@NotBlank @Size(max = 128) String skuCode, + @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class) + @Positive long quantity) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java new file mode 100644 index 000000000..5130349d8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.returnreceipt.dto; + +import java.time.LocalDateTime; +import java.math.BigDecimal; +import java.util.List; + +public final class ReturnInventoryResponses { + private ReturnInventoryResponses() { } + + public record CartonSummary(String cartonMark, String overseasWarehouseId, String sourceBatchNo, + String sourceReturnNo, String status, int skuTypes, long totalQuantity, + BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, + LocalDateTime createdAt) { } + + public record InventoryItem(String overseasWarehouseId, String cartonMark, String sourceBatchNo, String status, String skuCode, + String productNameZh, long quantity, int cartonSkuTypes, long cartonTotalQuantity, + LocalDateTime receivedAt) { } + public record SummaryItem(String overseasWarehouseId, String skuCode, String productNameZh, + long totalQuantity, long availableQuantity, long cartonQuantity, LocalDateTime updatedAt) { } + public record CartonDetail(String cartonMark, String customerId, String overseasWarehouseId, String sourceBatchNo, String sourceReturnNo, String status, + String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, String relabelNo, String relabelStatus, + int skuTypes, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, + BigDecimal weight, LocalDateTime receivedAt, List items) { } + public record CartonItem(String skuCode, String productNameZh, long quantity) { } + public record Label(String cartonMark, String overseasWarehouseId, String customerId, String sourceBatchNo, String sourceReturnNo, + long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, + List items, LocalDateTime generatedAt, String printableHtml) { } + public record MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label) { } + public record PackingOrder(String returnNo, String customerId, String customerReferenceNo, String status) { } + public record PackingSku(String skuCode, String productNameZh, long availableQuantity) { } + public record PackingPreview(List orders, String customerId, List skus) { } + public record PlatformStock(String customerId, String overseasWarehouseId, String skuCode, String productNameZh, + long totalQuantity, long availableQuantity, long cartonQuantity, LocalDateTime updatedAt) { } + public record PlatformCarton(String customerId, String overseasWarehouseId, String cartonMark, String sourceReturnNo, + String sourceBatchNo, String status, int skuTypes, long totalQuantity, + String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, String relabelNo, String relabelStatus, + BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, + LocalDateTime createdAt) { } + public record PlatformMovement(String customerId, String overseasWarehouseId, String skuCode, String productNameZh, + String movementType, String referenceNo, long totalDelta, long availableDelta, + LocalDateTime occurredAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java new file mode 100644 index 000000000..889d7b642 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.returnreceipt.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.Positive; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public final class ReturnOrderRequests { + private ReturnOrderRequests() { } + public record Create(@NotBlank @Size(max = 128) String overseasWarehouseId, + @Size(max = 128) String warehouseContactName, + @Size(max = 64) String warehouseContactPhone, + @Size(max = 128) String customerReferenceNo, + boolean needRelabel, + @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 = 255) String productNameZh, + @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class) + @Positive long expectedQuantity) { } + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String returnNo, + String trackingNo, String skuCode, String status) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + public record RelabelCreate(@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 128) String contactName, @Size(max = 64) String contactPhone, + @Size(max = 32) String deliveryMethod, @Size(max = 1000) String remark) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java new file mode 100644 index 000000000..bbc57322a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.returnreceipt.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class ReturnOrderResponses { + private ReturnOrderResponses() { } + public record Summary(String returnNo, String lastlegOrderNo, String overseasWarehouseId, + String warehouseContactName, String warehouseContactPhone, String customerReferenceNo, String status, + int skuTypes, long expectedQuantity, long receivedQuantity, boolean needRelabel, + String relabelNo, String relabelStatus, LocalDateTime createdAt) { } + public record Item(String skuCode, String productNameZh, long expectedQuantity, + long receivedQuantity) { } + public record Detail(Summary order, String remark, List items, List receipts) { } + public record Receipt(String receiptNo, String receiptBatchNo, String cartonMark, String trackingNo, String startedBy, + long receivedQuantity, String operatorName, LocalDateTime signedAt) { } + public record RelabelCandidate(Long cartonId, String cartonMark, String sourceReturnNo, String status, + long totalQuantity, List items, String relabelStatus) { } + public record RelabelCreated(String relabelNo, Long relabelApplicationId, String returnNo, + List cartonMarks, String status) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnQuantityDeserializer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnQuantityDeserializer.java new file mode 100644 index 000000000..3122d0c70 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnQuantityDeserializer.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.returnreceipt.dto; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import java.io.IOException; + +/** Return quantities are whole units; never truncate fractional JSON input. */ +public final class ReturnQuantityDeserializer extends JsonDeserializer { + @Override + public Long deserialize(JsonParser parser, DeserializationContext context) throws IOException { + if (!parser.hasToken(JsonToken.VALUE_NUMBER_INT)) { + return context.reportInputMismatch(Long.class, "退货数量必须为整数"); + } + return parser.getLongValue(); + } + + @Override + public Long getNullValue(DeserializationContext context) throws JsonMappingException { + return context.reportInputMismatch(Long.class, "退货数量不能为空"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryBalance.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryBalance.java new file mode 100644 index 000000000..e11a5fb8f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryBalance.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.returnreceipt.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Customer-facing available return-inventory balance. */ +@Data +@TableName("ll_return_inventory_balance") +public class ReturnInventoryBalance { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String overseasWarehouseId; + private String skuCode; + private String productNameZh; + /** 实物总库存;只有实际出库才会减少。 */ + private Long totalQuantity; + private Long availableQuantity; + private Long cartonQuantity; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java new file mode 100644 index 000000000..95e724962 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.returnreceipt.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; +import java.math.BigDecimal; + +/** Physical carton that holds return inventory. */ +@Data +@TableName("ll_return_inventory_carton") +public class ReturnInventoryCarton { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String overseasWarehouseId; + private Long sourceReceiptId; + private String sourceBatchNo; + private String sourceReturnNo; + private String cartonMark; + private String fbaCartonMark; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private Long relabelApplicationId; + private String relabelNo; + private String relabelStatus; + private LocalDateTime relabelCompletedAt; + private String status; + private Integer skuTypes; + private Long totalQuantity; + private BigDecimal length; + private BigDecimal width; + private BigDecimal height; + private BigDecimal weight; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime mergedAt; + private String newCartonMark; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCartonItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCartonItem.java new file mode 100644 index 000000000..322ee241f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCartonItem.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.returnreceipt.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +@Data +@TableName("ll_return_inventory_carton_item") +public class ReturnInventoryCartonItem { + @TableId(type = IdType.INPUT) private Long id; + private Long cartonId; + private String customerId; + private String skuCode; + private String productNameZh; + private Long quantity; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOperation.java new file mode 100644 index 000000000..de6ae0930 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOperation.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.returnreceipt.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_return_inventory_operation") +public class ReturnInventoryOperation { + @TableId(type = IdType.INPUT) private Long id; + private String operatorId; + private String idempotencyKey; + private String requestHash; + private String operationType; + private Long resultCartonId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrder.java new file mode 100644 index 000000000..8b9175362 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrder.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.returnreceipt.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_return_order") +public class ReturnOrder { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String returnNo; + private String overseasWarehouseId; + private String warehouseContactName; + private String warehouseContactPhone; + private String customerReferenceNo; + private Boolean needRelabel; + private Long lastlegOrderId; + private String lastlegOrderNo; + private Long relabelApplicationId; + private String relabelNo; + private String relabelStatus; + private String status; + private Integer expectedSkuTypes; + private Long expectedQuantity; + private Long receivedQuantity; + private String remark; + 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/returnreceipt/entity/ReturnOrderItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrderItem.java new file mode 100644 index 000000000..7fb5194cc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrderItem.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.returnreceipt.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_return_order_item") +public class ReturnOrderItem { + @TableId(type = IdType.INPUT) private Long id; + private Long returnOrderId; + private String customerId; + private String skuCode; + private String productNameZh; + private Long expectedQuantity; + private Long receivedQuantity; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceipt.java new file mode 100644 index 000000000..449d7a130 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceipt.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.returnreceipt.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_return_receipt") +public class ReturnReceipt { + @TableId(type = IdType.INPUT) private Long id; + private Long returnOrderId; + private String customerId; + private String receiptNo; + private String receiptBatchNo; + private String cartonMark; + private String trackingNo; + private String boundBy; + private String status; + private Long receivedQuantity; + private String operatorId; + private String operatorName; + private String idempotencyKey; + private String requestHash; + private LocalDateTime signedAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceiptBatch.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceiptBatch.java new file mode 100644 index 000000000..e5a0c618c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceiptBatch.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.returnreceipt.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** A completed receipt starts the storage-billing clock, even when the whole return order remains partial. */ +@Data +@TableName("ll_return_receipt_batch") +public class ReturnReceiptBatch { + @TableId(type = IdType.INPUT) private Long id; + private Long returnOrderId; + private Long receiptId; + private String customerId; + private String batchNo; + private String overseasWarehouseId; + private String storageBillingStatus; + private LocalDateTime storageStartedAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceiptItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceiptItem.java new file mode 100644 index 000000000..a21eb4237 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceiptItem.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.returnreceipt.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +@Data +@TableName("ll_return_receipt_item") +public class ReturnReceiptItem { + @TableId(type = IdType.INPUT) private Long id; + private Long receiptId; + private Long returnOrderItemId; + private String customerId; + private String skuCode; + private Long receivedQuantity; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java new file mode 100644 index 000000000..9aaeeb487 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance; +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.util.List; + +@Mapper +public interface ReturnInventoryBalanceMapper extends BaseMapper { + @Insert("INSERT INTO ll_return_inventory_balance (id,customer_id,overseas_warehouse_id,sku_code,product_name_zh,total_quantity,available_quantity,carton_quantity,created_at,updated_at) " + + "VALUES (#{id},#{customerId},#{warehouseId},#{skuCode},#{productNameZh},#{totalQuantity},#{availableQuantity},#{cartons},NOW(3),NOW(3)) " + + "ON DUPLICATE KEY UPDATE total_quantity=total_quantity+VALUES(total_quantity),available_quantity=available_quantity+VALUES(available_quantity),carton_quantity=carton_quantity+VALUES(carton_quantity), " + + "product_name_zh=COALESCE(NULLIF(VALUES(product_name_zh),''),product_name_zh),updated_at=NOW(3)") + int adjust(@Param("id") long id, @Param("customerId") String customerId, @Param("warehouseId") String warehouseId, + @Param("skuCode") String skuCode, @Param("productNameZh") String productNameZh, + @Param("totalQuantity") long totalQuantity, @Param("availableQuantity") long availableQuantity, + @Param("cartons") long cartons); + + @Update("UPDATE ll_return_inventory_balance SET carton_quantity=carton_quantity+#{cartons},updated_at=NOW(3) " + + "WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} " + + "AND carton_quantity+#{cartons}>=0") + int adjustCartonQuantity(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, + @Param("skuCode") String skuCode, @Param("cartons") long cartons); + + @Select("") + long countPage(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, + @Param("skuCode") String skuCode); + + @Select("") + List page(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, + @Param("skuCode") String skuCode, @Param("offset") long offset, @Param("size") long size); + + @Select("") + long platformCount(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("skuCode") String skuCode); + + @Select("") + List platformPage(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, + @Param("skuCode") String skuCode, @Param("offset") long offset, @Param("size") long size); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonItemMapper.java new file mode 100644 index 000000000..374525575 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonItemMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +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 ReturnInventoryCartonItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} ORDER BY sku_code") + List listCarton(@Param("cartonId") long cartonId); + @Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} ORDER BY sku_code FOR UPDATE") + List lockCarton(@Param("cartonId") long cartonId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java new file mode 100644 index 000000000..17807822d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +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 ReturnInventoryCartonMapper extends BaseMapper { + String MERCHANT_FILTER = " FROM ll_return_inventory_carton c WHERE c.customer_id=#{customerId} " + + "AND c.overseas_warehouse_id=#{query.overseasWarehouseId} " + + "AND c.carton_mark LIKE CONCAT('%',#{query.cartonMark},'%') " + + "AND c.source_return_no LIKE CONCAT('%',#{query.returnNo},'%') " + + "AND c.status=#{query.status} " + + "AND EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=c.id AND i.sku_code LIKE CONCAT('%',#{query.skuCode},'%')) "; + + @Select("") + long merchantCount(@Param("customerId") String customerId, + @Param("query") com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.Query query); + + @Select("") + List merchantPage(@Param("customerId") String customerId, + @Param("query") com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.Query query, @Param("offset") long offset); + + @Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='AVAILABLE' ORDER BY created_at DESC,id DESC LIMIT 500") + List findAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId); + @Select("SELECT * FROM ll_return_inventory_carton WHERE carton_mark=#{cartonMark} LIMIT 1") + ReturnInventoryCarton findMark(@Param("cartonMark") String cartonMark); + + @Select("SELECT * FROM ll_return_inventory_carton WHERE carton_mark=#{cartonMark} LIMIT 1 FOR UPDATE") + ReturnInventoryCarton lockMark(@Param("cartonMark") String cartonMark); + + @Select("SELECT * FROM ll_return_inventory_carton WHERE id=#{id} FOR UPDATE") + ReturnInventoryCarton lockId(@Param("id") long id); + + @Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} LIMIT 1") + ReturnInventoryCarton findReceipt(@Param("receiptId") long receiptId); + + @Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND carton_mark=#{cartonMark} LIMIT 1") + ReturnInventoryCarton findCustomerMark(@Param("customerId") String customerId, @Param("cartonMark") String cartonMark); + + @Select("") + long platformCount(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, + @Param("cartonMark") String cartonMark, @Param("returnNo") String returnNo, @Param("status") String status, @Param("skuCode") String skuCode); + + @Select("") + List platformPage(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, + @Param("cartonMark") String cartonMark, @Param("returnNo") String returnNo, @Param("status") String status, + @Param("skuCode") String skuCode, @Param("offset") long offset, @Param("size") long size); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java new file mode 100644 index 000000000..d4aa8999b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** Read-only return inventory movement ledger reconstructed from signed receipts and merged cartons. */ +@Mapper +public interface ReturnInventoryMovementMapper { + String MOVEMENTS = "SELECT o.customer_id customerId,o.overseas_warehouse_id overseasWarehouseId,ri.sku_code skuCode,oi.product_name_zh productNameZh," + + "'RECEIPT' movementType,r.receipt_no referenceNo,ri.received_quantity totalDelta,ri.received_quantity availableDelta,r.signed_at occurredAt " + + "FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id JOIN ll_return_receipt_item ri ON ri.receipt_id=r.id " + + "JOIN ll_return_order_item oi ON oi.id=ri.return_order_item_id " + + "UNION ALL SELECT c.customer_id,c.overseas_warehouse_id,i.sku_code,i.product_name_zh,'MERGE',c.carton_mark,0,-i.quantity,c.created_at " + + "FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED'"; + + @Select("") + long platformCount(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("skuCode") String skuCode); + + @Select("") + List platformPage(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, + @Param("skuCode") String skuCode, @Param("offset") long offset, @Param("size") long size); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryOperationMapper.java new file mode 100644 index 000000000..d0a866fcb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryOperationMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ReturnInventoryOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_return_inventory_operation WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1") + ReturnInventoryOperation findKey(@Param("operatorId") String operatorId, @Param("key") String key); + + @Select("SELECT * FROM ll_return_inventory_operation WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1 FOR UPDATE") + ReturnInventoryOperation lockKey(@Param("operatorId") String operatorId, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryQueryMapper.java new file mode 100644 index 000000000..b5d131221 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryQueryMapper.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import com.baiyue.lastleg.returnreceipt.model.ReturnInventoryRow; +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 ReturnInventoryQueryMapper { + @Select("") + long count(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, + @Param("skuCode") String skuCode, @Param("cartonMark") String cartonMark, + @Param("status") String status, @Param("returnNo") String returnNo); + + @Select("") + List page(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, + @Param("skuCode") String skuCode, @Param("cartonMark") String cartonMark, + @Param("status") String status, @Param("returnNo") String returnNo, + @Param("offset") long offset, @Param("size") long size); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderItemMapper.java new file mode 100644 index 000000000..6bca29bc6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderItemMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnOrderItem; +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 ReturnOrderItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_return_order_item WHERE return_order_id=#{orderId} ORDER BY sku_code,id") + List listOrder(@Param("orderId") Long orderId); + @Select("SELECT * FROM ll_return_order_item WHERE return_order_id=#{orderId} ORDER BY sku_code,id FOR UPDATE") + List lockOrder(@Param("orderId") Long orderId); + + @Select("SELECT i.* FROM ll_return_order_item i JOIN ll_return_order o ON o.id=i.return_order_id " + + "WHERE o.overseas_warehouse_id=#{warehouseId} AND o.status IN ('PENDING_RECEIPT','PARTIALLY_RECEIVED') " + + "AND i.sku_code=#{skuCode} AND i.received_quantity activeSkuCandidates(@Param("warehouseId") String warehouseId, @Param("skuCode") String skuCode); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java new file mode 100644 index 000000000..3eb209b27 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder; +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 ReturnOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + ReturnOrder findKey(@Param("customerId") String customerId, @Param("key") String key); + @Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND return_no=#{returnNo} LIMIT 1") + ReturnOrder findNo(@Param("customerId") String customerId, @Param("returnNo") String returnNo); + @Select("SELECT * FROM ll_return_order WHERE return_no=#{returnNo} LIMIT 1 FOR UPDATE") + ReturnOrder lockNo(@Param("returnNo") String returnNo); + @Select("SELECT * FROM ll_return_order WHERE return_no=#{returnNo} AND overseas_warehouse_id=#{warehouseId} " + + "AND status IN ('PENDING_RECEIPT','PARTIALLY_RECEIVED') LIMIT 1") + ReturnOrder findActiveNo(@Param("warehouseId") String warehouseId, @Param("returnNo") String returnNo); + @Select("SELECT * FROM ll_return_order WHERE overseas_warehouse_id=#{warehouseId} AND status='COMPLETED' " + + "AND return_no LIKE CONCAT('%',#{returnNo},'%') ORDER BY created_at DESC,id DESC LIMIT 100") + List findCompletedLike(@Param("warehouseId") String warehouseId, @Param("returnNo") String returnNo); + @Select("") + List findByNos(@Param("warehouseId") String warehouseId, @Param("returnNos") List returnNos); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptBatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptBatchMapper.java new file mode 100644 index 000000000..217fc0dd4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptBatchMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptBatch; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ReturnReceiptBatchMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptItemMapper.java new file mode 100644 index 000000000..e8d437be1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptItemMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptItem; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ReturnReceiptItemMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptMapper.java new file mode 100644 index 000000000..b2a33e9d9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt; +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 ReturnReceiptMapper extends BaseMapper { + @Select("SELECT * FROM ll_return_receipt WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1") + ReturnReceipt findKey(@Param("operatorId") String operatorId, @Param("key") String key); + @Select("SELECT * FROM ll_return_receipt WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1 FOR UPDATE") + ReturnReceipt lockKey(@Param("operatorId") String operatorId, @Param("key") String key); + @Select("SELECT * FROM ll_return_receipt WHERE return_order_id=#{orderId} ORDER BY signed_at DESC,id DESC") + List listOrder(@Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/model/ReturnInventoryRow.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/model/ReturnInventoryRow.java new file mode 100644 index 000000000..dd63846d5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/model/ReturnInventoryRow.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.returnreceipt.model; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class ReturnInventoryRow { + private Long cartonId; + private String overseasWarehouseId; + private String cartonMark; + private String sourceBatchNo; + private String status; + private Integer skuTypes; + private Long totalQuantity; + private LocalDateTime createdAt; + private String skuCode; + private String productNameZh; + private Long quantity; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcode.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcode.java new file mode 100644 index 000000000..7811d8d63 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcode.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.returnreceipt.service; + +import com.google.zxing.oned.Code128Writer; + +/** Inline vector bars remain printable even when browser background printing is disabled. */ +final class ReturnCartonBarcode { + private static final int QUIET_ZONE = 10; + + private ReturnCartonBarcode() { } + + static String svg(String cartonMark) { + boolean[] modules = new Code128Writer().encode(cartonMark); + int width = modules.length + QUIET_ZONE * 2; + StringBuilder svg = new StringBuilder("") + .append(""); + for (int index = 0; index < modules.length;) { + if (!modules[index]) { index++; continue; } + int start = index; + while (index < modules.length && modules[index]) index++; + svg.append(""); + } + return svg.append("").toString(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java new file mode 100644 index 000000000..02460fc85 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -0,0 +1,409 @@ +package com.baiyue.lastleg.returnreceipt.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOperation; +import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder; +import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryOperationMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryQueryMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryMovementMapper; +import com.baiyue.lastleg.returnreceipt.model.ReturnInventoryRow; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +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.security.MessageDigest; +import java.time.LocalDateTime; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +/** Return stock is deliberately separate from normal inbound stock because it has no putaway location yet. */ +@Service +public class ReturnInventoryService { + public static final String AVAILABLE = "AVAILABLE"; + public static final String MERGED = "MERGED"; + + @Resource private ReturnInventoryCartonMapper cartons; + @Resource private ReturnInventoryCartonItemMapper cartonItems; + @Resource private ReturnInventoryBalanceMapper balances; + @Resource private ReturnInventoryOperationMapper operations; + @Resource private ReturnInventoryQueryMapper queries; + @Resource private ReturnInventoryMovementMapper movements; + @Resource private LastlegOrderNoGenerator numbers; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper orders; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper receiptMapper; + @Resource private LastlegOrderMapper lastlegOrders; + @Resource private LastlegOrderDetailMapper lastlegOrderDetails; + + @Transactional + public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt, + String requestedMark, List lines) { + if (lines.isEmpty()) throw new IllegalArgumentException("退货签收库存明细不能为空"); + String cartonMark = text(requestedMark); + long cartonId = IdWorker.getId(); + if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId); + if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark); + LocalDateTime now = LocalDateTime.now(); + long total = lines.stream().mapToLong(ReceiptLine::quantity).sum(); + ReturnInventoryCarton carton = new ReturnInventoryCarton(); + carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId()); + carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo()); + carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark); + carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now); + cartons.insert(carton); + for (ReceiptLine line : lines) { + ReturnInventoryCartonItem item = new ReturnInventoryCartonItem(); + item.setId(IdWorker.getId()); item.setCartonId(cartonId); item.setCustomerId(order.getCustomerId()); + item.setSkuCode(line.skuCode()); item.setProductNameZh(text(line.productNameZh())); item.setQuantity(line.quantity()); + cartonItems.insert(item); + balances.adjust(IdWorker.getId(), order.getCustomerId(), order.getOverseasWarehouseId(), line.skuCode(), + text(line.productNameZh()), line.quantity(), line.quantity(), 1); + } + return carton; + } + + @Transactional(readOnly = true) + public PageResult page(String customerId, ReturnInventoryRequests.Query request) { + String warehouseId = text(request.overseasWarehouseId()); + String sku = text(request.skuCode()); + String carton = text(request.cartonMark()); + long total = queries.count(customerId, warehouseId, sku, carton, text(request.status()), text(request.returnNo())); + List records = total == 0 ? List.of() : queries.page(customerId, warehouseId, sku, + carton, text(request.status()), text(request.returnNo()), (request.page() - 1) * request.size(), request.size()).stream().map(this::item).toList(); + return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); + } + + @Transactional(readOnly = true) + public PageResult cartonPage(String customerId, ReturnInventoryRequests.Query request) { + required(customerId, "客户身份不能为空"); + long total = cartons.merchantCount(customerId, request); + List records = total == 0 ? List.of() : + cartons.merchantPage(customerId, request, (request.page() - 1) * request.size()).stream() + .map(carton -> new ReturnInventoryResponses.CartonSummary(carton.getCartonMark(), carton.getOverseasWarehouseId(), + carton.getSourceBatchNo(), carton.getSourceReturnNo(), carton.getStatus(), + carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), + carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt())).toList(); + return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); + } + + @Transactional(readOnly = true) + public PageResult summaryPage(String customerId, ReturnInventoryRequests.SummaryQuery request) { + String warehouseId = text(request.overseasWarehouseId()); + String sku = text(request.skuCode()); + long total = balances.countPage(customerId, warehouseId, sku); + List records = total == 0 ? List.of() : balances.page(customerId, warehouseId, sku, + (request.page() - 1) * request.size(), request.size()).stream().map(this::summary).toList(); + return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); + } + + @Transactional(readOnly = true) + public PageResult platformStockPage(ReturnInventoryRequests.PlatformQuery request) { + String customerId = text(request.customerId()); String warehouseId = text(request.overseasWarehouseId()); String sku = text(request.skuCode()); + long total = balances.platformCount(customerId, warehouseId, sku); + List records = total == 0 ? List.of() : balances.platformPage(customerId, warehouseId, sku, + (request.page() - 1) * request.size(), request.size()).stream().map(balance -> new ReturnInventoryResponses.PlatformStock( + balance.getCustomerId(), balance.getOverseasWarehouseId(), balance.getSkuCode(), balance.getProductNameZh(), + value(balance.getTotalQuantity()), value(balance.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt())).toList(); + return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); + } + + @Transactional(readOnly = true) + public PageResult platformMovementPage(ReturnInventoryRequests.PlatformQuery request) { + String customerId = text(request.customerId()); String warehouseId = text(request.overseasWarehouseId()); String sku = text(request.skuCode()); + long total = movements.platformCount(customerId, warehouseId, sku); + List records = total == 0 ? List.of() : movements.platformPage(customerId, warehouseId, sku, + (request.page() - 1) * request.size(), request.size()); + return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); + } + + @Transactional(readOnly = true) + public PageResult platformCartonPage(ReturnInventoryRequests.PlatformQuery request) { + String customerId = text(request.customerId()); String warehouseId = text(request.overseasWarehouseId()); + String cartonMark = text(request.cartonMark()); String returnNo = text(request.returnNo()); String status = normStatus(request.status()); String sku = text(request.skuCode()); + long total = cartons.platformCount(customerId, warehouseId, cartonMark, returnNo, status, sku); + List records = total == 0 ? List.of() : cartons.platformPage(customerId, warehouseId, cartonMark, + returnNo, status, sku, (request.page() - 1) * request.size(), request.size()).stream().map(carton -> new ReturnInventoryResponses.PlatformCarton( + carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getSourceBatchNo(), + carton.getStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getLength(), + carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt())).toList(); + return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); + } + + @Transactional(readOnly = true) + public ReturnInventoryResponses.CartonDetail platformCarton(String cartonMark) { + ReturnInventoryCarton carton = cartons.findMark(required(cartonMark, "箱号不能为空")); + if (carton == null) throw notFound("退货箱不存在"); + return cartonDetail(carton); + } + + @Transactional(readOnly = true) + public ReturnInventoryResponses.Label platformLabel(String cartonMark) { + ReturnInventoryCarton carton = cartons.findMark(required(cartonMark, "箱号不能为空")); + if (carton == null) throw notFound("退货箱不存在"); + return label(carton); + } + + @Transactional(readOnly = true) + public ReturnInventoryResponses.CartonDetail carton(String customerId, String cartonMark) { + ReturnInventoryCarton carton = cartons.findCustomerMark(customerId, required(cartonMark, "箱号不能为空")); + if (carton == null) throw notFound("退货箱不存在"); + return cartonDetail(carton); + } + + @Transactional(readOnly = true) + public ReturnInventoryResponses.Label label(String customerId, String cartonMark) { + ReturnInventoryCarton carton = cartons.findCustomerMark(customerId, required(cartonMark, "箱号不能为空")); + if (carton == null) throw notFound("退货箱不存在"); + return label(carton); + } + + @Transactional(readOnly = true) + public ReturnInventoryResponses.Label label(PdaWarehouseIdentity identity, String cartonMark) { + ReturnInventoryCarton carton = cartons.findMark(required(cartonMark, "箱号不能为空")); + if (carton == null || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw notFound("当前仓没有该退货箱"); + return label(carton); + } + + @Transactional(readOnly = true) + public ReturnInventoryResponses.PackingPreview packingPreview(PdaWarehouseIdentity identity, ReturnInventoryRequests.PackingQuery request) { + List candidates = orders.findCompletedLike(String.valueOf(identity.overseasWarehouseId()), text(request.returnNo()) == null ? "" : text(request.returnNo())); + Map unique = new LinkedHashMap<>(); + candidates.forEach(order -> unique.put(order.getReturnNo(), order)); + List selected = unique.values().stream().limit(100).toList(); + String customerId = selected.size() == 1 ? selected.get(0).getCustomerId() : null; + Map quantities = new LinkedHashMap<>(); + Map names = new LinkedHashMap<>(); + for (ReturnOrder order : selected) { + for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) { + ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId()); + if (carton == null || !AVAILABLE.equals(carton.getStatus())) continue; + for (ReturnInventoryCartonItem item : cartonItems.listCarton(carton.getId())) { + quantities.merge(item.getSkuCode(), value(item.getQuantity()), Math::addExact); + names.putIfAbsent(item.getSkuCode(), item.getProductNameZh()); + } + } + } + return new ReturnInventoryResponses.PackingPreview(selected.stream().map(order -> + new ReturnInventoryResponses.PackingOrder(order.getReturnNo(), order.getCustomerId(), order.getCustomerReferenceNo(), order.getStatus())).toList(), + customerId, quantities.entrySet().stream().map(entry -> new ReturnInventoryResponses.PackingSku(entry.getKey(), names.get(entry.getKey()), entry.getValue())).toList()); + } + + @Transactional + public ReturnInventoryResponses.MergeResult merge(PdaWarehouseIdentity identity, ReturnInventoryRequests.Merge request) { + String requestHash = hash(request); + String key = text(request.idempotencyKey()); + ReturnInventoryOperation replay = operations.findKey(identity.operatorKey(), key); + if (replay != null) return replayMerge(replay, requestHash); + LocalDateTime now = LocalDateTime.now(); + ReturnInventoryOperation operation = new ReturnInventoryOperation(); + operation.setId(IdWorker.getId()); operation.setOperatorId(identity.operatorKey()); operation.setIdempotencyKey(key); + operation.setRequestHash(requestHash); operation.setOperationType("MERGE"); operation.setCreatedAt(now); operation.setUpdatedAt(now); + try { operations.insert(operation); } + catch (DuplicateKeyException concurrent) { + // The duplicate insert waited for the winner; the earlier snapshot cannot see its commit. + ReturnInventoryOperation existing = operations.lockKey(identity.operatorKey(), key); + if (existing != null) return replayMerge(existing, requestHash); + throw concurrent; + } + + List returnNos = request.returnNos().stream().map(this::requiredReturnNo).distinct().toList(); + List selectedOrders = orders.findByNos(String.valueOf(identity.overseasWarehouseId()), returnNos); + if (selectedOrders.size() != returnNos.size()) throw notFound("存在不属于当前仓或不存在的退货单"); + String customerId = selectedOrders.get(0).getCustomerId(); + if (selectedOrders.stream().anyMatch(order -> !"COMPLETED".equals(order.getStatus()))) throw conflict("只能合并已完成签收的退货单"); + if (selectedOrders.stream().anyMatch(order -> !customerId.equals(order.getCustomerId()))) throw conflict("不同客户的退货单禁止合箱"); + List sources = new ArrayList<>(); + Map sourceOwners = new LinkedHashMap<>(); + Map candidateOwners = new java.util.TreeMap<>(); + for (ReturnOrder order : selectedOrders) for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) { + ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId()); + if (carton != null && AVAILABLE.equals(carton.getStatus())) { + candidateOwners.put(carton.getCartonMark(), order); + } + } + // All requests acquire source locks in the same order. The earlier + // candidate query is only a snapshot: availability must be checked again. + for (Map.Entry candidate : candidateOwners.entrySet()) { + ReturnInventoryCarton locked = cartons.lockMark(candidate.getKey()); + if (locked != null && AVAILABLE.equals(locked.getStatus())) { + sources.add(locked); sourceOwners.put(locked.getId(), candidate.getValue()); + } + } + if (sources.isEmpty()) throw conflict("所选退货单没有可合箱的库存箱"); + Map requested = new LinkedHashMap<>(); + Map productNames = new LinkedHashMap<>(); + for (ReturnInventoryRequests.ValidItem item : request.items()) { + String sku = required(item.skuCode(), "SKU 不能为空").toUpperCase(java.util.Locale.ROOT); + if (requested.put(sku, item.quantity()) != null) throw badRequest("合箱 SKU 不能重复"); + } + Map remaining = new LinkedHashMap<>(requested); + Map emptiedBySku = new LinkedHashMap<>(); + LinkedHashSet consumedBatchNos = new LinkedHashSet<>(); + LinkedHashSet consumedReturnNos = new LinkedHashSet<>(); + Map> attachedByOrder = new LinkedHashMap<>(); + long total = 0; + for (ReturnInventoryCarton source : sources) { + // Read committed quantities under the source lock, never a stale MVCC snapshot. + List sourceItems = cartonItems.lockCarton(source.getId()); + long sourceTotal = 0; int sourceTypes = 0; + for (ReturnInventoryCartonItem item : sourceItems) { + String sku = item.getSkuCode().toUpperCase(java.util.Locale.ROOT); + long take = Math.min(value(remaining.get(sku)), value(item.getQuantity())); + if (take > 0) { + consumedBatchNos.add(required(source.getSourceBatchNo(), "来源签收批次缺失,无法合箱")); + consumedReturnNos.add(required(source.getSourceReturnNo(), "来源退货单缺失,无法合箱")); + remaining.put(sku, remaining.get(sku) - take); total = Math.addExact(total, take); + ReturnOrder owner = sourceOwners.get(source.getId()); + if (owner != null) attachedByOrder.computeIfAbsent(owner.getId(), ignored -> new LinkedHashMap<>()) + .merge(sku, take, Math::addExact); + productNames.putIfAbsent(sku, item.getProductNameZh()); + item.setQuantity(item.getQuantity() - take); + if (item.getQuantity() == 0) cartonItems.deleteById(item.getId()); else cartonItems.updateById(item); + if (item.getQuantity() == 0) emptiedBySku.merge(sku, 1, Integer::sum); + } + if (item.getQuantity() > 0) { sourceTotal = Math.addExact(sourceTotal, item.getQuantity()); sourceTypes++; } + } + if (sourceTotal == 0) { + source.setStatus(MERGED); source.setMergedAt(now); + } + source.setTotalQuantity(sourceTotal); source.setSkuTypes(sourceTypes); source.setUpdatedAt(now); cartons.updateById(source); + } + if (remaining.values().stream().anyMatch(value -> value > 0)) throw conflict("合箱数量超过对应退货单的可用库存"); + if (total <= 0) throw badRequest("至少填写一个大于 0 的 SKU 合箱数量"); + long targetId = IdWorker.getId(); + ReturnInventoryCarton target = new ReturnInventoryCarton(); + target.setId(targetId); target.setCustomerId(customerId); target.setOverseasWarehouseId(sources.get(0).getOverseasWarehouseId()); + target.setCartonMark(numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId)); target.setSourceBatchNo(String.join(",", consumedBatchNos)); target.setSourceReturnNo(String.join(",", consumedReturnNos)); target.setStatus(AVAILABLE); + target.setSkuTypes(requested.size()); target.setTotalQuantity(total); target.setLength(request.length()); target.setWidth(request.width()); target.setHeight(request.height()); target.setWeight(request.weight()); target.setCreatedAt(now); target.setUpdatedAt(now); cartons.insert(target); + for (Map.Entry entry : requested.entrySet()) { + ReturnInventoryCartonItem targetItem = new ReturnInventoryCartonItem(); + targetItem.setId(IdWorker.getId()); targetItem.setCartonId(targetId); targetItem.setCustomerId(customerId); targetItem.setSkuCode(entry.getKey()); + targetItem.setProductNameZh(productNames.get(entry.getKey())); targetItem.setQuantity(entry.getValue()); cartonItems.insert(targetItem); + // A SKU's carton count is the number of available physical cartons containing it: + // remove fully emptied source cartons, then add the one merged target carton. + long cartonDelta = 1L - emptiedBySku.getOrDefault(entry.getKey(), 0); + if (balances.adjustCartonQuantity(customerId, target.getOverseasWarehouseId(), entry.getKey(), cartonDelta) != 1) { + throw new IllegalStateException("退货合箱库存箱数更新失败: " + entry.getKey()); + } + } + attachMergedCarton(target, attachedByOrder, selectedOrders, productNames, now); + operation.setResultCartonId(targetId); operation.setUpdatedAt(now); operations.updateById(operation); + return new ReturnInventoryResponses.MergeResult(target.getCartonMark(), requested.keySet().iterator().next(), total, label(target)); + } + + private void attachMergedCarton(ReturnInventoryCarton target, Map> attachedByOrder, + List selectedOrders, Map productNames, LocalDateTime now) { + Map byId = selectedOrders.stream().collect(java.util.stream.Collectors.toMap(ReturnOrder::getId, value -> value)); + for (Map.Entry> orderEntry : attachedByOrder.entrySet()) { + ReturnOrder returnOrder = byId.get(orderEntry.getKey()); + if (returnOrder == null || returnOrder.getLastlegOrderId() == null) continue; + LastlegOrder tail = lastlegOrders.selectById(returnOrder.getLastlegOrderId()); + if (tail == null || !returnOrder.getCustomerId().equals(tail.getCustomerId())) continue; + long quantity = orderEntry.getValue().values().stream().mapToLong(Long::longValue).sum(); + boolean firstSku = true; + for (Map.Entry skuEntry : orderEntry.getValue().entrySet()) { + LastlegOrderDetail detail = new LastlegOrderDetail(); + detail.setId(IdWorker.getId()); detail.setCustomerId(returnOrder.getCustomerId()); detail.setOrderId(tail.getId()); + detail.setSourceInboundDetailId("RETURN_CARTON:" + target.getId() + ":" + returnOrder.getId() + ":" + skuEntry.getKey()); + detail.setSkuCode(skuEntry.getKey()); detail.setProductNameZh(productNames.get(skuEntry.getKey())); + detail.setPackageCount(firstSku ? 1 : 0); detail.setProductQuantity(Math.toIntExact(skuEntry.getValue())); detail.setShippingMark(target.getCartonMark()); + detail.setDestinationWarehouseCode(target.getDestinationWarehouseCode()); detail.setDestinationWarehouseName(target.getDestinationWarehouseName()); + lastlegOrderDetails.insert(detail); firstSku = false; + } + tail.setPackageCount(Math.addExact(tail.getPackageCount() == null ? 0 : tail.getPackageCount(), 1)); + tail.setProductQuantity(Math.addExact(tail.getProductQuantity() == null ? 0 : tail.getProductQuantity(), Math.toIntExact(quantity))); + tail.setUpdatedAt(now); lastlegOrders.updateById(tail); + } + } + + private ReturnInventoryResponses.MergeResult replayMerge(ReturnInventoryOperation operation, String requestHash) { + if (!requestHash.equals(operation.getRequestHash())) throw conflict("幂等键已用于不同的合箱请求"); + if (operation.getResultCartonId() == null) throw conflict("合箱请求正在处理"); + // The result and its label must also bypass the snapshot established before the insert race. + ReturnInventoryCarton carton = cartons.lockId(operation.getResultCartonId()); + if (carton == null) throw new IllegalStateException("合箱结果箱不存在"); + List items = cartonItems.lockCarton(carton.getId()); + return new ReturnInventoryResponses.MergeResult(carton.getCartonMark(), items.get(0).getSkuCode(), carton.getTotalQuantity(), label(carton, items)); + } + + private ReturnInventoryResponses.Label label(ReturnInventoryCarton carton) { + return label(carton, cartonItems.listCarton(carton.getId())); + } + + private ReturnInventoryResponses.Label label(ReturnInventoryCarton carton, List cartonLines) { + List items = cartonLines.stream() + .map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity())).toList(); + return new ReturnInventoryResponses.Label(carton.getCartonMark(), carton.getOverseasWarehouseId(), carton.getCustomerId(), + carton.getSourceBatchNo(), carton.getSourceReturnNo(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), items, LocalDateTime.now(), printableHtml(carton, items)); + } + + private ReturnInventoryResponses.InventoryItem item(ReturnInventoryRow row) { + return new ReturnInventoryResponses.InventoryItem(row.getOverseasWarehouseId(), row.getCartonMark(), row.getSourceBatchNo(), row.getStatus(), + row.getSkuCode(), row.getProductNameZh(), value(row.getQuantity()), row.getSkuTypes() == null ? 0 : row.getSkuTypes(), + value(row.getTotalQuantity()), row.getCreatedAt()); + } + + private ReturnInventoryResponses.SummaryItem summary(com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance balance) { + return new ReturnInventoryResponses.SummaryItem(balance.getOverseasWarehouseId(), balance.getSkuCode(), balance.getProductNameZh(), + value(balance.getTotalQuantity()), value(balance.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt()); + } + + private ReturnInventoryResponses.CartonDetail cartonDetail(ReturnInventoryCarton carton) { + List items = cartonItems.listCarton(carton.getId()).stream() + .map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity())).toList(); + return new ReturnInventoryResponses.CartonDetail(carton.getCartonMark(), carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(), + carton.getStatus(), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items); + } + + private static String printableHtml(ReturnInventoryCarton carton, List items) { + StringBuilder rows = new StringBuilder(); + for (ReturnInventoryResponses.CartonItem item : items) rows.append("").append(escape(item.skuCode())).append("") + .append(escape(item.productNameZh())).append("").append(item.quantity()).append(""); + return "退货箱唛
BAIYUE OVERSEAS WAREHOUSE
RETURN CARTON LABEL / 退货箱唛
" + + escape(carton.getCartonMark()) + "
" + ReturnCartonBarcode.svg(carton.getCartonMark()) + "
" + + "
来源退货单:" + escape(carton.getSourceReturnNo()) + + "
箱规:" + dimension(carton.getLength()) + " × " + dimension(carton.getWidth()) + " × " + dimension(carton.getHeight()) + " cm / " + dimension(carton.getWeight()) + " kg" + + "
签收批次:" + escape(carton.getSourceBatchNo()) + "
" + + rows + "
SKU品名数量
合计:" + carton.getTotalQuantity() + " 件 / " + carton.getSkuTypes() + " 种 SKU
"; + } + private static String dimension(BigDecimal value) { return value == null ? "—" : value.stripTrailingZeros().toPlainString(); } + private String hash(Object request) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(String.valueOf(request).getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException("无法生成请求哈希", exception); } } + private static String escape(String value) { return text(value) == null ? "" : text(value).replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); } + private static long value(Long number) { return number == null ? 0 : number; } + private static String required(String value, String message) { String result = text(value); if (result == null) throw badRequest(message); return result; } + private String requiredReturnNo(String value) { return required(value, "退货单号不能为空"); } + private static String text(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String normStatus(String value) { String result = text(value); return result == null ? null : result.toUpperCase(java.util.Locale.ROOT); } + 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); } + + public record ReceiptLine(String skuCode, String productNameZh, long quantity) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java new file mode 100644 index 000000000..e2c97cf83 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -0,0 +1,391 @@ +package com.baiyue.lastleg.returnreceipt.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.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests; +import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptResponses; +import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests; +import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses; +import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder; +import com.baiyue.lastleg.returnreceipt.entity.ReturnOrderItem; +import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt; +import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptBatch; +import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptBatchMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.entity.LastlegOrder; +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.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +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.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.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Customer return forecasts and PDA receipt facts. A receipt batch is the storage-fee start fact. */ +@Service +public class ReturnReceiptService { + public static final String PENDING_RECEIPT = "PENDING_RECEIPT"; + public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED"; + public static final String COMPLETED = "COMPLETED"; + + @Resource private ReturnOrderMapper orders; + @Resource private ReturnOrderItemMapper orderItems; + @Resource private ReturnReceiptMapper receipts; + @Resource private ReturnReceiptItemMapper receiptItems; + @Resource private ReturnReceiptBatchMapper batches; + @Resource private ReturnInventoryService inventory; + @Resource private LastlegOrderNoGenerator numbers; + @Resource private ObjectMapper objectMapper; + @Resource private LastlegOrderMapper lastlegOrders; + @Resource private ReturnInventoryCartonMapper cartons; + @Resource private ReturnInventoryCartonItemMapper cartonItems; + @Resource private RelabelApplicationMapper relabelApplications; + @Resource private RelabelItemMapper relabelItems; + + @Transactional + public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) { + String key = text(request.idempotencyKey()); + String requestHash = hash(request); + ReturnOrder replay = orders.findKey(customerId, key); + if (replay != null) return replayOrder(customerId, replay, requestHash); + Set skuCodes = new LinkedHashSet<>(); + long expected = 0; + for (ReturnOrderRequests.Item item : request.items()) { + String skuCode = text(item.skuCode()); + if (!skuCodes.add(norm(skuCode))) throw badRequest("同一退货单不能重复预报相同 SKU: " + skuCode); + expected = Math.addExact(expected, item.expectedQuantity()); + } + LocalDateTime now = LocalDateTime.now(); + ReturnOrder order = new ReturnOrder(); + order.setId(IdWorker.getId()); order.setCustomerId(customerId); + order.setOverseasWarehouseId(text(request.overseasWarehouseId())); + order.setWarehouseContactName(text(request.warehouseContactName())); + order.setWarehouseContactPhone(text(request.warehouseContactPhone())); + order.setReturnNo(numbers.next("RETURN", order.getOverseasWarehouseId(), order.getId())); + order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_RECEIPT); + order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L); + order.setRemark(text(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(requestHash); + order.setCreatedBy(text(operatorId)); order.setCreatedAt(now); order.setUpdatedAt(now); + try { orders.insert(order); } + catch (DuplicateKeyException concurrent) { + ReturnOrder existing = orders.findKey(customerId, key); + if (existing != null) return replayOrder(customerId, existing, requestHash); + throw concurrent; + } + createTailOrder(order, text(operatorId), now); + for (ReturnOrderRequests.Item input : request.items()) { + ReturnOrderItem item = new ReturnOrderItem(); + item.setId(IdWorker.getId()); item.setReturnOrderId(order.getId()); item.setCustomerId(customerId); + item.setSkuCode(text(input.skuCode())); + item.setProductNameZh(text(input.productNameZh())); item.setExpectedQuantity(input.expectedQuantity()); + item.setReceivedQuantity(0L); item.setCreatedAt(now); item.setUpdatedAt(now); orderItems.insert(item); + } + return detail(customerId, order.getReturnNo()); + } + + private void createTailOrder(ReturnOrder returnOrder, String operatorId, LocalDateTime now) { + LastlegOrder tail = new LastlegOrder(); + tail.setId(IdWorker.getId()); + tail.setOrderNo(numbers.next("RETURN_RELABEL", returnOrder.getOverseasWarehouseId(), tail.getId())); + tail.setCustomerId(returnOrder.getCustomerId()); tail.setOrderType("RETURN_RELABEL"); + tail.setSourceRegion("CUSTOMER"); tail.setSourceInboundId(returnOrder.getReturnNo()); + tail.setSourceOrderId(String.valueOf(returnOrder.getId())); + tail.setSourceOrderNo(returnOrder.getReturnNo()); tail.setOverseasWarehouseId(returnOrder.getOverseasWarehouseId()); + tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "RETURN_RELABEL:PENDING_BINDING" : "RETURN_RELABEL:NOT_REQUIRED"); + tail.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED"); + tail.setCustomerRemark(returnOrder.getRemark()); tail.setSourceEventId("RETURN_ORDER:" + returnOrder.getReturnNo()); + tail.setSourceLoadingPlanId("RETURN_RELABEL"); tail.setCreatedAt(now); tail.setUpdatedAt(now); + tail.setSourcePayloadHash(returnOrder.getRequestHash()); + lastlegOrders.insert(tail); + returnOrder.setLastlegOrderId(tail.getId()); returnOrder.setLastlegOrderNo(tail.getOrderNo()); + returnOrder.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED"); + orders.updateById(returnOrder); + } + + @Transactional(readOnly = true) + public PageResult page(String customerId, ReturnOrderRequests.Query query) { + LambdaQueryWrapper w = new LambdaQueryWrapper() + .eq(ReturnOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.returnNo()), ReturnOrder::getReturnNo, text(query.returnNo())) + .eq(StringUtils.hasText(query.status()), ReturnOrder::getStatus, norm(query.status())) + .orderByDesc(ReturnOrder::getCreatedAt).orderByDesc(ReturnOrder::getId); + if (StringUtils.hasText(query.trackingNo()) || StringUtils.hasText(query.skuCode())) { + Set ids = null; + if (StringUtils.hasText(query.skuCode())) { + ids = new LinkedHashSet<>(orderItems.selectList(new LambdaQueryWrapper() + .eq(ReturnOrderItem::getCustomerId, customerId) + .like(ReturnOrderItem::getSkuCode, text(query.skuCode()))) + .stream().map(ReturnOrderItem::getReturnOrderId).toList()); + } + if (StringUtils.hasText(query.trackingNo())) { + Set trackingIds = new LinkedHashSet<>(receipts.selectList(new LambdaQueryWrapper() + .eq(ReturnReceipt::getCustomerId, customerId) + .like(ReturnReceipt::getTrackingNo, text(query.trackingNo()))) + .stream().map(ReturnReceipt::getReturnOrderId).toList()); + if (ids == null) ids = trackingIds; else ids.retainAll(trackingIds); + } + if (ids.isEmpty()) return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); + w.in(ReturnOrder::getId, ids); + } + 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()); + } + + @Transactional(readOnly = true) + public ReturnOrderResponses.Detail detail(String customerId, String returnNo) { + ReturnOrder order = orders.findNo(customerId, text(returnNo)); + if (order == null) throw notFound("退货单不存在"); + List items = orderItems.listOrder(order.getId()).stream().map(this::item).toList(); + List receiptViews = receipts.listOrder(order.getId()).stream().map(receipt -> + new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getCartonMark(), receipt.getTrackingNo(), + receipt.getBoundBy(), value(receipt.getReceivedQuantity()), receipt.getOperatorName(), receipt.getSignedAt())).toList(); + return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews); + } + + @Transactional(readOnly = true) + public List relabelCandidates(String customerId, String returnNo) { + ReturnOrder order = orders.findNo(customerId, text(returnNo)); + if (order == null) throw notFound("退货单不存在"); + if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收"); + List result = new ArrayList<>(); + for (ReturnReceipt receipt : receipts.listOrder(order.getId())) { + ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId()); + if (carton == null || !ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) continue; + List items = cartonItems.listCarton(carton.getId()).stream() + .map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), value(i.getQuantity()), value(i.getQuantity()))).toList(); + result.add(new ReturnOrderResponses.RelabelCandidate(carton.getId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getStatus(), value(carton.getTotalQuantity()), items, carton.getRelabelStatus())); + } + return result; + } + + @Transactional + public ReturnOrderResponses.RelabelCreated createRelabel(String customerId, String operatorId, String returnNo, + ReturnOrderRequests.RelabelCreate request) { + ReturnOrder order = orders.findNo(customerId, text(returnNo)); + if (order == null) throw notFound("退货单不存在"); + if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收"); + String key = text(request.idempotencyKey()); + RelabelApplication replay = relabelApplications.findKey(customerId, key); + if (replay != null) return new ReturnOrderResponses.RelabelCreated(replay.getRelabelNo(), replay.getId(), order.getReturnNo(), request.cartonMarks(), replay.getStatus()); + List selected = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (String mark : request.cartonMarks()) { + String normalized = text(mark); + if (!seen.add(normalized.toUpperCase(java.util.Locale.ROOT))) throw badRequest("箱唛不能重复"); + ReturnInventoryCarton carton = cartons.findCustomerMark(customerId, normalized); + if (carton == null || !order.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())) throw notFound("退货箱不存在: " + normalized); + if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) throw conflict("退货箱不可申请换标: " + normalized); + if (carton.getSourceReturnNo() == null || !java.util.Arrays.asList(carton.getSourceReturnNo().split(",")).contains(order.getReturnNo())) throw conflict("退货箱不属于当前退货单: " + normalized); + selected.add(cartons.lockMark(normalized)); + } + long appId = IdWorker.getId(); LocalDateTime now = LocalDateTime.now(); + String relabelNo = numbers.next("RELABEL", order.getOverseasWarehouseId(), appId); + LastlegOrder tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("RELABEL", order.getOverseasWarehouseId(), tail.getId())); + tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER"); tail.setSourceOrderId(String.valueOf(appId)); tail.setSourceOrderNo(relabelNo); + tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus("RELABEL:RETURN_CARTON"); tail.setRelabelStatus("RETURN_CARTON"); tail.setPackageCount(selected.size()); + tail.setProductQuantity(Math.toIntExact(selected.stream().mapToLong(c -> value(c.getTotalQuantity())).sum())); tail.setCreatedAt(now); tail.setUpdatedAt(now); lastlegOrders.insert(tail); + RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId()); + app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId()); app.setContactName(text(request.contactName())); app.setContactPhone(text(request.contactPhone())); app.setDeliveryMethod(text(request.deliveryMethod()) == null ? "TRUCK" : text(request.deliveryMethod())); + app.setFulfillmentStatus("WAIT_RELABEL"); app.setRemark(text(request.remark())); app.setStatus("PENDING_PROCESSING"); app.setItemCount(selected.size()); app.setTotalPackageCount(selected.size()); app.setNotifiedPackageCount(selected.size()); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(hash(request)); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app); + for (ReturnInventoryCarton carton : selected) { + for (ReturnInventoryCartonItem ci : cartonItems.listCarton(carton.getId())) { + RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId); item.setSkuCode(ci.getSkuCode()); item.setProductNameZh(ci.getProductNameZh()); item.setTotalPackageCount(1); item.setNotifiedPackageCount(1); item.setShipmentNo(order.getReturnNo()); item.setCartonMark(carton.getCartonMark()); item.setFbaCartonMark(carton.getFbaCartonMark()); item.setOperationType("CARTON_LABEL"); item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark()); item.setCreatedAt(now); relabelItems.insert(item); + } + carton.setRelabelApplicationId(appId); carton.setRelabelNo(relabelNo); carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); cartons.updateById(carton); + } + order.setRelabelNo(relabelNo); order.setRelabelStatus("PENDING_PROCESSING"); orders.updateById(order); + return new ReturnOrderResponses.RelabelCreated(relabelNo, appId, order.getReturnNo(), selected.stream().map(ReturnInventoryCarton::getCartonMark).toList(), app.getStatus()); + } + + @Transactional(readOnly = true) + public PdaReturnReceiptResponses.Start start(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Start request) { + ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), request.returnNo()); + return new PdaReturnReceiptResponses.Start(order.getReturnNo(), order.getStatus(), orderItems.listOrder(order.getId()).stream() + .filter(item -> value(item.getReceivedQuantity()) < value(item.getExpectedQuantity())).map(this::pdaItem).toList()); + } + + @Transactional(readOnly = true) + public PdaReturnReceiptResponses.SkuCandidates skuCandidates(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.SkuLookup request) { + String skuCode = text(request.skuCode()); + List matchingItems = orderItems.activeSkuCandidates(String.valueOf(identity.overseasWarehouseId()), skuCode); + Map candidateOrders = new LinkedHashMap<>(); + if (!matchingItems.isEmpty()) { + for (ReturnOrder order : orders.selectBatchIds(matchingItems.stream().map(ReturnOrderItem::getReturnOrderId).distinct().toList())) { + candidateOrders.put(order.getId(), order); + } + } + List candidates = matchingItems.stream().map(item -> { + ReturnOrder order = candidateOrders.get(item.getReturnOrderId()); + if (order == null) throw new IllegalStateException("SKU 候选关联退货单不存在"); + return new PdaReturnReceiptResponses.Candidate(order.getReturnNo(), order.getCustomerId(), order.getCustomerReferenceNo(), + order.getStatus(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity()), + value(order.getExpectedQuantity()), value(order.getReceivedQuantity())); + }).toList(); + return new PdaReturnReceiptResponses.SkuCandidates(skuCode, candidates); + } + + @Transactional + public PdaReturnReceiptResponses.Confirmed confirm(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Confirm request) { + String key = text(request.idempotencyKey()); + String requestHash = hash(request); + ReturnReceipt replay = receipts.findKey(identity.operatorKey(), key); + if (replay != null) return replayReceipt(replay, requestHash); + String startedBy = norm(request.startedBy()); + if (!Set.of("RETURN_NO", "SKU").contains(startedBy)) throw badRequest("startedBy 必须为 RETURN_NO 或 SKU"); + String trackingNo = text(request.trackingNo()); + ReturnOrder order = orders.lockNo(text(request.returnNo())); + if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) { + throw notFound("当前绑定仓没有该退货单"); + } + // Waiting for the order lock may allow the same request to commit. Use a + // current read, not the repeatable-read snapshot established by findKey. + ReturnReceipt concurrentReplay = receipts.lockKey(identity.operatorKey(), key); + if (concurrentReplay != null) { + if (!requestHash.equals(concurrentReplay.getRequestHash())) throw conflict("幂等键已用于不同的签收请求"); + return confirmed(order, concurrentReplay, COMPLETED.equals(order.getStatus())); + } + if (!Set.of(PENDING_RECEIPT, PARTIALLY_RECEIVED).contains(order.getStatus())) { + throw conflict("仅待签收或签收中的退货单允许 PDA 签收"); + } + List lockedItems = orderItems.lockOrder(order.getId()); + Map receivedBySku = aggregate(request.items()); + List allocations = allocate(lockedItems, receivedBySku); + long receiptQuantity = allocations.stream().mapToLong(Allocation::quantity).sum(); + LocalDateTime now = LocalDateTime.now(); + long receiptId = IdWorker.getId(); + ReturnReceipt receipt = new ReturnReceipt(); + receipt.setId(receiptId); receipt.setReturnOrderId(order.getId()); receipt.setCustomerId(order.getCustomerId()); + receipt.setReceiptNo(numbers.next("RETURN_RECEIPT", order.getOverseasWarehouseId(), receiptId)); + receipt.setReceiptBatchNo(numbers.next("RETURN_BATCH", order.getOverseasWarehouseId(), receiptId)); + receipt.setTrackingNo(trackingNo); receipt.setBoundBy(startedBy); receipt.setStatus("SIGNED"); + receipt.setReceivedQuantity(receiptQuantity); receipt.setOperatorId(identity.operatorKey()); + receipt.setOperatorName(identity.operatorName()); receipt.setIdempotencyKey(key); receipt.setRequestHash(requestHash); + receipt.setSignedAt(now); receipt.setCreatedAt(now); + try { receipts.insert(receipt); } + catch (DuplicateKeyException concurrent) { + ReturnReceipt existing = receipts.lockKey(identity.operatorKey(), key); + if (existing != null) return replayReceipt(existing, requestHash); + throw concurrent; + } + for (Allocation allocation : allocations) { + ReturnOrderItem item = allocation.item(); + item.setReceivedQuantity(value(item.getReceivedQuantity()) + allocation.quantity()); item.setUpdatedAt(now); + orderItems.updateById(item); + ReturnReceiptItem receiptItem = new ReturnReceiptItem(); + receiptItem.setId(IdWorker.getId()); receiptItem.setReceiptId(receiptId); receiptItem.setReturnOrderItemId(item.getId()); + receiptItem.setCustomerId(order.getCustomerId()); receiptItem.setSkuCode(item.getSkuCode()); + receiptItem.setReceivedQuantity(allocation.quantity()); receiptItems.insert(receiptItem); + } + Map inventoryLines = new LinkedHashMap<>(); + for (Allocation allocation : allocations) { + ReturnOrderItem item = allocation.item(); + inventoryLines.merge(item.getSkuCode(), new ReturnInventoryService.ReceiptLine(item.getSkuCode(), + item.getProductNameZh(), allocation.quantity()), (left, right) -> new ReturnInventoryService.ReceiptLine( + left.skuCode(), left.productNameZh() == null ? right.productNameZh() : left.productNameZh(), + Math.addExact(left.quantity(), right.quantity()))); + } + receipt.setCartonMark(inventory.createReceiptCarton(order, receipt, request.cartonMark(), + new ArrayList<>(inventoryLines.values())).getCartonMark()); + receipts.updateById(receipt); + List finalItems = orderItems.listOrder(order.getId()); + long received = finalItems.stream().mapToLong(item -> value(item.getReceivedQuantity())).sum(); + boolean completed = finalItems.stream().allMatch(item -> value(item.getReceivedQuantity()) == value(item.getExpectedQuantity())); + order.setReceivedQuantity(received); order.setStatus(completed ? COMPLETED : PARTIALLY_RECEIVED); order.setUpdatedAt(now); orders.updateById(order); + ReturnReceiptBatch batch = new ReturnReceiptBatch(); + batch.setId(IdWorker.getId()); batch.setReturnOrderId(order.getId()); batch.setReceiptId(receiptId); + batch.setCustomerId(order.getCustomerId()); batch.setBatchNo(receipt.getReceiptBatchNo()); + batch.setOverseasWarehouseId(order.getOverseasWarehouseId()); batch.setStorageBillingStatus("PENDING_PRICING"); + batch.setStorageStartedAt(now); batch.setCreatedAt(now); batches.insert(batch); + return confirmed(order, receipt, completed); + } + + private ReturnOrder activeOrder(String warehouseId, String returnNo) { + ReturnOrder order = orders.findActiveNo(warehouseId, text(returnNo)); + if (order == null) throw notFound("当前仓没有待签收或签收中的退货单"); + return order; + } + private List allocate(List source, Map requested) { + List allocations = new ArrayList<>(); + for (Map.Entry entry : requested.entrySet()) { + long remaining = entry.getValue(); + for (ReturnOrderItem item : source) { + if (!entry.getKey().equals(item.getSkuCode())) continue; + long capacity = value(item.getExpectedQuantity()) - value(item.getReceivedQuantity()); + long accepted = Math.min(remaining, capacity); + if (accepted > 0) { allocations.add(new Allocation(item, accepted)); remaining -= accepted; } + if (remaining == 0) break; + } + if (remaining > 0) throw conflict("SKU 未预报或超出预报可签收数量: " + entry.getKey()); + } + return allocations; + } + + private Map aggregate(List inputs) { + Map result = new LinkedHashMap<>(); + for (PdaReturnReceiptRequests.Item item : inputs) result.merge(text(item.skuCode()), item.receivedQuantity(), Math::addExact); + return result; + } + private ReturnOrderResponses.Detail replayOrder(String customerId, ReturnOrder order, String requestHash) { + if (!requestHash.equals(order.getRequestHash())) throw conflict("幂等键已用于不同的退货预报"); + return detail(customerId, order.getReturnNo()); + } + private PdaReturnReceiptResponses.Confirmed replayReceipt(ReturnReceipt receipt, String requestHash) { + if (!requestHash.equals(receipt.getRequestHash())) throw conflict("幂等键已用于不同的签收请求"); + ReturnOrder order = orders.selectById(receipt.getReturnOrderId()); + if (order == null) throw new IllegalStateException("签收单关联退货单不存在"); + return confirmed(order, receipt, COMPLETED.equals(order.getStatus())); + } + private PdaReturnReceiptResponses.Confirmed confirmed(ReturnOrder order, ReturnReceipt receipt, boolean completed) { + return new PdaReturnReceiptResponses.Confirmed(order.getReturnNo(), receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getCartonMark(), + order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt()); + } + private ReturnOrderResponses.Summary summary(ReturnOrder order) { return new ReturnOrderResponses.Summary(order.getReturnNo(), order.getLastlegOrderNo(), + order.getOverseasWarehouseId(), order.getWarehouseContactName(), order.getWarehouseContactPhone(), order.getCustomerReferenceNo(), order.getStatus(), + order.getExpectedSkuTypes() == null ? 0 : order.getExpectedSkuTypes(), value(order.getExpectedQuantity()), + value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getCreatedAt()); } + private ReturnOrderResponses.Item item(ReturnOrderItem item) { return new ReturnOrderResponses.Item(item.getSkuCode(), + item.getProductNameZh(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity())); } + private PdaReturnReceiptResponses.Item pdaItem(ReturnOrderItem item) { return new PdaReturnReceiptResponses.Item(item.getSkuCode(), + item.getProductNameZh(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity())); } + private static long value(Long value) { return value == null ? 0 : value; } + private static String text(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String norm(String value) { return text(value) == null ? null : text(value).toUpperCase(java.util.Locale.ROOT); } + private String hash(Object value) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException("无法生成请求哈希", exception); } } + 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 Allocation(ReturnOrderItem item, long quantity) { } +} 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..98f5ca509 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -0,0 +1,77 @@ +package com.baiyue.lastleg.security; + +/** Canonical authorities for overseas browser 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 WAREHOUSE_TRANSFER_APPROVE = + "platform:overseas-warehouse:warehouse-transfer:approve"; + 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 RETURN_INVENTORY_LIST = "platform:overseas-warehouse:return-inventory:list"; + public static final String RETURN_CARTONS_LIST = "platform:overseas-warehouse:return-cartons:list"; + 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 DELIVERY_LIST = "platform:overseas-warehouse:delivery:list"; + public static final String DELIVERY_RETURN = "platform:overseas-warehouse:delivery:return"; + public static final String DELIVERY_STATUS = "platform:overseas-warehouse:delivery:status"; + public static final String DELIVERY_DOCUMENTS = "platform:overseas-warehouse:delivery:documents"; + public static final String DISPATCH_LIST = "platform:overseas-warehouse:dispatch:list"; + public static final String DISPATCH_EDIT = "platform:overseas-warehouse:dispatch:edit"; + public static final String DISPATCH_CANCEL = "platform:overseas-warehouse:dispatch:cancel"; + public static final String DISPATCH_TRANSFER_DELIVERY = + "platform:overseas-warehouse:dispatch:transfer-delivery"; + public static final String OUTBOUND_LIST = "platform:overseas-warehouse:outbound:list"; + public static final String OUTBOUND_CREATE = "platform:overseas-warehouse:outbound:create"; + public static final String OUTBOUND_CANCEL = "platform:overseas-warehouse:outbound:cancel"; + public static final String EXCEPTION_ORDER_LIST = "platform:overseas-warehouse:exception-order:list"; + public static final String EXCEPTION_ORDER_CREATE = "platform:overseas-warehouse:exception-order:create"; + public static final String EXCEPTION_ORDER_PROCESS = "platform:overseas-warehouse:exception-order:process"; + public static final String CUSTOMER_VISIT_LIST = "platform:overseas-warehouse:customer-visit:list"; + public static final String CUSTOMER_VISIT_EDIT = "platform:overseas-warehouse:customer-visit:edit"; + public static final String MERCHANT_DELIVERY_LIST = "merchant:warehouse:delivery:list"; + public static final String MERCHANT_DELIVERY_CREATE = "merchant:warehouse:delivery:create"; + public static final String MERCHANT_INBOUND_LIST = "merchant:warehouse:inbound:list"; + public static final String MERCHANT_PROCUREMENT = "merchant:cross-border:procurement"; + public static final String MERCHANT_TRUCKING = "merchant:cross-border:domestic-trucking"; + public static final String MERCHANT_LOOSEBOARD = "merchant:cross-border:pallet-shipping"; + 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..b7a7e72ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.security.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.time.Instant; +import java.util.Set; + +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, + Set roles, Set authorities) { + public ValidationResponse { + roles = roles == null ? Set.of() : Set.copyOf(roles); + authorities = authorities == null ? Set.of() : Set.copyOf(authorities); + } + public static ValidationResponse accepted(Set roles, Set authorities) { + return new ValidationResponse(true, null, roles, authorities); + } + public static ValidationResponse rejected(FailureReason reason) { + return new ValidationResponse(false, reason, Set.of(), Set.of()); + } + } + + 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..298d506c6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java @@ -0,0 +1,208 @@ +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; +import java.util.Set; + +@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(), Set.of(), Set.of()); + } + 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(), Set.of(), Set.of()); + } + 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()); + requireAuthorizationSnapshot(value.roles(), value.authorities()); + yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(), + null, value.permissionVersion(), envelope.aggregateVersion(), payloadHash, + envelope.eventId(), value.changedAt(), value.roles(), value.authorities()); + } + 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 static void requireAuthorizationSnapshot(Set roles, Set authorities) { + if (roles == null || authorities == null + || roles.stream().anyMatch(value -> value == null || value.isBlank()) + || authorities.stream().anyMatch(value -> value == null || value.isBlank())) { + throw new BizException(ResultCode.BAD_REQUEST, "IAM 海外授权快照无效"); + } + } + + 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) { + final String rolesJson; + final String authoritiesJson; + try { + rolesJson = objectMapper.writeValueAsString(value.roles()); + authoritiesJson = objectMapper.writeValueAsString(value.authorities()); + } catch (Exception exception) { + throw new IllegalStateException("海外授权快照序列化失败", exception); + } + jdbc.update("INSERT INTO ll_security_projection(aggregate_type,aggregate_id,tenant_id,security_status," + + "permission_version,roles_json,authorities_json,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)," + + "security_status=VALUES(security_status),permission_version=VALUES(permission_version)," + + "roles_json=VALUES(roles_json),authorities_json=VALUES(authorities_json)," + + "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(), rolesJson, authoritiesJson, 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, Set roles, + Set authorities) { } +} 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..1d8224b3f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java @@ -0,0 +1,82 @@ +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 com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +@Service +public class LastlegSecurityValidationService { + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + + public LastlegSecurityValidationService(JdbcTemplate jdbc, ObjectMapper objectMapper) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + } + + 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 permission_version FROM ll_security_projection " + + "WHERE aggregate_type='AUTHORIZATION' AND aggregate_id=? AND tenant_id=?", + (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); + } + List snapshots = jdbc.query( + "SELECT CONCAT(roles_json, CHAR(10), authorities_json) FROM ll_security_projection " + + "WHERE aggregate_type='AUTHORIZATION' AND aggregate_id=? AND tenant_id=?", + (rs, row) -> rs.getString(1), String.valueOf(request.userId()), request.tenantId()); + if (snapshots.isEmpty() || snapshots.get(0) == null || !snapshots.get(0).contains("\n")) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); + } + try { + String[] values = snapshots.get(0).split("\n", 2); + return ValidationResponse.accepted(readSet(values[0]), readSet(values[1])); + } catch (Exception exception) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); + } + } + + 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); + } + + private Set readSet(String json) throws Exception { + if (json == null || json.isBlank()) return Set.of(); + return objectMapper.readValue(json, new TypeReference>() { }); + } +} 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/FbaWarehouseValidator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/FbaWarehouseValidator.java new file mode 100644 index 000000000..5dd39f930 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/FbaWarehouseValidator.java @@ -0,0 +1,93 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; +import feign.FeignException; +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.util.List; +import java.util.Objects; + +/** 通过国内 Warehouse Service 权威主档校验尾程转仓 FBA 目的仓。 */ +@Component +public class FbaWarehouseValidator { + private static final String INVALID_MESSAGE = "所选FBA仓不存在或已停用"; + + @Resource private DomesticWarehouseDirectoryClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public VerifiedFbaWarehouse required(String requestedId, String requestedCode, String requestedName) { + long warehouseId = parseId(requestedId); + var response = lookup(warehouseId); + if (response == null || !response.successful() || response.data() == null + || response.data().warehouse() == null) { + throw invalid(); + } + DomesticWarehouseDirectoryClient.Warehouse warehouse = response.data().warehouse(); + if (!Objects.equals(warehouse.id(), warehouseId) + || !Objects.equals(warehouse.tenantId(), 0L) + || !"FBA".equals(warehouse.warehouseType()) + || !"ACTIVE".equals(warehouse.status()) + || !same(requestedCode, warehouse.warehouseCode()) + || !same(requestedName, warehouse.warehouseName())) { + throw invalid(); + } + String region = firstRegion(response.data().addresses()); + return new VerifiedFbaWarehouse(warehouse.id(), warehouse.warehouseCode().trim(), + warehouse.warehouseName().trim(), trimToNull(warehouse.countryCode()), region); + } + + private com.baiyue.common.core.api.ApiResponse lookup(long warehouseId) { + try { + return client.activeWarehouse(warehouseId, internalToken); + } catch (FeignException exception) { + if (exception.status() >= 400 && exception.status() < 500) throw invalid(); + throw unavailable(exception); + } catch (ResponseStatusException exception) { + throw exception; + } catch (RuntimeException exception) { + throw unavailable(exception); + } + } + + private long parseId(String value) { + try { + long id = Long.parseLong(value == null ? "" : value.trim()); + if (id <= 0) throw invalid(); + return id; + } catch (NumberFormatException exception) { + throw invalid(); + } + } + + private boolean same(String requested, String actual) { + return StringUtils.hasText(requested) && StringUtils.hasText(actual) + && requested.trim().equals(actual.trim()); + } + + private String firstRegion(List addresses) { + if (addresses == null) return null; + return addresses.stream().filter(Objects::nonNull) + .map(DomesticWarehouseDirectoryClient.Address::stateProvince) + .map(FbaWarehouseValidator::trimToNull).filter(Objects::nonNull).findFirst().orElse(null); + } + + private ResponseStatusException invalid() { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, INVALID_MESSAGE); + } + + private ResponseStatusException unavailable(RuntimeException cause) { + return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "国内FBA仓主数据暂不可用", cause); + } + + private static String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + public record VerifiedFbaWarehouse(long id, String code, String name, String countryCode, String region) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java new file mode 100644 index 000000000..52bf9194a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.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.OverseasInboundOrderDetainedChangedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +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 java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +/** 在尾程业务事务内冻结按来源入仓单回传国内的暂扣状态事件。 */ +@Service +public class InboundOrderDetainedOutboxService { + @Resource private LastlegEventOutboxMapper mapper; + @Resource private ObjectMapper objectMapper; + + public void publish(LastlegOrder order, boolean detained, String reason, String operatorId, + String operatorName, Instant changedAt, long changeVersion) { + requireText(order.getSourceInboundId(), "sourceInboundId"); + requireText(order.getInboundCode(), "inboundCode"); + requireText(order.getCustomerId(), "customerId"); + var payload = new OverseasInboundOrderDetainedChangedV1(order.getSourceInboundId(), + order.getInboundCode(), String.valueOf(order.getId()), order.getOrderNo(), detained, + reason, operatorId, operatorName, changedAt); + JsonNode payloadNode = objectMapper.valueToTree(payload); + String eventKey = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED + ':' + + order.getSourceInboundId() + ':' + changeVersion; + String causationId = EventIdentifiers.isSupported(order.getSourceEventId()) + ? order.getSourceEventId() : null; + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), + EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, changedAt, + RegionCode.OVERSEAS, RegionCode.CN, order.getCustomerId(), "INBOUND_ORDER", + order.getSourceInboundId(), changeVersion, 1L, EventIdentifiers.newUuid(), + eventKey, causationId, payloadNode, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); + + LastlegEventOutbox event = new LastlegEventOutbox(); + event.setId(IdWorker.getId()); + event.setCustomerId(order.getCustomerId()); + 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.ofInstant(changedAt, ZoneOffset.UTC)); + mapper.insert(event); + } + + private static void requireText(String value, String field) { + if (value == null || value.isBlank()) throw new IllegalStateException(field + " is required"); + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize inbound detained event", exception); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java new file mode 100644 index 000000000..4b71d885c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java @@ -0,0 +1,240 @@ +package com.baiyue.lastleg.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; +import com.baiyue.lastleg.entity.LastlegOrderHoldHistory; +import com.baiyue.lastleg.entity.LastlegOrderManualNode; +import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory; +import com.baiyue.lastleg.entity.LastlegOrderOperationLog; +import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; +import com.baiyue.lastleg.mapper.LastlegOrderHoldHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +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.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class LastlegOrderCommandService { + private static final String HOLD = "HOLD"; + private static final String MANUAL_NODES = "MANUAL_NODES"; + private static final List NODE_CODES = List.of( + "DOMESTIC_CUSTOMS_COMPLETED", "ESTIMATED_DEPARTURE", "ACTUAL_DEPARTURE", "ESTIMATED_ARRIVAL", + "OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED"); + + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderCommandRecordMapper commands; + @Resource private LastlegOrderHoldHistoryMapper holdHistory; + @Resource private LastlegOrderManualNodeMapper manualNodes; + @Resource private LastlegOrderManualNodeHistoryMapper manualNodeHistory; + @Resource private LastlegOrderOperationLogMapper operationLogs; + @Resource private InboundOrderDetainedOutboxService detainedOutbox; + @Resource private ObjectMapper objectMapper; + + @Transactional + public LastlegOrderCommandDtos.HoldResponse hold(String requestedCustomerId, long orderId, + String idempotencyKey, + LastlegOrderCommandDtos.HoldRequest request, + String operatorId, String operatorName) { + String key = key(idempotencyKey); + LastlegOrder order = lockOwned(requestedCustomerId, orderId); + String hash = hash(request); + LastlegOrderCommandRecord replay = commands.find(order.getCustomerId(), orderId, HOLD, operatorId, key); + if (replay != null) return replay(replay, hash, LastlegOrderCommandDtos.HoldResponse.class); + int version = version(order); + if (request.expectedVersion() != version) throw conflict("尾程订单版本已变化,请刷新后重试"); + boolean held = request.held(); + if (Boolean.TRUE.equals(order.getDetained()) == held) { + throw conflict(held ? "尾程订单已暂扣" : "尾程订单未处于暂扣状态"); + } + if (held && (positive(order.getPickedPackageCount()) || positive(order.getShippedPackageCount()) + || text(order.getOutboundOrderNo()) != null)) { + throw conflict("尾程订单已进入出库流程,不能新增暂扣"); + } + LocalDateTime now = LocalDateTime.now(); + boolean chargeable = held && request.chargeable(); + String reason = request.reason().trim(); + if (orders.updateHold(order.getCustomerId(), orderId, held, chargeable, reason, version, now) != 1) { + throw conflict("尾程订单版本已变化,请刷新后重试"); + } + int nextVersion = version + 1; + LastlegOrderCommandDtos.HoldResponse response = new LastlegOrderCommandDtos.HoldResponse( + orderId, held, chargeable, held ? reason : null, nextVersion); + LastlegOrderHoldHistory history = new LastlegOrderHoldHistory(); + history.setId(IdWorker.getId()); history.setCustomerId(order.getCustomerId()); history.setOrderId(orderId); + history.setHeld(held); history.setChargeable(chargeable); history.setReason(reason); + history.setOrderVersion(nextVersion); history.setOperatorId(operatorId); history.setOperatorName(operatorName); + history.setIdempotencyKey(key); history.setCreatedAt(now); holdHistory.insert(history); + operation(order, held ? "暂扣" : "解除暂扣", operatorName, + (chargeable ? "计费;" : "不计费;") + reason, now); + saveCommand(order, HOLD, operatorId, key, hash, null, response, nextVersion, now); + order.setDetained(held); + order.setHoldChargeable(chargeable); + order.setHoldReason(held ? reason : null); + order.setVersion(nextVersion); + if (text(order.getSourceInboundId()) != null) { + detainedOutbox.publish(order, held, reason, operatorId, operatorName, + now.toInstant(ZoneOffset.UTC), history.getId()); + } + return response; + } + + @Transactional + public LastlegOrderCommandDtos.ManualNodesResponse saveManualNodes(String requestedCustomerId, long orderId, + String idempotencyKey, + LastlegOrderCommandDtos.ManualNodesRequest request, + String operatorId, String operatorName) { + String key = key(idempotencyKey); + LastlegOrder order = lockOwned(requestedCustomerId, orderId); + List normalized = normalizeNodes(request.nodes()); + String hash = hash(new LastlegOrderCommandDtos.ManualNodesRequest(request.expectedVersion(), normalized)); + LastlegOrderCommandRecord replay = commands.find(order.getCustomerId(), orderId, MANUAL_NODES, operatorId, key); + if (replay != null) return replay(replay, hash, LastlegOrderCommandDtos.ManualNodesResponse.class); + int version = version(order); + if (request.expectedVersion() != version) throw conflict("尾程订单版本已变化,请刷新后重试"); + Map values = new HashMap<>(); + normalized.forEach(node -> values.put(node.nodeCode(), node.nodeAt())); + LocalDateTime now = LocalDateTime.now(); + if (orders.updateManualNodes(order.getCustomerId(), orderId, + values.get("ESTIMATED_ARRIVAL"), values.get("ACTUAL_ARRIVAL_WAITING_UNLOAD"), + null, version, now) != 1) { + throw conflict("尾程订单版本已变化,请刷新后重试"); + } + int nextVersion = version + 1; + manualNodes.deleteCurrent(order.getCustomerId(), orderId); + List views = new ArrayList<>(normalized.size()); + for (LastlegOrderCommandDtos.ManualNode node : normalized) { + LastlegOrderManualNode entity = new LastlegOrderManualNode(); + entity.setId(IdWorker.getId()); entity.setCustomerId(order.getCustomerId()); entity.setOrderId(orderId); + entity.setNodeCode(node.nodeCode()); entity.setNodeAt(node.nodeAt()); entity.setSource("MANUAL"); + entity.setOrderVersion(nextVersion); entity.setUpdatedBy(operatorId); entity.setUpdatedAt(now); + manualNodes.insert(entity); + views.add(new LastlegOrderCommandDtos.ManualNodeView(node.nodeCode(), node.nodeAt(), "MANUAL")); + } + LastlegOrderCommandDtos.ManualNodesResponse response = new LastlegOrderCommandDtos.ManualNodesResponse( + orderId, true, List.copyOf(views), nextVersion); + LastlegOrderManualNodeHistory history = new LastlegOrderManualNodeHistory(); + history.setId(IdWorker.getId()); history.setCustomerId(order.getCustomerId()); history.setOrderId(orderId); + history.setNodesJson(json(views)); history.setOrderVersion(nextVersion); history.setOperatorId(operatorId); + history.setOperatorName(operatorName); history.setIdempotencyKey(key); history.setCreatedAt(now); + manualNodeHistory.insert(history); + operation(order, "人工计划节点", operatorName, "保存" + views.size() + "个人工节点", now); + saveCommand(order, MANUAL_NODES, operatorId, key, hash, null, response, nextVersion, now); + return response; + } + + @Transactional(readOnly = true) + public LastlegOrderCommandDtos.ManualNodesResponse manualNodes(String requestedCustomerId, long orderId) { + LastlegOrder order = findOwned(requestedCustomerId, orderId); + List nodes = manualNodes.selectList( + new LambdaQueryWrapper() + .eq(LastlegOrderManualNode::getCustomerId, order.getCustomerId()) + .eq(LastlegOrderManualNode::getOrderId, orderId) + .orderByAsc(LastlegOrderManualNode::getNodeCode)) + .stream().map(node -> new LastlegOrderCommandDtos.ManualNodeView( + node.getNodeCode(), node.getNodeAt(), node.getSource())) + .sorted(java.util.Comparator.comparingInt(node -> NODE_CODES.indexOf(node.nodeCode()))).toList(); + return new LastlegOrderCommandDtos.ManualNodesResponse(orderId, + Boolean.TRUE.equals(order.getManualNodeOverride()), nodes, version(order)); + } + + private List normalizeNodes(List nodes) { + Set seen = new HashSet<>(); + boolean anyTime = false; + List result = new ArrayList<>(); + for (LastlegOrderCommandDtos.ManualNode node : nodes) { + String code = node.nodeCode().trim().toUpperCase(); + if (!NODE_CODES.contains(code)) throw badRequest("不支持的人工展示节点:" + code); + if (!seen.add(code)) throw badRequest("人工节点不能重复:" + code); + result.add(new LastlegOrderCommandDtos.ManualNode(code, node.nodeAt())); + anyTime |= node.nodeAt() != null; + } + if (!seen.equals(Set.copyOf(NODE_CODES))) throw badRequest("必须提交完整8行人工时间快照"); + if (!anyTime) throw badRequest("人工时间快照至少填写一个节点时间"); + result.sort(java.util.Comparator.comparingInt(node -> NODE_CODES.indexOf(node.nodeCode()))); + return List.copyOf(result); + } + + private LastlegOrder lockOwned(String requestedCustomerId, long orderId) { + LastlegOrder found = findOwned(requestedCustomerId, orderId); + LastlegOrder locked = orders.lockById(found.getCustomerId(), orderId); + if (locked == null) throw notFound(); + return locked; + } + + private LastlegOrder findOwned(String requestedCustomerId, long orderId) { + LastlegOrder order = orders.selectById(orderId); + if (order == null || (text(requestedCustomerId) != null + && !requestedCustomerId.trim().equals(order.getCustomerId()))) throw notFound(); + return order; + } + + private void operation(LastlegOrder order, String node, String operatorName, String result, LocalDateTime now) { + LastlegOrderOperationLog log = new LastlegOrderOperationLog(); + log.setId(IdWorker.getId()); log.setCustomerId(order.getCustomerId()); log.setOrderId(order.getId()); + log.setOperationNode(node); log.setOperatorName(operatorName); log.setResult(result); log.setCreatedAt(now); + operationLogs.insert(log); + } + + private void saveCommand(LastlegOrder order, String action, String operatorId, String key, String hash, + String resultRef, Object response, int resultVersion, LocalDateTime now) { + LastlegOrderCommandRecord record = new LastlegOrderCommandRecord(); + record.setId(IdWorker.getId()); record.setCustomerId(order.getCustomerId()); record.setOrderId(order.getId()); + record.setAction(action); record.setOperatorId(operatorId); record.setIdempotencyKey(key); + record.setRequestHash(hash); record.setResultRef(resultRef); record.setResultJson(json(response)); + record.setResultVersion(resultVersion); record.setCreatedAt(now); commands.insert(record); + } + + private T replay(LastlegOrderCommandRecord record, String hash, Class type) { + if (!MessageDigest.isEqual(record.getRequestHash().getBytes(StandardCharsets.UTF_8), + hash.getBytes(StandardCharsets.UTF_8))) throw conflict("同一幂等键不能用于不同请求"); + try { return objectMapper.readValue(record.getResultJson(), type); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取已保存命令结果", exception); } + } + + private String hash(Object request) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsBytes(request))); + } catch (NoSuchAlgorithmException | JsonProcessingException exception) { + throw new IllegalStateException("无法生成命令摘要", exception); + } + } + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存命令结果", exception); } + } + private String key(String value) { + String key = text(value); + if (key == null || key.length() > 128) throw badRequest("Idempotency-Key 必须为1至128个字符"); + return key; + } + private int version(LastlegOrder order) { return order.getVersion() == null ? 0 : order.getVersion(); } + private boolean positive(Integer value) { return value != null && value > 0; } + private String text(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在"); } + private ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} 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..fb34ba751 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -0,0 +1,112 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.UUID; +import java.math.BigInteger; +import java.nio.ByteBuffer; + +@Component +public class LastlegOrderNoGenerator { + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter BUSINESS_DATE = DateTimeFormatter.ofPattern("yyMMdd"); + @Resource private LastlegOrderNoDailySequenceMapper sequenceMapper; + + public String next() { + return next(LocalDate.now(BUSINESS_ZONE)); + } + + String next(LocalDate businessDate) { + sequenceMapper.nextValue(businessDate); + long sequence = sequenceMapper.currentValue(); + if (sequence < 1 || sequence > 9999) { + throw new IllegalStateException("尾程单日流水超出 0001-9999 范围: " + businessDate); + } + return "LMO-" + BUSINESS_DATE.format(businessDate) + "-" + String.format("%04d", sequence); + } + + public String next(String businessType, String warehouse, long uniqueId) { + String code = businessType == null ? "" : businessType.trim().toUpperCase(Locale.ROOT); + String returnPrefix = switch (code) { + case "RETURN" -> "RT"; + case "RETURN_RECEIPT" -> "RR"; + case "RETURN_BATCH" -> "RB"; + case "RETURN_CARTON" -> "RC"; + case "RETURN_RELABEL" -> "RETU"; + default -> null; + }; + // Return numbers are independent business identifiers, not truncated database IDs. + // Keep every UUID bit; the longest result (RETU + 25 base-36 digits) fits VARCHAR(32). + if (returnPrefix != null) return returnPrefix + "-" + encodeReturnUuid(UUID.randomUUID()); + String type = normalizeType(businessType); + return type + "-" + warehouseCode(warehouse) + "-" + + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" + + String.format(Locale.ROOT, "%06d", Math.floorMod(uniqueId, 1_000_000)); + } + + static String encodeReturnUuid(UUID uuid) { + byte[] bytes = ByteBuffer.allocate(16).putLong(uuid.getMostSignificantBits()) + .putLong(uuid.getLeastSignificantBits()).array(); + String encoded = new BigInteger(1, bytes).toString(36).toUpperCase(Locale.ROOT); + return "0".repeat(25 - encoded.length()) + encoded; + } + + private String normalizeType(String value) { + String code = value == null ? "LMO" : value.trim().toUpperCase(Locale.ROOT); + return switch (code) { + case "FIRST_LEG" -> "FL"; + case "ONE_CLICK_DROPSHIP" -> "OD"; + case "RELABEL_APPLICATION", "RELABEL" -> "RL"; + case "WAREHOUSE_TRANSFER", "TRANSFER" -> "WT"; + case "LOCAL_LOOSEBOARD", "LOOSEBOARD" -> "LB"; + case "DISPATCH", "PICKUP_CAR" -> "DS"; + case "OUTBOUND" -> "OB"; + case "DELIVERY" -> "DL"; + case "TRUCK" -> "TR"; + case "EXCEPTION" -> "EX"; + case "DISCARD" -> "CD"; + case "PROCUREMENT" -> "PR"; + case "INBOUND_PLAN" -> "IP"; + default -> { + String normalized = code.replaceAll("[^A-Z0-9]", ""); + yield normalized.isEmpty() ? "LMO" : normalized.substring(0, Math.min(4, normalized.length())); + } + }; + } + + public String warehouseCode(String value) { + String code = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (code.isEmpty()) return "WH"; + if (code.matches(".*(LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD).*")) { + var match = java.util.regex.Pattern.compile("(LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD)").matcher(code); + if (match.find()) return match.group(1); + } + if (code.matches("[A-Z0-9]{2,8}")) return code; + return "WH"; + } + + /** 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(); + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(normalized.getBytes(StandardCharsets.UTF_8)); + long suffix = java.nio.ByteBuffer.wrap(digest, 0, 8).getLong(); + return next("FIRST_LEG", null, suffix); + } 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..622a55aae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -0,0 +1,743 @@ +package com.baiyue.lastleg.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.common.event.ContainerNumbers; +import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; +import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; +import com.baiyue.lastleg.dto.ContainerArrivalTimeView; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.entity.LastlegOrderOperationLog; +import com.baiyue.lastleg.entity.LastlegOrderManualNode; +import com.baiyue.lastleg.entity.ReceivingWarehouseOperation; +import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; +import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper; +import com.baiyue.lastleg.mapper.ContainerArrivalTimeOperationMapper; +import com.baiyue.lastleg.model.LastlegOrderType; +import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +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.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@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"; + public static final String PALLET_MERGE_REQUIRED = "REQUIRED"; + public static final String PALLET_MERGED = "MERGED"; + private static final List MANUAL_NODE_CODES = List.of( + "DOMESTIC_CUSTOMS_COMPLETED", "ESTIMATED_DEPARTURE", "ACTUAL_DEPARTURE", "ESTIMATED_ARRIVAL", + "OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED"); + + @Resource private LastlegOrderMapper orderMapper; + @Resource private LastlegOrderDetailMapper detailMapper; + @Resource private LastlegOrderStatusHistoryMapper historyMapper; + @Resource private OverseasFeeSourceService feeSources; + @Resource private LastlegOrderOperationLogMapper operationLogMapper; + @Resource private LastlegOrderManualNodeMapper manualNodeMapper; + @Resource private InboundPlanCartonMapper cartonMapper; + @Resource private FbaWarehouseValidator fbaWarehouseValidator; + @Resource private InventoryCommandService inventory; + @Resource private InboundOrderDetainedOutboxService detainedOutbox; + @Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations; + @Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations; + @Resource private WarehouseResourceGateway warehouseResourceGateway; + @Resource private ContainerShippingGateMapper containerShippingGates; + + public PageResult page(String customerId, LastlegOrderRequests.Query query) { + return page(customerId, query, null); + } + + public PageResult page(String customerId, LastlegOrderRequests.Query query, String relabelNo) { + Page page = orderMapper.selectPage( + new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt)); + Map> mergeRelations = palletMergeRelations(page.getRecords()); + Map containerNodes = containerNodes(page.getRecords()); + Map dispatchStatuses = dispatchStatuses(page.getRecords()); + return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()), + StringUtils.hasText(order.getSourceContainerBookingId()) + ? containerNodes.get(order.getSourceContainerBookingId()) : null, dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"))).toList(), + page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + @Transactional(readOnly = true) + public PageResult containerProgressPage( + LastlegOrderRequests.ContainerProgressQuery 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 = orderMapper.countContainerProgress(query); + List records = total == 0 ? List.of() + : orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size()) + .stream().map(this::containerSummaryForRead).toList(); + long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size(); + return new PageResult<>(records, query.page(), query.size(), total, pages); + } + + @Transactional(readOnly = true) + public ContainerPlanProgressDetail containerProgressDetail(String sourceContainerBookingId) { + String bookingId = sourceContainerBookingId == null ? null : sourceContainerBookingId.trim(); + if (!StringUtils.hasText(bookingId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能为空"); + } + ContainerPlanProgressSummary summary = orderMapper.findContainerProgress(bookingId); + if (summary == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); + } + return new ContainerPlanProgressDetail(containerSummaryForRead(summary), + orderMapper.findContainerProgressItems(bookingId)); + } + + @Transactional(readOnly = true) + public List containerArrivalTimes( + LastlegOrderRequests.ContainerArrivalTimeQuery request) { + LinkedHashSet bookingIds = new LinkedHashSet<>(); + for (String value : request.sourceContainerBookingIds()) bookingIds.add(normalizeBookingId(value)); + if (bookingIds.size() != request.sourceContainerBookingIds().size()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能重复"); + } + return orderMapper.findContainerArrivalTimes(List.copyOf(bookingIds)); + } + + @Transactional + public ContainerArrivalTimeView changeExpectedWarehouseArrival(String sourceContainerBookingId, + String idempotencyKey, LastlegOrderRequests.ChangeExpectedWarehouseArrival request, + String operatorName) { + String bookingId = normalizeBookingId(sourceContainerBookingId); + String key = normalizeIdempotencyKey(idempotencyKey); + String requestHash = hash(bookingId + "\n" + request.estimatedWarehouseArrivalAt() + "\n" + + request.expectedVersion() + "\n" + Objects.toString(request.remark(), "")); + ContainerArrivalTimeOperation replay = containerArrivalTimeOperations.findByKey(key); + if (replay != null) { + if (!bookingId.equals(replay.getSourceContainerBookingId()) + || !requestHash.equals(replay.getRequestHash())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Idempotency-Key 已用于其他预计到仓时间修改请求"); + } + return requireContainerArrivalTime(bookingId); + } + + List orders = orderMapper.lockContainerOrders(bookingId); + if (orders.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); + int version = requireSingleArrivalTimeVersion(orders); + if (version != request.expectedVersion()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "预计到仓时间版本已变化,请刷新后重试"); + } + if (orderMapper.countContainerPlans(bookingId) > 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜已生成入库计划,预计到仓时间不可修改"); + } + int updated = orderMapper.updateContainerExpectedWarehouseArrival(bookingId, + request.estimatedWarehouseArrivalAt(), version); + if (updated != orders.size()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间发生并发变化"); + } + ContainerArrivalTimeOperation operation = new ContainerArrivalTimeOperation(); + operation.setId(IdWorker.getId()); operation.setSourceContainerBookingId(bookingId); + operation.setIdempotencyKey(key); operation.setRequestHash(requestHash); + operation.setResultVersion(version + 1); operation.setOperatorName(operatorName); + operation.setRemark(request.remark() == null ? null : request.remark().trim()); + operation.setCreatedAt(LocalDateTime.now()); containerArrivalTimeOperations.insert(operation); + return requireContainerArrivalTime(bookingId); + } + + private ContainerArrivalTimeView requireContainerArrivalTime(String bookingId) { + List values = orderMapper.findContainerArrivalTimes(List.of(bookingId)); + if (values.size() != 1) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); + if (values.get(0).version() < 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致"); + } + return values.get(0); + } + + private int requireSingleArrivalTimeVersion(List orders) { + Set versions = orders.stream().map(LastlegOrder::getArrivalTimeVersion) + .map(value -> value == null ? 0 : value).collect(Collectors.toSet()); + if (versions.size() != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致"); + } + return versions.iterator().next(); + } + + @Transactional + public ContainerPlanProgressSummary changeReceivingWarehouse(String sourceContainerBookingId, + String idempotencyKey, LastlegOrderRequests.ChangeReceivingWarehouse request, String operatorName) { + String bookingId = normalizeBookingId(sourceContainerBookingId); + String key = normalizeIdempotencyKey(idempotencyKey); + String requestHash = hash(bookingId + "\n" + request.overseasWarehouseId() + "\n" + + request.expectedVersion() + "\n" + Objects.toString(request.remark(), "")); + ReceivingWarehouseOperation replay = receivingWarehouseOperations.findByKey(key); + if (replay != null) { + if (!bookingId.equals(replay.getSourceContainerBookingId()) || !requestHash.equals(replay.getRequestHash())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Idempotency-Key 已用于其他接柜仓修改请求"); + } + return requireFrozenContainer(orderMapper.findContainerProgress(bookingId)); + } + + List orders = orderMapper.lockContainerOrders(bookingId); + if (orders.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); + int version = requireSingleAssignmentVersion(orders); + if (version != request.expectedVersion()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓分配版本已变化,请刷新后重试"); + } + if (orderMapper.countReceivingSessions(bookingId) > 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "海外PDA已建立接柜会话,接柜仓已永久锁定"); + } + WarehouseResourceGateway.WarehouseReference warehouse = + warehouseResourceGateway.requireActiveSharedWarehouse(request.overseasWarehouseId()); + for (LastlegOrder order : orders) { + inventory.rebindReceivingWarehouse(order.getCustomerId(), order.getId(), warehouse.warehouseId(), + warehouse.warehouseCode(), warehouse.warehouseName(), operatorName, key); + } + int updated = orderMapper.updateContainerReceivingWarehouse(bookingId, + Long.toString(warehouse.warehouseId()), warehouse.warehouseCode(), warehouse.warehouseName(), version); + if (updated != orders.size()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓分配发生并发变化"); + } + orderMapper.updateContainerPlanReceivingWarehouse(bookingId, warehouse.warehouseId()); + ReceivingWarehouseOperation operation = new ReceivingWarehouseOperation(); + operation.setId(IdWorker.getId()); operation.setSourceContainerBookingId(bookingId); + operation.setIdempotencyKey(key); operation.setRequestHash(requestHash); + operation.setResultVersion(version + 1); operation.setOperatorName(operatorName); + operation.setRemark(request.remark() == null ? null : request.remark().trim()); + operation.setCreatedAt(LocalDateTime.now()); receivingWarehouseOperations.insert(operation); + return requireFrozenContainer(orderMapper.findContainerProgress(bookingId)); + } + + private ContainerPlanProgressSummary requireFrozenContainer(ContainerPlanProgressSummary summary) { + if (summary == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); + String issue = containerSnapshotIssue(summary); + if (issue != null) throw new ResponseStatusException(HttpStatus.CONFLICT, issue); + return summary; + } + + private ContainerPlanProgressSummary containerSummaryForRead(ContainerPlanProgressSummary summary) { + String issue = containerSnapshotIssue(summary); + if (issue == null) return summary; + boolean invalidContainer = !hasCanonicalContainerNo(summary.containerNo()); + return new ContainerPlanProgressSummary(summary.sourceContainerBookingId(), summary.containerNo(), + summary.overseasWarehouseId(), summary.overseasWarehouseCode(), summary.overseasWarehouseName(), + summary.assignmentVersion(), invalidContainer ? false : summary.canChangeReceivingWarehouse(), + invalidContainer ? issue : summary.receivingWarehouseLockReason(), summary.totalOrderCount(), + summary.inboundCodeCount(), summary.missingInboundCodeCount(), summary.planCount(), + summary.plannedInboundCodeCount(), summary.pendingPlanCount(), summary.resourcePendingPlanCount(), summary.plannedArrivalAt(), + false, issue); + } + + private String containerSnapshotIssue(ContainerPlanProgressSummary summary) { + if (!hasCanonicalContainerNo(summary.containerNo())) { + return "来源柜真实集装箱号缺失、格式无效或存在漂移"; + } + if (!StringUtils.hasText(summary.overseasWarehouseId()) + || !StringUtils.hasText(summary.overseasWarehouseCode()) + || !StringUtils.hasText(summary.overseasWarehouseName()) || summary.assignmentVersion() < 0) { + return "柜内接柜仓业务快照不一致"; + } + return null; + } + + private boolean hasCanonicalContainerNo(String containerNo) { + try { + ContainerNumbers.requireCanonical(containerNo); + return true; + } catch (IllegalArgumentException exception) { + return false; + } + } + + private String normalizeBookingId(String value) { + String result = value == null ? null : value.trim(); + if (!StringUtils.hasText(result) || result.length() > 128) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能为空"); + } + return result; + } + + private String normalizeIdempotencyKey(String value) { + String result = value == null ? null : value.trim(); + if (!StringUtils.hasText(result) || result.length() > 255) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key 必填且不得超过255字符"); + } + return result; + } + + private int requireSingleAssignmentVersion(List orders) { + Integer version = orders.get(0).getReceivingWarehouseVersion(); + if (version == null || orders.stream().anyMatch(order -> !Objects.equals(version, + order.getReceivingWarehouseVersion()) || !StringUtils.hasText(order.getOverseasWarehouseId()) + || !StringUtils.hasText(order.getOverseasWarehouseCode()) + || !StringUtils.hasText(order.getOverseasWarehouseName()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓分配事实不一致"); + } + return version; + } + + private String hash(String value) { + try { + return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + + 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)); + List fees = feeSources.listForBusiness(order.getCustomerId(), "LASTLEG_ORDER", orderId); + List operations = operationLogMapper.selectList( + new LambdaQueryWrapper() + .eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId()) + .eq(LastlegOrderOperationLog::getOrderId, orderId) + .orderByAsc(LastlegOrderOperationLog::getCreatedAt)); + List manualNodes = manualNodeMapper.selectList( + new LambdaQueryWrapper() + .eq(LastlegOrderManualNode::getCustomerId, order.getCustomerId()) + .eq(LastlegOrderManualNode::getOrderId, orderId) + .orderByAsc(LastlegOrderManualNode::getId)).stream() + .map(value -> new LastlegOrderCommandDtos.ManualNodeView( + value.getNodeCode(), value.getNodeAt(), value.getSource())) + .sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList(); + return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()), + containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED")), manualNodes, cargo, history.stream() + .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), + value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() + .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), + value.getOperatorName(), value.getResult(), value.getCreatedAt())).toList()); + } + + @Transactional + public OverseasFeeSource createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request, + String operatorName, String idempotencyKey) { + LastlegOrder order = findOwned(customerId, orderId); + LocalDateTime now = LocalDateTime.now(); + OverseasFeeSource fee = feeSources.create(operatorName, idempotencyKey, + new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(), + "OVERSEAS_ORDER_FEE", "LASTLEG_ORDER", orderId, order.getOrderNo(), orderId, + null, order.getInboundCode(), null, parseLong(order.getSourceContainerBookingId()), + order.getContainerNo(), parseLong(order.getOverseasWarehouseId()), request.feeItemCode(), + request.feeItemName(), request.direction(), request.supplierId(), request.supplierName(), + request.supplierCategory(), request.settlementMode(), request.paidAt(), request.paymentMethod(), + request.paymentReferenceNo(), request.paymentVoucherFileIds(), request.businessDate(), + request.currency(), request.amount(), request.chargeableQuantity(), request.quantityUnit(), + request.unitPrice(), request.periodStart(), request.periodEnd())); + fee = feeSources.submit(fee.getSourceFeeId(), operatorName, childKey(idempotencyKey, "submit")); + + LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(order.getCustomerId()); + operation.setOrderId(orderId); + operation.setOperationNode("新增费用"); + operation.setOperatorName(operatorName); + operation.setResult("新增" + fee.getFeeItemName() + ":" + fee.getOriginalCurrency() + " " + + fee.getOriginalAmount().toPlainString() + ",已提交财务审核"); + operation.setCreatedAt(now); + operationLogMapper.insert(operation); + return fee; + } + + @Transactional + public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request, + String operatorName) { + setDetained(customerId, orderId, request, null, operatorName); + } + + @Transactional + public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request, + String operatorId, String operatorName) { + LastlegOrder current = findOwned(customerId, orderId); + LastlegOrder order = orderMapper.lockById(current.getCustomerId(), orderId); + if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); + boolean detained = request.detained(); + if (Boolean.TRUE.equals(order.getDetained()) == detained) { + throw new ResponseStatusException(HttpStatus.CONFLICT, detained ? "Order is already held" : "Order is not held"); + } + if (detained && "INBOUND_COMPLETED".equals(order.getCurrentStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed inbound orders cannot be newly held"); + } + LocalDateTime now = LocalDateTime.now(); + order.setDetained(detained); + order.setUpdatedAt(now); + orderMapper.updateById(order); + if (!detained && "INBOUND_COMPLETED".equals(order.getCurrentStatus())) { + inventory.releaseInboundOrderHold(order.getCustomerId(), operatorName, orderId, order.getOrderNo(), request.remark().trim()); + } + LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(order.getCustomerId()); + operation.setOrderId(orderId); + operation.setOperationNode(detained ? "暂扣" : "解除暂扣"); + operation.setOperatorName(operatorName); + operation.setResult(request.remark().trim()); + operation.setCreatedAt(now); + operationLogMapper.insert(operation); + if (StringUtils.hasText(order.getSourceInboundId())) { + detainedOutbox.publish(order, detained, request.remark().trim(), operatorId, operatorName, + now.toInstant(ZoneOffset.UTC), operation.getId()); + } + } + + @Transactional + public void setPalletMergeRequired(String customerId, long orderId, + LastlegOrderRequests.SetPalletMergeRequired request, + String operatorName) { + LastlegOrder current = findOwned(customerId, orderId); + LastlegOrder order = orderMapper.lockById(current.getCustomerId(), orderId); + if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); + if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Completed inbound orders cannot change the pallet merge mark"); + } + if (PALLET_MERGED.equals(order.getPalletMergeFlag())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "A completed pallet merge cannot be unmarked"); + } + String target = request.palletMergeRequired() ? PALLET_MERGE_REQUIRED : null; + if (Objects.equals(target, order.getPalletMergeFlag())) return; + LocalDateTime now = LocalDateTime.now(); + if (orderMapper.updatePalletMergeFlag(order.getCustomerId(), orderId, target, now) != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet merge mark was not updated"); + } + LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(order.getCustomerId()); + operation.setOrderId(orderId); + operation.setOperationNode(request.palletMergeRequired() ? "标记合托" : "取消合托标记"); + operation.setOperatorName(operatorName); + operation.setResult(request.palletMergeRequired() + ? "PDA扫描本单箱唛时提示需要合托" : "已取消PDA合托提示"); + operation.setCreatedAt(now); + operationLogMapper.insert(operation); + } + + @Transactional + public void setPalletMergeBatch(String customerId, LastlegOrderRequests.SetPalletMergeBatch request, + String operatorName) { + 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"); + } + if (orders.stream().map(LastlegOrder::getCustomerId).distinct().count() != 1) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Pallet merge orders must belong to one customer"); + } + if (orders.stream().map(order -> StringUtils.hasText(order.getTransferWarehouseCode()) + ? order.getTransferWarehouseCode() : order.getFbaWarehouseCode()).filter(Objects::nonNull).distinct().count() > 1) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Pallet merge orders must have one destination warehouse"); + } + if (orders.stream().anyMatch(order -> "INBOUND_COMPLETED".equals(order.getCurrentStatus()) + || PALLET_MERGED.equals(order.getPalletMergeFlag()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed pallet merge orders cannot be changed"); + } + String batchId = request.palletMergeRequired() + ? "PMG-" + java.util.UUID.randomUUID().toString().replace("-", "") : null; + String flag = request.palletMergeRequired() ? PALLET_MERGE_REQUIRED : null; + LocalDateTime now = LocalDateTime.now(); + for (LastlegOrder order : orders) { + if (orderMapper.updatePalletMergeBatch(order.getCustomerId(), order.getId(), flag, batchId, now) != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet merge group was not updated"); + } + LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); + operation.setId(IdWorker.getId()); operation.setCustomerId(order.getCustomerId()); + operation.setOrderId(order.getId()); + operation.setOperationNode(request.palletMergeRequired() ? "批量标记合托" : "取消合托标记"); + operation.setOperatorName(operatorName); + operation.setResult(request.palletMergeRequired() ? "PDA合托分组 " + batchId : "已取消PDA合托分组"); + operation.setCreatedAt(now); operationLogMapper.insert(operation); + } + } + + @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"); + } + FbaWarehouseValidator.VerifiedFbaWarehouse target = fbaWarehouseValidator.required( + request.warehouseId(), request.warehouseCode(), request.warehouseName()); + String warehouseId = Long.toString(target.id()); + 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(warehouseId); + order.setTransferWarehouseCode(target.code()); + order.setTransferWarehouseName(target.name()); + order.setCurrentStatus(DESTINATION_ASSIGNED); + order.setUpdatedAt(now); + orderMapper.updateById(order); + + LastlegOrderDetail detailUpdate = new LastlegOrderDetail(); + detailUpdate.setDestinationWarehouseId(warehouseId); + detailUpdate.setDestinationWarehouseCode(target.code()); + detailUpdate.setDestinationWarehouseName(target.name()); + detailMapper.update(detailUpdate, new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) + .eq(LastlegOrderDetail::getOrderId, order.getId())); + + InboundPlanCarton cartonUpdate = new InboundPlanCarton(); + cartonUpdate.setDestinationWarehouseId(warehouseId); + cartonUpdate.setDestinationWarehouseCode(target.code()); + cartonUpdate.setDestinationWarehouseName(target.name()); + 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: " + target.code()); + history.setCreatedAt(now); + historyMapper.insert(history); + } + return orders.size(); + } + + public byte[] exportCsv(String customerId, LastlegOrderRequests.Query query) { + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + writeExportCsv(customerId, query, output); + return output.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("Unable to export tail-leg orders", e); + } + } + + public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output) + throws IOException { + output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8)); + final int batchSize = 1000; + long page = 1; + while (true) { + List orders = orderMapper.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, batchSize), + filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)).getRecords(); + if (orders.isEmpty()) break; + Map containerNodes = containerNodes(orders); + for (LastlegOrder order : orders) { + String line = String.join(",", List.of( + csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())), + csv(displayInboundCode(order)), + csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()), + csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), + csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId()) + ? containerNodes.get(order.getSourceContainerBookingId()) : null), 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(relabelBusinessStatus(order.getRelabelStatus())), + csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()), + csv(order.getCustomerRemark()), csv(order.getWarehouseRemark()))) + "\r\n"; + output.write(line.getBytes(StandardCharsets.UTF_8)); + } + output.flush(); + if (orders.size() < batchSize) break; + page++; + } + } + + private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.Query query) { + return filter(customerId, query, null); + } + + private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.Query query, String relabelNo) { + 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(); + String warehouse = StringUtils.hasText(query.warehouse()) ? query.warehouse().trim() : null; + return new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) + .and(StringUtils.hasText(query.orderNo()), nested -> nested + .like(LastlegOrder::getOrderNo, query.orderNo()) + .or() + .like(LastlegOrder::getSourceOrderNo, query.orderNo())) + .like(StringUtils.hasText(relabelNo), LastlegOrder::getRelabelOrderNo, relabelNo) + .eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType()) + .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) + .like(StringUtils.hasText(query.lxCode()), LastlegOrder::getLxCode, query.lxCode()) + .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) + .apply(StringUtils.hasText(warehouse), LastlegOrderMapper.WAREHOUSE_FILTER_SQL, 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 Map> palletMergeRelations(List orders) { + if (orders.isEmpty()) return Map.of(); + Map> result = new LinkedHashMap<>(); + List rows = orderMapper.findPalletMergeRelations( + orders.stream().map(LastlegOrder::getId).toList()); + if (rows == null) return Map.of(); + for (LastlegOrderMapper.PalletMergeRelationRow row : rows) { + result.computeIfAbsent(row.orderId(), ignored -> new ArrayList<>()).add(row); + } + return result; + } + + private Map dispatchStatuses(List orders) { + if (orders.isEmpty()) return Map.of(); + List rows = orderMapper.findDispatchStatuses( + orders.stream().map(LastlegOrder::getId).toList()); + if (rows == null) return Map.of(); + return rows.stream().collect(Collectors.toMap(LastlegOrderMapper.DispatchStatusRow::orderId, + LastlegOrderMapper.DispatchStatusRow::dispatchStatus)); + } + + private LastlegOrderSummary summary(LastlegOrder o, List relations, + String containerNode, String dispatchStatus) { + List related = relations == null ? List.of() : relations; + return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(), + LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), + displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), + o.getSourceContainerBookingId(), o.getOverseasWarehouseId(), + o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), + o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode, + o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(), + Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER", + o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), + o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), + o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), relabelBusinessStatus(o.getRelabelStatus()), + o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), + related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(), + o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), + o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); + } + + static String displayOrderNo(LastlegOrder order) { + return "WAREHOUSE_TRANSFER".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo()) + ? order.getSourceOrderNo() : order.getOrderNo(); + } + + private String containerNode(LastlegOrder order) { + if (!StringUtils.hasText(order.getSourceContainerBookingId())) return null; + ContainerShippingGate gate = containerShippingGates.selectById(order.getSourceContainerBookingId()); + return gate == null ? null : gate.getStandardNode(); + } + + private Map containerNodes(List orders) { + List bookingIds = orders.stream().map(LastlegOrder::getSourceContainerBookingId) + .filter(StringUtils::hasText).distinct().toList(); + if (bookingIds.isEmpty()) return Map.of(); + Map result = new LinkedHashMap<>(); + containerShippingGates.selectBatchIds(bookingIds).forEach(gate -> { + if (StringUtils.hasText(gate.getStandardNode())) { + result.put(gate.getContainerBookingId(), gate.getStandardNode()); + } + }); + return result; + } + + private static String displayInboundCode(LastlegOrder order) { + String orderType = order.getOrderType(); + return "RELABEL_APPLICATION".equals(orderType) || "LOCAL_LOOSEBOARD".equals(orderType) + ? null : order.getInboundCode(); + } + + private static String relabelBusinessStatus(String workflowStatus) { + if ("PROCESSING".equals(workflowStatus)) return "PROCESSING"; + if (!StringUtils.hasText(workflowStatus) || "NOT_REQUIRED".equals(workflowStatus)) return workflowStatus; + return Set.of("READY_FOR_OUTBOUND", "COMPLETED").contains(workflowStatus) + ? "COMPLETED" : "PENDING_PROCESSING"; + } + + private static Long parseLong(String value) { + try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; } + catch (NumberFormatException ignored) { return null; } + } + + private static String childKey(String key, String suffix) { + String prefix = key == null ? "" : key.trim(); + int maxPrefix = Math.max(0, 128 - suffix.length() - 1); + return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix; + } + + 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..15e448879 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -0,0 +1,671 @@ +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.inboundplan.pda.entity.ContainerLoadingEvidence; +import com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; +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.util.Map; +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 { + static final String DEFAULT_RECEIVING_WAREHOUSE_ID = "193000000000200003"; + static final String DEFAULT_RECEIVING_WAREHOUSE_CODE = "US-LAX-01"; + static final String DEFAULT_RECEIVING_WAREHOUSE_NAME = "洛杉矶仓"; + @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; + @Resource private LastlegSourcePalletMapper sourcePallets; + @Resource private ContainerLoadingEvidenceMapper loadingEvidence; + + @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 && envelope.eventVersion() != 3)) + 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); + String containerNo = ContainerNumbers.requireCanonical(payload.containerNo()); + String sourceContainerBookingId = sourceContainerBookingId(envelope, payload); + if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); + OwnerSnapshot owner = requireOwnerSnapshot(payload.orders(), envelope.tenantId()); + if (envelope.eventVersion() >= 2) validateCartonSkuMappings(payload, envelope.payload()); + freezeContainerIdentity(envelope, sourceContainerBookingId, containerNo); + LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); + if(existing!=null) { + if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突"); + return countOrders(envelope,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,containerNo); + } + + projectLoadingEvidence(envelope, sourceContainerBookingId); + + // 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. + JsonNode rawOrders = envelope.payload().path("orders"); + String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); + 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, containerNo, + sourceContainerBookingId, owner); + } + inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox); + return payload.orders().size(); + } + + private void projectLoadingEvidence(EventEnvelope envelope, String sourceContainerBookingId) { + if (envelope.eventVersion() < 3) return; + JsonNode values = envelope.payload().path("loadingEvidence"); + if (!values.isArray() || values.size() != 5) { + throw new IllegalArgumentException("V3 尾程交接事件必须包含五类国内装柜照片"); + } + Set expected = Set.of("PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"); + Set actual = new HashSet<>(); + for (JsonNode value : values) { + String type = value.path("evidenceType").asText("").trim(); + long fileId = value.path("fileId").asLong(0); + String fileName = value.path("fileName").asText("").trim(); + if (!expected.contains(type) || !actual.add(type) || fileId <= 0 + || fileName.isBlank() || fileName.length() > 255) { + throw new IllegalArgumentException("V3 尾程交接事件的国内装柜照片引用无效"); + } + ContainerLoadingEvidence existing = loadingEvidence.find(sourceContainerBookingId, type); + if (existing != null) { + if (!Objects.equals(existing.getSourceFileId(), fileId) + || !Objects.equals(existing.getFileName(), fileName)) { + throw new IllegalStateException("同一来源柜的国内装柜照片快照冲突"); + } + continue; + } + ContainerLoadingEvidence row = new ContainerLoadingEvidence(); + row.setId(IdWorker.getId()); + row.setSourceContainerBookingId(sourceContainerBookingId); + row.setEvidenceType(type); row.setSourceFileId(fileId); row.setFileName(fileName); + row.setCreatedAt(LocalDateTime.now()); + try { loadingEvidence.insert(row); } + catch (DuplicateKeyException duplicate) { + ContainerLoadingEvidence raced = loadingEvidence.find(sourceContainerBookingId, type); + if (raced == null || !Objects.equals(raced.getSourceFileId(), fileId) + || !Objects.equals(raced.getFileName(), fileName)) throw duplicate; + } + } + if (!actual.equals(expected)) throw new IllegalArgumentException("V3 尾程交接事件的国内装柜照片类型不完整"); + } + + private FirstLegLoadingPlanCompletedV1 parsePayload(EventEnvelope envelope) { + try { + ObjectMapper compatibleMapper = objectMapper.copy() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + // The internal order model keeps the shared fields. V2 pallet source-detail + // identity is validated separately from the original immutable payload. + 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 freezeContainerIdentity(EventEnvelope envelope, String sourceContainerBookingId, + String containerNo) { + shippingGates.initialize(sourceContainerBookingId, "", + containerNo, 0, envelope.handoverVersion()); + ContainerShippingGate frozen = shippingGates.lock(sourceContainerBookingId); + if (frozen == null) { + throw new IllegalStateException("来源柜真实集装箱号冻结失败"); + } + if (!containerNo.equals(ContainerNumbers.requireCanonical(frozen.getContainerNo()))) { + throw new IllegalStateException("同一 sourceContainerBookingId 的真实集装箱号冲突"); + } + for (String existing : orderMapper.lockContainerNumbers(sourceContainerBookingId)) { + if (!containerNo.equals(ContainerNumbers.requireCanonical(existing))) { + throw new IllegalStateException("同一 sourceContainerBookingId 的尾程订单柜号冲突"); + } + } + } + + private String sourceContainerBookingId(EventEnvelope envelope, FirstLegLoadingPlanCompletedV1 payload) { + String value = envelope.eventVersion() >= 2 + ? envelope.payload().path("sourceContainerBookingId").asText(null) + : payload.containerBookingId(); + if (value == null || value.isBlank() || value.trim().length() > 128) { + throw new IllegalArgumentException("事件缺少稳定来源柜预订 ID sourceContainerBookingId"); + } + return value.trim(); + } + + private OwnerSnapshot requireOwnerSnapshot(List orders, + String trustedCustomerId) { + String customerId = requiredOwnerValue(orders.get(0).customerId(), "customerId"); + String customerName = requiredOwnerValue(orders.get(0).customerName(), "customerName"); + if (!trustedCustomerId.equals(customerId)) { + throw new IllegalArgumentException("Order customerId does not match the trusted event customer"); + } + for (FirstLegLoadingPlanCompletedV1.Order order : orders) { + if (!customerId.equals(requiredOwnerValue(order.customerId(), "customerId")) + || !customerName.equals(requiredOwnerValue(order.customerName(), "customerName"))) { + throw new IllegalArgumentException("同一装柜完成事件的货主快照不一致"); + } + } + return new OwnerSnapshot(customerId, customerName); + } + + private String requiredOwnerValue(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("尾程事件缺少不可变货主快照 " + field); + } + String normalized = value.trim(); + if (normalized.length() > 128) { + throw new IllegalArgumentException("尾程事件货主快照过长 " + field); + } + return normalized; + } + + private void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload, JsonNode rawPayload) { + for (int index = 0; index < payload.orders().size(); index++) { + FirstLegLoadingPlanCompletedV1.Order order = payload.orders().get(index); + Set orderCartonMarks = new HashSet<>(); + if ("ONE_CLICK_DROPSHIP".equals(order.orderType())) { + JsonNode rawPallets = rawPayload.path("orders").get(index).path("pallets"); + for (int palletIndex = 0; palletIndex < order.pallets().size(); palletIndex++) { + FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet = order.pallets().get(palletIndex); + Set palletCartonMarks = new HashSet<>(); + for (FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot sku : pallet.skuPackages()) { + requireSkuCartonMapping(sku.skuCode(), sku.packageCount(), sku.cartonMarks(), new HashSet<>()); + palletCartonMarks.addAll(sku.cartonMarks()); + } + int manifestPackages = rawPallets.path(palletIndex).path("manifestPackageCount").asInt(-1); + if (palletCartonMarks.size() != manifestPackages) { + throw new IllegalArgumentException("一件代发托盘物理箱数与清单箱数不一致: " + pallet.palletNo()); + } + for (String mark : palletCartonMarks) { + if (!orderCartonMarks.add(mark)) { + throw new IllegalArgumentException("一件代发物理箱不能跨托盘重复: " + mark); + } + } + } + dropshipReceivingLabels(order, rawPayload.path("orders").get(index)); + 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 containerNo, String sourceContainerBookingId, OwnerSnapshot owner) { + 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 = owner.customerId(); + String customerName = owner.customerName(); + List items = "ONE_CLICK_DROPSHIP".equals(orderType) + ? canonicalItems(source.items()) : source.items(); + 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.setLxCode("ONE_CLICK_DROPSHIP".equals(orderType) ? source.lxCode() : null); + order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId()); + order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId()); + order.setCustomerName(customerName); + order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName()); + order.setOverseasWarehouseId(DEFAULT_RECEIVING_WAREHOUSE_ID); + order.setOverseasWarehouseCode(DEFAULT_RECEIVING_WAREHOUSE_CODE); + order.setOverseasWarehouseName(DEFAULT_RECEIVING_WAREHOUSE_NAME); + order.setReceivingWarehouseVersion(0); + order.setFbaWarehouseCode("FBA".equals(destinationType) ? source.destinationWarehouseCode() : null); + order.setContainerNo(containerNo); + order.setAbnormal(false); order.setDetained(false); + order.setPackageCount(source.packageCount()); + order.setPalletCount("ONE_CLICK_DROPSHIP".equals(orderType) + ? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0)); + order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm()); + order.setEstimatedWarehouseAt(parsePlannedArrival(payload.estimatedWarehouseArrivalAt())); + order.setVolumeWeightKg(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(sourceContainerBookingId); + 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); + + Map> dropshipLabels = "ONE_CLICK_DROPSHIP".equals(orderType) + && "OVERSEAS".equals(destinationType) ? dropshipReceivingLabels(source, rawSource) : Map.of(); + if ("FIRST_LEG".equals(orderType) && event.eventVersion() >= 2) { + List pallets = source.pallets() == null ? List.of() : source.pallets(); + Set palletNos = new HashSet<>(); + for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) { + String palletNo = pallets.get(palletIndex).palletNo(); + if (palletNo == null || palletNo.isBlank() || palletNo.length() > 128 || !palletNos.add(palletNo)) { + throw new IllegalArgumentException("散货来源托盘码为空、重复或过长"); + } + sourcePallets.insert(IdWorker.getId(), customerId, order.getId(), palletNo.trim(), palletIndex + 1, event.eventId()); + } + if (!pallets.isEmpty() && order.getPalletCount() > 0 && pallets.size() != order.getPalletCount()) { + throw new IllegalArgumentException("散货来源托盘码数量与托盘数不一致"); + } + } + for(var item: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, + "ONE_CLICK_DROPSHIP".equals(orderType) ? dropshipLabels.getOrDefault(item.sourceInboundDetailId(), List.of()) + : item.cartonMarks()); + } + 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:" + source.sourceInboundId(), dropshipPallets)); + } + } + + private LocalDateTime parsePlannedArrival(String value) { + if (value == null || value.isBlank()) return null; + try { + return LocalDateTime.parse(value.trim()); + } catch (java.time.format.DateTimeParseException exception) { + throw new IllegalArgumentException("事件计划到仓时间 estimatedWarehouseArrivalAt 无效", exception); + } + } + + 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.setLxCode("ONE_CLICK_DROPSHIP".equals(order.getOrderType()) ? source.lxCode() : null); + 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(), order.getOverseasWarehouseId(), + order.getOverseasWarehouseCode(), order.getOverseasWarehouseName(), + 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 Map> dropshipReceivingLabels(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) { + Map itemsByDetail = new java.util.LinkedHashMap<>(); + for (var item : canonicalItems(source.items())) { + itemsByDetail.put(item.sourceInboundDetailId(), item); + } + Map> marksByDetail = new java.util.LinkedHashMap<>(); + for (JsonNode pallet : rawSource.path("pallets")) { + for (JsonNode sku : pallet.path("skuPackages")) { + String detailId = sku.path("sourceInboundDetailId").asText(null); + var item = itemsByDetail.get(detailId); + if (item == null || !Objects.equals(item.skuCode(), sku.path("skuCode").asText(null))) { + throw new IllegalArgumentException("一件代发托盘来源明细缺失或与 SKU 不一致"); + } + List marks = marksByDetail.computeIfAbsent(detailId, ignored -> new java.util.ArrayList<>()); + sku.path("cartonMarks").forEach(mark -> marks.add(mark.asText())); + } + } + for (var item : itemsByDetail.values()) { + if (marksByDetail.getOrDefault(item.sourceInboundDetailId(), List.of()).size() != item.packageCount()) { + throw new IllegalArgumentException("一件代发托盘箱唛与来源明细箱数不一致"); + } + } + return marksByDetail; + } + + /** 接受同一来源明细因多托装柜形成的多个分片,并在尾程落库前合并为唯一明细。 */ + private List canonicalItems(List source) { + Map> byDetail = new java.util.LinkedHashMap<>(); + for (var item : source == null ? List.of() : source) { + if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank()) { + throw new IllegalArgumentException("一件代发来源明细标识缺失"); + } + byDetail.computeIfAbsent(item.sourceInboundDetailId(), ignored -> new ArrayList<>()).add(item); + } + List result = new ArrayList<>(); + for (List fragments : byDetail.values()) { + var first = fragments.get(0); + if (fragments.stream().anyMatch(item -> !Objects.equals(first.skuCode(), item.skuCode()) + || !Objects.equals(first.destinationWarehouseId(), item.destinationWarehouseId()) + || !Objects.equals(first.destinationWarehouseCode(), item.destinationWarehouseCode()))) { + throw new IllegalArgumentException("同一来源明细的尾程事件分片快照不一致"); + } + List marks = fragments.stream().flatMap(item -> item.cartonMarks().stream()).toList(); + result.add(new FirstLegLoadingPlanCompletedV1.Item(first.sourceInboundDetailId(), first.skuCode(), + first.productNameZh(), first.productNameEn(), + fragments.stream().mapToInt(FirstLegLoadingPlanCompletedV1.Item::packageCount).sum(), + sumNullableInt(fragments, FirstLegLoadingPlanCompletedV1.Item::productQuantity), + sumNullableInt(fragments, FirstLegLoadingPlanCompletedV1.Item::palletCount), + sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::grossWeightKg), + sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::netWeightKg), + sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg), + sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::volumeCbm), + first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), + first.fbaTrackingNo(), first.shipmentNo(), first.shippingMark(), first.palletNo(), marks)); + } + return List.copyOf(result); + } + + private static Integer sumNullableInt(List values, + java.util.function.Function getter) { + List present = values.stream().map(getter).filter(Objects::nonNull).toList(); + return present.isEmpty() ? null : present.stream().mapToInt(Integer::intValue).sum(); + } + + private static BigDecimal sumDecimal(List values, + java.util.function.Function getter) { + List present = values.stream().map(getter).filter(Objects::nonNull).toList(); + return present.isEmpty() ? null : present.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private void createCartonLabels(String customerId, Long orderId, String destinationType, + FirstLegLoadingPlanCompletedV1.Item item, List sourceMarks) { + if (!"OVERSEAS".equals(destinationType)) return; + List marks = sourceMarks == null ? List.of() : sourceMarks; + 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()) { + List result = new ArrayList<>(); + JsonNode rawPallets = rawSource.path("pallets"); + for (int palletIndex = 0; palletIndex < source.pallets().size(); palletIndex++) { + FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet = source.pallets().get(palletIndex); + JsonNode rawPallet = rawPallets.isArray() && palletIndex < rawPallets.size() + ? rawPallets.get(palletIndex) : objectMapper.createObjectNode(); + Map inventorySkus = new java.util.LinkedHashMap<>(); + for (int skuIndex = 0; skuIndex < pallet.skuPackages().size(); skuIndex++) { + var sku = pallet.skuPackages().get(skuIndex); + inventorySkus.merge(sku.skuCode(), new DropshipRequests.SkuPackages(sku.skuCode(), + sku.productName(), sku.packageCount(), sku.productQuantity(), sku.cartonMarks(), + cartonQuantities(rawPallet.path("skuPackages").path(skuIndex))), (left, right) -> { + List marks = new ArrayList<>(left.cartonMarks()); marks.addAll(right.cartonMarks()); + Map quantities = new java.util.LinkedHashMap<>(left.cartonQuantities()); + right.cartonQuantities().forEach((mark, quantity) -> { + if (quantities.putIfAbsent(mark, quantity) != null) throw new IllegalArgumentException("Duplicate carton quantity: " + mark); + }); + return new DropshipRequests.SkuPackages(left.skuCode(), left.productName(), + Math.addExact(left.packageCount(), right.packageCount()), + Math.addExact(left.productQuantity(), right.productQuantity()), List.copyOf(marks), Map.copyOf(quantities)); + }); + } + result.add(new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()), + positiveManifest(rawPallet, "manifestVersion"), positiveManifest(rawPallet, "manifestPackageCount"), + positiveManifest(rawPallet, "manifestFragmentCount"), requiredManifestHash(rawPallet))); + } + return List.copyOf(result); + } + 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, + positiveManifest(pallet, "manifestVersion"), positiveManifest(pallet, "manifestPackageCount"), + positiveManifest(pallet, "manifestFragmentCount"), requiredManifestHash(pallet))); + } + } + 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, + positiveManifest(source, "manifestVersion"), positiveManifest(source, "manifestPackageCount"), + positiveManifest(source, "manifestFragmentCount"), requiredManifestHash(source))); + } + return result; + } + + private int positiveManifest(JsonNode source, String field) { + int value = source.path(field).asInt(0); + if (value <= 0) throw new IllegalArgumentException("One-click dropship pallet is missing " + field); + return value; + } + + private String requiredManifestHash(JsonNode source) { + String value = source.path("manifestHash").asText(null); + if (value == null || !value.matches("(?i)[0-9a-f]{64}")) { + throw new IllegalArgumentException("One-click dropship pallet manifestHash is invalid"); + } + return value.toLowerCase(java.util.Locale.ROOT); + } + + private Map cartonQuantities(JsonNode sku) { + JsonNode node = sku.path("cartonQuantities"); + if (!node.isObject()) throw new IllegalArgumentException("One-click dropship cartonQuantities is required"); + Map result = new java.util.LinkedHashMap<>(); + node.fields().forEachRemaining(entry -> { + if (!entry.getValue().isIntegralNumber() || !entry.getValue().canConvertToLong() || entry.getValue().longValue() <= 0) { + throw new IllegalArgumentException("Invalid carton quantity: " + entry.getKey()); + } + result.put(entry.getKey(), entry.getValue().longValue()); + }); + return Map.copyOf(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, cartonQuantities(item))); + } + } + return result; + } + + private record OwnerSnapshot(String customerId, String customerName) { } +} 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/entity/ContainerShippingGate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java new file mode 100644 index 000000000..23bb9111e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java @@ -0,0 +1,35 @@ +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 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..502508040 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java @@ -0,0 +1,28 @@ +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,ocean_booking_id," + + "container_no,status,node_sequence,aggregate_version,handover_version,created_at,updated_at) VALUES(" + + "#{containerBookingId},#{oceanBookingId},#{containerNo},'AWAITING_PICKUP_READY',0," + + "#{aggregateVersion},#{handoverVersion},NOW(3),NOW(3))") + int initialize(@Param("containerBookingId") String containerBookingId, + @Param("oceanBookingId") String oceanBookingId, @Param("containerNo") String containerNo, + @Param("aggregateVersion") long aggregateVersion, @Param("handoverVersion") long handoverVersion); + + @Select("SELECT CASE WHEN COUNT(*)=1 AND MIN(CASE WHEN status='RECEIVING_AVAILABLE' THEN 1 ELSE 0 END)=1 " + + "THEN 1 ELSE 0 END FROM ll_container_shipping_gate " + + "WHERE container_booking_id=#{containerBookingId} AND container_no=#{containerNo}") + int receivingAvailable(@Param("containerBookingId") String containerBookingId, + @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..a0787bdce --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java @@ -0,0 +1,127 @@ +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.oceanBookingId(), + payload.containerNo(), 0, 1); + ContainerShippingGate gate = gates.lock(payload.containerBookingId()); + if (gate == null || !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 (!"0".equals(envelope.tenantId()) || !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/controller/MerchantWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java new file mode 100644 index 000000000..63f93c8c4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java @@ -0,0 +1,54 @@ +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)); + } + + @PostMapping("/{transferNo}/confirm-measurement") + public ApiResponse confirmMeasurement( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.CustomerConfirm request) { + return ApiResponse.success(service.confirmMeasurement(customer(user), transferNo, 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/transfer/controller/PdaWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java new file mode 100644 index 000000000..848f20c01 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.transfer.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +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.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; + +import java.util.List; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/warehouse-transfers") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +public class PdaWarehouseTransferController { + @Resource private WarehouseTransferService service; + + @GetMapping("/candidates") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaCandidates(identity.overseasWarehouseId())); + } + + @PostMapping("/{transferNo}/palletize") + public ApiResponse palletize( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Palletize request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.palletize(identity.operatorKey(), identity.overseasWarehouseId(), + transferNo, request)); + } +} 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..9967c47d5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java @@ -0,0 +1,104 @@ +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.security.OverseasPermissions; +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.access.prepost.PreAuthorize; +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 + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + 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}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo) { + return ApiResponse.success(service.detail(scope(user, customerId), transferNo)); + } + + @GetMapping("/{transferNo}/carton-label") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + public ApiResponse cartonLabel(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo) { + return ApiResponse.success(service.cartonLabel(scope(user, customerId), transferNo)); + } + + @PostMapping("/{transferNo}/review") + @PreAuthorize("hasAuthority('" + OverseasPermissions.WAREHOUSE_TRANSFER_APPROVE + "')") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Review request) { + 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 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..db1373ca1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -0,0 +1,132 @@ +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( + @Size(max = 128) String inboundCode, + @Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds, + @NotBlank @Size(max = 128) String operatingWarehouseId, + @Size(max = 128) String fbaWarehouseCode, + @NotBlank @Size(max = 1000) String deliveryAddress, + @Positive int packageCount, + @NotNull @DecimalMin(value = "0.0") BigDecimal volumeCbm, + @NotBlank @Size(max = 64) String deliveryMethod, + @NotNull LocalDateTime estimatedArrivalAt, + LocalDateTime estimatedShipAt, + @Size(max = 128) String fbaShipmentNo, + @Size(max = 255) String trackingNo, + @Size(max = 2000) String fbaBoxNos, + @Size(max = 1000) String remark, + boolean needRelabel, + LocalDateTime labelDeadline, + @Size(max = 128) String relabelContactName, + @Size(max = 64) String relabelContactPhone, + @DecimalMin(value = "0.01") BigDecimal relabelChargeAmount, + @Size(max = 32) String relabelDeliveryMethod, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 500) List<@Valid Item> items, + @Size(max = 1000) List<@Valid RelabelItem> relabelItems + ) { + public Create(String inboundCode, List sourceLastlegOrderIds, String operatingWarehouseId, + String fbaWarehouseCode, String deliveryAddress, int packageCount, BigDecimal volumeCbm, + String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo, + String trackingNo, String fbaBoxNos, String remark, boolean needRelabel, + String idempotencyKey, List items) { + this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode, deliveryAddress, + packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo, + fbaBoxNos, remark, needRelabel, null, null, null, null, null, idempotencyKey, items, null); + } + } + + /** 审核通过备注选填;驳回必须提供非空原因,由服务在任何状态写入前校验。 */ + 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 Palletize( + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 100) List<@Valid Pallet> pallets + ) {} + + public record Pallet( + @Size(max = 128) String palletCode, + @Positive int cartonCount, + @NotNull @DecimalMin("0.000001") BigDecimal volumeCbm, + @NotNull @DecimalMin("0.01") BigDecimal grossWeightKg, + @NotEmpty @Size(max = 500) List<@Valid PalletItem> items + ) {} + + public record PalletItem(@NotNull Long transferItemId, @Positive int packageCount) {} + + public record CustomerConfirm(@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 RelabelItem( + @NotBlank @Size(max = 32) String operationType, + @NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> customerCartonMarks, + @NotBlank @Size(max = 128) String originalSku, + @Size(max = 128) String newSku, + @Positive int quantity, + @Size(max = 255) String fbaCartonMark, + @Size(max = 128) String destinationWarehouseCode, + @Size(max = 1000) String remark + ) {} + + 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..3abdde3da --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.transfer.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class WarehouseTransferResponses { + private WarehouseTransferResponses() {} + public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark) {} + public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status) {} + public record Label(String transferNo, List cartons, String printableHtml) {} + public record PalletItem(Long transferItemId, String skuCode, int packageCount) {} + public record Pallet(Long id, int sequenceNo, Long warehousePalletId, String palletNo, + Long warehouseLocationId, String warehouseLocationCode, int cartonCount, + BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, + BigDecimal grossWeightKg, BigDecimal volumeCbm, String status, + List items, LocalDateTime completedAt) {} + public record RelabelBinding(String operationType, String customerCartonMark, String originalSku, + String newSku, int quantity, String fbaCartonMark, + String destinationWarehouseCode, String remark) {} + 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, + LocalDateTime estimatedShipAt, + String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark, + String reviewDecision, String reviewRemark, String reviewedBy, LocalDateTime reviewedAt, + List sourceOrders, List items, List cartons, + List pallets, List relabelBindings, + List allocations, LocalDateTime customerConfirmedAt, + LocalDateTime actualShipAt, LocalDateTime createdAt) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferCarton.java new file mode 100644 index 000000000..e17cfd9c4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferCarton.java @@ -0,0 +1,24 @@ +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_carton") +public class WarehouseTransferCarton { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private Long transferItemId; + private String customerId; + private String cartonMark; + private String customerCartonMark; + private Integer cartonIndex; + private Integer totalCartons; + private String status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} 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..6ce3b354f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java @@ -0,0 +1,59 @@ +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 LocalDateTime estimatedShipAt; + private String fbaShipmentNo; + private String trackingNo; + private String fbaBoxNos; + private String remark; + private Boolean needRelabel; + private LocalDateTime labelDeadline; + private String relabelContactName; + private String relabelContactPhone; + private java.math.BigDecimal relabelChargeAmount; + private String relabelDeliveryMethod; + private Long relabelApplicationId; + private String relabelNo; + private LocalDateTime relabelCompletedAt; + private String status; + private String reviewDecision; + private String reviewRemark; + private String reviewedBy; + private LocalDateTime reviewedAt; + private String allocationIdempotencyKey; + private String allocationRequestHash; + private String outboundIdempotencyKey; + private String outboundRequestHash; + private LocalDateTime outboundAt; + private String palletizeIdempotencyKey; + private String palletizeRequestHash; + private String confirmationIdempotencyKey; + private String confirmationRequestHash; + private LocalDateTime customerConfirmedAt; + 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/WarehouseTransferPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java new file mode 100644 index 000000000..749eeb1af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java @@ -0,0 +1,33 @@ +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.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_warehouse_transfer_pallet") +public class WarehouseTransferPallet { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private Integer sequenceNo; + private Long warehousePalletId; + private String palletNo; + private Long warehouseLocationId; + private String warehouseLocationCode; + private Long warehouseResourceLockId; + private Integer cartonCount; + private BigDecimal lengthCm; + private BigDecimal widthCm; + private BigDecimal heightCm; + private BigDecimal grossWeightKg; + private BigDecimal volumeCbm; + private String status; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPalletItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPalletItem.java new file mode 100644 index 000000000..35ed63541 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPalletItem.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_pallet_item") +public class WarehouseTransferPalletItem { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private Long transferPalletId; + private Long transferItemId; + private String customerId; + private Integer packageCount; + private LocalDateTime createdAt; +} 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/WarehouseTransferRelabelBinding.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferRelabelBinding.java new file mode 100644 index 000000000..f87fcca96 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferRelabelBinding.java @@ -0,0 +1,25 @@ +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_relabel_binding") +public class WarehouseTransferRelabelBinding { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private String operationType; + private String customerCartonMark; + private String originalSku; + private String newSku; + private Integer quantity; + private String fbaCartonMark; + private String destinationWarehouseCode; + private String remark; + 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/WarehouseTransferCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferCartonMapper.java new file mode 100644 index 000000000..ae584e342 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferCartonMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton; +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 WarehouseTransferCartonMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_carton WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY carton_index") + List listOrder(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); +} 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..bc86eed62 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java @@ -0,0 +1,27 @@ +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; +import org.apache.ibatis.annotations.Update; +@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); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE") + WarehouseTransferOrder lockForPda(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE") + WarehouseTransferOrder lockByRelabelNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouseId} AND status='WAIT_PDA_RECEIVING' ORDER BY created_at,id") + java.util.List pdaCandidates(@Param("warehouseId") String warehouseId); + @Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND lastleg_order_id=#{orderId} AND outbound_at IS NULL") + int markOutboundByOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("outboundAt") java.time.LocalDateTime outboundAt); + @Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND outbound_at IS NULL") + int markOutboundByRelabel(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo, + @Param("outboundAt") java.time.LocalDateTime outboundAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletItemMapper.java new file mode 100644 index 000000000..b744cb4af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletItemMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem; +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 WarehouseTransferPalletItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_pallet_item WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY transfer_pallet_id,id") + List listOrder(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java new file mode 100644 index 000000000..6526636ea --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; +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 WarehouseTransferPalletMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY sequence_no") + List listOrder(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); +} 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/WarehouseTransferRelabelBindingMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferRelabelBindingMapper.java new file mode 100644 index 000000000..7c876d91e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferRelabelBindingMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding; +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 WarehouseTransferRelabelBindingMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_relabel_binding WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY id") + List listOrder(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); +} 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..e34173b70 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -0,0 +1,1066 @@ +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.LastlegInboundOrder; +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.mapper.LastlegInboundOrderMapper; +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.mapper.RelabelApplicationMapper; +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.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.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +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.WarehouseTransferCarton; +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.entity.WarehouseTransferPallet; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding; +import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper; +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.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper; +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.Objects; +import java.util.Set; +import java.util.ArrayList; +import java.util.Comparator; +import java.math.BigDecimal; +import java.math.RoundingMode; +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 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"; + public static final String WAIT_PDA_RECEIVING = "WAIT_PDA_RECEIVING"; + public static final String WAIT_CUSTOMER_CONFIRMATION = "WAIT_CUSTOMER_CONFIRMATION"; + public static final String READY_FOR_DISPATCH = "READY_FOR_DISPATCH"; + public static final String WAIT_RELABEL = "WAIT_RELABEL"; + + @Resource private WarehouseTransferOrderMapper transfers; + @Resource private WarehouseTransferItemMapper transferItems; + @Resource private WarehouseTransferCartonMapper transferCartons; + @Resource private WarehouseTransferSourceOrderMapper sourceOrders; + @Resource private WarehouseTransferPalletMapper transferPallets; + @Resource private WarehouseTransferPalletItemMapper transferPalletItems; + @Resource private WarehouseTransferRelabelBindingMapper relabelBindings; + @Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations; + @Resource private WarehouseTransferReceiptMapper transferReceipts; + @Resource private InboundSnapshotMapper inboundSnapshots; + @Resource private LastlegInboundOrderMapper inboundOrders; + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderDetailMapper orderDetails; + @Resource private LastlegOrderStatusHistoryMapper orderHistories; + @Resource private RelabelApplicationMapper relabelApplications; + @Resource private RelabelApplicationService relabelService; + @Resource private WarehouseResourceGateway warehouseResources; + @Resource private InboundPlanMapper inboundPlans; + @Resource private InboundPlanLineMapper inboundPlanLines; + @Resource private InboundPlanPalletMapper inboundPlanPallets; + @Resource private InboundPlanPalletAllocationMapper inboundPlanAllocations; + @Resource private InboundStockReceiptMapper stockReceipts; + @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"); + } + validateRelabelRequest(request); + 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()); + } + + LocalDateTime now = LocalDateTime.now(); + long transferId = IdWorker.getId(); + String transferNo = orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), transferId); + String requestedInboundCode = trim(request.inboundCode()); + List selectedIds = request.sourceLastlegOrderIds() == null + ? List.of() : request.sourceLastlegOrderIds().stream().distinct().toList(); + boolean existingInventoryTransfer = StringUtils.hasText(requestedInboundCode) || !selectedIds.isEmpty(); + if (existingInventoryTransfer && (!StringUtils.hasText(requestedInboundCode) || selectedIds.isEmpty())) { + throw badRequest("Existing inventory transfer requires both inboundCode and sourceLastlegOrderIds"); + } + + Long inboundOrderId; + String inboundCode; + List selectedOrders; + if (existingInventoryTransfer) { + inboundCode = requestedInboundCode; + inboundOrderId = inboundSnapshots.findId(customerId, inboundCode); + if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode); + 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"); + } + } else { + inboundCode = transferNo; + inboundOrderId = createInboundSnapshot(customerId, transferId, transferNo, request, requestHash, now); + selectedOrders = List.of(); + } + LastlegOrder order = createTailOrder(customerId, inboundOrderId, inboundCode, 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(trim(request.fbaWarehouseCode())); + transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount()); + transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt()); + transfer.setEstimatedShipAt(request.estimatedShipAt()); + transfer.setFbaShipmentNo(trim(request.fbaShipmentNo())); + transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) ? trim(request.trackingNo()) : null); + transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark())); + transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(PENDING_REVIEW); + transfer.setLabelDeadline(request.labelDeadline()); + transfer.setRelabelContactName(trim(request.relabelContactName())); + transfer.setRelabelContactPhone(trim(request.relabelContactPhone())); + transfer.setRelabelChargeAmount(request.relabelChargeAmount()); + transfer.setRelabelDeliveryMethod(trim(request.relabelDeliveryMethod())); + 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); + } + + if (request.needRelabel() && request.relabelItems() != null) insertRelabelBindings(customerId, transferId, request.relabelItems(), now); + + int cartonIndex = 0; + for (int index = 0; index < request.items().size(); index++) { + WarehouseTransferRequests.Item source = request.items().get(index); + long itemId = insertTransferItem(customerId, transferId, source, now); + List customerMarks = splitMarks(source.shippingMark()); + for (int itemCarton = 0; itemCarton < source.packageCount(); itemCarton++) { + cartonIndex++; + insertTransferCarton(customerId, transferId, itemId, transferNo, + cartonIndex, request.packageCount(), itemCarton < customerMarks.size() ? customerMarks.get(itemCarton) : null, now); + } + insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now); + } + insertOrderHistory(customerId, order.getId(), null, PENDING_REVIEW, transferNo, + "Warehouse transfer submitted for review", 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.getId(), value.getSkuCode(), value.getProductNameZh(), + value.getPackageCount(), value.getShippingMark())).toList(); + Map itemById = items.stream() + .collect(Collectors.toMap(WarehouseTransferResponses.Item::id, Function.identity())); + 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(); + List cartons = transferCartons.listOrder(customerId, transfer.getId()) + .stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), + value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList(); + List palletItemRows = transferPalletItems == null + ? List.of() : transferPalletItems.listOrder(customerId, transfer.getId()); + Map> palletItemsByPallet = palletItemRows.stream() + .collect(Collectors.groupingBy(WarehouseTransferPalletItem::getTransferPalletId)); + List pallets = transferPallets == null ? List.of() : transferPallets.listOrder(customerId, transfer.getId()).stream() + .map(value -> new WarehouseTransferResponses.Pallet(value.getId(), value.getSequenceNo(), + value.getWarehousePalletId(), value.getPalletNo(), value.getWarehouseLocationId(), + value.getWarehouseLocationCode(), value.getCartonCount(), value.getLengthCm(), value.getWidthCm(), + value.getHeightCm(), value.getGrossWeightKg(), value.getVolumeCbm(), value.getStatus(), + palletItemsByPallet.getOrDefault(value.getId(), List.of()).stream().map(row -> { + WarehouseTransferResponses.Item item = itemById.get(row.getTransferItemId()); + return new WarehouseTransferResponses.PalletItem(row.getTransferItemId(), + item == null ? null : item.skuCode(), row.getPackageCount()); + }).toList(), value.getCompletedAt())).toList(); + List bindings = relabelBindings == null ? List.of() : relabelBindings.listOrder(customerId, transfer.getId()) + .stream().map(value -> new WarehouseTransferResponses.RelabelBinding(value.getOperationType(), + value.getCustomerCartonMark(), value.getOriginalSku(), value.getNewSku(), value.getQuantity(), + value.getFbaCartonMark(), value.getDestinationWarehouseCode(), value.getRemark())).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.getEstimatedShipAt(), transfer.getFbaShipmentNo(), transfer.getTrackingNo(), transfer.getFbaBoxNos(), + transfer.getRemark(), transfer.getReviewDecision(), transfer.getReviewRemark(), transfer.getReviewedBy(), + transfer.getReviewedAt(), sources, items, cartons, pallets, bindings, allocations, + transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt()); + } + + @Transactional(readOnly = true) + public WarehouseTransferResponses.Label cartonLabel(String customerId, String transferNo) { + WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + List cartons = transferCartons.listOrder(customerId, transfer.getId()).stream() + .map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList(); + StringBuilder labels = new StringBuilder(); + for (WarehouseTransferResponses.Carton carton : cartons) { + labels.append("
仓储转运箱唛
") + .append(escape(carton.cartonMark())).append("
") + .append(carton.cartonIndex()).append(" / ").append(carton.totalCartons()).append(" 转运单号:") + .append(escape(transferNo)).append("
"); + if (text(carton.customerCartonMark()) != null) labels.append("
客户箱唛:") + .append(escape(carton.customerCartonMark())).append("
"); + labels.append("
"); + } + String html = "" + escape(transferNo) + "-箱唛" + + labels + ""; + return new WarehouseTransferResponses.Label(transferNo, cartons, html); + } + + @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"); + }; + if (REJECTED.equals(next) && !StringUtils.hasText(request.remark())) { + throw badRequest("驳回原因不能为空"); + } + 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()) || APPROVED.equals(next) + && Set.of(WAIT_PDA_RECEIVING, WAIT_CUSTOMER_CONFIRMATION, READY_FOR_DISPATCH, WAIT_RELABEL) + .contains(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(); + boolean incomingTransit = transfer.getTransferNo().equals(transfer.getInboundCode()); + String transferStatus = APPROVED.equals(next) && incomingTransit ? WAIT_PDA_RECEIVING : next; + transfer.setStatus(transferStatus); + transfer.setReviewDecision(next); + transfer.setReviewRemark(trim(request.remark())); + transfer.setReviewedBy(operatorId); + transfer.setReviewedAt(now); + transfer.setUpdatedAt(now); + transfers.updateById(transfer); + String orderStatus = APPROVED.equals(next) + ? (incomingTransit + ? ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod()) ? "WAIT_PICKUP" : "WAIT_DELIVERY") + : PENDING_ASSIGNMENT) + : "CANCELLED"; + order.setCurrentStatus(orderStatus); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + next + ";RELABEL:" + + (StringUtils.hasText(order.getRelabelStatus()) ? order.getRelabelStatus() : "NOT_REQUIRED")); + order.setUpdatedAt(now); orders.updateById(order); + if (!java.util.Objects.equals(before, orderStatus)) { + insertOrderHistory(customerId, order.getId(), before, orderStatus, transferNo, + "Warehouse transfer reviewed: " + next + + (StringUtils.hasText(request.remark()) ? "; " + request.remark().trim() : ""), now); + } + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail palletize(String operatorId, long overseasWarehouseId, + String transferNo, + WarehouseTransferRequests.Palletize request) { + WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!String.valueOf(overseasWarehouseId).equals(transfer.getOperatingWarehouseId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer belongs to another warehouse"); + } + String customerId = transfer.getCustomerId(); + String requestHash = hash(request); + if (WAIT_CUSTOMER_CONFIRMATION.equals(transfer.getStatus()) + && request.idempotencyKey().equals(transfer.getPalletizeIdempotencyKey())) { + if (!requestHash.equals(transfer.getPalletizeRequestHash())) { + throw conflict("Palletization idempotency key was reused with different data"); + } + return detail(customerId, transferNo); + } + if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) { + throw conflict("Warehouse transfer is not awaiting PDA receiving"); + } + List items = transferItems.selectList(new LambdaQueryWrapper() + .eq(WarehouseTransferItem::getCustomerId, customerId) + .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)); + Map itemById = items.stream() + .collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); + Map totals = new java.util.HashMap<>(); + int palletCartons = 0; + for (WarehouseTransferRequests.Pallet pallet : request.pallets()) { + int itemTotal = pallet.items().stream().mapToInt(WarehouseTransferRequests.PalletItem::packageCount).sum(); + if (itemTotal != pallet.cartonCount()) throw badRequest("Each pallet carton count must equal its SKU carton total"); + palletCartons = Math.addExact(palletCartons, pallet.cartonCount()); + Set uniqueItems = new HashSet<>(); + for (WarehouseTransferRequests.PalletItem item : pallet.items()) { + if (!uniqueItems.add(item.transferItemId()) || !itemById.containsKey(item.transferItemId())) { + throw badRequest("Pallet SKU rows must be unique and belong to this transfer"); + } + totals.merge(item.transferItemId(), item.packageCount(), Math::addExact); + } + } + if (palletCartons != transfer.getPackageCount()) throw badRequest("Pallet carton total must equal transfer package count"); + for (WarehouseTransferItem item : items) { + if (totals.getOrDefault(item.getId(), 0) != item.getPackageCount()) { + throw badRequest("Palletized carton total does not match declared SKU " + item.getSkuCode()); + } + } + + String batchKey = "WAREHOUSE_TRANSFER:" + transferNo + ":PALLETIZE"; + List batchItems = new ArrayList<>(); + for (int index = 0; index < request.pallets().size(); index++) { + WarehouseTransferRequests.Pallet pallet = request.pallets().get(index); + batchItems.add(new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(customerId), + transferNo + ":" + (index + 1), transferNo, index + 1, "STANDARD", + trim(transfer.getFbaWarehouseCode()), trim(pallet.palletCode()))); + } + WarehouseResourceGateway.PalletBatchResponse ensured = warehouseResources.ensurePalletBatch( + new WarehouseResourceGateway.PalletBatchEnsure(overseasWarehouseId, batchKey, batchItems)); + WarehouseResourceGateway.PalletBatchResponse confirmed = warehouseResources.confirmPalletBatch( + new WarehouseResourceGateway.PalletBatchCommand(overseasWarehouseId, batchKey, + request.idempotencyKey() + ":CONFIRM")); + if (confirmed == null || confirmed.items() == null || confirmed.items().size() != request.pallets().size()) { + throw conflict("Warehouse pallet batch did not return every pallet"); + } + LocalDateTime now = LocalDateTime.now(); + for (int index = 0; index < request.pallets().size(); index++) { + int sequenceNo = index + 1; + WarehouseTransferRequests.Pallet source = request.pallets().get(index); + WarehouseResourceGateway.PalletBatchItem resource = confirmed.items().stream() + .filter(value -> value.sequenceNo() == sequenceNo).findFirst() + .orElseThrow(() -> conflict("Warehouse pallet sequence is incomplete")); + WarehouseResourceGateway.WarehouseLocationReference location = warehouseResources.resolveLocation( + overseasWarehouseId, resource.locationId()); + WarehouseTransferPallet pallet = new WarehouseTransferPallet(); + pallet.setId(IdWorker.getId()); pallet.setTransferOrderId(transfer.getId()); pallet.setCustomerId(customerId); + pallet.setSequenceNo(sequenceNo); pallet.setWarehousePalletId(resource.palletId()); + pallet.setPalletNo(resource.palletCode()); pallet.setWarehouseLocationId(resource.locationId()); + pallet.setWarehouseLocationCode(location.locationCode()); pallet.setWarehouseResourceLockId(resource.resourceLockId()); + // 转运测量以打托后的托盘为单位,体积由 PDA 直接录入托盘总体积,不再按单箱尺寸换算。 + // 旧表字段仍为 NOT NULL;按托模式不采集长宽高,以 0 兼容存储且不得参与总体积计算。 + pallet.setLengthCm(BigDecimal.ZERO); pallet.setWidthCm(BigDecimal.ZERO); pallet.setHeightCm(BigDecimal.ZERO); + pallet.setCartonCount(source.cartonCount()); pallet.setGrossWeightKg(source.grossWeightKg()); + pallet.setVolumeCbm(source.volumeCbm().setScale(6, RoundingMode.HALF_UP)); + pallet.setStatus("COMPLETED"); pallet.setCompletedAt(now); pallet.setCreatedAt(now); pallet.setUpdatedAt(now); + transferPallets.insert(pallet); + for (WarehouseTransferRequests.PalletItem sourceItem : source.items()) { + WarehouseTransferPalletItem row = new WarehouseTransferPalletItem(); + row.setId(IdWorker.getId()); row.setTransferOrderId(transfer.getId()); row.setTransferPalletId(pallet.getId()); + row.setTransferItemId(sourceItem.transferItemId()); row.setCustomerId(customerId); + row.setPackageCount(sourceItem.packageCount()); row.setCreatedAt(now); transferPalletItems.insert(row); + } + } + transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); transfer.setPalletizeIdempotencyKey(request.idempotencyKey().trim()); + transfer.setPalletizeRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer); + LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); + order.setCurrentStatus(WAIT_CUSTOMER_CONFIRMATION); order.setPalletCount(request.pallets().size()); + order.setVolumeCbm(transferPallets.listOrder(customerId, transfer.getId()).stream() + .map(WarehouseTransferPallet::getVolumeCbm).reduce(BigDecimal.ZERO, BigDecimal::add)); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + WAIT_CUSTOMER_CONFIRMATION); order.setUpdatedAt(now); orders.updateById(order); + insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, + "PDA receiving measurement and palletization completed", now); + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail confirmMeasurement(String customerId, String transferNo, + String operatorId, + WarehouseTransferRequests.CustomerConfirm request) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + String requestHash = hash(request); + if (Set.of(READY_FOR_DISPATCH, WAIT_RELABEL, OUTBOUNDED).contains(transfer.getStatus()) + && request.idempotencyKey().equals(transfer.getConfirmationIdempotencyKey())) { + if (!requestHash.equals(transfer.getConfirmationRequestHash())) { + throw conflict("Confirmation idempotency key was reused with different data"); + } + return detail(customerId, transferNo); + } + if (!WAIT_CUSTOMER_CONFIRMATION.equals(transfer.getStatus())) { + throw conflict("Warehouse transfer measurement is not awaiting customer confirmation"); + } + List items = transferItems.selectList(new LambdaQueryWrapper() + .eq(WarehouseTransferItem::getCustomerId, customerId) + .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)); + Map itemById = items.stream() + .collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); + List pallets = transferPallets.listOrder(customerId, transfer.getId()); + List palletItems = transferPalletItems.listOrder(customerId, transfer.getId()); + WarehouseResourceGateway.WarehouseReference warehouse = warehouseResources.requireActiveSharedWarehouse( + Long.parseLong(transfer.getOperatingWarehouseId())); + Map> stockSourcesBySku = new java.util.LinkedHashMap<>(); + LocalDateTime now = LocalDateTime.now(); + for (WarehouseTransferPallet pallet : pallets) { + for (WarehouseTransferPalletItem row : palletItems.stream() + .filter(value -> value.getTransferPalletId().equals(pallet.getId())).toList()) { + WarehouseTransferItem item = itemById.get(row.getTransferItemId()); + String operationKey = "WAREHOUSE_TRANSFER_STOCK_IN:" + transferNo + ":" + pallet.getSequenceNo() + ":" + item.getId(); + InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, + new InventoryCommandRequests.StockIn(customerId, "客户-" + customerId, + transfer.getOperatingWarehouseId(), warehouse.warehouseCode(), warehouse.warehouseName(), + null, trim(transfer.getFbaWarehouseCode()), trim(transfer.getFbaWarehouseCode()), + transfer.getInboundOrderId(), transferNo, transferNo, item.getSkuCode(), item.getProductNameZh(), null, + pallet.getWarehousePalletId(), pallet.getPalletNo(), transferNo, pallet.getWarehouseLocationId(), + pallet.getWarehouseLocationCode(), row.getPackageCount(), transferNo, + "WAREHOUSE_TRANSFER_CONFIRMED:" + transferNo, operationKey, trim(request.remark()))); + stockSourcesBySku.computeIfAbsent(item.getSkuCode(), ignored -> new ArrayList<>()) + .add(new ConfirmedStockSource(result.balanceId(), result.positionId(), row.getPackageCount())); + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setId(IdWorker.getId()); receipt.setCustomerId(customerId); + // 转运收货没有独立 PDA 入仓任务,使用本业务链的稳定主键填充通用收货快照维度。 + receipt.setPlanId(transfer.getInboundOrderId()); receipt.setTaskId(transfer.getId()); + receipt.setAllocationId(row.getId()); receipt.setLineId(item.getId()); + receipt.setOrderId(transfer.getLastlegOrderId()); + receipt.setInboundCode(transferNo); receipt.setSkuCode(item.getSkuCode()); receipt.setProductNameZh(item.getProductNameZh()); + receipt.setWarehousePalletId(pallet.getWarehousePalletId()); receipt.setPalletNo(pallet.getPalletNo()); + receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); receipt.setWarehouseLocationCode(pallet.getWarehouseLocationCode()); + receipt.setBatchNo(transferNo); receipt.setReceivedPackages(Long.valueOf(row.getPackageCount())); + receipt.setInventoryBalanceId(result.balanceId()); receipt.setInventoryPositionId(result.positionId()); + receipt.setInventoryOperationKey(operationKey); receipt.setReceivedAt(now); receipt.setCreatedAt(now); stockReceipts.insert(receipt); + } + } + if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { + RelabelResponses.Detail relabel = createLinkedRelabel(customerId, operatorId, transfer, itemById, + stockSourcesBySku); + transfer.setRelabelApplicationId(relabel.application().id()); + transfer.setRelabelNo(relabel.application().relabelNo()); transfer.setStatus(WAIT_RELABEL); + } else { + if (!StringUtils.hasText(transfer.getFbaWarehouseCode())) { + throw conflict("A destination warehouse is required before dispatch"); + } + createDispatchPlan(customerId, transfer, items, pallets, palletItems, now); + transfer.setStatus(READY_FOR_DISPATCH); + } + transfer.setConfirmationIdempotencyKey(request.idempotencyKey().trim()); + transfer.setConfirmationRequestHash(requestHash); transfer.setCustomerConfirmedAt(now); transfer.setUpdatedAt(now); + transfers.updateById(transfer); + LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); + order.setCurrentStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? WAIT_RELABEL : READY_FOR_DISPATCH); + order.setRelabelStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? "PENDING_PROCESSING" : "NOT_REQUIRED"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus() + ";RELABEL:" + order.getRelabelStatus()); + order.setUpdatedAt(now); orders.updateById(order); + insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, + "Customer confirmed PDA measurements; actual inventory stocked in", 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); + } + + @Transactional(readOnly = true) + public List pdaCandidates(long overseasWarehouseId) { + return transfers.pdaCandidates(String.valueOf(overseasWarehouseId)).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(); + } + + private void validateRelabelRequest(WarehouseTransferRequests.Create request) { + if (!request.needRelabel()) { + if (request.relabelItems() != null && !request.relabelItems().isEmpty()) { + throw badRequest("Relabel items require needRelabel=true"); + } + return; + } + if (request.relabelItems() == null || request.relabelItems().isEmpty()) return; + if (request.labelDeadline() == null || !StringUtils.hasText(request.relabelContactName()) + || !StringUtils.hasText(request.relabelContactPhone()) || request.relabelChargeAmount() == null) { + throw badRequest("Relabel deadline, contact and charge are required"); + } + Set declaredSkus = request.items().stream().map(item -> item.skuCode().trim()).collect(Collectors.toSet()); + Set marks = new HashSet<>(); + for (WarehouseTransferRequests.RelabelItem row : request.relabelItems()) { + String type = row.operationType().trim().toUpperCase(); + if (!Set.of("CARTON_LABEL", "SKU_LABEL", "CARTON_AND_SKU").contains(type)) { + throw badRequest("Unsupported relabel operation type"); + } + if (!declaredSkus.contains(row.originalSku().trim())) { + throw badRequest("Relabel SKU must exist in transfer cargo: " + row.originalSku()); + } + if (("CARTON_LABEL".equals(type) || "CARTON_AND_SKU".equals(type)) + && (!StringUtils.hasText(row.fbaCartonMark()) || !StringUtils.hasText(row.destinationWarehouseCode()))) { + throw badRequest("Carton relabel requires new FBA carton mark and destination warehouse"); + } + if (("SKU_LABEL".equals(type) || "CARTON_AND_SKU".equals(type)) && !StringUtils.hasText(row.newSku())) { + throw badRequest("SKU relabel requires a new SKU"); + } + for (String mark : row.customerCartonMarks()) { + if (!marks.add(mark.trim().toUpperCase())) throw badRequest("Customer carton marks must be unique"); + } + } + if (marks.size() > request.packageCount()) throw badRequest("Relabel carton marks cannot exceed transfer cartons"); + } + + private void insertRelabelBindings(String customerId, long transferId, + List rows, LocalDateTime now) { + if (rows == null || rows.isEmpty()) return; + for (WarehouseTransferRequests.RelabelItem source : rows) { + for (String mark : source.customerCartonMarks()) { + WarehouseTransferRelabelBinding row = new WarehouseTransferRelabelBinding(); + row.setId(IdWorker.getId()); row.setTransferOrderId(transferId); row.setCustomerId(customerId); + row.setOperationType(source.operationType().trim().toUpperCase()); + row.setCustomerCartonMark(mark.trim()); row.setOriginalSku(source.originalSku().trim()); + row.setNewSku(trim(source.newSku())); row.setQuantity(source.quantity()); + row.setFbaCartonMark(trim(source.fbaCartonMark())); + row.setDestinationWarehouseCode(trim(source.destinationWarehouseCode())); + row.setRemark(trim(source.remark())); row.setCreatedAt(now); relabelBindings.insert(row); + } + } + } + + private RelabelResponses.Detail createLinkedRelabel(String customerId, String operatorId, + WarehouseTransferOrder transfer, + Map itemById, + Map> stockSourcesBySku) { + Map itemsBySku = itemById.values().stream() + .collect(Collectors.toMap(WarehouseTransferItem::getSkuCode, Function.identity(), (left, right) -> left)); + List items = new ArrayList<>(); + for (WarehouseTransferRelabelBinding binding : relabelBindings.listOrder(customerId, transfer.getId())) { + WarehouseTransferItem source = itemsBySku.get(binding.getOriginalSku()); + ConfirmedStockSource stockSource = nextStockSource(stockSourcesBySku.get(binding.getOriginalSku())); + if (source == null || stockSource == null) { + throw conflict("Relabel binding cannot find confirmed inventory for SKU " + binding.getOriginalSku()); + } + List skuItems = Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(binding.getOperationType()) + ? List.of(new RelabelRequests.SkuItem(binding.getOriginalSku(), binding.getNewSku(), + binding.getQuantity(), binding.getRemark())) : List.of(); + items.add(new RelabelRequests.Item(stockSource.balanceId, stockSource.positionId, transfer.getInboundOrderId(), + binding.getOriginalSku(), null, null, null, source.getProductNameZh(), 1, 1, + StringUtils.hasText(transfer.getFbaShipmentNo()) ? transfer.getFbaShipmentNo() : transfer.getTransferNo(), + transfer.getTrackingNo(), binding.getNewSku(), "仓储转运自动换标", binding.getRemark(), + null, null, null, binding.getCustomerCartonMark(), binding.getFbaCartonMark(), + binding.getDestinationWarehouseCode(), binding.getOperationType(), skuItems)); + } + RelabelRequests.Create request = new RelabelRequests.Create(transfer.getTransferNo(), + transfer.getOperatingWarehouseId(), transfer.getLabelDeadline(), transfer.getRelabelContactName(), + transfer.getRelabelContactPhone(), transfer.getRelabelChargeAmount(), "仓储转运确认后自动创建换标单", + "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL", items, + trim(transfer.getRelabelDeliveryMethod()), false); + return relabelService.create(customerId, operatorId, request); + } + + private static ConfirmedStockSource nextStockSource(List sources) { + if (sources == null) return null; + for (ConfirmedStockSource source : sources) { + if (source.remainingPackages > 0) { + source.remainingPackages--; + return source; + } + } + return null; + } + + private static final class ConfirmedStockSource { + private final Long balanceId; + private final Long positionId; + private int remainingPackages; + + private ConfirmedStockSource(Long balanceId, Long positionId, int remainingPackages) { + this.balanceId = balanceId; + this.positionId = positionId; + this.remainingPackages = remainingPackages; + } + } + + private void createDispatchPlan(String customerId, WarehouseTransferOrder transfer, + List items, List pallets, + List palletItems, LocalDateTime now) { + long planId = IdWorker.getId(); + InboundPlan plan = new InboundPlan(); plan.setId(planId); plan.setPlanNo("WT-" + transfer.getTransferNo()); + plan.setCustomerId(customerId); plan.setSourceOrderId(transfer.getLastlegOrderId()); + plan.setContainerNo(transfer.getTransferNo()); plan.setOverseasWarehouseId(Long.parseLong(transfer.getOperatingWarehouseId())); + plan.setPlannedArrivalDate(transfer.getEstimatedArrivalAt().toLocalDate()); plan.setPalletType("STANDARD"); + plan.setAssignedFlag(true); plan.setMergeRequired(false); plan.setUrgentFlag(false); + plan.setCreationMode("WAREHOUSE_TRANSFER"); plan.setPlanRemark("Customer-confirmed transfer measurements"); + plan.setStatus("COMPLETED"); plan.setLineCount(items.size()); plan.setPalletCount(pallets.size()); + plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) + .reduce(BigDecimal.ZERO, BigDecimal::add)); plan.setCreatedAt(now); plan.setUpdatedAt(now); plan.setVersion(0); + inboundPlans.insert(plan); + Map lineIds = new java.util.HashMap<>(); + for (WarehouseTransferItem source : items) { + InboundPlanLine line = new InboundPlanLine(); line.setId(IdWorker.getId()); line.setCustomerId(customerId); + line.setPlanId(planId); line.setOrderId(transfer.getLastlegOrderId()); line.setInboundCode(transfer.getTransferNo()); + line.setDestinationType("FBA"); line.setFbaWarehouseCode(transfer.getFbaWarehouseCode()); + line.setDestinationWarehouseCode(transfer.getFbaWarehouseCode()); line.setDestinationWarehouseName(transfer.getFbaWarehouseCode()); + line.setVolumeCbm(BigDecimal.ZERO); line.setRequiredPackageCount(source.getPackageCount()); + line.setPlannedPalletCount((int) palletItems.stream().filter(row -> row.getTransferItemId().equals(source.getId())).count()); + inboundPlanLines.insert(line); lineIds.put(source.getId(), line.getId()); + } + for (WarehouseTransferPallet source : pallets) { + InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(IdWorker.getId()); pallet.setCustomerId(customerId); + pallet.setPlanId(planId); pallet.setPalletNo(source.getPalletNo()); pallet.setPalletType("STANDARD"); + pallet.setDestinationWarehouseCode(transfer.getFbaWarehouseCode()); pallet.setDestinationWarehouseName(transfer.getFbaWarehouseCode()); + pallet.setAllocatedVolumeCbm(source.getVolumeCbm()); pallet.setSequenceNo(source.getSequenceNo()); + pallet.setWarehousePalletId(source.getWarehousePalletId()); pallet.setWarehouseLocationId(source.getWarehouseLocationId()); + pallet.setWarehouseResourceLockId(source.getWarehouseResourceLockId()); pallet.setAssignmentStatus("ASSIGNED"); + pallet.setLockedAt(source.getCompletedAt()); pallet.setActualVolumeCbm(source.getVolumeCbm()); + pallet.setActualPackageCount(source.getCartonCount()); inboundPlanPallets.insert(pallet); + for (WarehouseTransferPalletItem sourceItem : palletItems.stream() + .filter(row -> row.getTransferPalletId().equals(source.getId())).toList()) { + InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); + allocation.setCustomerId(customerId); allocation.setPlanId(planId); allocation.setLineId(lineIds.get(sourceItem.getTransferItemId())); + allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO); + allocation.setPlannedPackageCount(sourceItem.getPackageCount()); allocation.setCountedPackageCount(sourceItem.getPackageCount()); + allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(source.getVolumeCbm()); + allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation); + } + } + } + + private static List splitMarks(String value) { + if (!StringUtils.hasText(value)) return List.of(); + return java.util.Arrays.stream(value.split("[,\\n\\r]+")) + .map(String::trim).filter(StringUtils::hasText).distinct().toList(); + } + + private static String escape(String value) { + return org.springframework.web.util.HtmlUtils.htmlEscape(Objects.toString(value, "")); + } + + private static String text(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + + private Long createInboundSnapshot(String customerId, long inboundOrderId, String transferNo, + WarehouseTransferRequests.Create request, String requestHash, + LocalDateTime now) { + LastlegInboundOrder inbound = new LastlegInboundOrder(); + String sourceId = "WAREHOUSE_TRANSFER:" + transferNo; + inbound.setId(inboundOrderId); inbound.setCustomerId(customerId); inbound.setSourceRegion("CUSTOMER"); + inbound.setSourceInboundId(sourceId); inbound.setRootInboundId(sourceId); + inbound.setSourceOrderId(transferNo); inbound.setSourceOrderNo(transferNo); inbound.setRootOrderId(transferNo); + inbound.setInboundCode(transferNo); inbound.setDestinationWarehouseId(request.operatingWarehouseId().trim()); + inbound.setDestinationType("OVERSEAS"); inbound.setPackageCount(request.packageCount()); + inbound.setPalletCount(0); inbound.setVolumeCbm(request.volumeCbm()); + inbound.setSourceEventId(sourceId); inbound.setSourceLoadingPlanId(ORDER_TYPE); + inbound.setSourceLoadingPlanVersion(1L); inbound.setHandoverVersion(1L); + inbound.setPayloadHash("sha256:" + requestHash); inbound.setStatus(PENDING_REVIEW); + inbound.setExceptionFlag("NONE"); inbound.setCreatedAt(now); inbound.setUpdatedAt(now); + inboundOrders.insert(inbound); + return inbound.getId(); + } + + private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String inboundCode, String transferNo, + WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) { + LastlegOrder order = new LastlegOrder(); + order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), order.getId())); order.setCustomerId(customerId); + order.setOrderType(ORDER_TYPE); + order.setInboundCode(inboundCode); order.setSourceRegion("CUSTOMER"); + order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId)); + order.setSourceOrderNo(transferNo); order.setOverseasWarehouseId(request.operatingWarehouseId().trim()); + order.setFbaWarehouseCode(trim(request.fbaWarehouseCode())); 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_REVIEW); + order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + order.getRelabelStatus()); + 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 long 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); + return item.getId(); + } + + private void insertTransferCarton(String customerId, long transferId, long transferItemId, + String transferNo, int cartonIndex, int totalCartons, + String customerCartonMark, LocalDateTime now) { + WarehouseTransferCarton carton = new WarehouseTransferCarton(); + carton.setId(IdWorker.getId()); carton.setTransferOrderId(transferId); carton.setTransferItemId(transferItemId); + carton.setCustomerId(customerId); carton.setCartonMark(transferNo + "#" + totalCartons + "-" + cartonIndex); + carton.setCustomerCartonMark(trim(customerCartonMark)); + carton.setCartonIndex(cartonIndex); carton.setTotalCartons(totalCartons); carton.setStatus("EXPECTED"); + carton.setCreatedAt(now); carton.setUpdatedAt(now); transferCartons.insert(carton); + } + + 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(trim(request.fbaWarehouseCode())); + detail.setFbaTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) + ? trim(request.trackingNo()) : null); + detail.setShipmentNo(trim(request.fbaShipmentNo())); + detail.setShippingMark(trim(source.shippingMark())); orderDetails.insert(detail); + } + + 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/controller/MerchantUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java new file mode 100644 index 000000000..a33e914af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java @@ -0,0 +1,65 @@ +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 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.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") +@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING + "')") +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.MerchantQuery query) { + return ApiResponse.success(service.page(customer(user), query.scoped())); + } + + @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..97ec10406 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java @@ -0,0 +1,126 @@ +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 MerchantQuery( + @Min(1) long page, @Min(1) @Max(200) long size, + String orderNo, String routeKeyword, String status, String transportType, + LocalDate requestedFrom, LocalDate requestedTo) { + public Query scoped() { + return new Query(page, size, orderNo, routeKeyword, status, transportType, + null, requestedFrom, 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..c02177466 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -0,0 +1,455 @@ +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.common.core.container.ContainerSpecificationCatalog; +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 { + @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; + 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(orderNoGenerator.next("TRUCK", request.origin(), 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()) + ? ContainerSpecificationCatalog.require(request.containerType()).containerType() + : ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE); + 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( + ContainerSpecificationCatalog.require(v).containerType())); + 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/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java new file mode 100644 index 000000000..a95dd7e06 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java @@ -0,0 +1,73 @@ +package com.baiyue.lastleg.wallet; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventEnvelopeValidator; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.RegionCode; +import com.baiyue.common.event.WalletCommandCompletedV1; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +@Service +@RequiredArgsConstructor +public class WalletCommandCompletedConsumer { + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final List handlers; + + @Transactional + public int consume(EventEnvelope envelope) { + WalletCommandCompletedV1 result = validate(envelope); + List existing = jdbc.queryForList("SELECT payload_hash FROM ll_wallet_event_inbox WHERE event_id=?", String.class, envelope.eventId()); + if (!existing.isEmpty()) { + if (!existing.get(0).equals(envelope.payloadHash())) throw bad("同一资金结果事件ID对应不同载荷"); + return 0; + } + try { + jdbc.update("INSERT INTO ll_wallet_event_inbox(event_id,command_event_id,payload_hash,payload_json,status,received_at) VALUES(?,?,?,CAST(? AS JSON),'RECEIVED',NOW(3))", + envelope.eventId(), result.commandEventId(), envelope.payloadHash(), json(envelope)); + } catch (DuplicateKeyException exception) { return 0; } + WalletResultHandler handler = handlers.stream().filter(value -> value.supports(result.businessType())).findFirst() + .orElseThrow(() -> bad("没有可处理该资金结果的业务模块")); + handler.handle(result); + jdbc.update("UPDATE ll_wallet_event_inbox SET status='PROCESSED',processed_at=NOW(3) WHERE event_id=?", envelope.eventId()); + return 1; + } + + private WalletCommandCompletedV1 validate(EventEnvelope envelope) { + try { EventEnvelopeValidator.requireValid(envelope); } + catch (RuntimeException exception) { throw bad("资金结果事件信封无效"); } + if (!EventTypes.WALLET_COMMAND_COMPLETED.equals(envelope.eventType()) + || envelope.eventVersion() != WalletCommandCompletedV1.VERSION + || envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { + throw bad("资金结果事件类型、版本或区域无效"); + } + if (!EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(envelope.payload())).equals(envelope.payloadHash())) { + throw bad("资金结果事件载荷哈希无效"); + } + try { + WalletCommandCompletedV1 result = objectMapper.treeToValue(envelope.payload(), WalletCommandCompletedV1.class); + if (result.customerId() <= 0 || result.commandEventId() == null || result.commandEventId().isBlank() + || result.businessType() == null || result.businessType().isBlank() + || result.businessNo() == null || result.businessNo().isBlank()) throw bad("资金结果事件字段无效"); + return result; + } catch (ResponseStatusException exception) { throw exception; } + catch (Exception exception) { throw bad("资金结果事件载荷无法解析"); } + } + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (Exception exception) { throw bad("资金结果事件序列化失败"); } + } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(BAD_REQUEST, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java new file mode 100644 index 000000000..5225583d9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.wallet; + +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.RegionCode; +import com.baiyue.common.event.WalletCommandRequestedV1; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +@Service +@RequiredArgsConstructor +public class WalletCommandOutboxService { + private final LastlegEventOutboxMapper mapper; + private final ObjectMapper objectMapper; + + public String publish(String operation, long customerId, String businessType, String businessNo, + BigDecimal amount, String remark, long aggregateVersion) { + String eventKey = "WALLET_COMMAND:" + businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion; + LastlegEventOutbox existing = mapper.findByEventKey(eventKey); + if (existing != null) return eventId(existing.getPayloadJson()); + String idempotencyKey = businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion; + WalletCommandRequestedV1 value = new WalletCommandRequestedV1(operation, customerId, businessType, + businessNo, amount, remark, idempotencyKey, Instant.now()); + JsonNode payload = objectMapper.valueToTree(value); + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_REQUESTED, + WalletCommandRequestedV1.VERSION, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, + String.valueOf(customerId), businessType, businessNo, aggregateVersion, null, + EventIdentifiers.newUuid(), eventKey, null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid(); + LastlegEventOutbox row = new LastlegEventOutbox(); + row.setId(IdWorker.getId()); row.setCustomerId(String.valueOf(customerId)); + row.setAggregateType(businessType); row.setAggregateId(businessNo); + row.setEventType(envelope.eventType()); row.setEventVersion(envelope.eventVersion()); + row.setEventKey(eventKey); row.setPayloadJson(json(envelope)); row.setStatus("PENDING"); + row.setRetryCount(0); row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); + mapper.insert(row); + return envelope.eventId(); + } + + private String eventId(String json) { + try { return objectMapper.readValue(json, EventEnvelope.class).eventId(); } + catch (Exception exception) { throw new IllegalStateException("资金指令事件快照损坏", exception); } + } + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (Exception exception) { throw new IllegalStateException("资金指令事件序列化失败", exception); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletResultHandler.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletResultHandler.java new file mode 100644 index 000000000..b4a2360b9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletResultHandler.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.wallet; + +import com.baiyue.common.event.WalletCommandCompletedV1; + +public interface WalletResultHandler { + boolean supports(String businessType); + void handle(WalletCommandCompletedV1 result); +} diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml new file mode 100644 index 000000000..01e04f1c7 --- /dev/null +++ b/lastleg-service/src/main/resources/application.yml @@ -0,0 +1,97 @@ +server: + port: ${LASTLEG_PORT:9303} + max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB} +spring: + servlet: + multipart: + max-file-size: ${LASTLEG_MULTIPART_MAX_FILE_SIZE:10MB} + max-request-size: ${LASTLEG_MULTIPART_MAX_REQUEST_SIZE:11MB} + 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} + customer-visit-file: + connect-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_READ_TIMEOUT:30s} + procurement-file: + connect-timeout: ${LASTLEG_PROCUREMENT_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_PROCUREMENT_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} + domestic-loading-evidence: + connect-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_READ_TIMEOUT:30s} + clients: + print: + url: ${PRINT_SERVICE_URL:} + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL:} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} + file: + url: ${FILE_SERVICE_URL:http://127.0.0.1:9205} + domestic-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: + gateway-internal-service-token: ${GATEWAY_INTERNAL_SERVICE_TOKEN:${INTERNAL_SERVICE_TOKEN:}} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:} + jwt-mode: jwks + session-mode: gateway + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql new file mode 100644 index 000000000..e92e0168c --- /dev/null +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -0,0 +1,2576 @@ +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', + ocean_booking_id VARCHAR(128) 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(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_no_daily_sequence ( + business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)', + last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号', + PRIMARY KEY(business_date) +) ENGINE=InnoDB COMMENT='尾程单号按日原子流水'; + +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一件代发', + lx_code VARCHAR(128) NULL COMMENT '一件代发领星入库单代码', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + inbound_code VARCHAR(128) 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) NULL COMMENT '海外接柜仓ID;新建尾程订单由业务服务预分配', + overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外接柜仓业务编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外接柜仓名称快照', + receiving_warehouse_version INT NOT NULL DEFAULT 0 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 '集装箱号', + abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT '当前暂扣是否计费:0否1是', + hold_reason VARCHAR(500) NULL COMMENT '当前暂扣原因;解除后为空', + manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用单票人工计划节点覆盖:0否1是,启用后不再继承柜级节点', + version 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 '累计已拣箱数', + 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 '预计到仓时间', + arrival_time_version INT NOT NULL DEFAULT 0 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 '合托标记', + pallet_merge_batch_id VARCHAR(64) NULL 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), + KEY idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id) +) ENGINE=InnoDB COMMENT='尾程订单主表'; + +CREATE TABLE IF NOT EXISTS ll_order_source_pallet ( + id BIGINT NOT NULL COMMENT '来源托盘快照ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + source_pallet_no VARCHAR(128) NOT NULL COMMENT '头程冻结托盘码', + sequence_no INT NOT NULL COMMENT '订单内稳定顺序', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源装柜完成事件ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_source_pallet_no(source_pallet_no), + UNIQUE KEY uk_ll_source_pallet_sequence(customer_id,order_id,sequence_no), + KEY idx_ll_source_pallet_order(customer_id,order_id), + CONSTRAINT fk_ll_source_pallet_order FOREIGN KEY(order_id) REFERENCES ll_order(id) +) 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='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag')); +PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_chargeable'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT ''当前暂扣是否计费:0否1是'' AFTER detained')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_reason'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_reason VARCHAR(500) NULL COMMENT ''当前暂扣原因;解除后为空'' AFTER hold_chargeable')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='manual_node_override'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT ''是否启用单票人工计划节点覆盖:0否1是'' AFTER hold_reason')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''尾程订单命令乐观锁版本'' AFTER manual_node_override')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; + +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_order_operation_log ( + id BIGINT NOT NULL COMMENT '操作记录主键', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', + result VARCHAR(500) NOT NULL COMMENT '处理结果', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), + KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单操作记录'; + +CREATE TABLE IF NOT EXISTS ll_order_command_record ( + id BIGINT NOT NULL COMMENT '尾程订单命令幂等记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', action VARCHAR(32) NOT NULL COMMENT '命令动作:MANUAL_PLAN、HOLD、MANUAL_NODES', + operator_id VARCHAR(128) NOT NULL COMMENT '平台操作人ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', result_ref VARCHAR(128) NULL COMMENT '结果业务引用', + result_json LONGTEXT NOT NULL COMMENT '首次成功响应JSON快照', result_version INT NOT NULL COMMENT '命令成功后的订单版本', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_command_key(customer_id,order_id,action,operator_id,idempotency_key), + KEY idx_ll_order_command_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单写命令幂等与结果审计记录'; + +CREATE TABLE IF NOT EXISTS ll_order_hold_history ( + id BIGINT NOT NULL COMMENT '尾程订单暂扣历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', held TINYINT NOT NULL COMMENT '操作后是否暂扣:0否1是', + chargeable TINYINT NOT NULL COMMENT '本次暂扣是否计费:0否1是', reason VARCHAR(500) NOT NULL COMMENT '暂扣或解除原因', + order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_hold_key(customer_id,order_id,operator_id,idempotency_key), + KEY idx_ll_order_hold_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单暂扣与解除不可变历史'; + +CREATE TABLE IF NOT EXISTS ll_order_manual_node ( + id BIGINT NOT NULL COMMENT '尾程订单人工节点ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', node_code VARCHAR(32) NOT NULL COMMENT '八行人工展示节点稳定代码,与Carrier标准节点隔离', + node_at DATETIME(3) NULL COMMENT '人工填写节点时间,允许为空', source VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '节点来源,固定MANUAL', + order_version INT NOT NULL COMMENT '最近编辑后的订单版本', updated_by VARCHAR(128) NOT NULL COMMENT '最近操作人ID', + updated_at DATETIME(3) NOT NULL COMMENT '最近编辑时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_manual_node(customer_id,order_id,node_code), KEY idx_ll_order_manual_node_order(customer_id,order_id,node_at) +) ENGINE=InnoDB COMMENT='尾程订单当前人工节点覆盖事实'; + +CREATE TABLE IF NOT EXISTS ll_order_manual_node_history ( + id BIGINT NOT NULL COMMENT '尾程订单人工节点历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', nodes_json LONGTEXT NOT NULL COMMENT '本次全量人工节点JSON快照', + order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_manual_node_history_key(customer_id,order_id,operator_id,idempotency_key), + KEY idx_ll_order_manual_node_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单人工节点覆盖不可变历史'; + +CREATE TABLE IF NOT EXISTS ll_receiving_warehouse_operation ( + id BIGINT NOT NULL COMMENT '柜级接柜仓修改操作ID', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '平台请求幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '规范化修改请求SHA-256摘要', + result_version INT NOT NULL COMMENT '修改成功后的柜级分配版本', + operator_name VARCHAR(128) NOT NULL COMMENT '平台操作人名称', + remark VARCHAR(500) NULL COMMENT '接柜仓修改备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_warehouse_idempotency(idempotency_key), + KEY idx_ll_receiving_warehouse_container(source_container_booking_id,created_at) +) ENGINE=InnoDB COMMENT='柜级海外接柜仓修改幂等审计记录'; + +CREATE TABLE IF NOT EXISTS ll_container_arrival_time_operation ( + id BIGINT NOT NULL COMMENT '柜级预计到仓时间修改操作ID', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '平台请求幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '规范化修改请求SHA-256摘要', + result_version INT NOT NULL COMMENT '修改成功后的柜级时间版本', + operator_name VARCHAR(128) NOT NULL COMMENT '平台操作人名称', + remark VARCHAR(500) NULL COMMENT '预计到仓时间修改备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_container_arrival_time_idempotency(idempotency_key), + KEY idx_ll_container_arrival_time_container(source_container_booking_id,created_at) +) ENGINE=InnoDB COMMENT='柜级预计到仓时间修改幂等审计记录'; + +-- 派送单列表与详情快照、转派送费用及操作记录。 +CREATE TABLE IF NOT EXISTS ll_delivery_order ( + id BIGINT NOT NULL COMMENT '派送单主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + 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', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '本派送单目的FBA仓ID快照', + destination_warehouse_code VARCHAR(128) NOT NULL COMMENT '本派送单目的FBA仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '本派送单目的FBA仓名称快照', + delivery_group_key VARCHAR(255) NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键', + status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', + waybill_no VARCHAR(128) NULL COMMENT '承运运单号', + bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识', + documents_json JSON NULL COMMENT '派送三类资料当前绑定及权威文件元数据快照', + delivery_method VARCHAR(32) NULL COMMENT '派送方式', + carrier_name VARCHAR(255) NULL COMMENT '派送承运商', + pickup_method VARCHAR(64) NULL COMMENT '提货方式', + loading_type VARCHAR(64) NULL COMMENT '装车类型', + vehicle_type VARCHAR(64) NULL COMMENT '车辆类型', + vehicle_size VARCHAR(64) NULL COMMENT '车辆大小', + vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', + driver_name VARCHAR(128) NULL COMMENT '司机或车队名称', + driver_phone VARCHAR(64) NULL COMMENT '车队联系电话', + appointment_account VARCHAR(128) NULL COMMENT '预约账号', + seller_name VARCHAR(128) NULL COMMENT '卖家姓名', + package_count INT NOT NULL DEFAULT 0 COMMENT '派送箱数', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '派送托盘数', + total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', + total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', + planned_delivery_at DATETIME(3) NULL COMMENT '预计派送时间', + dispatched_at DATETIME(3) NULL COMMENT '实际派送时间', + expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', + signed_at DATETIME(3) NULL COMMENT '签收时间', + remark VARCHAR(1000) NULL COMMENT '派送备注', + idempotency_key VARCHAR(255) 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_delivery_no (customer_id,delivery_no), + UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key), + UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key), + KEY idx_ll_delivery_list (customer_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程派送单'; + +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_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照'' AFTER overseas_warehouse_id')); +PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; +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_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照'' AFTER overseas_warehouse_name')); +PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓ID快照'' AFTER customer_name, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''本派送单目的FBA仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN delivery_group_key VARCHAR(255) NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键'' AFTER destination_warehouse_name')); +PREPARE delivery_destination_stmt FROM @ddl; EXECUTE delivery_destination_stmt; DEALLOCATE PREPARE delivery_destination_stmt; +SET @old_delivery_outbound_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound'); +SET @new_delivery_destination_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound_destination'); +SET @ddl = IF(@new_delivery_destination_index > 0, 'SELECT 1', IF(@old_delivery_outbound_index > 0, 'ALTER TABLE ll_delivery_order DROP INDEX uk_ll_delivery_outbound, ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)', 'ALTER TABLE ll_delivery_order ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)')); +PREPARE delivery_destination_index_stmt FROM @ddl; EXECUTE delivery_destination_index_stmt; DEALLOCATE PREPARE delivery_destination_index_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='waybill_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN waybill_no VARCHAR(128) NULL COMMENT ''承运运单号'', ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式'', ADD COLUMN carrier_name VARCHAR(255) NULL COMMENT ''派送承运商'', ADD COLUMN pickup_method VARCHAR(64) NULL COMMENT ''提货方式'', ADD COLUMN loading_type VARCHAR(64) NULL COMMENT ''装车类型'', ADD COLUMN vehicle_type VARCHAR(64) NULL COMMENT ''车辆类型'', ADD COLUMN vehicle_size VARCHAR(64) NULL COMMENT ''车辆大小'', ADD COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号'', ADD COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机或车队名称'', ADD COLUMN driver_phone VARCHAR(64) NULL COMMENT ''车队联系电话'', ADD COLUMN appointment_account VARCHAR(128) NULL COMMENT ''预约账号'', ADD COLUMN seller_name VARCHAR(128) NULL COMMENT ''卖家姓名'' AFTER status')); +PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'' AFTER pallet_count')); +PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; +CREATE TABLE IF NOT EXISTS ll_delivery_document_revision ( + id BIGINT NOT NULL COMMENT '派送资料历史ID', + customer_id VARCHAR(128) NOT NULL COMMENT '派送所属客户隔离键', + delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', + version BIGINT NOT NULL COMMENT '保存成功时派送单版本', + idempotency_key VARCHAR(128) NOT NULL COMMENT '资料保存幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '完整保存请求哈希', + snapshot_json JSON NOT NULL COMMENT '不可变三类资料及BOL号结果快照', + operator_id VARCHAR(128) NOT NULL COMMENT '保存操作人ID', + created_at DATETIME(3) NOT NULL COMMENT '资料保存时间', + PRIMARY KEY(id), + UNIQUE KEY uk_delivery_document_command(customer_id,delivery_order_id,idempotency_key), + UNIQUE KEY uk_delivery_document_version(customer_id,delivery_order_id,version) +) ENGINE=InnoDB COMMENT='派送资料版本历史与幂等保存记录'; + +CREATE TABLE IF NOT EXISTS ll_delivery_operation ( + id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', + operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', + result VARCHAR(500) NOT NULL COMMENT '处理结果', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), KEY idx_ll_delivery_operation_order(customer_id,delivery_order_id,created_at,id) +) 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 '入仓码', lx_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='arrival_time_version'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN arrival_time_version INT NOT NULL DEFAULT 0 COMMENT ''柜级预计到仓时间乐观锁版本'' AFTER estimated_warehouse_at')); +PREPARE lastleg_order_arrival_time_version_stmt FROM @ddl; EXECUTE lastleg_order_arrival_time_version_stmt; DEALLOCATE PREPARE lastleg_order_arrival_time_version_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_pending_container'), 'ALTER TABLE ll_order DROP INDEX idx_ll_order_pending_container', 'SELECT 1')); +PREPARE lastleg_order_pending_container_idx_stmt FROM @ddl; EXECUTE lastleg_order_pending_container_idx_stmt; DEALLOCATE PREPARE lastleg_order_pending_container_idx_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_container_progress'), 'SELECT 1', 'ALTER TABLE ll_order ADD INDEX idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)')); +PREPARE lastleg_order_container_progress_idx_stmt FROM @ddl; EXECUTE lastleg_order_container_progress_idx_stmt; DEALLOCATE PREPARE lastleg_order_container_progress_idx_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_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 lastleg_order_source_warehouse_stmt FROM @ddl; EXECUTE lastleg_order_source_warehouse_stmt; DEALLOCATE PREPARE lastleg_order_source_warehouse_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 lastleg_order_source_warehouse_stmt FROM @ddl; EXECUTE lastleg_order_source_warehouse_stmt; DEALLOCATE PREPARE lastleg_order_source_warehouse_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) 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_code VARCHAR(128) NULL COMMENT '海外接柜仓业务编码快照', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外接柜仓名称快照', + MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本', + MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码', + MODIFY COLUMN container_no VARCHAR(64) NULL 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 NOT NULL DEFAULT 193000000000200003 COMMENT '平台预分配的海外实际收货共享物理仓ID', + planned_arrival_date DATE NULL COMMENT '人工计划预计到仓日期', + pallet_type VARCHAR(32) NULL COMMENT '人工计划托盘类型', + assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否已分配:0否1是', + merge_required TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否要求合托:0否1是', + urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否加急:0否1是', + creation_mode VARCHAR(16) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工', + plan_remark VARCHAR(1000) NULL COMMENT '入仓计划备注', + 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 '更新时间', + version INT NOT NULL DEFAULT 0 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_container_plan_conversion_command ( + id BIGINT NOT NULL COMMENT '柜级转换命令ID', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', + idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键', + request_hash CHAR(64) NOT NULL COMMENT '确认请求SHA-256摘要', + snapshot_version BIGINT NOT NULL COMMENT '确认的柜级快照版本', + snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256', + warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', + batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键', + status VARCHAR(32) NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED', + lease_token VARCHAR(64) NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离', + preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', + result_json JSON NULL COMMENT '本地计划提交结果', + last_error VARCHAR(1000) 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_container_conversion_command(source_container_booking_id), + UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), + KEY idx_ll_container_conversion_status(status,updated_at) +) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_container_plan_conversion_command' AND column_name='lease_token'), 'SELECT 1', 'ALTER TABLE ll_container_plan_conversion_command ADD COLUMN lease_token VARCHAR(64) NULL COMMENT ''当前执行租约令牌,用于陈旧执行者隔离'' AFTER status')); +PREPARE container_conversion_lease_stmt FROM @ddl; EXECUTE container_conversion_lease_stmt; DEALLOCATE PREPARE container_conversion_lease_stmt; + +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(128) NOT NULL COMMENT '海外仓资源服务返回的真实托盘码', + source_pallet_no VARCHAR(128) NULL COMMENT '一件代发不可变来源托盘号快照', + pallet_type VARCHAR(32) 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 '计划内托盘序号', + version INT NOT NULL DEFAULT 0 COMMENT '收货累计值乐观锁版本', + PRIMARY KEY (id), + KEY idx_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; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='planned_arrival_date'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN planned_arrival_date DATE NULL COMMENT ''人工计划预计到仓日期'' AFTER overseas_warehouse_id')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_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='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER planned_arrival_date')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_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='assigned_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否已分配:0否1是'' AFTER pallet_type')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_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='merge_required'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN merge_required TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否要求合托:0否1是'' AFTER assigned_flag')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_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='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_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='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_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='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_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_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(128) 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' COMMENT '入库计划状态: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 COMMENT ''计划明细应收箱数''')); +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 COMMENT ''海外仓实物托盘ID''')); +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='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER pallet_no')); +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 COMMENT ''海外仓库位ID''')); +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 COMMENT ''海外仓资源锁ID''')); +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'' COMMENT ''托盘分配状态: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 COMMENT ''资源锁定时间''')); +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 COMMENT ''实收体积(立方米)''')); +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 COMMENT ''实收箱数''')); +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='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''收货累计值乐观锁版本''')); +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 COMMENT ''计划分配箱数''')); +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 COMMENT ''清点箱数''')); +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 COMMENT ''已拣箱数''')); +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 COMMENT ''实收体积(立方米)''')); +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'' COMMENT ''分配作业状态: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 COMMENT ''托盘分配乐观锁版本''')); +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_finance_fee_source ( + id BIGINT NOT NULL COMMENT '费用版本记录ID', source_fee_id BIGINT NOT NULL COMMENT '跨版本稳定费用来源ID', + source_version INT NOT NULL COMMENT '费用来源版本,从1递增', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', + customer_name VARCHAR(200) NULL COMMENT '客户名称快照', source_type VARCHAR(64) NOT NULL COMMENT '海外费用来源类型', + source_no VARCHAR(128) NOT NULL COMMENT '来源业务编号或入仓码快照', business_type VARCHAR(32) NOT NULL COMMENT '来源业务类型', + business_id BIGINT NULL COMMENT '来源业务记录ID', business_no VARCHAR(128) NOT NULL COMMENT '来源业务编号', + order_id BIGINT NULL COMMENT '关联订单ID', inbound_id BIGINT NULL COMMENT '关联海外入仓计划或入仓记录ID', + inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID', + container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照', + warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照', + fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', + supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照', + supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付', + paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式', + payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照', + business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY', + amount DECIMAL(20,6) NOT NULL COMMENT '人民币结算金额', original_currency CHAR(3) NOT NULL COMMENT '业务原币种,支持CNY或USD', + original_amount DECIMAL(20,6) NOT NULL COMMENT '业务原币金额', exchange_rate DECIMAL(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1', + exchange_rate_month CHAR(7) NOT NULL COMMENT '汇率月份yyyy-MM', chargeable_quantity DECIMAL(20,6) NULL COMMENT '计费数量,如箱数、托数、体积或天数', + quantity_unit VARCHAR(32) NULL COMMENT '计费数量单位', unit_price DECIMAL(20,6) NULL COMMENT '原币计费单价', + period_start DATE NULL COMMENT '仓租等期间费用开始日期', period_end DATE NULL COMMENT '仓租等期间费用结束日期', + review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED', + publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED', + review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间', + created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version), + KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at), + KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at), + CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'), + CONSTRAINT chk_ll_finance_fee_original_currency CHECK(original_currency IN('CNY','USD')), + CONSTRAINT chk_ll_finance_fee_period CHECK((period_start IS NULL AND period_end IS NULL) OR (period_start IS NOT NULL AND period_end IS NOT NULL AND period_start<=period_end)), + CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')), + CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')), + CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')), + CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED')) +) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_finance_fee_source' AND index_name='idx_ll_finance_fee_container_customer'), 'SELECT 1', 'ALTER TABLE ll_finance_fee_source ADD KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at)')); +PREPARE ll_finance_fee_ddl FROM @ddl; EXECUTE ll_finance_fee_ddl; DEALLOCATE PREPARE ll_finance_fee_ddl; + +CREATE TABLE IF NOT EXISTS ll_finance_fee_command ( + id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID', + source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key), + KEY idx_ll_finance_fee_command_source(source_fee_id,source_version) +) 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 COMMENT ''FBA仓库编码'' 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 TEMPORARY TABLE IF EXISTS inbound_plan_customer_scope_assertion; +CREATE TEMPORARY TABLE inbound_plan_customer_scope_assertion ( + is_valid TINYINT NOT NULL, + CONSTRAINT chk_inbound_plan_customer_scope_ready CHECK (is_valid=1) +); +INSERT INTO inbound_plan_customer_scope_assertion(is_valid) +SELECT 0 +WHERE 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); +DROP TEMPORARY TABLE inbound_plan_customer_scope_assertion; +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', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', overseas_warehouse_code VARCHAR(128) NOT NULL COMMENT '海外接柜仓业务编码快照', 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、COMPLETED_WITH_DIFFERENCE', + 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(container_no,status), KEY idx_ll_receiving_operator(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_fba_pallet_count ( + id BIGINT NOT NULL COMMENT 'FBA托盘现场累计箱数ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', + group_key VARCHAR(255) NOT NULL COMMENT '柜号与FBA仓组成的收货组键', pallet_code VARCHAR(128) NOT NULL COMMENT '现有FBA托盘码', + actual_packages 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), UNIQUE KEY uk_ll_receiving_fba_count(session_id,group_key,pallet_code), + KEY idx_ll_receiving_fba_group(session_id,group_key) +) ENGINE=InnoDB COMMENT='海外PDA普通FBA托盘现场累计与超收标记'; + + +CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( + id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', + 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) NOT 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', customer_name VARCHAR(255) NOT NULL COMMENT '客户名称不可变快照', + 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 '海外仓实物托盘码', + source_pallet_no VARCHAR(128) 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 (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 '数据来源阶段', + carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照', + source_business_type VARCHAR(32) NULL COMMENT '来源业务类型', + source_business_no VARCHAR(128) 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='派车货物冻结明细'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''换标箱唛映射快照'' AFTER source_stage')); 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_item' AND column_name='source_business_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT ''来源业务类型'' AFTER carton_marks')); 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_item' AND column_name='source_business_no'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT ''来源业务单号'' AFTER source_business_type')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + + 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 COMMENT ''关联订单信息'' 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) NOT NULL COMMENT 'Token绑定的海外仓库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 (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 NULL COMMENT '派车单ID,一件代发为空', dispatch_no VARCHAR(64) 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 '更新时间', + outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总', + recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息', + request_hash CHAR(64) NULL COMMENT '一件代发创建请求摘要,其他出库类型为空', + PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (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 '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', 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,inbound_code,sku_code), + 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_outbound_pda_task ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', + task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:待拣货、拣货中、已完成', + completion_idempotency_key VARCHAR(255) NULL COMMENT '完成命令幂等键', + pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no), + UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key) +) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务'; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key' +), 'SELECT 1', + 'ALTER TABLE ll_outbound_pda_task ADD COLUMN completion_idempotency_key VARCHAR(255) NULL COMMENT ''完成命令幂等键'' AFTER status')); +PREPARE outbound_pda_completion_column_stmt FROM @ddl; EXECUTE outbound_pda_completion_column_stmt; DEALLOCATE PREPARE outbound_pda_completion_column_stmt; +SET @index_ready = (SELECT COUNT(*)=2 AND MIN(non_unique)=0 + AND SUM(seq_in_index=1 AND column_name='customer_id')=1 + AND SUM(seq_in_index=2 AND column_name='completion_idempotency_key')=1 + FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key'); +SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key'); +SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists, + 'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_complete_key, ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)', + 'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)')); +PREPARE outbound_pda_completion_index_stmt FROM @ddl; EXECUTE outbound_pda_completion_index_stmt; DEALLOCATE PREPARE outbound_pda_completion_index_stmt; + +CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID', + inventory_position_id BIGINT NOT NULL COMMENT '库存仓位ID', packages BIGINT NOT NULL COMMENT '拣货箱数', idempotency_key VARCHAR(255) NOT NULL COMMENT '拣货幂等键', + picked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拣货时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_pick_key(customer_id,idempotency_key) +) ENGINE=InnoDB COMMENT='一件代发出库PDA托盘拣货记录'; + +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_type'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN outbound_type VARCHAR(32) NOT NULL DEFAULT ''DISPATCH_DELIVERY'' COMMENT ''出库类型'' AFTER status')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +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='sku_code'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''一件代发SKU''')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='recipient_info'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN recipient_info VARCHAR(1000) NULL COMMENT ''一件代发收件信息'' AFTER lx_codes')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +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='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''已拣箱数'' AFTER package_count')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +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='status'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''WAIT_PICK'' COMMENT ''拣货状态'' AFTER picked_package_count')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +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 is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_outbound_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''派车单ID,一件代发为空'', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT ''派车单号,一件代发为空''')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; + +-- 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) 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) 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 '业务状态', + review_decision VARCHAR(32) NULL COMMENT '审核决定', review_remark VARCHAR(1000) NULL COMMENT '审核备注或驳回原因', + reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL 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='客户转仓单'; + +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 is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码'', MODIFY COLUMN fba_shipment_no VARCHAR(128) NULL COMMENT ''可选FBA货件编号'', MODIFY COLUMN delivery_address VARCHAR(1000) NOT NULL COMMENT ''提货地址或中转仓收货地址'', MODIFY COLUMN delivery_method VARCHAR(64) NOT NULL COMMENT ''入仓方式'', MODIFY COLUMN estimated_arrival_at DATETIME(3) NOT NULL COMMENT ''预计入仓时间''', 'SELECT 1')); +PREPARE transfer_optional_fba_stmt FROM @ddl; EXECUTE transfer_optional_fba_stmt; DEALLOCATE PREPARE transfer_optional_fba_stmt; + +-- 兼容已存在的旧版转仓单表: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; +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_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN estimated_ship_at DATETIME(3) NULL COMMENT ''预计发货时间'' AFTER estimated_arrival_at')); +PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_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='label_deadline'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN label_deadline DATETIME(3) NULL COMMENT ''换标截止时间'', ADD COLUMN relabel_contact_name VARCHAR(128) NULL COMMENT ''换标联系人'', ADD COLUMN relabel_contact_phone VARCHAR(64) NULL COMMENT ''换标联系电话'', ADD COLUMN relabel_charge_amount DECIMAL(20,2) NULL COMMENT ''换标费用CNY'', ADD COLUMN relabel_delivery_method VARCHAR(32) NULL COMMENT ''换标后派送方式''')); +PREPARE transfer_relabel_config_stmt FROM @ddl; EXECUTE transfer_relabel_config_stmt; DEALLOCATE PREPARE transfer_relabel_config_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='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''关联贴标完成时间'' AFTER relabel_no')); +PREPARE transfer_relabel_completed_stmt FROM @ddl; EXECUTE transfer_relabel_completed_stmt; DEALLOCATE PREPARE transfer_relabel_completed_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='palletize_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN palletize_idempotency_key VARCHAR(255) NULL COMMENT ''PDA打托幂等键'', ADD COLUMN palletize_request_hash CHAR(64) NULL COMMENT ''PDA打托请求哈希'', ADD COLUMN confirmation_idempotency_key VARCHAR(255) NULL COMMENT ''客户测量确认幂等键'', ADD COLUMN confirmation_request_hash CHAR(64) NULL COMMENT ''客户测量确认请求哈希'', ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认测量时间''')); +PREPARE transfer_fulfillment_stmt FROM @ddl; EXECUTE transfer_fulfillment_stmt; DEALLOCATE PREPARE transfer_fulfillment_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='review_decision'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN review_decision VARCHAR(32) NULL COMMENT ''审核决定'' AFTER status, ADD COLUMN review_remark VARCHAR(1000) NULL COMMENT ''审核备注或驳回原因'' AFTER review_decision, ADD COLUMN reviewed_by VARCHAR(128) NULL COMMENT ''审核人ID'' AFTER review_remark, ADD COLUMN reviewed_at DATETIME(3) NULL COMMENT ''审核时间'' AFTER reviewed_by')); +PREPARE transfer_review_fact_stmt FROM @ddl; EXECUTE transfer_review_fact_stmt; DEALLOCATE PREPARE transfer_review_fact_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_carton ( + id BIGINT NOT NULL COMMENT '预报箱主键', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', + transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + carton_mark VARCHAR(255) NOT NULL COMMENT '系统生成的一箱一码箱唛', carton_index INT NOT NULL COMMENT '全单箱序号', + total_cartons INT NOT NULL COMMENT '首次生成时全单总箱数', status VARCHAR(32) NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收', + 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_carton_mark (customer_id,carton_mark), + UNIQUE KEY uk_ll_wh_transfer_carton_index (transfer_order_id,carton_index), + KEY idx_ll_wh_transfer_carton_list (customer_id,transfer_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_warehouse_transfer_carton' AND column_name='customer_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN customer_carton_mark VARCHAR(255) NULL COMMENT ''客户原箱唛,用于换标现场绑定'' AFTER carton_mark')); +PREPARE transfer_customer_carton_stmt FROM @ddl; EXECUTE transfer_customer_carton_stmt; DEALLOCATE PREPARE transfer_customer_carton_stmt; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet ( + id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', sequence_no INT NOT NULL COMMENT '托序号', + warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码', + warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', + warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数', + length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', + height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', + volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '打托状态', + completed_at DATETIME(3) 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_transfer_pallet_seq(transfer_order_id,sequence_no), + UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), + KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status) +) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', + transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID', transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_transfer_pallet_item(transfer_pallet_id,transfer_item_id), + KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型', + customer_carton_mark VARCHAR(255) NOT NULL COMMENT '客户原箱唛', original_sku VARCHAR(128) NOT NULL COMMENT '旧SKU', + new_sku VARCHAR(128) NULL COMMENT '新SKU', quantity INT NOT NULL COMMENT '换标件数', + fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark), + KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id) +) 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 '联系人电话', + delivery_method VARCHAR(32) NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填', + print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要打印面单', + shipping_label_file_id VARCHAR(128) NULL COMMENT '客户上传面单文件ID', + shipping_label_file_name VARCHAR(255) NULL COMMENT '客户上传面单文件名', + shipping_label_file_url VARCHAR(1000) NULL COMMENT '客户上传面单下载地址', + fulfillment_status VARCHAR(32) NOT NULL DEFAULT 'WAIT_RELABEL' COMMENT '换标后履约状态', + outbound_order_no VARCHAR(64) NULL COMMENT '自提或快递生成的换标出库单号', + customer_confirmation_key VARCHAR(255) NULL COMMENT '客户确认完成幂等键', + customer_confirmed_at DATETIME(3) 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 '已通知箱数', + charge_amount DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '换标费用CNY', + charge_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', + wallet_status VARCHAR(32) NOT NULL DEFAULT 'FREEZE_PENDING' COMMENT '资金状态', + wallet_error_code VARCHAR(64) NULL COMMENT '资金错误码', + wallet_error_message VARCHAR(500) NULL COMMENT '资金错误说明', + wallet_command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件ID', + 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='客户换标申请单'; + +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_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_id VARCHAR(128) NULL COMMENT ''总换标PDF文件ID'' AFTER 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_relabel_application' AND column_name='total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_name VARCHAR(255) NULL COMMENT ''总换标PDF文件名'' AFTER total_file_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_relabel_application' AND column_name='total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_url VARCHAR(1000) NULL COMMENT ''总换标PDF预览地址'' AFTER total_file_name')); 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_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填'' AFTER contact_phone')); 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_relabel_application' AND column_name='print_shipping_label'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要打印面单'' AFTER delivery_method')); 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_relabel_application' AND column_name='shipping_label_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT ''客户上传面单文件ID'' AFTER print_shipping_label')); 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_relabel_application' AND column_name='shipping_label_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT ''客户上传面单文件名'' AFTER shipping_label_file_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_relabel_application' AND column_name='shipping_label_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT ''客户上传面单下载地址'' AFTER shipping_label_file_name')); 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_relabel_application' AND column_name='fulfillment_status'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT ''WAIT_RELABEL'' COMMENT ''换标后履约状态'' AFTER shipping_label_file_url')); 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_relabel_application' AND column_name='outbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT ''自提或快递生成的换标出库单号'' AFTER fulfillment_status')); 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_relabel_application' AND column_name='customer_confirmation_key'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT ''客户确认完成幂等键'' AFTER fulfillment_status')); 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_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS ll_relabel_item ( + return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID', + source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照', + new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛', + 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) 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) NULL COMMENT '货件编号(无FBA去向时为空)', + tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码', + new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码', + label_requirement VARCHAR(1000) NULL COMMENT '换标要求', + remark 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 '换标文件地址', + carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛', + fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛', + destination_warehouse_id VARCHAR(128) NULL COMMENT '该箱目的海外仓ID', + destination_fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码(与海外操作仓分离)', + operation_type VARCHAR(32) NOT NULL DEFAULT 'CARTON_LABEL' COMMENT '换标模式:箱唛、SKU或组合换标', + sku_relabel_items_json JSON NULL COMMENT '同箱SKU换标子明细快照(数量按件)', + source_inventory_balance_id BIGINT NULL COMMENT '原库存余额ID(预入仓换标任务可为空)', + source_inventory_position_id BIGINT NULL COMMENT '原库存库位ID(预入仓换标任务可为空)', + source_inbound_order_id BIGINT NULL COMMENT '原入仓单ID(预入仓换标任务可为空)', + source_inbound_order_no VARCHAR(128) NULL COMMENT '原入仓单号快照', + source_inbound_code VARCHAR(128) NULL COMMENT '原进仓编码快照', + source_warehouse_id VARCHAR(128) NOT NULL COMMENT '原海外中转仓ID', + source_warehouse_code VARCHAR(128) NULL COMMENT '原海外中转仓编码快照', + source_warehouse_name VARCHAR(255) 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), + KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id) +) ENGINE=InnoDB COMMENT='换标货物冻结明细'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码(与海外操作仓分离)''')); 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_relabel_item' AND column_name='source_inventory_balance_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额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_relabel_item' AND column_name='source_inventory_position_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位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_relabel_item' AND column_name='source_inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单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_relabel_item' AND column_name IN ('shipment_no','destination_fba_warehouse_code','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id') AND is_nullable='NO'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT ''货件编号(无FBA去向时为空)'', MODIFY COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码(与海外操作仓分离)'', MODIFY COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID(预入仓换标任务可为空)''', 'SELECT 1')); +PREPARE relabel_pre_inbound_stmt FROM @ddl; EXECUTE relabel_pre_inbound_stmt; DEALLOCATE PREPARE relabel_pre_inbound_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); 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_relabel_item' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); 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_relabel_item' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); 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_relabel_item' AND column_name='destination_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''该箱目的海外仓ID'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE 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_destination'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +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 '来源托盘号快照', + manifest_version INT NOT NULL COMMENT '整托清单版本', manifest_package_count INT NOT NULL COMMENT '整托总箱数', + manifest_fragment_count INT NOT NULL COMMENT '整托客户分片总数', manifest_hash CHAR(64) 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), + KEY idx_ll_dropship_profile_manifest(source_pallet_no,manifest_hash) +) 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 '箱唛', + product_quantity BIGINT 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_carton_sku ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', order_id BIGINT NOT NULL COMMENT '尾程订单ID', + profile_id BIGINT NOT NULL COMMENT '一件代发托盘档案ID', carton_id BIGINT NOT NULL COMMENT '一件代发物理箱ID', + sku_code VARCHAR(128) NOT NULL COMMENT '箱内SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称快照', + product_quantity BIGINT 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_carton_sku(carton_id,sku_code), + KEY idx_ll_dropship_carton_sku_order(customer_id,order_id,sku_code), + KEY idx_ll_dropship_carton_sku_profile(customer_id,profile_id,sku_code) +) ENGINE=InnoDB COMMENT='一件代发物理箱内SKU明细'; + +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 '申请废弃箱数', + actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED', + pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间', + 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 '采购入仓仓库名称', outbound_details JSON 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) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', + 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) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', 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_exception_order ( + id BIGINT NOT NULL COMMENT '异常订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', exception_no VARCHAR(64) NOT NULL COMMENT '异常订单号', + exception_type VARCHAR(32) NOT NULL COMMENT '异常类型', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '异常处理状态', reason VARCHAR(1000) NOT NULL COMMENT '异常原因', + last_mile_order_id BIGINT NULL COMMENT '关联尾程订单ID', last_mile_order_no VARCHAR(64) NULL COMMENT '关联尾程订单号', inbound_order_id BIGINT NULL COMMENT '关联入仓单ID', inbound_code VARCHAR(128) NULL COMMENT '关联入仓码', + delivery_order_id BIGINT NULL COMMENT '关联派送单ID', delivery_no VARCHAR(64) NULL COMMENT '关联派送单号', outbound_no VARCHAR(64) NULL COMMENT '关联出库单号', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID快照', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + sku_code VARCHAR(128) NULL COMMENT '异常货物SKU', package_count INT NULL COMMENT '异常箱数', return_direction VARCHAR(32) NULL COMMENT '退回方向', return_logistics_no VARCHAR(128) NULL COMMENT '退回物流单号', + handling_action VARCHAR(500) NULL COMMENT '异常处理措施', handling_result VARCHAR(2000) NULL COMMENT '异常处理结果', + evidence_file_ids_json JSON NULL COMMENT '异常处理证据文件ID列表', inventory_adjustment_id BIGINT NULL COMMENT '退回库存恢复操作ID', + handler_id VARCHAR(128) NULL COMMENT '处理人ID', handler_name VARCHAR(128) NULL COMMENT '处理人名称', + source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '异常来源类型', created_by VARCHAR(128) NULL COMMENT '创建人ID', + exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', completed_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_exception_no(customer_id,exception_no), + UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type), + KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no), + KEY idx_ll_exception_inbound(customer_id,inbound_code) +) ENGINE=InnoDB COMMENT='海外仓异常订单'; + +CREATE TABLE IF NOT EXISTS ll_exception_return_item ( + id BIGINT NOT NULL COMMENT '异常退回明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细ID', + inventory_position_id BIGINT NOT NULL COMMENT '原出库库存位置ID', pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照', + sku_code VARCHAR(128) NULL COMMENT '原出库SKU快照', max_return_packages BIGINT NOT NULL COMMENT '原出库可退箱数上限', + approved_return_packages BIGINT NOT NULL COMMENT '批准退回箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_return_source(customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id), + KEY idx_ll_exception_return_order(customer_id,exception_order_id) +) ENGINE=InnoDB COMMENT='异常退回原出库库存冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_exception_order_command ( + id BIGINT NOT NULL COMMENT '异常处理命令ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', action VARCHAR(32) NOT NULL COMMENT '异常处理动作', + idempotency_key VARCHAR(255) NOT NULL COMMENT '幂等键', request_hash VARCHAR(64) NOT NULL COMMENT '请求内容SHA256哈希', + result_json JSON NOT NULL COMMENT '命令结果快照', created_at DATETIME(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_command_key(customer_id,action,idempotency_key), + KEY idx_ll_exception_command_order(customer_id,exception_order_id,created_at) +) ENGINE=InnoDB COMMENT='异常处理命令幂等审计'; + +CREATE TABLE IF NOT EXISTS ll_customer_visit ( + id BIGINT NOT NULL COMMENT '客户回访记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + delivery_order_id BIGINT NOT NULL COMMENT '关联派送单ID', + delivery_no VARCHAR(64) NOT NULL COMMENT '关联派送单号', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + contact_name VARCHAR(128) NULL COMMENT '客户联系人姓名快照', + contact_phone VARCHAR(64) NULL COMMENT '客户联系人电话快照', + destination VARCHAR(500) NULL COMMENT '派送目的仓编码汇总快照', + delivery_completed_at DATETIME(3) NOT NULL COMMENT '派送完成时间', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访', + selected_last_mile_order_id BIGINT NULL COMMENT '本次回访选择的尾程订单ID', + selected_last_mile_order_no VARCHAR(64) NULL COMMENT '本次回访选择的尾程订单号', + selected_inbound_order_id BIGINT NULL COMMENT '本次回访选择的入仓单ID', + selected_inbound_code VARCHAR(128) NULL COMMENT '本次回访选择的入仓码', + visit_result VARCHAR(32) NULL COMMENT '最近一次回访结果', + customer_feedback VARCHAR(2000) NULL COMMENT '最近一次客户反馈', + remark VARCHAR(1000) NULL COMMENT '最近一次回访备注', + attachment_json JSON NULL COMMENT '当前回访资料文件元数据列表', + visited_by VARCHAR(128) NULL COMMENT '最近一次回访操作人ID', + visited_at DATETIME(3) NULL COMMENT '最近一次回访时间', + visit_count 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_customer_visit_delivery(customer_id,delivery_order_id), + KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at), + KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no) +) 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 '当前单调权限版本', + roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照', + authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照', + 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安全状态与权限版本投影'; + +-- 收敛尚未具备授权快照字段的开发/测试库,CREATE TABLE IF NOT EXISTS 不会补充已存在表的字段。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_security_projection' AND column_name='roles_json'), 'SELECT 1', + 'ALTER TABLE ll_security_projection ADD COLUMN roles_json JSON NULL COMMENT ''IAM投影的当前角色快照'' AFTER permission_version')); +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_security_projection' AND column_name='authorities_json'), 'SELECT 1', + 'ALTER TABLE ll_security_projection ADD COLUMN authorities_json JSON NULL COMMENT ''IAM投影的当前海外权限快照'' AFTER roles_json')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL; +UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL; +ALTER TABLE ll_security_projection + MODIFY COLUMN roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照', + MODIFY COLUMN authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照'; + +-- 空库基线固定管理员必须携带当前海外授权快照;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 +SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN'); +SET @platform_admin_overseas_authorities = JSON_ARRAY( + 'platform:overseas-warehouse:warehouse-transfer:approve', + 'platform:finance:bill:adjust', + 'platform:finance:bill:update', + 'platform:finance:bill:void', + 'platform:finance:cost:create', + 'platform:finance:cost:view', + 'platform:finance:customer-account:adjust', + 'platform:finance:customer-account:export', + 'platform:finance:customer-account:list', + 'platform:finance:customer-arap:export', + 'platform:finance:customer-arap:list', + 'platform:finance:customer-statement:export', + 'platform:finance:customer-statement:list', + 'platform:finance:customer-terms:update', + 'platform:finance:customer-terms:view', + 'platform:finance:fee-source:edit', + 'platform:finance:fee-source:review', + 'platform:finance:payment:create', + 'platform:finance:receipt:create', + 'platform:finance:receipt:view', + 'platform:finance:receivable-statistics:export', + 'platform:finance:receivable-statistics:list', + 'platform:finance:recharge:review', + 'platform:finance:reconciliation:adjust', + 'platform:finance:reconciliation:create', + 'platform:finance:reconciliation:issue', + 'platform:finance:reconciliation:review', + 'platform:finance:reconciliation:void', + 'platform:finance:settlement-ap:create', + 'platform:finance:settlement-ap:export', + 'platform:finance:settlement-ap:list', + 'platform:finance:settlement-ar:create', + 'platform:finance:settlement-ar:export', + 'platform:finance:settlement-ar:list', + 'platform:finance:settlement:void', + 'platform:overseas-business:looseboard:create', + 'platform:overseas-business:looseboard:edit', + 'platform:overseas-business:looseboard:list', + 'platform:overseas-business:looseboard:status', + 'platform:overseas-business:purchase:edit', + 'platform:overseas-business:purchase:list', + 'platform:overseas-business:purchase:review', + 'platform:overseas-business:truck:create', + 'platform:overseas-business:truck:edit', + 'platform:overseas-business:truck:list', + 'platform:overseas-business:truck:status', + 'platform:overseas-master-data:exchange-rate:create', + 'platform:overseas-master-data:exchange-rate:edit', + 'platform:overseas-master-data:exchange-rate:export', + 'platform:overseas-master-data:exchange-rate:list', + 'platform:overseas-master-data:exchange-rate:status', + 'platform:overseas-master-data:quote:create', + 'platform:overseas-master-data:quote:edit', + 'platform:overseas-master-data:quote:export', + 'platform:overseas-master-data:quote:list', + 'platform:overseas-master-data:quote:status', + 'platform:overseas-master-data:vehicle:create', + 'platform:overseas-master-data:vehicle:edit', + 'platform:overseas-master-data:vehicle:export', + 'platform:overseas-master-data:vehicle:list', + 'platform:overseas-master-data:vehicle:status', + 'platform:overseas-warehouse:customer-visit:edit', + 'platform:overseas-warehouse:customer-visit:list', + 'platform:overseas-warehouse:delivery:documents', + 'platform:overseas-warehouse:delivery:list', + 'platform:overseas-warehouse:delivery:return', + 'platform:overseas-warehouse:delivery:status', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:list', + 'platform:overseas-warehouse:exception-order:process', + 'platform:overseas-warehouse:inbound-order:create', + 'platform:overseas-warehouse:inbound-order:edit', + 'platform:overseas-warehouse:inbound-order:fba-appointment', + 'platform:overseas-warehouse:inbound-order:list', + 'platform:overseas-warehouse:inbound-order:smart-dispatch', + 'platform:overseas-warehouse:inventory:adjust', + 'platform:overseas-warehouse:inventory:edit', + 'platform:overseas-warehouse:inventory:export', + 'platform:overseas-warehouse:inventory:list', + 'platform:overseas-warehouse:last-mile-order:edit', + 'platform:overseas-warehouse:last-mile-order:export', + 'platform:overseas-warehouse:last-mile-order:list', + 'platform:overseas-warehouse:location:create', + 'platform:overseas-warehouse:location:delete', + 'platform:overseas-warehouse:location:edit', + 'platform:overseas-warehouse:location:list', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:pallet:create', + 'platform:overseas-warehouse:pallet:delete', + 'platform:overseas-warehouse:pallet:edit', + 'platform:overseas-warehouse:pallet:list', + 'platform:overseas-warehouse:relabel-order:create', + 'platform:overseas-warehouse:relabel-order:list', + 'platform:overseas-warehouse:relabel-order:status', + 'platform:overseas-warehouse:return-cartons:list', + 'platform:overseas-warehouse:return-inventory:list', + 'platform:overseas-warehouse:warehouse:create', + 'platform:overseas-warehouse:warehouse:edit', + 'platform:overseas-warehouse:warehouse:list', + 'platform:overseas-warehouse:warehouse:status' +); +INSERT IGNORE INTO ll_security_projection ( + aggregate_type,aggregate_id,tenant_id,security_status,permission_version,roles_json,authorities_json,aggregate_version, + payload_hash,last_event_id,changed_at,updated_at +) VALUES + ('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),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,@platform_admin_overseas_roles,@platform_admin_overseas_authorities,0, + SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0:current-authorities',256), + 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); + +UPDATE ll_security_projection +SET roles_json=@platform_admin_overseas_roles, + authorities_json=@platform_admin_overseas_authorities, + payload_hash=SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0:current-authorities',256), + updated_at=CURRENT_TIMESTAMP(3) +WHERE aggregate_type='AUTHORIZATION' AND aggregate_id='1000001' AND tenant_id=0 + AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0'; + + +CREATE TABLE IF NOT EXISTS ll_wallet_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '国内Finance资金结果事件ID', + command_event_id VARCHAR(64) NOT NULL COMMENT '原海外资金指令事件ID', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化载荷SHA-256,包含sha256:前缀', + payload_json JSON NOT NULL COMMENT '冻结事件信封', + status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_ll_wallet_command_result (command_event_id), + KEY idx_ll_wallet_event_status (status,received_at) +) ENGINE=InnoDB COMMENT='跨区客户资金结果幂等收件箱'; + + + +CREATE TABLE IF NOT EXISTS ll_procurement_daily_sequence ( + customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', + business_date DATE NOT NULL COMMENT '北京时间业务日期', + sequence_value INT NOT NULL COMMENT '客户当日已分配最大序号', + PRIMARY KEY (customer_id,business_date), + CONSTRAINT chk_ll_procurement_sequence CHECK (sequence_value BETWEEN 1 AND 9999) +) ENGINE=InnoDB COMMENT='海外采购客户日序列'; + + + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_file ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', + category VARCHAR(16) NOT NULL COMMENT 'RECEIPT签收文件或VOUCHER费用凭证', file_id BIGINT NOT NULL COMMENT 'File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '媒体类型', size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小', + created_by VARCHAR(128) NULL COMMENT '上传人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_file(order_id,file_id), KEY idx_ll_procurement_file_order(customer_id,order_id,category,created_at), + CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER')) +) ENGINE=InnoDB COMMENT='海外采购签收文件及费用凭证'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', + fee_no VARCHAR(64) NOT NULL COMMENT '费用业务号', fee_item_code VARCHAR(32) NOT NULL COMMENT '费用项编码', fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项名称', + amount DECIMAL(20,2) NOT NULL COMMENT 'CNY金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', voucher_file_id BIGINT NOT NULL COMMENT '凭证文件ID', + voucher_file_name VARCHAR(255) NOT NULL COMMENT '凭证文件名', remark VARCHAR(500) NULL COMMENT '说明', + wallet_status VARCHAR(32) NOT NULL COMMENT '资金状态', wallet_error_code VARCHAR(64) NULL COMMENT '资金错误码', wallet_error_message VARCHAR(500) NULL COMMENT '资金错误说明', + wallet_command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件ID', created_by VARCHAR(128) 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_procurement_fee_no(fee_no), KEY idx_ll_procurement_fee_order(customer_id,order_id,created_at), + CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY') +) ENGINE=InnoDB COMMENT='海外采购费用明细及资金生命周期'; + + + +CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost ( + id BIGINT NOT NULL COMMENT '主键ID', source_container_booking_id VARCHAR(128) NOT NULL COMMENT '来源柜次业务ID', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', warehouse_id BIGINT NULL COMMENT '海外仓ID', + pallet_count INT NOT NULL COMMENT '实际打托数', total_actual_volume_cbm DECIMAL(20,6) NOT NULL COMMENT '客户实收总体积', total_cost DECIMAL(20,2) NOT NULL COMMENT '打托总成本', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '成本币种', + status VARCHAR(16) NOT NULL COMMENT '生成状态', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_destuff_cost_booking(source_container_booking_id), CONSTRAINT chk_ll_destuff_cost CHECK(pallet_count>0 AND total_actual_volume_cbm>0 AND total_cost>0 AND currency='CNY') +) ENGINE=InnoDB COMMENT='拆柜打托实际成本汇总'; +CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation ( + id BIGINT NOT NULL COMMENT '主键ID', cost_id BIGINT NOT NULL COMMENT '打托成本汇总ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', actual_volume_cbm DECIMAL(20,6) NOT NULL COMMENT '客户实收体积', + allocated_amount DECIMAL(20,2) NOT NULL COMMENT '客户分摊金额', fee_source_id BIGINT NOT NULL COMMENT '海外费用来源ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id), + CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0) +) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊'; + +-- 客户退货预报、PDA 签收与仓储费起算批次 +CREATE TABLE IF NOT EXISTS ll_return_order ( + -- need_relabel is added below for existing deployments + id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照', warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', lastleg_order_id BIGINT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NULL COMMENT '尾程订单号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity) +) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总'; +SET @ll_return_order_need_relabel = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='need_relabel'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN need_relabel TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要换标'' AFTER customer_reference_no')); PREPARE stmt FROM @ll_return_order_need_relabel; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''尾程订单ID'' AFTER customer_reference_no')); 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_return_order' AND column_name='lastleg_order_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(32) NULL COMMENT ''尾程订单号'' AFTER lastleg_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_return_order' AND column_name='warehouse_contact_name'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_name VARCHAR(128) NULL COMMENT ''中转仓联系人快照'' AFTER overseas_warehouse_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_return_order' AND column_name='warehouse_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_phone VARCHAR(64) NULL COMMENT ''中转仓联系电话快照'' AFTER warehouse_contact_name')); 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_return_order' AND column_name='relabel_application_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_application_id BIGINT NULL COMMENT ''绑定换标申请单ID'' AFTER need_relabel')); 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_return_order' AND column_name='relabel_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_no VARCHAR(64) NULL COMMENT ''绑定换标单号'' AFTER relabel_application_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_return_order' AND column_name='relabel_status'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_status VARCHAR(32) NULL COMMENT ''换标状态'' AFTER relabel_no')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +CREATE TABLE IF NOT EXISTS ll_return_order_item ( + id BIGINT NOT NULL COMMENT '退货预报明细ID', return_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) NULL COMMENT '中文品名', expected_quantity BIGINT NOT NULL COMMENT 'SKU预报数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT 'SKU累计实收数量', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code), KEY idx_ll_return_item_sku(sku_code,received_quantity), CONSTRAINT chk_ll_return_item_quantity CHECK(expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity) +) ENGINE=InnoDB COMMENT='客户退货订单的SKU预报与实收明细'; +CREATE TABLE IF NOT EXISTS ll_return_receipt ( + id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', receipt_no VARCHAR(128) NOT NULL COMMENT '签收单号', receipt_batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', tracking_no VARCHAR(128) NULL COMMENT '本次签收包裹的可选快递单号', bound_by VARCHAR(16) NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', status VARCHAR(32) NOT NULL COMMENT '签收状态,当前SIGNED', received_quantity BIGINT NOT NULL COMMENT '本次实收数量', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', operator_name VARCHAR(128) NULL COMMENT 'PDA操作人名称', idempotency_key VARCHAR(255) NOT NULL COMMENT 'PDA确认幂等键', request_hash CHAR(64) NOT NULL COMMENT 'PDA确认请求SHA-256', signed_at DATETIME(3) NOT NULL COMMENT '签收时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_receipt_no(receipt_no), UNIQUE KEY uk_ll_return_receipt_batch(receipt_batch_no), UNIQUE KEY uk_ll_return_receipt_operator_key(operator_id,idempotency_key), KEY idx_ll_return_receipt_order(return_order_id,signed_at), CONSTRAINT chk_ll_return_receipt_quantity CHECK(received_quantity>0), CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN('RETURN_NO','SKU')) +) ENGINE=InnoDB COMMENT='PDA退货实收签收事实'; + +-- 已创建开发库升级为“客户只预报 SKU,快递单号仅属于签收批次”的最终模型。 +SET @ll_return_item_drop_tracking_order_sku = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX uk_ll_return_item_order_tracking_sku', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_tracking_sku'); +PREPARE ll_return_item_drop_tracking_order_sku_stmt FROM @ll_return_item_drop_tracking_order_sku; +EXECUTE ll_return_item_drop_tracking_order_sku_stmt; +DEALLOCATE PREPARE ll_return_item_drop_tracking_order_sku_stmt; +SET @ll_return_item_drop_tracking = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_tracking', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_tracking'); +PREPARE ll_return_item_drop_tracking_stmt FROM @ll_return_item_drop_tracking; +EXECUTE ll_return_item_drop_tracking_stmt; +DEALLOCATE PREPARE ll_return_item_drop_tracking_stmt; +SET @ll_return_item_drop_order = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_order', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_order'); +PREPARE ll_return_item_drop_order_stmt FROM @ll_return_item_drop_order; +EXECUTE ll_return_item_drop_order_stmt; +DEALLOCATE PREPARE ll_return_item_drop_order_stmt; +SET @ll_return_item_drop_tracking_no = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP COLUMN tracking_no', 'SELECT 1') FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND column_name='tracking_no'); +PREPARE ll_return_item_drop_tracking_no_stmt FROM @ll_return_item_drop_tracking_no; +EXECUTE ll_return_item_drop_tracking_no_stmt; +DEALLOCATE PREPARE ll_return_item_drop_tracking_no_stmt; +SET @ll_return_item_add_order_sku = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ll_return_order_item ADD UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code)', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_sku'); +PREPARE ll_return_item_add_order_sku_stmt FROM @ll_return_item_add_order_sku; +EXECUTE ll_return_item_add_order_sku_stmt; +DEALLOCATE PREPARE ll_return_item_add_order_sku_stmt; +SET @ll_return_item_add_sku_idx = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ll_return_order_item ADD KEY idx_ll_return_item_sku(sku_code,received_quantity)', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_sku'); +PREPARE ll_return_item_add_sku_idx_stmt FROM @ll_return_item_add_sku_idx; +EXECUTE ll_return_item_add_sku_idx_stmt; +DEALLOCATE PREPARE ll_return_item_add_sku_idx_stmt; +UPDATE ll_return_receipt SET bound_by='RETURN_NO' WHERE bound_by='TRACKING'; +SET @ll_return_receipt_drop_bound_check = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_receipt DROP CHECK chk_ll_return_receipt_bound', 'SELECT 1') FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_receipt' AND constraint_name='chk_ll_return_receipt_bound'); +PREPARE ll_return_receipt_drop_bound_check_stmt FROM @ll_return_receipt_drop_bound_check; +EXECUTE ll_return_receipt_drop_bound_check_stmt; +DEALLOCATE PREPARE ll_return_receipt_drop_bound_check_stmt; +SET @ll_return_receipt_alter = 'ALTER TABLE ll_return_receipt MODIFY COLUMN tracking_no VARCHAR(128) NULL COMMENT ''本次签收包裹的可选快递单号'', MODIFY COLUMN bound_by VARCHAR(16) NOT NULL COMMENT ''开始签收方式,RETURN_NO退货单号或SKU候选'', ADD CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN(''RETURN_NO'',''SKU''))'; +PREPARE ll_return_receipt_alter_stmt FROM @ll_return_receipt_alter; +EXECUTE ll_return_receipt_alter_stmt; +DEALLOCATE PREPARE ll_return_receipt_alter_stmt; +CREATE TABLE IF NOT EXISTS ll_return_receipt_item ( + id BIGINT NOT NULL COMMENT '签收SKU明细ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id BIGINT NOT NULL COMMENT '退货预报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', received_quantity BIGINT NOT NULL COMMENT '本次SKU实收数量', + PRIMARY KEY(id), KEY idx_ll_return_receipt_item_receipt(receipt_id), KEY idx_ll_return_receipt_item_order_item(return_order_item_id), CONSTRAINT chk_ll_return_receipt_item_quantity CHECK(received_quantity>0) +) ENGINE=InnoDB COMMENT='PDA退货签收SKU实际数量明细'; +CREATE TABLE IF NOT EXISTS ll_return_receipt_batch ( + id BIGINT NOT NULL COMMENT '退货仓储计费批次ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', storage_billing_status VARCHAR(32) NOT NULL COMMENT '仓储计费状态,初始PENDING_PRICING', storage_started_at DATETIME(3) NOT NULL COMMENT '仓储计费起算时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_batch_no(batch_no), UNIQUE KEY uk_ll_return_batch_receipt(receipt_id), KEY idx_ll_return_batch_billing(customer_id,overseas_warehouse_id,storage_billing_status,storage_started_at) +) ENGINE=InnoDB COMMENT='每次退货签收生成的仓储费起算批次'; + +-- 退货库存:签收即生成箱号,库存按客户、海外仓和SKU汇总;箱内明细保留给合箱与箱唛。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no')); +PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt; +CREATE TABLE IF NOT EXISTS ll_return_inventory_carton ( + new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛', + id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED')) +) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛'; +-- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 SQL 兼容 Spring 的数据库初始化器。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); +PREPARE return_inventory_carton_check_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_stmt; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1')); +PREPARE return_inventory_carton_status_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_status_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_status_drop_stmt; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no')); +PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt; +ALTER TABLE ll_return_inventory_carton MODIFY COLUMN source_batch_no TEXT NULL COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', MODIFY COLUMN source_return_no TEXT NULL COMMENT '实际消耗来源退货单号,多个单号逗号分隔'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''FBA箱唛'' AFTER carton_mark')); 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_return_inventory_carton' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码'' AFTER fba_carton_mark')); 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_return_inventory_carton' AND column_name='destination_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称'' AFTER destination_warehouse_code')); 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_return_inventory_carton' AND column_name='relabel_application_id'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_application_id BIGINT NULL COMMENT ''绑定换标申请单ID'' AFTER destination_warehouse_name')); 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_return_inventory_carton' AND column_name='relabel_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_no VARCHAR(64) NULL COMMENT ''绑定换标单号'' AFTER relabel_application_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_return_inventory_carton' AND column_name='relabel_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_status VARCHAR(32) NULL COMMENT ''换标状态'' AFTER relabel_no')); 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_return_inventory_carton' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''换标完成时间'' AFTER relabel_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item ( + id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_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) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0) +) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量'; +CREATE TABLE IF NOT EXISTS ll_return_inventory_balance ( + id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code), CONSTRAINT chk_ll_return_inventory_balance_quantity CHECK(total_quantity>=available_quantity AND available_quantity>=0 AND carton_quantity>=0) +) ENGINE=InnoDB COMMENT='退货库存按客户海外仓SKU汇总余额'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_balance' AND column_name='total_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_balance ADD COLUMN total_quantity BIGINT NULL COMMENT ''实物总库存,仅实际出库扣减'' AFTER product_name_zh')); +PREPARE return_inventory_balance_total_stmt FROM @ddl; EXECUTE return_inventory_balance_total_stmt; DEALLOCATE PREPARE return_inventory_balance_total_stmt; +-- 旧数据的可用量原先包含已合箱库存;升级时一次性拆分为总库存和未合箱可用库存。 +UPDATE ll_return_inventory_balance b LEFT JOIN (SELECT c.customer_id,c.overseas_warehouse_id,i.sku_code,SUM(i.quantity) merged_quantity FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED' GROUP BY c.customer_id,c.overseas_warehouse_id,i.sku_code) m ON m.customer_id=b.customer_id AND m.overseas_warehouse_id=b.overseas_warehouse_id AND m.sku_code=b.sku_code SET b.total_quantity=b.available_quantity,b.available_quantity=GREATEST(0,b.available_quantity-COALESCE(m.merged_quantity,0)) WHERE b.total_quantity IS NULL; +ALTER TABLE ll_return_inventory_balance MODIFY COLUMN total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减'; +CREATE TABLE IF NOT EXISTS ll_return_inventory_operation ( + id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id) +) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计'; + +CREATE TABLE IF NOT EXISTS ll_container_loading_evidence ( + id BIGINT NOT NULL COMMENT '海外整柜装柜照片投影ID', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '国内稳定来源柜预订ID', + evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1至PHOTO_STEP_4或SEAL', + source_file_id BIGINT NOT NULL COMMENT '国内File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '国内文件名快照', + created_at DATETIME(3) NOT NULL COMMENT '投影创建时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_container_loading_evidence_type(source_container_booking_id,evidence_type), + UNIQUE KEY uk_ll_container_loading_evidence_file(source_container_booking_id,source_file_id) +) ENGINE=InnoDB COMMENT='海外拆柜只读国内装柜照片引用'; diff --git a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml new file mode 100644 index 000000000..70ce3be2c --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml @@ -0,0 +1,180 @@ +openapi: 3.0.3 +info: + title: 派送单三类资料 + version: 1.0.0 + description: | + 所有入口只允许平台,customerId 必须为真实派送所属客户;不得按 BOL 文本查找或复用其他客户/派送单文件。 + GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。 + RECEIPT 签收文件只有在 PUT 保存进入权威资料快照后才自动完成当前派送单;仅上传未保存不会完成。allowedActions 不再返回人工 COMPLETE_DELIVERY。 + START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。 + 资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。 +security: [{ bearerAuth: [] }] +paths: + /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents: + parameters: + - { $ref: '#/components/parameters/DeliveryNo' } + - { $ref: '#/components/parameters/CustomerId' } + get: + summary: 查询已保存三类资料 + description: 权限 platform:overseas-warehouse:delivery:list;空资料仍返回全部三类MISSING。上传未保存不计入摘要。 + responses: + '200': { description: 当前快照, content: { application/json: { schema: { $ref: '#/components/schemas/DetailResponse' } } } } + put: + summary: 原子保存全部类别绑定与BOL号 + description: | + 权限 platform:overseas-warehouse:delivery:documents。documents为全量快照,遗漏类别清空;Admin须提交完整三类。 + 同一派送单锁下校验expectedVersion、全部文件客户/派送单/类别归属后一起提交,任一失败不写任何绑定。 + 保存增加派送version,与状态修改共享版本;幂等键按客户+派送单限定,同键同请求重放原结果、异请求冲突。 + 替换/删除通过修改fileIds并保存表达,只解除当前绑定,历史对象和版本快照不可变保留。 + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/Save' } } } + responses: + '200': { description: 保存结果或统一业务错误, content: { application/json: { schema: { $ref: '#/components/schemas/DetailResponse' } } } } + /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/{category}/files: + parameters: + - { $ref: '#/components/parameters/DeliveryNo' } + - { $ref: '#/components/parameters/CustomerId' } + - { $ref: '#/components/parameters/Category' } + post: + summary: 上传一个待绑定文件 + description: | + 权限 platform:overseas-warehouse:delivery:documents。FormData唯一file,无额外幂等头。 + File沿用ARBITRARY任意格式、非空且不超过52428800字节(50MiB),每类别最多20个文件由保存校验。 + 上传仅产生不可变对象,不修改派送version或摘要;取消弹框/未保存不落绑定,不物理删除File对象。 + 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: + allOf: + - { $ref: '#/components/schemas/Envelope' } + - type: object + properties: { data: { $ref: '#/components/schemas/File' } } + /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/history: + parameters: + - { $ref: '#/components/parameters/DeliveryNo' } + - { $ref: '#/components/parameters/CustomerId' } + get: + summary: 查看不可变资料保存历史 + description: 权限 platform:overseas-warehouse:delivery:list;版本倒序,仅当前客户和派送单。 + responses: + '200': + description: 历史快照 + content: + application/json: + schema: + allOf: + - { $ref: '#/components/schemas/Envelope' } + - type: object + properties: + data: + type: array + items: + type: object + properties: + version: { type: integer, format: int64 } + operatorId: { type: string } + savedAt: { type: string, format: date-time } + snapshot: { $ref: '#/components/schemas/Detail' } + /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/{category}/files/{fileId}/download: + parameters: + - { $ref: '#/components/parameters/DeliveryNo' } + - { $ref: '#/components/parameters/CustomerId' } + - { $ref: '#/components/parameters/Category' } + - { in: path, name: fileId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { in: query, name: revisionVersion, required: false, schema: { type: integer, format: int64, minimum: 1 } } + get: + summary: 下载当前或指定保存版本已绑定文件 + description: 权限 platform:overseas-warehouse:delivery:list;省略revisionVersion只允许当前绑定。历史版本仍严格校验客户/单据/类别。未保存或跨单文件拒绝;附件响应no-store/nosniff。 + responses: + '200': { description: 文件内容, content: { application/octet-stream: { schema: { type: string, format: binary } } } } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + parameters: + DeliveryNo: { in: path, name: deliveryNo, required: true, schema: { type: string } } + CustomerId: { in: query, name: customerId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + Category: { in: path, name: category, required: true, schema: { $ref: '#/components/schemas/CategoryCode' } } + schemas: + CategoryCode: + type: string + enum: [LOADING_PHOTO, BOL, RECEIPT] + description: 依次为装车照片、BOL文件(含签名)、签收文件(POD签收/快递签收)。BOL签名由用户提供,不进行OCR或电子签署验证。 + Save: + type: object + required: [expectedVersion, idempotencyKey, documents] + properties: + expectedVersion: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, minLength: 1, maxLength: 128 } + bolNo: { type: string, nullable: true, maxLength: 128, description: 不作为文件复用或归属匹配依据;空值清空 } + documents: + type: array + maxItems: 3 + items: + type: object + required: [category, fileIds] + properties: + category: { $ref: '#/components/schemas/CategoryCode' } + fileIds: { type: array, maxItems: 20, uniqueItems: true, items: { type: string, pattern: '^[1-9][0-9]*$' } } + File: + type: object + required: [fileId, fileName, sizeBytes, uploadedAt] + properties: + fileId: { type: string, pattern: '^[1-9][0-9]*$' } + fileName: { type: string } + contentType: { type: string, nullable: true } + sizeBytes: { type: integer, format: int64, minimum: 1, maximum: 52428800 } + uploadedAt: { type: string, format: date-time, description: File持久化的真实创建时间 } + CategorySummary: + type: object + required: [category, status, fileCount] + properties: + category: { $ref: '#/components/schemas/CategoryCode' } + status: { type: string, enum: [UPLOADED, MISSING] } + fileCount: { type: integer, minimum: 0, maximum: 20 } + DocumentSummary: + type: object + required: [uploadedCategoryCount, totalCategoryCount, categories] + properties: + uploadedCategoryCount: { type: integer, minimum: 0, maximum: 3 } + totalCategoryCount: { type: integer, enum: [3] } + categories: { type: array, minItems: 3, maxItems: 3, items: { $ref: '#/components/schemas/CategorySummary' } } + Detail: + type: object + required: [deliveryNo, customerId, version, uploadedCategoryCount, totalCategoryCount, categories, allowedActions, replayed] + properties: + deliveryNo: { type: string } + customerId: { type: string } + version: { type: integer, format: int64 } + bolNo: { type: string, nullable: true } + uploadedCategoryCount: { type: integer, minimum: 0, maximum: 3 } + totalCategoryCount: { type: integer, enum: [3] } + categories: + type: array + minItems: 3 + maxItems: 3 + items: + allOf: + - { $ref: '#/components/schemas/CategorySummary' } + - type: object + required: [files] + properties: { files: { type: array, maxItems: 20, items: { $ref: '#/components/schemas/File' } } } + allowedActions: { type: array, items: { type: string, enum: [MANAGE_DOCUMENTS] }, description: 返回时按当前认证授权裁剪;只读用户为空,历史和幂等重放也不能沿用旧权限 } + replayed: { type: boolean } + Envelope: + type: object + properties: + code: { type: string } + message: { type: string } + DetailResponse: + allOf: + - { $ref: '#/components/schemas/Envelope' } + - type: object + properties: { data: { $ref: '#/components/schemas/Detail' } } diff --git a/lastleg-service/src/main/resources/openapi/lastleg-internal-dropship-v1.yaml b/lastleg-service/src/main/resources/openapi/lastleg-internal-dropship-v1.yaml new file mode 100644 index 000000000..348ca8973 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/lastleg-internal-dropship-v1.yaml @@ -0,0 +1,83 @@ +openapi: 3.0.3 +info: + title: Lastleg Internal Dropship API + version: 1.0.0 + description: 国内头程事件落地后向海外 Lastleg 冻结一件代发托盘、SKU 与箱唛快照的内部同步契约。 +paths: + /internal/v1/lastleg/dropship/receive: + post: + summary: 接收一件代发托盘与 SKU 快照 + description: 仅服务间调用;必须携带内部服务令牌。相同 customerId、orderId 与 idempotencyKey 重放返回既有结果。 + security: + - internalServiceToken: [] + parameters: + - name: X-Internal-Service-Token + in: header + required: true + schema: { type: string, minLength: 1 } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/DropshipReceiveRequest' } + responses: + '200': + description: 统一 ApiResponse,data 为接收的箱唛数量 + content: + application/json: + schema: { $ref: '#/components/schemas/IntegerApiResponse' } + '401': { description: 内部服务令牌缺失或无效 } +components: + securitySchemes: + internalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token + schemas: + DropshipReceiveRequest: + type: object + required: [customerId, orderId, warehouseId, idempotencyKey, pallets] + properties: + customerId: { type: string, minLength: 1, description: 客户数据隔离键 } + orderId: { type: integer, format: int64, description: Lastleg 尾程订单 ID } + warehouseId: { type: integer, format: int64, description: 海外仓 ID 快照 } + idempotencyKey: { type: string, minLength: 1, description: 跨区域调用幂等键 } + pallets: + type: array + minItems: 1 + items: { $ref: '#/components/schemas/DropshipPallet' } + DropshipPallet: + type: object + required: [palletNo, skuPackages] + properties: + palletNo: { type: string, minLength: 1, description: 来源头程托盘号快照 } + skuPackages: + type: array + minItems: 1 + items: { $ref: '#/components/schemas/DropshipSkuPackages' } + DropshipSkuPackages: + type: object + required: [skuCode, packageCount, productQuantity, cartonMarks, cartonQuantities] + properties: + skuCode: { type: string, minLength: 1 } + productName: { type: string, nullable: true } + packageCount: { type: integer, format: int64, minimum: 1 } + productQuantity: { type: integer, format: int64, minimum: 1 } + cartonQuantities: + type: object + description: 箱唛到逐箱实收商品件数的完整映射,键必须与 cartonMarks 一致,值合计必须等于 productQuantity + additionalProperties: { type: integer, format: int64, minimum: 1 } + cartonMarks: + type: array + minItems: 1 + items: { type: string, minLength: 1 } + IntegerApiResponse: + type: object + required: [code, message, data, timestamp] + properties: + code: { type: string, example: '200' } + message: { type: string } + data: { type: integer, format: int32, minimum: 0 } + requestId: { type: string, nullable: true } + traceId: { type: string, nullable: true } + timestamp: { type: string, format: date-time } 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..16fea794d --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -0,0 +1,3281 @@ +openapi: 3.0.3 +info: + title: Lastleg Overseas Business API + description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。 + version: 1.0.0 +paths: + /api/platform/v1/last-leg/inventory/balances/{balanceId}: + get: + summary: 查询单条库存余额详情 + description: 仅平台且要求 lastleg:inventory:list;按 balanceId 直接查询非零库存余额,不依赖列表分页。可选 customerId 用于精确客户隔离,越权或不存在统一返回404。 + security: [{ bearerAuth: [] }] + parameters: + - { in: path, name: balanceId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$', description: 长整型ID按字符串传输 } } + - { in: query, name: customerId, required: false, schema: { type: string } } + responses: + '200': { description: 单条库存余额, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryBalanceApiResponse' } } } } + '403': { description: 非平台身份或缺少库存查看权限 } + '404': { description: 库存余额不存在、已归零或不属于指定客户 } + /api/platform/v1/last-leg/inventory/inbound-code-relationships/{inboundCode}: + get: + summary: 按入仓码查询海外关系摘要 + description: 仅平台且要求 lastleg:inventory:list;不接收customerId,不跨国内数据库查询,未命中返回found=false和空关系。 + security: [{ bearerAuth: [] }] + parameters: + - { in: path, name: inboundCode, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + responses: + '200': { description: 海外订单、计划、收货、托盘和库存业务关系, content: { application/json: { schema: { $ref: '#/components/schemas/InboundCodeRelationshipApiResponse' } } } } + '403': { description: 非平台身份或缺少库存查看权限 } + '409': { description: 入仓码跨客户或快照关系不一致 } + /api/pda/v1/last-leg/receiving-containers/candidates: + get: + summary: 查询当前仓待签收柜号 + description: 权限 lastleg:pda:operate;仅 PLATFORM OVERSEAS_WAREHOUSE_PDA_OPERATOR 可用;按来源柜跨客户聚合,仅返回已单调消费 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/domestic-loading-photos/{evidenceType}/content: + get: + summary: 查看当前柜国内装柜照片 + description: 权限 lastleg:pda:operate;只按当前操作员已锁定的物理柜读取国内装柜完成快照,不返回客户或内部文件ID,加载失败不改变拆柜状态。 + security: [{ bearerAuth: [] }] + parameters: + - { name: evidenceType, in: path, required: true, schema: { type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL] } } + responses: + '200': + description: 国内装柜照片字节流;禁止缓存。 + content: + image/jpeg: { schema: { type: string, format: binary } } + image/png: { schema: { type: string, format: binary } } + application/octet-stream: { schema: { type: string, format: binary } } + '404': { description: 当前柜或指定类型照片不存在 } + '503': { 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;一件代发扫描 DP 或任一箱码解析整托并校验跨客户完整清单;普通 FBA 扫任一箱码后按当前柜+FBA目的仓聚合所有入仓码;海外仓中转仍逐箱解析。响应不返回客户信息。 + 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;普通 FBA 只解析当前柜该 FBA 目的仓已经生成的“柜号-FBA仓-序号”托盘,返回版本与该托现场累计箱数。 + 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;只允许普通 FBA。packageCount 是该托现场累计箱数,服务端按当前柜+FBA目的仓稳定分摊到全部关联入仓码;超出计划部分只记差异,不生成可用库存。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } } + responses: { '200': { description: 本次与累计实收进度,超收返回 DIFFERENCE_RECORDED, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 非普通FBA、累计数回退、版本变化或托盘不匹配 } } + /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/receipts/pallet: + post: + summary: 一件代发按完整来源托盘收货 + description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描 DP 或该托任一箱码,一次接收跨客户完整 DP;所有客户事件分片清单一致且齐全后才允许收货。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWholePallet' } } } } + responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '404': { description: 来源托盘不属于当前柜 }, '409': { description: 非一件代发、来源托盘重复或已操作 } } + /api/pda/v1/last-leg/receiving-containers/current/complete: + post: + summary: 完成当前柜收货并入库存 + description: 权限 lastleg:pda:operate;无差异时正常入库;allowDifference=true 时允许短少或超收现场结束,差异计划进入 PENDING_RECONCILIATION 且不生成客户可用库存。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingComplete' } } } } + responses: { '200': { description: COMPLETED 或 COMPLETED_WITH_DIFFERENCE 柜号会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 未确认差异或状态冲突 } } + /api/pda/v1/last-leg/inventory/resolve: + post: + summary: PDA按SKU、箱唛或入仓码扫码查询库存 + description: 仅 OVERSEAS_WAREHOUSE_PDA_OPERATOR + 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:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁。 + 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, CARGO_DISCARD] } } + - { 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, description: 按尾程订单冻结的海外接柜仓名称或编码进行大小写无关的包含匹配, 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;inboundCode 仅返回真实来源入仓码,无入仓关系的换标申请和本土散板为空 } } + /api/platform/v1/last-leg/orders/{id}: + get: + summary: 查询尾程订单详情 + description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + responses: + '200': { description: 统一 ApiResponse 包装的尾程订单详情 } + '404': { description: 尾程订单不存在或不属于查询客户 } + /api/platform/v1/last-leg/orders/{id}/fees: + post: + summary: 新增尾程订单费用 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;在订单客户范围内创建统一费用来源并提交财务审核。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/LastMileFeeCreate' } } } + responses: + '200': { description: 新增后的费用明细, content: { application/json: { schema: { $ref: '#/components/schemas/LastMileFeeApiResponse' } } } } + '404': { description: 尾程订单不存在或不属于操作客户 } + /api/platform/v1/last-leg/orders/{id}/hold: + put: + summary: 设置或解除尾程订单暂扣 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用订单版本和 Idempotency-Key 原子写入计费标记、原因及不可变历史。已进入出库流程的订单不得新增暂扣;暂扣只作用于当前票,不影响同柜其他订单。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/LastlegOrderHoldRequest' } + responses: + '200': { description: 暂扣状态已更新, content: { application/json: { schema: { type: object, properties: { code: { type: string }, data: { $ref: '#/components/schemas/LastlegOrderHoldResponse' } } } } } } + '409': { description: 版本冲突、幂等键冲突、重复状态或订单已进入出库流程 } + /api/platform/v1/last-leg/orders/{id}/manual-nodes: + get: + summary: 查询尾程订单人工展示节点 + description: 权限 platform:overseas-warehouse:last-mile-order:list;人工覆盖开启后返回完整且固定顺序的8行快照,行值允许为空。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + responses: { '200': { description: 完整人工展示节点快照 } } + put: + summary: 永久启用并保存尾程订单人工展示节点 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;提交完整8行快照且至少一个时间非空,使用订单版本和 Idempotency-Key;保存后 nodeSource 永久为 MANUAL,不回写柜节点,也不提供清除或恢复继承接口。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/LastlegOrderManualNodesRequest' } } } + responses: + '200': { description: 更新后的完整8行人工展示节点快照 } + '409': { description: 订单版本或幂等键冲突 } + /api/platform/v1/last-leg/orders/{id}/pallet-merge-required: + put: + summary: 设置尾程订单合托标记 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;标记后 PDA 扫描本单箱唛时返回合托提示;实际完成合托后不可取消。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [palletMergeRequired] + properties: { palletMergeRequired: { type: boolean } } + responses: { '200': { description: 合托标记已更新 }, '409': { description: 已完成实际合托,不能修改标记 } } + /api/platform/v1/last-leg/orders/pallet-merge-required/batch: + put: + summary: 批量设置同组合托标记 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;所选订单按同一合托批次原子标记或取消。 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [orderIds, palletMergeRequired] + properties: + orderIds: { type: array, minItems: 1, uniqueItems: true, items: { type: integer, format: int64 } } + palletMergeRequired: { type: boolean } + responses: { '200': { description: 批量合托标记已更新 } } + /api/platform/v1/last-leg/orders/export: + get: + summary: 导出筛选后的尾程订单 + description: 权限 platform:overseas-warehouse:last-mile-order:export;筛选参数与列表一致,返回 UTF-8 CSV。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: CSV 文件, content: { text/csv: { schema: { type: string, format: binary } } } } } + /api/platform/v1/last-leg/orders/container-summaries: + get: + summary: 分页查询柜级入库计划进度 + description: 权限 platform:overseas-warehouse:last-mile-order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,返回全部稳定来源柜;全部入仓码均已生成有效独立计划的柜仍保留,供柜级接柜仓查询与修改,生成计划动作由 allPlansReady/pendingPlanCount 控制。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 取 ll_order.estimated_warehouse_at,可由装柜完成事件初始化,并在入库计划生成前由平台按整柜人工修正;按该时间升序、null 最后、稳定柜 ID 升序。 + 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: containerNo, in: query, schema: { type: string } } + - { name: createdFrom, in: query, schema: { type: string, format: date-time } } + - { name: createdTo, in: query, schema: { type: string, format: date-time } } + responses: + '200': + description: 统一 ApiResponse 包装的柜级摘要分页 + content: + application/json: + schema: + type: object + properties: + code: { type: string, enum: ['200'] } + data: + type: object + properties: + page: { type: integer, format: int64 } + size: { type: integer, format: int64 } + total: { type: integer, format: int64 } + pages: { type: integer, format: int64 } + records: + type: array + items: { $ref: '#/components/schemas/ContainerPlanProgressSummary' } + /api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}: + get: + summary: 查询柜级入库计划进度详情 + description: 权限 platform:overseas-warehouse:last-mile-order:list;按稳定柜 ID 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页截断。 + security: [{ bearerAuth: [] }] + parameters: + - { name: sourceContainerBookingId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + responses: + '200': + description: 统一 ApiResponse 包装的柜级进度详情 + content: + application/json: + schema: + type: object + properties: + code: { type: string, enum: ['200'] } + data: { $ref: '#/components/schemas/ContainerPlanProgressDetail' } + '404': { description: 稳定柜 ID 不存在 } + /api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse: + put: + summary: 修改整柜海外接柜仓 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;目标必须为国内 Warehouse Service 有效 OVERSEAS 仓。使用柜级版本和 Idempotency-Key,事务内原子同步柜内全部尾程订单及已有入库计划;海外PDA一旦建立接柜会话即永久锁定。 + security: [{ bearerAuth: [] }] + parameters: + - { name: sourceContainerBookingId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 255 } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ChangeReceivingWarehouseRequest' } + responses: + '200': + description: 更新后的柜级摘要 + content: + application/json: + schema: + type: object + properties: + code: { type: string, enum: ['200'] } + data: { $ref: '#/components/schemas/ContainerPlanProgressSummary' } + '400': { description: 请求无效或目标不是有效 OVERSEAS 仓 } + '409': { description: 版本冲突、幂等键冲突或PDA接柜会话已建立 } + /api/platform/v1/last-leg/orders/container-arrival-times/query: + post: + summary: 批量查询整柜预计与实际到仓时间 + description: 权限 platform:overseas-warehouse:last-mile-order:list;预计到仓取整柜人工冻结值,实际到仓取海外 PDA 首次开始收货时间。 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ContainerArrivalTimeQuery' } + responses: + '200': + description: 柜级到仓时间列表 + content: + application/json: + schema: + type: object + properties: + code: { type: string, enum: ['200'] } + data: + type: array + items: { $ref: '#/components/schemas/ContainerArrivalTimeView' } + /api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/expected-arrival: + put: + summary: 修改整柜预计到仓时间 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用柜级时间版本与 Idempotency-Key,在入库计划生成前原子更新柜内全部尾程订单。 + security: [{ bearerAuth: [] }] + parameters: + - { name: sourceContainerBookingId, in: path, required: true, schema: { type: string } } + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 255 } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ChangeExpectedWarehouseArrivalRequest' } + responses: + '200': + description: 更新后的柜级到仓时间 + content: + application/json: + schema: + type: object + properties: + code: { type: string, enum: ['200'] } + data: { $ref: '#/components/schemas/ContainerArrivalTimeView' } + '409': { description: 已生成入库计划、版本冲突或幂等键冲突 } + /api/platform/v1/last-leg/orders/{id}/destination: + put: + summary: 将单票尾程订单转入 FBA 目的仓 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;目标必须来自国内 Warehouse Service 平台公共 ACTIVE FBA 主档,warehouseId/warehouseCode/warehouseName 必须与权威记录一致。实际海外收货仓、普通国内中转仓、停用仓或伪造字段均返回 A0400“所选FBA仓不存在或已停用”。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/FbaTransferDestination' } + responses: + '200': { description: 统一 ApiResponse 包装的更新订单数 } + '400': { description: 所选FBA仓不存在、已停用、类型错误或字段不一致 } + '503': { description: 国内FBA仓权威主数据暂不可用,写入失败关闭 } + /api/platform/v1/last-leg/orders/destination: + put: + summary: 批量将尾程订单转入 FBA 目的仓 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;一次校验国内 Warehouse Service 权威 FBA 主档后,在 Lastleg 事务内更新所选订单及冻结明细。目标仓必须为平台公共 ACTIVE FBA 且 ID/编码/名称完全一致。 + security: [{ bearerAuth: [] }] + parameters: + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/FbaBatchTransferDestination' } + responses: + '200': { description: 统一 ApiResponse 包装的更新订单数 } + '400': { description: 所选FBA仓不存在、已停用、类型错误或字段不一致 } + '503': { description: 国内FBA仓权威主数据暂不可用,写入失败关闭 } + /api/platform/v1/last-leg/inbound-plans: + get: + summary: 平台分页查询海外入库订单 + description: 权限 platform:overseas-warehouse:inbound-order:list;列表中的 overseasWarehouseCode/overseasWarehouseName 只取尾程订单预分配接柜仓事实,warehouseCode/warehouseName 仍表示目的仓。 + security: [{ bearerAuth: [] }] + parameters: + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { 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: planNo, in: query, schema: { type: string } } + - { name: inboundCode, in: query, schema: { type: string } } + - { name: lxCode, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string } } + - { name: overseasWarehouseId, in: query, schema: { type: string } } + - { name: destinationWarehouseCode, in: query, schema: { type: string } } + - { name: warehouse, in: query, schema: { type: string } } + - { name: fbaWarehouseCode, in: query, schema: { type: string } } + - { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED] } } + - { name: dispatchStatus, in: query, schema: { type: string, enum: [UNSCHEDULED, SCHEDULED] } } + - { name: createdFrom, in: query, schema: { type: string, format: date-time } } + - { name: createdTo, in: query, schema: { type: string, format: date-time } } + responses: { '200': { description: 平台入库订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanPageResponse' } } } } } + /api/platform/v1/last-leg/inbound-plans/{planNo}: + get: + summary: 平台查询海外入库订单详情 + description: 权限 platform:overseas-warehouse:inbound-order:list;详情中的 overseasWarehouseCode/overseasWarehouseName 只取当前计划来源尾程订单的预分配接柜仓事实。 + security: [{ bearerAuth: [] }] + parameters: + - { name: planNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + responses: + '200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } } + '404': { description: 入库订单不存在或不属于指定客户 } + /api/platform/v1/last-leg/inbound-plans/manual: + post: + summary: 平台按单票手工生成入库计划 + description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。预计到仓日期由服务端锁定读取来源柜全部订单的 estimatedWarehouseAt,缺失或同柜不一致时拒绝生成,不接受调用方提交。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。 + security: [{ bearerAuth: [] }] + parameters: + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ManualInboundPlanRequest' } + responses: + '200': { description: 统一 ApiResponse 包装的入库计划详情 } + '400': { description: 客户、入仓码、仓库主数据或请求字段无效 } + '409': { description: 订单版本、状态、暂扣状态、柜级预计到仓时间、重复计划或幂等键冲突 } + /api/platform/v1/last-leg/inbound-plans/convert-container: + post: + summary: 按整柜批量生成剩余入库计划 + description: 权限 platform:overseas-warehouse:inbound-order:create;调用方必须先取柜级预览并原样回传 snapshotVersion/snapshotHash。服务端以 sourceContainerBookingId 建立唯一 Saga 命令,先向海外仓资源服务按票、按托盘批量预留真实 palletId/palletCode/locationId/resourceLockId,再在一个本地事务中锁整柜并为每票生成独立计划,最后确认资源批次。本地任一票失败时整柜回滚并补偿资源;本地已提交但远端确认失败时重放只重试确认。同柜不同 Idempotency-Key 也不会产生第二批资源。 + security: [{ bearerAuth: [] }] + parameters: + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sourceContainerBookingId, snapshotVersion, snapshotHash] + properties: + sourceContainerBookingId: { type: string, minLength: 1, maxLength: 128 } + snapshotVersion: { type: integer, format: int64, minimum: 0 } + snapshotHash: { type: string, pattern: '^[0-9a-f]{64}$' } + responses: { '200': { description: 统一 ApiResponse 包装的入库计划数组 } } + /api/platform/v1/last-leg/inbound-plans/container-preview: + get: + summary: 预览按柜生成入库计划 + description: 权限 platform:overseas-warehouse:inbound-order:list;按稳定 sourceContainerBookingId 返回不受订单分页影响的全柜事实。分组固定为 FBA、一件代发、私人地址,FBA 按目的仓编码/名称/ID 稳定排序;托数及 readiness/blockers 只由服务端计算。确认时必须原样回传 snapshotVersion 与 snapshotHash,服务端仍会锁柜重算。 + security: [{ bearerAuth: [] }] + parameters: + - { name: sourceContainerBookingId, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + responses: + '200': { description: 柜级入库计划转换预览, content: { application/json: { schema: { $ref: '#/components/schemas/ContainerInboundPlanPreviewApiResponse' } } } } + '404': { description: 来源柜没有尾程订单 } + /api/platform/v1/last-leg/dispatches: + get: + summary: 分页查询排车单 + description: 权限 platform:overseas-warehouse:dispatch: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:dispatch:list;列表权限包含详情只读;平台按全局排车单号定位记录,并使用记录自身客户范围查询明细,前端不传 customerId。 + security: [{ bearerAuth: [] }] + parameters: + - { name: dispatchNo, in: path, required: true, schema: { type: string } } + responses: + '200': { description: 统一 ApiResponse 包装的 DispatchResponse } + '404': { description: 排车单不存在 } + put: + summary: 编辑待发车排车单 + description: 权限 platform:overseas-warehouse:dispatch:edit;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId。仅允许 PENDING_DEPARTURE,且请求不包含 status/departAt,发车事实只能由 PDA 作业命令产生。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchUpdate' } } } } + responses: { '200': { description: 更新后的 DispatchResponse }, '404': { description: 排车单不存在 }, '409': { description: 排车单已执行或不可编辑 } } + delete: + summary: 取消待发车排车单 + description: 权限 platform:overseas-warehouse:dispatch:cancel;只按全局唯一 dispatchNo 锁定记录并解析客户范围,不接收 customerId。仅 PENDING_DEPARTURE 可取消并释放正式托盘锁;重复取消幂等成功。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 取消成功或已取消 }, '404': { description: 排车单不存在 }, '409': { description: 排车已开始执行 } } + /api/platform/v1/last-leg/dispatches/{dispatchNo}/prints/bol: + get: + summary: 生成排车 BOL PDF + description: 权限 platform:overseas-warehouse:dispatch:list;服务端按目的 FBA 仓分组并分页生成固定 A4 PDF,浏览器不直接访问内部打印服务。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + responses: + '200': + description: BOL PDF + headers: + Content-Disposition: { schema: { type: string } } + X-Print-Page-Count: { schema: { type: integer, minimum: 1 } } + content: { application/pdf: { schema: { type: string, format: binary } } } + '404': { description: 排车单不存在 } + '422': { description: 排车单没有可打印的 BOL 明细 } + /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/{dispatchNo}/items/{itemId}: + delete: + summary: 移除已排车托盘 + description: 权限 platform:overseas-warehouse:dispatch:edit;仅待发车、未拣货且尚未生成出库单时允许,成功后释放托盘锁并刷新排车汇总。 + security: [{ bearerAuth: [] }] + parameters: + - { name: dispatchNo, in: path, required: true, schema: { type: string } } + - { name: itemId, in: path, required: true, schema: { type: integer, format: int64 } } + responses: { '200': { description: 更新后的 DispatchResponse }, '404': { description: 排车单或托盘不存在 }, '409': { description: 已生成出库单或排车已开始执行 } } + /api/platform/v1/last-leg/dispatches/pre-arrange: + post: + summary: 创建智能预排车单 + description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围。首次推送创建 PENDING_DEPARTURE 排车单,未生成出库单前保留虚拟车草稿,再次推送刷新原排车单明细而不重复建单。 + 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/dispatches/{dispatchNo}/transfer-delivery: + post: + summary: 排车转出库与派送单 + description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId;一个排车仍生成一张出库单,并按目的FBA仓分别生成派送单,返回 deliveries 数组;同一幂等键或已转换排车重复调用返回全部既有单据。派送费用只发布一次,避免按拆分派送单重复计费。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } } + responses: { '200': { description: 一张出库单与按目的FBA仓拆分的派送单转换结果 }, '404': { description: 排车单不存在 }, '409': { description: 状态、托盘、目的FBA仓或幂等冲突 } } + /api/platform/v1/last-leg/outbound-orders: + get: + summary: 分页查询出库单 + description: 权限 platform:overseas-warehouse:outbound:list;customerId 仅为可选平台筛选条件。 + security: [{ bearerAuth: [] }] + parameters: + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { 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: documentNo, in: query, schema: { type: string } } + - { name: warehouseId, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string } } + responses: { '200': { description: 出库单分页 } } + /api/platform/v1/last-leg/outbound-orders/one-click-dropship: + post: + summary: 创建一件代发出库单 + description: 权限 platform:overseas-warehouse:outbound: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/OneClickOutboundCreate' } } } } + responses: { '200': { description: 一件代发出库 PDA 任务 }, '409': { description: 库存或幂等冲突 } } + /api/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates: + get: + summary: 查询一件代发出库候选库存 + description: 权限 platform:overseas-warehouse:outbound:create;仅平台身份可查询,customerId 为可选客户筛选条件,未传时跨客户查询,可按海外仓筛选。仅返回 ACTIVE 一件代发档案、OCCUPIED 正可用库存、AVAILABLE 箱唛与 SKU 余额共同覆盖且未被排车、转仓或待执行出库占用的维度。创建命令仍必须指定唯一客户并再次校验客户与仓库,不信任前端候选快照。 + security: [{ bearerAuth: [] }] + parameters: + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { name: warehouseId, in: query, required: false, schema: { type: string } } + responses: + '200': + description: 最多 500 条一件代发出库候选;单次 SQL 返回,不逐行补查 + content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/OneClickOutboundCandidate' } } } } + /api/platform/v1/last-leg/outbound-orders/{outboundNo}: + get: + summary: 查询出库单详情 + description: 权限 platform:overseas-warehouse:outbound:list;只按全局唯一 outboundNo 定位并从记录解析客户范围,不接收 customerId。 + security: [{ bearerAuth: [] }] + parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: '出库单、出库托盘及关联 deliveries 派送单数组;一件代发等独立出库时数组为空,排车转派送按目的FBA仓返回一项或多项;pallets[].palletNo 为 Warehouse 权威实物托盘码' }, '404': { description: 出库单不存在 } } + /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel: + post: + summary: 取消未执行出库单 + description: 权限 platform:overseas-warehouse:outbound:cancel;只按全局唯一 outboundNo 锁定并解析客户范围,不接收 customerId。仅未开始拣货/出库的单据可取消,重复取消幂等成功。 + security: [{ bearerAuth: [] }] + parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OutboundCancel' } } } } + responses: { '200': { description: 取消成功或已取消 }, '404': { description: 出库单不存在 }, '409': { description: 出库已开始执行 } } + /api/platform/v1/last-leg/delivery-orders: + get: + summary: 分页查询平台派送单 + description: 权限 platform:overseas-warehouse:delivery:list;customerId 仅为可选平台筛选条件。同一排车按目的FBA仓显示为不同派送单,每行返回目的仓快照和服务端计算的 allowedActions,页面不得自行推导状态门禁;完成动作不再由人工触发。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 派送单分页 } } + /api/platform/v1/last-leg/delivery-orders/{deliveryNo}: + get: + summary: 查询平台派送单详情 + description: 权限 platform:overseas-warehouse:delivery:list;列表权限包含详情只读;order.allowedActions 为当前可执行动作。 + security: [{ bearerAuth: [] }] + parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 派送单详情,SKU与BOL打印数据只包含当前派送单目的FBA仓 }, '404': { description: 派送单不存在 } } + /api/platform/v1/last-leg/exception-orders: + get: + summary: 分页查询异常订单 + description: 权限 platform:overseas-warehouse:exception-order:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 异常订单分页 } } + post: + summary: 创建异常订单 + description: 权限 platform:overseas-warehouse:exception-order:create。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { type: object } } } } + responses: { '200': { description: 已创建异常订单 } } + /api/platform/v1/last-leg/exception-orders/{exceptionNo}: + get: + summary: 查询异常订单详情与冻结退回明细 + description: 权限 platform:overseas-warehouse:exception-order:list;返回 version 和服务端 allowedActions。 + security: [{ bearerAuth: [] }] + parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 异常订单详情 }, '404': { description: 异常订单不存在 } } + /api/platform/v1/last-leg/exception-orders/{exceptionNo}/start-processing: + post: + summary: 开始处理异常订单 + description: 权限 platform:overseas-warehouse:exception-order:process;仅 PENDING→PROCESSING,同版本与同幂等请求可重放。 + security: [{ bearerAuth: [] }] + parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionStartProcessing' } } } } + responses: { '200': { description: 异常处理命令结果 }, '409': { description: 版本、状态或幂等冲突 } } + /api/platform/v1/last-leg/exception-orders/{exceptionNo}/evidence: + post: + summary: 上传异常处理证据 + description: 权限 platform:overseas-warehouse:exception-order:process;文件按当前客户和异常订单绑定为 OVERSEAS_EXCEPTION_EVIDENCE。 + security: [{ bearerAuth: [] }] + parameters: [{ name: exceptionNo, 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: 异常处理证据元数据 }, '400': { description: 文件或归属无效 } } + /api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete: + post: + summary: 完成异常处理并原子恢复退回库存 + description: 权限 platform:overseas-warehouse:exception-order:process;仅 PROCESSING→COMPLETED。退回海外仓必须提交证据和实收目标,库存 RETURN_STOCK_IN 成功后才完成异常单和派送退回。 + security: [{ bearerAuth: [] }] + parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionComplete' } } } } + responses: { '200': { description: 异常处理与库存恢复结果 }, '409': { description: 版本、状态、归属、数量或幂等冲突 } } + /api/platform/v1/last-leg/exception-orders/{exceptionNo}/cancel: + post: + summary: 取消未处理异常订单 + description: 权限 platform:overseas-warehouse:exception-order:process;仅 PENDING→CANCELLED。 + security: [{ bearerAuth: [] }] + parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionCancel' } } } } + responses: { '200': { description: 异常取消结果 }, '409': { description: 版本、状态或幂等冲突 } } + /api/platform/v1/last-leg/exception-orders/resolve: + get: + summary: 解析异常订单关联关系 + description: 权限 platform:overseas-warehouse:exception-order:list;属于列表页只读辅助接口。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 关联关系 } } + /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/return-inbound-options: + get: + summary: 查询派送退回候选入仓单 + description: 权限 platform:overseas-warehouse:exception-order:list;返回按原出库拣货事实解析的 returnSources,页面不得自由填写 SKU 或猜测库存位置。 + security: [{ bearerAuth: [] }] + parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 退回候选入仓单 } } + /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return: + post: + summary: 将派送单标记为退回入仓 + description: 权限 platform:overseas-warehouse:delivery:return;items 必须来自 return-inbound-options,服务端冻结源出库明细与批准退回上限。 + security: [{ bearerAuth: [] }] + parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryMarkReturn' } } } } + responses: { '200': { description: 已创建退回异常单 } } + /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status: + put: + summary: 更新派送状态 + description: 权限 platform:overseas-warehouse:delivery:status。人工只允许 WAIT_DELIVERY→DELIVERING/REJECTED/CANCELLED、DELIVERING→REJECTED;DELIVERED 仅由签收资料保存成功自动触发,同状态幂等,终态不可倒退。 + security: [{ bearerAuth: [] }] + parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } } + responses: { '200': { description: 更新后的派送状态 }, '409': { description: 非法状态迁移 } } + /api/platform/v1/last-leg/customer-visits: + get: + summary: 分页查询客户回访 + description: 权限 platform:overseas-warehouse:customer-visit:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 客户回访分页 } } + /api/platform/v1/last-leg/customer-visits/{visitId}: + get: + summary: 查询客户回访详情 + description: 权限 platform:overseas-warehouse:customer-visit:list;列表权限包含详情只读。 + security: [{ bearerAuth: [] }] + parameters: [{ name: visitId, in: path, required: true, schema: { type: integer, format: int64 } }] + responses: { '200': { description: 客户回访详情 } } + put: + summary: 保存客户回访 + description: 权限 platform:overseas-warehouse:customer-visit:edit。 + security: [{ bearerAuth: [] }] + parameters: [{ name: visitId, in: path, required: true, schema: { type: integer, format: int64 } }] + requestBody: { required: true, content: { application/json: { schema: { type: object } } } } + responses: { '200': { description: 已保存客户回访 } } + /api/platform/v1/last-leg/customer-visits/{visitId}/attachments: + post: + summary: 上传客户回访附件 + description: 权限 platform:overseas-warehouse:customer-visit:edit。 + security: [{ bearerAuth: [] }] + parameters: [{ name: visitId, in: path, required: true, schema: { type: integer, format: int64 } }] + requestBody: { required: true, content: { multipart/form-data: { schema: { type: object } } } } + responses: { '200': { 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/delivery-orders: + get: + summary: 客户分页查询自己的派送单 + description: 权限 merchant:warehouse:delivery:list;客户范围只取认证 Token,不接收 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: documentNo, in: query, schema: { type: string } } + - { name: warehouseId, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string } } + - { name: dispatchNo, in: query, schema: { type: string } } + - { name: waybillNo, in: query, schema: { type: string } } + - { name: vehiclePlate, in: query, schema: { type: string } } + - { name: driverName, in: query, schema: { type: string } } + - { name: deliveryMethod, in: query, schema: { type: string } } + - { name: plannedFrom, in: query, schema: { type: string, format: date-time } } + - { name: plannedTo, in: query, schema: { type: string, format: date-time } } + responses: { '200': { description: 当前客户派送单分页 }, '403': { description: 缺少客户派送查看权限 } } + post: + summary: 客户从自己的排车单创建派送单 + description: 权限 merchant:warehouse:delivery:create;客户范围只取认证 Token,dispatchNo 必填,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: query, required: true, schema: { type: string, minLength: 1 } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } } + responses: { '200': { description: 出库与派送单转换结果 }, '403': { description: 缺少客户派送创建权限 }, '404': { description: 当前客户下排车单不存在 }, '409': { description: 状态、参数或幂等冲突 } } + /api/merchant/v1/last-leg/delivery-orders/{deliveryNo}: + get: + summary: 客户查询自己的派送单详情 + description: 权限 merchant:warehouse:delivery:list;列表权限包含详情只读,客户范围只取认证 Token,不接收 customerId。 + security: [{ bearerAuth: [] }] + parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 当前客户派送单详情 }, '403': { description: 缺少客户派送查看权限 }, '404': { description: 当前客户下派送单不存在 } } + /api/merchant/v1/last-leg/overseas-procurements: + post: + summary: 客户创建海外采购订单 + description: 权限 merchant:cross-border:procurement;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } } + responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '409': { description: 幂等冲突 } } + get: + summary: 客户分页查询自己的海外采购订单 + description: 权限 merchant:cross-border:procurement;客户范围只取认证 Token,不接收 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: purchaseCategory, in: query, schema: { type: string } } + - { name: productName, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string } } + - { name: purchaseWarehouseId, in: query, schema: { type: string } } + - { name: createdFrom, in: query, schema: { type: string, format: date-time } } + - { name: createdTo, in: query, schema: { type: string, format: date-time } } + responses: { '200': { description: 当前客户采购订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementPageResponse' } } } } } + /api/merchant/v1/last-leg/overseas-procurements/{orderNo}: + get: + summary: 客户查询自己的海外采购订单详情及流水 + description: 权限 merchant:cross-border:procurement;列表权限包含详情只读,客户范围只取认证 Token。 + security: [{ bearerAuth: [] }] + parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 当前客户采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } } + /api/merchant/v1/last-leg/us-truck-orders: + get: + summary: 客户分页查询自己的美国本土卡订单 + description: 权限 merchant:cross-border:domestic-trucking;客户范围只取认证 Token,不接收 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: routeKeyword, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/TruckStatus' } } + - { name: transportType, in: query, schema: { type: string } } + - { name: requestedFrom, in: query, schema: { type: string, format: date } } + - { name: requestedTo, in: query, schema: { type: string, format: date } } + responses: { '200': { description: 当前客户本土卡订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/TruckPageResponse' } } } } } + post: + summary: 客户创建美国本土卡订单 + description: 权限 merchant:cross-border:domestic-trucking;新单固定进入 PENDING_REVIEW,客户不能调用平台审核或状态推进端点。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCreate' } } } } + responses: { '200': { description: 本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 幂等冲突 } } + /api/merchant/v1/last-leg/us-truck-orders/{orderNo}: + parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] + get: + summary: 客户查询自己的美国本土卡订单详情 + description: 权限 merchant:cross-border:domestic-trucking;不泄露其他客户订单。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 当前客户本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } } + patch: + summary: 客户编辑待审核的美国本土卡订单 + description: 权限 merchant:cross-border:domestic-trucking;仅 PENDING_REVIEW 可编辑,version 必填;不能修改状态。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckUpdate' } } } } + responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 版本或状态冲突 } } + /api/merchant/v1/last-leg/local-looseboard-orders: + get: + summary: 客户分页查询自己的本土散板订单 + description: 权限 merchant:cross-border:pallet-shipping;客户范围只取认证 Token,不接收 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: routeKeyword, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/LooseboardStatus' } } + - { name: transportMode, in: query, schema: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } } + - { name: requestedFrom, in: query, schema: { type: string, format: date } } + - { name: requestedTo, in: query, schema: { type: string, format: date } } + responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } } + post: + summary: 客户创建本土散板订单 + description: 权限 merchant:cross-border:pallet-shipping;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } } + responses: { '200': { description: 本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 幂等冲突 } } + /api/merchant/v1/last-leg/local-looseboard-orders/{orderNo}: + parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] + get: + summary: 客户查询自己的本土散板订单详情 + description: 权限 merchant:cross-border:pallet-shipping;不泄露其他客户订单。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } } + put: + summary: 客户编辑待接单的本土散板订单 + description: 权限 merchant:cross-border:pallet-shipping;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } } + responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } } + /api/merchant/v1/last-leg/inbound-plans: + get: + summary: 客户分页查询自己的海外入库订单 + description: 权限 merchant:warehouse:inbound:list;客户范围只取认证 Token,不接收 customerId;overseasWarehouseCode/overseasWarehouseName 只取该客户尾程订单预分配接柜仓事实,warehouseCode/warehouseName 仍表示目的仓。 + 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: planNo, in: query, schema: { type: string } } + - { name: inboundCode, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string } } + - { name: overseasWarehouseId, in: query, schema: { type: string } } + - { name: destinationWarehouseCode, in: query, schema: { type: string } } + - { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED] } } + - { name: dispatchStatus, in: query, schema: { type: string, enum: [UNSCHEDULED, SCHEDULED] } } + - { name: createdFrom, in: query, schema: { type: string, format: date-time } } + - { name: createdTo, in: query, schema: { type: string, format: date-time } } + responses: { '200': { description: 当前客户入库订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanPageResponse' } } } } } + /api/merchant/v1/last-leg/inbound-plans/{planNo}: + get: + summary: 客户查询自己的海外入库订单详情 + description: 权限 merchant:warehouse:inbound:list;列表权限包含详情只读,不接收 customerId;overseasWarehouseCode/overseasWarehouseName 只取该计划来源尾程订单的预分配接柜仓事实。 + security: [{ bearerAuth: [] }] + parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 当前客户入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } }, '404': { description: 当前客户下入库订单不存在 } } + /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;PDA复核通过后进入CUSTOMER_CONFIRM_PENDING,客户确认后进入READY_FOR_OUTBOUND,再由排车或直接出库履约。 + 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: 非法状态迁移或关联尾程单缺失 } } + /api/platform/v1/last-leg/relabel-applications/{relabelNo}/wallet-settlement/retry: + post: + summary: 重试失败的换标费用结算 + description: 权限 platform:overseas-warehouse:relabel-order:status;仅允许已完成且资金状态为SETTLE_FAILED的换标单,使用新幂等键重新发送结算指令。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WalletFreezeRetry' } } } } + responses: { '200': { description: 结算重试已提交 }, '409': { description: 当前换标单不满足重试条件 } } + /api/platform/v1/last-leg/relabel-applications/{relabelNo}/files: + post: + summary: 上传总换标PDF或明细换标PDF + description: 权限 platform:overseas-warehouse:relabel-order:create。仅PENDING_PROCESSING/PROCESSING可替换,PDF内容且不超过10MiB。客户从申请单解析,ITEM必须属于该申请单同客户。File类别分别为OVERSEAS_RELABEL_TOTAL_DOCUMENT(application.id)及OVERSEAS_RELABEL_ITEM_DOCUMENT(item.id)。成功保存File返回的资料元数据,旧资料不再允许通过当前绑定下载。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string } } + - { name: scope, in: query, required: true, schema: { type: string, enum: [TOTAL, ITEM] } } + - { name: itemId, in: query, description: ITEM必填,TOTAL不得传入, schema: { type: integer, format: int64, minimum: 1 } } + requestBody: + required: true + content: + multipart/form-data: + schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } + responses: + '200': { description: 换标详情;application包含totalFileId/totalFileName/totalFileUrl,items包含relabelFileId/relabelFileName/relabelFileUrl。URL为受权下载路径,文件ID按字符串返回 } + '400': { description: 非PDF、超限或资料范围无效 } + '404': { description: 申请或明细不属于当前客户 } + '409': { description: 换标状态不允许修改资料 } + /api/platform/v1/last-leg/relabel-applications/{relabelNo}/files/{fileId}/download: + get: + summary: 读取当前绑定的换标PDF + description: 权限 platform:overseas-warehouse:relabel-order:list;按申请单客户、资料类别、application.id或item.id及fileId精确授权。仅当前已保存资料可读,未绑定或替换的文件拒绝。响应Cache-Control为no-store。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } } + - { name: scope, in: query, required: true, schema: { type: string, enum: [TOTAL, ITEM] } } + - { name: itemId, in: query, schema: { type: integer, format: int64, minimum: 1 } } + responses: + '200': { description: PDF字节, content: { application/pdf: { schema: { type: string, format: binary } } } } + '404': { description: 资料未绑定或归属不匹配 } + /api/merchant/v1/last-leg/relabel-applications/inventory-candidates: + get: + summary: 查询客户可换标库存候选 + description: 权限 merchant:warehouse:relabel;仅返回当前客户在指定海外中转仓仍有可用箱数的历史入仓单和库位摘要。候选按客户 Token 隔离,不接收 customerId;inboundCode 可选精确筛选。 + security: [{ bearerAuth: [] }] + parameters: + - { name: operationWarehouseId, in: query, required: true, schema: { type: string, maxLength: 128 } } + - { name: inboundCode, in: query, required: false, schema: { type: string, maxLength: 128 } } + responses: + '200': { description: 可换标库存候选, content: { application/json: { schema: { type: object, properties: { code: { type: string, example: '200' }, data: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryCandidate' } } } } } } } + '403': { description: 缺少客户换标权限 } + /api/merchant/v1/last-leg/relabel-applications: + get: + summary: 分页查询客户换标申请 + description: 权限 merchant:warehouse:relabel;客户范围只取认证 Token,不接收 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: 客户换标申请分页, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantPageResponse' } } } } + post: + summary: 创建客户换标申请 + description: 权限 merchant:warehouse:relabel;申请创建即冻结对应库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } } + responses: + '200': { description: 换标申请详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } } + '409': { description: 幂等键、库存或状态冲突 } + /api/merchant/v1/last-leg/relabel-applications/historical-orders: + get: + summary: 查询贴标申请历史订单候选 + description: 按海外仓和关键字查询客户历史尾程订单,并包含贴标出库单(RELABEL_OUTBOUND),供贴标申请订单下拉筛选。 + security: [{ bearerAuth: [] }] + parameters: + - { name: operationWarehouseId, in: query, required: true, schema: { type: string, maxLength: 128 } } + - { name: keyword, in: query, required: false, schema: { type: string, maxLength: 128 } } + responses: + '200': { description: 历史订单候选, content: { application/json: { schema: { type: object, properties: { code: { type: string, example: '200' }, data: { type: array, items: { $ref: '#/components/schemas/RelabelHistoricalOrder' } } } } } } } + '403': { description: 缺少客户换标权限 } + /api/merchant/v1/last-leg/relabel-applications/{relabelNo}: + get: + summary: 查询客户换标申请详情 + description: 权限 merchant:warehouse:relabel;客户范围只取认证 Token。 + security: [{ bearerAuth: [] }] + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] + responses: + '200': { description: 换标申请详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } } + '404': { description: 申请不存在或不属于当前客户 } + /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label: + post: + summary: 上传换标快递面单 + description: 权限 merchant:warehouse:relabel;仅 EXPRESS 且申请尚未客户确认时允许上传,文件由服务端绑定到申请单。 + security: [{ bearerAuth: [] }] + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] + 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/RelabelMerchantDetailResponse' } } } } + '400': { description: 文件类型或大小不合法 } + '409': { description: 当前状态不允许上传 } + /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label/{fileId}/download: + get: + summary: 下载换标快递面单 + description: 权限 merchant:warehouse:relabel;仅允许下载当前申请已绑定的面单文件。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } } + responses: + '200': { description: 面单文件, content: { application/pdf: { schema: { type: string, format: binary } }, image/jpeg: { schema: { type: string, format: binary } }, image/png: { schema: { type: string, format: binary } } } } + '404': { description: 文件未绑定或不属于当前客户 } + /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/completion-photos/{fileId}/download: + get: + summary: 下载换标完成照片 + description: 权限 merchant:warehouse:relabel;仅允许下载该申请 PDA 完工证据中的照片。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } } + responses: + '200': { description: 完工照片, content: { image/jpeg: { schema: { type: string, format: binary } }, image/png: { schema: { type: string, format: binary } } } } + '404': { description: 照片不存在或不属于当前客户 } + /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/confirm-completion: + post: + summary: 客户确认换标完成 + description: 权限 merchant:warehouse:relabel;仅 CUSTOMER_CONFIRM_PENDING 可确认。TRUCK 进入 WAIT_PRE_DISPATCH,PICKUP/EXPRESS 幂等生成 RELABEL_OUTBOUND 出库单;EXPRESS 且要求打印面单时必须先上传面单。 + security: [{ bearerAuth: [] }] + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCustomerConfirm' } } } } + responses: + '200': { description: 更新后的换标详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } } + '409': { description: 状态、面单、库存或幂等冲突 } + /api/merchant/v1/last-leg/return-orders: + get: + summary: 客户分页查询退货签收订单 + description: 客户范围只取认证 Token。可按退货单号、快递单号、SKU 和签收状态过滤;实收数量以仓库 PDA 签收为准。 + 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: returnNo, in: query, schema: { type: string } } + - { name: trackingNo, in: query, schema: { type: string } } + - { name: skuCode, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string, enum: [PENDING_RECEIPT, PARTIALLY_RECEIVED, COMPLETED] } } + responses: { '200': { description: 当前客户退货订单分页 } } + post: + summary: 客户创建退货预报订单 + description: 客户只预报不重复的 SKU 和预计数量,不要求提前知道快递单号;仓库 PDA 实收数量才是实际入库事实。系统退货编号由业务前缀和25位大写Base36完整随机UUID编码组成,退货单RT、签收单RR、批次RB、箱唛RC及退货主单RETU均独立生成,不编码内部ID、仓库或日期。幂等重放返回已保存的编号,不重新生成。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnOrderCreate' } } } } + responses: { '200': { description: 创建后的退货订单详情 }, '409': { description: 幂等键冲突 } } + /api/merchant/v1/last-leg/return-orders/{returnNo}: + get: + summary: 客户查询退货订单与签收批次 + description: 返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。 + security: [{ bearerAuth: [] }] + parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } } + /api/pda/v1/last-leg/return-receipts/start: + post: + summary: PDA 以退件单号开始签收 + description: 仓库先从包裹收件信息取得客户填写的退件单号。仅当前绑定仓的 PENDING_RECEIPT(待签收)和 PARTIALLY_RECEIVED(签收中)退货单可开始签收,并返回该单尚未签收的 SKU 明细。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { type: object, required: [returnNo], properties: { returnNo: { type: string, maxLength: 128 } } } } } } + responses: { '200': { description: 可开始签收的退货单及未签收 SKU 明细 }, '404': { description: 当前仓没有待签收或签收中的退货单 } } + /api/pda/v1/last-leg/return-receipts/sku-candidates: + post: + summary: PDA 扫描 SKU 查询候选退货单 + description: 仅查询当前 PDA 绑定仓内待签收或签收中的退货单,最多返回 50 个候选。仓库人员根据退货单号、客户和数量选择后,再调用 start 进入统一签收流程。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { type: object, required: [skuCode], properties: { skuCode: { type: string, maxLength: 128 } } } } } } + responses: { '200': { description: SKU 及候选退货单列表;没有匹配时 candidates 为空数组 } } + /api/pda/v1/last-leg/return-receipts/confirm: + post: + summary: PDA 确认退货签收 + description: PDA 仅能操作绑定海外仓。startedBy 记录通过退货单号或 SKU 候选进入;trackingNo 为本次包裹可选快递单号。按预报可签收数量校验、禁止超收;每次成功确认创建唯一签收单及仓储计费批次。全部预报 SKU 数量吻合时自动 COMPLETED。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnReceiptConfirm' } } } } + responses: { '200': { description: 签收结果及自动完成状态 }, '409': { description: 超收、错绑或幂等键冲突 } } + /api/merchant/v1/last-leg/return-inventory: + get: + summary: 客户查询退货库存 + description: 客户范围只取认证 Token。库存按客户、海外仓、SKU 和箱号查询,数量以仓库 PDA 实收上架数量为准。 + 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: overseasWarehouseId, in: query, schema: { type: string, maxLength: 128 } } + - { name: skuCode, in: query, schema: { type: string, maxLength: 128 } } + - { name: cartonMark, in: query, schema: { type: string, maxLength: 128 } } + responses: { '200': { description: 当前客户退货库存分页 } } + /api/merchant/v1/last-leg/return-inventory/summary: + get: + summary: 客户查询退货库存 SKU 汇总 + description: 客户范围只取认证 Token。按海外仓和 SKU 汇总 PDA 实收上架数量及可用箱数。 + 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: overseasWarehouseId, in: query, schema: { type: string, maxLength: 128 } } + - { name: skuCode, in: query, schema: { type: string, maxLength: 128 } } + responses: { '200': { description: 当前客户 SKU 汇总库存分页 } } + /api/merchant/v1/last-leg/return-inventory/cartons: + get: + summary: 客户退货箱号分页 + description: 需要 merchant:overseas-warehouse:return-inventory:list。客户范围仅取登录身份且精确匹配;每个实物箱一条记录,SKU 筛选使用存在性条件,不重复箱号或放大总箱数。 + 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: overseasWarehouseId, in: query, schema: { type: string } } + - { name: skuCode, in: query, schema: { type: string } } + - { name: cartonMark, in: query, schema: { type: string } } + - { name: returnNo, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string } } + responses: + '200': { description: 分页 records 每箱一行,包含 cartonMark、overseasWarehouseId、sourceBatchNo、sourceReturnNo、status、skuTypes、totalQuantity、length、width、height、weight、createdAt;total 为实际箱数 } + /api/merchant/v1/last-leg/return-inventory/cartons/{cartonMark}: + get: + summary: 客户查询退货箱明细 + description: 仅返回当前客户的箱号、签收批次和箱内 SKU 数量。 + security: [{ bearerAuth: [] }] + parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string, maxLength: 128 } }] + responses: { '200': { description: 当前客户退货箱明细 }, '404': { description: 当前客户下退货箱不存在 } } + /api/merchant/v1/last-leg/return-inventory/cartons/{cartonMark}/label: + get: + summary: 客户获取退货箱唛 + description: 返回当前客户退货箱的可打印标签及 SKU 数量,客户可据此打印或复核箱唛。 + security: [{ bearerAuth: [] }] + parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string, maxLength: 128 } }] + responses: { '200': { description: 当前客户退货箱唛 }, '404': { description: 当前客户下退货箱不存在 } } + /api/pda/v1/last-leg/return-inventory/cartons/merge: + post: + summary: PDA 合并退货箱 + description: 仅当前 PDA 绑定海外仓可操作。选择同一客户的已完成退货单,按查询出的 SKU 填写本箱数量并录入箱规;系统扣减可用库存并生成合箱箱号。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnInventoryMerge' } } } } + responses: { '200': { description: 新箱号、SKU 汇总数量及可打印箱唛 }, '409': { description: 箱状态、客户或 SKU 不满足合箱条件,或幂等键冲突 } } + /api/pda/v1/last-leg/return-inventory/packing/orders: + get: + summary: PDA 查询可合箱退货单和 SKU 库存 + security: [{ bearerAuth: [] }] + parameters: [{ name: returnNo, in: query, required: false, schema: { type: string, maxLength: 128 } }] + responses: { '200': { description: 当前仓已完成签收退货单及可用 SKU 数量 } } + /api/pda/v1/last-leg/return-inventory/cartons/{cartonMark}/label: + get: + summary: PDA 获取退货箱唛 + description: 返回当前绑定仓退货箱的结构化数据及 100mm x 150mm 可打印 HTML,包含箱号、仓库、客户、签收批次及 SKU 数量。 + security: [{ bearerAuth: [] }] + parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string, maxLength: 128 } }] + responses: { '200': { description: 退货箱唛打印数据 }, '404': { description: 当前仓没有该退货箱 } } +components: + schemas: + ReturnOrderCreate: + type: object + required: [overseasWarehouseId, needRelabel, idempotencyKey, items] + properties: + overseasWarehouseId: { type: string, maxLength: 128 } + warehouseContactName: { type: string, maxLength: 128, description: 中转仓联系人快照;仓库未维护时可由客户填写 } + warehouseContactPhone: { type: string, maxLength: 64, description: 中转仓联系电话快照;仓库未维护时可由客户填写 } + customerReferenceNo: { type: string, maxLength: 128 } + needRelabel: { type: boolean, default: false, description: 是否需要换标 } + remark: { type: string, maxLength: 1000 } + idempotencyKey: { type: string, maxLength: 255 } + items: + type: array + minItems: 1 + maxItems: 500 + items: + type: object + required: [skuCode, expectedQuantity] + properties: + skuCode: { type: string, maxLength: 128 } + productNameZh: { type: string, maxLength: 255 } + expectedQuantity: { type: integer, minimum: 1 } + ReturnReceiptConfirm: + type: object + required: [returnNo, startedBy, items, idempotencyKey] + properties: + returnNo: { type: string, maxLength: 128 } + trackingNo: { type: string, nullable: true, maxLength: 128, description: '本次签收包裹有快递面单时扫描绑定;没有时为空' } + startedBy: { type: string, enum: [RETURN_NO, SKU], description: '本次通过退货单号查询或 SKU 候选开始签收' } + cartonMark: { type: string, nullable: true, maxLength: 128, description: PDA 实际绑定的箱号;不传时由系统生成 } + idempotencyKey: { type: string, maxLength: 255 } + items: + type: array + minItems: 1 + maxItems: 500 + items: + type: object + required: [skuCode, receivedQuantity] + properties: + skuCode: { type: string, maxLength: 128 } + receivedQuantity: { type: integer, minimum: 1 } + ReturnInventoryMerge: + type: object + required: [returnNos, items, length, width, height, weight, idempotencyKey] + properties: + returnNos: + type: array + minItems: 1 + maxItems: 100 + items: { type: string, minLength: 1, maxLength: 128 } + items: + type: array + minItems: 1 + maxItems: 500 + items: + type: object + required: [skuCode, quantity] + properties: + skuCode: { type: string, maxLength: 128 } + quantity: { type: integer, minimum: 1 } + length: { type: number, minimum: 0, exclusiveMinimum: true, description: '箱长,CM' } + width: { type: number, minimum: 0, exclusiveMinimum: true, description: '箱宽,CM' } + height: { type: number, minimum: 0, exclusiveMinimum: true, description: '箱高,CM' } + weight: { type: number, minimum: 0, exclusiveMinimum: true, description: '箱重,KG' } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + InventoryBalance: + type: object + required: [id, customerId, totalPackages, availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages, receivingPackages, procurementPackages] + properties: + id: { type: string, pattern: '^[1-9][0-9]*$', description: 库存余额长整型ID,按字符串传输 } + customerId: { type: string } + customerName: { type: string, nullable: true } + overseasWarehouseId: { type: string, nullable: true } + overseasWarehouseCode: { type: string, nullable: true } + overseasWarehouseName: { type: string, nullable: true } + destinationWarehouseId: { type: string, nullable: true } + destinationWarehouseCode: { type: string, nullable: true } + destinationWarehouseName: { type: string, nullable: true } + inboundOrderId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$', description: 长整型ID按字符串传输 } + inboundOrderNo: { type: string, nullable: true } + inboundCode: { type: string, nullable: true } + skuCode: { type: string, nullable: true } + productNameZh: { type: string, nullable: true } + productNameEn: { type: string, nullable: true } + totalPackages: { type: integer, format: int64, minimum: 0 } + availablePackages: { type: integer, format: int64, minimum: 0 } + holdPackages: { type: integer, format: int64, minimum: 0 } + allocatedPackages: { type: integer, format: int64, minimum: 0 } + pickedPackages: { type: integer, format: int64, minimum: 0 } + inTransitPackages: { type: integer, format: int64, minimum: 0 } + receivingPackages: { type: integer, format: int64, minimum: 0 } + procurementPackages: { type: integer, format: int64, minimum: 0 } + updatedAt: { type: string, format: date-time, nullable: true } + InventoryBalanceApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: { data: { $ref: '#/components/schemas/InventoryBalance' } } + InboundCodeRelationship: + type: object + required: [inboundCode, found, taillegOrders, inboundPlans, palletCodes, inventory] + properties: + inboundCode: { type: string } + found: { type: boolean } + customer: { $ref: '#/components/schemas/InboundCodeRelationshipCustomer', nullable: true } + overseasWarehouse: { $ref: '#/components/schemas/InboundCodeRelationshipWarehouse', nullable: true } + overseasReceivingStatus: { type: string, nullable: true } + taillegOrders: { type: array, items: { $ref: '#/components/schemas/InboundCodeRelationshipOrder' } } + inboundPlans: { type: array, items: { $ref: '#/components/schemas/InboundCodeRelationshipPlan' } } + palletCodes: { type: array, uniqueItems: true, items: { type: string } } + inventory: { type: array, items: { $ref: '#/components/schemas/InboundCodeRelationshipInventorySku' } } + InboundCodeRelationshipCustomer: + type: object + required: [customerId] + properties: + customerId: { type: string, description: 字符串业务隔离标识,禁止按JavaScript number处理 } + customerName: { type: string, nullable: true } + InboundCodeRelationshipWarehouse: + type: object + properties: + warehouseId: { type: string, nullable: true, description: 字符串共享仓库ID,禁止按JavaScript number处理 } + warehouseCode: { type: string, nullable: true } + warehouseName: { type: string, nullable: true } + InboundCodeRelationshipOrder: + type: object + required: [orderNo] + properties: + orderNo: { type: string } + orderStatus: { type: string, nullable: true } + InboundCodeRelationshipPlan: + type: object + required: [planNo] + properties: + planNo: { type: string } + planStatus: { type: string, nullable: true } + receivingStatus: { type: string, nullable: true } + InboundCodeRelationshipInventorySku: + type: object + required: [skuCode, packageCount] + properties: + skuCode: { type: string } + productNameZh: { type: string, nullable: true } + productNameEn: { type: string, nullable: true } + packageCount: { type: integer, format: int64, minimum: 0 } + InboundCodeRelationshipApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: { data: { $ref: '#/components/schemas/InboundCodeRelationship' } } + FbaTransferDestination: + type: object + required: [warehouseId, warehouseCode, warehouseName] + properties: + warehouseId: { type: string, pattern: '^[1-9][0-9]*$', description: 国内 Warehouse Service 平台公共 FBA 仓 ID } + warehouseCode: { type: string, minLength: 1, maxLength: 128 } + warehouseName: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 500 } + FbaBatchTransferDestination: + allOf: + - { $ref: '#/components/schemas/FbaTransferDestination' } + - type: object + required: [orderIds] + properties: + orderIds: + type: array + minItems: 1 + maxItems: 200 + uniqueItems: true + items: { type: string, pattern: '^[1-9][0-9]*$' } + ContainerPlanProgressSummary: + type: object + required: [sourceContainerBookingId, containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, assignmentVersion, canChangeReceivingWarehouse, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, resourcePendingPlanCount, allPlansReady] + properties: + sourceContainerBookingId: { type: string } + containerNo: { type: string, pattern: '^[A-Z]{3}[UJZ][0-9]{7}$', description: 装柜完成事件非空且通过 ISO 6346 校验位验证的规范化真实集装箱号;不使用 cabinetNo、订单号或内部 ID 回退 } + overseasWarehouseId: { type: string, pattern: '^[1-9][0-9]*$', description: 平台预分配的海外接柜仓ID,不是FBA目的仓 } + overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码 } + overseasWarehouseName: { type: string, description: 海外接柜仓业务名称 } + assignmentVersion: { type: integer, minimum: 0, description: 柜级接柜仓乐观锁版本 } + canChangeReceivingWarehouse: { type: boolean } + receivingWarehouseLockReason: { type: string, nullable: true } + totalOrderCount: { type: integer, format: int64 } + inboundCodeCount: { type: integer, format: int64 } + missingInboundCodeCount: { type: integer, format: int64 } + planCount: { type: integer, format: int64 } + plannedInboundCodeCount: { type: integer, format: int64 } + pendingPlanCount: { type: integer, format: int64, description: 尚未生成有效计划的尾程订单数,即未操作数 } + resourcePendingPlanCount: { type: integer, format: int64, description: 已有有效计划但尚未完整绑定海外仓真实托盘的订单数 } + plannedArrivalAt: { type: string, format: date-time, nullable: true, description: 整柜一致的 ll_order.estimated_warehouse_at,可由装柜事件初始化并在计划生成前人工维护 } + allPlansReady: { type: boolean, description: 全柜每票均有入仓码且均存在有效独立计划时为 true } + blockingReason: { type: string, nullable: true } + ChangeReceivingWarehouseRequest: + type: object + required: [overseasWarehouseId, expectedVersion] + properties: + overseasWarehouseId: { type: integer, format: int64, minimum: 1 } + expectedVersion: { type: integer, minimum: 0 } + remark: { type: string, maxLength: 500, nullable: true } + ContainerArrivalTimeQuery: + type: object + required: [sourceContainerBookingIds] + properties: + sourceContainerBookingIds: + type: array + minItems: 1 + maxItems: 200 + uniqueItems: true + items: { type: string, minLength: 1, maxLength: 128 } + ChangeExpectedWarehouseArrivalRequest: + type: object + required: [estimatedWarehouseArrivalAt, expectedVersion] + properties: + estimatedWarehouseArrivalAt: { type: string, format: date-time } + expectedVersion: { type: integer, minimum: 0 } + remark: { type: string, maxLength: 500, nullable: true } + ContainerArrivalTimeView: + type: object + required: [sourceContainerBookingId, version] + properties: + sourceContainerBookingId: { type: string } + estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true } + actualWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 海外 PDA 首次开始收货时间 } + version: { type: integer, minimum: 0 } + ContainerPlanProgressDetail: + type: object + required: [summary, orders] + properties: + summary: { $ref: '#/components/schemas/ContainerPlanProgressSummary' } + orders: + type: array + items: { $ref: '#/components/schemas/ContainerPlanProgressItem' } + ContainerPlanProgressItem: + type: object + required: [orderId, customerId, orderStatus, planValid] + properties: + orderId: { type: string, pattern: '^[1-9][0-9]*$' } + customerId: { type: string } + orderNo: { type: string, nullable: true } + inboundCode: { type: string, nullable: true } + lxCode: { type: string, nullable: true, maxLength: 128, description: 一件代发领星入库单代码 } + orderStatus: { type: string } + planId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$' } + planNo: { type: string, nullable: true } + planStatus: { type: string, nullable: true } + planValid: { type: boolean } + plannedArrivalAt: { type: string, format: date-time, nullable: true, description: ll_order.estimated_warehouse_at } + LastMileFeeCreate: + type: object + required: [feeItemCode, feeItemName, direction, currency, amount, businessDate] + properties: + feeItemCode: { type: string, minLength: 1, maxLength: 64 } + feeItemName: { type: string, minLength: 1, maxLength: 128 } + direction: { type: string, enum: [RECEIVABLE, PAYABLE] } + currency: { type: string, enum: [CNY, USD] } + amount: { type: number, format: decimal, minimum: 0.01 } + businessDate: { type: string, format: date } + supplierId: { type: string, pattern: '^[1-9][0-9]*$' } + supplierName: { type: string, maxLength: 200 } + supplierCategory: { type: string, maxLength: 32 } + settlementMode: { type: string, enum: [ON_ACCOUNT, DIRECT_PAID] } + chargeableQuantity: { type: number, minimum: 0, exclusiveMinimum: true } + quantityUnit: { type: string, maxLength: 32 } + unitPrice: { type: number, minimum: 0, exclusiveMinimum: true } + periodStart: { type: string, format: date } + periodEnd: { type: string, format: date } + LastMileFee: + type: object + required: [id, sourceFeeId, sourceVersion, customerId, feeItemCode, feeItemName, direction, currency, amount, originalCurrency, originalAmount, reviewStatus, publicationStatus, sourceStatus] + properties: + id: { type: string, pattern: '^[1-9][0-9]*$' } + sourceFeeId: { type: string, pattern: '^[1-9][0-9]*$' } + sourceVersion: { type: integer, minimum: 1 } + customerId: { type: string } + orderId: { type: string, pattern: '^[1-9][0-9]*$' } + feeItemCode: { type: string } + feeItemName: { type: string } + direction: { type: string, enum: [RECEIVABLE, PAYABLE] } + currency: { type: string, enum: [CNY] } + amount: { type: number, format: decimal } + originalCurrency: { type: string, enum: [CNY, USD] } + originalAmount: { type: number, format: decimal } + exchangeRate: { type: number, format: decimal } + exchangeRateMonth: { type: string, pattern: '^\\d{4}-\\d{2}$' } + reviewStatus: { type: string, enum: [DRAFT, PENDING, APPROVED, REJECTED] } + publicationStatus: { type: string } + sourceStatus: { type: string } + LastMileFeeApiResponse: + type: object + required: [code, message, data] + properties: + code: { type: string } + message: { type: string } + data: { $ref: '#/components/schemas/LastMileFee' } + 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 } } + ReceivingComplete: + type: object + required: [allowDifference, idempotencyKey] + properties: + allowDifference: { type: boolean } + 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 } + expectedPalletVersion: { type: integer, minimum: 0, nullable: true } + 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 } + ReceivingWholePallet: + type: object + required: [sourcePalletNo, idempotencyKey] + properties: + sourcePalletNo: { 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, assigned, mergeAllowed, urgent, wholePalletInbound] + 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 } + assigned: { type: boolean, description: 入库计划是否已分配 } + mergeAllowed: { type: boolean, description: 入库计划是否允许现场选择合托;true不代表强制合托 } + urgent: { type: boolean, description: 入库计划是否加急 } + mergeHint: { type: string, nullable: true, description: 允许合托时供PDA直接展示的非强制中文提示 } + 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: [sourceContainerBookingId, containerNo, overseasWarehouseCode, overseasWarehouseName, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount] + properties: + sourceContainerBookingId: { type: string, description: 头程与尾程共享的稳定柜身份 } + containerNo: { type: string } + overseasWarehouseCode: { type: string } + overseasWarehouseName: { type: string } + status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] } + totalOrderCount: { type: integer, minimum: 1 } + missingInboundCodeCount: { type: integer, minimum: 0 } + planCount: { type: integer } + inboundCodeCount: { type: integer } + plannedInboundCodeCount: { type: integer, minimum: 0 } + pendingPlanCount: { type: integer, minimum: 0 } + destinationCodes: { type: array, uniqueItems: true, items: { type: string } } + plannedPalletCount: { type: integer } + totalPackages: { type: integer } + receivedPackages: { type: integer } + operable: { type: boolean } + allPlansReady: { type: boolean } + blockReason: { type: string, nullable: true } + plannedArrivalAt: { type: string, format: date-time, nullable: true } + currentAccount: { type: boolean } + ReceivingSession: + type: object + required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, domesticLoadingPhotos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds] + properties: + containerNo: { type: string } + overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID } + overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码快照 } + overseasWarehouseName: { type: string, description: Warehouse Overseas服务校验后的共享实际海外收货仓名称快照 } + status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED, COMPLETED_WITH_DIFFERENCE] } + operatorName: { type: string, nullable: true } + version: { type: integer } + photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedSessionPhoto' } } + domesticLoadingPhotos: { type: array, maxItems: 5, items: { $ref: '#/components/schemas/DomesticLoadingPhoto' }, description: 当前物理柜国内装柜五类只读照片,不含客户信息与内部文件ID } + 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 } + hasDifference: { type: boolean, description: 是否存在短少或普通FBA超收差异 } + 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 } + DomesticLoadingPhoto: + type: object + required: [evidenceType, label, downloadPath] + properties: + evidenceType: { type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL] } + label: { type: string, enum: [空仓, 半仓, 满仓, 锁仓, 带锁码] } + downloadPath: { type: string, description: 当前接柜会话鉴权下载路径 } + ReceivingCartonResolved: + allOf: + - { $ref: '#/components/schemas/ReceivingInbound' } + - type: object + required: [cartonMark, operationMode, groupKey, inboundCodes] + properties: + cartonMark: { type: string } + operationMode: { type: string, enum: [DROPSHIP_PALLET, FBA_GROUP], nullable: true } + groupKey: { type: string, nullable: true } + inboundCodes: { type: array, items: { type: string } } + sourcePalletNo: { type: string, nullable: true } + ReceivingPalletResolved: + type: object + required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible, version, currentReceivedPackages] + 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 } + version: { type: integer, minimum: 0 } + currentReceivedPackages: { type: integer, minimum: 0 } + ReceivingReceipt: + type: object + required: [result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, receivedPackages, pendingPackages, sessionStatus, assigned, mergeAllowed, urgent] + 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] } + assigned: { type: boolean } + mergeAllowed: { type: boolean, description: 现场可选择合托,不作为实收或完成门禁 } + urgent: { type: boolean } + mergeHint: { type: string, nullable: true } + orderType: { type: string, nullable: true } + wholePalletInbound: { type: boolean, description: 一件代发必须按完整来源托盘扫描收货 } + groupKey: { type: string, nullable: true } + inboundCodes: { type: array, items: { type: string } } + 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, description: 未入库时为计划托盘码;绑定实物托盘后为 Warehouse 权威实物托盘码 } + 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 } } + 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, enum: [15, 30] } + 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 } } + DispatchUpdate: + type: object + description: 普通编辑字段;不包含 status、departAt 或路线,路线由已排托盘关联的 FBA 仓自动汇总。 + properties: + vehiclePlate: { type: string, nullable: true, maxLength: 64 } + driverName: { type: string, nullable: true, maxLength: 128 } + driverPhone: { type: string, nullable: true, maxLength: 64 } + carrierName: { type: string, nullable: true, maxLength: 255 } + fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } + fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32 } + vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32 } + estimatedArrivalAt: { type: string, format: date-time, nullable: true } + DeliveryTransfer: + type: object + required: [idempotencyKey, plannedDeliveryAt, deliveryMethod, pickupMethod, loadingType, fees] + properties: + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + plannedDeliveryAt: { type: string, format: date-time } + deliveryMethod: { type: string, maxLength: 32 } + waybillNo: { type: string, nullable: true, maxLength: 128 } + carrierName: { type: string, nullable: true, maxLength: 255 } + pickupMethod: { type: string, maxLength: 64 } + loadingType: { type: string, maxLength: 64 } + vehicleType: { type: string, nullable: true, maxLength: 64 } + vehicleSize: { type: string, nullable: true, enum: [15托, 30托] } + driverName: { type: string, nullable: true, maxLength: 128 } + driverPhone: { type: string, nullable: true, maxLength: 64 } + vehiclePlate: { type: string, nullable: true, maxLength: 64 } + appointmentAccount: { type: string, nullable: true, maxLength: 128 } + sellerName: { type: string, nullable: true, maxLength: 128 } + remark: { type: string, nullable: true, maxLength: 1000 } + fees: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/DeliveryFeeInput' } } + DeliveryStatusChange: + type: object + required: [status, expectedVersion] + properties: + status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, REJECTED, CANCELLED] } + expectedVersion: { type: integer, format: int64, minimum: 0 } + remark: { type: string, nullable: true, maxLength: 500 } + DeliveryAllowedAction: + type: string + enum: [START_DELIVERY, REJECT_DELIVERY, CANCEL_DELIVERY, MARK_RETURN] + description: START/REJECT/CANCEL 使用通用 status 接口;MARK_RETURN 使用 mark-return 接口;派送完成由签收资料保存成功自动触发。 + DeliveryMarkReturn: + type: object + required: [inboundOrderId, reason, returnDirection, items, idempotencyKey] + properties: + inboundOrderId: { type: string, pattern: '^[1-9][0-9]*$' } + reason: { type: string, minLength: 1, maxLength: 1000 } + returnDirection: { type: string, enum: [OVERSEAS_WAREHOUSE, CUSTOMER, OTHER] } + returnLogisticsNo: { type: string, nullable: true, maxLength: 128 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + items: + type: array + minItems: 1 + maxItems: 200 + items: + type: object + required: [outboundPalletItemId, inventoryPositionId, packageCount] + properties: + outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' } + inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' } + packageCount: { type: integer, format: int64, minimum: 1 } + ExceptionStartProcessing: + type: object + required: [expectedVersion, idempotencyKey] + properties: + expectedVersion: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + handlingAction: { type: string, nullable: true, maxLength: 1000 } + ExceptionCancel: + type: object + required: [expectedVersion, idempotencyKey, reason] + properties: + expectedVersion: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + reason: { type: string, minLength: 1, maxLength: 1000 } + ExceptionComplete: + type: object + required: [expectedVersion, idempotencyKey, handlingResult, evidenceFileIds] + properties: + expectedVersion: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + handlingResult: { type: string, minLength: 1, maxLength: 2000 } + evidenceFileIds: { type: array, minItems: 1, maxItems: 20, items: { type: string } } + receipts: + type: array + maxItems: 200 + items: + type: object + required: [outboundPalletItemId, inventoryPositionId, receivedPackages, targetPalletId, targetPalletNo, targetLocationId, targetLocationCode] + properties: + outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' } + inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' } + receivedPackages: { type: integer, format: int64, minimum: 1 } + targetPalletId: { type: string, pattern: '^[1-9][0-9]*$' } + targetPalletNo: { type: string, minLength: 1, maxLength: 128 } + targetLocationId: { type: string, pattern: '^[1-9][0-9]*$' } + targetLocationCode: { type: string, minLength: 1, maxLength: 128 } + ExceptionAllowedAction: + type: string + enum: [START_PROCESSING, COMPLETE_EXCEPTION, CANCEL_EXCEPTION] + DeliveryFeeInput: + type: object + required: [feeType, feeName, direction, currency, amount] + properties: + feeType: { type: string, maxLength: 64 } + feeName: { type: string, maxLength: 128 } + direction: { type: string, enum: [RECEIVABLE, PAYABLE] } + currency: { type: string, enum: [CNY, USD] } + amount: { type: number, minimum: 0, exclusiveMinimum: true } + supplierId: { type: string, pattern: '^[1-9][0-9]*$' } + supplierName: { type: string, maxLength: 200 } + supplierCategory: { type: string, maxLength: 32 } + settlementMode: { type: string, enum: [ON_ACCOUNT, DIRECT_PAID] } + chargeableQuantity: { type: number, minimum: 0, exclusiveMinimum: true } + quantityUnit: { type: string, maxLength: 32 } + unitPrice: { type: number, minimum: 0, exclusiveMinimum: true } + periodStart: { type: string, format: date } + periodEnd: { type: string, format: date } + remark: { type: string, nullable: true, maxLength: 500 } + OneClickOutboundCreate: + type: object + description: 同一幂等键只重放相同请求;收件人、明细、箱唛或备注变化返回 A0409。 + required: [recipient, items, idempotencyKey] + properties: + recipient: { type: string, maxLength: 1000 } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + items: + type: array + minItems: 1 + maxItems: 200 + items: + type: object + required: [inventoryPositionId, packages, sku] + properties: + inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' } + packages: { type: integer, format: int64, minimum: 1 } + sku: { type: string, maxLength: 128 } + cartonMarks: + type: array + maxItems: 200 + description: 指定出库箱唛,提供时必须唯一且与箱数一致。 + items: { type: string, minLength: 1, maxLength: 128 } + OneClickOutboundCandidate: + type: object + required: [inventoryPositionId, overseasWarehouseId, palletNo, skuCode, availablePackages] + properties: + inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$', description: 创建命令提交键,不作为用户展示编号 } + overseasWarehouseId: { type: string } + overseasWarehouseName: { type: string, nullable: true } + palletNo: { type: string } + inboundCode: { type: string, nullable: true } + skuCode: { type: string } + productName: { type: string, nullable: true } + availablePackages: { type: integer, format: int64, minimum: 1, description: 库存位置、SKU余额和AVAILABLE箱唛三者的可用最小值 } + OutboundCancel: + type: object + required: [reason] + properties: { reason: { type: string, minLength: 1, maxLength: 1000 } } + 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' } } + bolLines: + type: array + description: 仅详情接口返回的BOL打印聚合行;分页接口返回空数组,避免列表查询放大 + items: { $ref: '#/components/schemas/DispatchBolLine' } + 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] } + DispatchBolLine: + type: object + properties: + containerNo: { type: string, nullable: true } + palletNo: { type: string, nullable: true } + fbaWarehouseCode: { type: string, nullable: true } + destinationWarehouseName: { type: string, nullable: true } + warehouseNo: { type: string, nullable: true, description: 来源入仓码/仓库单号 } + fbaShipmentId: { type: string, nullable: true, description: 来源冻结货件编号;多值以逗号分隔 } + poReferenceNo: { type: string, nullable: true, description: 来源冻结FBA跟踪/参考号;多值以逗号分隔 } + packageCount: { type: integer, minimum: 0 } + RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, COMPLETED, REJECTED, CANCELLED] } + RelabelMerchantPageResponse: + allOf: + - { $ref: '#/components/schemas/MerchantApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/RelabelMerchantPage' } } + RelabelMerchantPage: + type: object + properties: + page: { type: integer } + size: { type: integer } + total: { type: integer, format: int64 } + records: { type: array, items: { $ref: '#/components/schemas/RelabelSummary' } } + RelabelMerchantDetailResponse: + allOf: + - { $ref: '#/components/schemas/MerchantApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/RelabelDetail' } } + RelabelSummary: + type: object + properties: + id: { type: string } + relabelNo: { type: string } + lastlegOrderNo: { type: string, nullable: true } + customerOrderNo: { type: string } + overseasWarehouseId: { type: string } + labelDeadline: { type: string, format: date-time } + status: { $ref: '#/components/schemas/RelabelStatus' } + itemCount: { type: integer } + totalPackageCount: { type: integer } + notifiedPackageCount: { type: integer } + deliveryMethod: { type: string, nullable: true, enum: [TRUCK, PICKUP, EXPRESS] } + printShippingLabel: { type: boolean } + shippingLabelFileId: { type: string, nullable: true } + fulfillmentStatus: { type: string, nullable: true, enum: [WAIT_RELABEL, WAIT_PRE_DISPATCH, READY_FOR_OUTBOUND, OUTBOUND_CREATED, COMPLETED] } + outboundOrderNo: { type: string, nullable: true } + customerConfirmedAt: { type: string, format: date-time, nullable: true } + createdAt: { type: string, format: date-time } + RelabelDetail: + type: object + properties: + application: { $ref: '#/components/schemas/RelabelSummary' } + remark: { type: string, nullable: true } + items: { type: array, items: { $ref: '#/components/schemas/RelabelItem' } } + history: { type: array, items: { $ref: '#/components/schemas/RelabelHistory' } } + completionEvidence: { $ref: '#/components/schemas/RelabelCompletionEvidence' } + RelabelHistory: + type: object + properties: + fromStatus: { type: string, nullable: true } + toStatus: { type: string } + operatorId: { type: string } + remark: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + RelabelCompletionEvidence: + type: object + nullable: true + properties: + taskId: { type: string, nullable: true } + photoFileIds: { type: array, items: { type: string } } + completionRemark: { type: string, nullable: true } + reviewerName: { type: string, nullable: true } + reviewRemark: { type: string, nullable: true } + completedAt: { type: string, format: date-time, nullable: true } + RelabelInventoryCandidate: + type: object + properties: + inventoryBalanceId: { type: string } + inboundOrderId: { type: string } + inboundOrderNo: { type: string } + inboundCode: { type: string } + overseasWarehouseId: { type: string } + overseasWarehouseCode: { type: string } + overseasWarehouseName: { type: string } + destinationFbaWarehouseCode: { type: string, nullable: true } + destinationFbaWarehouseName: { type: string, nullable: true } + skuCode: { type: string, nullable: true } + productNameZh: { type: string } + availablePackages: { type: integer, format: int64 } + version: { type: integer, format: int64 } + positions: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryPosition' } } + RelabelInventoryPosition: + type: object + properties: + positionId: { type: string } + palletNo: { type: string, nullable: true } + batchNo: { type: string, nullable: true } + warehouseLocationCode: { type: string, nullable: true } + availablePackages: { type: integer, format: int64 } + RelabelHistoricalOrder: + type: object + properties: + id: { type: string, nullable: true } + orderNo: { type: string } + sourceOrderNo: { type: string, nullable: true } + outboundOrderNo: { type: string, nullable: true } + relabelOrderNo: { type: string, nullable: true } + orderType: { type: string, nullable: true } + status: { type: string, nullable: true } + packageCount: { type: integer, nullable: true } + createdAt: { type: string, format: date-time, nullable: true } + RelabelCustomerConfirm: + type: object + required: [idempotencyKey] + properties: + idempotencyKey: { type: string, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } + RelabelCreate: + type: object + required: [customerOrderNo, labelDeadline, contactName, contactPhone, idempotencyKey, items] + properties: + customerOrderNo: { type: string, maxLength: 128 } + overseasWarehouseId: { type: string, nullable: true, maxLength: 128, description: 统一目的海外仓;明细未填写目的仓时使用 } + labelDeadline: { type: string, format: date-time } + contactName: { type: string, maxLength: 128 } + contactPhone: { type: string, maxLength: 64 } + chargeAmount: { type: number, nullable: true, minimum: 0.01, description: 本次贴标费用(CNY);未传时不冻结或结算资金 } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, maxLength: 255 } + deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, TRUCK_PICKUP, EXPRESS_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传 } + printShippingLabel: { type: boolean, default: false, description: 仅快递派送可选,客户确认前必须已上传面单 } + items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } } + RelabelItem: + type: object + required: [productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, newSkuCode, cartonMark, fbaCartonMark] + properties: + skuCode: { type: string, nullable: true, maxLength: 128, description: 商品 SKU(选填) } + 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 } + cartonMark: { type: string, maxLength: 255, description: 客户自有箱唛(不在编辑列表展示) } + fbaCartonMark: { type: string, maxLength: 255, description: 关联 FBA 箱唛 } + destinationWarehouseId: { type: string, nullable: true, maxLength: 128, description: 该箱目的海外仓;为空时使用申请统一目的仓 } + RelabelStatusChange: + type: object + required: [status] + properties: + status: { $ref: '#/components/schemas/RelabelStatus' } + remark: { type: string, nullable: true, maxLength: 1000 } + WalletFreezeRetry: + type: object + required: [idempotencyKey] + properties: + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } + MerchantApiResponse: + type: object + required: [code, data] + properties: + code: { type: string, example: '200' } + message: { type: string, nullable: true } + data: { nullable: true } + requestId: { type: string, nullable: true } + traceId: { type: string, nullable: true } + timestamp: { type: string, format: date-time, nullable: true } + ProcurementPageResponse: + allOf: + - { $ref: '#/components/schemas/MerchantApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/ProcurementPage' } } + ProcurementDetailResponse: + allOf: + - { $ref: '#/components/schemas/MerchantApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/ProcurementDetail' } } + TruckPageResponse: + allOf: + - { $ref: '#/components/schemas/MerchantApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/TruckPage' } } + TruckDetailResponse: + allOf: + - { $ref: '#/components/schemas/MerchantApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/TruckDetail' } } + LooseboardPageResponse: + allOf: + - { $ref: '#/components/schemas/MerchantApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/LooseboardPage' } } + LooseboardDetailResponse: + allOf: + - { $ref: '#/components/schemas/MerchantApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/LooseboardDetail' } } + InboundPlanPageResponse: + allOf: + - { $ref: '#/components/schemas/MerchantApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/InboundPlanPage' } } + InboundPlanDetailResponse: + allOf: + - { $ref: '#/components/schemas/MerchantApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/InboundPlanDetail' } } + ContainerInboundPlanPreviewApiResponse: + allOf: + - { $ref: '#/components/schemas/MerchantApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/ContainerInboundPlanPreview' } } + LastlegOrderHoldRequest: + type: object + required: [held, chargeable, reason, expectedVersion] + properties: + held: { type: boolean } + chargeable: { type: boolean, description: 仅 held=true 时生效;解除暂扣后固定为 false } + reason: { type: string, minLength: 1, maxLength: 500 } + expectedVersion: { type: integer, minimum: 0 } + LastlegOrderHoldResponse: + type: object + required: [orderId, held, chargeable, version] + properties: + orderId: { type: integer, format: int64 } + held: { type: boolean } + chargeable: { type: boolean } + reason: { type: string, nullable: true } + version: { type: integer, minimum: 0 } + LastlegOrderManualNodeCode: + type: string + enum: [DOMESTIC_CUSTOMS_COMPLETED, ESTIMATED_DEPARTURE, ACTUAL_DEPARTURE, ESTIMATED_ARRIVAL, OVERSEAS_CUSTOMS_COMPLETED, ACTUAL_ARRIVAL_WAITING_UNLOAD, PICKUP_APPOINTED, UNLOADED] + LastlegOrderManualNode: + type: object + required: [nodeCode] + properties: + nodeCode: { $ref: '#/components/schemas/LastlegOrderManualNodeCode' } + nodeAt: { type: string, format: date-time, nullable: true } + LastlegOrderManualNodesRequest: + type: object + required: [expectedVersion, nodes] + properties: + expectedVersion: { type: integer, minimum: 0 } + nodes: + type: array + minItems: 8 + maxItems: 8 + description: 必须包含8个枚举节点且各出现一次,至少一个 nodeAt 非空。 + items: { $ref: '#/components/schemas/LastlegOrderManualNode' } + ManualInboundPlanRequest: + type: object + description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由服务端读取来源柜冻结的 estimatedWarehouseArrivalAt,同柜所有计划保持一致,不接受人工提交。 + required: [customerId, orderId, expectedOrderVersion, palletCount, palletType, assigned, mergeRequired, urgent] + properties: + customerId: { type: string, pattern: '^[1-9][0-9]*$' } + orderId: { type: integer, format: int64, minimum: 1 } + expectedOrderVersion: { type: integer, minimum: 0 } + palletCount: { type: integer, minimum: 1, maximum: 999 } + palletType: { type: string, minLength: 1, maxLength: 64 } + assigned: { type: boolean } + mergeRequired: { type: boolean } + urgent: { type: boolean } + remark: { type: string, nullable: true, maxLength: 1000 } + PageMetadata: + type: object + required: [page, size, total, pages] + properties: + page: { type: integer, format: int64 } + size: { type: integer, format: int64 } + total: { type: integer, format: int64 } + pages: { type: integer, format: int64 } + ProcurementPage: + allOf: + - { $ref: '#/components/schemas/PageMetadata' } + - type: object + required: [records] + properties: { records: { type: array, items: { $ref: '#/components/schemas/ProcurementSummary' } } } + ProcurementSummary: + type: object + required: [id, orderNo, customerId, purchaseCategory, productName, purchaseQuantity, inboundQuantity, outboundQuantity, inventoryQuantity, status, version, createdAt, updatedAt] + properties: + id: { type: string, pattern: '^[0-9]+$' } + orderNo: { type: string } + customerId: { type: string } + customerName: { type: string, nullable: true } + purchaseCategory: { type: string } + productName: { type: string } + productSpecification: { type: string, nullable: true } + purchaseQuantity: { type: integer, format: int64 } + budgetUnitPrice: { type: number, nullable: true } + currency: { type: string, nullable: true } + purchaseWarehouseId: { type: string, nullable: true } + purchaseWarehouseName: { type: string, nullable: true } + inboundQuantity: { type: integer, format: int64 } + outboundQuantity: { type: integer, format: int64 } + inventoryQuantity: { type: integer, format: int64 } + status: { type: string, description: PENDING_REVIEW/APPROVED/REJECTED 或按审核后出库数量派生的 PENDING_OUTBOUND/PARTIALLY_OUTBOUND/OUTBOUNDED } + version: { type: integer, format: int64 } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + ProcurementDetail: + type: object + required: [summary, histories, outboundDetails] + properties: + summary: { $ref: '#/components/schemas/ProcurementSummary' } + productUrl: { type: string, nullable: true } + receivingAddress: { type: string, nullable: true } + contactName: { type: string, nullable: true } + contactPhone: { type: string, nullable: true } + domesticCarrier: { type: string, nullable: true } + domesticTrackingNo: { type: string, nullable: true } + overseasCarrier: { type: string, nullable: true } + overseasTrackingNo: { type: string, nullable: true } + estimatedArrivalAt: { type: string, format: date-time, nullable: true } + remark: { type: string, nullable: true } + createdBy: { type: string, nullable: true } + reviewedBy: { type: string, nullable: true } + reviewRemark: { type: string, nullable: true } + reviewedAt: { type: string, format: date-time, nullable: true } + histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } } + outboundDetails: { type: array, items: { $ref: '#/components/schemas/ProcurementOutboundDetail' } } + ProcurementOutboundDetail: + type: object + properties: + id: { type: string } + quantity: { type: integer, format: int64 } + carrier: { type: string, nullable: true } + trackingNo: { type: string, nullable: true } + shippedAt: { type: string, format: date-time, nullable: true } + remark: { type: string, nullable: true } + TruckPage: + allOf: + - { $ref: '#/components/schemas/PageMetadata' } + - type: object + required: [records] + properties: { records: { type: array, items: { $ref: '#/components/schemas/TruckSummary' } } } + TruckSummary: + type: object + required: [id, customerId, orderNo, origin, destination, palletCount, packageCount, receivedPalletCount, allocatedPalletCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt] + properties: + id: { type: string, pattern: '^[0-9]+$' } + customerId: { type: string } + orderNo: { type: string } + transportType: { type: string, nullable: true } + origin: { type: string } + destination: { type: string } + containerType: { type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型 } + palletCount: { type: integer } + packageCount: { type: integer } + receivedPalletCount: { type: integer } + allocatedPalletCount: { type: integer } + totalWeightKg: { type: number } + totalVolumeCbm: { type: number } + budgetAmount: { type: number, nullable: true } + currency: { type: string, nullable: true } + contactName: { type: string, nullable: true } + contactInfo: { type: string, nullable: true } + requestedDate: { type: string, format: date, nullable: true } + status: { $ref: '#/components/schemas/TruckStatus' } + version: { type: integer, format: int64 } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + TruckDetail: + type: object + required: [summary, pallets, histories] + properties: + summary: { $ref: '#/components/schemas/TruckSummary' } + originAddress: { type: string, nullable: true } + destinationAddress: { type: string, nullable: true } + remark: { type: string, nullable: true } + createdBy: { type: string, nullable: true } + pallets: { type: array, items: { $ref: '#/components/schemas/TruckPallet' } } + histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } } + TruckPallet: + type: object + properties: + id: { type: string, pattern: '^[0-9]+$' } + palletNo: { type: string } + sequenceNo: { type: integer } + source: { type: string } + status: { type: string } + warehousePalletId: { type: string, pattern: '^[0-9]+$', nullable: true } + packageCount: { type: integer, nullable: true } + receivedAt: { type: string, format: date-time, nullable: true } + LooseboardPage: + allOf: + - { $ref: '#/components/schemas/PageMetadata' } + - type: object + required: [records] + properties: { records: { type: array, items: { $ref: '#/components/schemas/LooseboardSummary' } } } + LooseboardSummary: + type: object + required: [id, customerId, orderNo, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt] + properties: + id: { type: string, pattern: '^[0-9]+$' } + customerId: { type: string } + orderNo: { type: string } + transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } + palletRequired: { type: string, enum: [YES, NO] } + transportType: { type: string } + origin: { type: string } + destination: { type: string } + containerType: { type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型 } + packageCount: { type: integer } + totalWeightKg: { type: number } + totalVolumeCbm: { type: number } + budgetAmount: { type: number, nullable: true } + currency: { type: string, nullable: true } + contactName: { type: string, nullable: true } + contactInfo: { type: string, nullable: true } + requestedDate: { type: string, format: date, nullable: true } + status: { $ref: '#/components/schemas/LooseboardStatus' } + version: { type: integer, format: int64 } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + LooseboardDetail: + type: object + required: [summary, histories] + properties: + summary: { $ref: '#/components/schemas/LooseboardSummary' } + originAddress: { type: string, nullable: true } + destinationAddress: { type: string, nullable: true } + remark: { type: string, nullable: true } + createdBy: { type: string, nullable: true } + histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } } + BusinessHistory: + type: object + properties: + operationType: { type: string } + fromStatus: { type: string, nullable: true } + toStatus: { type: string, nullable: true } + operatorId: { type: string, nullable: true } + idempotencyKey: { type: string, nullable: true } + changeDetail: { type: string, nullable: true } + remark: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + InboundPlanPage: + allOf: + - { $ref: '#/components/schemas/PageMetadata' } + - type: object + required: [records] + properties: { records: { type: array, items: { $ref: '#/components/schemas/InboundPlanSummary' } } } + InboundPlanSummary: + type: object + required: [lineId, planId, planNo, customerId, orderId, inboundCode, overseasWarehouseCode, overseasWarehouseName, packageCount, pickedPackageCount, palletCount, assigned, mergeAllowed, urgent, status, createdAt, version] + properties: + lineId: { type: string, pattern: '^[0-9]+$' } + planId: { type: string, pattern: '^[0-9]+$' } + planNo: { type: string } + customerId: { type: string } + orderId: { type: string, pattern: '^[0-9]+$' } + inboundCode: { type: string } + orderType: { type: string, nullable: true } + overseasWarehouseCode: { type: string, description: 接柜仓编码,取自尾程订单预分配接收仓事实 } + overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 } + warehouseId: { type: string, nullable: true } + warehouseCode: { type: string, nullable: true } + warehouseName: { type: string, nullable: true } + fbaWarehouseCode: { type: string, nullable: true } + customerName: { type: string, nullable: true } + packageCount: { type: integer } + pickedPackageCount: { type: integer } + volumeCbm: { type: number, nullable: true } + palletCount: { type: integer } + assigned: { type: boolean, description: 入库计划是否已分配 } + mergeAllowed: { type: boolean, description: 入库计划是否允许现场选择合托;不作为强制门禁 } + urgent: { type: boolean, description: 入库计划是否加急 } + status: { type: string } + appointmentNo: { type: string, nullable: true } + appointmentStatus: { type: string, nullable: true } + appointmentAt: { type: string, format: date-time, nullable: true } + expectedLoadingAt: { type: string, format: date-time, nullable: true } + dispatchStatus: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + version: { type: integer } + InboundPlanDetail: + type: object + required: [id, planNo, customerId, overseasWarehouseCode, overseasWarehouseName, status, lineCount, palletCount, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version, createdAt, updatedAt] + properties: + id: { type: string, pattern: '^[0-9]+$' } + planNo: { type: string } + customerId: { type: string } + sourceOrderId: { type: string, pattern: '^[0-9]+$', nullable: true } + sourceOrderNo: { type: string, nullable: true, description: 关联尾程订单业务单号;手工入库计划为空 } + customerName: { type: string, nullable: true, description: 货主名称;手工入库计划为空 } + orderType: { type: string, nullable: true, description: 关联尾程订单类型;手工入库计划为空 } + containerNo: { type: string, nullable: true } + overseasWarehouseCode: { type: string, description: 接柜仓编码,取自尾程订单预分配接收仓事实 } + overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 } + status: { type: string } + lineCount: { type: integer } + palletCount: { type: integer } + totalVolumeCbm: { type: number } + expectedPackageCount: { type: integer } + pickedPackageCount: { type: integer } + plannedArrivalDate: { type: string, format: date, nullable: true } + palletType: { type: string, nullable: true } + assigned: { type: boolean } + mergeRequired: { type: boolean } + urgent: { type: boolean } + creationMode: { type: string, enum: [AUTO, MANUAL] } + remark: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + lines: { type: array, items: { $ref: '#/components/schemas/InboundPlanLine' } } + pallets: { type: array, items: { $ref: '#/components/schemas/InboundPlanPallet' } } + allocations: { type: array, items: { $ref: '#/components/schemas/InboundPlanAllocation' } } + version: { type: integer } + ContainerInboundPlanPreview: + type: object + required: [sourceContainerBookingId, snapshotVersion, snapshotHash, totalOrderCount, readyOrderCount, blockedOrderCount, pendingOrderCount, pendingPalletCount, sourcePalletCount, supplementPalletCount, existingPlannedPalletCount, totalSuggestedPalletCount, totalPlannedPalletCount, ready, groups] + properties: + sourceContainerBookingId: { type: string } + containerNo: { type: string, nullable: true } + overseasWarehouseId: { type: string, nullable: true } + overseasWarehouseCode: { type: string, nullable: true } + overseasWarehouseName: { type: string, nullable: true } + snapshotVersion: { type: integer, format: int64, minimum: 0 } + snapshotHash: { type: string, pattern: '^[0-9a-f]{64}$' } + totalOrderCount: { type: integer } + readyOrderCount: { type: integer } + blockedOrderCount: { type: integer } + pendingOrderCount: { type: integer, description: 本次确认将新建计划的 READY 订单数 } + pendingPalletCount: { type: integer, description: 本次确认最终向资源服务批量预留的托盘总数,包含头程已有编码及海外补充编码 } + sourcePalletCount: { type: integer, description: 本次处理订单随头程装柜完成事件冻结并传入的托盘码数 } + supplementPalletCount: { type: integer, description: 最终托数超过头程已有托盘码数时,在海外仓连续补充的托盘码数 } + existingPlannedPalletCount: { type: integer, description: 已有有效计划的托盘总数,不计入本次新增 } + totalSuggestedPalletCount: { type: integer } + totalPlannedPalletCount: { type: integer } + ready: { type: boolean } + groups: { type: array, items: { $ref: '#/components/schemas/ContainerInboundPlanPreviewGroup' } } + ContainerInboundPlanPreviewGroup: + type: object + required: [destinationType, orderCount, pendingOrderCount, pendingPalletCount, sourcePalletCount, supplementPalletCount, existingPlannedPalletCount, suggestedPalletCount, plannedPalletCount, orders] + properties: + destinationType: { type: string, enum: [FBA, ONE_CLICK_DROPSHIP, PRIVATE_ADDRESS] } + destinationWarehouseId: { type: string, nullable: true } + destinationWarehouseCode: { type: string, nullable: true } + destinationWarehouseName: { type: string, nullable: true } + orderCount: { type: integer } + pendingOrderCount: { type: integer } + pendingPalletCount: { type: integer } + sourcePalletCount: { type: integer } + supplementPalletCount: { type: integer } + existingPlannedPalletCount: { type: integer } + suggestedPalletCount: { type: integer } + plannedPalletCount: { type: integer } + orders: { type: array, items: { $ref: '#/components/schemas/ContainerInboundPlanPreviewOrder' } } + ContainerInboundPlanPreviewOrder: + type: object + required: [customerId, orderId, businessOrderNo, orderType, destinationType, packageCount, volumeCbm, sourcePalletCount, targetPalletCount, supplementPalletCount, orderVersion, readiness, blockers] + properties: + customerId: { type: string } + customerName: { type: string, nullable: true } + orderId: { type: string, pattern: '^[0-9]+$' } + businessOrderNo: { type: string } + inboundCode: { type: string, nullable: true } + orderType: { type: string } + destinationType: { type: string, enum: [FBA, ONE_CLICK_DROPSHIP, PRIVATE_ADDRESS] } + destinationWarehouseId: { type: string, nullable: true } + destinationWarehouseCode: { type: string, nullable: true } + destinationWarehouseName: { type: string, nullable: true } + fbaWarehouseCode: { type: string, nullable: true, description: 底层目的类型为 FBA 时的目的仓编码;一件代发展示分类不改变该事实 } + packageCount: { type: integer } + volumeCbm: { type: number } + suggestedPalletCount: { type: integer, nullable: true } + sourcePalletCount: { type: integer, description: 头程已经冻结并传入的托盘码数 } + targetPalletCount: { type: integer, description: 本票最终生成的托盘总数,不少于头程已有托盘码数 } + supplementPalletCount: { type: integer, description: 本票需要在海外仓连续补充的托盘码数 } + plannedPalletCount: { type: integer, nullable: true } + existingPlanNo: { type: string, nullable: true } + planStatus: { type: string, nullable: true } + orderVersion: { type: integer, minimum: 0 } + readiness: { type: string, enum: [READY, RESOURCE_PENDING, PLANNED, BLOCKED] } + blockers: { type: array, items: { type: string } } + InboundPlanLine: + type: object + properties: + id: { type: string, pattern: '^[0-9]+$' } + orderId: { type: string, pattern: '^[0-9]+$' } + inboundCode: { type: string } + fbaWarehouseCode: { type: string, nullable: true } + destinationWarehouseId: { type: string, nullable: true } + destinationWarehouseCode: { type: string, nullable: true } + destinationWarehouseName: { type: string, nullable: true } + volumeCbm: { type: number } + requiredPackageCount: { type: integer } + plannedPalletCount: { type: integer } + InboundPlanPallet: + type: object + properties: + id: { type: string, pattern: '^[0-9]+$' } + palletNo: { type: string } + palletType: { type: string, nullable: true } + destinationWarehouseId: { type: string, nullable: true } + destinationWarehouseCode: { type: string, nullable: true } + destinationWarehouseName: { type: string, nullable: true } + capacityCbm: { type: number } + allocatedVolumeCbm: { type: number } + sequenceNo: { type: integer } + warehousePalletId: { type: string, pattern: '^[0-9]+$', nullable: true } + warehouseLocationId: { type: string, pattern: '^[0-9]+$', nullable: true } + warehouseResourceLockId: { type: string, pattern: '^[0-9]+$', nullable: true } + assignmentStatus: { type: string, nullable: true } + lockedAt: { type: string, format: date-time, nullable: true } + actualVolumeCbm: { type: number, nullable: true } + actualPackageCount: { type: integer } + InboundPlanAllocation: + type: object + properties: + lineId: { type: string, pattern: '^[0-9]+$' } + palletId: { type: string, pattern: '^[0-9]+$' } + palletNo: { type: string, description: 分配实际引用的托盘码,跨计划合托时仍返回真实托盘码 } + palletOwnerPlanNo: { type: string, description: 托盘所属入库计划号 } + palletOwnedByCurrentPlan: { type: boolean, description: 托盘是否归属当前详情入库计划 } + allocatedVolumeCbm: { type: number } + plannedPackageCount: { type: integer } + countedPackageCount: { type: integer } + pickedPackageCount: { type: integer } + actualVolumeCbm: { type: number, nullable: true } + status: { type: string } + version: { type: integer } + 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' } + outboundDetails: + type: array + description: 出库明细,可一次提交多条;总出库量不得超过入库量 + items: + type: object + required: [quantity] + properties: + id: { type: string } + quantity: { type: integer, format: int64, minimum: 0 } + carrier: { type: string, nullable: true, maxLength: 128 } + trackingNo: { type: string, nullable: true, maxLength: 128 } + shippedAt: { type: string, format: date-time, nullable: true } + remark: { type: string, nullable: true, maxLength: 500 } + 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, enum: [40GP, 40HQ, 45HQ], default: 40HQ, 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, enum: [40GP, 40HQ, 45HQ], 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, enum: [40GP, 40HQ, 45HQ], default: 40HQ, 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, enum: [40GP, 40HQ, 45HQ], 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-finance-fees-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml new file mode 100644 index 000000000..0e511069e --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml @@ -0,0 +1,95 @@ +openapi: 3.0.3 +info: { title: Overseas Business Fee Source API, version: 1.0.0 } +paths: + /api/platform/v1/last-leg/finance-fee-sources: + get: + summary: 查询海外业务费用来源当前版本 + security: [{ bearerAuth: [] }] + parameters: + - { in: query, name: customerId, schema: { type: string } } + - { in: query, name: businessType, schema: { $ref: '#/components/schemas/BusinessType' } } + - { in: query, name: businessNo, schema: { type: string } } + - { in: query, name: containerBookingId, description: 精确查询真实来源柜下全部客户的费用;可再传customerId收窄到单个客户, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + responses: { '200': { description: 当前版本列表 } } + post: + summary: 创建海外业务费用来源草稿 + description: 客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。海外货柜费用必须以相同的businessId和containerBookingId引用真实来源柜,且businessNo与containerNo均为该柜真实集装箱号。 + security: [{ bearerAuth: [] }] + parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 128 } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SaveRequest' } } } } + responses: { '200': { description: 草稿费用来源 }, '400': { description: 业务归属或付款事实无效 } } + /api/platform/v1/last-leg/finance-fee-sources/container-customers: + get: + summary: 查询真实来源柜内客户选项 + description: 单次按真实来源柜聚合全部客户,供柜详情维护费用时选择,不逐票查询;货柜不存在或尚无尾程订单时返回空列表。 + security: [{ bearerAuth: [] }] + parameters: + - { in: query, name: containerBookingId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + responses: + '200': + description: 柜内客户选项;无数据时data为空数组 + content: + application/json: + schema: { $ref: '#/components/schemas/ContainerCustomerListResponse' } + /api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/{action}: + post: + summary: 提交、审核、调整或作废海外业务费用来源 + description: 审核通过与作废均事务性写入Outbox,经Event Bridge可靠投递至独立Finance;action为submit、review、adjust、void。 + security: [{ bearerAuth: [] }] + parameters: + - { in: path, name: sourceFeeId, required: true, schema: { type: string } } + - { in: path, name: action, required: true, schema: { type: string, enum: [submit, review, adjust, void] } } + responses: { '200': { description: 当前费用来源版本 }, '409': { description: 状态、版本或幂等冲突 } } +components: + schemas: + BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, PROCUREMENT_FEE, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER, LASTLEG_ORDER, DELIVERY_ORDER, RELABEL_ORDER] } + OverseasContainerFeeItem: + type: string + description: 海外阶段柜级费用项目:港口费、堆存费、修箱费、吊机费、码头拖车费、其他。 + enum: [PORT_FEE, STORAGE_FEE, CONTAINER_REPAIR_FEE, CRANE_FEE, TERMINAL_DRAYAGE_FEE, OTHER] + ContainerCustomerOption: + type: object + required: [customerId] + properties: + customerId: { type: string } + customerName: { type: string, nullable: true } + ContainerCustomerListResponse: + type: object + required: [data] + properties: + code: { type: string } + message: { type: string } + data: + type: array + items: { $ref: '#/components/schemas/ContainerCustomerOption' } + requestId: { type: string, nullable: true } + traceId: { type: string, nullable: true } + timestamp: { type: string, format: date-time } + SaveRequest: + type: object + required: [customerId, sourceType, businessType, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount] + properties: + customerId: { type: string } + sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_PROCUREMENT_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE, OVERSEAS_CONTAINER_FEE, OVERSEAS_ORDER_FEE, OVERSEAS_DELIVERY_FEE, OVERSEAS_RELABEL_FEE] } + businessType: { $ref: '#/components/schemas/BusinessType' } + businessId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true } + businessNo: { type: string, maxLength: 128 } + containerBookingId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true } + containerNo: { type: string, maxLength: 64, nullable: true } + warehouseId: { type: string, nullable: true } + inboundId: { type: string, nullable: true } + inboundCode: { type: string, maxLength: 64, nullable: true } + feeItemCode: + type: string + description: OVERSEAS_CONTAINER时必须取OverseasContainerFeeItem枚举;其他业务类型沿用各自费用项目编码。 + feeItemName: { type: string } + direction: { type: string, enum: [RECEIVABLE, PAYABLE] } + businessDate: { type: string, format: date } + currency: { type: string, enum: [CNY, USD], description: 业务原币;USD由服务端按业务月份有效汇率折算为CNY入池 } + amount: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true, description: 业务原币金额 } + chargeableQuantity: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true, nullable: true } + quantityUnit: { type: string, maxLength: 32, nullable: true } + unitPrice: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true, nullable: true } + periodStart: { type: string, format: date, nullable: true } + periodEnd: { type: string, format: date, nullable: true } + 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..bb904ef34 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -0,0 +1,804 @@ +openapi: 3.0.3 +info: + title: Lastleg Overseas PDA Warehouse Operations API + version: 1.1.0 + description: 海外仓PDA排车拣货发车、一件代发出库、盘点、同仓整托移库、托盘与换标作业;全部范围从PDA Token解析,派送不进入PDA。 +security: + - bearerAuth: [] +paths: + /api/pda/v1/last-leg/dispatches: + get: + summary: 查询Token绑定仓的排车PDA任务 + description: 仅 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate;跨客户读取绑定仓任务,不接收customerId或warehouseId。 + parameters: [{ name: status, in: query, required: false, schema: { type: string } }] + responses: { '200': { description: 排车任务列表 }, '403': { description: 角色、权限或仓库绑定缺失 } } + /api/pda/v1/last-leg/dispatches/{dispatchNo}: + get: + summary: 查询绑定仓排车任务详情 + description: 返回 canDepart 与 departBlockReason;PDA 必须以服务端门禁决定是否开放发车,不得仅按本地拣货数量推断。 + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 排车托盘、目的仓与库存位置详情 }, '404': { description: 当前绑定仓无此排车任务 } } + /api/pda/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}/pick: + post: + summary: 扫码拣取排车托盘包裹 + parameters: + - { name: dispatchNo, in: path, required: true, schema: { type: string } } + - { name: itemId, in: path, required: true, schema: { type: string, pattern: '^[0-9]+$' } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchPick' } } } } + responses: { '200': { description: 本次及累计拣货结果;同键同请求重放既有结果 }, '409': { description: 跨仓、跨客户、库存、数量或幂等冲突 } } + /api/pda/v1/last-leg/dispatches/{dispatchNo}/complete: + post: + summary: 完成排车拣货 + description: 每个要求托盘及全部包裹必须精确完成;不允许部分完成。 + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } + responses: { '200': { description: READY_TO_DEPART结果 }, '409': { description: 未全部拣货或幂等冲突 } } + /api/pda/v1/last-leg/dispatches/{dispatchNo}/depart: + post: + summary: 排车发车并完成出库门禁 + description: 仅完整拣货且存在待出库单时发车;同事务推进出库与排车状态、订单归属并释放托盘锁,不重复扣减扫码时已扣库存。 + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DepartAction' } } } } + responses: { '200': { description: DEPARTED结果;同键同请求返回既有结果 }, '409': { description: 拣货、出库或幂等门禁未满足 } } + /api/pda/v1/last-leg/outbound-tasks: + get: + summary: 查询Token绑定仓的一件代发出库任务 + description: 一件代发复用统一出库任务模型;不接收customerId或warehouseId,派送不进入PDA。 + parameters: [{ name: status, in: query, required: false, schema: { type: string } }] + responses: { '200': { description: 出库任务列表 } } + /api/pda/v1/last-leg/outbound-tasks/resolve/{businessNo}: + get: + summary: 解析出库作业扫码业务号 + description: 接受一件代发任务号、出库单号或排车单号;按Token绑定仓返回 OUTBOUND 的 taskNo 或 DISPATCH 的 dispatchNo,PDA据此进入正确作业页。 + parameters: [{ name: businessNo, in: path, required: true, schema: { type: string } }] + responses: + '200': { description: 正确作业类型及稳定业务编号 } + '404': { description: 当前绑定仓无此出库或排车任务 } + /api/pda/v1/last-leg/outbound-tasks/{taskNo}: + get: + summary: 查询一件代发出库任务详情 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + responses: + '200': + description: 出库任务、出库项及每条可拣库存位置;inventoryPositionId仅作为pick提交键,不作为用户展示编号 + content: { application/json: { schema: { $ref: '#/components/schemas/PdaOutboundTaskDetail' } } } + '404': { description: 当前绑定仓无此任务 } + /api/pda/v1/last-leg/outbound-tasks/{taskNo}/pick: + post: + summary: 扫码拣取一件代发箱件 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OutboundPick' } } } } + responses: { '200': { description: 更新后的统一出库任务 }, '409': { description: 库存、数量或幂等冲突 } } + /api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick: + post: + summary: 扫描一件代发托盘并原子拣取整托剩余箱件 + description: 仅适用于一件代发出库;按托盘汇总本任务内全部 SKU 和未拣箱唛,任一库存校验失败则整托回滚。 + parameters: + - { name: taskNo, in: path, required: true, schema: { type: string } } + - { name: palletNo, in: path, required: true, schema: { type: string } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } + responses: { '200': { description: 整托拣货后的任务 }, '404': { description: 当前任务没有该托盘 }, '409': { description: 非一件代发、库存不足或幂等冲突 } } + /api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete: + post: + summary: 完成一件代发出库任务 + description: 全部托盘及箱件精确拣完后完成;同一完成幂等键返回既有结果,其他键明确冲突。 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } + responses: { '200': { description: COMPLETED任务 }, '409': { description: 未全部拣货或幂等冲突 } } + /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: 盘点任务分页, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeSummaryPageResponse' } } } } + post: + summary: 按库位或托盘创建盘点任务 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCreate' } } } } + responses: + '200': { description: 创建后的完整盘点任务, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeDetailResponse' } } } } + /api/pda/v1/last-leg/stocktakes/{taskNo}: + get: + summary: 查询盘点任务详情 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + responses: + '200': { description: 盘点任务及冻结库存位置, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeDetailResponse' } } } } + /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: 已计数明细, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeItemResponse' } } } } + '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: 已提交盘点任务, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeDetailResponse' } } } } + '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: 移库结果, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMoveResponse' } } } } + '404': { description: 来源托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } } + '409': { description: 跨仓、资源或库存版本冲突 } + /api/pda/v1/last-leg/inventory/resolve: + post: + summary: 扫码查询Token绑定仓库存 + description: 支持箱唛、SKU、入仓码、托盘码或库位码;不接收customerId或warehouseId。 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } } + responses: + '200': { description: 匹配的库存位置列表, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryScanResponse' } } } } + /api/pda/v1/last-leg/locations/resolve: + post: + summary: 扫码查询Token绑定仓库位 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } } + responses: + '200': { description: 库位及占用摘要, content: { application/json: { schema: { $ref: '#/components/schemas/LocationResponse' } } } } + /api/pda/v1/last-leg/pallets/resolve: + post: + summary: 扫码查询Token绑定仓托盘 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } } + responses: + '200': { description: 托盘及其库存内容, content: { application/json: { schema: { $ref: '#/components/schemas/PalletResponse' } } } } + '404': { description: 托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } } + /api/pda/v1/last-leg/operations: + get: + summary: 查询当前操作人的仓内操作历史 + 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, required: false, schema: { type: string } } + responses: + '200': { description: 当前Token绑定仓及操作人的历史分页, content: { application/json: { schema: { $ref: '#/components/schemas/OperationPageResponse' } } } } + /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绑定仓托盘内容 }, '404': { description: 托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } } } + /api/pda/v1/last-leg/pallet-operations/relabel-pallet-label: + post: + summary: 为换标拆托幂等生成并返回可打印的新托盘标签 + description: 返回空托盘编码、初始版本和打印HTML;随后可将该托盘作为relabel-disassemble的target。库存移入后仍继承原库存余额对应的客户和入库单。 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/NewRelabelPallet' } } } } + responses: { '200': { description: 新托盘及可打印标签 }, '409': { 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/relabel-disassemble: + post: + summary: 按换标箱唛拆托并绑定目标托盘 + description: PDA先扫描原托盘,再扫描换标单指定箱唛;服务端按换标单库存快照解析仓位,在同一事务中扣减原托盘并绑定目标托盘。目标托盘须先由仓储服务创建后扫码确认。 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPalletDisassemble' } } } } + 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: 待认领、处理中或待复核换标任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCandidatesResponse' } } } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}: + get: + summary: 查询换标现场任务详情 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + responses: + '200': { description: 申请明细、逐件事实、照片与历史, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } } + /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: 已认领任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 仓库、状态或幂等冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units: + post: + summary: 扫描我方箱唛并登记整箱正常换标事实;可只传oldLabel由服务端定位箱唛任务 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } } + responses: { '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '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: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } } } + /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: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } } } + /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文件元数据, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPhotoResponse' } } } } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files/{fileId}/download: + get: + summary: 读取当前操作人处理中任务的已上传照片,用于缩略图和大图预览 + description: 校验 Token 绑定仓、任务认领人和文件的客户及任务归属;移除和重拍只替换待提交文件引用,不删除历史文件。 + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } } + responses: + '200': { description: 作业照片内容, content: { 'image/*': { schema: { type: string, format: binary } } } } + '404': { description: 照片不属于当前任务或仓库 } + '409': { description: 当前操作人未认领任务或任务不在处理中 } + /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任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 箱数未核平、照片或版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review: + post: + summary: 已停用;客户确认完成复核,退回重贴由平台执行 + deprecated: true + 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任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 状态或版本冲突 } } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + schemas: + ApiResponse: + type: object + required: [code, data] + properties: + code: { type: string, example: '200' } + message: { type: string, nullable: true } + data: { nullable: true } + requestId: { type: string, nullable: true } + traceId: { type: string, nullable: true } + timestamp: { type: string, format: date-time, nullable: true } + StocktakeSummaryPageResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/StocktakeSummaryPage' } } + StocktakeDetailResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/StocktakeDetail' } } + StocktakeItemResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/StocktakeItem' } } + InventoryMoveResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/InventoryMoveResult' } } + InventoryScanResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/InventoryScan' } } + LocationResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/WarehouseLocation' } } + PalletResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/WarehousePallet' } } + OperationPageResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/OperationPage' } } + RelabelCandidatesResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/RelabelCandidate' } } + RelabelDetailResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/RelabelDetail' } } + RelabelPhotoResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/RelabelPhoto' } } + StocktakeSummaryPage: + type: object + required: [records, page, size, total, pages] + properties: + records: { type: array, items: { $ref: '#/components/schemas/StocktakeSummary' } } + page: { type: integer, format: int64 } + size: { type: integer, format: int64 } + total: { type: integer, format: int64 } + pages: { type: integer, format: int64 } + StocktakeSummary: + type: object + required: [taskNo, scopeType, scopeCode, status, positionCount, countedCount, differenceCount, version, createdAt] + properties: + taskNo: { type: string } + scopeType: { type: string, enum: [LOCATION, PALLET] } + scopeCode: { type: string } + status: { type: string, enum: [IN_PROGRESS, SUBMITTED] } + positionCount: { type: integer } + countedCount: { type: integer } + differenceCount: { type: integer } + version: { type: integer, format: int64 } + createdAt: { type: string, format: date-time } + submittedAt: { type: string, format: date-time, nullable: true } + StocktakeItem: + type: object + required: [itemId, customerId, balanceId, positionId, skuCode, inboundCode, palletId, palletCode, locationId, locationCode, bookPackages, status, positionVersion, version] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + customerId: { type: string } + balanceId: { type: string, pattern: '^[0-9]+$' } + positionId: { type: string, pattern: '^[0-9]+$' } + skuCode: { type: string } + productName: { type: string, nullable: true } + inboundCode: { type: string } + palletId: { type: string, pattern: '^[0-9]+$' } + palletCode: { type: string } + locationId: { type: string, pattern: '^[0-9]+$' } + locationCode: { type: string } + bookPackages: { type: integer, format: int64 } + actualPackages: { type: integer, format: int64, nullable: true } + differencePackages: { type: integer, format: int64, nullable: true } + status: { type: string } + positionVersion: { type: integer, format: int64 } + version: { type: integer, format: int64 } + StocktakeDetail: + type: object + required: [task, items] + properties: + task: { $ref: '#/components/schemas/StocktakeSummary' } + items: { type: array, items: { $ref: '#/components/schemas/StocktakeItem' } } + InventoryMoveResult: + type: object + required: [moveNo, palletId, palletCode, sourceLocationId, sourceLocationCode, targetLocationId, targetLocationCode, customerCount, positionCount, packageCount, palletVersion, status, completedAt, replayed] + properties: + moveNo: { type: string } + palletId: { type: string, pattern: '^[0-9]+$' } + palletCode: { type: string } + sourceLocationId: { type: string, pattern: '^[0-9]+$' } + sourceLocationCode: { type: string } + targetLocationId: { type: string, pattern: '^[0-9]+$' } + targetLocationCode: { type: string } + customerCount: { type: integer } + positionCount: { type: integer } + packageCount: { type: integer, format: int64 } + palletVersion: { type: integer } + status: { type: string } + completedAt: { type: string, format: date-time } + replayed: { type: boolean } + InventoryScan: + type: object + required: [queryCode, items] + properties: + queryCode: { type: string } + items: { type: array, items: { $ref: '#/components/schemas/InventoryItem' } } + InventoryItem: + type: object + required: [customerId, balanceId, positionId, skuCode, inboundCode, palletId, palletCode, availablePackages, holdPackages, receivingPackages, status, version] + properties: + customerId: { type: string } + customerName: { type: string, nullable: true } + balanceId: { type: string, pattern: '^[0-9]+$' } + positionId: { type: string, pattern: '^[0-9]+$' } + skuCode: { type: string } + productName: { type: string, nullable: true } + inboundCode: { type: string } + cartonMark: { type: string, nullable: true } + palletId: { type: string, pattern: '^[0-9]+$' } + palletCode: { type: string } + locationId: { type: string, pattern: '^[0-9]+$', nullable: true } + locationCode: { type: string, nullable: true } + availablePackages: { type: integer, format: int64 } + holdPackages: { type: integer, format: int64 } + receivingPackages: { type: integer, format: int64 } + status: { type: string } + version: { type: integer, format: int64 } + WarehouseLocation: + type: object + required: [locationId, locationCode, locationType, status, occupiedPackages, inventoryPositionCount] + properties: + locationId: { type: string, pattern: '^[0-9]+$' } + locationCode: { type: string } + locationType: { type: string } + maxLoadKg: { type: number, nullable: true } + status: { type: string } + occupiedPackages: { type: integer, format: int64 } + inventoryPositionCount: { type: integer } + WarehousePallet: + type: object + required: [palletId, palletCode, status, version, occupiedPackages, inventoryPositionCount, inventory] + properties: + palletId: { type: string, pattern: '^[0-9]+$' } + palletCode: { type: string } + status: { type: string } + version: { type: integer } + locationId: { type: string, pattern: '^[0-9]+$', nullable: true } + locationCode: { type: string, nullable: true } + occupiedPackages: { type: integer, format: int64 } + inventoryPositionCount: { type: integer } + inventory: { type: array, items: { $ref: '#/components/schemas/InventoryItem' } } + OperationPage: + type: object + required: [records, page, size, total, pages] + properties: + records: { type: array, items: { $ref: '#/components/schemas/WarehouseOperation' } } + page: { type: integer, format: int64 } + size: { type: integer, format: int64 } + total: { type: integer, format: int64 } + pages: { type: integer, format: int64 } + WarehouseOperation: + type: object + required: [operationId, operationType, businessKey, status, occurredAt] + properties: + operationId: { type: string, pattern: '^[0-9]+$' } + 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 } + RelabelCandidate: + type: object + required: [relabelNo, applicationStatus, taskStatus, operationType, itemCount, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, claimedByCurrentOperator] + properties: + relabelNo: { type: string } + customerOrderNo: { type: string, nullable: true } + labelDeadline: { type: string, format: date-time, nullable: true } + applicationStatus: { type: string } + taskStatus: { type: string } + operationType: { type: string, enum: [SKU_LABEL, CARTON_LABEL, CARTON_AND_SKU] } + itemCount: { type: integer } + expectedPackages: { type: integer } + relabelledPackages: { type: integer } + skippedPackages: { type: integer } + exceptionPackages: { type: integer } + version: { type: integer } + claimedByCurrentOperator: { type: boolean } + RelabelDetail: + type: object + required: [relabelNo, applicationStatus, operationType, taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, completionPhotoFileIds, items, unitFacts, history] + properties: + relabelNo: { type: string } + customerOrderNo: { type: string, nullable: true } + labelDeadline: { type: string, format: date-time, nullable: true } + contactName: { type: string, nullable: true } + contactPhone: { type: string, nullable: true } + applicationStatus: { type: string } + operationType: { type: string, enum: [SKU_LABEL, CARTON_LABEL, CARTON_AND_SKU] } + taskStatus: { type: string } + expectedPackages: { type: integer } + relabelledPackages: { type: integer } + skippedPackages: { type: integer } + exceptionPackages: { type: integer } + version: { type: integer } + operatorName: { type: string, nullable: true } + reviewerName: { type: string, nullable: true } + completionPhotoFileIds: { type: array, items: { type: string, pattern: '^[0-9]+$' } } + completionRemark: { type: string, nullable: true } + reviewDecision: { type: string, nullable: true } + reviewRemark: { type: string, nullable: true } + claimedAt: { type: string, format: date-time, nullable: true } + submittedAt: { type: string, format: date-time, nullable: true } + reviewedAt: { type: string, format: date-time, nullable: true } + items: { type: array, items: { $ref: '#/components/schemas/RelabelItem' } } + unitFacts: { type: array, items: { $ref: '#/components/schemas/RelabelUnitFact' } } + history: { type: array, items: { $ref: '#/components/schemas/RelabelHistory' } } + RelabelItem: + type: object + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + skuCode: { type: string, nullable: true } + productNameZh: { type: string, nullable: true } + shipmentNo: { type: string, nullable: true } + trackingCode: { type: string, nullable: true } + newSkuCode: { type: string, nullable: true } + labelRequirement: { type: string, nullable: true } + relabelFileId: { type: string, nullable: true } + relabelFileName: { type: string, nullable: true } + relabelFileUrl: { type: string, nullable: true } + cartonMark: { type: string, nullable: true, description: 我方箱唛 } + fbaCartonMark: { type: string, nullable: true, description: 新FBA箱唛 } + operationType: { type: string, enum: [CARTON_LABEL, SKU_LABEL, CARTON_AND_SKU] } + skuItems: { type: array, items: { $ref: '#/components/schemas/RelabelSkuItem' } } + expectedPackages: { type: integer } + relabelledPackages: { type: integer } + skippedPackages: { type: integer } + exceptionPackages: { type: integer } + cartonProcessedPackages: { type: integer, description: 本轮箱唛作业已完成箱数,混合单独立计数 } + skuProcessedPackages: { type: integer, description: 本轮SKU作业已完成箱数,两项均完成才增加整箱已换标数 } + RelabelSkuItem: + type: object + required: [skuCode, newSkuCode, quantity] + properties: + skuCode: { type: string } + newSkuCode: { type: string } + quantity: { type: integer, description: 换标件数 } + remark: { type: string, nullable: true } + RelabelUnitFact: + type: object + properties: + factId: { type: string, pattern: '^[0-9]+$' } + itemId: { type: string, pattern: '^[0-9]+$' } + oldLabel: { type: string, nullable: true } + newLabel: { type: string, nullable: true } + result: { type: string } + exceptionCode: { type: string, nullable: true } + reason: { type: string, nullable: true } + operatorName: { type: string, nullable: true } + occurredAt: { type: string, format: date-time } + RelabelHistory: + type: object + properties: + action: { type: string } + fromStatus: { type: string, nullable: true } + toStatus: { type: string, nullable: true } + operatorName: { type: string, nullable: true } + detail: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + RelabelPhoto: + type: object + required: [fileId, fileName, contentType, sizeBytes] + properties: + fileId: { type: string, pattern: '^[0-9]+$' } + fileName: { type: string } + contentType: { type: string } + sizeBytes: { type: integer, format: int64 } + ScanCode: + type: object + required: [code] + properties: { code: { type: string, minLength: 1, maxLength: 128 } } + NewRelabelPallet: + type: object + required: [relabelNo, idempotencyKey] + properties: + relabelNo: { type: string, minLength: 1, maxLength: 64 } + idempotencyKey: { type: string, minLength: 1, maxLength: 128 } + IdempotentAction: + type: object + required: [idempotencyKey] + properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } + DepartAction: + type: object + required: [idempotencyKey] + properties: + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, maxLength: 1000, nullable: true } + DispatchPick: + type: object + required: [positionId, packages, productQuantity, idempotencyKey] + properties: + positionId: { type: string, pattern: '^[0-9]+$' } + packages: { type: integer, format: int64, minimum: 1 } + productQuantity: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, maxLength: 1000, nullable: true } + OutboundPick: + type: object + required: [outboundPalletItemId, inventoryPositionId, packages, idempotencyKey] + properties: + outboundPalletItemId: { type: string, pattern: '^[0-9]+$' } + inventoryPositionId: { type: string, pattern: '^[0-9]+$' } + packages: { type: integer, format: int64, minimum: 1 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + cartonMarks: + type: array + maxItems: 500 + description: 本次实际扫描的箱唛。一件代发指定箱唛任务分批拣货时必须提交本次子集,数量与 packages 一致;换标任务仍须提交全部指定箱唛。 + items: { type: string, minLength: 1, maxLength: 255 } + PdaOutboundTaskDetail: + type: object + required: [task, items] + properties: + task: + type: object + required: [taskNo, status, outboundNo] + properties: + taskNo: { type: string } + status: { type: string } + outboundNo: { type: string } + pushedAt: { type: string, format: date-time, nullable: true } + completedAt: { type: string, format: date-time, nullable: true } + items: + type: array + items: + type: object + required: [outboundPalletItemId, palletNo, skuCode, inboundCode, packageCount, pickedPackageCount, status, positions] + properties: + outboundPalletItemId: { type: string, description: pick请求提交键,不作为用户展示编号 } + palletNo: { type: string, description: Warehouse 权威实物托盘码 } + skuCode: { type: string } + inboundCode: { type: string } + packageCount: { type: integer } + pickedPackageCount: { type: integer } + status: { type: string } + positions: + type: array + items: + type: object + required: [inventoryPositionId, palletNo, cartonMark, skuCode, inboundCode, availablePackages, pickedPackages] + properties: + inventoryPositionId: { type: string, description: pick请求提交键,不作为用户展示编号 } + palletNo: { type: string, description: Warehouse 权威实物托盘码 } + cartonMark: { type: string, nullable: true } + skuCode: { type: string } + inboundCode: { type: string } + availablePackages: { type: integer, format: int64, minimum: 0 } + pickedPackages: { type: integer, format: int64, minimum: 0 } + 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 } + RelabelPalletDisassemble: + type: object + required: [idempotencyKey, relabelNo, source, target, cartonMarks] + properties: + idempotencyKey: { type: string, maxLength: 128 } + relabelNo: { type: string, maxLength: 64 } + source: { $ref: '#/components/schemas/PalletExpectation' } + target: { $ref: '#/components/schemas/PalletExpectation' } + cartonMarks: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string, maxLength: 255 } } + 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, description: 当前箱的仓库唛头,用于逐箱防重复 } + newLabel: { type: string, maxLength: 255, description: 新FBA箱唛,SKU-only不得提交SKU到此字段 } + skuVerifications: + type: array + description: SKU及组合换标必须提交该箱全部旧新SKU和件数核对结果 + items: + type: object + required: [skuCode, newSkuCode, quantity] + properties: + skuCode: { type: string } + newSkuCode: { type: string } + quantity: { type: integer, minimum: 1 } + 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/MapperRegistrationArchitectureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/MapperRegistrationArchitectureTest.java new file mode 100644 index 000000000..d8046ad4a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/MapperRegistrationArchitectureTest.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import org.apache.ibatis.annotations.Mapper; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.annotation.MapperScan; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static org.assertj.core.api.Assertions.assertThat; + +class MapperRegistrationArchitectureTest { + private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() + .importPackages("com.baiyue.lastleg"); + + @Test + void everyMapperInterfaceDeclaresItsRegistrationMarker() { + classes().that().areInterfaces().and().haveSimpleNameEndingWith("Mapper") + .should().beAnnotatedWith(Mapper.class) + .because("Mapper registration must fail during tests instead of service startup") + .check(PRODUCTION_CLASSES); + } + + @Test + void applicationUsesOneAnnotationFilteredRootScan() { + MapperScan scan = BaiyueLastlegApplication.class.getAnnotation(MapperScan.class); + assertThat(scan.basePackages()).containsExactly("com.baiyue.lastleg"); + assertThat(scan.annotationClass()).isEqualTo(Mapper.class); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/QueryBindingContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/QueryBindingContractTest.java new file mode 100644 index 000000000..911464195 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/QueryBindingContractTest.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg; + +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class QueryBindingContractTest { + @Test + void mvcQueryRecordsExposeOneResolvableConstructor() { + assertDoesNotThrow(() -> BeanUtils.getResolvableConstructor(DeliveryRequests.Query.class)); + assertDoesNotThrow(() -> BeanUtils.getResolvableConstructor(InboundPlanRequests.Query.class)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java new file mode 100644 index 000000000..f9573422f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.client; + +import org.junit.jupiter.api.Test; +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.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class DomesticWarehouseDirectoryClientContractTest { + @Test + void freezesActivePlatformWarehouseLookupAndDto() throws Exception { + FeignClient feign = DomesticWarehouseDirectoryClient.class.getAnnotation(FeignClient.class); + assertThat(feign.name()).isEqualTo("warehouse-service"); + assertThat(feign.contextId()).isEqualTo("domesticWarehouseDirectoryClient"); + assertThat(feign.url()).isEqualTo("${baiyue.clients.domestic-warehouse.url:}"); + + Method method = DomesticWarehouseDirectoryClient.class.getDeclaredMethod( + "activeWarehouse", long.class, String.class); + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/internal/warehouses/0/{warehouseId}/active"); + assertThat(method.getParameters()[0].getAnnotation(PathVariable.class).value()) + .isEqualTo("warehouseId"); + assertThat(method.getParameters()[1].getAnnotation(RequestHeader.class).value()) + .isEqualTo("X-Internal-Service-Token"); + assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Warehouse.class.getRecordComponents()) + .map(value -> value.getName() + ":" + value.getType().getSimpleName())) + .containsExactly("id:Long", "tenantId:Long", "warehouseCode:String", "warehouseName:String", + "warehouseType:String", "countryCode:String", "status:String"); + assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Address.class.getRecordComponents()) + .map(value -> value.getName() + ":" + value.getType().getSimpleName())) + .containsExactly("addressType:String", "countryCode:String", "stateProvince:String", "city:String", + "district:String", "postalCode:String", "addressLine1:String", "addressLine2:String"); + } +} 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..9f311cfbb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -0,0 +1,176 @@ +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.LastlegOrderCommandService; +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; +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.beans.BeanUtils; +import org.springframework.security.access.prepost.PreAuthorize; +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.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +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 { + // Controller contract tests intentionally instantiate the controller without a Spring context here. + private final LastlegOrderService service = mock(LastlegOrderService.class); + private final LastlegOrderCommandService commandService = mock(LastlegOrderCommandService.class); + private final LastlegOrderController controller = controller(); + private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( + 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null); + + @Test + void orderQueryHasOneResolvableConstructorForRequestParameterBinding() { + assertEquals(16, BeanUtils.getResolvableConstructor(LastlegOrderRequests.Query.class).getParameterCount()); + } + + @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 holdCommandPassesVersionedIdempotentRequestToSingleOrderService() { + var request = new LastlegOrderCommandDtos.HoldRequest(true, true, "海关查验", 2); + + controller.hold(user(0L, "PLATFORM"), "100", 11L, "hold-11", request); + + verify(commandService).hold("100", 11L, "hold-11", request, "1", "tester"); + } + + @Test + void manualNodeQueryUsesOrderScopeRatherThanContainerScope() { + controller.manualNodes(user(0L, "PLATFORM"), "100", 11L); + + verify(commandService).manualNodes("100", 11L); + verify(service, never()).containerProgressDetail(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void platformOperatorCanQueryCrossCustomerContainerSummaries() { + var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, "CONT-", null, null); + + controller.containerSummaries(user(0L, "PLATFORM"), summaryQuery); + + verify(service).containerProgressPage(summaryQuery); + } + + @Test + void containerEndpointsUseCanonicalLastMileOrderAuthorities() throws Exception { + var list = LastlegOrderController.class.getMethod("containerSummaries", + AuthenticatedUser.class, LastlegOrderRequests.ContainerProgressQuery.class); + var detail = LastlegOrderController.class.getMethod("containerSummaryDetail", + AuthenticatedUser.class, String.class); + var change = LastlegOrderController.class.getMethod("changeReceivingWarehouse", + AuthenticatedUser.class, String.class, String.class, + LastlegOrderRequests.ChangeReceivingWarehouse.class); + + assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:list')", + list.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:list')", + detail.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:edit')", + change.getAnnotation(PreAuthorize.class).value()); + } + + @Test + void containerSummariesRejectMerchantIdentity() { + var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, null, null, null); + + assertThrows(ResponseStatusException.class, + () -> controller.containerSummaries(user(100L, "MERCHANT"), summaryQuery)); + verify(service, never()).containerProgressPage(summaryQuery); + } + + @Test + void platformOperatorCanQueryUnpagedContainerDetailByStableId() { + controller.containerSummaryDetail(user(0L, "PLATFORM"), "BOOKING-1"); + + verify(service).containerProgressDetail("BOOKING-1"); + } + + @Test + void exportBuildsCsvBeforeReturningTheResponse() { + byte[] csv = "csv".getBytes(java.nio.charset.StandardCharsets.UTF_8); + when(service.exportCsv("200", query)).thenReturn(csv); + + var response = controller.export(user(0L, "PLATFORM"), "200", query); + + assertArrayEquals(csv, response.getBody()); + verify(service).exportCsv("200", query); + } + + @Test + void platformOperatorCanExportAllCustomersWithoutFilters() { + byte[] csv = "all customers".getBytes(java.nio.charset.StandardCharsets.UTF_8); + LastlegOrderRequests.Query emptyQuery = new LastlegOrderRequests.Query( + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); + when(service.exportCsv(null, emptyQuery)).thenReturn(csv); + + var response = controller.export(user(0L, "PLATFORM"), null, emptyQuery); + + assertEquals(Long.valueOf(1), emptyQuery.page()); + assertEquals(Long.valueOf(20), emptyQuery.size()); + assertArrayEquals(csv, response.getBody()); + verify(service).exportCsv(isNull(), eq(emptyQuery)); + } + + @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, "commandService", commandService); + 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..87b632521 --- /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( + 1L, 20L, null, 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..890f61a38 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java @@ -0,0 +1,126 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.lastleg.delivery.controller.DeliveryTransferController; +import com.baiyue.lastleg.delivery.controller.OutboundPdaController; +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.customervisit.controller.CustomerVisitController; +import com.baiyue.lastleg.exceptionorder.controller.ExceptionOrderController; +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 outboundControllersAreNotPropertyGated() { + for (Class type : new Class[] { + DispatchController.class, DeliveryTransferController.class, DispatchPdaController.class, + OutboundPdaController.class, CustomerVisitController.class, ExceptionOrderController.class + }) { + assertFalse(type.isAnnotationPresent(ConditionalOnProperty.class), type.getSimpleName()); + } + } + + @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() == 3); + 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 dispatchQueryAndSmartDispatchAreEnabledByDefault() 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}")); + } + + @Test + void overseasLastlegReachesDomesticPrintThroughSharedInfrastructureNetwork() throws Exception { + Path composePath = Path.of("deploy/docker-compose.overseas.yml"); + if (!Files.exists(composePath)) composePath = Path.of("..").resolve(composePath); + String compose = Files.readString(composePath); + assertTrue(compose.contains("PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207}")); + } + + @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/customervisit/service/CustomerVisitServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java new file mode 100644 index 000000000..b0241ac14 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java @@ -0,0 +1,102 @@ +package com.baiyue.lastleg.customervisit.service; + +import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests; +import com.baiyue.lastleg.customervisit.entity.CustomerVisit; +import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomerVisitServiceTest { + @Mock private CustomerVisitMapper visits; + @Mock private DeliveryOrderMapper deliveryOrders; + @Mock private DeliveryOperationMapper deliveryOperations; + @Mock private LastlegOrderMapper lastMileOrders; + private CustomerVisitService service; + + @BeforeEach + void setUp() { + service = new CustomerVisitService(); + ReflectionTestUtils.setField(service, "visits", visits); + ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); + ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations); + ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders); + } + + @Test + void rejectsRollbackFromDeliveredToWaitDeliveryWithoutWritingHistory() { + DeliveryOrder delivery = delivery("DELIVERED"); + when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); + + assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus("WAIT_DELIVERY", 0L, "错误倒退"))) + .isInstanceOf(ResponseStatusException.class) + .satisfies(error -> assertThat(((ResponseStatusException) error).getStatusCode().value()).isEqualTo(409)) + .hasMessageContaining("DELIVERED").hasMessageContaining("WAIT_DELIVERY"); + + verify(deliveryOrders, never()).updateById(delivery); + verifyNoInteractions(deliveryOperations); + } + + @Test + void rejectsManualDeliveryCompletionBeforeLoadingTheOrder() { + assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus("DELIVERED", 0L, null))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("不支持"); + + verifyNoInteractions(deliveryOrders, deliveryOperations); + } + + @Test + void rejectsForwardTransitionWithStaleVersion() { + DeliveryOrder delivery = delivery("WAIT_DELIVERY"); + delivery.setVersion(3L); + when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); + + assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus("DELIVERING", 2L, null))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本"); + + verify(deliveryOrders, never()).updateById(delivery); + verifyNoInteractions(deliveryOperations); + } + + @Test + void startMeansDeliveringWithoutSigningOrCreatingVisit() { + DeliveryOrder delivery = delivery("WAIT_DELIVERY"); delivery.setVersion(0L); + when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); + var started = service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus("DELIVERING", 0L, "开始派送")); + assertThat(started.deliveryStatus()).isEqualTo("DELIVERING"); + assertThat(delivery.getVersion()).isEqualTo(1); + assertThat(delivery.getDispatchedAt()).isNotNull(); + assertThat(delivery.getSignedAt()).isNull(); + verify(visits, never()).insert(org.mockito.ArgumentMatchers.any(CustomerVisit.class)); + verify(deliveryOrders).updateById(delivery); + } + + private DeliveryOrder delivery(String status) { + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(11L); + delivery.setCustomerId("1001"); + delivery.setDeliveryNo("DL-1"); + delivery.setStatus(status); + return delivery; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDocumentContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDocumentContractTest.java new file mode 100644 index 000000000..a5fe1a2a6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDocumentContractTest.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.controller.DeliveryDocumentController; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; +import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory; +import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import java.util.*; +import java.nio.file.*; +import static org.assertj.core.api.Assertions.*; + +class DeliveryDocumentContractTest { + @Test void missingVersionNestedNullIdsAndTooManyFilesAreRejected() { + try(var factory=Validation.buildDefaultValidatorFactory()) { + var validator=factory.getValidator(); + assertThat(validator.validate(new CustomerVisitRequests.DeliveryStatus("DELIVERING",null,"123"))) + .anyMatch(v->v.getPropertyPath().toString().equals("expectedVersion")); + assertThat(validator.validate(new Save(null,"key",null,List.of()))).isNotEmpty(); + assertThat(validator.validate(new Save(0L,"key",null,List.of(new Binding(DeliveryDocumentCategory.BOL, + Arrays.asList((Long)null)))))).isNotEmpty(); + assertThat(validator.validate(new Save(0L,"key",null,List.of(new Binding(DeliveryDocumentCategory.BOL, + java.util.stream.LongStream.rangeClosed(1,21).boxed().toList()))))).isNotEmpty(); + } + } + @Test void removedCategoriesAreNotAcceptedAndOnlyThreeRemainInOrder() { + assertThat(DeliveryDocumentCategory.values()).containsExactly(DeliveryDocumentCategory.LOADING_PHOTO, + DeliveryDocumentCategory.BOL, DeliveryDocumentCategory.RECEIPT); + for(String category:List.of("POD","EXPRESS_RECEIPT","CUSTOMER_DOCUMENT","BILL_OF_LADING","DELIVERY_PHOTO")) { + assertThatThrownBy(()->new ObjectMapper().readValue("\""+category+"\"",DeliveryDocumentCategory.class)) + .isInstanceOf(com.fasterxml.jackson.core.JsonProcessingException.class); + } + } + @Test void readDownloadAndWritePermissionsAreSeparate() { + for(var method:DeliveryDocumentController.class.getDeclaredMethods()) { + if(!Set.of("detail","history","download","save","upload").contains(method.getName())) continue; + String suffix=Set.of("save","upload").contains(method.getName())?"documents":"list"; + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:overseas-warehouse:delivery:"+suffix+"')"); + } + } + @Test void baselineAndOverseasEntryContainSameImmutableRevisionTable() throws Exception { + String baseline=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + String overseas=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); + String marker="CREATE TABLE IF NOT EXISTS ll_delivery_document_revision ("; + String a=baseline.substring(baseline.indexOf(marker),baseline.indexOf(";",baseline.indexOf(marker))+1); + String b=overseas.substring(overseas.indexOf(marker),overseas.indexOf(";",overseas.indexOf(marker))+1); + assertThat(a.replaceAll("\\s+","")).isEqualTo(b.replaceAll("\\s+","")); + assertThat(a).contains("uk_delivery_document_command","uk_delivery_document_version"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java new file mode 100644 index 000000000..0af56242b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.common.web.LongIdJacksonConfiguration; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("派送单长整型 ID JSON 契约") +class DeliveryLongIdJsonContractTest { + + private static final long DELIVERY_ID = 19_000_000_000_000_001L; + private static final long FEE_ID = 19_000_000_000_000_002L; + private static final long OPERATION_ID = 19_000_000_000_000_003L; + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + JacksonAutoConfiguration.class, LongIdJacksonConfiguration.class)); + + @Test + void deliveryResponseIdsShouldBeSerializedAsJsonStrings() { + contextRunner.run(context -> { + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + DeliveryResponses.DeliveryDetail detail = new DeliveryResponses.DeliveryDetail( + deliveryRow(), + List.of(), + List.of(new DeliveryResponses.DeliveryFeeRow( + FEE_ID, "RECEIVABLE", "DELIVERY_FEE", "USD", null, null)), + List.of(new DeliveryResponses.DeliveryOperationRow( + OPERATION_ID, null, "CREATED", "tester", "success"))); + + JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(detail)); + + assertThat(json.at("/order/id").isTextual()).isTrue(); + assertThat(json.at("/order/id").textValue()).isEqualTo(Long.toString(DELIVERY_ID)); + assertThat(json.at("/fees/0/id").textValue()).isEqualTo(Long.toString(FEE_ID)); + assertThat(json.at("/operations/0/id").textValue()).isEqualTo(Long.toString(OPERATION_ID)); + }); + } + + @Test + void quotedRequestIdShouldBeDeserializedToLong() { + contextRunner.run(context -> { + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + + DeliveryRequests.OneClickItem item = objectMapper.readValue( + "{\"inventoryPositionId\":\"19000000000000001\",\"packages\":\"2\",\"sku\":\"SKU-1\"}", + DeliveryRequests.OneClickItem.class); + + assertThat(item.inventoryPositionId()).isEqualTo(DELIVERY_ID); + assertThat(item.packages()).isEqualTo(2L); + }); + } + + private DeliveryResponses.DeliveryRow deliveryRow() { + return new DeliveryResponses.DeliveryRow( + DELIVERY_ID, + null, null, "PS-1", null, null, null, null, "CREATED", null, + null, null, null, null, null, null, null, null, null, null, null, + 0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null, + com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null); + } +} 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..00d4b5cad --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java @@ -0,0 +1,101 @@ +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.assertFalse; +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("request_hash char(64) null")); + 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,inbound_code,sku_code)")); + 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_destination (customer_id,outbound_order_id,delivery_group_key)")); + assertTrue(schema.contains("destination_warehouse_code varchar(128) not null")); + assertTrue(schema.contains("delivery_group_key varchar(255) not null")); + } + + @Test + void cleanDatabaseCreatesTheCompleteDeliveryTableBeforeEveryDependency() throws Exception { + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8) + .toLowerCase(); + String createToken = "create table if not exists ll_delivery_order"; + int create = schema.indexOf(createToken); + int firstReference = schema.indexOf("ll_delivery_order"); + int createEnd = schema.indexOf(';', create); + + assertTrue(create >= 0); + assertEquals(create + "create table if not exists ".length(), firstReference); + assertEquals(1, count(schema, createToken)); + assertTrue(createEnd > create); + String definition = schema.substring(create, createEnd); + for (String column : new String[] { + "overseas_warehouse_name", "customer_name", "destination_warehouse_id", + "destination_warehouse_code", "destination_warehouse_name", "delivery_group_key", "waybill_no", "delivery_method", + "carrier_name", "pickup_method", "loading_type", "vehicle_type", "vehicle_size", + "vehicle_plate", "driver_name", "driver_phone", "appointment_account", "seller_name", + "total_weight_kg", "total_volume_cbm", "planned_delivery_at", "dispatched_at", + "expected_arrival_at", "signed_at", "remark" + }) { + assertTrue(definition.contains(column), column); + } + assertTrue(schema.indexOf("alter table ll_delivery_order", createEnd) > createEnd); + + String overseas = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8).toLowerCase(); + assertTrue(overseas.contains("begin inline empty-database baseline: lastleg")); + assertTrue(overseas.contains(createToken)); + assertTrue(overseas.contains("request_hash char(64) null")); + assertTrue(overseas.indexOf(createToken) < overseas.indexOf("assert_phase5_lastleg_identifiers_migratable")); + assertTrue(overseas.contains("alter table ll_delivery_order add column overseas_warehouse_name")); + } + + @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")); + } + + private static int count(String source, String token) { + return source.split(java.util.regex.Pattern.quote(token), -1).length - 1; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java new file mode 100644 index 000000000..4f7071583 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DropshipOutboundCandidateMapperContractTest { + @Test + void candidateQueryIsSingleStatementAndFailsClosedOnEveryEligibilityFact() throws Exception { + String sql = select("listOutboundCandidates", String.class, String.class); + for (String token : new String[] {"p.customer_id=#{customerId}", "p.status='ACTIVE'", + "o.order_type='ONE_CLICK_DROPSHIP'", "COALESCE(o.detained,0)=0", "pos.status='OCCUPIED'", + "pos.available_packages>0", "c.status='AVAILABLE'", "COUNT(*)=1", "ELSE 'MIXED'", + "ll_dispatch_pallet_lock", "ll_warehouse_transfer_inventory_allocation", + "ll_outbound_pallet_item", "pos.overseas_warehouse_id=#{warehouseId}", "LIMIT 500"}) { + assertTrue(sql.contains(token), token); + } + assertTrue(sql.contains("LEAST(pos.available_packages,(SELECT COUNT(*) FROM ll_dropship_carton")); + assertTrue(sql.contains("#{customerId} IS NULL OR #{customerId}='' OR p.customer_id=#{customerId}")); + assertTrue(sql.contains("ORDER BY b.overseas_warehouse_name,p.pallet_no,sku_code")); + assertTrue(!sql.contains("ORDER BY b.overseas_warehouse_name,p.pallet_no,s.sku_code")); + } + + @Test + void creationLockUsesTheSameEligibilityContractAndLocksOnePhysicalPalletDimension() throws Exception { + String sql = select("lockOutboundCandidate", String.class, Long.class, String.class); + assertTrue(sql.contains("AND pos.id=#{positionId}")); + assertTrue(sql.contains("AND #{skuCode}=(SELECT CASE WHEN COUNT(*)=1")); + assertTrue(sql.contains("LIMIT 1 FOR UPDATE")); + assertTrue(sql.contains("ll_dispatch_pallet_lock")); + assertTrue(sql.contains("ll_outbound_pallet_item")); + } + + private String select(String method, Class... parameterTypes) throws Exception { + Select annotation = DropshipOrderProfileMapper.class.getMethod(method, parameterTypes) + .getAnnotation(Select.class); + assertNotNull(annotation); + return String.join("", annotation.value()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/Phase5PdaDeploymentSqlContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/Phase5PdaDeploymentSqlContractTest.java new file mode 100644 index 000000000..4907ad79b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/Phase5PdaDeploymentSqlContractTest.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.delivery; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Phase5PdaDeploymentSqlContractTest { + @Test + void overseasUnifiedMigrationConvergesPhase5IdentifiersWithoutBackfill() throws Exception { + String sql = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + int start = sql.indexOf("-- 五期排车、出库与海外PDA正式业务号/幂等约束"); + int end = sql.indexOf("USE baiyue_lastleg;", start); + assertTrue(start >= 0 && end > start); + String phase5 = sql.substring(start, end); + + assertTrue(phase5.contains("assert_phase5_lastleg_identifiers_migratable")); + assertTrue(phase5.contains("assert_phase5_lastleg_identifiers_ready")); + assertTrue(phase5.contains("warehouse_id IS NULL OR TRIM(warehouse_id)='' OR TRIM(warehouse_id)='0'")); + assertTrue(phase5.contains("SIGNAL SQLSTATE '45000'")); + assertTrue(phase5.contains("MODIFY COLUMN warehouse_id VARCHAR(128) NOT NULL")); + assertTrue(phase5.contains("ADD COLUMN completion_idempotency_key VARCHAR(255) NULL")); + assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_dispatch_order_no(dispatch_no)")); + assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_no(outbound_no)")); + assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_dispatch_pda_task_no(task_no)")); + assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_pda_task_no(task_no)")); + assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)")); + assertFalse(Pattern.compile("(?im)^\\s*UPDATE\\s+").matcher(phase5).find()); + assertFalse(phase5.contains("JOIN ll_dispatch_order")); + + assertFalse(sql.contains("uk_ll_dispatch_order_no(customer_id,dispatch_no)")); + assertFalse(sql.contains("uk_ll_outbound_no(customer_id,outbound_no)")); + assertFalse(sql.contains("uk_ll_dispatch_pda_task_no(customer_id,task_no)")); + assertFalse(sql.contains("uk_ll_outbound_pda_task_no(customer_id,task_no)")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java new file mode 100644 index 000000000..42155e065 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java @@ -0,0 +1,236 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.delivery.controller.DeliveryTransferController; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.dispatch.controller.DispatchController; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.security.OverseasPermissions; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.server.ResponseStatusException; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class PlatformDispatchOutboundContractTest { + @Test + void candidateHttpQueryAcceptsWarehouseWithoutCustomerAndPreservesExplicitFilter() throws Exception { + DeliveryTransferController controller = new DeliveryTransferController(); + var service = mock(com.baiyue.lastleg.delivery.service.DeliveryTransferService.class); + ReflectionTestUtils.setField(controller, "service", service); + MockMvc mvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()).build(); + AuthenticatedUser platform = new AuthenticatedUser(1000001L, 0L, 0L, 1L, "platform_admin", + "PLATFORM", "WEB", Set.of(OverseasPermissions.OUTBOUND_CREATE)); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(platform, null, Set.of())); + String path = "/api/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates"; + try { + mvc.perform(get(path).param("warehouseId", "WH-1")).andExpect(status().isOk()); + org.mockito.Mockito.verify(service).oneClickCandidates(null, "WH-1"); + mvc.perform(get(path).param("customerId", "101").param("warehouseId", "WH-1")) + .andExpect(status().isOk()); + org.mockito.Mockito.verify(service).oneClickCandidates("101", "WH-1"); + SecurityContextHolder.clearContext(); + mvc.perform(get(path).param("warehouseId", "WH-1")).andExpect(status().isUnauthorized()); + AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", + "MERCHANT", "WEB", Set.of(OverseasPermissions.OUTBOUND_CREATE)); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(merchant, null, Set.of())); + mvc.perform(get(path).param("warehouseId", "WH-1")).andExpect(status().isForbidden()); + org.mockito.Mockito.verifyNoMoreInteractions(service); + } finally { + SecurityContextHolder.clearContext(); + } + } + + @Test + void outboundDetailAcceptsTheDocumentUrlWithoutPagingParameters() throws Exception { + DeliveryTransferController controller = new DeliveryTransferController(); + ReflectionTestUtils.setField(controller, "service", + mock(com.baiyue.lastleg.delivery.service.DeliveryTransferService.class)); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + AuthenticatedUser platform = new AuthenticatedUser(1000001L, 0L, 0L, 1L, "platform_admin", + "PLATFORM", "WEB", Set.of(OverseasPermissions.OUTBOUND_LIST)); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(platform, null, Set.of())); + try { + mockMvc.perform(get("/api/platform/v1/last-leg/outbound-orders/OB-WH-20260907-030529")) + .andExpect(status().isOk()); + } finally { + SecurityContextHolder.clearContext(); + } + } + + @Test + void platformActionsHaveIndependentPermissionsAndNoCustomerParameter() throws Exception { + Method update = DispatchController.class.getMethod("update", AuthenticatedUser.class, + String.class, DispatchRequests.Update.class); + Method cancel = DispatchController.class.getMethod("cancel", AuthenticatedUser.class, String.class); + Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class, + String.class, DeliveryRequests.Transfer.class); + Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail", + AuthenticatedUser.class, String.class); + Method outboundCancel = DeliveryTransferController.class.getMethod("cancelOutbound", + AuthenticatedUser.class, String.class, DeliveryRequests.Cancel.class); + + assertPermission(update, OverseasPermissions.DISPATCH_EDIT); + assertPermission(cancel, OverseasPermissions.DISPATCH_CANCEL); + assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY); + assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST); + assertEquals(2, outboundDetail.getParameterCount()); + assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL); + for (Method method : Set.of(update, cancel, transfer, outboundDetail, outboundCancel)) { + assertTrue(Arrays.stream(method.getParameters()) + .noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class))); + } + assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods()) + .noneMatch(method -> "depart".equals(method.getName()))); + assertEquals(Set.of("vehiclePlate", "driverName", "driverPhone", "carrierName", + "fbaAppointmentNo", "fbaAppointmentStatus", "vehicleAppointmentStatus", "estimatedArrivalAt"), + Arrays.stream(DispatchRequests.Update.class.getRecordComponents()) + .map(component -> component.getName()).collect(java.util.stream.Collectors.toSet())); + } + + @Test + void oneClickCreationRequiresCustomerWhileCandidateAndListFiltersAreOptional() throws Exception { + Method create = DeliveryTransferController.class.getMethod("createOneClick", AuthenticatedUser.class, + String.class, DeliveryRequests.OneClickOutbound.class); + Method candidates = DeliveryTransferController.class.getMethod("oneClickCandidates", AuthenticatedUser.class, + String.class, String.class); + Method page = DeliveryTransferController.class.getMethod("outboundPage", AuthenticatedUser.class, + String.class, DeliveryRequests.Query.class); + + assertPermission(create, OverseasPermissions.OUTBOUND_CREATE); + assertPermission(candidates, OverseasPermissions.OUTBOUND_CREATE); + assertPermission(page, OverseasPermissions.OUTBOUND_LIST); + RequestParam createCustomer = create.getParameters()[1].getAnnotation(RequestParam.class); + RequestParam pageCustomer = page.getParameters()[1].getAnnotation(RequestParam.class); + assertNotNull(createCustomer); + assertTrue(createCustomer.required()); + assertFalse(candidates.getParameters()[1].getAnnotation(RequestParam.class).required()); + assertFalse(candidates.getParameters()[2].getAnnotation(RequestParam.class).required()); + assertNotNull(pageCustomer); + assertFalse(pageCustomer.required()); + + Method deliveryPage = DeliveryTransferController.class.getMethod("deliveryPage", AuthenticatedUser.class, + String.class, DeliveryRequests.Query.class); + RequestParam deliveryCustomer = deliveryPage.getParameters()[1].getAnnotation(RequestParam.class); + assertNotNull(deliveryCustomer); + assertFalse(deliveryCustomer.required()); + } + + @Test + void platformActionsRejectMerchantIdentityBeforeBusinessLookup() { + AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", + "MERCHANT", "WEB", Set.of()); + DispatchController dispatchController = new DispatchController(); + DeliveryTransferController deliveryController = new DeliveryTransferController(); + + ResponseStatusException dispatchRejected = org.junit.jupiter.api.Assertions.assertThrows( + ResponseStatusException.class, + () -> dispatchController.cancel(merchant, "DSP-101")); + ResponseStatusException outboundRejected = org.junit.jupiter.api.Assertions.assertThrows( + ResponseStatusException.class, + () -> deliveryController.outboundDetail(merchant, "OUT-101")); + + assertEquals(403, dispatchRejected.getStatusCode().value()); + assertEquals(403, outboundRejected.getStatusCode().value()); + } + + @Test + void globalBusinessNumberMappersAndSchemaDoNotUseCustomerForIdentity() throws Exception { + String dispatchFind = select(DispatchOrderMapper.class, "findGlobalByDispatchNo", String.class); + String dispatchLock = select(DispatchOrderMapper.class, "lockGlobalByDispatchNo", String.class); + String outboundFind = select(OutboundOrderMapper.class, "findGlobalByNo", String.class); + String outboundLock = select(OutboundOrderMapper.class, "lockGlobalByNo", String.class); + + for (String sql : Set.of(dispatchFind, dispatchLock, outboundFind, outboundLock)) { + assertFalse(sql.contains("customer_id")); + } + assertTrue(dispatchLock.contains("FOR UPDATE")); + assertTrue(outboundLock.contains("FOR UPDATE")); + + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8); + assertTrue(schema.contains("UNIQUE KEY uk_ll_dispatch_order_no (dispatch_no)")); + assertTrue(schema.contains("UNIQUE KEY uk_ll_outbound_no (outbound_no)")); + assertFalse(schema.contains("uk_ll_dispatch_order_no (customer_id,dispatch_no)")); + assertFalse(schema.contains("uk_ll_outbound_no (customer_id,outbound_no)")); + } + + @Test + void openApiPublishesOnlyTheFrozenPlatformProtocol() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"), + StandardCharsets.UTF_8); + String dispatchAction = slice(yaml, " /api/platform/v1/last-leg/dispatches/{dispatchNo}:", + " /api/platform/v1/last-leg/dispatches/candidates:"); + String transfer = slice(yaml, + " /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:", + " /api/platform/v1/last-leg/outbound-orders:"); + String outboundDetail = slice(yaml, + " /api/platform/v1/last-leg/outbound-orders/{outboundNo}:", + " /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:"); + String outboundCancel = slice(yaml, + " /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:", + " /api/platform/v1/last-leg/overseas-procurements:"); + + for (String action : Set.of(dispatchAction, transfer, outboundDetail, outboundCancel)) { + assertFalse(action.contains("name: customerId")); + } + assertTrue(outboundDetail.contains("pallets[].palletNo")); + assertTrue(yaml.contains("/api/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates:")); + assertTrue(yaml.contains("OneClickOutboundCandidate")); + assertTrue(yaml.contains("单次 SQL 返回,不逐行补查")); + assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:edit")); + assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:cancel")); + assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:transfer-delivery")); + assertTrue(yaml.contains("platform:overseas-warehouse:outbound:list")); + assertTrue(yaml.contains("platform:overseas-warehouse:outbound:create")); + assertTrue(yaml.contains("platform:overseas-warehouse:outbound:cancel")); + assertTrue(yaml.contains("vehicleSize: { type: string, nullable: true, enum: [15托, 30托] }")); + } + + private static void assertPermission(Method method, String permission) { + PreAuthorize annotation = method.getAnnotation(PreAuthorize.class); + assertNotNull(annotation); + assertEquals("hasAuthority('" + permission + "')", annotation.value()); + } + + private static String select(Class mapper, String method, Class... parameterTypes) throws Exception { + Select annotation = mapper.getMethod(method, parameterTypes).getAnnotation(Select.class); + assertNotNull(annotation); + return String.join("", annotation.value()); + } + + private static String slice(String source, String start, String end) { + int from = source.indexOf(start); + int to = source.indexOf(end, from + start.length()); + assertTrue(from >= 0 && to > from); + return source.substring(from, to); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/DeliveryDocumentControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/DeliveryDocumentControllerTest.java new file mode 100644 index 000000000..69d45d438 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/DeliveryDocumentControllerTest.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.delivery.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; +import com.baiyue.lastleg.delivery.service.DeliveryDocumentService; +import com.baiyue.lastleg.security.OverseasPermissions; +import org.junit.jupiter.api.*; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DeliveryDocumentControllerTest { + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + @Test void readonlyUserGetsNoWriteActionsInCurrentReplayOrHistory() throws Exception { verifyActions(false); } + @Test void currentWritePermissionRetainsManagementAction() throws Exception { verifyActions(true); } + private void verifyActions(boolean writer) throws Exception { + var service=mock(DeliveryDocumentService.class); + var controller=new DeliveryDocumentController(); ReflectionTestUtils.setField(controller,"service",service); + var user=new AuthenticatedUser(9L,0L,null,null,"reader","PLATFORM","WEB",Set.of()); + var authorities=writer?List.of(new SimpleGrantedAuthority(OverseasPermissions.DELIVERY_DOCUMENTS)): + List.of(new SimpleGrantedAuthority(OverseasPermissions.DELIVERY_LIST)); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,null,authorities)); + var snapshot=new Detail("DL-1","100",1,null,0,3,List.of(),List.of("MANAGE_DOCUMENTS"),true); + when(service.detail("100","DL-1")).thenReturn(snapshot); + when(service.history("100","DL-1")).thenReturn(List.of(new History(1,"9",LocalDateTime.now(),snapshot))); + var request=new Save(0L,"key",null,List.of()); + when(service.save("100","DL-1","9",request)).thenReturn(snapshot); + var json=new ObjectMapper().findAndRegisterModules(); + assertThat(json.valueToTree(controller.detail(user,"DL-1","100")).at("/data/allowedActions").size()).isEqualTo(writer?1:0); + assertThat(json.valueToTree(controller.save(user,"DL-1","100",request)).at("/data/allowedActions").size()).isEqualTo(writer?1:0); + assertThat(json.valueToTree(controller.history(user,"DL-1","100")).at("/data/0/snapshot/allowedActions").size()).isEqualTo(writer?1:0); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java new file mode 100644 index 000000000..7ff48fee5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java @@ -0,0 +1,141 @@ +package com.baiyue.lastleg.delivery.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.server.ResponseStatusException; + +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class MerchantDeliveryControllerTest { + private final DeliveryTransferService service = mock(DeliveryTransferService.class); + private final MerchantDeliveryController controller = controller(); + private final DeliveryRequests.Query query = new DeliveryRequests.Query( + 1, 20, null, null, null, null, null, null, null, null, null, null, null, null); + + @Test + void fixedRoutesUseIndependentListAndCreatePermissionsWithoutCustomerParameter() throws Exception { + RequestMapping mapping = MerchantDeliveryController.class.getAnnotation(RequestMapping.class); + assertEquals(List.of("/api/merchant/v1/last-leg/delivery-orders"), List.of(mapping.value())); + + Method page = MerchantDeliveryController.class.getMethod("page", AuthenticatedUser.class, + DeliveryRequests.Query.class); + Method create = MerchantDeliveryController.class.getMethod("create", AuthenticatedUser.class, + String.class, DeliveryRequests.Transfer.class); + Method detail = MerchantDeliveryController.class.getMethod("detail", AuthenticatedUser.class, String.class); + + assertPermission(page, OverseasPermissions.MERCHANT_DELIVERY_LIST); + assertPermission(detail, OverseasPermissions.MERCHANT_DELIVERY_LIST); + assertPermission(create, OverseasPermissions.MERCHANT_DELIVERY_CREATE); + assertNotEquals(OverseasPermissions.MERCHANT_DELIVERY_LIST, + OverseasPermissions.MERCHANT_DELIVERY_CREATE); + assertTrue(Arrays.stream(page.getParameters()).noneMatch(this::isCustomerParameter)); + assertTrue(Arrays.stream(detail.getParameters()).noneMatch(this::isCustomerParameter)); + assertTrue(Arrays.stream(create.getParameters()).noneMatch(this::isCustomerParameter)); + assertTrue(Arrays.stream(DeliveryRequests.Transfer.class.getRecordComponents()) + .noneMatch(component -> "customerId".equals(component.getName()))); + } + + @Test + void allOperationsUseOnlyAuthenticatedCustomerScope() { + AuthenticatedUser merchant = user(100L, "MERCHANT"); + DeliveryRequests.Transfer request = transferRequest(); + + controller.page(merchant, query); + controller.detail(merchant, "DEL-100"); + controller.create(merchant, "DSP-100", request); + + verify(service).deliveryPage("100", query); + verify(service).deliveryDetail("100", "DEL-100"); + verify(service).merchantTransfer("100", "DSP-100", "1", request); + } + + @Test + void merchantEndpointsRejectPlatformIdentity() { + AuthenticatedUser platform = user(0L, "PLATFORM"); + + assertThrows(ResponseStatusException.class, () -> controller.page(platform, query)); + assertThrows(ResponseStatusException.class, () -> controller.detail(platform, "DEL-100")); + assertThrows(ResponseStatusException.class, () -> controller.create(platform, "DSP-100", transferRequest())); + } + + @Test + void invalidCreatePayloadIsRejectedByBeanValidationContract() { + DeliveryRequests.Transfer invalid = new DeliveryRequests.Transfer(" ", null, " ", null, null, + " ", " ", null, null, null, null, null, null, null, null, null); + try (var factory = Validation.buildDefaultValidatorFactory()) { + assertTrue(factory.getValidator().validate(invalid).size() >= 6); + } + } + + @Test + void openApiPublishesOnlyTokenScopedMerchantDeliveryProtocol() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"), + StandardCharsets.UTF_8); + String contract = slice(yaml, " /api/merchant/v1/last-leg/delivery-orders:", + " /api/merchant/v1/last-leg/overseas-procurements:"); + + assertTrue(contract.contains("merchant:warehouse:delivery:list")); + assertTrue(contract.contains("merchant:warehouse:delivery:create")); + assertFalse(contract.contains("name: customerId")); + } + + private boolean isCustomerParameter(java.lang.reflect.Parameter parameter) { + RequestParam annotation = parameter.getAnnotation(RequestParam.class); + return annotation != null && ("customerId".equals(annotation.name()) + || "customerId".equals(annotation.value())); + } + + private static void assertPermission(Method method, String permission) { + PreAuthorize annotation = method.getAnnotation(PreAuthorize.class); + assertEquals("hasAuthority('" + permission + "')", annotation.value()); + } + + private static String slice(String source, String start, String end) { + int from = source.indexOf(start); + int to = source.indexOf(end, from + start.length()); + assertTrue(from >= 0 && to > from); + return source.substring(from, to); + } + + private static AuthenticatedUser user(long tenantId, String appCode) { + return new AuthenticatedUser(1L, tenantId, tenantId, 9L, "merchant", appCode, "WEB", Set.of()); + } + + private MerchantDeliveryController controller() { + MerchantDeliveryController value = new MerchantDeliveryController(); + ReflectionTestUtils.setField(value, "service", service); + return value; + } + + private static DeliveryRequests.Transfer transferRequest() { + return new DeliveryRequests.Transfer("merchant-key", LocalDateTime.now(), "SELF_DELIVERY", null, null, + "ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, null, + List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "派送费", "RECEIVABLE", "CNY", + new BigDecimal("10.00"), null, null, null, null, null, null, null, null, + null, null, null, null, null, null))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGatewayTest.java new file mode 100644 index 000000000..e30d5652a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGatewayTest.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.delivery.gateway; + +import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; + +class HttpDeliveryDocumentFileGatewayTest { + HttpDeliveryDocumentFileGateway gateway; + MockRestServiceServer server; + @BeforeEach void setUp() { + var builder=RestClient.builder().baseUrl("http://file.test"); + server=MockRestServiceServer.bindTo(builder).build(); + gateway=new HttpDeliveryDocumentFileGateway(); + ReflectionTestUtils.setField(gateway,"client",builder.build()); + ReflectionTestUtils.setField(gateway,"internalToken","test-service-token"); + } + @Test void validatesExactOwnershipAndUsesFileCreationTime() { + expect(metadata("100","10","DELIVERY_BOL","11")); + var file=gateway.metadata("100",10,DeliveryDocumentCategory.BOL,11); + assertThat(file.fileId()).isEqualTo(11); + assertThat(file.uploadedAt().toString()).isEqualTo("2026-09-09T08:00"); + server.verify(); + } + @ParameterizedTest + @ValueSource(strings={"customer","delivery","category","file"}) + void rejectsCrossCustomerCrossDeliveryWrongCategoryAndWrongFile(String mismatch) { + expect(metadata(mismatch.equals("customer")?"200":"100",mismatch.equals("delivery")?"20":"10", + mismatch.equals("category")?"DELIVERY_RECEIPT":"DELIVERY_BOL",mismatch.equals("file")?"12":"11")); + assertThatThrownBy(()->gateway.metadata("100",10,DeliveryDocumentCategory.BOL,11)) + .hasMessageContaining("不属于"); + server.verify(); + } + private void expect(String json) { + server.expect(requestTo("http://file.test/internal/file/v1/business-files/100/DELIVERY_BOL/10/11")) + .andExpect(header("X-Internal-Service-Token","test-service-token")) + .andRespond(withSuccess(json,MediaType.APPLICATION_JSON)); + } + private String metadata(String customer,String delivery,String type,String file) { + return "{\"code\":\"200\",\"data\":{\"fileId\":"+file+",\"tenantId\":"+customer+ + ",\"businessType\":\""+type+"\",\"businessId\":\""+delivery+"\",\"fileName\":\"test.pdf\","+ + "\"contentType\":\"application/pdf\",\"sizeBytes\":10,\"createdAt\":\"2026-09-09T08:00:00\"}}"; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java new file mode 100644 index 000000000..94c949b2b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.delivery.model; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DeliveryStatusPolicyTest { + @Test + void exposesOnlyServerAuthorizedActionsForEachState() { + assertThat(DeliveryStatusPolicy.allowedActions("WAIT_DELIVERY")) + .containsExactly("START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); + assertThat(DeliveryStatusPolicy.allowedActions("DELIVERING")) + .containsExactly("REJECT_DELIVERY", "MARK_RETURN"); + assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN"); + assertThat(DeliveryStatusPolicy.isEditableTarget("DELIVERED")).isFalse(); + assertThat(DeliveryStatusPolicy.allowedActions("RETURN_PENDING")).isEmpty(); + assertThat(DeliveryStatusPolicy.allowedActions("CANCELLED")).isEmpty(); + } + + @Test + void terminalStatesCannotReenterTheDeliveryFlow() { + assertThat(DeliveryStatusPolicy.canTransition("DELIVERED", "WAIT_DELIVERY")).isFalse(); + assertThat(DeliveryStatusPolicy.canTransition("DELIVERED", "DELIVERING")).isFalse(); + assertThat(DeliveryStatusPolicy.canTransition("REJECTED", "DELIVERING")).isFalse(); + assertThat(DeliveryStatusPolicy.canTransition("CANCELLED", "DELIVERED")).isFalse(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java new file mode 100644 index 000000000..1251a4f31 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.OverseasDeliveryCompletedV1; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class DeliveryCompletedOutboxServiceTest { + @Test + void emitsOneEventPerSourceInboundAcrossFbaWarehouses() throws Exception { + LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService(); + ReflectionTestUtils.setField(service, "outbox", mapper); + ReflectionTestUtils.setField(service, "objectMapper", json); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(1L); delivery.setDeliveryNo("DL-1"); delivery.setVersion(2L); + + service.publish(delivery, List.of(order(11, "101", "201", "IN-1", "ONT8"), + order(12, "102", "202", "IN-2", "ACY2")), Instant.parse("2026-09-15T08:00:00Z")); + + ArgumentCaptor rows = ArgumentCaptor.forClass(LastlegEventOutbox.class); + verify(mapper, times(2)).insert(rows.capture()); + List payloads = rows.getAllValues().stream().map(row -> { + try { + EventEnvelope envelope = json.readValue(row.getPayloadJson(), EventEnvelope.class); + return json.treeToValue(envelope.payload(), OverseasDeliveryCompletedV1.class); + } catch (Exception exception) { throw new RuntimeException(exception); } + }).toList(); + assertThat(payloads).extracting(OverseasDeliveryCompletedV1::sourceInboundId) + .containsExactly("101", "102"); + assertThat(payloads).extracting(OverseasDeliveryCompletedV1::fbaWarehouseCode) + .containsExactly("ONT8", "ACY2"); + } + + private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) { + LastlegOrder value = new LastlegOrder(); + value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88"); + value.setSourceInboundId(inboundId); value.setInboundCode(inboundCode); + value.setSourceOrderId(orderId); value.setSourceOrderNo("FL-" + orderId); + value.setRootOrderId(orderId); value.setFbaWarehouseCode(fba); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java new file mode 100644 index 000000000..ad3d0bcee --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java @@ -0,0 +1,156 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.model.*; +import com.baiyue.lastleg.customervisit.service.CustomerVisitService; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import java.time.LocalDateTime; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory.*; + +@ExtendWith(MockitoExtension.class) +class DeliveryDocumentServiceTest { + @Mock DeliveryOrderMapper orders; + @Mock DeliveryDocumentRevisionMapper revisions; + @Mock DeliveryOperationMapper operations; + @Mock DeliveryDocumentFileGateway files; + @Mock LastlegOrderMapper lastlegOrders; + @Mock DeliveryCompletedOutboxService deliveryCompletedOutbox; + @Mock CustomerVisitService customerVisits; + DeliveryDocumentService service; + DeliveryOrder order; + @BeforeEach void setUp() { + service = new DeliveryDocumentService(); + ReflectionTestUtils.setField(service,"orders",orders); + ReflectionTestUtils.setField(service,"revisions",revisions); + ReflectionTestUtils.setField(service,"operations",operations); + ReflectionTestUtils.setField(service,"files",files); + ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules()); + ReflectionTestUtils.setField(service,"lastlegOrders",lastlegOrders); + ReflectionTestUtils.setField(service,"deliveryCompletedOutbox",deliveryCompletedOutbox); + ReflectionTestUtils.setField(service,"customerVisits",customerVisits); + order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100"); + order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED"); + } + @Test void savedCategoryCountCountsCategoriesNotFilesAndSurvivesReload() { + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,BOL,11)).thenReturn(file(11)); + when(files.metadata("100",10,BOL,12)).thenReturn(file(12)); + var result=service.save("100","DL-1","9",request(0,"save",List.of(new Binding(BOL,List.of(11L,12L))))); + assertThat(result.uploadedCategoryCount()).isEqualTo(1); + assertThat(result.categories()).hasSize(3); + assertThat(result.categories().get(1).fileCount()).isEqualTo(2); + assertThat(result.version()).isEqualTo(1); + assertThat(result.categories().get(1).files().get(0).uploadedAt()).isEqualTo(file(11).uploadedAt()); + when(orders.findByNo("100","DL-1")).thenReturn(order); + assertThat(service.detail("100","DL-1")).isEqualTo(result); + assertThat(DeliveryDocumentSnapshot.summary(order.getDocumentsJson()).uploadedCategoryCount()).isEqualTo(1); + verify(revisions).insert(any(DeliveryDocumentRevision.class)); verify(operations).insert(any(DeliveryOperation.class)); + } + @Test void staleVersionDoesNotValidateFilesOrWrite() { + order.setVersion(2L); when(orders.lockByNo("100","DL-1")).thenReturn(order); + assertThatThrownBy(()->service.save("100","DL-1","9",request(1,"stale",List.of()))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本"); + verifyNoInteractions(files,operations); verify(orders,never()).updateById(any(DeliveryOrder.class)); + verify(revisions,never()).insert(any(DeliveryDocumentRevision.class)); + } + @Test void anyInvalidOwnershipRejectsWholeSnapshotBeforeMutation() { + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,BOL,11)).thenReturn(file(11)); + when(files.metadata("100",10,RECEIPT,12)).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST,"归属错误")); + assertThatThrownBy(()->service.save("100","DL-1","9",request(0,"bad",List.of( + new Binding(BOL,List.of(11L)),new Binding(RECEIPT,List.of(12L)))))).hasMessageContaining("归属"); + assertThat(order.getDocumentsJson()).isNull(); assertThat(order.getVersion()).isZero(); + verify(orders,never()).updateById(any(DeliveryOrder.class)); verifyNoInteractions(operations); + verify(revisions,never()).insert(any(DeliveryDocumentRevision.class)); + } + @Test void exactReplayDoesNotWriteOrRevalidateAndChangedPayloadConflicts() { + when(orders.lockByNo("100","DL-1")).thenReturn(order); + var request=request(0,"same",List.of()); + var first=service.save("100","DL-1","9",request); + ArgumentCaptor saved=ArgumentCaptor.forClass(DeliveryDocumentRevision.class); + verify(revisions).insert(saved.capture()); + when(revisions.findCommand("100",10,"same")).thenReturn(saved.getValue()); + assertThat(service.save("100","DL-1","9",request).replayed()).isTrue(); + assertThat(first.version()).isEqualTo(1); + verify(orders,times(1)).updateById(any(DeliveryOrder.class)); + assertThatThrownBy(()->service.save("100","DL-1","9",request(1,"same",List.of()))) + .hasMessageContaining("幂等键"); + } + @Test void replacementRemovalPreservesHistoryAndCurrentDownloadRejectsRemovedFile() { + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,BOL,11)).thenReturn(file(11)); + service.save("100","DL-1","9",request(0,"first",List.of(new Binding(BOL,List.of(11L))))); + ArgumentCaptor first=ArgumentCaptor.forClass(DeliveryDocumentRevision.class); + verify(revisions).insert(first.capture()); + assertThat(service.save("100","DL-1","9",request(1,"clear",List.of())).uploadedCategoryCount()).isZero(); + when(orders.findByNo("100","DL-1")).thenReturn(order); + assertThatThrownBy(()->service.download("100","DL-1",BOL,11,null)).isInstanceOf(ResponseStatusException.class); + when(revisions.history("100",10)).thenReturn(List.of(first.getValue())); + when(files.download("100",10,BOL,11)).thenReturn(new byte[]{1}); + assertThat(service.download("100","DL-1",BOL,11,1L).content()).containsExactly((byte)1); + } + @Test void uploadWithoutSaveNeverChangesSummaryAndUnboundFileCannotDownload() { + when(orders.findByNo("100","DL-1")).thenReturn(order); + var input=new MockMultipartFile("file","a.pdf","application/pdf",new byte[]{1}); + when(files.upload("100",10,BOL,"9",input)).thenReturn(file(11)); + assertThat(service.upload("100","DL-1",BOL,"9",input).fileId()).isEqualTo(11); + assertThat(service.detail("100","DL-1").uploadedCategoryCount()).isZero(); + assertThatThrownBy(()->service.download("100","DL-1",BOL,11,null)).isInstanceOf(ResponseStatusException.class); + verifyNoInteractions(revisions,operations); verify(orders,never()).updateById(any(DeliveryOrder.class)); + } + @Test void anotherCustomerOrDeliveryCannotLoadOrBindFiles() { + assertThatThrownBy(()->service.detail("200","DL-1")).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->service.save("100","DL-2","9",request(0,"wrong",List.of()))) + .isInstanceOf(ResponseStatusException.class); + verifyNoInteractions(files,revisions,operations); + } + @Test void allThreeCategoriesCountToThreeRegardlessOfFileMultiplicity() { + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,LOADING_PHOTO,11)).thenReturn(file(11)); + when(files.metadata("100",10,BOL,12)).thenReturn(file(12)); + when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13)); + var result=service.save("100","DL-1","9",request(0,"all",List.of( + new Binding(LOADING_PHOTO,List.of(11L)),new Binding(BOL,List.of(12L)),new Binding(RECEIPT,List.of(13L))))); + assertThat(result.uploadedCategoryCount()).isEqualTo(3); assertThat(result.totalCategoryCount()).isEqualTo(3); + assertThat(result.categories()).allMatch(c->c.status().equals("UPLOADED")); + } + @Test void savingReceiptAutomaticallyCompletesOnlyThisDeliveryAndItsBoundOrders() { + order.setStatus("WAIT_DELIVERY"); + LastlegOrder related = new LastlegOrder(); related.setId(20L); related.setCustomerId("100"); + related.setDeliveryOrderNo("DL-1"); related.setSubOrderStatus("DELIVERY:WAIT_DELIVERY"); + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13)); + when(lastlegOrders.selectList(any())).thenReturn(List.of(related)); + + service.save("100","DL-1","9",request(0,"receipt",List.of(new Binding(RECEIPT,List.of(13L))))); + + assertThat(order.getStatus()).isEqualTo("DELIVERED"); + assertThat(order.getSignedAt()).isNotNull(); + assertThat(related.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED"); + verify(lastlegOrders).updateById(related); + verify(deliveryCompletedOutbox).publish(eq(order), eq(List.of(related)), any()); + verify(customerVisits).ensureForCompletedDelivery(eq(order), any()); + ArgumentCaptor operation = ArgumentCaptor.forClass(DeliveryOperation.class); + verify(operations, times(2)).insert(operation.capture()); + assertThat(operation.getAllValues()).extracting(DeliveryOperation::getOperationNode) + .containsExactly("SAVE_DOCUMENTS", "RECEIPT_AUTO_COMPLETE"); + } + private Save request(long version,String key,List bindings) { return new Save(version,key,"BOL-1",bindings); } + private File file(long id) { return new File(id,"a.pdf","application/pdf",1,LocalDateTime.of(2026,9,9,8,0)); } +} 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..64226bf1e --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -0,0 +1,983 @@ +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.entity.OutboundPdaTask; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +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.InboundPlanCartonMapper; +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 com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipCartonSku; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; +import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate; +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.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.inOrder; +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 OverseasFeeSourceService feeSources; + @Mock DeliveryOperationMapper deliveryOperations; + @Mock InboundPlanPalletAllocationMapper planAllocations; + @Mock InboundPlanLineMapper planLines; + @Mock InboundPlanCartonMapper inboundCartons; + @Mock LastlegOrderMapper tailOrders; + @Mock LastlegOrderStatusHistoryMapper histories; + @Mock LastlegOrderDetailMapper tailOrderDetails; + @Mock OutboundPdaTaskMapper pdaTasks; + @Mock OutboundPdaPickMapper pdaPicks; + @Mock InventoryPositionMapper inventoryPositions; + @Mock InventoryBalanceMapper inventoryBalances; + @Mock DropshipOrderProfileMapper dropshipProfiles; + @Mock DropshipCartonMapper dropshipCartons; + @Mock DropshipCartonSkuMapper dropshipCartonSkus; + @Mock DropshipSkuInventoryMapper dropshipSkuInventory; + @Mock WarehouseTransferPalletMapper warehouseTransferPallets; + + private DeliveryTransferService service; + + @BeforeEach + void setUp() { + service = new DeliveryTransferService(); + ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper()); + 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, "feeSources", feeSources); + ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations); + ReflectionTestUtils.setField(service, "planAllocations", planAllocations); + ReflectionTestUtils.setField(service, "planLines", planLines); + ReflectionTestUtils.setField(service, "inboundCartons", inboundCartons); + ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "tailOrders", tailOrders); + ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "tailOrderDetails", tailOrderDetails); + ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks); + ReflectionTestUtils.setField(service, "pdaPicks", pdaPicks); + ReflectionTestUtils.setField(service, "inventoryPositions", inventoryPositions); + ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances); + ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles); + ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons); + ReflectionTestUtils.setField(service, "dropshipCartonSkus", dropshipCartonSkus); + ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory); + ReflectionTestUtils.setField(service, "warehouseTransferPallets", warehouseTransferPallets); + } + + @Test + void firstTransferCreatesOneOutboundAndOneDeliveryPerDestinationFbaWarehouse() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).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.platformTransfer("DSP-101", "U-1", + transferRequest("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, org.mockito.Mockito.times(2)).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().matches("OB-WH-\\d{8}-\\d{6}")); + assertTrue(delivery.getAllValues().stream().allMatch(value -> outbound.getValue().getId().equals(value.getOutboundOrderId()))); + assertEquals(List.of("ONT8", "LAX9"), delivery.getAllValues().stream().map(DeliveryOrder::getDestinationWarehouseCode).toList()); + assertEquals(List.of(12, 8), delivery.getAllValues().stream().map(DeliveryOrder::getPackageCount).toList()); + assertTrue(delivery.getAllValues().stream().allMatch(value -> "WAIT_DELIVERY".equals(value.getStatus()))); + 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()); + assertEquals(2, result.deliveries().size()); + verify(feeSources, org.mockito.Mockito.times(1)).publishApproved(any(), any(), any()); + assertTrue(!result.replayed()); + } + + @Test + void transferBackfillsReceivedPlanPalletsBeforeCheckingDispatchItems() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); + when(dispatchItems.bindReceivedPallets("TENANT-A", 101L)).thenReturn(1); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); + + service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-after-receiving", null)); + + var ordered = inOrder(dispatchItems); + ordered.verify(dispatchItems).bindReceivedPallets("TENANT-A", 101L); + ordered.verify(dispatchItems).findByDispatchOrder("TENANT-A", 101L); + } + + @Test + void transferCollectsLxCodesWhenAnInboundCodeBelongsToMultipleTailOrders() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); + LastlegOrder primary = new LastlegOrder(); + primary.setCustomerId("TENANT-A"); primary.setInboundCode("IN-A"); primary.setLxCode("LX-1"); + LastlegOrder derived = new LastlegOrder(); + derived.setCustomerId("TENANT-A"); derived.setInboundCode("IN-A"); derived.setLxCode(null); + when(tailOrders.selectList(any())).thenReturn(List.of(primary, derived), List.of()); + + service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-duplicate-inbound", null)); + + ArgumentCaptor outbound = ArgumentCaptor.forClass(OutboundOrder.class); + verify(outboundOrders).insert(outbound.capture()); + assertEquals("LX-1", outbound.getValue().getLxCodes()); + } + + @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.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); + when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); + + DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", + transferRequest(" transfer-101 ", null)); + + assertTrue(result.replayed()); + assertEquals("OUT-301", result.outboundNo()); + assertEquals("DEL-401", result.deliveries().get(0).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.lockGlobalByDispatchNo("DSP-102")).thenReturn(dispatch); + when(outboundOrders.findByIdempotency("TENANT-A", "shared-key")).thenReturn(other); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.platformTransfer("DSP-102", "U-1", transferRequest("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.lockGlobalByDispatchNo("DSP-101")).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(List.of(delivery)); + + DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", + transferRequest("transfer-101", null)); + + assertTrue(result.replayed()); + assertEquals("OUT-301", result.outboundNo()); + assertEquals("DEL-401", result.deliveries().get(0).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.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(source)); + when(tailOrders.selectList(any())).thenReturn(List.of(tailOrder)); + + service.platformTransfer("DSP-101", "U-1", transferRequest("direct-order", null)); + + assertEquals("WAIT_OUTBOUND", tailOrder.getCurrentStatus()); + assertNotNull(tailOrder.getOutboundOrderNo()); + assertNotNull(tailOrder.getDeliveryOrderNo()); + verify(tailOrders).updateById(tailOrder); + ArgumentCaptor history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); + verify(histories).insert(history.capture()); + assertEquals("TENANT-A", history.getValue().getCustomerId()); + } + + @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.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(planned)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-101", null))); + + assertEquals(409, exception.getStatusCode().value()); + assertTrue(exception.getReason().contains("received")); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void dedicatedOutboundDetailDoesNotRequireADeliveryDocument() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "dropship-301"); + outbound.setOutboundType("ONE_CLICK_DROPSHIP"); + outbound.setStatus("OUTBOUNDED"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of( + pallet(501L, "DP-260914-0001", "ONT8", "Ontario", "IN-A", 4))); + + DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301"); + + assertEquals("OUTBOUNDED", detail.outbound().status()); + org.junit.jupiter.api.Assertions.assertTrue(detail.deliveries().isEmpty()); + assertEquals("IN-A", detail.pallets().get(0).inboundCode()); + assertEquals(4, detail.pallets().get(0).packageCount()); + } + + @Test + void dispatchOutboundStillRequiresItsDeliveryDocument() { + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301"); + outbound.setOutboundType("DISPATCH_DELIVERY"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + assertThrows(IllegalStateException.class, () -> service.outboundDetail("OUT-301")); + } + + @Test + void outboundDetailIncludesPalletCodesForDisplay() { + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); + OutboundPalletItem pallet = pallet(501L, "IP-ONT8-20260907-000001-P001", "ONT8", "Ontario", "IN-A", 12); + pallet.setSkuCode("SKU-1"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(pallet)); + + DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301"); + + assertEquals("OUT-301", detail.outbound().outboundNo()); + assertEquals("DEL-401", detail.deliveries().get(0).deliveryNo()); + assertEquals(1, detail.pallets().size()); + assertEquals("IP-ONT8-20260907-000001-P001", detail.pallets().get(0).palletNo()); + assertEquals("IN-A", detail.pallets().get(0).inboundCode()); + assertEquals("ONT8", detail.pallets().get(0).destinationWarehouseCode()); + assertEquals("SKU-1", detail.pallets().get(0).skuCode()); + assertEquals(12, detail.pallets().get(0).packageCount()); + verify(outboundPallets).findByOutbound("TENANT-A", 301L); + } + + @Test + void globalOutboundDetailUsesTheCustomerResolvedFromTheBusinessNumber() { + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); + + DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301"); + + assertEquals("OUT-301", detail.outbound().outboundNo()); + verify(deliveryOrders).lockByOutbound("TENANT-A", 301L); + } + + @Test + void merchantTransferCannotResolveAnotherCustomersDispatch() { + when(dispatchOrders.findByDispatchNo("TENANT-B", "DSP-101")).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.merchantTransfer("TENANT-B", "DSP-101", "U-2", transferRequest("merchant-key", null))); + + assertEquals(404, exception.getStatusCode().value()); + verify(dispatchOrders, never()).lockGlobalByDispatchNo(any()); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void merchantTransferReplaysExistingDocumentsWithinAuthenticatedCustomer() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "merchant-key"); + 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", "merchant-key")).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); + + DeliveryResponses.TransferResult result = service.merchantTransfer("TENANT-A", "DSP-101", "U-2", + transferRequest(" merchant-key ", null)); + + assertTrue(result.replayed()); + assertEquals("DEL-401", result.deliveries().get(0).deliveryNo()); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void merchantTransferRejectsCancelledDispatch() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + dispatch.setStatus("CANCELLED"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.merchantTransfer("TENANT-A", "DSP-101", "U-2", transferRequest("merchant-key", null))); + + assertEquals(409, exception.getStatusCode().value()); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void merchantDeliveryDetailCannotCrossCustomerBoundary() { + when(deliveryOrders.findByNo("TENANT-B", "DEL-401")).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.deliveryDetail("TENANT-B", "DEL-401")); + + assertEquals(404, exception.getStatusCode().value()); + verify(dispatchItems, never()).findByDispatchOrder(any(), any()); + } + + @Test + void deliveryDetailAndBolSourceRowsContainOnlyItsDestinationFbaWarehouse() { + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder ont8 = delivery(401L, outbound, "DEL-ONT8"); + DispatchItem ont8Item = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); + ont8Item.setOrderId(901L); + DispatchItem lax9Item = item(202L, 502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8); + lax9Item.setOrderId(902L); + when(deliveryOrders.findByNo("TENANT-A", "DEL-ONT8")).thenReturn(ont8); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(ont8Item, lax9Item)); + when(tailOrders.selectBatchIds(List.of(901L))).thenReturn(List.of()); + + DeliveryResponses.DeliveryDetail detail = service.deliveryDetail("TENANT-A", "DEL-ONT8"); + + assertEquals("ONT8", detail.order().destinationWarehouseCode()); + verify(tailOrders).selectBatchIds(List.of(901L)); + } + + @Test + void globalOutboundCancelIsIdempotentForAnUnexecutedOrder() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); + outbound.setStatus("WAIT_PICK"); + OutboundPalletItem item = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); + item.setStatus("WAIT_PICK"); item.setPickedPackageCount(0); + OutboundPdaTask task = new OutboundPdaTask(); + task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); task.setStatus("PENDING"); + when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); + when(pdaTasks.lockByOutbound("TENANT-A", 301L)).thenReturn(task); + + service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("cancelled by platform")); + service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("duplicate command")); + + assertEquals("CANCELLED", outbound.getStatus()); + assertEquals("CANCELLED", item.getStatus()); + assertEquals("CANCELLED", task.getStatus()); + verify(outboundPallets, org.mockito.Mockito.times(1)).findByOutbound("TENANT-A", 301L); + } + + @Test + void globalOutboundCancelRejectsAnExecutedOrderWithoutDrift() { + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-key"); + outbound.setStatus("PICKED"); + when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("too late"))); + + assertEquals(409, exception.getStatusCode().value()); + assertEquals("PICKED", outbound.getStatus()); + verify(outboundOrders, never()).updateById(any(OutboundOrder.class)); + verify(outboundPallets, never()).findByOutbound(any(), any()); + } + + @Test + void sharedPalletOutboundFreezesEachInboundAndCountsOnePhysicalPallet() { + for (long i = 1; i <= 2; i++) { + when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 900L + i, "SKU-1")) + .thenReturn(dropshipCandidate(900L + i, 1000L + i, "WH-1", "DP-SHARED", "SKU-1", 1L)); + when(dropshipCartons.lockAvailablePhysical("TENANT-A", 1000L + i, 1L)) + .thenReturn(List.of(new DropshipCarton())); + InventoryPosition position = new InventoryPosition(); + position.setId(900L + i); position.setCustomerId("TENANT-A"); position.setBalanceId(800L + i); + position.setOverseasWarehouseId("WH-1"); + position.setPalletId(501L); position.setPalletNo("DP-SHARED"); + InventoryBalance balance = new InventoryBalance(); + balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-" + i); + when(inventoryPositions.selectById(900L + i)).thenReturn(position); + when(inventoryBalances.selectById(800L + i)).thenReturn(balance); + } + service.createOneClick("TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of( + new DeliveryRequests.OneClickItem(901L, 1L, "SKU-1"), + new DeliveryRequests.OneClickItem(902L, 1L, "SKU-1")), null, "shared-pallet")); + ArgumentCaptor items = ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(outboundPallets, org.mockito.Mockito.times(2)).insert(items.capture()); + assertEquals(List.of("IN-1", "IN-2"), items.getAllValues().stream().map(OutboundPalletItem::getInboundCode).toList()); + ArgumentCaptor order = ArgumentCaptor.forClass(OutboundOrder.class); + verify(outboundOrders).updateById(order.capture()); + assertEquals(1, order.getValue().getPalletCount()); + assertEquals(2, order.getValue().getPackageCount()); + } + + @Test + void oneClickOutboundRejectsSummarySkuWithoutAvailableDropshipCartons() { + var generator = org.mockito.Mockito.mock(LastlegOrderNoGenerator.class); + ReflectionTestUtils.setField(service, "orderNoGenerator", generator); + InventoryPosition position = new InventoryPosition(); position.setCustomerId("TENANT-A"); position.setOverseasWarehouseId("WH-1"); + when(inventoryPositions.selectById(901L)).thenReturn(position); + DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "PLT-501", "MIXED", 1L); + when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 901L, "MIXED")).thenReturn(candidate); + when(dropshipCartons.lockAvailablePhysical("TENANT-A", 1001L, 1L)).thenReturn(List.of()); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.createOneClick( + "TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of( + new DeliveryRequests.OneClickItem(901L, 1L, "MIXED")), null, "one-click-mixed"))); + + assertEquals(409, exception.getStatusCode().value()); + assertTrue(exception.getReason().contains("一件代发可用箱数不足")); + verify(outboundPallets, never()).insert(any(OutboundPalletItem.class)); + } + + @Test + void concurrentOneClickCreateReplaysCurrentCommittedRowBeforeTakingInventoryLocks() { + var generator = org.mockito.Mockito.mock(LastlegOrderNoGenerator.class); + ReflectionTestUtils.setField(service, "orderNoGenerator", generator); + var request = new DeliveryRequests.OneClickOutbound("recipient", List.of( + new DeliveryRequests.OneClickItem(901L, 2L, "SKU-1", List.of("A", "B"))), "remark", "race-key"); + OutboundOrder winner = outbound(301L, null, null, "OUT-WINNER", "race-key"); + winner.setOutboundType("ONE_CLICK_DROPSHIP"); winner.setRequestHash(service.oneClickRequestHash(request)); + OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setTaskNo("PDA-WINNER"); task.setStatus("PENDING"); + InventoryPosition position = new InventoryPosition(); position.setCustomerId("TENANT-A"); position.setOverseasWarehouseId("WH-1"); + when(inventoryPositions.selectById(901L)).thenReturn(position); + when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("race")); + when(outboundOrders.lockByIdempotency("TENANT-A", "race-key")).thenReturn(winner); + when(pdaTasks.lockByOutbound("TENANT-A", 301L)).thenReturn(task); + assertEquals("PDA-WINNER", service.createOneClick("TENANT-A", "U-1", request).taskNo()); + verify(dropshipProfiles, never()).lockOutboundCandidate(any(), any(), any()); + verify(outboundPallets, never()).insert(any(OutboundPalletItem.class)); + verify(pdaTasks, never()).insert(any(OutboundPdaTask.class)); + } + + @Test + void oneClickReplayRejectsChangedRecipientItemsMarksAndRemark() { + var item = new DeliveryRequests.OneClickItem(901L, 2L, "SKU-1", List.of("A", "B")); + var request = new DeliveryRequests.OneClickOutbound("recipient", List.of(item), "remark", "key"); + OutboundOrder existing = outbound(301L, null, null, "OUT-1", "key"); + existing.setOutboundType("ONE_CLICK_DROPSHIP"); existing.setRequestHash(service.oneClickRequestHash(request)); + when(outboundOrders.findByIdempotency("TENANT-A", "key")).thenReturn(existing); + for (var changed : List.of( + new DeliveryRequests.OneClickOutbound("changed", List.of(item), "remark", "key"), + new DeliveryRequests.OneClickOutbound("recipient", List.of(item), "changed", "key"), + new DeliveryRequests.OneClickOutbound("recipient", List.of(new DeliveryRequests.OneClickItem(902L, 2L, "SKU-1", List.of("A", "B"))), "remark", "key"), + new DeliveryRequests.OneClickOutbound("recipient", List.of(new DeliveryRequests.OneClickItem(901L, 2L, "SKU-1", List.of("A", "C"))), "remark", "key"))) { + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> service.createOneClick("TENANT-A", "U-1", changed)).getStatusCode().value()); + } + verify(inventoryPositions, never()).selectById(any()); + verify(outboundOrders, never()).insert(any(OutboundOrder.class)); + } + + @Test + void oneClickCandidatesUseOneAuthoritativeQueryAndExposeStableBusinessFields() { + DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "DS-A002", "SKU-1", 3L); + candidate.setOverseasWarehouseName("Los Angeles"); candidate.setInboundCode("IN-1"); + candidate.setProductName("Product 1"); + when(dropshipProfiles.listOutboundCandidates("TENANT-A", "WH-1")).thenReturn(List.of(candidate)); + + List result = service.oneClickCandidates("TENANT-A", " WH-1 "); + + assertEquals(1, result.size()); + assertEquals(901L, result.get(0).inventoryPositionId()); + assertEquals("DS-A002", result.get(0).palletNo()); + assertEquals("SKU-1", result.get(0).skuCode()); + assertEquals(3L, result.get(0).availablePackages()); + verify(dropshipProfiles).listOutboundCandidates("TENANT-A", "WH-1"); + verify(inventoryPositions, never()).selectById(any()); + } + + @Test + void oneClickCandidatesAllowPlatformWarehouseQueryWithoutCustomer() { + DropshipOutboundCandidate first = dropshipCandidate(901L, 1001L, "WH-1", "DS-A001", "SKU-1", 1L); + DropshipOutboundCandidate second = dropshipCandidate(902L, 1002L, "WH-1", "DS-A002", "SKU-1", 3L); + when(dropshipProfiles.listOutboundCandidates(null, "WH-1")).thenReturn(List.of(first, second)); + + var result = service.oneClickCandidates(null, " WH-1 "); + + assertEquals(2, result.size()); + assertEquals("DS-A001", result.get(0).palletNo()); + assertEquals("DS-A002", result.get(1).palletNo()); + verify(dropshipProfiles).listOutboundCandidates(null, "WH-1"); + } + + @Test + void oneClickCreationRejectsOrdinaryInventoryBeforeAnyWrite() { + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.createOneClick( + "TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of( + new DeliveryRequests.OneClickItem(901L, 1L, "MIXED")), null, "ordinary-stock"))); + + assertEquals(409, exception.getStatusCode().value()); + assertEquals("所选库存不是可用的一件代发库存,或已被其他作业占用", exception.getReason()); + verify(outboundOrders, never()).insert(any(OutboundOrder.class)); + verify(outboundPallets, never()).insert(any(OutboundPalletItem.class)); + verify(pdaTasks, never()).insert(any(OutboundPdaTask.class)); + } + + @Test + void outboundPdaListAndDetailUseOnlyBoundWarehouse() throws Exception { + assertNotNull(DeliveryTransferService.class.getMethod("pdaPick", String.class, String.class, + String.class, DeliveryRequests.PdaPick.class).getAnnotation(Transactional.class)); + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); + outbound.setOverseasWarehouseId("WH-1"); + OutboundPdaTask task = new OutboundPdaTask(); + task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); + task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + when(pdaTasks.listByWarehouse("WH-1", null)).thenReturn(List.of(task)); + when(pdaTasks.findByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of()); + + assertEquals(1, service.pdaTasks("WH-1", null).size()); + assertEquals("PDA-701", service.pdaTaskDetail("WH-1", "PDA-701").task().taskNo()); + verify(pdaTasks).listByWarehouse("WH-1", null); + } + + @Test + void outboundNumberResolvesDispatchWorkflowInBoundWarehouse() { + OutboundOrder outbound = outbound(301L, 401L, "DSP-401", "OUT-301", "transfer-key"); + outbound.setOverseasWarehouseId("WH-1"); + outbound.setOutboundType("DISPATCH_DELIVERY"); + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(401L); dispatch.setDispatchNo("DSP-401"); dispatch.setOverseasWarehouseId("WH-1"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + when(dispatchOrders.findGlobalByDispatchNo("DSP-401")).thenReturn(dispatch); + + DeliveryResponses.PdaTaskResolution result = service.resolvePdaTask("WH-1", "OUT-301"); + + assertEquals("DISPATCH", result.workflow()); + assertEquals("DSP-401", result.dispatchNo()); + assertEquals("OUT-301", result.outboundNo()); + } + + @Test + void outboundNumberResolvesOneClickTaskInBoundWarehouse() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); + outbound.setOverseasWarehouseId("WH-1"); + outbound.setOutboundType("ONE_CLICK_DROPSHIP"); + OutboundPdaTask task = new OutboundPdaTask(); + task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + when(pdaTasks.findByOutboundAndWarehouse("TENANT-A", 301L, "WH-1")).thenReturn(task); + + DeliveryResponses.PdaTaskResolution result = service.resolvePdaTask("WH-1", "OUT-301"); + + assertEquals("OUTBOUND", result.workflow()); + assertEquals("PDA-701", result.taskNo()); + assertEquals("OUT-301", result.outboundNo()); + } + + @Test + void pdaTaskResolutionDoesNotLeakAnotherWarehouse() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); + outbound.setOverseasWarehouseId("WH-2"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.resolvePdaTask("WH-1", "OUT-301")); + + assertEquals(404, exception.getStatusCode().value()); + verify(pdaTasks, never()).findByOutboundAndWarehouse(any(), any(), any()); + } + + @Test + void outboundPdaDetailReturnsEveryPickablePositionWithStableBusinessCodes() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); + outbound.setOverseasWarehouseId("WH-1"); + OutboundPdaTask task = new OutboundPdaTask(); + task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); + task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + OutboundPalletItem item = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 2); + item.setSkuCode("SKU-1"); item.setInboundCode("IN-A"); + InventoryPosition position = new InventoryPosition(); + position.setId(901L); position.setCustomerId("TENANT-A"); position.setBalanceId(801L); + position.setOverseasWarehouseId("WH-1"); position.setPalletId(501L); position.setPalletNo("PLT-501"); + position.setAvailablePackages(2L); position.setPickedPackages(0L); + InventoryBalance balance = new InventoryBalance(); + balance.setId(801L); balance.setCustomerId("TENANT-A"); balance.setOverseasWarehouseId("WH-1"); + balance.setSkuCode("SKU-1"); balance.setInboundCode("IN-A"); + DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L); + DropshipCarton first = new DropshipCarton(); first.setStatus("AVAILABLE"); first.setSkuCode("SKU-1"); first.setCartonMark("CTN-A"); + DropshipCarton second = new DropshipCarton(); second.setStatus("AVAILABLE"); second.setSkuCode("SKU-1"); second.setCartonMark("CTN-B"); + InventoryPosition otherInbound = new InventoryPosition(); + otherInbound.setId(902L); otherInbound.setCustomerId("TENANT-A"); otherInbound.setBalanceId(802L); + otherInbound.setOverseasWarehouseId("WH-1"); otherInbound.setPalletId(501L); + otherInbound.setAvailablePackages(2L); + InventoryBalance otherBalance = new InventoryBalance(); + otherBalance.setId(802L); otherBalance.setCustomerId("TENANT-A"); otherBalance.setInboundCode("IN-B"); + otherBalance.setSkuCode("SKU-1"); + when(pdaTasks.findByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); + when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position, otherInbound)); + when(inventoryBalances.selectById(801L)).thenReturn(balance); + when(inventoryBalances.selectById(802L)).thenReturn(otherBalance); + when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile); + when(dropshipCartons.listProfile("TENANT-A", 1001L)).thenReturn(List.of(first, second)); + + DeliveryResponses.PdaTaskDetail detail = service.pdaTaskDetail("WH-1", "PDA-701"); + + assertEquals(2, detail.items().get(0).positions().size()); + verify(dropshipProfiles, never()).findPosition("TENANT-A", 902L); + assertEquals(List.of("CTN-A", "CTN-B"), detail.items().get(0).positions().stream() + .map(DeliveryResponses.PdaPickPosition::cartonMark).toList()); + DeliveryResponses.PdaPickPosition projected = detail.items().get(0).positions().get(0); + assertEquals(901L, projected.inventoryPositionId()); + assertEquals("PLT-501", projected.palletNo()); + assertEquals("SKU-1", projected.skuCode()); + assertEquals("IN-A", projected.inboundCode()); + assertEquals(1L, projected.availablePackages()); + + item.setCartonMarks("CTN-B"); + assertEquals(List.of("CTN-B"), service.pdaTaskDetail("WH-1", "PDA-701").items().get(0).positions() + .stream().map(DeliveryResponses.PdaPickPosition::cartonMark).toList()); + second.setStatus("SHIPPED"); + assertTrue(service.pdaTaskDetail("WH-1", "PDA-701").items().get(0).positions().isEmpty()); + } + + @Test + void outboundPdaDetailUsesCartonMarksWhenInventoryBalanceIsMixed() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); + outbound.setOverseasWarehouseId("WH-1"); + OutboundPdaTask task = new OutboundPdaTask(); + task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); + OutboundPalletItem item = pallet(501L, "PLT-501", "ONT8", "Ontario", null, 1); + item.setSkuCode("SKU-1"); + InventoryPosition position = new InventoryPosition(); + position.setId(901L); position.setCustomerId("TENANT-A"); position.setBalanceId(801L); + position.setOverseasWarehouseId("WH-1"); position.setPalletId(501L); position.setPalletNo("PLT-501"); position.setAvailablePackages(1L); + InventoryBalance balance = new InventoryBalance(); + balance.setId(801L); balance.setCustomerId("TENANT-A"); balance.setOverseasWarehouseId("WH-1"); balance.setSkuCode("MIXED"); + DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L); + DropshipCarton carton = new DropshipCarton(); carton.setStatus("AVAILABLE"); carton.setSkuCode("SKU-1"); carton.setCartonMark("CTN-A"); + when(pdaTasks.findByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); + when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position)); + when(inventoryBalances.selectById(801L)).thenReturn(balance); + when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile); + when(dropshipCartons.listProfile("TENANT-A", 1001L)).thenReturn(List.of(carton)); + + DeliveryResponses.PdaTaskDetail detail = service.pdaTaskDetail("WH-1", "PDA-701"); + + assertEquals("CTN-A", detail.items().get(0).positions().get(0).cartonMark()); + assertEquals("SKU-1", detail.items().get(0).positions().get(0).skuCode()); + } + + @Test + void dropshipPalletScanAtomicallyPicksEveryRemainingCartonOnThePallet() { + var commands = org.mockito.Mockito.mock(com.baiyue.lastleg.inventory.command.service.InventoryCommandService.class); + ReflectionTestUtils.setField(service, "inventoryCommands", commands); + OutboundOrder outbound = outbound(301L, null, null, "OD-WH-1", "whole-pallet"); + outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); + OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A"); + task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + OutboundPalletItem item = pallet(501L, "DP-1", "ONT8", "Ontario", "IN-A", 2); + item.setSkuCode("SKU-1"); item.setCartonMarks("BOX-A,BOX-B"); item.setStatus("WAIT_PICK"); item.setPickedPackageCount(0); + OutboundPalletItem secondItem = pallet(501L, "DP-1", "ONT8", "Ontario", "IN-A", 1); + secondItem.setId(1502L); secondItem.setSkuCode("SKU-2"); secondItem.setCartonMarks("BOX-C"); + secondItem.setStatus("WAIT_PICK"); secondItem.setPickedPackageCount(0); + InventoryPosition position = new InventoryPosition(); position.setId(901L); position.setCustomerId("TENANT-A"); + position.setBalanceId(801L); position.setPalletId(501L); position.setPalletNo("DP-1"); + position.setOverseasWarehouseId("WH-1"); position.setAvailablePackages(2L); + InventoryPosition secondPosition = new InventoryPosition(); secondPosition.setId(902L); secondPosition.setCustomerId("TENANT-A"); + secondPosition.setBalanceId(802L); secondPosition.setPalletId(501L); secondPosition.setPalletNo("DP-1"); + secondPosition.setOverseasWarehouseId("WH-1"); secondPosition.setAvailablePackages(1L); + InventoryBalance balance = new InventoryBalance(); balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-A"); balance.setSkuCode("SKU-1"); + InventoryBalance secondBalance = new InventoryBalance(); secondBalance.setCustomerId("TENANT-A"); secondBalance.setInboundCode("IN-A"); secondBalance.setSkuCode("SKU-2"); + DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L); + DropshipOrderProfile secondProfile = new DropshipOrderProfile(); secondProfile.setId(1002L); + DropshipCarton first = new DropshipCarton(); first.setId(1101L); first.setProductQuantity(7L); + DropshipCarton second = new DropshipCarton(); second.setId(1102L); second.setProductQuantity(13L); + first.setStatus("AVAILABLE"); first.setSkuCode("SKU-1"); first.setCartonMark("BOX-A"); + second.setStatus("AVAILABLE"); second.setSkuCode("SKU-1"); second.setCartonMark("BOX-B"); + DropshipCarton third = new DropshipCarton(); third.setId(1103L); third.setProductQuantity(5L); + third.setStatus("AVAILABLE"); third.setSkuCode("SKU-2"); third.setCartonMark("BOX-C"); + when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundOrders.lockByNo("TENANT-A", "OD-WH-1")).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item, secondItem)); + when(outboundPallets.selectById(item.getId())).thenReturn(item); + when(outboundPallets.selectById(secondItem.getId())).thenReturn(secondItem); + when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position, secondPosition)); + when(inventoryPositions.selectById(901L)).thenReturn(position); + when(inventoryPositions.selectById(902L)).thenReturn(secondPosition); + when(inventoryBalances.selectById(801L)).thenReturn(balance); + when(inventoryBalances.selectById(802L)).thenReturn(secondBalance); + when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile); + when(dropshipProfiles.findPosition("TENANT-A", 902L)).thenReturn(secondProfile); + when(dropshipCartons.listProfile("TENANT-A", 1001L)).thenReturn(List.of(first, second)); + when(dropshipCartons.listProfile("TENANT-A", 1002L)).thenReturn(List.of(third)); + when(dropshipCartons.findByProfileAndMark("TENANT-A", 1001L, "BOX-A")).thenReturn(first); + when(dropshipCartons.findByProfileAndMark("TENANT-A", 1001L, "BOX-B")).thenReturn(second); + when(dropshipCartons.findByProfileAndMark("TENANT-A", 1002L, "BOX-C")).thenReturn(third); + when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 2L, 20L)).thenReturn(1); + when(dropshipSkuInventory.deduct("TENANT-A", 1002L, "SKU-2", 1L, 5L)).thenReturn(1); + + DeliveryResponses.PdaTask result = service.pdaPickPallet("WH-1", "U-1", "PDA-701", "DP-1", + new DeliveryRequests.PdaPalletPick("whole-pallet-1")); + + assertEquals("IN_PROGRESS", result.status()); + assertEquals(2, item.getPickedPackageCount()); assertEquals("PICKED", item.getStatus()); + assertEquals(1, secondItem.getPickedPackageCount()); assertEquals("PICKED", secondItem.getStatus()); + assertEquals("SHIPPED", first.getStatus()); assertEquals("SHIPPED", second.getStatus()); + assertEquals("SHIPPED", third.getStatus()); + assertEquals("PICKED", outbound.getStatus()); + verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 2L, 20L); + verify(dropshipSkuInventory).deduct("TENANT-A", 1002L, "SKU-2", 1L, 5L); + } + + @Test + void dropshipPartialPicksDeductExactCartonProductQuantities() { + var commands = org.mockito.Mockito.mock(com.baiyue.lastleg.inventory.command.service.InventoryCommandService.class); + ReflectionTestUtils.setField(service, "inventoryCommands", commands); + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "quantity-test"); + outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("ONE_CLICK_DROPSHIP"); + OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A"); + task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + OutboundPalletItem item = pallet(501L, "DP-1", "ONT8", "Ontario", "IN-A", 2); + item.setSkuCode("MIXED"); item.setCartonMarks("BOX-A,BOX-B"); + InventoryPosition position = new InventoryPosition(); position.setId(901L); position.setCustomerId("TENANT-A"); + position.setBalanceId(801L); position.setPalletId(501L); position.setOverseasWarehouseId("WH-1"); + InventoryBalance balance = new InventoryBalance(); balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-A"); + DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L); + DropshipCarton first = new DropshipCarton(); first.setId(1101L); first.setProductQuantity(7L); + DropshipCarton second = new DropshipCarton(); second.setId(1102L); second.setProductQuantity(13L); + first.setStatus("AVAILABLE"); first.setSkuCode("MIXED"); first.setCartonMark("BOX-A"); + second.setStatus("AVAILABLE"); second.setSkuCode("MIXED"); second.setCartonMark("BOX-B"); + when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound); + when(outboundPallets.selectById(item.getId())).thenReturn(item); + when(inventoryPositions.selectById(901L)).thenReturn(position); + when(inventoryBalances.selectById(801L)).thenReturn(balance); + when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile); + when(dropshipCartons.findByProfileAndMark("TENANT-A", 1001L, "BOX-A")).thenReturn(first); + when(dropshipCartons.findByProfileAndMark("TENANT-A", 1001L, "BOX-B")).thenReturn(second); + when(dropshipCartonSkus.listCarton("TENANT-A", 1101L)).thenReturn(List.of( + cartonSku("SKU-1", 7), cartonSku("SKU-2", 3))); + when(dropshipCartonSkus.listCarton("TENANT-A", 1102L)).thenReturn(List.of( + cartonSku("SKU-1", 13), cartonSku("SKU-2", 2))); + when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L)).thenReturn(1); + when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-2", 1L, 3L)).thenReturn(1); + when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L)).thenReturn(1); + when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-2", 1L, 2L)).thenReturn(1); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "U-1", "PDA-701", + new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "wrong-mark", List.of("WRONG-BOX")))); + service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-7", List.of("BOX-A"))); + assertEquals(1, item.getPickedPackageCount()); assertEquals("AVAILABLE", second.getStatus()); + assertEquals("PICKING", outbound.getStatus()); + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "U-1", "PDA-701", + new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "repeat-box-new-key", List.of("BOX-A")))); + service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-13", List.of("BOX-B"))); + verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L); + verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-2", 1L, 3L); + verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L); + verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-2", 1L, 2L); + assertEquals("SHIPPED", first.getStatus()); assertEquals("SHIPPED", second.getStatus()); + assertEquals(2, item.getPickedPackageCount()); + } + + @Test + void outboundPdaPickReplayDoesNotDeductInventoryAgainAndRejectsChangedRequest() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); + outbound.setOverseasWarehouseId("WH-1"); + OutboundPdaTask task = new OutboundPdaTask(); + task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); + task.setTaskNo("PDA-701"); task.setStatus("IN_PROGRESS"); + com.baiyue.lastleg.delivery.entity.OutboundPdaPick replay = new com.baiyue.lastleg.delivery.entity.OutboundPdaPick(); + replay.setTaskId(701L); replay.setOutboundPalletItemId(801L); replay.setInventoryPositionId(901L); + replay.setPackages(2L); + when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(pdaPicks.findByKey("TENANT-A", "pick-1")).thenReturn(replay); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + + service.pdaPick("WH-1", "7", "PDA-701", new DeliveryRequests.PdaPick(801L, 901L, 2L, "pick-1")); + verify(outboundPallets, never()).selectById(any()); + + ResponseStatusException conflict = assertThrows(ResponseStatusException.class, + () -> service.pdaPick("WH-1", "7", "PDA-701", + new DeliveryRequests.PdaPick(801L, 901L, 3L, "pick-1"))); + assertEquals(409, conflict.getStatusCode().value()); + } + + @Test + void outboundPdaCompleteRequiresAllPackagesAndReplaysOnlySameKey() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); + outbound.setOverseasWarehouseId("WH-1"); + OutboundPdaTask task = new OutboundPdaTask(); + task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); + task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + OutboundPalletItem item = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 2); + item.setPickedPackageCount(1); + when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); + + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-1"))) + .getStatusCode().value()); + + item.setPickedPackageCount(2); + service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-1")); + assertEquals("COMPLETED", task.getStatus()); + service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-1")); + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-2"))) + .getStatusCode().value()); + } + + 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 DeliveryRequests.Transfer transferRequest(String idempotencyKey, String remark) { + return new DeliveryRequests.Transfer(idempotencyKey, LocalDateTime.now(), "SELF_DELIVERY", null, null, + "ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, remark, + List.of(receivableDeliveryFee())); + } + + private static DeliveryRequests.FeeItem receivableDeliveryFee() { + return new DeliveryRequests.FeeItem("DELIVERY_FEE", "派送费", "RECEIVABLE", "CNY", + new BigDecimal("10.00"), null, null, null, null, null, null, null, null, + null, null, null, null, null, null); + } + + 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.setDestinationWarehouseId("DEST-ONT8"); value.setDestinationWarehouseCode("ONT8"); + value.setDestinationWarehouseName("Ontario"); value.setDeliveryGroupKey("CODE:ONT8"); + 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; + } + + private static DropshipOutboundCandidate dropshipCandidate(Long positionId, Long profileId, String warehouseId, + String palletNo, String sku, Long packages) { + DropshipOutboundCandidate value = new DropshipOutboundCandidate(); + value.setInventoryPositionId(positionId); value.setProfileId(profileId); value.setWarehousePalletId(501L); + value.setOverseasWarehouseId(warehouseId); value.setPalletNo(palletNo); value.setSkuCode(sku); + value.setAvailablePackages(packages); return value; + } + + private static DropshipCartonSku cartonSku(String sku, long quantity) { + DropshipCartonSku value = new DropshipCartonSku(); + value.setSkuCode(sku); value.setProductQuantity(quantity); + 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/CargoDiscardPhysicalCartonMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardPhysicalCartonMapperContractTest.java new file mode 100644 index 000000000..f1be16f64 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardPhysicalCartonMapperContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.discard; + +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CargoDiscardPhysicalCartonMapperContractTest { + @Test + void candidatesJoinSkuFactsButCountAvailableOrdersPhysically() throws Exception { + String candidates = select("candidates", String.class, String.class); + assertTrue(candidates.contains("JOIN ll_dropship_carton_sku cs")); + assertTrue(candidates.contains("cs.carton_id=c.id")); + assertTrue(candidates.contains("COUNT(c.id) available_packages")); + + String physicalTotal = select("countAvailableOrder", String.class, Long.class); + assertTrue(physicalTotal.contains("COUNT(*) FROM ll_dropship_carton")); + assertTrue(!physicalTotal.contains("ll_dropship_carton_sku")); + } + + @Test + void skuSelectionResolvesThroughPhysicalCartonSkuFacts() throws Exception { + String count = select("countAvailable", String.class, Long.class, String.class); + String lock = select("lockAvailable", String.class, Long.class, String.class, int.class); + for (String sql : new String[] {count, lock}) { + assertTrue(sql.contains("EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs")); + assertTrue(sql.contains("cs.carton_id=c.id")); + assertTrue(sql.contains("cs.sku_code=#{skuCode}")); + } + assertTrue(lock.contains("FOR UPDATE")); + } + + private String select(String method, Class... parameterTypes) throws Exception { + Select annotation = CargoDiscardInventoryMapper.class.getMethod(method, parameterTypes) + .getAnnotation(Select.class); + assertNotNull(annotation); + return String.join("", annotation.value()); + } +} 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..17061788e --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java @@ -0,0 +1,164 @@ +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.DropshipCartonSku; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +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; + @Mock DropshipCartonSkuMapper cartonSkus; + @Mock DropshipSkuInventoryMapper skuInventories; + @Mock DropshipOrderProfileMapper profiles; + @Mock InventoryPositionMapper positions; + @Mock InventoryBalanceMapper balances; + private CargoDiscardInventoryAdjustmentService service; + + @BeforeEach + void setUp() { + service = new CargoDiscardInventoryAdjustmentService(); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "cartonLedgers", ledgers); + ReflectionTestUtils.setField(service, "cartonSkus", cartonSkus); + ReflectionTestUtils.setField(service, "skuInventories", skuInventories); + ReflectionTestUtils.setField(service, "profiles", profiles); + ReflectionTestUtils.setField(service, "positions", positions); + ReflectionTestUtils.setField(service, "balances", balances); + } + + @Test + void pdaDiscardDeductsEverySkuInsideOnePhysicalCartonOnce() { + CargoDiscardApplication application = application(1); + CargoDiscardItem item = item(1); + item.setProfileId(201L); + DropshipCarton carton = carton(1L); + carton.setSkuCode("MIXED"); carton.setProductQuantity(5L); + DropshipCartonSku skuA = cartonSku("SKU-A", 2L); + DropshipCartonSku skuB = cartonSku("SKU-B", 3L); + DropshipOrderProfile profile = new DropshipOrderProfile(); + profile.setId(201L); profile.setCustomerId("100"); profile.setInventoryPositionId(801L); + InventoryPosition position = new InventoryPosition(); + position.setId(801L); position.setBalanceId(901L); + when(inventory.lockSkuInventory("100", 301L)).thenReturn(sku(5)); + when(inventory.lockCarton("100", 1L)).thenReturn(carton); + when(cartonSkus.listCarton("100", 1L)).thenReturn(List.of(skuA, skuB)); + when(skuInventories.deduct("100", 201L, "SKU-A", 1, 2)).thenReturn(1); + when(skuInventories.deduct("100", 201L, "SKU-B", 1, 3)).thenReturn(1); + when(profiles.selectById(201L)).thenReturn(profile); + when(positions.findTenantPosition("100", 801L)).thenReturn(position); + when(positions.deductAvailable("100", 801L, 1)).thenReturn(1); + when(balances.deductAvailable("100", 901L, 1)).thenReturn(1); + when(inventory.changeCartonStatus("100", 1L, "DISCARD_PENDING", "DISCARDED")).thenReturn(1); + when(inventory.skuPackages("100", 301L)).thenReturn(4L); + + var result = service.discardPalletItem("100", application, item, List.of(link(1L)), + 1, "operator", LocalDateTime.now()); + + assertEquals(1, result.discardedPackages()); + verify(skuInventories).deduct("100", 201L, "SKU-A", 1, 2); + verify(skuInventories).deduct("100", 201L, "SKU-B", 1, 3); + verify(positions).deductAvailable("100", 801L, 1); + } + + @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; + } + private DropshipCartonSku cartonSku(String skuCode, long quantity) { + DropshipCartonSku value = new DropshipCartonSku(); + value.setSkuCode(skuCode); value.setProductQuantity(quantity); 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..fbe05b4c4 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -0,0 +1,205 @@ +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.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.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +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; + @Mock LastlegOrderStatusHistoryMapper orderHistories; + + 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, "orderNoGenerator", new LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "orderHistories", orderHistories); + 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 candidateTotalCountsPhysicalCartonsInsteadOfSkuRows() { + CargoDiscardCandidateRow skuA = candidate("SKU-A", 20L); + CargoDiscardCandidateRow skuB = candidate("SKU-B", 20L); + when(inventory.candidates("100", "RK-1")).thenReturn(List.of(skuA, skuB)); + when(inventory.countAvailableOrder("100", 101L)).thenReturn(20L); + + var result = service.candidates("100", "RK-1"); + + assertEquals(1, result.size()); + assertEquals(20, result.get(0).availablePackages()); + assertEquals(2, result.get(0).skus().size()); + } + + @Test + void approvalPushesToPdaWithoutDeductingInventory() { + 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); + var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok")); + + assertEquals(CargoDiscardService.APPROVED, detail.summary().status()); + verify(adjustments, never()).discard(any(), any(), any(), any(), any(), any()); + verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt()); + assertEquals("ok", 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; + } + + private CargoDiscardCandidateRow candidate(String skuCode, long packages) { + CargoDiscardCandidateRow value = new CargoDiscardCandidateRow(); + value.setOrderId(101L); value.setInboundCode("RK-1"); value.setInboundOrderNo("IN-1"); + value.setOverseasWarehouseId("WH-1"); value.setOverseasWarehouseName("LA"); + value.setSkuCode(skuCode); value.setProductName(skuCode); value.setAvailablePackages(packages); + 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..7cb4ac1b5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -0,0 +1,78 @@ +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", "relabelCartonMarks" + ); + + @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)); + assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("relabelAvailable", + String.class, String.class, String.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")); + } + + @Test + void candidateWarehouseComesFromPhysicalInventoryOrFrozenInboundPlan() throws Exception { + for (Method method : List.of( + DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, + String.class, String.class, String.class), + DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL")); + assertTrue(sql.contains("pos.overseas_warehouse_id")); + assertTrue(sql.contains("CAST(ip.overseas_warehouse_id AS CHAR)")); + assertTrue(sql.contains("ll_inventory_balance")); + } + } + + @Test + void candidateMarksFollowPdaRelabelPalletBinding() throws Exception { + for (Method method : List.of( + DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, + String.class, String.class, String.class), + DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + assertTrue(sql.contains("rp.id=ri.source_inventory_position_id")); + assertTrue(sql.contains("rp.pallet_id=p.warehouse_pallet_id")); + assertTrue(sql.contains("ri.fba_carton_mark")); + assertTrue(sql.contains("AS relabelCartonMarks")); + } + } + + 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..24fac6a54 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -0,0 +1,49 @@ +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("capacity: { type: integer, enum: [15, 30] }")); + 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")); + String preArrangeSchema = yaml.substring(yaml.indexOf(" SmartDispatchPreArrange:"), + yaml.indexOf(" SmartDispatchDraftPallet:")); + String updateSchema = yaml.substring(yaml.indexOf(" DispatchUpdate:"), + yaml.indexOf(" DeliveryTransfer:")); + assertFalse(preArrangeSchema.contains("route:")); + assertFalse(updateSchema.contains("route:")); + 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(yaml.indexOf("/api/platform/v1/last-leg/dispatches:"), + yaml.indexOf("/api/platform/v1/last-leg/outbound-orders:")); + 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..483f033f3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java @@ -0,0 +1,75 @@ +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.dispatch.service.DispatchBolPrintService; +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; + @Mock DispatchBolPrintService printService; + private DispatchQueryController controller; + + @BeforeEach + void setUp() { + controller = new DispatchQueryController(); + ReflectionTestUtils.setField(controller, "service", service); + ReflectionTestUtils.setField(controller, "printService", printService); + } + + @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 listAndDetailUseDedicatedDispatchListAuthority() 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); + PreAuthorize print = DispatchQueryController.class + .getMethod("printBol", AuthenticatedUser.class, String.class, jakarta.servlet.http.HttpServletRequest.class) + .getAnnotation(PreAuthorize.class); + + assertThat(page.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')"); + assertThat(detail.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')"); + assertThat(print.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_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..4fb109ff9 --- /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), "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..605c962ac --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -0,0 +1,303 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +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.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.DispatchPdaOperation; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +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 org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +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 DispatchPdaWorkflowServiceTest { + @Mock DispatchOrderMapper orders; + @Mock OutboundOrderMapper outboundOrders; + @Mock DispatchItemMapper items; + @Mock DispatchPdaTaskMapper tasks; + @Mock DispatchPdaPickMapper picks; + @Mock DispatchPdaOperationMapper operations; + @Mock DispatchPalletLockMapper palletLocks; + @Mock InventoryPositionMapper positions; + @Mock InventoryCommandService inventory; + @Mock DispatchPickOrderAttributionService attribution; + private DispatchPdaWorkflowService service; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + service = new DispatchPdaWorkflowService(); + objectMapper = new ObjectMapper(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "tasks", tasks); + ReflectionTestUtils.setField(service, "picks", picks); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "palletLocks", palletLocks); + ReflectionTestUtils.setField(service, "positions", positions); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "pickAttribution", attribution); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + } + + @Test + void labelsTransferredPalletsWithOriginalAndCurrentFbaWarehouses() { + assertEquals("ONT8 -> ONT", DispatchPdaWorkflowService.warehouseRouteLabel("ONT8", "ONT")); + assertNull(DispatchPdaWorkflowService.warehouseRouteLabel("ONT8", "ont8")); + assertNull(DispatchPdaWorkflowService.warehouseRouteLabel(null, "ONT")); + } + + @Test + void taskListUsesOnlyTokenBoundWarehouseAndResolvedCustomer() { + DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); + DispatchPdaTask task = task(11L, "100", "WH-1", "PDA_PENDING"); + when(tasks.listByWarehouse("WH-1", null)).thenReturn(List.of(task)); + when(orders.selectById(1L)).thenReturn(order); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of()); + + List result = service.list("WH-1", null); + + assertEquals(1, result.size()); + assertEquals("DSP-1", result.get(0).dispatchNo()); + verify(tasks).listByWarehouse("WH-1", null); + } + + @Test + void detailRejectsDispatchFromAnotherWarehouseBeforeCustomerLookup() { + when(orders.findGlobalByDispatchNo("DSP-1")) + .thenReturn(order("100", "DSP-1", "WH-2", "PENDING_DEPARTURE")); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.task("WH-1", "DSP-1")); + + assertEquals(404, exception.getStatusCode().value()); + verify(tasks, never()).findByDispatch(any(), any()); + } + + @Test + void completedTaskExposesTransferDeliveryGateBeforeDeparture() { + DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); + order.setVehiclePlate("CA-100"); + DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); + DispatchItem item = item(21L, "100", 6); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(tasks.findByDispatch("100", 1L)).thenReturn(task); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); + when(picks.totalItem("100", 11L, 21L)).thenReturn(6L); + + DispatchPdaResponses.Task result = service.task("WH-1", "DSP-1"); + + assertFalse(result.canDepart()); + assertEquals("请先在平台完成“转派送”,生成待出库单后才能发车。", result.departBlockReason()); + } + + @Test + void completedTaskExposesDepartureWhenPendingOutboundExists() { + DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); + order.setVehiclePlate("CA-100"); + DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); + DispatchItem item = item(21L, "100", 6); + OutboundOrder outbound = new OutboundOrder(); + outbound.setStatus("WAIT_OUTBOUND"); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(tasks.findByDispatch("100", 1L)).thenReturn(task); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); + when(picks.totalItem("100", 11L, 21L)).thenReturn(6L); + when(outboundOrders.findByDispatch("100", 1L)).thenReturn(outbound); + + DispatchPdaResponses.Task result = service.task("WH-1", "DSP-1"); + + assertTrue(result.canDepart()); + assertNull(result.departBlockReason()); + } + + @Test + void exactPickRetryReturnsPersistedResultAfterTaskAutomaticallyCompleted() throws Exception { + DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); + order.setVehiclePlate("CA-100"); + DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); + task.setOperatorId("7"); + DispatchPdaRequests.Pick request = new DispatchPdaRequests.Pick(31L, 2, 2, "pick-1", null); + DispatchPdaResponses.Picked persisted = new DispatchPdaResponses.Picked(21L, 31L, 2, 2, false, false); + DispatchPdaOperation replay = operation("PICK", request, persisted); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(tasks.lockByDispatch("100", 1L)).thenReturn(task); + when(operations.findKey("100", "pick-1")).thenReturn(replay); + + DispatchPdaResponses.Picked result = service.pick("WH-1", "DSP-1", 21L, "7", "Picker", request); + + assertEquals(2, result.packages()); + verify(inventory, never()).allocate(any(), any(), any()); + verify(picks, never()).insert((DispatchPdaPick) any()); + verify(items, never()).bindReceivedPallets(any(), any()); + } + + @Test + void completionRejectsPartialPickingWithoutStatusDrift() { + DispatchOrder order = order("100", "DSP-1", "WH-1", "PICKING"); + order.setVehiclePlate("CA-100"); + DispatchPdaTask task = task(11L, "100", "WH-1", "PICKING"); + DispatchItem item = item(21L, "100", 6); + DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(3L); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(tasks.lockByDispatch("100", 1L)).thenReturn(task); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); + when(picks.listTask("100", 11L)).thenReturn(List.of(pick)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("done-1"))); + + assertEquals(409, exception.getStatusCode().value()); + assertEquals("PICKING", order.getStatus()); + verify(orders, never()).updateById((DispatchOrder) any()); + } + + @Test + void automaticallyCompletedTaskPersistsFirstCompleteKeyThenReplaysIt() throws Exception { + DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); + order.setVehiclePlate("CA-100"); + DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); + DispatchItem item = item(21L, "100", 6); + DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L); + DispatchPdaRequests.CompletePicking request = new DispatchPdaRequests.CompletePicking("done-1"); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(tasks.lockByDispatch("100", 1L)).thenReturn(task); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); + when(picks.listTask("100", 11L)).thenReturn(List.of(pick)); + + DispatchPdaResponses.PickingCompleted first = service.completePicking("WH-1", "DSP-1", request); + assertFalse(first.replayed()); + verify(operations).insert((DispatchPdaOperation) any()); + + DispatchPdaOperation replay = operation("COMPLETE_PICKING", request, first); + when(operations.findKey("100", "done-1")).thenReturn(replay); + DispatchPdaResponses.PickingCompleted second = service.completePicking("WH-1", "DSP-1", request); + assertTrue(second.replayed()); + } + + @Test + void departRequiresFullyCompletedPickingAndPendingOutbound() { + DispatchOrder order = order("100", "DSP-1", "WH-1", "PARTIAL_READY_TO_DEPART"); + order.setVehiclePlate("CA-100"); + OutboundOrder outbound = new OutboundOrder(); outbound.setStatus("WAIT_OUTBOUND"); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null))); + + assertEquals(409, exception.getStatusCode().value()); + verify(outboundOrders, never()).updateById((OutboundOrder) any()); + } + + @Test + void successfulDeparturePersistsBusinessDepartureTime() { + DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); + order.setVehiclePlate("CA-100"); + DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); + DispatchItem item = item(21L, "100", 6); + DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L); + OutboundOrder outbound = new OutboundOrder(); + outbound.setOutboundNo("OB-1"); outbound.setStatus("WAIT_OUTBOUND"); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound); + when(tasks.lockByDispatch("100", 1L)).thenReturn(task); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); + when(picks.totalItem("100", 11L, 21L)).thenReturn(6L); + when(picks.listTask("100", 11L)).thenReturn(List.of(pick)); + when(palletLocks.selectList(any())).thenReturn(List.of()); + + DispatchPdaResponses.Departed result = service.depart( + "WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null)); + + assertEquals("DEPARTED", result.status()); + assertNotNull(order.getDepartAt()); + assertEquals(order.getDepartAt(), order.getUpdatedAt()); + verify(orders).updateById(order); + assertEquals("DEPARTED", order.getStatus()); + assertEquals("OUTBOUNDED", outbound.getStatus()); + } + + @Test + void exactDepartRetryReturnsPersistedResultAfterStatusChanged() throws Exception { + DispatchOrder order = order("100", "DSP-1", "WH-1", "DEPARTED"); + DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null); + DispatchPdaResponses.Departed persisted = new DispatchPdaResponses.Departed("DSP-1", "DEPARTED", 12, false); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(operations.findKey("100", "depart-1")).thenReturn(operation("DEPART", request, persisted)); + + DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request); + + assertEquals("DEPARTED", result.status()); + assertTrue(result.replayed()); + } + + private DispatchPdaOperation operation(String type, Object request, Object result) throws Exception { + DispatchPdaOperation value = new DispatchPdaOperation(); + value.setTaskId(11L); value.setOperationType(type); value.setStatus("COMPLETED"); + value.setRequestHash(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)))); + value.setResultJson(objectMapper.writeValueAsString(result)); + return value; + } + + private static DispatchOrder order(String customerId, String dispatchNo, String warehouseId, String status) { + DispatchOrder value = new DispatchOrder(); + value.setId(1L); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo); + value.setOverseasWarehouseId(warehouseId); value.setStatus(status); + return value; + } + + private static DispatchPdaTask task(Long id, String customerId, String warehouseId, String status) { + DispatchPdaTask value = new DispatchPdaTask(); + value.setId(id); value.setCustomerId(customerId); value.setDispatchOrderId(1L); + value.setTaskNo("DPDA-1"); value.setWarehouseId(warehouseId); value.setStatus(status); + return value; + } + + private static DispatchItem item(Long id, String customerId, int packages) { + DispatchItem value = new DispatchItem(); + value.setId(id); value.setCustomerId(customerId); value.setDispatchOrderId(1L); + value.setOverseasWarehouseId("WH-1"); value.setWarehousePalletId(41L); value.setPackageCount(packages); + return value; + } +} 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..dd7339d81 --- /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) -> "t1".equals(value.getCustomerId()) + && "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/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java new file mode 100644 index 000000000..c0d40b167 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -0,0 +1,108 @@ +package com.baiyue.lastleg.dispatch.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; +import com.baiyue.lastleg.client.PrintPdfClient; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +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.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class DispatchBolPrintServiceTest { + @Mock private DispatchService dispatches; + @Mock private PrintPdfClient print; + @Mock private DomesticWarehouseDirectoryClient warehouses; + @InjectMocks private DispatchBolPrintService service; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "internalToken", "internal-test-token"); + } + + @Test + void usesDenseFirstAndContinuationPageCapacities() { + assertThat(DispatchBolPrintService.pageCount(1)).isEqualTo(1); + assertThat(DispatchBolPrintService.pageCount(18)).isEqualTo(1); + assertThat(DispatchBolPrintService.pageCount(19)).isEqualTo(2); + assertThat(DispatchBolPrintService.pageCount(44)).isEqualTo(2); + assertThat(DispatchBolPrintService.pageCount(45)).isEqualTo(3); + assertThat(DispatchBolPrintService.pageCount(70)).isEqualTo(3); + assertThat(DispatchBolPrintService.pageCount(71)).isEqualTo(4); + } + + @Test + void resolvesTheOperatingWarehouseAddressIntoTheBolRequest() { + String warehouseId = "193000000000200003"; + DispatchResponse detail = new DispatchResponse(1L, "DS-WH-20260911-624449", "DEV-23653906", + null, null, "五月开发验收车队", null, null, warehouseId, "洛杉矶仓", null, + null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE", + 1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), + List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2", + "NYEXX2609110214", null, null, 1))); + var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga", + null, "91730", "10767 7th Street", null); + var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, + "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE"); + when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); + when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token")) + .thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of(address)))); + when(print.generateDispatchBol(eq("internal-test-token"), eq("request-1"), any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF) + .header("X-Print-Page-Count", "1").body("%PDF-".getBytes(StandardCharsets.US_ASCII))); + + service.generate(detail.dispatchNo(), "request-1"); + + ArgumentCaptor request = ArgumentCaptor.forClass(PrintPdfClient.Request.class); + verify(print).generateDispatchBol(eq("internal-test-token"), eq("request-1"), request.capture()); + assertThat(request.getValue().documents()).singleElement().satisfies(document -> + assertThat(document.shipFromAddress()).isEqualTo( + "10767 7th Street, Rancho Cucamonga, California 91730, US")); + } + + @Test + void createsOneBolDocumentPerFbaWarehouse() { + String warehouseId = "193000000000200003"; + DispatchResponse detail = new DispatchResponse(1L, "DS-WH-20260911-624449", "DEV-23653906", + null, null, "五月开发验收车队", null, null, warehouseId, "洛杉矶仓", null, + null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE", + 2, 2, 2, 0, 0, 2, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), + List.of( + new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse", + "NYEXX2609110214", null, null, 1), + new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse", + "NYEXX2609110215", null, null, 1))); + var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, + "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE"); + when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); + when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token")) + .thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of()))); + when(print.generateDispatchBol(eq("internal-test-token"), eq("request-2"), any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF) + .header("X-Print-Page-Count", "2").body("%PDF-".getBytes(StandardCharsets.US_ASCII))); + + service.generate(detail.dispatchNo(), "request-2"); + + ArgumentCaptor request = ArgumentCaptor.forClass(PrintPdfClient.Request.class); + verify(print).generateDispatchBol(eq("internal-test-token"), eq("request-2"), request.capture()); + assertThat(request.getValue().documents().stream() + .map(PrintPdfClient.Document::fbaWarehouseCode).toList()) + .containsExactly("ABE2", "ONT8"); + } +} 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..f40235374 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -0,0 +1,474 @@ +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.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.entity.DispatchDraft; +import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; +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.DispatchBolMapper; +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 com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import 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.assertNull; +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 DispatchBolMapper bolLines; + @Mock DispatchPalletLockMapper locks; + @Mock DispatchCandidateMapper candidates; + @Mock DispatchDraftMapper drafts; + @Mock DispatchDraftContainerMapper draftContainers; + @Mock DispatchPdaTaskMapper pdaTasks; + @Mock LastlegOrderMapper tailOrders; + @Mock OutboundOrderMapper outboundOrders; + private DispatchService service; + + @BeforeEach + void setUp() { + service = new DispatchService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "bolLines", bolLines); + ReflectionTestUtils.setField(service, "locks", locks); + ReflectionTestUtils.setField(service, "candidates", candidates); + ReflectionTestUtils.setField(service, "drafts", drafts); + ReflectionTestUtils.setField(service, "draftContainers", draftContainers); + ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks); + ReflectionTestUtils.setField(service, "tailOrders", tailOrders); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); + ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); + lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of()); + lenient().when(bolLines.findByDispatchOrder(any(), any())).thenReturn(List.of()); + lenient().when(tailOrders.selectList(any())).thenReturn(List.of()); + } + + @Test + void preArrangeRejectsAHeldTailOrderBeforeCreatingDispatchDocuments() { + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + LastlegOrder held = new LastlegOrder(); + held.setId(2L); held.setCustomerId("100"); held.setOrderNo("LMO-HOLD-1"); held.setDetained(true); + when(tailOrders.selectList(any())).thenReturn(List.of(held)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, + null, null, null))); + + assertEquals(409, exception.getStatusCode().value()); + assertEquals("暂扣订单不可创建排车或出库计划:LMO-HOLD-1", exception.getReason()); + verify(orders, never()).insert(any(DispatchOrder.class)); + } + + @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"))); + when(items.findByDispatchOrder(eq("100"), any())).thenReturn(List.of(item(11L, "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()); + ArgumentCaptor pdaTask = ArgumentCaptor.forClass(DispatchPdaTask.class); + verify(pdaTasks).insert(pdaTask.capture()); + assertEquals(dispatch.getValue().getId(), pdaTask.getValue().getDispatchOrderId()); + assertEquals("WH-1", pdaTask.getValue().getWarehouseId()); + assertEquals("PDA_PENDING", pdaTask.getValue().getStatus()); + assertEquals(1, dispatch.getValue().getPalletCount()); + verify(locks).insert(any(DispatchPalletLock.class)); + verify(items).insert(any(DispatchItem.class)); + } + + @Test + void preArrangeBuildsCommaSeparatedDistinctFbaRouteFromAssignedPallets() { + when(candidates.lockSelected("100", List.of(10L, 11L, 12L))).thenReturn(List.of( + candidate(10L, null, "ONT8"), candidate(11L, null, "ABE4"), candidate(12L, null, "ONT8"))); + when(items.findByDispatchOrder(eq("100"), any())).thenReturn(List.of( + item(21L, "ONT8"), item(22L, "ABE4"), item(23L, "ONT8"))); + + DispatchResponse result = service.preArrange("100", "7", + new DispatchRequests.PreArrange(List.of(10L, 11L, 12L), null, null, null)); + + assertEquals("ONT8,ABE4", result.route()); + ArgumentCaptor dispatch = ArgumentCaptor.forClass(DispatchOrder.class); + verify(orders).insert(dispatch.capture()); + assertEquals("ONT8,ABE4", dispatch.getValue().getRoute()); + } + + @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 preArrangeRejectsPalletWithoutResolvedOverseasWarehouseWithPreciseReason() { + DispatchPalletCandidate unresolved = new DispatchPalletCandidate(1L, 10L, 20L, 2L, + null, null, "LMO1", "LMO1", "RK1", "LIP1-P001", "ONT8", "ONT8", "ONT8", "ONT8", + 12, new BigDecimal("1.2000"), "RECEIVED"); + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(unresolved)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.preArrange("100", "7", + new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); + + assertEquals("所选托盘中存在尚未关联海外仓的托盘", exception.getReason()); + verify(orders, never()).insert(any(DispatchOrder.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 preArrangeKeepsVirtualVehicleAndItsLocksEditableUntilOutboundExists() { + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + DispatchPalletLock lock = new DispatchPalletLock(); + lock.setDraftToken("draft-1"); lock.setDraftContainerId(21L); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10"); + DispatchDraft draft = new DispatchDraft(); + draft.setId(31L); draft.setDraftToken("draft-1"); draft.setOperatorId("7"); draft.setStatus("ACTIVE"); + when(locks.lockActive("100", "PLAN:10")).thenReturn(lock); + when(drafts.lockActive("draft-1", "7")).thenReturn(draft); + + service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, "draft-1")); + + assertNotNull(lock.getDispatchOrderId()); + assertEquals("draft-1", lock.getDraftToken()); + assertEquals(21L, lock.getDraftContainerId()); + assertEquals("ACTIVE", draft.getStatus()); + assertNull(draft.getExpiresAt()); + verify(locks).updateById(lock); + verify(locks, never()).insert(any(DispatchPalletLock.class)); + verify(drafts).updateById(draft); + } + + @Test + void removingUnpickedPalletReleasesLockAndRefreshesDispatchTotals() { + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); + dispatch.setStatus(DispatchService.PENDING_DEPARTURE); dispatch.setPickedPackageCount(0); + DispatchItem removed = new DispatchItem(); + removed.setId(11L); removed.setPlanPalletId(10L); removed.setWarehousePalletId(20L); + DispatchItem remaining = new DispatchItem(); + remaining.setId(12L); remaining.setPlanPalletId(12L); remaining.setPackageCount(7); + DispatchPalletLock lock = new DispatchPalletLock(); + lock.setPlanPalletId(10L); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10"); + when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); + when(items.lockById("100", 1L, 11L)).thenReturn(removed); + when(locks.selectList(any())).thenReturn(List.of(lock)); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(remaining)); + + DispatchResponse result = service.removeItem("PC1", 11L); + + assertEquals(1, result.palletCount()); + assertEquals(7, result.packageCount()); + assertEquals("RELEASED", lock.getStatus()); + verify(items).deleteById(11L); + verify(orders).updateById(dispatch); + } + + @Test + void activeDraftOnlyRestoresLocksStillOwnedByThatDraft() { + DispatchDraft draft = new DispatchDraft(); + draft.setDraftToken("draft-1"); draft.setOperatorId("7"); draft.setStatus("ACTIVE"); + DispatchDraftContainer container = new DispatchDraftContainer(); + container.setId(21L); container.setDraftToken("draft-1"); container.setCapacity(12); + DispatchPalletLock owned = new DispatchPalletLock(); + owned.setPlanPalletId(10L); owned.setDraftToken("draft-1"); owned.setDraftContainerId(21L); + when(drafts.findActiveByOperator("7")).thenReturn(List.of(draft)); + when(draftContainers.findByToken("draft-1")).thenReturn(List.of(container)); + when(locks.findActiveByDraftContainer("draft-1", 21L)).thenReturn(List.of(owned)); + + var result = service.activeDrafts("7"); + + assertEquals(List.of("10"), result.get(0).containers().get(0).palletIds()); + verify(locks).findActiveByDraftContainer("draft-1", 21L); + } + + @Test + void draftContainerOnlyAcceptsFifteenOrThirtyPalletVehicles() { + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.createDraftContainer("7", new DispatchRequests.DraftContainer("draft-1", 14))); + + assertEquals(409, exception.getStatusCode().value()); + assertEquals("车辆大小仅支持15托或30托", exception.getReason()); + verify(drafts, never()).lockActive(any(), any()); + verify(draftContainers, never()).insert(any(DispatchDraftContainer.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 platformEditResolvesCustomerFromGlobalDispatchNumberAndPreservesStatus() { + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); + dispatch.setStatus(DispatchService.PENDING_DEPARTURE); + when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); + DispatchItem ont8 = item(11L, "ONT8"); + DispatchItem abe4 = item(12L, "ABE4"); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(ont8, abe4, item(13L, "ONT8"))); + DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null, + null, null, null, LocalDateTime.now()); + + DispatchResponse result = service.update("PC1", request); + + assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus()); + assertEquals("ABC-1", result.vehiclePlate()); + assertEquals("ONT8,ABE4", result.route()); + assertEquals("ONT8,ABE4", dispatch.getRoute()); + verify(orders).updateById(dispatch); + verify(items).findByDispatchOrder("100", 1L); + } + + @Test + void platformEditRejectsAnExecutedDispatchWithoutWriting() { + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); + dispatch.setStatus("DEPARTED"); + when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); + DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, + null, null, null, null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.update("PC1", request)); + + assertEquals(409, exception.getStatusCode().value()); + 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); + verify(bolLines).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); + verify(bolLines, never()).findByDispatchOrder(any(), any()); + } + + @Test + void platformDetailUsesCustomerFromMatchedDispatch() { + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1"); + when(orders.findGlobalByDispatchNo("PC1")).thenReturn(dispatch); + + var expected = new DispatchResponse.BolLine("D2144", "D2144-112-1", "LBE1", "Amazon LBE1", + "NPHK2607030206", "FBA19HFHQCHB", "7UH3F1DW", 138); + when(bolLines.findByDispatchOrder("200", 1L)).thenReturn(List.of(expected)); + + var result = service.platformDetail("PC1"); + + verify(items).findByDispatchOrder("200", 1L); + verify(bolLines).findByDispatchOrder("200", 1L); + assertEquals(List.of(expected), result.bolLines()); + } + + @Test + void globalCancelIsIdempotentAndReleasesOnlyTheResolvedCustomerLocks() { + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1"); + dispatch.setStatus(DispatchService.PENDING_DEPARTURE); + DispatchPalletLock lock = new DispatchPalletLock(); + lock.setStatus("ACTIVE"); lock.setActiveKey("PALLET:1"); + when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); + when(locks.selectList(any())).thenReturn(List.of(lock)); + + service.cancel("PC1"); + + assertEquals(DispatchService.CANCELLED, dispatch.getStatus()); + assertEquals("RELEASED", lock.getStatus()); + assertEquals(null, lock.getActiveKey()); + verify(locks).updateById(lock); + + service.cancel("PC1"); + verify(locks, org.mockito.Mockito.times(1)).selectList(any()); + } + + @Test + void receivedPalletUsesPhysicalPalletAsDispatchLockIdentity() { + DispatchPalletCandidate mergedInboundOrder = candidate(10L, 900L, "ONT8"); + DispatchPalletCandidate plannedOnly = candidate(11L, null, "ONT8"); + + assertEquals("RECEIVED:900", mergedInboundOrder.sourcePalletKey()); + assertEquals("PLAN:11", plannedOnly.sourcePalletKey()); + } + + 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"); + } + + private DispatchItem item(long id, String fbaWarehouseCode) { + DispatchItem item = new DispatchItem(); + item.setId(id); + item.setFbaWarehouseCode(fbaWarehouseCode); + return item; + } +} 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/controller/DropshipOrderControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/controller/DropshipOrderControllerContractTest.java new file mode 100644 index 000000000..85d128f4c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/controller/DropshipOrderControllerContractTest.java @@ -0,0 +1,74 @@ +package com.baiyue.lastleg.dropship.controller; + +import com.baiyue.lastleg.dropship.dto.DropshipRequests; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DropshipOrderControllerContractTest { + @Test + void routeRequiresTheFrozenInternalTokenHeaderBeforeCallingTheService() throws Exception { + RequestMapping base = DropshipOrderController.class.getAnnotation(RequestMapping.class); + Method receive = DropshipOrderController.class.getMethod("receive", String.class, + DropshipRequests.Receive.class); + RequestHeader token = receive.getParameters()[0].getAnnotation(RequestHeader.class); + + assertThat(base.value()).containsExactly("/internal/v1/lastleg/dropship"); + assertThat(receive.getAnnotation(PostMapping.class).value()).containsExactly("/receive"); + assertThat(token.value()).isEqualTo("X-Internal-Service-Token"); + + InternalServiceTokenGuard guard = mock(InternalServiceTokenGuard.class); + DropshipOrderService service = mock(DropshipOrderService.class); + DropshipOrderController controller = new DropshipOrderController(); + ReflectionTestUtils.setField(controller, "tokenGuard", guard); + ReflectionTestUtils.setField(controller, "service", service); + DropshipRequests.Receive request = request(); + when(service.receive(request)).thenReturn(1); + + assertThat(controller.receive("internal-token", request).data()).isEqualTo(1); + InOrder order = inOrder(guard, service); + order.verify(guard).verify("internal-token"); + order.verify(service).receive(request); + } + + @Test + void versionedOpenApiMatchesTheControllerAndDtoContract() throws Exception { + String yaml = Files.readString( + Path.of("src/main/resources/openapi/lastleg-internal-dropship-v1.yaml"), + StandardCharsets.UTF_8); + + assertThat(yaml).contains( + "version: 1.0.0", + "/internal/v1/lastleg/dropship/receive:", + "name: X-Internal-Service-Token", + "required: [customerId, orderId, warehouseId, idempotencyKey, pallets]", + "required: [palletNo, skuPackages]", + "required: [skuCode, packageCount, productQuantity, cartonMarks, cartonQuantities]", + "data: { type: integer, format: int32, minimum: 0 }"); + assertThat(yaml.indexOf("name: X-Internal-Service-Token")) + .isLessThan(yaml.indexOf("requestBody:")); + } + + private static DropshipRequests.Receive request() { + return new DropshipRequests.Receive("100", 10L, 20L, "event-1", + List.of(new DropshipRequests.Pallet("PALLET-1", + List.of(new DropshipRequests.SkuPackages("SKU-1", "Product", 1, 2, + List.of("CARTON-1"), java.util.Map.of("CARTON-1", 2L))), 1, 1, 1, "0".repeat(64)))); + } +} 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..fac18ec1d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java @@ -0,0 +1,182 @@ +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.entity.DropshipCartonSku; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper; +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 DropshipCartonSkuMapper cartonSkus; + @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"), java.util.Map.of("MARK-1", 7L, "MARK-2", 13L))), 1, 2, 1, "0".repeat(64)), + new DropshipRequests.Pallet("SOURCE-P-2", List.of( + new DropshipRequests.SkuPackages("SKU-A", "Product A", 1, 10, + List.of("MARK-3"), java.util.Map.of("MARK-3", 10L))), 1, 1, 1, "1".repeat(64)))); + + 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(cartonSkus, times(3)).insert(any(DropshipCartonSku.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::getProductQuantity) + .containsExactly(7L, 13L, 10L); + 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 multiSkuCartonsAreStoredOnceWithSeparateSkuFacts() { + LastlegOrder order = new LastlegOrder(); + order.setId(10L); order.setCustomerId("C-1"); order.setOrderType("ONE_CLICK_DROPSHIP"); + order.setOrderNo("LL-10"); order.setPackageCount(2); 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-MIXED", + List.of(new DropshipRequests.Pallet("SOURCE-P-1", List.of( + new DropshipRequests.SkuPackages("SKU-A", "A", 2, 20, + List.of("BOX-1", "BOX-2"), java.util.Map.of("BOX-1", 10L, "BOX-2", 10L)), + new DropshipRequests.SkuPackages("SKU-B", "B", 2, 10, + List.of("BOX-1", "BOX-2"), java.util.Map.of("BOX-1", 5L, "BOX-2", 5L))), + 1, 2, 1, "2".repeat(64)))); + + assertThat(service.receive(request)).isEqualTo(2); + ArgumentCaptor physical = ArgumentCaptor.forClass(DropshipCarton.class); + verify(cartons, times(2)).insert(physical.capture()); + assertThat(physical.getAllValues()).extracting(DropshipCarton::getCartonMark) + .containsExactly("BOX-1", "BOX-2"); + assertThat(physical.getAllValues()).extracting(DropshipCarton::getSkuCode).containsOnly("MIXED"); + assertThat(physical.getAllValues()).extracting(DropshipCarton::getProductQuantity).containsOnly(15L); + verify(cartonSkus, times(4)).insert(any(DropshipCartonSku.class)); + verify(ledger, times(2)).insert(any(DropshipCartonLedger.class)); + } + + @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, "SOURCE-P-1", 201L, 1), receipt(102L, "SOURCE-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("WH-" + palletId); receipt.setSourcePalletNo(palletNo); + receipt.setInventoryPositionId(positionId); receipt.setReceivedPackages(packages); + return receipt; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java new file mode 100644 index 000000000..2d2063fbb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java @@ -0,0 +1,113 @@ +package com.baiyue.lastleg.exceptionorder.service; + +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.delivery.mapper.OutboundPdaPickMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.entity.LastlegInboundOrder; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +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.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ExceptionOrderDispatchReturnSourceTest { + @Mock private DeliveryOrderMapper deliveryOrders; + @Mock private OutboundOrderMapper outboundOrders; + @Mock private OutboundPalletItemMapper outboundPalletItems; + @Mock private OutboundPdaPickMapper outboundPdaPicks; + @Mock private DispatchItemMapper dispatchItems; + @Mock private DispatchPdaTaskMapper dispatchPdaTasks; + @Mock private DispatchPdaPickMapper dispatchPdaPicks; + @Mock private DispatchPdaPickOrderMapper dispatchPdaPickOrders; + @Mock private LastlegOrderMapper lastMileOrders; + @Mock private LastlegInboundOrderMapper inboundOrders; + private ExceptionOrderService service; + + @BeforeEach + void setUp() { + service = new ExceptionOrderService(); + ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); + ReflectionTestUtils.setField(service, "outboundPalletItems", outboundPalletItems); + ReflectionTestUtils.setField(service, "outboundPdaPicks", outboundPdaPicks); + ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems); + ReflectionTestUtils.setField(service, "dispatchPdaTasks", dispatchPdaTasks); + ReflectionTestUtils.setField(service, "dispatchPdaPicks", dispatchPdaPicks); + ReflectionTestUtils.setField(service, "dispatchPdaPickOrders", dispatchPdaPickOrders); + ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders); + ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders); + } + + @Test + void returnsTheAttributedInventorySourceForDispatchDeliveryPicks() { + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(400L); delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1"); + delivery.setOutboundNo("OUT-1"); delivery.setStatus("DELIVERED"); + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(300L); outbound.setCustomerId("1001"); outbound.setOutboundNo("OUT-1"); + outbound.setDispatchOrderId(200L); + OutboundPalletItem outboundItem = new OutboundPalletItem(); + outboundItem.setId(301L); outboundItem.setCustomerId("1001"); outboundItem.setOutboundOrderId(300L); + outboundItem.setWarehousePalletId(501L); outboundItem.setPalletNo("PLT-1"); outboundItem.setInboundCode("IN-1"); + DispatchPdaTask task = new DispatchPdaTask(); + task.setId(201L); task.setCustomerId("1001"); task.setDispatchOrderId(200L); + DispatchItem dispatchItem = new DispatchItem(); + dispatchItem.setId(202L); dispatchItem.setCustomerId("1001"); dispatchItem.setDispatchOrderId(200L); + dispatchItem.setWarehousePalletId(501L); + DispatchPdaPick pick = new DispatchPdaPick(); + pick.setId(203L); pick.setCustomerId("1001"); pick.setTaskId(201L); + pick.setDispatchItemId(202L); pick.setInventoryPositionId(601L); pick.setPackages(2L); + DispatchPdaPickOrder attribution = new DispatchPdaPickOrder(); + attribution.setId(204L); attribution.setCustomerId("1001"); attribution.setPickId(203L); + attribution.setOrderId(101L); attribution.setPackages(2L); + LastlegOrder tail = new LastlegOrder(); + tail.setId(101L); tail.setCustomerId("1001"); tail.setOrderNo("LM-1"); tail.setInboundCode("IN-1"); + tail.setDeliveryOrderNo("DL-1"); tail.setOutboundOrderNo("OUT-1"); tail.setPackageCount(2); + LastlegInboundOrder inbound = new LastlegInboundOrder(); + inbound.setId(102L); inbound.setCustomerId("1001"); inbound.setInboundCode("IN-1"); inbound.setPackageCount(2); + + when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery); + when(lastMileOrders.selectList(any())).thenReturn(List.of(tail)); + when(inboundOrders.selectList(any())).thenReturn(List.of(inbound)); + when(outboundOrders.findByNo("1001", "OUT-1")).thenReturn(outbound); + when(outboundPalletItems.findByOutbound("1001", 300L)).thenReturn(List.of(outboundItem)); + when(outboundPdaPicks.findByOutbound("1001", 300L)).thenReturn(List.of()); + when(dispatchPdaTasks.findByDispatch("1001", 200L)).thenReturn(task); + when(dispatchItems.findByDispatchOrder("1001", 200L)).thenReturn(List.of(dispatchItem)); + when(dispatchPdaPicks.listTask("1001", 201L)).thenReturn(List.of(pick)); + when(dispatchPdaPickOrders.listPick("1001", 203L)).thenReturn(List.of(attribution)); + when(lastMileOrders.selectById(101L)).thenReturn(tail); + + var candidates = service.returnCandidates("1001", "DL-1"); + + assertThat(candidates).hasSize(1); + assertThat(candidates.get(0).returnSources()).singleElement().satisfies(source -> { + assertThat(source.outboundPalletItemId()).isEqualTo(301L); + assertThat(source.inventoryPositionId()).isEqualTo(601L); + assertThat(source.maxReturnPackages()).isEqualTo(2L); + }); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java new file mode 100644 index 000000000..5e828e45f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java @@ -0,0 +1,159 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem; +import com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +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.ArgumentCaptor; +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.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.time.LocalDateTime; +import java.util.List; + +@ExtendWith(MockitoExtension.class) +class ExceptionOrderLifecycleTest { + @Mock private ExceptionOrderMapper exceptionOrders; + @Mock private ExceptionOrderCommandMapper commands; + @Mock private ExceptionReturnItemMapper returnItems; + @Mock private InventoryCommandService inventoryCommands; + @Mock private ExceptionEvidenceFileGateway evidenceFiles; + @Mock private DeliveryOrderMapper deliveryOrders; + @Mock private DeliveryOperationMapper deliveryOperations; + @Mock private LastlegOrderMapper lastMileOrders; + private ExceptionOrderService service; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + service = new ExceptionOrderService(); + objectMapper = new ObjectMapper().findAndRegisterModules(); + ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders); + ReflectionTestUtils.setField(service, "commands", commands); + ReflectionTestUtils.setField(service, "returnItems", returnItems); + ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); + ReflectionTestUtils.setField(service, "evidenceFiles", evidenceFiles); + ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); + ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations); + ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + } + + @Test + void startsPendingExceptionWithVersionGateAndPersistsIdempotentResult() { + ExceptionOrder order = order("PENDING", 0); + when(commands.findByKey("1001", "START_PROCESSING", "start-1")).thenReturn(null); + when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order); + + var result = service.startProcessing("1001", "9001", "EX-1", + new ExceptionOrderRequests.StartProcessing(0, "start-1", "联系客户并核对退件")); + + assertThat(result.status()).isEqualTo("PROCESSING"); + assertThat(result.version()).isEqualTo(1); + assertThat(result.replayed()).isFalse(); + verify(exceptionOrders).updateById(order); + ArgumentCaptor command = ArgumentCaptor.forClass(ExceptionOrderCommand.class); + verify(commands).insert(command.capture()); + assertThat(command.getValue().getResultJson()).contains("PROCESSING"); + } + + @Test + void rejectsStaleVersionWithoutMutation() { + when(commands.findByKey("1001", "START_PROCESSING", "start-2")).thenReturn(null); + when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order("PENDING", 2)); + + assertThatThrownBy(() -> service.startProcessing("1001", "9001", "EX-1", + new ExceptionOrderRequests.StartProcessing(1, "start-2", null))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本"); + + verify(exceptionOrders, never()).updateById(any(ExceptionOrder.class)); + verify(commands, never()).insert(any(ExceptionOrderCommand.class)); + } + + @Test + void replaysSameCommandWithoutUpdatingAgain() throws Exception { + ExceptionOrderResponses.CommandResult saved = new ExceptionOrderResponses.CommandResult( + 1L, "EX-1", "PROCESSING", 1, null, 0, null, false); + ExceptionOrderRequests.StartProcessing request = + new ExceptionOrderRequests.StartProcessing(0, "start-3", null); + ExceptionOrderCommand command = new ExceptionOrderCommand(); + command.setRequestHash(hashFor(request)); + command.setResultJson(objectMapper.writeValueAsString(saved)); + when(commands.findByKey("1001", "START_PROCESSING", "start-3")).thenReturn(command); + + var replayed = service.startProcessing("1001", "9001", "EX-1", request); + + assertThat(replayed.replayed()).isTrue(); + assertThat(replayed.status()).isEqualTo("PROCESSING"); + verify(exceptionOrders, never()).lockByNo(any(), any()); + } + + @Test + void completesWarehouseReturnOnlyAfterInventoryRestoreSucceeds() { + ExceptionOrder order = order("PROCESSING", 1); + order.setExceptionType("RETURN"); order.setReturnDirection("OVERSEAS_WAREHOUSE"); + order.setOutboundNo("OUT-1"); order.setOverseasWarehouseId("WH-1"); order.setInboundOrderId(44L); + order.setDeliveryNo("DL-1"); + ExceptionReturnItem source = new ExceptionReturnItem(); + source.setOutboundPalletItemId(51L); source.setInventoryPositionId(61L); + source.setSkuCode("SKU-1"); source.setApprovedReturnPackages(2L); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(71L); delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1"); + delivery.setStatus("RETURN_PENDING"); delivery.setVersion(2L); + LocalDateTime completedAt = LocalDateTime.of(2026, 9, 8, 18, 30); + when(commands.findByKey("1001", "COMPLETE", "complete-1")).thenReturn(null); + when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order); + when(returnItems.findByException("1001", 1L)).thenReturn(List.of(source)); + when(inventoryCommands.restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class))) + .thenReturn(new ReturnedInventoryRestoreResponse(81L, List.of(91L), 0, 2, 2, false, completedAt)); + when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); + when(lastMileOrders.selectList(any())).thenReturn(List.of()); + ExceptionOrderRequests.Complete request = new ExceptionOrderRequests.Complete(1, "complete-1", "已退回入仓", + List.of(101L), List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, 2, 201L, + "PLT-201", 301L, "LOC-301"))); + + var result = service.complete("1001", "9001", "EX-1", request); + + assertThat(result.status()).isEqualTo("COMPLETED"); + assertThat(result.inventoryAdjustmentId()).isEqualTo(81L); + assertThat(result.restoredPackages()).isEqualTo(2); + assertThat(delivery.getStatus()).isEqualTo("RETURNED"); + verify(evidenceFiles).validate("1001", 1L, 101L); + verify(inventoryCommands).restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class)); + } + + private String hashFor(Object request) { + return (String) ReflectionTestUtils.invokeMethod(service, "hash", request); + } + + private ExceptionOrder order(String status, long version) { + ExceptionOrder order = new ExceptionOrder(); + order.setId(1L); order.setCustomerId("1001"); order.setExceptionNo("EX-1"); + order.setStatus(status); order.setVersion(version); + return order; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java new file mode 100644 index 000000000..095dda3ec --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java @@ -0,0 +1,87 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baiyue.lastleg.entity.LastlegInboundOrder; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; +import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +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 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.when; + +@ExtendWith(MockitoExtension.class) +class ExceptionOrderServiceTest { + @Mock private ExceptionOrderMapper exceptionOrders; + @Mock private LastlegOrderMapper lastMileOrders; + @Mock private LastlegInboundOrderMapper inboundOrders; + private ExceptionOrderService service; + + @BeforeEach + void setUp() { + service = new ExceptionOrderService(); + ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders); + ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders); + ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders); + } + + @Test + void resolvesTheOneToOneInboundAndLastMileRelation() { + LastlegOrder tail = tail("LM-1", "IN-1"); + LastlegInboundOrder inbound = inbound("IN-1"); + when(lastMileOrders.selectList(any())).thenReturn(List.of(tail)); + when(inboundOrders.selectList(any())).thenReturn(List.of(inbound)); + + var relation = service.resolve(null, null, "IN-1", "LM-1"); + + assertThat(relation.lastMileOrderNo()).isEqualTo("LM-1"); + assertThat(relation.inboundCode()).isEqualTo("IN-1"); + assertThat(relation.customerId()).isEqualTo("1001"); + } + + @Test + void rejectsInputsThatExistButDoNotBelongTogether() { + when(lastMileOrders.selectList(any())).thenReturn(List.of(tail("LM-1", "IN-1"))); + when(inboundOrders.selectList(any())).thenReturn(List.of(inbound("IN-2"))); + + assertThatThrownBy(() -> service.resolve(null, null, "IN-2", "LM-1")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("不是同一关联关系"); + } + + @Test + void resolvesTheExactInboundWhenTailOrderNumberIsReused() { + LastlegOrder selected = tail("LM-1", "IN-2"); + selected.setId(22L); + when(lastMileOrders.selectList(any())).thenReturn(List.of(selected)); + when(inboundOrders.selectList(any())).thenReturn(List.of(inbound("IN-2"))); + + var relation = service.resolve(null, null, "IN-2", "LM-1"); + + assertThat(relation.lastMileOrderId()).isEqualTo(22L); + assertThat(relation.inboundCode()).isEqualTo("IN-2"); + } + + private LastlegOrder tail(String orderNo, String inboundCode) { + LastlegOrder value = new LastlegOrder(); + value.setId(12L); value.setCustomerId("1001"); value.setOrderNo(orderNo); value.setInboundCode(inboundCode); + value.setCustomerName("测试客户"); value.setPackageCount(10); + return value; + } + + private LastlegInboundOrder inbound(String inboundCode) { + LastlegInboundOrder value = new LastlegInboundOrder(); + value.setId(11L); value.setCustomerId("1001"); value.setInboundCode(inboundCode); value.setPackageCount(10); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java new file mode 100644 index 000000000..59c3f2e4c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OverseasContainerFeeSourceServiceTest { + private final OverseasFeeSourceMapper mapper = mock(OverseasFeeSourceMapper.class); + private final OverseasFeeCommandMapper commands = mock(OverseasFeeCommandMapper.class); + private final OverseasFeeBusinessGuard guard = mock(OverseasFeeBusinessGuard.class); + private final OverseasFeeOutboxService outbox = mock(OverseasFeeOutboxService.class); + private final OverseasFeeSourceService service = new OverseasFeeSourceService( + mapper, commands, guard, outbox, + new OverseasFeeCurrencyConverter(mock(MonthlyExchangeRateMapper.class)), + new ObjectMapper().findAndRegisterModules()); + + @Test + void createsContainerFeeUsingTheRealContainerAsItsStableBusinessReference() { + SaveRequest request = OverseasFeeBusinessGuardTest.containerFee("customer-a", 701L, "MSCU1234567"); + when(commands.find("operator-a", "CREATE", "create-container-fee")).thenReturn(null); + OverseasFeeSource result = service.create("operator-a", "create-container-fee", request); + + verify(guard).requireOwned(request); + ArgumentCaptor inserted = ArgumentCaptor.forClass(OverseasFeeSource.class); + verify(mapper).insert(inserted.capture()); + assertThat(result).isSameAs(inserted.getValue()); + assertThat(result.getSourceType()).isEqualTo("OVERSEAS_CONTAINER_FEE"); + assertThat(result.getBusinessType()).isEqualTo("OVERSEAS_CONTAINER"); + assertThat(result.getBusinessId()).isEqualTo(701L); + assertThat(result.getContainerBookingId()).isEqualTo(701L); + assertThat(result.getSourceNo()).isEqualTo("MSCU1234567"); + } + + @Test + void rejectsUnsupportedContainerFeeItems() { + SaveRequest source = OverseasFeeBusinessGuardTest.containerFee("customer-a", 701L, "MSCU1234567"); + SaveRequest request = new SaveRequest(source.customerId(), source.customerName(), source.sourceType(), + source.businessType(), source.businessId(), source.businessNo(), source.orderId(), source.inboundId(), + source.inboundCode(), source.containerId(), source.containerBookingId(), source.containerNo(), + source.warehouseId(), "CUSTOM_FEE", "自定义费", source.direction(), source.supplierId(), + source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(), + source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(), + source.businessDate(), source.currency(), source.amount(), source.chargeableQuantity(), + source.quantityUnit(), source.unitPrice(), source.periodStart(), source.periodEnd()); + when(commands.find("operator-a", "CREATE", "bad-container-fee")).thenReturn(null); + + assertThatThrownBy(() -> service.create("operator-a", "bad-container-fee", request)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("海外货柜费用项目不受支持"); + } + + @Test + void canListAllCustomerFeesForAContainerInOneQuery() { + service.list(null, "OVERSEAS_CONTAINER", null, 701L); + + verify(mapper).selectList(any()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java new file mode 100644 index 000000000..df390eb6c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java @@ -0,0 +1,127 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayDeque; +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 OverseasFeeBusinessGuardTest { + + @Test + void acceptsOnlyARealContainerOwnedByTheCustomer() { + StubJdbc jdbc = new StubJdbc(3, 0, 1); + new OverseasFeeBusinessGuard(jdbc).requireOwned(containerFee("customer-a", 701L, "MSCU1234567")); + + assertThat(jdbc.sql()).hasSize(3); + assertThat(jdbc.sql().get(0)).contains("source_container_booking_id"); + assertThat(jdbc.sql().get(1)).contains("container_no"); + assertThat(jdbc.sql().get(2)).contains("customer_id"); + } + + @Test + void rejectsAContainerThatDoesNotContainTheClaimedCustomer() { + StubJdbc jdbc = new StubJdbc(3, 0, 0); + + assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc) + .requireOwned(containerFee("other-customer", 701L, "MSCU1234567"))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("该客户不属于费用关联的真实货柜"); + } + + @Test + void rejectsAContainerWhoseOrdersDoNotShareTheClaimedContainerNumber() { + StubJdbc jdbc = new StubJdbc(3, 1); + + assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc) + .requireOwned(containerFee("customer-a", 701L, "MSCU1234567"))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("真实货柜的集装箱号缺失或不一致"); + } + + @Test + void rejectsDifferentBusinessAndContainerBookingIdentifiersBeforeQuerying() { + SaveRequest source = containerFee("customer-a", 701L, "MSCU1234567"); + SaveRequest inconsistent = new SaveRequest(source.customerId(), source.customerName(), source.sourceType(), + source.businessType(), 702L, source.businessNo(), source.orderId(), source.inboundId(), + source.inboundCode(), source.containerId(), source.containerBookingId(), source.containerNo(), + source.warehouseId(), source.feeItemCode(), source.feeItemName(), source.direction(), source.supplierId(), + source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(), + source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(), + source.businessDate(), source.currency(), source.amount(), source.chargeableQuantity(), + source.quantityUnit(), source.unitPrice(), source.periodStart(), source.periodEnd()); + StubJdbc jdbc = new StubJdbc(); + + assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc).requireOwned(inconsistent)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("真实来源柜ID"); + assertThat(jdbc.sql()).isEmpty(); + } + + @Test + void listsAllContainerCustomersWithOneGroupedQuery() { + StubJdbc jdbc = new StubJdbc(); + jdbc.queryResults = List.of( + new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption("customer-a", "客户A"), + new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption("customer-b", "客户B")); + + var result = new OverseasFeeBusinessGuard(jdbc).listContainerCustomers(701L); + + assertThat(result).extracting("customerId").containsExactly("customer-a", "customer-b"); + assertThat(jdbc.sql()).singleElement().asString().contains("GROUP BY customer_id"); + } + + @Test + void returnsEmptyWhenContainerHasNoLastlegOrders() { + StubJdbc jdbc = new StubJdbc(); + + var result = new OverseasFeeBusinessGuard(jdbc).listContainerCustomers(701L); + + assertThat(result).isEmpty(); + assertThat(jdbc.sql()).singleElement().asString().contains("source_container_booking_id"); + } + + static SaveRequest containerFee(String customerId, Long bookingId, String containerNo) { + return new SaveRequest(customerId, "客户A", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_CONTAINER", + bookingId, containerNo, null, null, null, null, bookingId, containerNo, 91L, + "PORT_FEE", "港口费", "RECEIVABLE", null, null, null, null, null, null, null, + List.of(), LocalDate.of(2026, 9, 8), "CNY", new BigDecimal("88.00"), + null, null, null, null, null); + } + + private static final class StubJdbc extends JdbcTemplate { + private final ArrayDeque results = new ArrayDeque<>(); + private final List sql = new ArrayList<>(); + private List queryResults = List.of(); + + private StubJdbc(Integer... values) { + results.addAll(List.of(values)); + } + + @Override + public T queryForObject(String statement, Class requiredType, Object... args) { + sql.add(statement); + return requiredType.cast(results.removeFirst()); + } + + @Override + @SuppressWarnings("unchecked") + public List query(String statement, RowMapper rowMapper, Object... args) { + sql.add(statement); + return (List) queryResults; + } + + private List sql() { + return sql; + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java new file mode 100644 index 000000000..40358bd1f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate; +import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; + +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.Mockito.when; + +class OverseasFeeCurrencyConverterTest { + private final MonthlyExchangeRateMapper rates = mock(MonthlyExchangeRateMapper.class); + private final OverseasFeeCurrencyConverter converter = new OverseasFeeCurrencyConverter(rates); + + @Test + void convertsUsdUsingTheActiveRateForTheBusinessMonth() { + MonthlyExchangeRate rate = new MonthlyExchangeRate(); + rate.setStatus("ACTIVE"); rate.setUsdToCnyAverage(new BigDecimal("7.120000")); + when(rates.findMonth("2026-09")).thenReturn(rate); + + var result = converter.convert("USD", new BigDecimal("12.34"), LocalDate.of(2026, 9, 9)); + + assertThat(result.originalAmount()).isEqualByComparingTo("12.34"); + assertThat(result.exchangeRate()).isEqualByComparingTo("7.120000"); + assertThat(result.settlementAmount()).isEqualByComparingTo("87.86"); + } + + @Test + void refusesUsdWhenTheBusinessMonthHasNoActiveRate() { + assertThatThrownBy(() -> converter.convert("USD", BigDecimal.TEN, LocalDate.of(2026, 9, 9))) + .hasMessageContaining("缺少有效USD兑CNY汇率"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java new file mode 100644 index 000000000..6f9a6acdf --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.common.event.BusinessFeeSourceChangedV1; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OverseasFeeOutboxServiceTest { + private final LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private final OverseasFeeOutboxService service = new OverseasFeeOutboxService(); + + @Test + void publishesCompleteFeeSnapshotWithOptionalInboundReference() throws Exception { + ReflectionTestUtils.setField(service, "mapper", mapper); + ReflectionTestUtils.setField(service, "objectMapper", json); + when(mapper.findByEventKey(anyString())).thenReturn(null); + OverseasFeeSource source = new OverseasFeeSource(); + source.setSourceFeeId(701L); source.setSourceVersion(1); source.setCustomerId("10001"); + source.setCustomerName("客户A"); source.setSourceType("OVERSEAS_PROCUREMENT_FEE"); + source.setSourceNo("PO-US-001"); source.setBusinessType("OVERSEAS_PROCUREMENT"); + source.setBusinessId(801L); source.setBusinessNo("PO-US-001"); source.setWarehouseId(91L); + source.setFeeItemCode("PROCUREMENT_SERVICE"); source.setFeeItemName("采购服务费"); + source.setDirection("RECEIVABLE"); source.setBusinessDate(LocalDate.of(2026, 9, 2)); + source.setCurrency("CNY"); source.setAmount(new BigDecimal("120.00")); + source.setOriginalCurrency("USD"); source.setOriginalAmount(new BigDecimal("16.85")); + source.setExchangeRate(new BigDecimal("7.122000")); source.setExchangeRateMonth("2026-09"); + source.setReviewStatus("APPROVED"); source.setPublicationStatus("PUBLISHED"); + source.setSourceStatus("ACTIVE"); source.setPaymentVoucherFileIds(List.of()); + + service.publish(source, "PUBLISHED"); + + ArgumentCaptor row = ArgumentCaptor.forClass(LastlegEventOutbox.class); + verify(mapper).insert(row.capture()); + EventEnvelope envelope = json.readValue(row.getValue().getPayloadJson(), EventEnvelope.class).requireValid(); + BusinessFeeSourceChangedV1 payload = json.treeToValue(envelope.payload(), BusinessFeeSourceChangedV1.class); + assertThat(payload.customerId()).isEqualTo("10001"); + assertThat(payload.businessNo()).isEqualTo("PO-US-001"); + assertThat(payload.inboundId()).isNull(); + assertThat(payload.inboundCode()).isNull(); + assertThat(payload.sourceHash()).startsWith("sha256:").hasSize(71); + assertThat(payload.originalCurrency()).isEqualTo("USD"); + assertThat(payload.exchangeRateMonth()).isEqualTo("2026-09"); + } +} 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..0e3609ce5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -0,0 +1,43 @@ +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("ll_inbound_plan_pallet' and column_name='version'")); + 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("planned_arrival_date")); + assertTrue(schema.contains("creation_mode")); + assertTrue(schema.contains("pallet_type")); + assertTrue(schema.contains("create table if not exists ll_order_command_record")); + assertTrue(schema.contains("create table if not exists ll_order_hold_history")); + assertTrue(schema.contains("create table if not exists ll_order_manual_node")); + assertTrue(schema.contains("create table if not exists ll_order_manual_node_history")); + 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/TailPlanCommandOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java new file mode 100644 index 000000000..798a5228a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.inboundplan; + +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; +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 TailPlanCommandOpenApiContractTest { + @Test + void exposesOnlyLatestManualPlanHoldAndEightRowNodeContracts() throws Exception { + try (var stream = getClass().getClassLoader() + .getResourceAsStream("openapi/overseas-business-v1.yaml")) { + assertTrue(stream != null); + String api = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(api.contains("/api/platform/v1/last-leg/inbound-plans/manual:")); + assertFalse(api.contains("/api/platform/v1/last-leg/inbound-plans/convert:")); + assertFalse(api.contains("plannedWarehouseId")); + assertFalse(api.contains("plannedWarehouseCode")); + assertFalse(api.contains("plannedWarehouseName")); + int manualSchemaStart = api.indexOf(" ManualInboundPlanRequest:"); + int manualSchemaEnd = api.indexOf("\n PageMetadata:", manualSchemaStart); + assertTrue(manualSchemaStart >= 0 && manualSchemaEnd > manualSchemaStart); + assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("inboundCode")); + assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("plannedArrivalDate")); + assertFalse(java.util.Arrays.stream(ManualInboundPlanRequest.class.getRecordComponents()) + .anyMatch(component -> java.util.Set.of("inboundCode", "plannedArrivalDate") + .contains(component.getName()))); + assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/hold:")); + assertFalse(api.contains("/api/platform/v1/last-leg/orders/{id}/detained:")); + assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/manual-nodes:")); + assertTrue(api.contains("minItems: 8")); + assertTrue(api.contains("maxItems: 8")); + assertTrue(api.contains("ACTUAL_ARRIVAL_WAITING_UNLOAD")); + } + } +} 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..4884b8743 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -0,0 +1,110 @@ +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.ConvertContainerInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; +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.inboundplan.service.ContainerInboundPlanConversionCoordinator; +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.never; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundPlanControllerTest { + @Mock InboundPlanService service; + @Mock InboundFbaAppointmentService appointmentService; + @Mock ContainerInboundPlanConversionCoordinator containerConversion; + private InboundPlanController controller; + + @BeforeEach + void setUp() { + controller = new InboundPlanController(); + ReflectionTestUtils.setField(controller, "service", service); + ReflectionTestUtils.setField(controller, "appointmentService", appointmentService); + ReflectionTestUtils.setField(controller, "containerConversion", containerConversion); + } + + @Test + void platformPageAllowsAllCustomersWhenCustomerIdIsOmitted() { + InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, 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); + } + + @Test + void platformManualPlanPassesVersionedIdempotentCommand() { + ManualInboundPlanRequest request = manualRequest(); + controller.manual(platform(), "manual-key", request); + verify(containerConversion).convertManualAndAutoMaterialize(request, "manual-key", "7"); + } + + @Test + void merchantCannotCreateManualPlan() { + AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of()); + assertThatThrownBy(() -> controller.manual(customer, "manual-key", manualRequest())) + .isInstanceOf(ResponseStatusException.class); + verify(containerConversion, never()).convertManualAndAutoMaterialize(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + @Test + void platformContainerConversionUsesStableBookingIdentity() { + var request = new ConvertContainerInboundPlanRequest(" BOOKING-1 ", 3, "a".repeat(64)); + controller.convertContainer(platform(), "convert-key", request); + + verify(containerConversion).confirm(request, "convert-key"); + } + + @Test + void merchantCannotConvertAWholeContainer() { + AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of()); + + assertThatThrownBy(() -> controller.convertContainer(customer, "convert-key", + new ConvertContainerInboundPlanRequest("BOOKING-1", 3, "a".repeat(64)))) + .isInstanceOf(ResponseStatusException.class); + verify(containerConversion, never()).confirm(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + private static AuthenticatedUser platform() { + return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", + Set.of("platform:overseas-warehouse:inbound-order:list")); + } + + private static ManualInboundPlanRequest manualRequest() { + return new ManualInboundPlanRequest("193000000000200003", 9223372036854775807L, 0, + 2, "STANDARD", false, true, true, "加急入仓"); + } +} 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..49ebc5462 --- /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("manual")) + .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/mapper/InboundPlanOrderMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java new file mode 100644 index 000000000..e9b5e1e48 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InboundPlanOrderMapperContractTest { + @Test + void transferWarehouseIsTheEffectivePalletDestination() throws Exception { + Select select = InboundPlanOrderMapper.class + .getMethod("lockOrder", String.class, Long.class) + .getAnnotation(Select.class); + String sql = String.join(" ", select.value()); + + assertTrue(sql.contains("COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,'')))")); + assertTrue(sql.contains("COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,'')))")); + assertTrue(sql.contains("THEN 1 ELSE COUNT(DISTINCT")); + assertTrue(sql.contains("0 AS destinationMismatchCount")); + assertTrue(sql.contains("o.overseas_warehouse_id AS overseasWarehouseId")); + assertTrue(sql.contains("o.overseas_warehouse_code AS overseasWarehouseCode")); + assertTrue(sql.contains("o.overseas_warehouse_name AS overseasWarehouseName")); + } + + @Test + void receivingWarehouseSnapshotIsReadFromTenantScopedOrderFact() throws Exception { + Select select = InboundPlanOrderMapper.class + .getMethod("findReceivingWarehouse", String.class, Long.class) + .getAnnotation(Select.class); + String sql = String.join(" ", select.value()); + + assertTrue(sql.contains("overseas_warehouse_code AS overseasWarehouseCode")); + assertTrue(sql.contains("overseas_warehouse_name AS overseasWarehouseName")); + assertTrue(sql.contains("customer_id=#{customerId} AND id=#{orderId}")); + assertTrue(!sql.contains("destination_warehouse")); + } + + @Test + void manualPlanLocksEveryOrderInTheSelectedStableContainer() throws Exception { + Select select = InboundPlanOrderMapper.class + .getMethod("lockContainerWarehouseFacts", String.class, Long.class) + .getAnnotation(Select.class); + String sql = String.join(" ", select.value()); + + assertTrue(sql.contains("c.source_container_booking_id=target.source_container_booking_id")); + assertTrue(sql.contains("target.customer_id=#{customerId} AND target.id=#{orderId}")); + assertTrue(sql.contains("c.overseas_warehouse_id AS overseasWarehouseId")); + assertTrue(sql.contains("ORDER BY c.customer_id,c.id FOR UPDATE")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java new file mode 100644 index 000000000..a595bfbbc --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InboundPlanQueryMapperContractTest { + @Test + void platformListReadsVersionAndDoesNotRequireANullDispatchCustomer() throws Exception { + Select select = InboundPlanQueryMapper.class + .getMethod("rows", String.class, + com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests.Query.class, + long.class, long.class) + .getAnnotation(Select.class); + String sql = String.join(" ", select.value()); + + assertTrue(sql.contains("p.version version")); + assertTrue(sql.contains("WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id")); + } +} 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..9d8f1d297 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -0,0 +1,185 @@ +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.Complete.class, + ReceivingContainerRequests.Sign.class, ReceivingContainerRequests.BulkReceive.class, + ReceivingContainerRequests.CartonReceive.class, ReceivingContainerRequests.WholePalletReceive.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_OVERSEAS_WAREHOUSE_PDA_OPERATOR')" + .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.Complete.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)")); + assertFalse(tableBlock(schema, "ll_receiving_container_session").contains("customer_id")); + assertFalse(tableBlock(schema, "ll_receiving_container_operation").contains("customer_id")); + assertFalse(tableBlock(schema, "ll_container_shipping_gate").contains("customer_id")); + assertTrue(unified.contains("alter table ll_receiving_container_session drop column customer_id")); + assertTrue(unified.contains("alter table ll_receiving_container_operation drop column customer_id")); + assertTrue(unified.contains("alter table ll_container_shipping_gate drop column customer_id")); + 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)")); + assertTrue(schema.contains("create table if not exists ll_receiving_fba_pallet_count")); + assertTrue(schema.contains("unique key uk_ll_receiving_fba_count(session_id,group_key,pallet_code)")); + assertTrue(tableBlock(schema, "ll_inbound_plan_pallet").contains("key idx_ll_inbound_pallet_no (pallet_no)")); + assertFalse(tableBlock(schema, "ll_inbound_plan_pallet").contains("unique key uk_ll_inbound_pallet_no")); + String order = tableBlock(schema, "ll_order"); + assertTrue(order.contains("overseas_warehouse_id varchar(128) null")); + assertTrue(order.contains("overseas_warehouse_code varchar(128) null")); + assertTrue(order.contains("overseas_warehouse_name varchar(255) null")); + 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("create table if not exists ll_receiving_fba_pallet_count")); + 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 in ('completed','completed_with_difference') 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/receipts/pallet:")); + 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(17, successResponses.size()); + assertEquals(13, 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 OVERSEAS_WAREHOUSE_PDA_OPERATOR 可用")); + 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/ReceivingPhotoUploadConfigurationContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingPhotoUploadConfigurationContractTest.java new file mode 100644 index 000000000..137aaf146 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingPhotoUploadConfigurationContractTest.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.inboundplan.pda; + +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 ReceivingPhotoUploadConfigurationContractTest { + @Test + void lastlegAcceptsTheSameTenMegabyteLimitAsFileService() throws Exception { + String application = read("lastleg-service/src/main/resources/application.yml"); + String overseas = read("config/nacos/overseas/lastleg-service.yml"); + + for (String configuration : new String[]{application, overseas}) { + assertThat(configuration).contains( + "max-file-size: ${LASTLEG_MULTIPART_MAX_FILE_SIZE:10MB}", + "max-request-size: ${LASTLEG_MULTIPART_MAX_REQUEST_SIZE:11MB}"); + } + } + + 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/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..1d3474f23 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -0,0 +1,135 @@ +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.DomesticLoadingEvidenceGateway; +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( + "BOOKING-1", "CONT-1", "WAIT_SIGN", 2, 2, 0, 2, 2, 0, + List.of("ONT8"), 4, 20, 0, true, true, null, null, 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 downloadDomesticLoadingPhotoDelegatesWithoutExposingTheSourceFileId() { + when(service.downloadDomesticLoadingPhoto("42", "PHOTO_STEP_1")).thenReturn( + new DomesticLoadingEvidenceGateway.DownloadedFile( + "domestic".getBytes(), "empty-container.jpg", MediaType.IMAGE_JPEG_VALUE)); + + ResponseEntity response = controller.downloadDomesticLoadingPhoto(picker(), "PHOTO_STEP_1"); + + assertThat(response.getStatusCodeValue()).isEqualTo(200); + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.IMAGE_JPEG); + assertThat(response.getHeaders().getCacheControl()).isEqualTo("no-store"); + assertThat(response.getBody()).containsExactly("domestic".getBytes()); + verify(service).downloadDomesticLoadingPhoto("42", "PHOTO_STEP_1"); + } + + @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/HttpDomesticLoadingEvidenceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpDomesticLoadingEvidenceGatewayTest.java new file mode 100644 index 000000000..adead2c8b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpDomesticLoadingEvidenceGatewayTest.java @@ -0,0 +1,60 @@ +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.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; + +class HttpDomesticLoadingEvidenceGatewayTest { + private HttpDomesticLoadingEvidenceGateway gateway; + private MockRestServiceServer server; + + @BeforeEach + void setUp() { + RestClient.Builder builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).build(); + gateway = new HttpDomesticLoadingEvidenceGateway(); + ReflectionTestUtils.setField(gateway, "client", builder.baseUrl("http://domestic-file").build()); + ReflectionTestUtils.setField(gateway, "internalToken", "internal-token"); + } + + @Test + void downloadsImageFromDomesticFileService() { + server.expect(requestTo("http://domestic-file/internal/file/v1/loading-evidence/8/9/content")) + .andExpect(header("X-Internal-Service-Token", "internal-token")) + .andRespond(withSuccess("photo-bytes".getBytes(), MediaType.IMAGE_JPEG) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=seal.jpg")); + + DomesticLoadingEvidenceGateway.DownloadedFile result = gateway.download(8L, 9L); + + assertThat(result.content()).containsExactly("photo-bytes".getBytes()); + assertThat(result.fileName()).isEqualTo("seal.jpg"); + assertThat(result.contentType()).isEqualTo(MediaType.IMAGE_JPEG_VALUE); + server.verify(); + } + + @Test + void rejectsBusinessErrorEnvelopeReturnedWithHttp200() { + server.expect(requestTo("http://domestic-file/internal/file/v1/loading-evidence/8/9/content")) + .andRespond(withSuccess("{\"code\":\"S0500\",\"message\":\"Internal server error\"}", + MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> gateway.download(8L, 9L)) + .isInstanceOfSatisfying(ResponseStatusException.class, error -> { + assertThat(error.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(error.getReason()).isEqualTo("国内装柜照片返回内容无效"); + }); + server.verify(); + } +} 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..674dd2745 --- /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/0/OVERSEAS_UNLOAD_PHOTO/8")) + .andRespond(withSuccess("{\"code\":\"A0400\",\"message\":\"上传内容不是有效图片\",\"data\":null}", + MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> gateway.upload("0", 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 <= 90; attempt += 1) { + const missing = expected.filter((containerNo) => !consumed.has(containerNo)) + const results = await Promise.all(missing.map(async (containerNo) => { + const response = await context.requestOverseas('GET', + `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) + 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 convertReceivingContainers(context, producers) { + for (const producer of producers) { + const containerNo = id(producer?.containerNo) + const summaryResponse = await context.requestOverseas('GET', + `/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`, + undefined, {}, context.overseasToken) + const summaries = success(summaryResponse) && Array.isArray(summaryResponse.body?.data?.records) + ? summaryResponse.body.data.records : [] + const summary = summaries.find((item) => id(item?.containerNo) === containerNo) + const totalOrders = Number(summary?.totalOrderCount) + const inboundCodes = Number(summary?.inboundCodeCount) + const existingPlans = Number(summary?.planCount) + const pendingPlans = Number(summary?.pendingPlanCount) + const convertible = nonBlank(summary?.sourceContainerBookingId) + && totalOrders > 0 && inboundCodes === totalOrders + && Number(summary?.missingInboundCodeCount) === 0 + && ((existingPlans === 0 && pendingPlans === totalOrders) + || (summary?.allPlansReady === true && existingPlans === totalOrders && pendingPlans === 0)) + context.addStep(MODULE, `读取待分配柜级摘要 ${containerNo}`, summaryResponse, + convertible, + `sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS) + if (!convertible) return false + const bookingId = id(summary.sourceContainerBookingId) + const previewPath = `/api/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${encodeURIComponent(bookingId)}` + const previewResponse = await context.requestOverseas('GET', previewPath, + undefined, {}, context.overseasToken) + const preview = success(previewResponse) ? previewResponse.body?.data : null + context.addStep(MODULE, `读取PDA前柜级计划与自动托盘预览 ${containerNo}`, previewResponse, + preview?.ready === true && Number(preview?.totalOrderCount) === totalOrders + && Number(preview?.pendingOrderCount) === pendingPlans, + `orders=${preview?.totalOrderCount ?? 'missing'}, pendingOrders=${preview?.pendingOrderCount ?? 'missing'}, pendingPallets=${preview?.pendingPalletCount ?? 'missing'}; ${trace(previewResponse)}`, + OPTIONS) + if (preview?.ready !== true) return false + if (pendingPlans > 0) { + const body = { sourceContainerBookingId: bookingId, + snapshotVersion: Number(preview.snapshotVersion), snapshotHash: String(preview.snapshotHash) } + const key = `${context.marker}-pda-convert-${containerNo}`.slice(0, 128) + const convertedResponse = await context.requestOverseas('POST', + '/api/platform/v1/last-leg/inbound-plans/convert-container', body, + { 'Idempotency-Key': key }, context.overseasToken) + const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data) + ? convertedResponse.body.data : [] + const convertedInboundCodes = new Set(plans + .flatMap((item) => Array.isArray(item?.lines) ? item.lines : []) + .map((line) => id(line?.inboundCode)).filter(nonBlank)) + context.addStep(MODULE, `整柜转换为逐入仓码独立计划及实体托盘 ${containerNo}`, convertedResponse, + plans.length === inboundCodes + && plans.every((item) => Array.isArray(item?.lines) && item.lines.length > 0 + && Array.isArray(item?.pallets) && item.pallets.length > 0 + && item.pallets.every((pallet) => nonBlank(pallet?.palletNo) && longId(id(pallet?.warehousePalletId)))) + && convertedInboundCodes.size === inboundCodes, + `plans=${plans.length}, inboundCodes=${convertedInboundCodes.size}, expected=${inboundCodes}; ${trace(convertedResponse)}`, OPTIONS) + if (plans.length !== inboundCodes) return false + const actualPalletCodes = plans.flatMap((item) => Array.isArray(item?.pallets) ? item.pallets : []) + .map((pallet) => id(pallet?.palletNo)).filter(nonBlank).sort() + const expectedPalletCodes = (producer?.palletLabelManifest?.groups || []) + .flatMap((group) => group?.labels || []).map((label) => id(label?.palletCode)).filter(nonBlank).sort() + if (producer?.destinationType === 'FBA' && producer?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') { + context.assert(MODULE, `头程 FBA 散货托盘码跨区原样用于实体托盘 ${containerNo}`, convertedResponse, + expectedPalletCodes.length > 0 + && JSON.stringify(actualPalletCodes) === JSON.stringify(expectedPalletCodes), + `source=${expectedPalletCodes.join(',') || 'missing'}, overseas=${actualPalletCodes.join(',') || 'missing'}`, + OPTIONS) + } + if (producer?.destinationType === 'OVERSEAS' && producer?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') { + context.assert(MODULE, `标准海外仓订单由海外仓生成本地实体托盘码 ${containerNo}`, convertedResponse, + expectedPalletCodes.length === 0 && actualPalletCodes.length > 0 + && actualPalletCodes.every((code) => code.startsWith('PLT-')), + `sourceCount=${expectedPalletCodes.length}, overseas=${actualPalletCodes.join(',') || 'missing'}`, + OPTIONS) + } + rememberGeneratedPlans(context, plans) + const replayResponse = await context.requestOverseas('POST', + '/api/platform/v1/last-leg/inbound-plans/convert-container', body, + { 'Idempotency-Key': key }, context.overseasToken) + context.addStep(MODULE, `整柜转换幂等重放零新增 ${containerNo}`, replayResponse, + success(replayResponse) && JSON.stringify(replayResponse.body?.data) === JSON.stringify(plans), + trace(replayResponse), OPTIONS) + if (!success(replayResponse)) return false + } else { + const retainedPlans = context.createdResources?.phase4TailOrderControls?.generatedPlans + rememberGeneratedPlans(context, retainedPlans) + context.addStep(MODULE, `复用尾程控制已生成的整柜计划与实体托盘 ${containerNo}`, previewResponse, + summary?.allPlansReady === true && existingPlans === totalOrders + && Number(preview?.existingPlannedPalletCount) > 0, + `plans=${existingPlans}, plannedPallets=${preview?.existingPlannedPalletCount ?? 'missing'}; ${trace(previewResponse)}`, + OPTIONS) + } + const progressResponse = await context.requestOverseas('GET', + `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`, + undefined, {}, context.overseasToken) + const progress = success(progressResponse) ? progressResponse.body?.data?.summary : null + context.addStep(MODULE, `PDA前整柜计划完整度闭环 ${containerNo}`, progressResponse, + progress?.allPlansReady === true && Number(progress?.planCount) === totalOrders + && Number(progress?.pendingPlanCount) === 0, + `allPlansReady=${progress?.allPlansReady === true}, plans=${progress?.planCount ?? 'missing'}, pending=${progress?.pendingPlanCount ?? 'missing'}, plannedArrivalAt=${progress?.plannedArrivalAt || 'missing'}; ${trace(progressResponse)}`, OPTIONS) + if (progress?.allPlansReady !== true) return false + } + return true +} + +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 candidatesByContainer = new Map() + for (let attempt = 1; attempt <= 30; attempt += 1) { + response = await context.requestOverseas('GET', + '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token) + const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] + candidatesByContainer = new Map(candidateRows + .map((item) => [id(item?.containerNo), item]).filter(([containerNo]) => nonBlank(containerNo))) + if (expected.every((containerNo) => candidatesByContainer.get(containerNo)?.operable === true) + && awaiting.every((containerNo) => candidatesByContainer.get(containerNo)?.operable !== true)) break + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + const seenExpected = expected.filter((containerNo) => candidatesByContainer.has(containerNo)) + const missingPickupReady = expected.filter((containerNo) => candidatesByContainer.get(containerNo)?.operable !== true) + const unexpectedAwaiting = awaiting.filter((containerNo) => candidatesByContainer.get(containerNo)?.operable === true) + const unexpectedHistorical = [...candidatesByContainer.keys()].filter((containerNo) => !known.has(containerNo)) + const targetCorrelated = producers.every((item) => nonBlank(item?.resourceKey) + && item?.shippingReady !== false && candidatesByContainer.get(id(item?.containerNo))?.operable === true) + const ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 && targetCorrelated + context.addStep(MODULE, '实际处理柜全部达到PICKUP_READY并进入PDA候选', response, ready, + `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 +} + +export async function resolveCartonFixtures(context, token, inbounds, producerCartonMarks = [], dropshipProducer = null) { + const strictDropship = dropshipProducer?.fulfillmentType === 'ONE_CLICK_DROPSHIP' + if (strictDropship) { + const sealed = dropshipProducer.sealedCartonMarks + const inbound = inbounds.find((item) => item.inboundCode === dropshipProducer.inboundCode) + const valid = Array.isArray(sealed) && sealed.length > 0 && sealed.every(nonBlank) + && new Set(sealed).size === sealed.length && ['FBA', 'OVERSEAS'].includes(inbound?.destinationType) + && inbound.destinationType === dropshipProducer.destinationType + && sealed.length === Number(inbound.totalPackages) + context.assert(MODULE, '国内清点封托原箱码完整传递到当前海外收货柜', null, valid, + `sealed=${sealed?.length ?? 'missing'}, expected=${inbound?.totalPackages ?? 'missing'}`, OPTIONS) + if (!valid) return null + producerCartonMarks = sealed + } + const manifestCartons = (Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) + .map(id).filter(nonBlank) + const fbaCartons = (strictDropship ? [] : inbounds.filter((item) => item.destinationType === 'FBA')) + .map((item, index) => `${item.inboundCode}#PDA-CONTRACT-${index + 1}`) + const overseasCartons = strictDropship ? manifestCartons : expandOverseasCartonCandidates(inbounds, 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) + if (!strictDropship && overseasCartons.includes(cartonMark) + && a0404Message(response, /^Carton mark was not found$/)) { + context.addStep(MODULE, `排除未实装箱码候选${index + 1}`, response, true, + `cartonMark=${cartonMark}; ${trace(response)}`, OPTIONS) + continue + } + const resolved = response.body?.data + const delimiter = cartonMark.indexOf('#') + const scannedInboundCode = delimiter > 0 ? cartonMark.slice(0, delimiter).trim() : '' + const resolvedInboundCodes = Array.isArray(resolved?.inboundCodes) + ? resolved.inboundCodes.map(id) : [id(resolved?.inboundCode)].filter(nonBlank) + const matches = inbounds.filter((item) => item.inboundCode === scannedInboundCode) + 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) + && resolvedInboundCodes.includes(scannedInboundCode) + && (!strictDropship || (resolved?.cartonMark === cartonMark + && resolved?.inboundCode === dropshipProducer.inboundCode && resolved?.destinationType === dropshipProducer.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真实manifest箱码必须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, inbounds, pallet, cartonFixtures, index) { + const inboundCodes = new Set(inbounds.map((item) => item.inboundCode)) + const resolvedCartons = cartonFixtures.filter((item) => inboundCodes.has(item.inbound.inboundCode) + && item.resolved.destinationType === 'FBA') + if (resolvedCartons.length !== inbounds.length) { + blockerGap(context, `FBA组${[...inboundCodes].join('|')}箱码解析`, '没有为FBA组内每个入仓码完成唯一解析') + return false + } + const packageCount = inbounds.reduce((total, inbound) => total + Number(inbound.totalPackages || 0), 0) + if (packageCount <= 0) return true + const body = { inboundCode: inbounds[0].inboundCode, palletCode: pallet.palletCode, + packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) } + const first = await pda(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}按累计箱数实收`, + '/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组${[...inboundCodes].join('|')}同键同载荷重放`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + context.assert(MODULE, `FBA组${[...inboundCodes].join('|')}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}同键异载荷冲突`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { ...body, packageCount: packageCount + 1 }, conflict) + const already = await pda(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}新键重复累计为零增量`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', + { ...body, idempotencyKey: key(context, `fba-already-${index}`) }) + context.assert(MODULE, `FBA组${[...inboundCodes].join('|')}重复累计不产生超收`, already.response, + success(already.response) && already.data?.result === 'ALREADY_RECEIVED' + && Number(already.data?.acceptedPackages) === 0, trace(already.response), OPTIONS) + 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 receiveDropship(context, token, producer, inbounds) { + const sourcePalletNos = Array.isArray(producer?.sealedSourcePalletNos) + ? producer.sealedSourcePalletNos.map(id).filter(nonBlank) : [] + const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0) + context.assert(MODULE, '一件代发来源托盘快照已从国内封托完整传递', null, + sourcePalletNos.length > 0 && new Set(sourcePalletNos).size === sourcePalletNos.length, + `sourcePallets=${sourcePalletNos.length}, pendingPackages=${expectedPending}`, OPTIONS) + if (!sourcePalletNos.length) return false + let accepted = 0 + for (const [index, sourcePalletNo] of sourcePalletNos.entries()) { + const body = { sourcePalletNo, idempotencyKey: key(context, `dropship-pallet-${index}`) } + const first = await pda(context, token, 'POST', `一件代发整托实收${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', body) + if (!success(first.response)) return false + accepted += Number(first.data?.acceptedPackages || 0) + const replay = await pda(context, token, 'POST', `一件代发整托${index + 1}同键同载荷重放`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', body) + context.assert(MODULE, `一件代发整托${index + 1}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `一件代发整托${index + 1}同键异载荷冲突`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', + { ...body, sourcePalletNo: `${sourcePalletNo}-MUTATED` }, conflict) + const duplicate = await pda(context, token, 'POST', `一件代发来源托盘新键重复收货${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', + { ...body, idempotencyKey: key(context, `dropship-pallet-duplicate-${index}`) }) + context.assert(MODULE, `一件代发来源托盘重复收货不重复入账${index + 1}`, duplicate.response, + success(duplicate.response) && duplicate.data?.result === 'ALREADY_RECEIVED' + && Number(duplicate.data?.acceptedPackages) === 0, trace(duplicate.response), OPTIONS) + } + context.assert(MODULE, '一件代发整托实收箱数与待收箱数守恒', null, + accepted === expectedPending, `accepted=${accepted}, expected=${expectedPending}`, OPTIONS) + return accepted === expectedPending +} + +async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) { + if (runAuthorizationProbes && context.overseasToken) { + const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken) + context.addStep(MODULE, '平台管理员海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) + } + 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) + } + if (runAuthorizationProbes) { + const other = candidates.data.find((item) => item.containerNo !== containerNo + && item.operable === true && item.allPlansReady === true && Number(item.totalPackages) > 0) + 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, preferredProducer) + 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 resolveGeneratedPallet(context, inbound) + if (!pallet) { + blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}自动计划托盘`, + `按柜确认没有为该入仓码留下可解析的实体托盘;禁止在PDA收货时临时新建脱离计划的托盘`, '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') + const strictDropship = preferredProducer?.fulfillmentType === 'ONE_CLICK_DROPSHIP' + if (!strictDropship && 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$/)) + const groups = new Map() + for (const inbound of fbaInbounds) { + const fixture = cartonFixtures.find((item) => item.inbound.inboundCode === inbound.inboundCode) + const groupKey = fixture?.resolved?.groupKey || `INBOUND:${inbound.inboundCode}` + if (!groups.has(groupKey)) groups.set(groupKey, []) + groups.get(groupKey).push(inbound) + } + for (const [index, group] of [...groups.values()].entries()) { + await receiveFba(context, token, group, pallets.get(group[0].inboundCode), cartonFixtures, index) + } + } else if (!strictDropship && preferredProducer?.destinationType !== 'OVERSEAS') { + blockerGap(context, 'FBA按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') + } + if (!strictDropship && 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 (!strictDropship && preferredProducer?.destinationType !== 'FBA') { + blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') + } + if (strictDropship) { + const dropshipInbounds = [...fbaInbounds, ...overseasInbounds] + const firstInbound = dropshipInbounds[0] + const pallet = firstInbound ? pallets.get(firstInbound.inboundCode) : null + if (!firstInbound || !pallet) { + blockerGap(context, '一件代发整托链', '当前一件代发柜没有带预留实体托盘的入仓码') + return + } + if (firstInbound.destinationType === 'OVERSEAS') { + await expected(context, token, 'POST', '一件代发硬拒绝逐箱收货', + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', { + cartonMark: cartonFixtures[0]?.cartonMark, + palletCode: pallet.palletCode, idempotencyKey: key(context, 'dropship-carton-forbidden'), + }, (response) => a0409Message(response, /^One-click dropship inbound must be operated by complete source pallet$/)) + } else { + await expected(context, token, 'POST', '一件代发硬拒绝按箱数收货', + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { + inboundCode: firstInbound.inboundCode, palletCode: pallet.palletCode, + packageCount: 1, idempotencyKey: key(context, 'dropship-bulk-forbidden'), + }, (response) => a0409Message(response, /^One-click dropship inbound must be operated by complete source pallet$/)) + } + await receiveDropship(context, token, preferredProducer, dropshipInbounds) + } + 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 physicalPallets = [...new Map([...pallets.values()] + .map((item) => [id(item.palletId), item])).values()] + const generatedContainerPallets = current.data.inbounds.flatMap((item) => + context.createdResources.phase4OverseasPda.generatedPalletsByInbound[item.inboundCode] || []) + const completedFixtures = [] + 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) { + const fixture = { + containerNo, order, destinationType: inbound.destinationType, + fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null, + auxiliaryPallets: generatedContainerPallets.filter((candidate) => + id(candidate?.palletId) !== id(warehouseFacts.pallet?.palletId)), + ...warehouseFacts, + } + context.context.phase4OverseasPdaOperations.completedFixtures.push(fixture) + completedFixtures.push(fixture) + } + } + const receivedPallets = [...new Map(completedFixtures.map((fixture) => { + const palletId = id(fixture.pallet?.palletId) + const source = physicalPallets.find((candidate) => id(candidate?.palletId) === palletId) + return [palletId, { palletId, unitPrice: Number(source?.unitPrice || 0) }] + }).filter(([palletId]) => longId(palletId))).values()] + context.createdResources.phase4OverseasPda.completedContainerFacts.push({ + containerNo, + containerBookingId: id(preferredProducer?.containerBookingId), + expectedPalletCost: receivedPallets.reduce((sum, item) => sum + item.unitPrice, 0), + palletIds: receivedPallets.map((item) => item.palletId), + }) + } +} + +export async function run(context) { + context.createdResources.phase4OverseasPda = { + marker: context.marker, containerNo: null, customerId: null, orderId: null, + palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [], + completedContainerFacts: [], + overseasWarehouseId: null, overseasWarehouseName: null, + generatedPalletsByInbound: {}, + 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, + merchantToken, + completedFixtures: [], + cleanup, + } + try { + if (!primary?.token || !secondary?.token) { + blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个OVERSEAS_WAREHOUSE_PDA_OPERATOR平台账号,无法验证全局一柜锁') + return + } + const matrix = context.createdResources?.phase4InboundProducerFixtures + const dropship = matrix?.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') + const standardFbaProducers = (matrix?.byDestinationType?.FBA || []) + .filter((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') + .slice(0, 2) + // The sealed dropship pallet proves the indivisible handover. A separate standard FBA + // fixture supplies multiple cartons for reversible operations. The second standard FBA + // fixture remains available for the multi-destination dispatch and delivery split proof. + const producers = [dropship, ...standardFbaProducers, + matrix?.byDestinationType?.OVERSEAS?.[0]] + .filter(uniqueContainerProducer) + const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers + if (allProducers.length) await awaitProducerConsumption(context, allProducers) + if (producers.length && !(await convertReceivingContainers(context, producers))) { + blockerGap(context, '实际处理柜整柜转换门禁', + '目标柜未能通过正式柜级预览与确认形成逐入仓码计划及实体托盘') + return + } + if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) { + blockerGap(context, '实际处理柜PICKUP_READY门禁', + 'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜') + return + } + if (producers.length && context.options?.modules?.includes('phase5-terminal-drayage') + && !(await prepareTerminalDrayage(context, producers[0]))) { + blockerGap(context, '目的港码头拖车提柜前置', + '本轮首个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) + } + } + const refreshedMerchantToken = await loginMerchantForNegativeProbe(context) + if (nonBlank(refreshedMerchantToken)) { + context.context.phase4OverseasPdaOperations.merchantToken = refreshedMerchantToken + } + 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-return-followups.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs new file mode 100644 index 000000000..bf43e5537 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs @@ -0,0 +1,191 @@ +import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' + +const MODULE = 'phase4-overseas-return-followups' +const SCENARIO = 'phase4-overseas-return-followups' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const RETURN_ORDERS = '/api/merchant/v1/last-leg/return-orders' +const PDA_RECEIPTS = '/api/pda/v1/last-leg/return-receipts' +const RETURN_INVENTORY = '/api/merchant/v1/last-leg/return-inventory' + +function id(value) { return value == null ? '' : String(value) } +function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } +function code(response) { return String(response?.body?.code ?? '') } +function success(response) { return response?.http === 200 && code(response) === '200' } +function conflict(response) { + return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))) + && nonBlank(response?.requestId) && nonBlank(response?.traceId) +} +function notFound(response) { + return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) + && nonBlank(response?.requestId) && nonBlank(response?.traceId) +} +function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, values) { + const params = new URLSearchParams() + for (const [name, value] of Object.entries(values || {})) { + if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value)) + } + return params.size ? `${path}?${params}` : path +} +function key(context, action) { + return `${context.marker}-RETURN-${action}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 255) +} +function markerSuffix(context) { + return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() +} + +function add(context, name, response, passed, evidence = '') { + context.addStep(MODULE, name, response, passed, `${evidence}${evidence ? '; ' : ''}${trace(response)}`, OPTIONS) +} + +async function overseas(context, token, method, name, path, body = undefined) { + const response = await context.requestOverseas(method, path, body, {}, token) + add(context, name, response, success(response)) + return { response, data: success(response) ? response.body?.data : null } +} + +async function resolveWarehouse(context) { + const existing = id(context.createdResources?.phase4OverseasPda?.overseasWarehouseId) + const response = await context.request('GET', + '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE') + const rows = records(response?.body?.data) + const fixedIds = new Set(['193000000000200003', '193000000000200004']) + const warehouse = rows.find((item) => id(item?.id) === existing) + || rows.find((item) => fixedIds.has(id(item?.id))) + || rows[0] + add(context, '读取可执行退货签收的海外仓', response, + success(response) && nonBlank(id(warehouse?.id)), `warehouseId=${id(warehouse?.id) || 'missing'}`) + return success(response) && warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null +} + +async function loginMerchant(context) { + const fixture = context.context?.merchantQualificationFixture + if (fixture?.marker !== context.marker || !fixture?.username || !fixture?.password) { + add(context, '取得本轮客户账号', null, false, `marker=${fixture?.marker || 'missing'}`) + return null + } + const response = await context.request('POST', '/api/auth/login', { + loginId: fixture.username, password: fixture.password, + }, '') + const token = success(response) ? response.body?.data?.overseasAccessToken : null + add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`) + return nonBlank(token) ? token : null +} + +function itemMap(items) { + return new Map((Array.isArray(items) ? items : []).map((item) => [item.skuCode, item])) +} + +export async function run(context) { + const source = context.createdResources.phase4OverseasReturnReceipt + if (source?.marker !== context.marker || !source?.returnNo) { + add(context, '后续关联要求本轮退货签收合箱前置', null, false, '缺少本轮退货前置') + return + } + const merchantToken = await loginMerchant(context) + const warehouse = await resolveWarehouse(context) + const access = await resolvePickerAccess(context) + if (!merchantToken || !warehouse || !access) return + const picker = await createPicker(context, 92, warehouse.id, access) + if (!picker?.token) { + add(context, '创建后续关联PDA操作员', null, false, '未取得绑定仓PDA令牌') + if (picker?.membershipId) await disablePicker(context, picker, 92) + return + } + const returnNo = source.returnNo + const customerId = id(context.context?.merchantQualificationFixture?.tenantId) + const suffix = markerSuffix(context) + try { + const relabelCandidates = await overseas(context, merchantToken, 'GET', '客户查询本轮退货换标候选', + `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel-candidates`) + const relabelCandidate = (Array.isArray(relabelCandidates.data) ? relabelCandidates.data : []) + .find(row => nonBlank(row?.cartonMark) && row?.status === 'AVAILABLE') + context.assert(MODULE, '退货换标候选来自本轮已签收可用箱', relabelCandidates.response, + Boolean(relabelCandidate) && relabelCandidate.sourceReturnNo === returnNo, + `cartonMark=${relabelCandidate?.cartonMark || 'missing'}`, OPTIONS) + if (relabelCandidate) { + const relabelBody = { + cartonMarks: [relabelCandidate.cartonMark], idempotencyKey: key(context, 'RELABEL'), + contactName: '退货换标验收', contactPhone: '13800000008', deliveryMethod: 'TRUCK', remark: context.marker, + } + const relabel = await overseas(context, merchantToken, 'POST', '客户从退货箱创建换标单', + `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel`, relabelBody) + const relabelNo = id(relabel.data?.relabelNo) + context.assert(MODULE, '退货换标单绑定原退货单与选中箱', relabel.response, + nonBlank(relabelNo) && relabel.data?.returnNo === returnNo + && relabel.data?.status === 'PENDING_PROCESSING' + && relabel.data?.cartonMarks?.includes?.(relabelCandidate.cartonMark), + `relabelNo=${relabelNo || 'missing'}`, OPTIONS) + if (relabelNo) { + const form = new FormData() + form.append('file', new Blob([new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`)], + { type: 'application/pdf' }), `${suffix}-return-relabel.pdf`) + const uploaded = await context.requestOverseasMultipart('POST', + `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files?scope=CARTON_TOTAL`, + form, {}, merchantToken) + add(context, '客户编辑退货换标总资料', uploaded, success(uploaded)) + const relabelDetail = await overseas(context, merchantToken, 'GET', '客户回查退货换标资料修改', + `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`) + context.assert(MODULE, '退货换标详情持久化总资料且业务状态未漂移', relabelDetail.response, + relabelDetail.data?.application?.relabelNo === relabelNo + && relabelDetail.data?.application?.status === 'PENDING_PROCESSING' + && nonBlank(relabelDetail.data?.application?.totalFileId), trace(relabelDetail.response), OPTIONS) + context.createdResources.phase4OverseasReturnReceipt.relabelNo = relabelNo + } + } + + const outboundCandidates = await overseas(context, context.overseasToken, 'GET', '平台查询本轮退货出库候选', + query('/api/platform/v1/last-leg/outbound-orders/return/candidates', { + customerId, overseasWarehouseId: warehouse.id, + })) + const outboundCandidate = (Array.isArray(outboundCandidates.data) ? outboundCandidates.data : []) + .find(row => row?.returnNo === returnNo && nonBlank(row?.cartonMark) + && row.cartonMark !== relabelCandidate?.cartonMark && row.status === 'AVAILABLE') + context.assert(MODULE, '退货出库候选排除已冻结换标箱', outboundCandidates.response, + Boolean(outboundCandidate), `cartonMark=${outboundCandidate?.cartonMark || 'missing'}`, OPTIONS) + if (outboundCandidate) { + const outboundBody = { + recipient: `${context.marker} RETURN OUTBOUND`, overseasWarehouseId: warehouse.id, + returnNo, cartonMarks: [outboundCandidate.cartonMark], remark: context.marker, + idempotencyKey: key(context, 'OUTBOUND'), + } + const outbound = await overseas(context, context.overseasToken, 'POST', '平台创建独立退货出库单', + query('/api/platform/v1/last-leg/outbound-orders/return', { customerId }), outboundBody) + const taskNo = id(outbound.data?.taskNo) + const outboundNo = id(outbound.data?.outboundNo) + context.assert(MODULE, 'RETURN_OUTBOUND创建PDA待拣任务', outbound.response, + nonBlank(taskNo) && nonBlank(outboundNo) && outbound.data?.status === 'PENDING', + `outboundNo=${outboundNo || 'missing'}, taskNo=${taskNo || 'missing'}`, OPTIONS) + if (taskNo) { + const task = await overseas(context, picker.token, 'GET', 'PDA读取退货出库任务明细', + `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}`) + const taskItem = task.data?.items?.find?.(row => String(row?.cartonMarks || '').includes(outboundCandidate.cartonMark)) + || task.data?.items?.[0] + const pickBody = { outboundPalletItemId: taskItem?.outboundPalletItemId, + inventoryPositionId: null, packages: 1, cartonMarks: [outboundCandidate.cartonMark], + idempotencyKey: key(context, 'OUTBOUND-PICK') } + const picked = await overseas(context, picker.token, 'POST', 'PDA一次扫描退货箱完成拣货', + `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/pick`, pickBody) + context.assert(MODULE, '退货出库拣货推进为PICKED且任务进行中', picked.response, + picked.data?.outboundNo === outboundNo && picked.data?.status === 'IN_PROGRESS', trace(picked.response), OPTIONS) + const completed = await overseas(context, picker.token, 'POST', 'PDA完成退货出库', + `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/complete`, + { idempotencyKey: key(context, 'OUTBOUND-COMPLETE') }) + const outboundDetail = await overseas(context, context.overseasToken, 'GET', '平台回查退货出库最终状态', + query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId })) + context.assert(MODULE, 'RETURN_OUTBOUND真实完成且箱唛只出库一次', outboundDetail.response, + completed.data?.status === 'COMPLETED' + && outboundDetail.data?.outbound?.outboundType === 'RETURN_OUTBOUND' + && outboundDetail.data?.outbound?.status === 'OUTBOUNDED' + && outboundDetail.data?.pallets?.some?.(row => row.cartonMarks === outboundCandidate.cartonMark + && Number(row.pickedPackageCount) === 1), trace(outboundDetail.response), OPTIONS) + context.createdResources.phase4OverseasReturnReceipt.returnOutboundNo = outboundNo + } + } + } finally { + await disablePicker(context, picker, 92) + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs new file mode 100644 index 000000000..013e3b62b --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { validationFailure, inventorySnapshot } from './phase4-overseas-return-receipt.mjs' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' + +const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url) +const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url) + +test('full acceptance registers return receipt as a configured overseas module with an isolated merchant fixture', async () => { + const source = await readFile(acceptanceUrl, 'utf8') + assert.match(source, /CONFIGURED_MODULES[^\n]+phase4-overseas-return-receipt/) + assert.match(source, /\['phase4-overseas-return-receipt', \['merchant-qualification-fixture'\]\]/) + assert.match(source, /requiresMarkerOwnedMerchantFixture[\s\S]+phase4-overseas-return-receipt/) +}) + +test('return receipt acceptance covers both lookup paths, partial/final receipt and normal empty results', async () => { + const source = await readFile(moduleUrl, 'utf8') + assert.match(source, /PDA_RECEIPTS = '\/api\/pda\/v1\/last-leg\/return-receipts'/) + assert.match(source, /\$\{PDA_RECEIPTS\}\/start/) + assert.match(source, /\$\{PDA_RECEIPTS\}\/sku-candidates/) + assert.match(source, /PDA首次分批签收2件/) + assert.match(source, /PDA按SKU完成剩余3件签收/) + assert.match(source, /不存在SKU不是业务错误/) + assert.match(source, /已完成退货单不再进入待签收流程/) +}) + +test('return receipt acceptance proves idempotency, over-receipt isolation and exact inventory', async () => { + const source = await readFile(moduleUrl, 'utf8') + assert.match(source, /预报幂等重放复用同一退货单/) + assert.match(source, /签收重放不重复创建签收单、箱或计费批次/) + assert.match(source, /超收被拒绝且不得生成费用批次/) + assert.match(source, /订单5件核平且恰有两个唯一仓储计费批次/) + assert.match(source, /两个实收箱逐一引用对应计费批次且数量守恒/) +}) + +test('return receipt acceptance includes fee-batch evidence and return packing lifecycle', async () => { + const source = await readFile(moduleUrl, 'utf8') + assert.match(source, /billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt'/) + assert.match(source, /receiptBatchNos/) + assert.match(source, /sourceBatchNo/) + assert.match(source, /PDA查询完成退货单的可合箱库存/) + assert.match(source, /PDA将同客户退货库存合为新箱/) + assert.match(source, /合箱重放复用同一新箱且不重复扣减/) +}) + +test('HTTP acceptance uses injected clients and never bypasses APIs with database access', async () => { + const source = await readFile(moduleUrl, 'utf8') + assert.doesNotMatch(source, /process\.env|https?:\/\/|\bfetch\s*\(|\bmysql\b|\bSELECT\s|\bINSERT\s|\bUPDATE\s/i) +}) + +test('receipt scope stops before relabel and outbound; followups are explicit and dependency ordered', async () => { + const source = await readFile(moduleUrl, 'utf8') + assert.doesNotMatch(source, /relabel-candidates|relabel-applications|outbound-orders|outbound-tasks/) + const ordered = normalizeModuleOrder(['phase4-overseas-return-followups']) + assert.ok(ordered.indexOf('phase4-overseas-return-receipt') < ordered.indexOf('phase4-overseas-return-followups')) + assert.ok(!normalizeModuleOrder(['phase4-overseas-return-receipt']).includes('phase4-overseas-return-followups')) +}) + +test('validation predicate rejects outages, permission failures, success and missing diagnostics', () => { + const response = { http: 200, body: { code: 'A0422' }, requestId: 'request', traceId: 'trace' } + assert.equal(validationFailure(response), true) + for (const code of ['200', 'A0403', 'A0409', 'S0500', 'S0503']) { + assert.equal(validationFailure({ ...response, body: { code } }), false) + } + assert.equal(validationFailure({ ...response, traceId: '' }), false) +}) + +test('inventory snapshot ignores ordering but detects quantity, status and source drift', () => { + const a = { cartonMark: 'A', totalQuantity: 2, status: 'AVAILABLE', sourceBatchNo: 'B1', sourceReturnNo: 'R1' } + const b = { ...a, cartonMark: 'B', totalQuantity: 3 } + const baseline = inventorySnapshot([a, b]) + assert.equal(inventorySnapshot({ records: [b, a] }), baseline) + for (const change of [{ totalQuantity: 1 }, { status: 'MERGED' }, { sourceBatchNo: 'B2' }, { sourceReturnNo: 'R2' }]) { + assert.notEqual(inventorySnapshot([{ ...a, ...change }, b]), baseline) + } +}) + +test('extended HTTP coverage includes negative cases and persisted no-mutation verification', async () => { + const source = await readFile(moduleUrl, 'utf8') + for (const label of ['重复预报SKU', '负实收数量', '未预报SKU', '部分签收退货单禁止合箱', + '超可用库存', '失败与幂等重放不改变箱数数量状态及来源', '跨页箱号无重复无遗漏', '合箱及异常后客户SKU仍为甲3乙2']) { + assert.ok(source.includes(label), label) + } +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs new file mode 100644 index 000000000..649cec453 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -0,0 +1,443 @@ +import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' +import { runReturnBoundaries, verifyNoMutation } from './return-boundary-scenarios.mjs' + +const MODULE = 'phase4-overseas-return-receipt' +const SCENARIO = 'phase4-overseas-return-receipt-and-billing' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const RETURN_ORDERS = '/api/merchant/v1/last-leg/return-orders' +const PDA_RECEIPTS = '/api/pda/v1/last-leg/return-receipts' +const RETURN_INVENTORY = '/api/merchant/v1/last-leg/return-inventory' + +function id(value) { return value == null ? '' : String(value) } +function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } +function code(response) { return String(response?.body?.code ?? '') } +function success(response) { return response?.http === 200 && code(response) === '200' } +function conflict(response) { + return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))) + && nonBlank(response?.requestId) && nonBlank(response?.traceId) +} +function notFound(response) { + return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) + && nonBlank(response?.requestId) && nonBlank(response?.traceId) +} +export function validationFailure(response) { + return ((response?.http === 400 || response?.http === 422) + || (response?.http === 200 && ['400', '422', 'A0400', 'A0422'].includes(code(response)))) + && nonBlank(response?.requestId) && nonBlank(response?.traceId) +} + +export function inventorySnapshot(cartons) { + return JSON.stringify(records(cartons).map(row => ({ + cartonMark: row.cartonMark, status: row.status, quantity: Number(row.totalQuantity), + sourceBatchNo: row.sourceBatchNo, sourceReturnNo: row.sourceReturnNo, + })).sort((a, b) => a.cartonMark.localeCompare(b.cartonMark))) +} +function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, values) { + const params = new URLSearchParams() + for (const [name, value] of Object.entries(values || {})) { + if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value)) + } + return params.size ? `${path}?${params}` : path +} +function key(context, action) { + return `${context.marker}-RETURN-${action}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 255) +} +function markerSuffix(context) { + return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() +} + +function add(context, name, response, passed, evidence = '') { + context.addStep(MODULE, name, response, passed, `${evidence}${evidence ? '; ' : ''}${trace(response)}`, OPTIONS) +} + +async function overseas(context, token, method, name, path, body = undefined) { + const response = await context.requestOverseas(method, path, body, {}, token) + add(context, name, response, success(response)) + return { response, data: success(response) ? response.body?.data : null } +} + +async function resolveWarehouse(context) { + const existing = id(context.createdResources?.phase4OverseasPda?.overseasWarehouseId) + const response = await context.request('GET', + '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE') + const rows = records(response?.body?.data) + const fixedIds = new Set(['193000000000200003', '193000000000200004']) + const warehouse = rows.find((item) => id(item?.id) === existing) + || rows.find((item) => fixedIds.has(id(item?.id))) + || rows[0] + add(context, '读取可执行退货签收的海外仓', response, + success(response) && nonBlank(id(warehouse?.id)), `warehouseId=${id(warehouse?.id) || 'missing'}`) + return success(response) && warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null +} + +async function loginMerchant(context) { + const fixture = context.context?.merchantQualificationFixture + if (fixture?.marker !== context.marker || !fixture?.username || !fixture?.password) { + add(context, '取得本轮客户账号', null, false, `marker=${fixture?.marker || 'missing'}`) + return null + } + const response = await context.request('POST', '/api/auth/login', { + loginId: fixture.username, password: fixture.password, + }, '') + const token = success(response) ? response.body?.data?.overseasAccessToken : null + add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`) + return nonBlank(token) ? token : null +} + +function itemMap(items) { + return new Map((Array.isArray(items) ? items : []).map((item) => [item.skuCode, item])) +} + +export async function run(context) { + context.createdResources.phase4OverseasReturnReceipt = { + marker: context.marker, returnNo: null, receiptNos: [], receiptBatchNos: [], cartonMarks: [], + billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt', + } + const merchantToken = await loginMerchant(context) + const warehouse = await resolveWarehouse(context) + const access = await resolvePickerAccess(context) + if (!merchantToken || !warehouse || !access) return + const picker = await createPicker(context, 91, warehouse.id, access) + if (!picker?.token) { + add(context, '创建退货签收PDA操作员', null, false, '未取得绑定仓PDA令牌') + if (picker?.membershipId) await disablePicker(context, picker, 91) + return + } + + const suffix = markerSuffix(context) + const skuA = `RET-A-${suffix}`.slice(0, 128) + const skuB = `RET-B-${suffix}`.slice(0, 128) + const createBody = { + overseasWarehouseId: warehouse.id, + warehouseContactName: '全量验收海外仓', warehouseContactPhone: '0000000000', + customerReferenceNo: `REF-${suffix}`, needRelabel: false, remark: context.marker, + idempotencyKey: key(context, 'CREATE'), + items: [ + { skuCode: skuA, productNameZh: '退货验收商品A', expectedQuantity: 3 }, + { skuCode: skuB, productNameZh: '退货验收商品B', expectedQuantity: 2 }, + ], + } + + try { + const created = await overseas(context, merchantToken, 'POST', '客户创建双SKU退货预报', RETURN_ORDERS, createBody) + const returnNo = id(created.data?.order?.returnNo) + context.assert(MODULE, '退货预报初始数量和状态正确', created.response, + nonBlank(returnNo) && created.data?.order?.status === 'PENDING_RECEIPT' + && Number(created.data?.order?.expectedQuantity) === 5 + && Number(created.data?.order?.receivedQuantity) === 0 + && itemMap(created.data?.items).size === 2, + `returnNo=${returnNo || 'missing'}`, OPTIONS) + if (!returnNo) return + context.createdResources.phase4OverseasReturnReceipt.returnNo = returnNo + + async function snapshot() { + const reads = [ + ['订单列表', query(RETURN_ORDERS, { page: 1, size: 200 })], + ['订单及签收批次', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`], + ['全部状态箱', query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo })], + ['SKU汇总', query(`${RETURN_INVENTORY}/summary`, { page: 1, size: 200, overseasWarehouseId: warehouse.id })], + ] + const state = {} + for (const [name, path] of reads) { + const result = await overseas(context, merchantToken, 'GET', `失败操作逐项回查${name}`, path) + if (!success(result.response)) throw new Error(`无法读取${name},不能证明失败未落账`) + state[name] = result.data + } + state.items = [] + for (const row of records(state['全部状态箱'])) { + const result = await overseas(context, merchantToken, 'GET', '失败操作逐箱回查明细', `${RETURN_INVENTORY}/cartons/${encodeURIComponent(row.cartonMark)}`) + if (!success(result.response)) throw new Error('无法读取箱明细,不能证明失败未落账') + state.items.push(result.data) + } + return state + } + const reject = (name, expected, action) => verifyNoMutation({ snapshot, action, name, expected, + check: (label, response, passed) => add(context, label, response, passed) }) + + const createReplay = await overseas(context, merchantToken, 'POST', '退货预报同键同载荷重放', RETURN_ORDERS, createBody) + context.assert(MODULE, '预报幂等重放复用同一退货单', createReplay.response, + id(createReplay.data?.order?.returnNo) === returnNo, trace(createReplay.response), OPTIONS) + await reject('退货预报同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', RETURN_ORDERS, + { ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken)) + + for (const [name, items] of [ + ['空SKU明细', []], + ['零预报数量', [{ skuCode: skuA, expectedQuantity: 0 }]], + ['负预报数量', [{ skuCode: skuA, expectedQuantity: -1 }]], + ['小数预报数量', [{ skuCode: skuA, expectedQuantity: 1.5 }]], + ['重复预报SKU', [createBody.items[0], createBody.items[0]]], + ]) { + await reject(`拒绝${name}`, validationFailure, () => context.requestOverseas('POST', RETURN_ORDERS, + { ...createBody, items, idempotencyKey: key(context, `INVALID-CREATE-${encodeURIComponent(name)}`) }, {}, merchantToken)) + } + const onlyOrder = await overseas(context, merchantToken, 'GET', '非法预报后回查本轮订单数量', + query(RETURN_ORDERS, { page: 1, size: 20, skuCode: skuA })) + context.assert(MODULE, '非法预报不新增订单且原单仍为待签收', onlyOrder.response, + Number(onlyOrder.data?.total) === 1 && records(onlyOrder.data)[0]?.returnNo === returnNo + && records(onlyOrder.data)[0]?.status === 'PENDING_RECEIPT', trace(onlyOrder.response), OPTIONS) + + await reject('不存在退货单返回可诊断未找到', notFound, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, + { returnNo: `MISSING-${suffix}` }, {}, picker.token)) + + const emptySku = await overseas(context, picker.token, 'POST', 'PDA查询不存在SKU返回正常空候选', + `${PDA_RECEIPTS}/sku-candidates`, { skuCode: `MISSING-${suffix}` }) + context.assert(MODULE, '不存在SKU不是业务错误', emptySku.response, + success(emptySku.response) && Array.isArray(emptySku.data?.candidates) && emptySku.data.candidates.length === 0, + trace(emptySku.response), OPTIONS) + + const started = await overseas(context, picker.token, 'POST', 'PDA按退货单号开始签收', + `${PDA_RECEIPTS}/start`, { returnNo }) + context.assert(MODULE, '首次开始返回全部未收SKU', started.response, + started.data?.status === 'PENDING_RECEIPT' && itemMap(started.data?.expectedItems).size === 2, + trace(started.response), OPTIONS) + const skuCandidates = await overseas(context, picker.token, 'POST', 'PDA按SKU查询当前仓候选退货单', + `${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB }) + context.assert(MODULE, 'SKU候选包含本轮退货单及准确数量', skuCandidates.response, + skuCandidates.data?.candidates?.some?.((candidate) => candidate.returnNo === returnNo + && Number(candidate.skuExpectedQuantity) === 2 && Number(candidate.skuReceivedQuantity) === 0), + trace(skuCandidates.response), OPTIONS) + + const cartonA = `RC-${suffix}-A`.slice(0, 128) + const firstBody = { + returnNo, trackingNo: `TRACK-${suffix}`, startedBy: 'RETURN_NO', cartonMark: cartonA, + items: [{ skuCode: skuA, receivedQuantity: 2 }], idempotencyKey: key(context, 'RECEIPT-1'), + } + const first = await overseas(context, picker.token, 'POST', 'PDA首次分批签收2件', `${PDA_RECEIPTS}/confirm`, firstBody) + context.assert(MODULE, '首次签收形成唯一箱和仓储计费批次', first.response, + first.data?.orderStatus === 'PARTIALLY_RECEIVED' && first.data?.fullyMatched === false + && Number(first.data?.receivedQuantity) === 2 && first.data?.cartonMark === cartonA + && nonBlank(first.data?.receiptNo) && nonBlank(first.data?.receiptBatchNo), + `receiptNo=${id(first.data?.receiptNo) || 'missing'}, billingBatch=${id(first.data?.receiptBatchNo) || 'missing'}`, OPTIONS) + const firstReceiptNo = id(first.data?.receiptNo) + const firstBatchNo = id(first.data?.receiptBatchNo) + + const firstReplay = await overseas(context, picker.token, 'POST', '首次签收同键同载荷重放', `${PDA_RECEIPTS}/confirm`, firstBody) + context.assert(MODULE, '签收重放不重复创建签收单、箱或计费批次', firstReplay.response, + id(firstReplay.data?.receiptNo) === firstReceiptNo + && id(firstReplay.data?.receiptBatchNo) === firstBatchNo + && id(firstReplay.data?.cartonMark) === cartonA, + trace(firstReplay.response), OPTIONS) + await reject('签收同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, { + ...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`, + }, {}, picker.token)) + + await reject('超收被拒绝且不得生成费用批次', conflict, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, { + returnNo, startedBy: 'SKU', items: [{ skuCode: skuA, receivedQuantity: 2 }], + idempotencyKey: key(context, 'OVER-RECEIPT'), + }, {}, picker.token)) + + for (const [name, changes, expected] of [ + ['零实收数量', { items: [{ skuCode: skuA, receivedQuantity: 0 }] }, validationFailure], + ['负实收数量', { items: [{ skuCode: skuA, receivedQuantity: -1 }] }, validationFailure], + ['空实收明细', { items: [] }, validationFailure], + ['非法签收方式', { startedBy: 'INVALID' }, validationFailure], + ['未预报SKU', { items: [{ skuCode: `UNKNOWN-${suffix}`, receivedQuantity: 1 }] }, conflict], + ]) { + await reject(`拒绝${name}`, expected, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, + { ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, {}, picker.token)) + } + await reject('部分签收退货单禁止合箱', conflict, () => context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { + returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }], length: 30, width: 20, height: 10, + weight: 1, idempotencyKey: key(context, 'EARLY-MERGE'), + }, {}, picker.token)) + + const afterPartial = await overseas(context, merchantToken, 'GET', '客户回查部分签收状态', + `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`) + context.assert(MODULE, '超收失败后订单、签收和费用批次数量均未变化', afterPartial.response, + afterPartial.data?.order?.status === 'PARTIALLY_RECEIVED' + && Number(afterPartial.data?.order?.receivedQuantity) === 2 + && afterPartial.data?.receipts?.length === 1 + && id(afterPartial.data.receipts[0]?.receiptBatchNo) === firstBatchNo, + `receipts=${afterPartial.data?.receipts?.length ?? 'missing'}`, OPTIONS) + + const secondBody = { + returnNo, startedBy: 'SKU', + items: [{ skuCode: skuA, receivedQuantity: 1 }, { skuCode: skuB, receivedQuantity: 2 }], + idempotencyKey: key(context, 'RECEIPT-2'), + } + const second = await overseas(context, picker.token, 'POST', 'PDA按SKU完成剩余3件签收', `${PDA_RECEIPTS}/confirm`, secondBody) + context.assert(MODULE, '最终签收自动核平并生成第二个费用批次', second.response, + second.data?.orderStatus === 'COMPLETED' && second.data?.fullyMatched === true + && Number(second.data?.receivedQuantity) === 5 && nonBlank(second.data?.cartonMark) + && nonBlank(second.data?.receiptNo) && nonBlank(second.data?.receiptBatchNo) + && id(second.data?.receiptBatchNo) !== firstBatchNo, + `billingBatch=${id(second.data?.receiptBatchNo) || 'missing'}`, OPTIONS) + const secondReceiptNo = id(second.data?.receiptNo) + const secondBatchNo = id(second.data?.receiptBatchNo) + const cartonB = id(second.data?.cartonMark) + context.createdResources.phase4OverseasReturnReceipt.receiptNos.push(firstReceiptNo, secondReceiptNo) + context.createdResources.phase4OverseasReturnReceipt.receiptBatchNos.push(firstBatchNo, secondBatchNo) + context.createdResources.phase4OverseasReturnReceipt.cartonMarks.push(cartonA, cartonB) + + await reject('已完成退货单不再进入待签收流程', notFound, + () => context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, { returnNo }, {}, picker.token)) + const completedCandidates = await overseas(context, picker.token, 'POST', '完成后SKU候选自动清空', + `${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB }) + context.assert(MODULE, '完成单不再出现在SKU候选', completedCandidates.response, + !completedCandidates.data?.candidates?.some?.((candidate) => candidate.returnNo === returnNo), + trace(completedCandidates.response), OPTIONS) + + const detail = await overseas(context, merchantToken, 'GET', '客户回查完成退货单与两次签收', + `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`) + const detailItems = itemMap(detail.data?.items) + const detailReceipts = Array.isArray(detail.data?.receipts) ? detail.data.receipts : [] + const detailBatches = new Set(detailReceipts.map((receipt) => id(receipt.receiptBatchNo))) + context.assert(MODULE, '订单5件核平且恰有两个唯一仓储计费批次', detail.response, + detail.data?.order?.status === 'COMPLETED' && Number(detail.data?.order?.receivedQuantity) === 5 + && Number(detailItems.get(skuA)?.receivedQuantity) === 3 + && Number(detailItems.get(skuB)?.receivedQuantity) === 2 + && detailReceipts.length === 2 && detailBatches.size === 2 + && detailBatches.has(firstBatchNo) && detailBatches.has(secondBatchNo), + `receipts=${detailReceipts.length}, billingBatches=${detailBatches.size}`, OPTIONS) + + const merchantInventory = await context.requestOverseas('GET', + query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }), + undefined, {}, merchantToken) + add(context, '客户权限可读取自身退货库存', merchantInventory, success(merchantInventory)) + + const customerId = id(context.context?.merchantQualificationFixture?.tenantId) + const inventory = await overseas(context, context.overseasToken, 'GET', '平台按客户和退货单回查全部退货箱库存', + query('/api/platform/v1/last-leg/return-inventory/cartons', { + page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, returnNo, + })) + const inventoryRows = records(inventory.data) + const sourceBatches = new Set(inventoryRows.map((row) => id(row.sourceBatchNo))) + context.assert(MODULE, '两个实收箱逐一引用对应计费批次且数量守恒', inventory.response, + inventoryRows.length === 2 + && inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0) === 5 + && sourceBatches.size === 2 && sourceBatches.has(firstBatchNo) && sourceBatches.has(secondBatchNo), + `cartons=${inventoryRows.length}, quantity=${inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0)}`, OPTIONS) + + const summary = await overseas(context, context.overseasToken, 'GET', '平台回查退货SKU汇总库存', + query('/api/platform/v1/last-leg/return-inventory', { + page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, + })) + const summaries = new Map(records(summary.data).filter((row) => [skuA, skuB].includes(row.skuCode)) + .map((row) => [row.skuCode, row])) + context.assert(MODULE, '退货SKU汇总数量与两个实收箱一致', summary.response, + Number(summaries.get(skuA)?.totalQuantity) === 3 + && Number(summaries.get(skuB)?.totalQuantity) === 2, + `skuA=${summaries.get(skuA)?.totalQuantity ?? 'missing'}, skuB=${summaries.get(skuB)?.totalQuantity ?? 'missing'}`, OPTIONS) + + for (const [index, expected] of [{ cartonMark: cartonA, batchNo: firstBatchNo, quantity: 2 }, + { cartonMark: cartonB, batchNo: secondBatchNo, quantity: 3 }].entries()) { + const carton = await overseas(context, context.overseasToken, 'GET', `平台回查第${index + 1}个退货箱明细`, + `/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}`) + context.assert(MODULE, `第${index + 1}个退货箱来源费用批次和数量正确`, carton.response, + carton.data?.sourceBatchNo === expected.batchNo + && Number(carton.data?.totalQuantity) === expected.quantity + && Array.isArray(carton.data?.items) && carton.data.items.length > 0, + `batch=${carton.data?.sourceBatchNo || 'missing'}, quantity=${carton.data?.totalQuantity ?? 'missing'}`, OPTIONS) + const label = await overseas(context, context.overseasToken, 'GET', `平台生成第${index + 1}个退货箱标签`, + `/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}/label`) + context.assert(MODULE, `第${index + 1}个箱标签保留来源批次`, label.response, + label.data?.cartonMark === expected.cartonMark && label.data?.sourceBatchNo === expected.batchNo + && Number(label.data?.totalQuantity) === expected.quantity && nonBlank(label.data?.printableHtml), + trace(label.response), OPTIONS) + } + + const packing = await overseas(context, picker.token, 'GET', 'PDA查询完成退货单的可合箱库存', + query('/api/pda/v1/last-leg/return-inventory/packing/orders', { returnNo })) + const packingSkus = new Map((packing.data?.skus || []).map((row) => [row.skuCode, row])) + context.assert(MODULE, '退货合箱候选只含本轮完成单且库存准确', packing.response, + packing.data?.orders?.some?.((order) => order.returnNo === returnNo && order.status === 'COMPLETED') + && Number(packingSkus.get(skuA)?.availableQuantity) === 3 + && Number(packingSkus.get(skuB)?.availableQuantity) === 2, + trace(packing.response), OPTIONS) + + const mergeBody = { + returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }, { skuCode: skuB, quantity: 1 }], + length: 30, width: 20, height: 10, weight: 2, idempotencyKey: key(context, 'MERGE'), + } + const merged = await overseas(context, picker.token, 'POST', 'PDA将同客户退货库存合为新箱', + '/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody) + if (success(merged.response)) { + context.assert(MODULE, '退货合箱生成含两SKU和箱规的可打印新箱', merged.response, + nonBlank(merged.data?.cartonMark) && Number(merged.data?.totalQuantity) === 2 + && nonBlank(merged.data?.label?.sourceBatchNo) && merged.data?.label?.sourceBatchNo !== 'MERGED' + && merged.data?.label?.sourceReturnNo === returnNo + && Number(merged.data?.label?.items?.length) === 2 + && nonBlank(merged.data?.label?.printableHtml), + `cartonMark=${merged.data?.cartonMark || 'missing'}`, OPTIONS) + const consumedBatches = [] + for (const [mark, batch, before] of [[cartonA, firstBatchNo, 2], [cartonB, secondBatchNo, 3]]) { + const source = await overseas(context, merchantToken, 'GET', '合箱后回查来源箱实际扣减', + `${RETURN_INVENTORY}/cartons/${encodeURIComponent(mark)}`) + if (success(source.response) && Number(source.data?.totalQuantity) < before) consumedBatches.push(batch) + } + const actualBatches = String(merged.data?.label?.sourceBatchNo || '').split(',') + context.assert(MODULE, '合箱来源批次精确等于实际消耗来源且去重', merged.response, + consumedBatches.length > 0 && new Set(actualBatches).size === actualBatches.length + && JSON.stringify([...consumedBatches].sort()) === JSON.stringify([...actualBatches].sort()), + `expected=${consumedBatches.join(',')}, actual=${actualBatches.join(',')}`, OPTIONS) + const cartonPage = await overseas(context, merchantToken, 'GET', '客户箱号管理按实物箱分页', + query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 20, returnNo })) + context.assert(MODULE, '箱号分页包含本轮合箱且来源退货单保持真实业务号', cartonPage.response, + records(cartonPage.data).some(row => row.cartonMark === merged.data.cartonMark + && row.sourceReturnNo === returnNo && Number(row.totalQuantity) === 2), trace(cartonPage.response), OPTIONS) + const label = await overseas(context, merchantToken, 'GET', '客户读取本轮合箱条码标签', + `${RETURN_INVENTORY}/cartons/${encodeURIComponent(merged.data.cartonMark)}/label`) + context.assert(MODULE, '标签包含SVG条码和当前箱号', label.response, + label.data?.cartonMark === merged.data.cartonMark + && / context.requestOverseas('POST', + '/api/pda/v1/last-leg/return-inventory/cartons/merge', + { ...mergeBody, weight: 3 }, {}, picker.token)) + + const beforeInvalid = await overseas(context, merchantToken, 'GET', '冻结合箱异常检查前库存快照', + query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo })) + for (const [name, changes, expected] of [ + ['零箱长', { length: 0 }, validationFailure], + ['负重量', { weight: -1 }, validationFailure], + ['空合箱明细', { items: [] }, validationFailure], + ['零合箱数量', { items: [{ skuCode: skuA, quantity: 0 }] }, validationFailure], + ['超可用库存', { items: [{ skuCode: skuA, quantity: 999 }] }, conflict], + ['不存在来源单', { returnNos: [`MISSING-${suffix}`] }, notFound], + ]) { + await reject(`合箱拒绝${name}`, expected, () => context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', + { ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, {}, picker.token)) + } + await overseas(context, picker.token, 'POST', '异常检查后再重放原合箱请求', + '/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody) + const afterInvalid = await overseas(context, merchantToken, 'GET', '回查异常及重放后的全部箱库存', + query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo })) + context.assert(MODULE, '失败与幂等重放不改变箱数数量状态及来源', afterInvalid.response, + success(beforeInvalid.response) && success(afterInvalid.response) + && inventorySnapshot(beforeInvalid.data) === inventorySnapshot(afterInvalid.data) + && Number(afterInvalid.data?.total) === 3 + && records(afterInvalid.data).reduce((sum, row) => sum + Number(row.totalQuantity), 0) === 5, + trace(afterInvalid.response), OPTIONS) + const pagedMarks = [] + for (let page = 1; page <= 3; page += 1) { + const result = await overseas(context, merchantToken, 'GET', `实物箱逐页回查第${page}页`, + query(`${RETURN_INVENTORY}/cartons`, { page, size: 1, returnNo })) + context.assert(MODULE, `第${page}页保持总箱数且只有一个箱`, result.response, + Number(result.data?.total) === 3 && records(result.data).length === 1, trace(result.response), OPTIONS) + pagedMarks.push(...records(result.data).map(row => row.cartonMark)) + } + context.assert(MODULE, '跨页箱号无重复无遗漏', afterInvalid.response, + new Set(pagedMarks).size === 3 && JSON.stringify([...pagedMarks].sort()) + === JSON.stringify(records(afterInvalid.data).map(row => row.cartonMark).sort()), trace(afterInvalid.response), OPTIONS) + const merchantSummary = await overseas(context, merchantToken, 'GET', '客户合箱后回查SKU汇总', + query(`${RETURN_INVENTORY}/summary`, { page: 1, size: 20, skuCode: `RET-`, overseasWarehouseId: warehouse.id })) + const quantities = itemMap(records(merchantSummary.data)) + context.assert(MODULE, '合箱及异常后客户SKU仍为甲3乙2', merchantSummary.response, + Number(quantities.get(skuA)?.availableQuantity) === 3 && Number(quantities.get(skuB)?.availableQuantity) === 2, + trace(merchantSummary.response), OPTIONS) + } + + await runReturnBoundaries(context, { merchantToken, warehouse, picker, access }) + } finally { + await disablePicker(context, picker, 91) + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs new file mode 100644 index 000000000..6d447dff7 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { beijingDate, matchesProcurementNo } from './phase4-overseas-wallet-and-pallet-cost.mjs' + +const source = readFileSync(new URL('./phase4-overseas-wallet-and-pallet-cost.mjs', import.meta.url), 'utf8') +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const pda = readFileSync(new URL('./phase4-overseas-pda.mjs', import.meta.url), 'utf8') + +test('采购单号按北京时间日期执行精确合同', () => { + const instant = new Date('2026-09-05T16:30:00.000Z') + assert.equal(beijingDate(instant), '20260906') + assert.equal(matchesProcurementNo('QLYSXDKH202609060001', 'QLYSXDKH', instant), true) + assert.equal(matchesProcurementNo('QLYSXDKH202609050001', 'QLYSXDKH', instant), false) + assert.equal(matchesProcurementNo('QLYSXDKH20260906001', 'QLYSXDKH', instant), false) +}) + +test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释放', () => { + for (const token of [ + '/accounts/${encodeURIComponent(customerId)}/credit-limit', + 'creditExpiresOn', + '/accounts/${encodeURIComponent(customerId)}/adjustments', + "const CLIENT_FINANCE = '/api/client/finance/v1'", + "['VOUCHER', 'RECEIPT']", + "walletStatus === 'FROZEN'", + "walletStatus === 'SETTLED'", + "['FREEZE_PENDING', 'FROZEN']", + "['FREEZE', 'CONSUMPTION', 'UNFREEZE']", + 'item.sourceNo === businessNo', + "'RELEASED'", + "terminalStatus === 'COMPLETED'", + "status: 'CUSTOMER_CONFIRM_PENDING'", + "status: 'READY_FOR_OUTBOUND'", + "'CANCELLED'", + 'relabelPayload(context, warehouseId, source, suffix, 0.01)', + '/relabel-applications/inventory-candidates', + 'operationWarehouseId: id(warehouseId)', + 'sourceInventoryBalanceId:', + 'sourceInventoryPositionId:', + 'sourceInboundOrderId:', + 'destinationFbaWarehouseCode: source.destinationFbaWarehouseCode', + 'cartonMark:', + 'fbaCartonMark:', + "deliveryMethod: 'TRUCK'", + ]) assert.ok(source.includes(token), token) + assert.match(source, /actualBalance[\s\S]*frozenBalance[\s\S]*availableBalance[\s\S]*creditLimit[\s\S]*creditUsed[\s\S]*availableCredit/) + assert.match(source, /voucherFileId: id\(files\.find\(\(item\) => item\.category === 'VOUCHER'\)\?\.fileId\)/) + assert.doesNotMatch(source, /voucherFileId:\s*Number\(/) + assert.doesNotMatch(source, /customerOrderNo:[^\n]+overseasWarehouseId:/) + assert.doesNotMatch(source, /internal\/finance|jdbc|mysql|process\.env|https?:\/\//i) +}) + +test('托盘汇总和拆柜成本验证总数、分摊及跨区金额守恒', () => { + for (const token of [ + '/api/platform/v1/pallets/summary', + '/api/platform/v1/warehouse-admin/pallets/summary', + '/pallets/summary?page=1&size=20', + 'OVERSEAS_CONTAINER', + 'DESTUFF_PALLET', + 'expectedPalletCost', + 'sourceFeeId', + 'pollRequests=', + ]) assert.ok(source.includes(token), token) + assert.match(source, /Number\(d\.totalCount\) === Number\(d\.idleCount\) \+ Number\(d\.occupiedCount\)/) + assert.match(source, /allocated === money\(fact\.expectedPalletCost\)/) + assert.match(source, /delivered\.reduce[\s\S]*fact\.expectedPalletCost/) + assert.match(pda, /unitPrice: Number\(pallet\?\.unitPrice \|\| 0\)/) + assert.match(pda, /Number\(stored\.unitPrice \|\| pallet\.unitPrice \|\| 0\)/) + assert.match(pda, /completedContainerFacts\.push/) +}) + +test('模块进入默认全量并绑定真实商户和海外PDA生产链', () => { + assert.match(entry, /CONFIGURED_MODULES[^\n]*'phase4-overseas-wallet-and-pallet-cost'/) + assert.match(entry, /\['phase4-overseas-wallet-and-pallet-cost', \['phase4-overseas-business', 'phase4-overseas-pda-operations'\]\]/) + assert.match(source, /OPTIONS = \{ scenario: SCENARIO, deploymentBlocker: true \}/) + assert.match(source, /completedFacts\.length > 0/) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs new file mode 100644 index 000000000..bbb213f2f --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs @@ -0,0 +1,351 @@ +const MODULE = 'phase4-overseas-wallet-and-pallet-cost' +const SCENARIO = 'phase4-overseas-wallet-and-pallet-cost-http' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const PROCUREMENT = '/api/platform/v1/last-leg/overseas-procurements' +const RELABEL = '/api/platform/v1/last-leg/relabel-applications' +const FINANCE = '/api/platform/finance/v1' +const CLIENT_FINANCE = '/api/client/finance/v1' +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') + +const id = (value) => value == null ? '' : String(value) +const money = (value) => Number(Number(value || 0).toFixed(2)) +const success = (response) => response?.http === 200 && String(response?.body?.code) === '200' +const rows = (value) => Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] +const trace = (response) => `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +const 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}` +} + +export function beijingDate(now = new Date()) { + const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', + }).formatToParts(now).map((part) => [part.type, part.value])) + return `${parts.year}${parts.month}${parts.day}` +} + +export function matchesProcurementNo(orderNo, prefix, now = new Date()) { + return typeof prefix === 'string' && prefix.length > 0 + && new RegExp(`^${prefix}${beijingDate(now)}\\d{4}$`).test(String(orderNo || '')) +} + +async function poll(action, predicate, attempts = 60, intervalMs = 1_000) { + let result = null + for (let index = 0; index < attempts; index += 1) { + result = await action() + if (predicate(result)) return { result, attempts: index + 1 } + if (index + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + return { result, attempts } +} + +function assert(context, name, response, passed, evidence = '') { + context.assert(MODULE, name, response, passed, `${evidence}${evidence ? '; ' : ''}${trace(response)}`, OPTIONS) +} + +async function merchantLogin(context, fixture) { + const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '') + const auth = success(response) ? response.body.data : null + assert(context, '客户账号同时取得国内和海外短时Token', response, + Boolean(auth?.accessToken) && Boolean(auth?.overseasAccessToken), + `customerId=${fixture.tenantId}, domestic=${Boolean(auth?.accessToken)}, overseas=${Boolean(auth?.overseasAccessToken)}`) + return auth +} + +async function platformAccount(context, customerId) { + const response = await context.request('GET', query(`${FINANCE}/accounts`, { page: 1, pageSize: 20, customerId })) + const account = success(response) ? rows(response.body.data).find((item) => id(item.customerId) === id(customerId)) : null + return { response, account } +} + +async function prepareWallet(context, customerId) { + const before = await platformAccount(context, customerId) + assert(context, '平台客户账户返回余额和授信字段', before.response, + before.account && ['actualBalance', 'frozenBalance', 'availableBalance', 'creditLimit', 'creditUsed', 'availableCredit', 'accountVersion'] + .every((field) => Number.isFinite(Number(before.account[field]))), `customerId=${customerId}`) + if (!before.account) return null + + const creditLimit = 2.00 + const creditExpiresOn = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + const credit = await context.request('PUT', `${FINANCE}/accounts/${encodeURIComponent(customerId)}/credit-limit`, + { creditLimit, creditExpiresOn, remark: context.marker, + expectedAccountVersion: Number(before.account.accountVersion) }, context.token, + { 'Idempotency-Key': `${context.marker}-wallet-credit-limit`.slice(0, 128) }) + assert(context, '授信额度可通过正式接口初始化', credit, + success(credit) && money(credit.body.data?.creditLimitAfter) === creditLimit, + `creditLimit=${credit.body?.data?.creditLimitAfter ?? 'missing'}`) + if (!success(credit)) return null + + const credited = await platformAccount(context, customerId) + assert(context, '授信初始化后账户版本和可用授信已更新', credited.response, + credited.account && money(credited.account.creditLimit) === creditLimit + && Number(credited.account.accountVersion) > Number(before.account.accountVersion), + `version=${credited.account?.accountVersion ?? 'missing'}, availableCredit=${credited.account?.availableCredit ?? 'missing'}`) + if (!credited.account) return null + + const rechargeAmount = 10.00 + const recharge = await context.request('POST', `${FINANCE}/accounts/${encodeURIComponent(customerId)}/adjustments`, { + type: 'RECHARGE', amount: rechargeAmount, occurredDate: new Date().toISOString().slice(0, 10), + paymentMethod: 'BANK_TRANSFER', referenceNo: `${context.marker}-AUTO`, remark: context.marker, + expectedAccountVersion: Number(credited.account.accountVersion), + }, context.token, { 'Idempotency-Key': `${context.marker}-wallet-recharge`.slice(0, 128) }) + assert(context, '平台人工充值立即进入实际余额', recharge, + success(recharge) && money(recharge.body.data?.actualBalanceAfter) === money(Number(before.account.actualBalance) + rechargeAmount), + `before=${before.account.actualBalance}, after=${recharge.body?.data?.actualBalanceAfter ?? 'missing'}`) + return success(recharge) ? { before: before.account, funded: recharge.body.data, rechargeAmount, creditLimit } : null +} + +async function procurementFlow(context, fixture, overseasToken) { + const merchantPath = '/api/merchant/v1/last-leg/overseas-procurements' + const payload = { + customerName: fixture.tenantName, purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 钱包采购`, + productSpecification: 'full acceptance wallet', productUrl: null, purchaseQuantity: 2, + budgetUnitPrice: 1, currency: 'USD', receivingAddress: 'Los Angeles', + contactName: '全量验收联系人', contactPhone: '13800000002', remark: context.marker, + idempotencyKey: `${context.marker}-wallet-procurement`.slice(0, 255), + } + const created = await context.requestOverseas('POST', merchantPath, payload, {}, overseasToken) + const detail = created?.body?.data + const orderNo = detail?.summary?.orderNo + assert(context, '客户创建钱包采购单', created, + success(created) && id(detail?.summary?.customerId) === id(fixture.tenantId) && detail?.summary?.status === 'PENDING_REVIEW', + `orderNo=${orderNo || 'missing'}, status=${detail?.summary?.status || 'missing'}`) + if (!success(created) || !orderNo) return null + assert(context, '采购单号按客户拼音首字母加北京时间日期和四位序号生成', created, + fixture.expectedProcurementPrefix ? matchesProcurementNo(orderNo, fixture.expectedProcurementPrefix) : /^[A-Z0-9]{1,8}\d{8}\d{4}$/.test(orderNo), + `orderNo=${orderNo}, expectedPrefix=${fixture.expectedProcurementPrefix || 'custom-customer'}`) + + const customerId = id(fixture.tenantId) + const approved = await context.requestOverseas('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/review`, { customerId }), { + version: Number(detail.summary.version), decision: 'APPROVE', + idempotencyKey: `${context.marker}-wallet-procurement-review`.slice(0, 255), remark: context.marker, + }) + assert(context, '平台审核通过钱包采购单', approved, + success(approved) && approved.body.data?.summary?.status === 'APPROVED', + `status=${approved.body?.data?.summary?.status || 'missing'}`) + if (!success(approved)) return null + + const files = [] + for (const category of ['VOUCHER', 'RECEIPT']) { + const uploaded = await context.requestOverseasMultipart('POST', + query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }), + { content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` }) + assert(context, `采购单上传${category}文件`, uploaded, + success(uploaded) && id(uploaded.body.data?.fileId) !== '' && uploaded.body.data?.category === category, + `fileId=${id(uploaded.body?.data?.fileId) || 'missing'}`) + if (success(uploaded)) files.push(uploaded.body.data) + } + if (files.length !== 2) return null + + const fee = await context.requestOverseas('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/fees`, { customerId }), { + feeItemCode: 'PROCUREMENT_SERVICE', feeItemName: '采购服务费', amount: 0.01, + voucherFileId: id(files.find((item) => item.category === 'VOUCHER')?.fileId), remark: context.marker, + }) + const feeNo = fee?.body?.data?.feeNo + assert(context, '采购费用创建并开始钱包冻结', fee, + success(fee) && Boolean(feeNo) && ['FREEZE_PENDING', 'FROZEN'].includes(fee.body.data?.walletStatus), + `feeNo=${feeNo || 'missing'}, walletStatus=${fee.body?.data?.walletStatus || 'missing'}`) + if (!success(fee) || !feeNo) return null + + const detailPath = query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}`, { customerId }) + const frozen = await poll(async () => context.requestOverseas('GET', detailPath), + (response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'FROZEN') + const frozenFee = frozen.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo) + assert(context, '采购费用跨区冻结到账', frozen.result, + frozenFee?.walletStatus === 'FROZEN', `feeNo=${feeNo}, pollRequests=${frozen.attempts}, walletStatus=${frozenFee?.walletStatus || 'missing'}`) + if (frozenFee?.walletStatus !== 'FROZEN') return null + + const current = frozen.result.body.data.summary + const settledOrder = await context.requestOverseas('PATCH', detailPath, { + version: Number(current.version), inboundQuantity: Number(current.purchaseQuantity), + }) + assert(context, '采购全量入库推进待出库并触发结算', settledOrder, + success(settledOrder) && settledOrder.body.data?.summary?.status === 'PENDING_OUTBOUND', + `status=${settledOrder.body?.data?.summary?.status || 'missing'}`) + const settled = await poll(async () => context.requestOverseas('GET', detailPath), + (response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'SETTLED') + const settledFee = settled.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo) + assert(context, '采购费用跨区结算到账', settled.result, + settledFee?.walletStatus === 'SETTLED', `feeNo=${feeNo}, pollRequests=${settled.attempts}, walletStatus=${settledFee?.walletStatus || 'missing'}`) + return { orderNo, feeNo, freezePollRequests: frozen.attempts, settlePollRequests: settled.attempts } +} + +function relabelPayload(context, warehouseId, source, suffix, chargeAmount) { + const value = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() + return { + customerOrderNo: `${context.marker}-${suffix}`.slice(0, 128), operationWarehouseId: id(warehouseId), + labelDeadline: new Date(Date.now() + 86_400_000).toISOString().slice(0, 19), + contactName: '全量验收联系人', contactPhone: '+1-213-555-0199', chargeAmount, + deliveryMethod: 'TRUCK', printShippingLabel: false, + remark: context.marker, idempotencyKey: `${context.marker}-${suffix}`.slice(0, 255), + items: [{ sourceInventoryBalanceId: id(source.inventoryBalanceId), + sourceInventoryPositionId: id(source.position.positionId), sourceInboundOrderId: id(source.inboundOrderId), + skuCode: source.skuCode || `OLD-${value}-${suffix}`.slice(0, 128), + productNameZh: source.productNameZh || `${context.marker} 换标商品`, + totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: `SHIP-${value}-${suffix}`.slice(0, 128), + newSkuCode: `NEW-${value}-${suffix}`.slice(0, 128), labelRequirement: context.marker, + cartonMark: `CM-${value}-${suffix}`.slice(0, 255), + fbaCartonMark: `FBA-${value}-${suffix}`.slice(0, 255), + destinationFbaWarehouseCode: source.destinationFbaWarehouseCode }], + } +} + +async function relabelFlow(context, overseasToken, warehouseId, suffix, terminalStatus) { + const candidatesResponse = await context.requestOverseas('GET', query( + '/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { operationWarehouseId: warehouseId }), + undefined, {}, overseasToken) + const candidates = success(candidatesResponse) && Array.isArray(candidatesResponse.body?.data) + ? candidatesResponse.body.data : [] + const source = candidates.find((candidate) => id(candidate.inboundOrderId) + && id(candidate.inventoryBalanceId) + && id(candidate.destinationFbaWarehouseCode) + && candidate.positions?.some((position) => id(position.positionId) && Number(position.availablePackages) > 0)) + if (source) source.position = source.positions.find((position) => id(position.positionId) + && Number(position.availablePackages) > 0) + assert(context, `${suffix}换标读取当前客户权威库存候选`, candidatesResponse, + Boolean(source?.position), `warehouseId=${warehouseId}, candidates=${candidates.length}`) + if (!source?.position) return null + const payload = relabelPayload(context, warehouseId, source, suffix, 0.01) + const created = await context.requestOverseas('POST', '/api/merchant/v1/last-leg/relabel-applications', payload, {}, overseasToken) + const relabelNo = created?.body?.data?.application?.relabelNo + assert(context, `${suffix}换标单创建并携带收费金额`, created, + success(created) && Boolean(relabelNo) && money(created.body.data?.application?.chargeAmount) === 0.01, + `relabelNo=${relabelNo || 'missing'}, charge=${created.body?.data?.application?.chargeAmount ?? 'missing'}`) + if (!success(created) || !relabelNo) return null + + const detailPath = `${RELABEL}/${encodeURIComponent(relabelNo)}` + const frozen = await poll(async () => context.requestOverseas('GET', detailPath), + (response) => success(response) && response.body.data?.application?.walletStatus === 'FROZEN') + assert(context, `${suffix}换标费用跨区冻结到账`, frozen.result, + frozen.result?.body?.data?.application?.walletStatus === 'FROZEN', + `relabelNo=${relabelNo}, pollRequests=${frozen.attempts}, walletStatus=${frozen.result?.body?.data?.application?.walletStatus || 'missing'}`) + if (frozen.result?.body?.data?.application?.walletStatus !== 'FROZEN') return null + + if (terminalStatus === 'COMPLETED') { + const processing = await context.requestOverseas('PUT', `${detailPath}/status`, { status: 'PROCESSING', remark: context.marker }) + assert(context, '换标单冻结后才允许进入处理中', processing, + success(processing) && processing.body.data?.application?.status === 'PROCESSING') + const confirmPending = await context.requestOverseas('PUT', `${detailPath}/status`, + { status: 'CUSTOMER_CONFIRM_PENDING', remark: context.marker }) + assert(context, '换标处理完成后进入客户确认', confirmPending, + success(confirmPending) && confirmPending.body.data?.application?.status === 'CUSTOMER_CONFIRM_PENDING') + const readyForOutbound = await context.requestOverseas('PUT', `${detailPath}/status`, + { status: 'READY_FOR_OUTBOUND', remark: context.marker }) + assert(context, '换标客户确认后进入待出库', readyForOutbound, + success(readyForOutbound) && readyForOutbound.body.data?.application?.status === 'READY_FOR_OUTBOUND') + } + const terminal = await context.requestOverseas('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker }) + assert(context, `${suffix}换标单进入${terminalStatus}`, terminal, + success(terminal) && terminal.body.data?.application?.status === terminalStatus) + const walletTerminal = terminalStatus === 'COMPLETED' ? 'SETTLED' : 'RELEASED' + const wallet = await poll(async () => context.requestOverseas('GET', detailPath), + (response) => success(response) && response.body.data?.application?.walletStatus === walletTerminal) + assert(context, `${suffix}换标费用跨区${walletTerminal}`, wallet.result, + wallet.result?.body?.data?.application?.walletStatus === walletTerminal, + `relabelNo=${relabelNo}, pollRequests=${wallet.attempts}, walletStatus=${wallet.result?.body?.data?.application?.walletStatus || 'missing'}`) + return { relabelNo, terminalStatus, walletStatus: walletTerminal, freezePollRequests: frozen.attempts, terminalPollRequests: wallet.attempts } +} + +async function verifyClientWallet(context, token, fixture, wallet, procurement, relabels) { + const account = await context.request('GET', `${CLIENT_FINANCE}/account`, undefined, token) + const expectedActual = money(Number(wallet.before.actualBalance) + wallet.rechargeAmount - 0.02) + assert(context, '客户工作台余额与冻结结算结果一致', account, + success(account) && money(account.body.data?.actualBalance) === expectedActual + && money(account.body.data?.frozenBalance) === money(wallet.before.frozenBalance) + && money(account.body.data?.creditUsed) === money(wallet.before.creditUsed), + `expectedActual=${expectedActual}, actual=${account.body?.data?.actualBalance ?? 'missing'}, frozen=${account.body?.data?.frozenBalance ?? 'missing'}, creditUsed=${account.body?.data?.creditUsed ?? 'missing'}`) + const ledger = await context.request('GET', `${CLIENT_FINANCE}/account/ledger?page=1&size=100`, undefined, token) + const entries = rows(ledger?.body?.data) + const businessNos = [procurement.feeNo, ...relabels.map((item) => item.relabelNo)] + assert(context, '客户账本包含充值、冻结、结算和释放明细', ledger, + success(ledger) && entries.some((item) => item.changeType === 'RECHARGE') + && businessNos.every((businessNo) => entries.some((item) => item.sourceNo === businessNo)) + && ['FREEZE', 'CONSUMPTION', 'UNFREEZE'].every((type) => entries.some((item) => item.changeType === type)), + `entries=${entries.length}, businessNos=${businessNos.join(',')}`) + return account?.body?.data +} + +function summaryValid(value, fields) { + return fields.every((field) => Number.isInteger(Number(value?.[field])) && Number(value[field]) >= 0) +} + +async function verifyPalletSummaries(context, warehouseId) { + const domestic = await context.request('GET', '/api/platform/v1/pallets/summary?tenantId=0&page=1&size=20') + const d = domestic?.body?.data + assert(context, '国内托盘汇总含闲置和占用数量', domestic, + success(domestic) && summaryValid(d, ['totalCount', 'idleCount', 'occupiedCount']) + && Number(d.totalCount) === Number(d.idleCount) + Number(d.occupiedCount), + `total=${d?.totalCount}, idle=${d?.idleCount}, occupied=${d?.occupiedCount}`) + const overseas = await context.requestOverseas('GET', '/api/platform/v1/warehouse-admin/pallets/summary?page=1&size=20') + const o = overseas?.body?.data + assert(context, '海外托盘汇总含闲置、占用和报废数量', overseas, + success(overseas) && summaryValid(o, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount']) + && Number(o.totalCount) === Number(o.idleCount) + Number(o.occupiedCount) + Number(o.scrappedCount), + `total=${o?.totalCount}, idle=${o?.idleCount}, occupied=${o?.occupiedCount}, scrapped=${o?.scrappedCount}`) + const warehouse = await context.requestOverseas('GET', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/summary?page=1&size=20`) + assert(context, '海外仓级托盘汇总可用', warehouse, + success(warehouse) && summaryValid(warehouse.body.data, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount'])) +} + +async function verifyDestuffCosts(context, completedFacts) { + const evidence = [] + for (const fact of completedFacts) { + const businessNo = `DESTUFF-${fact.containerBookingId}` + const lastleg = await poll(async () => context.requestOverseas('GET', query('/api/platform/v1/last-leg/finance-fee-sources', { + businessType: 'OVERSEAS_CONTAINER', businessNo, + })), (response) => success(response) && rows(response.body.data).length > 0, 75) + const sources = success(lastleg.result) ? rows(lastleg.result.body.data) : [] + const allocated = money(sources.reduce((sum, item) => sum + Number(item.amount || 0), 0)) + assert(context, '拆柜打托费用按客户体积分摊且金额守恒', lastleg.result, + sources.length > 0 && sources.every((item) => item.sourceType === 'OVERSEAS_CONTAINER_FEE' + && item.feeItemCode === 'DESTUFF_PALLET' && Number(item.amount) > 0) + && allocated === money(fact.expectedPalletCost), + `containerBookingId=${fact.containerBookingId}, sources=${sources.length}, allocated=${allocated}, expected=${money(fact.expectedPalletCost)}, pollRequests=${lastleg.attempts}`) + const finance = await poll(async () => context.request('GET', query(`${FINANCE}/fee-sources`, { + page: 1, pageSize: 200, keyword: businessNo, + })), (response) => success(response) && sources.length > 0 + && sources.every((source) => rows(response.body.data).some((item) => id(item.sourceFeeId) === id(source.sourceFeeId))), 60) + const delivered = success(finance.result) ? rows(finance.result.body.data) + .filter((item) => sources.some((source) => id(source.sourceFeeId) === id(item.sourceFeeId))) : [] + assert(context, '拆柜打托费用跨区进入Finance且金额守恒', finance.result, + delivered.length === sources.length + && money(delivered.reduce((sum, item) => sum + Number(item.amount || 0), 0)) === money(fact.expectedPalletCost), + `containerBookingId=${fact.containerBookingId}, delivered=${delivered.length}, expected=${sources.length}, pollRequests=${finance.attempts}`) + evidence.push({ containerBookingId: fact.containerBookingId, expectedPalletCost: money(fact.expectedPalletCost), + allocations: sources.length, lastlegPollRequests: lastleg.attempts, financePollRequests: finance.attempts }) + } + return evidence +} + +export async function run(context) { + const start = context.steps.length + const fixture = context.context?.merchantQualificationFixture + const completedFacts = context.createdResources?.phase4OverseasPda?.completedContainerFacts || [] + const warehouseId = context.createdResources?.phase4OverseasPda?.overseasWarehouseId + || context.context?.phase4OverseasWarehouse?.warehouseId + context.assert(MODULE, '钱包闭环必须使用本轮已审核客户和PDA实收柜', null, + Boolean(fixture?.tenantId && fixture?.username && fixture?.password && warehouseId && completedFacts.length > 0), + `fixture=${Boolean(fixture)}, warehouseId=${warehouseId || 'missing'}, completedContainers=${completedFacts.length}`, OPTIONS) + if (!fixture?.tenantId || !warehouseId || completedFacts.length === 0) return + + const auth = await merchantLogin(context, fixture) + const wallet = auth ? await prepareWallet(context, id(fixture.tenantId)) : null + const procurement = wallet ? await procurementFlow(context, fixture, auth.overseasAccessToken) : null + const settledRelabel = procurement ? await relabelFlow(context, auth.overseasAccessToken, warehouseId, 'SETTLE', 'COMPLETED') : null + const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.overseasAccessToken, warehouseId, 'RELEASE', 'CANCELLED') : null + const relabels = [settledRelabel, releasedRelabel].filter(Boolean) + const finalAccount = procurement && relabels.length === 2 + ? await verifyClientWallet(context, auth.accessToken, fixture, wallet, procurement, relabels) : null + await verifyPalletSummaries(context, warehouseId) + const destuffCosts = await verifyDestuffCosts(context, completedFacts) + context.createdResources.phase4OverseasWalletAndPalletCost = { + marker: context.marker, customerId: id(fixture.tenantId), procurement, relabels, + finalAccount, completedContainerCosts: destuffCosts, + } + const steps = context.steps.slice(start) + context.scenarios.push({ name: SCENARIO, module: MODULE, success: steps.every((step) => step.passed), + deploymentBlocked: steps.some((step) => step.deploymentBlocker && !step.passed), + passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length }) +} 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..600eb6c4c --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -0,0 +1,156 @@ +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', warehouseType: 'OVERSEAS', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) { + const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null + const context = { + context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps, + async request(method, requestPath, body) { + calls.push({ channel: 'domestic', method, path: requestPath, body }) + if (requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants)) + if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) + if (method === 'GET' && requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' } }) + if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') + if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) + throw new Error(`Unexpected domestic path: ${requestPath}`) + }, + async requestOverseas(method, requestPath, body) { + calls.push({ channel: 'overseas', method, path: requestPath, body }) + if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') } + if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() } + if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() } + if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') } + if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { + if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409') + createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return voidResponse() + } + 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.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, 3) + assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.startsWith('/api/platform/v1/warehouse-admin?'))) + assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin/20001?'))) + assert.equal(context.calls.some((call) => call.channel === 'overseas' && call.path === '/api/platform/v1/warehouse-admin'), false) + 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.equal(writes.some((call) => call.path.includes('/relabel-applications')), false) + assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/'))) + 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?', '/locations?', '/pallets?']) { + assert.ok(context.calls.some((call) => call.channel === 'overseas' && call.path.includes(pathToken)), pathToken) + } + assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin?'))) + assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin/20001?'))) + for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/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' }) + assert.deepEqual(context.createdResources.phase4OverseasWarehouse, { + marker: 'ACC-PHASE4-TEST', customerId: '10001', warehouseId: '20001', palletId: '71001', terminalStatus: 'SCRAPPED', + locationId: '72002', locationTerminalStatus: 'DELETED', + }) +}) + +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.doesNotMatch(source, /创建本轮换标单|verifyRelabelLifecycle/) + 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..bd11e6a32 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -0,0 +1,362 @@ +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 domesticMutation(context, method, name, path, body) { + const response = await context.request(method, path, body) + const passed = response?.http === 200 && String(response?.body?.code) === '200' + context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) + return { response, data: passed ? response.body?.data : null } +} +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) + context.context.phase4OverseasWarehouse = { + customerId, warehouseId, + palletId: context.createdResources.phase4OverseasWarehouse.palletId, + } + } 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 createWarehouseFixture(context, customerId) { + const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() + const payload = { + tenantId: 0, warehouseCode: `P4W-${suffix}`.slice(0, 50), warehouseName: `${context.marker} 海外仓`, + warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', + contactName: 'Phase4 QA', contactPhone: '12135550188', 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 domesticMutation(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 domesticGet(context, '查询本轮公共海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}`, { tenantId: 0 })) + context.assert(MODULE, '本轮公共海外仓类型和状态正确', detail.response, + id(detail.data?.warehouse?.id || detail.data?.id) === warehouseId + && (detail.data?.warehouse?.warehouseType || detail.data?.warehouseType) === 'OVERSEAS' + && (detail.data?.warehouse?.status || detail.data?.status) === 'ACTIVE', + `warehouseId=${warehouseId}, warehouseType=${detail.data?.warehouse?.warehouseType || detail.data?.warehouseType || '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.request('PATCH', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/status`, { tenantId: 0, 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 result = await domesticGet(context, '查询可用公共海外仓', query('/api/platform/v1/warehouse-admin', { + tenantId: 0, warehouseType: 'OVERSEAS', page: 1, size: 100, status: 'ACTIVE', + })) + assertPage(context, '可用公共海外仓', result) + const warehouse = records(result.data).find((item) => longId(item?.id) + && item?.status === 'ACTIVE' && item?.warehouseType === 'OVERSEAS') + 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 domesticGet(context, '查询海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(warehouse.id))}`, { tenantId: 0 })) + 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/phase4-tail-order-controls-contract.test.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs new file mode 100644 index 000000000..99d667038 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' + +const source = await readFile(fileURLToPath(new URL('./phase4-tail-order-controls.mjs', import.meta.url)), 'utf8') + +test('tail order controls execute after current producer and before overseas PDA', () => { + const modules = normalizeModuleOrder(['phase4-overseas-pda']) + const controls = modules.indexOf('phase4-tail-order-controls') + assert.ok(controls > modules.indexOf('phase4-carrier-pickup-ready')) + assert.ok(controls < modules.indexOf('phase4-overseas-pda')) +}) + +test('module covers the public tail-order write surface before PDA receiving', () => { + for (const path of ['/receiving-warehouse', '/destination', '/pallet-merge-required', '/fees', '/export', + '/hold', '/manual-nodes', '/api/platform/v1/last-leg/inbound-plans', '/manual']) { + assert.ok(source.includes(path), `missing ${path}`) + } + for (const evidence of [ + '接柜仓变更同键同载荷重放', '接柜仓变更同键异载荷返回A0409', '接柜仓变更旧版本返回A0409', + '整柜恢复原公共接柜仓', '单票变更FBA目的仓', '批量变更FBA目的仓', + '单票开启PDA合托提示', '批量开启PDA合托分组', '批量取消PDA合托分组', + '尾程费用详情可追溯', '导出本轮尾程订单CSV', + '暂扣命令同键同载荷重放', '暂扣同键异载荷返回A0409', '暂扣尾程订单不能生成人工计划', + '解除本轮单票尾程暂扣', '保存尾程八个人工节点快照', '人工节点同键同载荷重放', + '人工节点同键异载荷返回A0409', '人工节点读取固定为MANUAL且不回退柜级节点', + '预览整柜待生成入仓计划清单', '按柜确认并自动生成入仓计划与实体托盘', + '按柜确认同键同快照幂等重放', '确认后预览待生成票数和托数归零', + ]) assert.ok(source.includes(evidence), `missing ${evidence}`) + assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/) + assert.match(source, /creationMode === 'AUTO'/) + assert.match(source, /requestOverseasBinary\('GET'/) + assert.match(source, /palletMergeBatchId/) + assert.match(source, /feeItemCode/) + assert.match(source, /feeItemName/) + assert.match(source, /direction: 'RECEIVABLE'/) + assert.match(source, /allIds\.length === 1 \|\| nonBlank\(groupRead\.data\?\.order\?\.palletMergeBatchId\)/) + assert.match(source, /businessDate:/) + assert.match(source, /originalCurrency === 'USD'/) + assert.match(source, /currency === 'CNY'/) + assert.match(source, /reviewStatus === 'PENDING'/) + assert.match(source, /'Idempotency-Key': `\$\{context\.marker\}-tail-fee`/) + assert.match(source, /ensureCurrentUsdRate/) + assert.match(source, /monthFrom=\$\{month\}&monthTo=\$\{month\}/) + assert.match(source, /existing\?\.status === 'ACTIVE'/) + assert.match(source, /businessDate: `\$\{feeMonth\}-01`/) + assert.doesNotMatch(source, /supplierDescription/) +}) + +test('receiving warehouse verification reuses public master data and restores the original assignment', () => { + assert.match(source, /tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE/) + assert.match(source, /id\(row\.id\) !== originalWarehouseId/) + assert.match(source, /overseasWarehouseId: originalWarehouseId, expectedVersion: Number\(changed\.data\.assignmentVersion\)/) + assert.doesNotMatch(source, /request\('POST', '\/api\/platform\/v1\/warehouse-admin'/) +}) + +test('module is marker-owned and never borrows historical tail orders', () => { + assert.match(source, /matrix\?\.marker !== context\.marker/) + assert.match(source, /containerNo: producer\.containerNo/) + assert.match(source, /customerId\) === id\(producer\.tenantId\)/) + assert.match(source, /禁止借历史尾程订单/) + assert.doesNotMatch(source, /records\([^)]*\)\[0\]/) +}) + +test('container conversion preserves long identifiers and generated pallets for PDA consumption', () => { + assert.match(source, /orderId: id\(order\.id\)/) + assert.doesNotMatch(source, /plannedWarehouse(?:Id|Code|Name):/) + assert.match(source, /!order\.overseasWarehouseId/) + assert.match(source, /container-preview/) + assert.match(source, /snapshotVersion/) + assert.match(source, /snapshotHash/) + assert.match(source, /convert-container/) + assert.match(source, /自动托盘进入托盘列表并保留FBA目的仓/) + assert.match(source, /planNos: generated\.map/) + assert.match(source, /generatedPlans: generated/) + assert.match(source, /AUTO_CONTAINER_PLANS_READY/) + assert.match(source, /remain as marker-owned prerequisites consumed by overseas PDA receiving/) +}) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs new file mode 100644 index 000000000..e19e10159 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -0,0 +1,424 @@ +const MODULE = 'phase4-tail-order-controls' +const SCENARIO = 'phase4-tail-order-controls-lifecycle' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const ORDERS = '/api/platform/v1/last-leg/orders' +const PLANS = '/api/platform/v1/last-leg/inbound-plans' +const NODE_CODES = Object.freeze([ + 'DOMESTIC_CUSTOMS_COMPLETED', 'ESTIMATED_DEPARTURE', 'ACTUAL_DEPARTURE', 'ESTIMATED_ARRIVAL', + 'OVERSEAS_CUSTOMS_COMPLETED', 'ACTUAL_ARRIVAL_WAITING_UNLOAD', 'PICKUP_APPOINTED', 'UNLOADED', +]) + +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) } +function success(response) { return response?.http === 200 && codeOf(response) === '200' } +function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') } +function records(value) { return Array.isArray(value?.records) ? value.records : [] } +function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 } +function localIso(value) { return value.toISOString().slice(0, 19) } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, values) { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(values || {})) { + if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value)) + } + return params.size ? `${path}?${params}` : path +} +async function api(context, method, name, path, body, headers = {}) { + const response = await context.requestOverseas(method, path, body, headers, context.overseasToken) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} +async function domesticApi(context, name, path) { + const response = await context.request('GET', path) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} +function blocker(context, name, evidence, response = null) { + context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS) + context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true }) +} +function targetProducer(context) { + const matrix = context.createdResources?.phase4InboundProducerFixtures + if (matrix?.marker !== context.marker) return null + return matrix.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') + || matrix.byDestinationType?.FBA?.[0] + || matrix.byDestinationType?.OVERSEAS?.[0] + || null +} +async function markerOrders(context, producer) { + let last = null + for (let attempt = 1; attempt <= 60; attempt += 1) { + last = await api(context, 'GET', `等待本轮尾程订单落地(${attempt}/60)`, + query(ORDERS, { page: 1, size: 200, containerNo: producer.containerNo })) + const matched = records(last.data).filter((row) => id(row?.containerNo) === id(producer.containerNo) + && id(row?.customerId) === id(producer.tenantId) && id(row?.id)) + if (matched.length > 0) return { rows: matched, response: last.response } + if (attempt < 60) await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + return { rows: [], response: last?.response || null } +} +async function detail(context, row, name = '读取尾程订单即时详情') { + return api(context, 'GET', name, + query(`${ORDERS}/${encodeURIComponent(id(row.id))}`, { customerId: id(row.customerId) })) +} +async function ensureCurrentUsdRate(context) { + const month = new Date().toISOString().slice(0, 7) + const path = `/api/platform/v1/last-leg/master-data/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}` + const listed = await api(context, 'GET', '查询尾程费用业务月汇率', path) + const existing = records(listed.data).find((row) => row?.month === month) + if (existing?.status === 'ACTIVE' && Number(existing?.usdToCnyAverage) > 0) return month + if (existing) { + blocker(context, '尾程费用业务月汇率', `业务月${month}汇率存在但不是ACTIVE,拒绝覆盖既有主数据`, listed.response) + return null + } + const created = await api(context, 'POST', '补齐尾程费用业务月汇率', + '/api/platform/v1/last-leg/master-data/exchange-rates', { + month, usdToCnyAverage: 7.1, remark: `${context.marker} acceptance rate`, + }) + context.assert(MODULE, '尾程费用业务月汇率可用于USD兑CNY', created.response, + created.data?.month === month && created.data?.status === 'ACTIVE' + && Number(created.data?.usdToCnyAverage) > 0, + `month=${month}, status=${created.data?.status || 'missing'}`, OPTIONS) + return created.data?.status === 'ACTIVE' ? month : null +} +async function verifyReceivingWarehouseChange(context, producer, details) { + const bookingId = id(producer.containerBookingId) + if (!bookingId) { + blocker(context, '取得本轮来源柜预订ID', `containerNo=${producer.containerNo}`) + return false + } + const progressPath = `${ORDERS}/container-summaries/${encodeURIComponent(bookingId)}` + const current = await api(context, 'GET', '读取本轮柜级入仓计划进度', progressPath) + const summary = current.data?.summary + const originalWarehouseId = id(summary?.overseasWarehouseId) + if (!success(current.response) || !originalWarehouseId || summary?.canChangeReceivingWarehouse !== true) { + blocker(context, '柜级接柜仓可变更前置', + `bookingId=${bookingId}, warehouseId=${originalWarehouseId || 'missing'}, canChange=${summary?.canChangeReceivingWarehouse}`, current.response) + return false + } + const warehouses = await domesticApi(context, '查询可用公共海外接柜仓', + '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE') + const alternate = records(warehouses.data).find((row) => id(row?.id) && id(row.id) !== originalWarehouseId + && row?.warehouseType === 'OVERSEAS' && row?.status === 'ACTIVE') + if (!alternate) { + blocker(context, '柜级接柜仓真实切换候选', + `originalWarehouseId=${originalWarehouseId}; 至少需要2个ACTIVE公共OVERSEAS接柜仓`, warehouses.response) + return false + } + const changePath = `${progressPath}/receiving-warehouse` + const changeKey = `${context.marker}-receiving-warehouse-change`.slice(0, 128) + const changeBody = { overseasWarehouseId: id(alternate.id), expectedVersion: Number(summary.assignmentVersion), remark: context.marker } + const changed = await api(context, 'PUT', '整柜切换到另一公共接柜仓', changePath, changeBody, + { 'Idempotency-Key': changeKey }) + context.assert(MODULE, '接柜仓变更原子推进版本并保持柜内订单数', changed.response, + id(changed.data?.overseasWarehouseId) === id(alternate.id) + && Number(changed.data?.assignmentVersion) === Number(summary.assignmentVersion) + 1 + && Number(changed.data?.totalOrderCount) === details.length, + trace(changed.response), OPTIONS) + if (!success(changed.response)) return false + const replay = await api(context, 'PUT', '接柜仓变更同键同载荷重放', changePath, changeBody, + { 'Idempotency-Key': changeKey }) + context.assert(MODULE, '接柜仓变更重放版本零漂移', replay.response, + Number(replay.data?.assignmentVersion) === Number(changed.data?.assignmentVersion) + && id(replay.data?.overseasWarehouseId) === id(alternate.id), trace(replay.response), OPTIONS) + const hashConflict = await context.requestOverseas('PUT', changePath, { ...changeBody, remark: `${context.marker}-DIFFERENT` }, + { 'Idempotency-Key': changeKey }, context.overseasToken) + context.addStep(MODULE, '接柜仓变更同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) + const stale = await context.requestOverseas('PUT', changePath, + { overseasWarehouseId: originalWarehouseId, expectedVersion: Number(summary.assignmentVersion), remark: context.marker }, + { 'Idempotency-Key': `${context.marker}-receiving-warehouse-stale`.slice(0, 128) }, context.overseasToken) + context.addStep(MODULE, '接柜仓变更旧版本返回A0409', stale, conflict(stale), trace(stale), OPTIONS) + const restored = await api(context, 'PUT', '整柜恢复原公共接柜仓', changePath, + { overseasWarehouseId: originalWarehouseId, expectedVersion: Number(changed.data.assignmentVersion), remark: `${context.marker}-restore` }, + { 'Idempotency-Key': `${context.marker}-receiving-warehouse-restore`.slice(0, 128) }) + context.assert(MODULE, '接柜仓恢复后供后续PDA消费', restored.response, + id(restored.data?.overseasWarehouseId) === originalWarehouseId + && Number(restored.data?.assignmentVersion) === Number(changed.data.assignmentVersion) + 1, + trace(restored.response), OPTIONS) + return success(restored.response) +} + +async function verifyDestinationMergeFeeAndExport(context, details, producer) { + const fbaResult = await domesticApi(context, '查询公共ACTIVE FBA目的仓', + '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&warehouseType=FBA&status=ACTIVE') + const fba = records(fbaResult.data).find((row) => id(row?.id) && nonBlank(row?.warehouseCode) + && nonBlank(row?.warehouseName) && row?.warehouseType === 'FBA' && row?.status === 'ACTIVE') + if (!fba) { + blocker(context, '尾程目的仓变更权威主档', '国内Warehouse未返回公共ACTIVE FBA仓', fbaResult.response) + return false + } + const destination = { warehouseId: id(fba.id), warehouseCode: fba.warehouseCode, + warehouseName: fba.warehouseName, remark: context.marker } + const representative = details[0] + const one = await api(context, 'PUT', '单票变更FBA目的仓', + query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/destination`, { customerId: id(representative.customerId) }), destination) + context.assert(MODULE, '单票目的仓变更返回1', one.response, Number(one.data) === 1, trace(one.response), OPTIONS) + if (!success(one.response)) return false + const batchOrders = details.slice(1).length > 0 ? details.slice(1) : [representative] + const batch = await api(context, 'PUT', '批量变更FBA目的仓', + query(`${ORDERS}/destination`, { customerId: id(representative.customerId) }), + { ...destination, orderIds: batchOrders.map((row) => id(row.id)) }) + context.assert(MODULE, '批量目的仓变更数量精确', batch.response, + Number(batch.data) === batchOrders.length, trace(batch.response), OPTIONS) + if (!success(batch.response)) return false + + const mergePath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/pallet-merge-required`, { + customerId: id(representative.customerId), + }) + const marked = await api(context, 'PUT', '单票开启PDA合托提示', mergePath, { palletMergeRequired: true }) + context.assert(MODULE, '单票合托标记写入成功', marked.response, marked.data == null, trace(marked.response), OPTIONS) + const markedRead = await detail(context, representative, '回查单票合托标记') + context.assert(MODULE, '单票合托标记为REQUIRED', markedRead.response, + markedRead.data?.order?.palletMergeFlag === 'REQUIRED', trace(markedRead.response), OPTIONS) + const unmarked = await api(context, 'PUT', '单票取消PDA合托提示', mergePath, { palletMergeRequired: false }) + context.assert(MODULE, '单票合托标记可取消', unmarked.response, unmarked.data == null, trace(unmarked.response), OPTIONS) + + const allIds = details.map((row) => id(row.id)) + const batchMergePath = query(`${ORDERS}/pallet-merge-required/batch`, { customerId: id(representative.customerId) }) + const batchMarked = await api(context, 'PUT', '批量开启PDA合托分组', batchMergePath, + { orderIds: allIds, palletMergeRequired: true }) + context.assert(MODULE, '批量合托分组写入成功', batchMarked.response, batchMarked.data == null, trace(batchMarked.response), OPTIONS) + const groupRead = await detail(context, representative, '回查批量合托关系') + context.assert(MODULE, '批量合托关系包含本轮订单', groupRead.response, + groupRead.data?.order?.palletMergeFlag === 'REQUIRED' + && (allIds.length === 1 || nonBlank(groupRead.data?.order?.palletMergeBatchId)), + `orders=${allIds.length}, batchId=${groupRead.data?.order?.palletMergeBatchId || 'single-order'}; ${trace(groupRead.response)}`, + OPTIONS) + const batchCleared = await api(context, 'PUT', '批量取消PDA合托分组', batchMergePath, + { orderIds: allIds, palletMergeRequired: false }) + context.assert(MODULE, '批量合托分组取消成功', batchCleared.response, batchCleared.data == null, trace(batchCleared.response), OPTIONS) + + const feeMonth = await ensureCurrentUsdRate(context) + if (!feeMonth) return false + const feeItem = `${context.marker}-TAIL-FEE`.slice(0, 128) + const fee = await api(context, 'POST', '新增本轮尾程订单费用', + query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/fees`, { customerId: id(representative.customerId) }), + { feeItemCode: feeItem.slice(0, 64), feeItemName: feeItem, direction: 'RECEIVABLE', + currency: 'USD', amount: 0.01, businessDate: `${feeMonth}-01` }, + { 'Idempotency-Key': `${context.marker}-tail-fee`.slice(0, 128) }) + context.assert(MODULE, '尾程费用冻结币种金额和审核状态', fee.response, + id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItemName === feeItem + && fee.data?.originalCurrency === 'USD' && Number(fee.data?.originalAmount) === 0.01 + && fee.data?.currency === 'CNY' && Number(fee.data?.amount) > 0 + && fee.data?.reviewStatus === 'PENDING', + trace(fee.response), OPTIONS) + const feeRead = await detail(context, representative, '回查尾程订单费用明细') + context.assert(MODULE, '尾程费用详情可追溯', feeRead.response, + feeRead.data?.fees?.some((row) => row?.feeItemName === feeItem + && row?.originalCurrency === 'USD' && Number(row?.originalAmount) === 0.01 + && row?.currency === 'CNY' && Number(row?.amount) > 0), trace(feeRead.response), OPTIONS) + + const exported = await context.requestOverseasBinary('GET', query(`${ORDERS}/export`, { + customerId: id(representative.customerId), containerNo: producer.containerNo, + })) + const csv = new TextDecoder().decode(exported.bytes || new Uint8Array()) + context.addStep(MODULE, '导出本轮尾程订单CSV', exported, + exported?.http === 200 && String(exported?.contentType || '').toLowerCase().includes('csv') + && csv.includes(producer.containerNo) && csv.includes('尾程单号'), + `bytes=${exported?.bytes?.length ?? 0}, containerNo=${producer.containerNo}; ${trace(exported)}`, OPTIONS) + return success(batchCleared.response) && success(fee.response) && exported?.http === 200 +} +function manualPlanBody(order, version, marker) { + return { + customerId: id(order.customerId), orderId: id(order.id), expectedOrderVersion: Number(version), + inboundCode: order.inboundCode, + palletCount: Math.min(999, Math.max(1, Number(order.palletCount) || 1)), + palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: true, + remark: marker, + } +} +function completeManualNodes(marker) { + const base = Date.now() - 8 * 60 * 60_000 + return NODE_CODES.map((nodeCode, index) => ({ + nodeCode, nodeAt: localIso(new Date(base + index * 60 * 60_000)), + })).map((node, index) => index === 3 ? { ...node, nodeAt: null } : node) +} + +function previewGroupRank(group) { + return ({ FBA: 0, ONE_CLICK_DROPSHIP: 1, PRIVATE_ADDRESS: 2 })[group?.destinationType] ?? 99 +} + +function previewGroupsSorted(groups) { + const rows = Array.isArray(groups) ? groups : [] + return rows.every((group, index) => { + if (index === 0) return true + const previous = rows[index - 1] + const rankDifference = previewGroupRank(previous) - previewGroupRank(group) + return rankDifference < 0 || (rankDifference === 0 + && String(previous?.destinationWarehouseCode || '').localeCompare( + String(group?.destinationWarehouseCode || '')) <= 0) + }) +} + +async function convertContainerPlans(context, producer, details) { + const bookingId = id(producer.containerBookingId) + const previewPath = query(`${PLANS}/container-preview`, { sourceContainerBookingId: bookingId }) + const preview = await api(context, 'GET', '预览整柜待生成入仓计划清单', previewPath) + const groups = Array.isArray(preview.data?.groups) ? preview.data.groups : [] + const previewOrders = groups.flatMap((group) => Array.isArray(group?.orders) ? group.orders : []) + const pendingPalletCount = Number(preview.data?.pendingPalletCount) + context.assert(MODULE, '柜级预览列清尾程单、入仓码、目的仓、业务类型与建议托数', preview.response, + preview.data?.ready === true && id(preview.data?.sourceContainerBookingId) === bookingId + && previewOrders.length === details.length && pendingPalletCount > 0 + && previewOrders.every((order) => nonBlank(order?.businessOrderNo) && nonBlank(order?.inboundCode) + && nonBlank(order?.destinationType) && nonBlank(order?.destinationWarehouseCode) + && Number(order?.suggestedPalletCount) > 0 && order?.readiness === 'READY' + && Array.isArray(order?.blockers) && order.blockers.length === 0), + `orders=${previewOrders.length}, pendingPallets=${pendingPalletCount}, groups=${groups.length}; ${trace(preview.response)}`, + OPTIONS) + context.assert(MODULE, '柜级预览按FBA、一件代发、私人地址及目的仓稳定排序', preview.response, + previewGroupsSorted(groups), groups.map((group) => `${group.destinationType}:${group.destinationWarehouseCode}`).join(','), OPTIONS) + context.assert(MODULE, '一件代发的FBA目的仓快照保留给自动托盘', preview.response, + previewOrders.filter((order) => order?.destinationType === 'ONE_CLICK_DROPSHIP' + && nonBlank(order?.destinationWarehouseCode)).every((order) => nonBlank(order?.fbaWarehouseCode)), + `dropshipFba=${previewOrders.filter((order) => nonBlank(order?.fbaWarehouseCode)).length}`, OPTIONS) + if (!success(preview.response) || preview.data?.ready !== true || pendingPalletCount <= 0) return null + + const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.data.snapshotVersion), + snapshotHash: preview.data.snapshotHash } + const key = `${context.marker}-container-plan-confirm`.slice(0, 128) + const converted = await api(context, 'POST', '按柜确认并自动生成入仓计划与实体托盘', + `${PLANS}/convert-container`, body, { 'Idempotency-Key': key }) + const plans = Array.isArray(converted.data) ? converted.data : [] + const pallets = plans.flatMap((plan) => Array.isArray(plan?.pallets) ? plan.pallets.map((pallet) => ({ plan, pallet })) : []) + context.assert(MODULE, '按柜确认逐票生成独立计划且托盘总数与预览一致', converted.response, + plans.length === details.length && pallets.length === pendingPalletCount + && plans.every((plan) => plan?.creationMode === 'AUTO' && nonBlank(plan?.planNo) + && Array.isArray(plan?.lines) && plan.lines.length === 1) + && pallets.every(({ pallet }) => nonBlank(pallet?.palletNo) && id(pallet?.warehousePalletId) + && id(pallet?.warehouseLocationId) && pallet?.assignmentStatus === 'RESERVED'), + `plans=${plans.length}, pallets=${pallets.length}, expectedPallets=${pendingPalletCount}; ${trace(converted.response)}`, + OPTIONS) + if (!success(converted.response)) return null + + for (const { plan, pallet } of pallets) { + const line = plan.lines[0] + const palletPath = query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(preview.data.overseasWarehouseId))}/pallets`, { + page: 1, size: 10, keyword: pallet.palletNo, + }) + const listed = await api(context, 'GET', `回查自动托盘${pallet.palletNo}`, palletPath) + const stored = records(listed.data).find((row) => row?.palletCode === pallet.palletNo) + context.assert(MODULE, '自动托盘进入托盘列表并保留FBA目的仓', listed.response, + id(stored?.id) === id(pallet.warehousePalletId) && stored?.status === 'OCCUPIED' + && (!nonBlank(line?.fbaWarehouseCode) || stored?.fbaWarehouseCode === line.fbaWarehouseCode), + `pallet=${pallet.palletNo}, expectedFba=${line?.fbaWarehouseCode || 'none'}, actualFba=${stored?.fbaWarehouseCode || 'none'}; ${trace(listed.response)}`, + OPTIONS) + } + + const replay = await api(context, 'POST', '按柜确认同键同快照幂等重放', + `${PLANS}/convert-container`, body, { 'Idempotency-Key': key }) + context.assert(MODULE, '按柜确认重放不新增计划或托盘', replay.response, + JSON.stringify(replay.data) === JSON.stringify(converted.data), trace(replay.response), OPTIONS) + const after = await api(context, 'GET', '回查整柜计划与托盘均已生成', previewPath) + context.assert(MODULE, '确认后预览待生成票数和托数归零', after.response, + Number(after.data?.pendingOrderCount) === 0 && Number(after.data?.pendingPalletCount) === 0 + && Number(after.data?.totalPlannedPalletCount) >= pendingPalletCount, + `pendingOrders=${after.data?.pendingOrderCount}, pendingPallets=${after.data?.pendingPalletCount}, totalPlannedPallets=${after.data?.totalPlannedPalletCount}; ${trace(after.response)}`, + OPTIONS) + return plans +} + +export async function run(context) { + context.createdResources.phase4TailOrderControls = { + marker: context.marker, containerNo: null, orderIds: [], planNos: [], representativeOrderId: null, + finalStatus: null, + cleanup: 'Automatically generated container inbound plans and pallets remain as marker-owned prerequisites consumed by overseas PDA receiving', + } + const producer = targetProducer(context) + if (!producer?.containerNo || !producer?.tenantId) { + blocker(context, '取得本轮尾程控制目标柜', '缺少本轮phase4入仓生产者,禁止借历史尾程订单') + return + } + const listed = await markerOrders(context, producer) + if (listed.rows.length === 0) { + blocker(context, '取得本轮尾程订单', `containerNo=${producer.containerNo}, customerId=${producer.tenantId}`, listed.response) + return + } + const details = [] + for (const row of listed.rows) { + const current = await detail(context, row) + if (success(current.response) && id(current.data?.order?.id) === id(row.id)) details.push(current.data.order) + } + if (details.length !== listed.rows.length || details.some((order) => !order.inboundCode + || !order.overseasWarehouseId)) { + blocker(context, '尾程订单人工计划冻结事实完整', `listed=${listed.rows.length}, detailed=${details.length}`) + return + } + if (!await verifyReceivingWarehouseChange(context, producer, details)) return + if (!await verifyDestinationMergeFeeAndExport(context, details, producer)) return + const refreshedDetails = [] + for (const original of details) { + const refreshed = await detail(context, original, `管理命令后刷新订单${id(original.id)}版本`) + if (!success(refreshed.response) || !refreshed.data?.order) return + refreshedDetails.push(refreshed.data.order) + } + const representative = refreshedDetails[0] + const orderPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { + customerId: id(representative.customerId), + }) + const holdKey = `${context.marker}-tail-hold`.slice(0, 128) + const holdBody = { held: true, chargeable: true, reason: `${context.marker} 暂扣验收`, expectedVersion: Number(representative.version) } + const held = await api(context, 'PUT', '暂扣本轮单票尾程订单', orderPath, holdBody, { 'Idempotency-Key': holdKey }) + context.assert(MODULE, '暂扣只推进单票版本并保留计费原因', held.response, + held.data?.held === true && held.data?.chargeable === true + && id(held.data?.orderId) === id(representative.id) && Number(held.data?.version) === Number(representative.version) + 1, + trace(held.response), OPTIONS) + if (!success(held.response)) return + const heldReplay = await api(context, 'PUT', '暂扣命令同键同载荷重放', orderPath, holdBody, { 'Idempotency-Key': holdKey }) + context.assert(MODULE, '暂扣重放版本零漂移', heldReplay.response, + JSON.stringify(heldReplay.data) === JSON.stringify(held.data), trace(heldReplay.response), OPTIONS) + const heldConflict = await context.requestOverseas('PUT', orderPath, + { ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, { 'Idempotency-Key': holdKey }, context.overseasToken) + context.addStep(MODULE, '暂扣同键异载荷返回A0409', heldConflict, conflict(heldConflict), trace(heldConflict), OPTIONS) + + const blockedPlanBody = manualPlanBody(representative, held.data.version, context.marker) + const blockedPlan = await context.requestOverseas('POST', `${PLANS}/manual`, blockedPlanBody, + { 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) }, context.overseasToken) + context.addStep(MODULE, '暂扣尾程订单不能生成人工计划', blockedPlan, conflict(blockedPlan), trace(blockedPlan), OPTIONS) + + const releaseKey = `${context.marker}-tail-release`.slice(0, 128) + const releaseBody = { held: false, chargeable: false, reason: `${context.marker} 解除暂扣`, expectedVersion: Number(held.data.version) } + const released = await api(context, 'PUT', '解除本轮单票尾程暂扣', orderPath, releaseBody, { 'Idempotency-Key': releaseKey }) + context.assert(MODULE, '解除暂扣清空计费与原因并推进版本', released.response, + released.data?.held === false && released.data?.chargeable === false && released.data?.reason == null + && Number(released.data?.version) === Number(held.data.version) + 1, trace(released.response), OPTIONS) + if (!success(released.response)) return + + const nodesPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/manual-nodes`, { + customerId: id(representative.customerId), + }) + const nodesKey = `${context.marker}-tail-manual-nodes`.slice(0, 128) + const nodesBody = { expectedVersion: Number(released.data.version), nodes: completeManualNodes(context.marker) } + const nodes = await api(context, 'PUT', '保存尾程八个人工节点快照', nodesPath, nodesBody, + { 'Idempotency-Key': nodesKey }) + context.assert(MODULE, '人工节点永久切换MANUAL且完整返回八行', nodes.response, + nodes.data?.manualOverrideEnabled === true && nodes.data?.nodes?.length === 8 + && nodes.data.nodes.every((node) => node?.source === 'MANUAL') + && Number(nodes.data?.version) === Number(released.data.version) + 1, + `nodes=${nodes.data?.nodes?.length ?? 'missing'}; ${trace(nodes.response)}`, OPTIONS) + if (!success(nodes.response)) return + const nodesReplay = await api(context, 'PUT', '人工节点同键同载荷重放', nodesPath, nodesBody, + { 'Idempotency-Key': nodesKey }) + context.assert(MODULE, '人工节点重放版本与八行快照零漂移', nodesReplay.response, + JSON.stringify(nodesReplay.data) === JSON.stringify(nodes.data), trace(nodesReplay.response), OPTIONS) + const nodesConflict = await context.requestOverseas('PUT', nodesPath, + { ...nodesBody, nodes: nodesBody.nodes.map((node, index) => index === 0 ? { ...node, nodeAt: localIso(new Date()) } : node) }, + { 'Idempotency-Key': nodesKey }, context.overseasToken) + context.addStep(MODULE, '人工节点同键异载荷返回A0409', nodesConflict, conflict(nodesConflict), trace(nodesConflict), OPTIONS) + const nodesRead = await api(context, 'GET', '回查尾程人工节点来源', nodesPath) + context.assert(MODULE, '人工节点读取固定为MANUAL且不回退柜级节点', nodesRead.response, + nodesRead.data?.manualOverrideEnabled === true && nodesRead.data?.nodes?.length === 8 + && nodesRead.data.nodes.every((node) => node?.source === 'MANUAL'), trace(nodesRead.response), OPTIONS) + + const generated = await convertContainerPlans(context, producer, refreshedDetails) + if (!generated) return + context.createdResources.phase4TailOrderControls = { + ...context.createdResources.phase4TailOrderControls, + containerNo: producer.containerNo, orderIds: refreshedDetails.map((order) => id(order.id)), + planNos: generated.map((plan) => plan.planNo), representativeOrderId: id(representative.id), + generatedPlans: generated, + finalStatus: 'AUTO_CONTAINER_PLANS_READY', + } +} diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs new file mode 100644 index 000000000..369f3757c --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -0,0 +1,110 @@ +import test from 'node:test' +import { relabelOutboundMarksMatch } from './phase5-overseas-fulfillment.mjs' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { parseArguments } from '../admin-test-env-acceptance.mjs' + +const source = await readFile(fileURLToPath(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url)), 'utf8') +const entry = await readFile(fileURLToPath(new URL('../admin-test-env-acceptance.mjs', import.meta.url)), 'utf8') + +test('phase-five fulfillment is a registered default HTTP module after the real phase-four dispatch fixture', () => { + assert.match(entry, /CONFIGURED_MODULES[^\n]*'phase4-overseas-dispatch', 'phase5-overseas-fulfillment'/) + assert.match(entry, /'phase5-overseas-fulfillment', \['phase4-overseas-dispatch'\]/) + const modules = parseArguments(['--modules', 'phase5-overseas-fulfillment']).modules + for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', 'phase3-loading-finalize', + 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-dispatch']) { + assert.ok(modules.indexOf(prerequisite) >= 0 + && modules.indexOf(prerequisite) < modules.indexOf('phase5-overseas-fulfillment'), prerequisite) + } +}) + +test('phase-five module covers dispatch edit, transfer, PDA pick/depart and delivery state', () => { + for (const token of [ + "const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'", + "const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", + 'query(MERCHANT_DELIVERY_BASE, { dispatchNo })', '/items/${encodeURIComponent(id(item.dispatchItemId))}/pick', + '/complete`', '/depart`', '/outbound-orders/${encodeURIComponent(outboundNo)}', + '/delivery-orders/${encodeURIComponent(currentNo)}/status', + ]) assert.ok(source.includes(token), token) + assert.match(source, /WAIT_OUTBOUND/) + assert.match(source, /WAIT_DELIVERY/) + assert.match(source, /READY_TO_DEPART/) + assert.match(source, /OUTBOUNDED/) + assert.match(source, /DELIVERING/) + assert.match(source, /expectedVersion: deliveryVersion/) + assert.match(source, /transferDeliveries\(transferred\.data\)/) + assert.match(source, /new Set\(destinationKeys\)\.size === deliveries\.length/) + assert.match(source, /deliveries\.reduce\(\(sum, value\) => sum \+ Number\(value\?\.packageCount \|\| 0\), 0\)/) +}) + +test('phase-five module proves the client delivery create, list and nested detail contract', () => { + assert.match(source, /const MERCHANT_DELIVERY_BASE = '\/api\/merchant\/v1\/last-leg\/delivery-orders'/) + assert.match(source, /loginMerchant\(context, customerId\)/) + assert.match(source, /context\.options\?\.merchantUsername/) + assert.match(source, /\/api\/merchant\/v1\/iam\/tenant\/profile/) + assert.match(source, /id\(profile\?\.id\) === customerId/) + assert.match(source, /客户创建派送并原子转出库单/) + assert.match(source, /deliveryMethod: 'TRUCK_PALLET'/) + assert.match(source, /fees: \[\{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE',\s*currency: 'USD', amount: 10/) + assert.match(source, /客户分页回查自身派送单/) + assert.match(source, /客户读取自身派送详情/) + assert.match(source, /merchantDetail\.data\?\.order\?\.deliveryNo/) + assert.match(source, /merchantDetail\.data\?\.skus/) + assert.match(source, /merchantDetail\.data\?\.fees/) + assert.match(source, /merchantDetail\.data\?\.operations/) +}) + +test('phase-five module proves receipt-save completion, cross-region convergence, customer visit, exception and return', () => { + for (const token of [ + '/documents/RECEIPT/files', 'requestOverseasMultipart', "category: 'RECEIPT'", + 'SAVE-RECEIPT', 'RECEIPT_AUTO_COMPLETE', + '/customer-visits/${encodeURIComponent(visitId)}', + "visitResult: 'SATISFIED'", '/exception-orders', + '/return-inbound-options', '/mark-return', "returnDirection: 'OVERSEAS_WAREHOUSE'", + 'returnSources', 'outboundPalletItemId', 'inventoryPositionId', + 'value.returnSources.some', 'returnInbound?.deliveryNo === deliveryNo', + "idempotencyKey: key(context, 'DELIVERY-RETURN')", '派送退回同键同载荷重放', + 'RETURN_PENDING_WITH_COMPLETED_VISIT', + ]) assert.ok(source.includes(token), token) + assert.match(source, /saved\.data\?\.categories\?\.some/) + assert.match(source, /completed\.data\?\.order\?\.status === 'DELIVERED'/) + assert.match(source, /人工确认派送完成接口已移除/) + assert.match(source, /query\('\/api\/platform\/inbounds'/) + assert.match(source, /inboundCode: inbound\.inboundCode/) + assert.match(source, /inboundSummary\?\.processStatus === 'DELIVERED'/) + assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(sourceOrderId\)\}/) + assert.match(source, /mainStatus === 'COMPLETED'/) + assert.doesNotMatch(source, /platform\(context, 'POST', '确认派送完成/) + assert.match(source, /manualExceptionNo/) + assert.match(source, /returnExceptionNo/) +}) + +test('phase-five module uses marker-owned fixtures and always disables its temporary PDA identity', () => { + assert.match(source, /dispatch\?\.marker !== context\.marker/) + assert.match(source, /receiving\?\.marker !== context\.marker/) + assert.match(source, /resolvePickerAccess\(context\)/) + assert.match(source, /createPicker\(context, 5, warehouseId, access\)/) + assert.match(source, /finally \{[\s\S]*disablePicker\(context, picker, 5\)[\s\S]*picker\.token = null/) + assert.doesNotMatch(source, /8\.163\.|password\s*:\s*['"`]/) +}) + +test('phase-five writes carry idempotency and record deployment blockers instead of passing empty fixtures', () => { + assert.match(source, /idempotencyKey: key\(context, 'TRANSFER'\)/) + assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-PICK'/) + assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-COMPLETE'\)/) + assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-DEPART'\)/) + assert.match(source, /addCoverageGap\(MODULE, SCENARIO/) + assert.match(source, /deploymentBlocker: true/) +}) +test('relabel marks are checked on the authoritative outbound DTO without weakening identity checks', () => { + const relabel = { relabelCartonMarks: ['NEW-1', 'NEW-2'], sourceCartonMarks: ['OLD-1', 'OLD-2'] } + const detail = { outbound: { packageCount: 2 }, pallets: [{ cartonMarks: 'NEW-1,NEW-2' }] } + assert.equal(relabelOutboundMarksMatch(detail, relabel), true) + for (const cartonMarks of ['', 'NEW-1', 'NEW-1,NEW-1', 'NEW-1,OLD-2', 'NEW-1,NEW-2,OTHER']) { + assert.equal(relabelOutboundMarksMatch({ ...detail, pallets: [{ cartonMarks }] }, relabel), false) + } + assert.equal(relabelOutboundMarksMatch({ ...detail, outbound: { packageCount: 3 } }, relabel), false) + assert.match(source, /detail\.data\?\.outbound\?\.dispatchNo === relabel\.dispatchNo/) + assert.match(source, /relabel\?\.marker !== context\.marker/) +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs new file mode 100644 index 000000000..2285bebff --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -0,0 +1,532 @@ +import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' + +const MODULE = 'phase5-overseas-fulfillment' +const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches' +const LASTLEG_BASE = '/api/platform/v1/last-leg' +const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches' +const MERCHANT_DELIVERY_BASE = '/api/merchant/v1/last-leg/delivery-orders' + +function id(value) { return value == null ? '' : String(value) } +function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } +function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } +function code(response) { return String(response?.body?.code ?? '') } +function conflict(response) { + return response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response))) +} +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, values) { + const params = new URLSearchParams() + for (const [name, value] of Object.entries(values || {})) { + if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value)) + } + return params.size ? `${path}?${params}` : path +} +function key(context, action, suffix = '') { + return `${context.marker}-P5-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 200) +} +function blocker(context, name, reason, response = null) { + context.addCoverageGap(MODULE, SCENARIO, name, reason, 'fixture-unavailable', { deploymentBlocker: true }) + context.assert(MODULE, `${name}覆盖前置`, response, false, `${reason}; ${trace(response)}`, OPTIONS) +} +async function platform(context, method, name, path, body = undefined) { + const response = await context.requestOverseas(method, path, body) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} +async function pda(context, token, method, name, path, body = undefined) { + const response = await context.requestOverseas(method, path, body, {}, token) + context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) + return { response, data: success(response) ? response.body?.data : null } +} +async function merchant(context, token, method, name, path, body = undefined) { + const response = await context.requestOverseas(method, path, body, {}, token) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} + +function transferDeliveries(data) { + return Array.isArray(data?.deliveries) ? data.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : [] +} + +function deliveryNumbers(data) { + return transferDeliveries(data).map((value) => id(value.deliveryNo)).sort() +} + +async function poll(action, predicate, attempts = 45, intervalMs = 1_000) { + let result = null + for (let attempt = 1; attempt <= attempts; attempt += 1) { + result = await action() + if (predicate(result)) return { result, attempts: attempt } + if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + return { result, attempts } +} + +export async function saveReceiptAndComplete(context, delivery, customerId, index) { + const deliveryNo = id(delivery.deliveryNo) + const documentsPath = query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`, { customerId }) + const before = await platform(context, 'GET', `读取第${index + 1}张派送单资料版本`, documentsPath) + const version = Number(before.data?.version) + context.assert(MODULE, `第${index + 1}张派送单资料提供乐观锁版本`, before.response, + Number.isInteger(version) && version >= 0, `version=${before.data?.version ?? 'missing'}; ${trace(before.response)}`, OPTIONS) + if (!success(before.response) || !Number.isInteger(version) || version < 0) return null + + const uploadResponse = await context.requestOverseasMultipart('POST', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents/RECEIPT/files`, { customerId }), { + name: `${context.marker}-${deliveryNo}-receipt.txt`, type: 'text/plain', + content: new TextEncoder().encode(`${context.marker} receipt acceptance for ${deliveryNo}`), + }) + const uploaded = context.apiSuccess(MODULE, `上传第${index + 1}张派送单签收文件`, uploadResponse, + trace(uploadResponse), OPTIONS) + const fileId = id(uploaded?.fileId) + context.assert(MODULE, `第${index + 1}张派送单签收文件返回业务绑定ID`, uploadResponse, + /^\d+$/.test(fileId), `fileIdPresent=${/^\d+$/.test(fileId)}; ${trace(uploadResponse)}`, OPTIONS) + if (!success(uploadResponse) || !fileId) return null + + const existing = (before.data?.categories || []).map((category) => ({ + category: category.category, + fileIds: (category.files || []).map((file) => id(file.fileId)).filter((value) => /^\d+$/.test(value)), + })).filter((binding) => binding.category && binding.category !== 'RECEIPT' && binding.fileIds.length > 0) + const saveBody = { + expectedVersion: version, idempotencyKey: key(context, 'SAVE-RECEIPT', String(index)), + bolNo: before.data?.bolNo || null, + documents: [...existing, { category: 'RECEIPT', fileIds: [fileId] }], + } + const saved = await platform(context, 'PUT', `保存第${index + 1}张派送单签收资料并自动完成`, documentsPath, saveBody) + context.assert(MODULE, `第${index + 1}张派送单签收资料保存后已上传`, saved.response, + saved.data?.categories?.some((category) => category?.category === 'RECEIPT' + && category?.status === 'UPLOADED' && Number(category?.fileCount) === 1), trace(saved.response), OPTIONS) + if (!success(saved.response)) return null + + const savedReplay = await platform(context, 'PUT', `第${index + 1}张派送单签收资料同键重放`, documentsPath, saveBody) + context.assert(MODULE, `第${index + 1}张派送单资料重放版本稳定`, savedReplay.response, + savedReplay.data?.replayed === true && Number(savedReplay.data?.version) === Number(saved.data?.version), + trace(savedReplay.response), OPTIONS) + const completed = await platform(context, 'GET', `回查第${index + 1}张派送单自动完成`, + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId })) + context.assert(MODULE, `第${index + 1}张派送单签收保存即进入DELIVERED`, completed.response, + completed.data?.order?.status === 'DELIVERED' + && completed.data?.operations?.some((operation) => operation?.operationNode === 'RECEIPT_AUTO_COMPLETE'), + trace(completed.response), OPTIONS) + return success(completed.response) && completed.data?.order?.status === 'DELIVERED' ? completed.data : null +} + +export function relabelOutboundMarksMatch(detail, relabel) { + const marks = (detail?.pallets || []).flatMap(item => String(item.cartonMarks || '') + .split(',').map(mark => mark.trim()).filter(Boolean)) + const expected = relabel?.relabelCartonMarks || [] + return expected.length > 0 && marks.length === expected.length + && new Set(marks).size === marks.length && expected.every(mark => marks.includes(mark)) + && (relabel.sourceCartonMarks || []).every(mark => !marks.includes(mark)) + && Number(detail?.outbound?.packageCount) === expected.length +} + +async function verifyRelabelDispatchOutbound(context, relabel, transferBody) { + if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo)) { + blocker(context, '换标排车箱唛传递前置', '缺少本轮换标排车,不借用历史数据') + return + } + const transferred = await platform(context, 'POST', '本轮换标排车转派送并生成出库快照', + `${DISPATCH_BASE}/${encodeURIComponent(relabel.dispatchNo)}/transfer-delivery`, + { ...transferBody, idempotencyKey: key(context, 'RELABEL-TRANSFER') }) + const outboundNo = transferred.data?.outboundNo + if (!success(transferred.response) || !nonBlank(outboundNo)) return + const detail = await platform(context, 'GET', '回查换标排车的真实出库箱唛', + query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 200 })) + context.assert(MODULE, '排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', detail.response, + success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo + && detail.data?.outbound?.dispatchNo === relabel.dispatchNo + && relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS) + Object.assign(relabel, { outboundNo, deliveryNos: deliveryNumbers(transferred.data) }) +} + +async function loginMerchant(context, customerId) { + const merchantFixture = context.context?.merchantQualificationFixture + const credentials = nonBlank(merchantFixture?.username) && nonBlank(merchantFixture?.password) + ? { username: merchantFixture.username, password: merchantFixture.password } + : { username: context.options?.merchantUsername, password: context.options?.merchantPassword } + if (!nonBlank(credentials.username) || !nonBlank(credentials.password)) { + blocker(context, '取得本轮客户工作台账号', '既无本轮动态客户凭据,也未配置开发验收客户凭据') + return null + } + const response = await context.request('POST', '/api/auth/login', { + loginId: credentials.username, + password: credentials.password, + }, '') + const auth = context.apiSuccess(MODULE, '本轮客户工作台账号登录', response, trace(response), OPTIONS) + context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.overseasAccessToken), + `tokenPresent=${Boolean(auth?.overseasAccessToken)}; ${trace(response)}`, OPTIONS) + if (!nonBlank(auth?.accessToken) || !nonBlank(auth?.overseasAccessToken)) return null + const profileResponse = await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, auth.accessToken) + const profile = context.apiSuccess(MODULE, '回查客户工作台企业身份', profileResponse, + trace(profileResponse), OPTIONS) + context.assert(MODULE, '客户工作台企业与五期履约客户严格一致', profileResponse, + id(profile?.id) === customerId, + `merchantCustomer=${id(profile?.id) || 'missing'}, fulfillmentCustomer=${customerId}; ${trace(profileResponse)}`, OPTIONS) + return id(profile?.id) === customerId ? auth.overseasAccessToken : null +} + +function fixture(context) { + const dispatch = context.createdResources?.phase4OverseasDispatch + const receiving = context.createdResources?.phase4OverseasPda + if (dispatch?.marker !== context.marker || dispatch?.finalStatus !== 'PENDING_DEPARTURE' + || !nonBlank(dispatch?.dispatchNo)) return null + if (receiving?.marker !== context.marker || receiving?.finalStatus !== 'COMPLETED' + || !id(receiving?.overseasWarehouseId) || !id(receiving?.customerId)) return null + return { dispatch, receiving } +} + +export async function pickAll(context, token, dispatchNo) { + let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + const items = Array.isArray(detail.data?.items) ? detail.data.items : [] + if (!success(detail.response) || items.length === 0) { + blocker(context, 'PDA排车拣货任务', '本轮排车未生成可操作PDA托盘任务', detail.response) + return false + } + + for (const [itemIndex, item] of items.entries()) { + let remaining = Number(item.scheduledPackages || 0) - Number(item.pickedPackages || 0) + const positions = Array.isArray(item.positions) ? item.positions : [] + for (const [positionIndex, position] of positions.entries()) { + if (remaining <= 0) break + const available = Number(position.availablePackages || 0) + if (available <= 0) continue + const packages = Math.min(remaining, available) + const body = { + positionId: id(position.id), packages, productQuantity: packages, + idempotencyKey: key(context, 'DISPATCH-PICK', `${itemIndex}-${positionIndex}`), + remark: `${context.marker}-phase5-dispatch-pick`, + } + const picked = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}库存位${positionIndex + 1}`, + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body) + if (!success(picked.response)) return false + const replay = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}同键重放`, + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body) + context.assert(MODULE, `PDA拣货托盘${itemIndex + 1}重放数量稳定`, replay.response, + success(replay.response) && Number(replay.data?.pickedPackages) === Number(picked.data?.pickedPackages), + trace(replay.response), OPTIONS) + remaining -= packages + } + if (remaining !== 0) { + blocker(context, `PDA托盘${itemIndex + 1}足量库存`, + `scheduled=${item.scheduledPackages}, picked=${item.pickedPackages}, remaining=${remaining}`, detail.response) + return false + } + } + + const completeBody = { idempotencyKey: key(context, 'DISPATCH-COMPLETE') } + const completed = await pda(context, token, 'POST', 'PDA确认排车拣货全部完成', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody) + context.assert(MODULE, '排车进入READY_TO_DEPART且数量守恒', completed.response, + completed.data?.status === 'READY_TO_DEPART' + && Number(completed.data?.plannedPackages) === Number(completed.data?.pickedPackages) + && Number(completed.data?.remainingPackages) === 0, + trace(completed.response), OPTIONS) + if (!success(completed.response)) return false + const replay = await pda(context, token, 'POST', 'PDA完成排车拣货同键重放', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody) + context.assert(MODULE, 'PDA完成拣货重放明确标记且结果稳定', replay.response, + replay.data?.replayed === true && replay.data?.status === 'READY_TO_DEPART', trace(replay.response), OPTIONS) + return success(replay.response) +} + +export async function run(context) { + context.createdResources.phase5OverseasFulfillment = { + marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null, deliveryNos: [], + visitId: null, manualExceptionNo: null, returnExceptionNo: null, finalStatus: null, + cleanup: 'marker-owned completed delivery, customer visit and return exception are retained as acceptance evidence', + } + const source = fixture(context) + if (!source) { + blocker(context, '取得本轮四期已收货排车前置', + `dispatchMarker=${context.createdResources?.phase4OverseasDispatch?.marker || 'missing'}, dispatchStatus=${context.createdResources?.phase4OverseasDispatch?.finalStatus || 'missing'}, receivingStatus=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}`) + return + } + const dispatchNo = source.dispatch.dispatchNo + const customerId = id(source.receiving.customerId) + const warehouseId = id(source.receiving.overseasWarehouseId) + context.createdResources.phase5OverseasFulfillment.dispatchNo = dispatchNo + const merchantToken = await loginMerchant(context, customerId) + if (!merchantToken) return + + const updateBody = { + vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64), + driverName: `五期验收司机-${context.marker.slice(-6)}`, + driverPhone: '19900000000', carrierName: '五期开发验收车队', + route: `${context.marker}-PHASE5-ROUTE`, estimatedArrivalAt: new Date(Date.now() + 86_400_000).toISOString(), + } + const updated = await platform(context, 'PUT', '补齐本轮排车车辆司机与线路', + `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`, updateBody) + context.assert(MODULE, '排车编辑字段完整回显', updated.response, + updated.data?.dispatchNo === dispatchNo && updated.data?.vehiclePlate === updateBody.vehiclePlate + && updated.data?.driverName === updateBody.driverName && updated.data?.route === updateBody.route, + trace(updated.response), OPTIONS) + if (!success(updated.response)) return + + const bol = await context.requestOverseasBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`) + context.addStep(MODULE, '排车BOL下载为真实PDF而非JSON错误', bol, + bol.http === 200 && /application\/pdf/i.test(bol.contentType || '') + && new TextDecoder().decode(bol.bytes?.slice(0, 5)) === '%PDF-', trace(bol), OPTIONS) + + const transferBody = { + idempotencyKey: key(context, 'TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(), + deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-WAYBILL`.slice(0, 64), + carrierName: updateBody.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', + vehicleType: 'TRUCK', vehicleSize: '15托', driverName: updateBody.driverName, + driverPhone: updateBody.driverPhone, vehiclePlate: updateBody.vehiclePlate, + appointmentAccount: 'SELF', sellerName: null, remark: `${context.marker}-phase5-transfer`, + fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', + currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }], + } + await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, transferBody) + const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单', + query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) + const outboundNo = id(transferred.data?.outboundNo) + const deliveries = transferDeliveries(transferred.data) + const deliveryNos = deliveryNumbers(transferred.data) + const destinationKeys = deliveries.map((value) => id(value.destinationWarehouseCode || value.destinationWarehouseId)) + const deliveryNo = deliveryNos[0] || '' + context.assert(MODULE, '转派送按目的FBA仓生成WAIT_DELIVERY单据数组', transferred.response, + nonBlank(outboundNo) && deliveries.length > 0 && new Set(deliveryNos).size === deliveries.length + && new Set(destinationKeys).size === deliveries.length && destinationKeys.every(nonBlank) + && transferred.data?.outboundStatus === 'WAIT_OUTBOUND' + && deliveries.every((value) => value?.deliveryStatus === 'WAIT_DELIVERY') + && deliveries.reduce((sum, value) => sum + Number(value?.packageCount || 0), 0) === Number(transferred.data?.packageCount) + && Number(transferred.data?.packageCount) > 0, + `outboundNo=${outboundNo || 'missing'}, deliveries=${deliveryNos.join(',') || 'missing'}, destinations=${destinationKeys.join(',') || 'missing'}; ${trace(transferred.response)}`, OPTIONS) + if (!success(transferred.response) || !outboundNo || deliveries.length === 0) return + Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos }) + const adminReplay = await platform(context, 'POST', '后台转派送复用客户已创建的同一出库派送单', + `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/transfer-delivery`, transferBody) + context.assert(MODULE, '后台与客户转派送共享同一业务事实', adminReplay.response, + adminReplay.data?.replayed === true && id(adminReplay.data?.outboundNo) === outboundNo + && JSON.stringify(deliveryNumbers(adminReplay.data)) === JSON.stringify(deliveryNos), trace(adminReplay.response), OPTIONS) + const lockedItem = updated.data?.items?.[0] + if (lockedItem?.id) { + const deniedRemove = await context.requestOverseas('DELETE', + `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`) + context.addStep(MODULE, '已生成出库单禁止移除排车托盘', deniedRemove, conflict(deniedRemove), trace(deniedRemove), OPTIONS) + const stable = await platform(context, 'GET', '移除拒绝后回查排车托盘零漂移', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + context.assert(MODULE, '移除失败不改变箱数和托盘身份', stable.response, + Number(stable.data?.packageCount) === Number(updated.data?.packageCount) + && JSON.stringify(stable.data?.items?.map(item => id(item.id))) === JSON.stringify(updated.data?.items?.map(item => id(item.id))), trace(stable.response), OPTIONS) + } else blocker(context, '转派送后移除门禁前置', '本轮排车缺少真实明细ID') + const transferReplay = await merchant(context, merchantToken, 'POST', '客户创建派送同键重放', + query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) + context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response, + transferReplay.data?.replayed === true && id(transferReplay.data?.outboundNo) === outboundNo + && JSON.stringify(deliveryNumbers(transferReplay.data)) === JSON.stringify(deliveryNos), trace(transferReplay.response), OPTIONS) + const transferConflict = await context.requestOverseas('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), + { ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, {}, merchantToken) + context.addStep(MODULE, '同一排车使用另一幂等键仍返回既有单据', transferConflict, + success(transferConflict) && id(transferConflict.body?.data?.outboundNo) === outboundNo + && transferConflict.body?.data?.replayed === true + && JSON.stringify(deliveryNumbers(transferConflict.body?.data)) === JSON.stringify(deliveryNos), trace(transferConflict), OPTIONS) + + const merchantPage = await merchant(context, merchantToken, 'GET', '客户分页回查自身派送单', + query(MERCHANT_DELIVERY_BASE, { page: 1, size: 20, dispatchNo })) + const merchantRows = Array.isArray(merchantPage.data?.records) ? merchantPage.data.records : [] + context.assert(MODULE, '客户派送列表包含本轮全部FBA派送单且不依赖customerId参数', merchantPage.response, + deliveryNos.every((number) => merchantRows.some((value) => id(value?.deliveryNo) === number + && id(value?.dispatchNo) === dispatchNo)), + `records=${merchantRows.length}; ${trace(merchantPage.response)}`, OPTIONS) + const merchantDetail = await merchant(context, merchantToken, 'GET', '客户读取自身派送详情', + `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(deliveryNo)}`) + context.assert(MODULE, '客户派送详情返回订单、货物、费用与操作四段正式契约', merchantDetail.response, + id(merchantDetail.data?.order?.deliveryNo) === deliveryNo + && id(merchantDetail.data?.order?.dispatchNo) === dispatchNo + && nonBlank(id(merchantDetail.data?.order?.destinationWarehouseCode + || merchantDetail.data?.order?.destinationWarehouseId)) + && Array.isArray(merchantDetail.data?.skus) && merchantDetail.data.skus.length > 0 + && Array.isArray(merchantDetail.data?.fees) && Array.isArray(merchantDetail.data?.operations), + `skus=${merchantDetail.data?.skus?.length ?? 'missing'}, fees=${merchantDetail.data?.fees?.length ?? 'missing'}, operations=${merchantDetail.data?.operations?.length ?? 'missing'}; ${trace(merchantDetail.response)}`, + OPTIONS) + + const access = await resolvePickerAccess(context) + const picker = access ? await createPicker(context, 5, warehouseId, access) : null + try { + if (!nonBlank(picker?.token)) { + blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪') + return + } + if (!await pickAll(context, picker.token, dispatchNo)) return + const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` } + const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) + context.assert(MODULE, '发车推进排车DEPARTED并扣减实物库存一次', departed.response, + departed.data?.status === 'DEPARTED' && Number(departed.data?.shippedPackages) > 0, + trace(departed.response), OPTIONS) + if (!success(departed.response)) return + const departReplay = await pda(context, picker.token, 'POST', 'PDA发车同键重放', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) + context.assert(MODULE, 'PDA发车重放明确标记且发运数量稳定', departReplay.response, + departReplay.data?.replayed === true + && Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages), + trace(departReplay.response), OPTIONS) + } finally { + await disablePicker(context, picker, 5) + if (picker) picker.token = null + } + + const outbound = await platform(context, 'GET', '回查发车后出库单状态', + query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 20 })) + context.assert(MODULE, '排车PDA发车同步出库单OUTBOUNDED', outbound.response, + outbound.data?.outbound?.outboundNo === outboundNo && outbound.data?.outbound?.status === 'OUTBOUNDED', + trace(outbound.response), OPTIONS) + + for (const [index, delivery] of deliveries.entries()) { + const currentNo = id(delivery.deliveryNo) + const beforeStatus = await platform(context, 'GET', `读取第${index + 1}张派送单状态版本`, + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}`, { customerId })) + const deliveryVersion = Number(beforeStatus.data?.order?.version) + context.assert(MODULE, `第${index + 1}张派送单提供状态乐观锁版本`, beforeStatus.response, + Number.isInteger(deliveryVersion) && deliveryVersion >= 0, + `version=${beforeStatus.data?.order?.version ?? 'missing'}; ${trace(beforeStatus.response)}`, OPTIONS) + if (!Number.isInteger(deliveryVersion) || deliveryVersion < 0) return + const delivering = await platform(context, 'PUT', `第${index + 1}张派送单推进配送中`, + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}/status`, { customerId }), + { status: 'DELIVERING', expectedVersion: deliveryVersion, remark: `${context.marker}-delivering-${index + 1}` }) + context.assert(MODULE, `第${index + 1}张派送状态进入DELIVERING`, delivering.response, + delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS) + if (!success(delivering.response)) return + } + + const completedDeliveries = [] + for (const [index, delivery] of deliveries.entries()) { + const completed = await saveReceiptAndComplete(context, delivery, customerId, index) + if (!completed) return + completedDeliveries.push(completed) + } + context.assert(MODULE, '每个目的FBA仓派送单均由各自签收文件自动完成', null, + completedDeliveries.length === deliveries.length + && completedDeliveries.every((value) => value?.order?.status === 'DELIVERED'), + `deliveries=${deliveryNos.join(',')}, completed=${completedDeliveries.length}`, OPTIONS) + + const removedComplete = await context.requestOverseas('POST', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), + { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` }) + context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete, + removedComplete?.http === 404 || removedComplete?.http === 405 + || (removedComplete?.http === 200 && ['A0404', 'A0405'].includes(code(removedComplete))), + trace(removedComplete), OPTIONS) + + const visits = await platform(context, 'GET', '按派送单回查签收自动生成的客户回访', + query(`${LASTLEG_BASE}/customer-visits`, { page: 1, size: 200, customerId, keyword: deliveryNo })) + const visitRow = Array.isArray(visits.data?.records) + ? visits.data.records.find((value) => id(value?.deliveryNo) === deliveryNo) : null + const visitId = id(visitRow?.id) + context.assert(MODULE, '签收自动完成生成PENDING回访记录', visits.response, + /^\d+$/.test(visitId) && visitRow?.status === 'PENDING', + `visitIdPresent=${/^\d+$/.test(visitId)}; ${trace(visits.response)}`, OPTIONS) + if (!visitId) return + context.createdResources.phase5OverseasFulfillment.visitId = visitId + + const visitDetail = await platform(context, 'GET', '读取本轮客户回访详情', + query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId })) + const inbound = Array.isArray(visitDetail.data?.inboundOrders) + ? visitDetail.data.inboundOrders.find((value) => id(value?.inboundOrderId) && nonBlank(value?.lastMileOrderNo)) : null + context.assert(MODULE, '回访详情包含派送关联的真实入仓单与尾程单', visitDetail.response, + Boolean(inbound), `inboundOption=${Boolean(inbound)}; ${trace(visitDetail.response)}`, OPTIONS) + if (!inbound) return + + const domesticInbound = await poll(async () => context.request('GET', query('/api/platform/inbounds', { + tenantId: customerId, inboundCode: inbound.inboundCode, page: 1, size: 20, + })), (response) => success(response) && Array.isArray(response.body?.data?.records) + && response.body.data.records.some((value) => value?.inboundCode === inbound.inboundCode + && value?.processStatus === 'DELIVERED')) + const inboundSummary = domesticInbound.result?.body?.data?.records?.find( + (value) => value?.inboundCode === inbound.inboundCode) + context.addStep(MODULE, '海外签收完成事件回推头程入仓单DELIVERED', domesticInbound.result, + success(domesticInbound.result) && inboundSummary?.processStatus === 'DELIVERED', + `inboundCode=${inbound.inboundCode}, pollRequests=${domesticInbound.attempts}, processStatus=${inboundSummary?.processStatus || 'missing'}; ${trace(domesticInbound.result)}`, OPTIONS) + const sourceOrderId = id(inboundSummary?.orderId) + if (!sourceOrderId) { + blocker(context, '取得签收对应头程订单', '头程入仓详情缺少来源订单ID', domesticInbound.result) + return + } + const domesticOrder = await poll(async () => context.request('GET', + query(`/api/platform/v1/orders/${encodeURIComponent(sourceOrderId)}`, { tenantId: customerId })), + (response) => success(response) && response.body?.data?.summary?.mainStatus === 'COMPLETED') + context.addStep(MODULE, '同订单全部入仓签收后统一订单进入COMPLETED', domesticOrder.result, + success(domesticOrder.result) && domesticOrder.result?.body?.data?.summary?.mainStatus === 'COMPLETED', + `orderId=${sourceOrderId}, pollRequests=${domesticOrder.attempts}, mainStatus=${domesticOrder.result?.body?.data?.summary?.mainStatus || 'missing'}; ${trace(domesticOrder.result)}`, OPTIONS) + + const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果', + query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), { + selectedInboundOrderId: id(inbound.inboundOrderId), visitResult: 'SATISFIED', + customerFeedback: `${context.marker}-customer-feedback`, remark: `${context.marker}-visit-completed`, attachments: [], + }) + context.assert(MODULE, '客户回访完成并冻结所选业务入仓码', savedVisit.response, + savedVisit.data?.visit?.status === 'COMPLETED' + && savedVisit.data?.visit?.selectedInboundCode === inbound.inboundCode + && savedVisit.data?.visit?.visitResult === 'SATISFIED', trace(savedVisit.response), OPTIONS) + + const manualException = await platform(context, 'POST', '为本轮尾程单创建人工异常', + `${LASTLEG_BASE}/exception-orders`, { + customerId, inboundCode: inbound.inboundCode, lastMileOrderNo: inbound.lastMileOrderNo, + exceptionType: 'DAMAGED', reason: `${context.marker}-manual-exception`, packageCount: 1, skuCode: null, + }) + const manualExceptionNo = id(manualException.data?.exceptionNo) + context.assert(MODULE, '人工异常进入PENDING并返回业务单号', manualException.response, + nonBlank(manualExceptionNo) && manualException.data?.status === 'PENDING', trace(manualException.response), OPTIONS) + context.createdResources.phase5OverseasFulfillment.manualExceptionNo = manualExceptionNo || null + + const returnOptions = await platform(context, 'GET', '查询派送退回可选入仓单', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { customerId })) + const returnInbound = Array.isArray(returnOptions.data) + ? returnOptions.data.find((value) => Array.isArray(value?.returnSources) + && value.returnSources.some((source) => id(source?.outboundPalletItemId) + && id(source?.inventoryPositionId) && Number(source?.maxReturnPackages) > 0)) : null + context.assert(MODULE, '退回候选严格属于当前派送单', returnOptions.response, + id(returnInbound?.inboundOrderId) && returnInbound?.deliveryNo === deliveryNo && returnInbound?.outboundNo === outboundNo, + `candidate=${Boolean(returnInbound)}; ${trace(returnOptions.response)}`, OPTIONS) + if (!returnInbound) return + const returnSource = Array.isArray(returnInbound.returnSources) + ? returnInbound.returnSources.find((value) => id(value?.outboundPalletItemId) + && id(value?.inventoryPositionId) && Number(value?.maxReturnPackages) > 0) : null + context.assert(MODULE, '退回候选包含可追溯的出库托盘库存来源', returnOptions.response, + Boolean(returnSource), `source=${Boolean(returnSource)}; ${trace(returnOptions.response)}`, OPTIONS) + if (!returnSource) return + const returnBody = { + inboundOrderId: id(returnInbound.inboundOrderId), reason: `${context.marker}-delivery-return`, + returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: `${context.marker}-RETURN`.slice(0, 128), + items: [{ + outboundPalletItemId: id(returnSource.outboundPalletItemId), + inventoryPositionId: id(returnSource.inventoryPositionId), + packageCount: 1, + }], + idempotencyKey: key(context, 'DELIVERY-RETURN'), + } + const returned = await platform(context, 'POST', '标记派送退回海外仓并创建退货异常', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, { customerId }), returnBody) + const returnExceptionNo = id(returned.data?.exceptionNo) + context.assert(MODULE, '派送退回生成独立PENDING异常单', returned.response, + nonBlank(returnExceptionNo) && returned.data?.status === 'PENDING' + && returnExceptionNo !== manualExceptionNo, trace(returned.response), OPTIONS) + context.createdResources.phase5OverseasFulfillment.returnExceptionNo = returnExceptionNo || null + + const returnReplay = await platform(context, 'POST', '派送退回同键同载荷重放', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, { customerId }), returnBody) + context.assert(MODULE, '派送退回重放复用同一异常单', returnReplay.response, + returnReplay.data?.replayed === true && id(returnReplay.data?.exceptionNo) === returnExceptionNo, + trace(returnReplay.response), OPTIONS) + + const delivery = await platform(context, 'GET', '回查派送退回终态', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId })) + context.assert(MODULE, '派送退回后单据状态为RETURN_PENDING且保留出库关联', delivery.response, + delivery.data?.order?.status === 'RETURN_PENDING' && delivery.data?.order?.outboundNo === outboundNo, + trace(delivery.response), OPTIONS) + const exceptionPage = await platform(context, 'GET', '分页回查本轮两类异常', + query(`${LASTLEG_BASE}/exception-orders`, { page: 1, size: 20, customerId, keyword: context.marker })) + const exceptionRows = Array.isArray(exceptionPage.data?.records) ? exceptionPage.data.records : [] + context.assert(MODULE, '异常列表同时包含人工异常与派送退回异常', exceptionPage.response, + exceptionRows.some((value) => id(value?.exceptionNo) === manualExceptionNo) + && exceptionRows.some((value) => id(value?.exceptionNo) === returnExceptionNo), + `records=${exceptionRows.length}; ${trace(exceptionPage.response)}`, OPTIONS) + context.createdResources.phase5OverseasFulfillment.finalStatus = 'RETURN_PENDING_WITH_COMPLETED_VISIT' +} diff --git a/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs b/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs new file mode 100644 index 000000000..547e5c866 --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' +import { prepareTerminalDrayage, run } from './phase5-terminal-drayage.mjs' + +const source = await readFile(fileURLToPath(new URL('./phase5-terminal-drayage.mjs', import.meta.url)), 'utf8') + +test('three real command payloads mutate contract fields and preserve remarks and state on conflict', async () => { + const marker = 'offline-terminal' + const containerBookingId = '9223372036854775806' + const containerNo = 'TEST0000001' + const actions = ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'] + const paths = ['fleet', 'pickup-appointment', 'empty-return'] + let state = { containerBookingId, containerNo, version: 0, allowedActions: ['SELECT_FLEET'] } + const history = [] + const commands = new Map() + let conflicts = 0 + let replays = 0 + const response = (data, code = '200') => ({ http: 200, body: { code, data }, requestId: 'offline-request', traceId: 'offline-trace' }) + const producer = { containerBookingId, containerNo, shippingReady: true, + carrierScheduleQueryId: '9223372036854775805', carrierLatestActualAt: '2026-09-09T01:00:00' } + const context = { + marker, token: 'offline-only', + createdResources: { + phase4InboundProducerFixtures: { marker, containers: [producer] }, + phase4OverseasPda: { marker, finalStatus: 'COMPLETED', completedContainerFacts: [{ containerBookingId, containerNo }] }, + }, + apiSuccess(_module, name, result) { assert.equal(result.body.code, '200', name); return result.body.data }, + assert(_module, name, _response, passed) { assert.ok(passed, name) }, + addStep(_module, name, _response, passed) { assert.ok(passed, name) }, + addCoverageGap() { assert.fail('unexpected missing fixture') }, + async request(method, path, body, _token, headers) { + if (path.includes('/suppliers?')) return response({ records: [{ id: '9223372036854775804', name: 'Offline fleet', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }] }) + if (method === 'GET') { + if (path.endsWith('/history')) return response(history) + if (path.includes('?containerBookingIds=')) return response([state]) + return response(state) + } + const actionPath = path.split('/').at(-1) + const index = paths.indexOf(actionPath) + assert.ok(index >= 0) + const field = index < 2 ? 'reason' : 'remark' + const forbidden = index < 2 ? 'remark' : 'reason' + assert.equal(Object.hasOwn(body, forbidden), false, `${actionPath} must not send ${forbidden}`) + const key = headers['Idempotency-Key'] + assert.match(key, /^[A-Za-z0-9:_-]+$/) + assert.ok(key.endsWith(`terminal-${actionPath}`)) + const saved = commands.get(key) + if (saved) { + if (JSON.stringify(saved.body) === JSON.stringify(body)) { replays++; return response(saved.state) } + assert.deepEqual(body, { ...saved.body, [field]: `${marker}-DIFFERENT` }) + conflicts++ + return response(null, 'A0409') + } + assert.equal(body[field], marker) + assert.equal(body.expectedVersion, state.version) + state = { ...state, version: state.version + 1 } + if (index === 0) Object.assign(state, { fleetSupplierId: body.fleetSupplierId, fleetSupplierName: body.fleetSupplierName, allowedActions: ['SCHEDULE_PICKUP'] }) + if (index === 1) state.pickupAppointmentAt = body.pickupAppointmentAt + if (index === 2) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } }) + history.push({ action: actions[index], fromVersion: index, toVersion: index + 1, reason: body[field] }) + commands.set(key, { body: structuredClone(body), state: structuredClone(state) }) + return response(state) + }, + } + await prepareTerminalDrayage(context, producer) + state = { ...state, status: 'UNLOADED', unloadingCompletedAt: '2026-09-09T04:00:00', actualPickupAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] } + await run(context) + assert.equal(commands.size, 3) + assert.equal(replays, 3) + assert.equal(conflicts, 3) + assert.equal(state.version, 3) + assert.equal(context.createdResources.phase5TerminalDrayage.finalStatus, 'EMPTY_RETURNED') +}) + +test('terminal drayage prepares pickup before unloading and finalizes return before carrier validation', () => { + const modules = normalizeModuleOrder(['phase4-carrier-nodes']) + const pickup = modules.indexOf('phase4-carrier-pickup-ready') + const pda = modules.indexOf('phase4-overseas-pda') + const drayage = modules.indexOf('phase5-terminal-drayage') + const finalCarrier = modules.indexOf('phase4-carrier-nodes') + assert.ok(pickup >= 0 && pickup < pda) + assert.ok(pda < drayage && drayage < finalCarrier) + assert.match(source, /attempt <= 30/) + assert.match(source, /initialData\?\.status === 'UNLOADED'/) +}) + +test('module uses only public business endpoints and reuses public trucking master data', () => { + for (const path of [ + '/api/platform/v1/terminal-drayages', "'fleet'", "'pickup-appointment'", "'empty-return'", '/history', + 'category=TRUCKING', + ]) assert.ok(source.includes(path), `missing ${path}`) + assert.doesNotMatch(source, /\/internal\//) + assert.doesNotMatch(source, /POST[^\n]*\/api\/platform\/v1\/suppliers/) + assert.match(source, /脚本不临时创建主数据/) +}) + +test('all three commands cover idempotent replay hash conflict and lifecycle semantics', () => { + for (const token of [ + '选择车队', '预约提柜', '确认实际还柜', + '同键重放', '同键异载荷返回A0409', 'UNLOADED', 'EMPTY_RETURNED', + 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED', + ]) assert.ok(source.includes(token), `missing ${token}`) + assert.match(source, /JSON\.stringify\(replay\.data\) === JSON\.stringify\(first\.data\)/) + assert.match(source, /terminal-\$\{path\}.*replace\(\/\[\^A-Za-z0-9:_-\]\//) + assert.doesNotMatch(source, /terminal-\$\{action\}/) +}) + +test('target is strictly current-marker pickup and completed-PDA container', () => { + assert.match(source, /matrix\?\.marker !== context\.marker/) + assert.match(source, /prepared\?\.marker !== context\.marker/) + assert.match(source, /pda\?\.finalStatus !== 'COMPLETED'/) + assert.match(source, /completedContainerFacts/) + assert.match(source, /producer\?\.shippingReady !== true/) + assert.match(source, /phase4CarrierPickupReady =/) + assert.match(source, /禁止借历史货柜/) +}) diff --git a/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs new file mode 100644 index 000000000..0405d1c68 --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs @@ -0,0 +1,187 @@ +const MODULE = 'phase5-terminal-drayage' +const SCENARIO = 'phase5-terminal-drayage-lifecycle' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const BASE = '/api/platform/v1/terminal-drayages' + +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) } +function success(response) { return response?.http === 200 && codeOf(response) === '200' } +function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') } +function localIso(value) { return value.toISOString().slice(0, 19) } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +async function api(context, method, name, path, body, headers = {}) { + const response = await context.request(method, path, body, context.token, headers) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} +function blocker(context, name, evidence, response = null) { + context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS) + context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true }) +} +function completedTarget(context) { + const prepared = context.createdResources?.phase5TerminalDrayage?.prepared + const pda = context.createdResources?.phase4OverseasPda + if (prepared?.marker !== context.marker || pda?.marker !== context.marker + || pda?.finalStatus !== 'COMPLETED') return null + const completed = (pda.completedContainerFacts || []).find((item) => + id(item?.containerBookingId) === id(prepared.containerBookingId) + && id(item?.containerNo) === id(prepared.containerNo)) + return completed ? { ...prepared, pda, completed } : null +} +async function command(context, target, action, path, body) { + const key = `${context.marker}-terminal-${path}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 128) + const first = await api(context, 'POST', `目的港码头拖车${action}`, `${BASE}/${target.containerBookingId}/${path}`, + body, { 'Idempotency-Key': key }) + if (!success(first.response)) return first + const replay = await api(context, 'POST', `目的港码头拖车${action}同键重放`, + `${BASE}/${target.containerBookingId}/${path}`, body, { 'Idempotency-Key': key }) + context.assert(MODULE, `${action}重放状态和版本零漂移`, replay.response, + JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + const changed = path === 'empty-return' ? { ...body, remark: `${body.remark}-DIFFERENT` } + : { ...body, reason: `${body.reason}-DIFFERENT` } + const hashConflict = await context.request('POST', `${BASE}/${target.containerBookingId}/${path}`, + changed, context.token, { 'Idempotency-Key': key }) + context.addStep(MODULE, `${action}同键异载荷返回A0409`, hashConflict, + conflict(hashConflict), trace(hashConflict), OPTIONS) + const afterConflict = await api(context, 'GET', `${action}冲突后回读聚合`, `${BASE}/${target.containerBookingId}`) + context.assert(MODULE, `${action}冲突后聚合零漂移`, afterConflict.response, + JSON.stringify(afterConflict.data) === JSON.stringify(first.data), trace(afterConflict.response), OPTIONS) + return first +} + +export async function prepareTerminalDrayage(context, producer) { + const matrix = context.createdResources?.phase4InboundProducerFixtures + if (matrix?.marker !== context.marker || !producer || producer?.shippingReady !== true + || !id(producer?.containerBookingId) || !id(producer?.containerNo) + || !id(producer?.carrierScheduleQueryId) || !producer?.carrierLatestActualAt) { + blocker(context, '取得本轮PICKUP_READY货柜执行码头提柜前置', + `matrix=${matrix?.marker || 'missing'}, container=${id(producer?.containerNo) || 'missing'}; 禁止借历史货柜`) + return null + } + const resource = context.createdResources.phase5TerminalDrayage || { + marker: context.marker, containerBookingId: null, containerNo: null, finalStatus: null, + cleanup: 'Terminal drayage ends at immutable EMPTY_RETURNED and is retained as acceptance evidence', + } + context.createdResources.phase5TerminalDrayage = resource + const target = { marker: context.marker, producer, containerBookingId: id(producer.containerBookingId), + containerNo: id(producer.containerNo) } + const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}` + const initial = await api(context, 'GET', '卸柜前读取目的港码头拖车初始聚合', path) + context.assert(MODULE, 'PICKUP_READY货柜允许选择车队', initial.response, + id(initial.data?.containerBookingId) === target.containerBookingId + && id(initial.data?.containerNo) === target.containerNo + && Number(initial.data?.version) === 0 && initial.data?.allowedActions?.includes('SELECT_FLEET'), + `status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS) + if (!success(initial.response) || !initial.data?.allowedActions?.includes('SELECT_FLEET')) return null + + const suppliers = await api(context, 'GET', '查询ACTIVE码头拖车供应商', + '/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&truckingScope=OVERSEAS&page=1&size=100') + const fleet = suppliers.data?.records?.find((item) => id(item?.id) && item?.status === 'ACTIVE' && item?.category === 'TRUCKING' && item?.truckingScope === 'OVERSEAS') + if (!fleet) { + blocker(context, '取得有效码头拖车供应商', '没有可复用的ACTIVE + TRUCKING + OVERSEAS公共供应商;脚本不临时创建主数据', suppliers.response) + return null + } + const selected = await command(context, target, '选择车队', 'fleet', { + expectedVersion: Number(initial.data.version), fleetSupplierId: id(fleet.id), + fleetSupplierName: fleet.name, reason: context.marker, + }) + context.assert(MODULE, '选车队后只绑定公共供应商并开放预约', selected.response, + id(selected.data?.fleetSupplierId) === id(fleet.id) && selected.data?.fleetSupplierName === fleet.name + && selected.data?.allowedActions?.includes('SCHEDULE_PICKUP'), trace(selected.response), OPTIONS) + if (!success(selected.response)) return null + + const latestActual = Date.parse(`${producer.carrierLatestActualAt}Z`) + const appointmentAt = localIso(new Date((Number.isNaN(latestActual) ? Date.now() : latestActual) + 60 * 60_000)) + const scheduled = await command(context, target, '预约提柜', 'pickup-appointment', { + expectedVersion: Number(selected.data.version), pickupAppointmentAt: appointmentAt, reason: context.marker, + }) + context.assert(MODULE, '预约提柜时间和版本持久化', scheduled.response, + scheduled.data?.pickupAppointmentAt === appointmentAt + && Number(scheduled.data?.version) === Number(selected.data.version) + 1, + trace(scheduled.response), OPTIONS) + if (!success(scheduled.response)) return null + + resource.prepared = { ...target, appointmentAt, scheduledVersion: Number(scheduled.data.version) } + resource.containerBookingId = target.containerBookingId + resource.containerNo = target.containerNo + return resource.prepared +} + +export async function run(context) { + const resource = context.createdResources.phase5TerminalDrayage || { + marker: context.marker, containerBookingId: null, containerNo: null, finalStatus: null, + cleanup: 'Terminal drayage ends at immutable EMPTY_RETURNED and is retained as acceptance evidence', + } + context.createdResources.phase5TerminalDrayage = resource + const target = completedTarget(context) + if (!target) { + blocker(context, '取得本轮已提柜且已卸柜货柜', + `prepared=${resource.prepared?.marker || 'missing'}, pda=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}; 禁止借历史货柜`) + return + } + const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}` + let initialResponse = null + let initialData = null + let pollRequests = 0 + for (let attempt = 1; attempt <= 30; attempt += 1) { + pollRequests = attempt + initialResponse = await context.request('GET', path, undefined, context.token) + initialData = success(initialResponse) ? initialResponse.body?.data : null + if (initialData?.status === 'UNLOADED' + && initialData?.allowedActions?.includes('RECORD_EMPTY_RETURN')) break + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + const initial = { + response: initialResponse, + data: context.apiSuccess(MODULE, '卸柜后等待跨区事件并读取目的港码头拖车聚合', initialResponse, + `pollRequests=${pollRequests}; ${trace(initialResponse)}`, OPTIONS), + } + context.assert(MODULE, '卸柜聚合保留提柜事实并开放还柜', initial.response, + id(initial.data?.containerBookingId) === target.containerBookingId + && id(initial.data?.containerNo) === id(target.completed.containerNo) + && Number(initial.data?.version) === Number(target.scheduledVersion) + && initial.data?.status === 'UNLOADED' + && initial.data?.actualPickupAt === initial.data?.unloadingCompletedAt + && initial.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'), + `pollRequests=${pollRequests}, status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS) + if (!success(initial.response)) return + + const unloadingAt = Date.parse(`${initial.data?.unloadingCompletedAt}Z`) + const returnBase = Number.isNaN(unloadingAt) ? Date.now() : unloadingAt + const returnedAt = localIso(new Date(returnBase + 60 * 60_000)) + const returned = await command(context, target, '确认实际还柜', 'empty-return', { + expectedVersion: Number(initial.data.version), actualAt: returnedAt, remark: context.marker, + }) + context.assert(MODULE, '实际还柜同步EMPTY_RETURNED并关闭全部动作', returned.response, + returned.data?.status === 'EMPTY_RETURNED' && returned.data?.actualEmptyReturnAt === returnedAt + && Array.isArray(returned.data?.allowedActions) && returned.data.allowedActions.length === 0 + && returned.data?.emptyReturnWarning?.status === 'RESOLVED', trace(returned.response), OPTIONS) + if (!success(returned.response)) return + + const list = await api(context, 'GET', '批量回读本轮码头拖车聚合', + `${BASE}?containerBookingIds=${encodeURIComponent(target.containerBookingId)}`) + context.assert(MODULE, '批量读取只命中本轮货柜终态', list.response, + Array.isArray(list.data) && list.data.length === 1 + && id(list.data[0]?.containerBookingId) === target.containerBookingId + && list.data[0]?.status === 'EMPTY_RETURNED', trace(list.response), OPTIONS) + const history = await api(context, 'GET', '读取目的港码头拖车不可变历史', `${path}/history`) + const actions = new Set((Array.isArray(history.data) ? history.data : []).map((item) => item?.action)) + context.assert(MODULE, '历史完整记录三个业务动作和连续版本', history.response, + ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action)) + && history.data.every((item) => Number(item?.toVersion) === Number(item?.fromVersion) + 1), + `actions=${[...actions].join(',')}; ${trace(history.response)}`, OPTIONS) + context.assert(MODULE, '还柜备注写入不可变历史', history.response, + history.data?.some((item) => item.action === 'RECORD_EMPTY_RETURN' && item.reason === context.marker), + trace(history.response), OPTIONS) + context.createdResources.phase5TerminalDrayage = { + ...context.createdResources.phase5TerminalDrayage, + containerBookingId: target.containerBookingId, containerNo: target.completed.containerNo, + finalStatus: 'EMPTY_RETURNED', version: Number(returned.data.version), + } + context.createdResources.phase4CarrierPickupReady = { + marker: context.marker, queryId: id(target.producer.carrierScheduleQueryId), + latestActualAt: target.producer.carrierLatestActualAt, ready: true, + } +} 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..140566283 --- /dev/null +++ b/scripts/admin-test-env-modules/pricing-contract.test.mjs @@ -0,0 +1,18 @@ +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.match(source, /sourceName = 'active-price-book-fixture\.xlsx'/) + assert.doesNotMatch(source, /sourceName = activeFixture\.originalFileName/) + 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 new file mode 100644 index 000000000..9dbed5222 --- /dev/null +++ b/scripts/admin-test-env-modules/pricing.mjs @@ -0,0 +1,336 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +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 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', + 'OA_NEW_YORK_TRUCK', 'OA_SAVANNAH_TRUCK', 'MATSON_CLX_PARCEL', + 'MATSON_MAX_PARCEL', 'HEDE_PARCEL', 'OA_PARCEL', +] +const MAPPING_ISSUE_CODES = new Set([ + 'ORIGIN_MAPPING_REQUIRED', + 'ORIGIN_WAREHOUSE_INVALID', + 'OVERSEAS_MAPPING_REQUIRED', + 'OVERSEAS_WAREHOUSE_INVALID', + 'WAREHOUSE_VALIDATION_FAILED', + 'DUPLICATE_ORIGIN_MAPPING', + 'DUPLICATE_OVERSEAS_MAPPING', + 'UNUSED_ORIGIN_MAPPING', + 'UNUSED_OVERSEAS_MAPPING', + 'DUPLICATE_WAREHOUSE_MAPPING', + 'DUPLICATE_RATE', +]) + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function normalize(value) { + return String(value || '').trim().toUpperCase() +} + +function validId(value) { + return typeof value === 'string' && /^[1-9][0-9]*$/.test(value) +} + +function finishScenario(context, module, scenario, start) { + const steps = context.steps.slice(start) + context.scenarios.push({ + name: scenario, + module, + success: steps.every((step) => step.passed), + deploymentBlocked: steps.some((step) => step.deploymentBlocker), + passed: steps.filter((step) => step.passed).length, + failed: steps.filter((step) => !step.passed).length, + }) +} + +async function uploadWorkbook(context, bytes, fileName) { + const form = new FormData() + form.append('file', new Blob([bytes], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), fileName) + try { + const response = await fetch(`${context.options.gateway}${PRICE_BOOK_PATH}`, { + method: 'POST', + headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + body: form, + signal: AbortSignal.timeout(120_000), + }) + const content = await response.text() + let payload = null + try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is diagnostic evidence. */ } + return { + method: 'POST', path: PRICE_BOOK_PATH, http: response.status, body: payload, + raw: payload === null ? content.slice(0, 500) : null, + requestId: payload?.requestId || response.headers.get('x-request-id'), + traceId: payload?.traceId || response.headers.get('x-trace-id'), + } + } catch (error) { + return { + method: 'POST', path: PRICE_BOOK_PATH, http: 0, body: null, + raw: error instanceof Error ? error.message : String(error), + requestId: null, traceId: null, + } + } +} + +function buildMappings(detail, warehouses) { + const domestic = warehouses.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC') + const overseas = warehouses.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'OVERSEAS') + const originSources = new Map() + const overseasSources = new Map() + + for (const rate of detail.rates || []) { + const originCode = String(rate.originWarehouseCode || '').trim() + if (originCode && !originSources.has(normalize(originCode))) originSources.set(normalize(originCode), originCode) + if (rate.destinationType === 'OVERSEAS' && String(rate.destinationName || '').trim()) { + const sourceName = String(rate.destinationName).trim() + const key = normalize(sourceName) + if (!overseasSources.has(key)) overseasSources.set(key, { + sourceName, + destinationCode: String(rate.destinationCode || '').trim(), + }) + } + } + + const originMappings = [] + const missingOrigins = [] + for (const sourceCode of originSources.values()) { + const matches = domestic.filter((warehouse) => normalize(warehouse.warehouseCode) === normalize(sourceCode)) + if (matches.length === 1) originMappings.push({ originWarehouseCode: sourceCode, warehouseId: String(matches[0].id) }) + else missingOrigins.push(`${sourceCode}:${matches.length}`) + } + + const overseasMappings = [] + const missingOverseas = [] + for (const source of overseasSources.values()) { + const matches = overseas.filter((warehouse) => ( + source.destinationCode + ? normalize(warehouse.warehouseCode) === normalize(source.destinationCode) + : normalize(warehouse.warehouseName) === normalize(source.sourceName) + )) + if (matches.length === 1) overseasMappings.push({ sourceDestinationName: source.sourceName, warehouseId: String(matches[0].id) }) + else missingOverseas.push(`${source.sourceName}/${source.destinationCode || '-'}:${matches.length}`) + } + return { originMappings, overseasMappings, missingOrigins, missingOverseas, originSources, overseasSources } +} + +export async function run(context) { + const module = 'pricing' + const scenario = 'price-book-lifecycle' + const start = context.steps.length + const blocker = { scenario, deploymentBlocker: true } + + const listBeforeResponse = await context.request('GET', PRICE_BOOK_PATH) + const versionsBefore = context.apiSuccess(module, '查询发布前船期价格版本', listBeforeResponse, '', blocker) + const activeBefore = Array.isArray(versionsBefore) ? versionsBefore.filter((version) => version.status === 'ACTIVE') : [] + context.assert(module, '发布前最多一份生效版本', listBeforeResponse, + 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 + let sourceBytes + 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 + // The active version may itself have been created by an earlier acceptance run. + // Reusing its original name would prepend a new marker on every run until the + // storage filename limit is exceeded. Keep the downloaded workbook content, + // but give each new isolated upload a stable, bounded fixture basename. + sourceName = 'active-price-book-fixture.xlsx' + } + const sourceHash = sha256(sourceBytes) + 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) + + const uploadResponse = await uploadWorkbook(context, sourceBytes, uploadName) + const uploaded = context.apiSuccess(module, '上传并解析船期价格表', uploadResponse, + `fileName=${uploadName}, bytes=${sourceBytes.length}, sha256=${sourceHash}`, blocker) + if (!uploaded?.version) { + finishScenario(context, module, scenario, start) + return + } + + const versionId = String(uploaded.version.id || '') + context.createdResources.priceBookVersion = { versionId, marker: context.marker, sourceSha256: sourceHash } + context.assert(module, '上传返回隔离的待确认版本', uploadResponse, + validId(versionId) + && uploaded.version.status === 'PENDING_CONFIRMATION' + && uploaded.version.originalFileName === uploadName + && uploaded.version.sourceSha256 === sourceHash, + `versionId=${versionId}, status=${uploaded.version.status}, fileName=${uploaded.version.originalFileName}, shaMatched=${uploaded.version.sourceSha256 === sourceHash}`, blocker) + + const channels = Array.isArray(uploaded.channels) ? uploaded.channels : [] + const rates = Array.isArray(uploaded.rates) ? uploaded.rates : [] + const issues = Array.isArray(uploaded.issues) ? uploaded.issues : [] + const channelCodes = channels.map((channel) => String(channel.channelCode)) + const channelSet = new Set(channelCodes) + const expectedChannels = EXPECTED_CHANNEL_CODES.every((code) => channelSet.has(code)) + context.assert(module, '解析出15个固定渠道且编码唯一', uploadResponse, + channels.length === EXPECTED_CHANNEL_CODES.length && channelSet.size === channels.length && expectedChannels + && Number(uploaded.version.channelCount) === channels.length, + `channelCount=${channels.length}, unique=${channelSet.size}, missing=${EXPECTED_CHANNEL_CODES.filter((code) => !channelSet.has(code)).join(',') || 'none'}`, blocker) + const ratesValid = rates.length > 0 && rates.every((rate) => ( + channelSet.has(String(rate.channelCode)) + && ['FBA_WAREHOUSE_RATE', 'REGION_WEIGHT_TIER'].includes(String(rate.priceModel)) + && String(rate.originWarehouseCode || '').trim() + && ['KG', 'CBM'].includes(String(rate.chargeUnit)) + && Number(rate.unitPrice) > 0 + && /^[A-Z]{3}$/.test(String(rate.currency || '')) + && String(rate.sourceSheetName || '').trim() + && Number(rate.sourceRowNumber) >= 1 + )) + context.assert(module, '解析价格行完整且可追溯到 Excel', uploadResponse, + ratesValid && Number(uploaded.version.rateCount) === rates.length, + `rateCount=${rates.length}, declared=${uploaded.version.rateCount}, valid=${ratesValid}`, blocker) + + const blockingIssues = issues.filter((issue) => issue.severity === 'BLOCKING') + const unexpectedBlocking = blockingIssues.filter((issue) => !MAPPING_ISSUE_CODES.has(issue.issueCode)) + const infrastructureIssue = issues.find((issue) => issue.issueCode === 'UPLOAD_OR_PARSE_FAILED' + || /SQLSyntaxError|bad SQL grammar|Service Unavailable/i.test(String(issue.message || ''))) + context.assert(module, '解析阻断仅包含可确认的仓库映射问题', uploadResponse, + !infrastructureIssue && unexpectedBlocking.length === 0 + && Number(uploaded.version.blockingErrorCount) === blockingIssues.length, + `blocking=${blockingIssues.length}, unexpected=${[...new Set(unexpectedBlocking.map((issue) => issue.issueCode))].join(',') || 'none'}, infrastructure=${infrastructureIssue?.issueCode || 'none'}`, blocker) + + const listPendingResponse = await context.request('GET', PRICE_BOOK_PATH) + const versionsPending = context.apiSuccess(module, '上传后回查价格版本列表', listPendingResponse, '', blocker) + const pendingEntry = versionsPending?.find?.((version) => String(version.id) === versionId) + const activeAfterUpload = versionsPending?.filter?.((version) => version.status === 'ACTIVE') || [] + context.assert(module, '上传只生成待确认版本且不替换当前生效版本', listPendingResponse, + pendingEntry?.status === 'PENDING_CONFIRMATION' + && activeAfterUpload.length === activeBefore.length + && activeBefore.every((before) => activeAfterUpload.some((after) => String(after.id) === String(before.id))), + `pending=${pendingEntry?.status || 'missing'}, activeBefore=${activeBefore.map((item) => item.id).join(',') || 'none'}, activeAfterUpload=${activeAfterUpload.map((item) => item.id).join(',') || 'none'}`, blocker) + + const detailResponse = await context.request('GET', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}`) + const detail = context.apiSuccess(module, '查询待确认版本渠道和价格预览', detailResponse, '', blocker) + context.assert(module, '详情与上传解析结果一致', detailResponse, + String(detail?.version?.id) === versionId + && detail?.version?.status === 'PENDING_CONFIRMATION' + && detail?.channels?.length === channels.length + && detail?.rates?.length === rates.length + && detail?.issues?.length === issues.length, + `versionId=${detail?.version?.id || 'missing'}, channels=${detail?.channels?.length ?? 'n/a'}, rates=${detail?.rates?.length ?? 'n/a'}, issues=${detail?.issues?.length ?? 'n/a'}`, blocker) + + const sourceResponse = await context.requestBinary('GET', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}/source`) + const downloadedHash = sourceResponse.bytes?.length ? sha256(sourceResponse.bytes) : '' + const zipMagic = sourceResponse.bytes?.[0] === 0x50 && sourceResponse.bytes?.[1] === 0x4b + context.addStep(module, '下载原始 XLSX 且内容散列一致', sourceResponse, + sourceResponse.http === 200 && sourceResponse.bytes?.length === sourceBytes.length + && zipMagic && downloadedHash === sourceHash + && !sourceResponse.contentType.toLowerCase().includes('json'), + `bytes=${sourceResponse.bytes?.length ?? 0}, contentType=${sourceResponse.contentType || 'missing'}, zipMagic=${zipMagic}, sha256=${downloadedHash}`, blocker) + + if (!detail || unexpectedBlocking.length > 0 || infrastructureIssue) { + finishScenario(context, module, scenario, start) + return + } + + const domesticResponse = await context.request('GET', DOMESTIC_WAREHOUSE_PATH) + const domesticPage = context.apiSuccess(module, '查询启用的平台公共国内仓', domesticResponse, '', blocker) + const overseasResponse = await context.request('GET', OVERSEAS_WAREHOUSE_PATH) + const overseasPage = context.apiSuccess(module, '查询启用的平台公共海外仓', overseasResponse, '', blocker) + const warehouses = [ + ...(Array.isArray(domesticPage?.records) ? domesticPage.records : []), + ...(Array.isArray(overseasPage?.records) ? overseasPage.records : []), + ] + const mappings = buildMappings(detail, warehouses) + const mappingsComplete = mappings.originSources.size > 0 + && mappings.originMappings.length === mappings.originSources.size + && mappings.overseasMappings.length === mappings.overseasSources.size + && mappings.missingOrigins.length === 0 + && mappings.missingOverseas.length === 0 + context.assert(module, '国内起运仓和目的海外仓均精确映射', overseasResponse, + mappingsComplete, + `origins=${mappings.originMappings.map((item) => `${item.originWarehouseCode}->${item.warehouseId}`).join(',') || 'none'}, overseas=${mappings.overseasMappings.map((item) => `${item.sourceDestinationName}->${item.warehouseId}`).join(',') || 'none'}, missing=${[...mappings.missingOrigins, ...mappings.missingOverseas].join(',') || 'none'}`, blocker) + if (!mappingsComplete) { + finishScenario(context, module, scenario, start) + return + } + + const publishResponse = await context.request('POST', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}/publish`, { + originMappings: mappings.originMappings, + overseasMappings: mappings.overseasMappings, + }) + const published = context.apiSuccess(module, '确认仓库映射并立即生效', publishResponse, + `versionId=${versionId}, originMappings=${mappings.originMappings.length}, overseasMappings=${mappings.overseasMappings.length}`, blocker) + context.assert(module, '发布后价格版本为 ACTIVE 且无阻断', publishResponse, + published?.version?.status === 'ACTIVE' + && Number(published?.version?.blockingErrorCount) === 0 + && Boolean(published?.version?.publishedAt) + && published?.channels?.length === channels.length + && published?.rates?.length === rates.length + && !(published?.issues || []).some((issue) => issue.severity === 'BLOCKING'), + `status=${published?.version?.status || 'missing'}, blocking=${published?.version?.blockingErrorCount ?? 'n/a'}, publishedAt=${published?.version?.publishedAt || 'missing'}, channels=${published?.channels?.length ?? 'n/a'}, rates=${published?.rates?.length ?? 'n/a'}`, blocker) + + const listAfterResponse = await context.request('GET', PRICE_BOOK_PATH) + const versionsAfter = context.apiSuccess(module, '发布后回查当前生效和历史版本', listAfterResponse, '', blocker) + const activeAfter = versionsAfter?.filter?.((version) => version.status === 'ACTIVE') || [] + const oldStatusesCorrect = activeBefore.every((before) => { + if (String(before.id) === versionId) return true + const after = versionsAfter?.find?.((candidate) => String(candidate.id) === String(before.id)) + return after?.status === 'SUPERSEDED' && Boolean(after.supersededAt) + }) + context.assert(module, '始终只有一份当前生效版本', listAfterResponse, + activeAfter.length === 1 && String(activeAfter[0]?.id) === versionId, + `active=${activeAfter.map((item) => item.id).join(',') || 'none'}`, blocker) + context.assert(module, '上一生效版本已保留为历史版本', listAfterResponse, + oldStatusesCorrect, + `previous=${activeBefore.map((item) => item.id).join(',') || 'none'}, superseded=${oldStatusesCorrect}`, blocker) + + const activeDetailResponse = await context.request('GET', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}`) + const activeDetail = context.apiSuccess(module, '查询当前生效价格版本详情', activeDetailResponse, '', blocker) + context.assert(module, '当前生效版本预览数据完整', activeDetailResponse, + activeDetail?.version?.status === 'ACTIVE' + && activeDetail?.channels?.length === channels.length + && activeDetail?.rates?.length === rates.length, + `status=${activeDetail?.version?.status || 'missing'}, channels=${activeDetail?.channels?.length ?? 'n/a'}, rates=${activeDetail?.rates?.length ?? 'n/a'}`, blocker) + + const repeatedPublishResponse = await context.request('POST', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}/publish`, { + originMappings: mappings.originMappings, + overseasMappings: mappings.overseasMappings, + }) + const repeatRejected = repeatedPublishResponse.http === 200 + && String(repeatedPublishResponse.body?.code) !== '200' + context.addStep(module, '已生效版本禁止重复发布', repeatedPublishResponse, repeatRejected, + `HTTP=${repeatedPublishResponse.http}, code=${repeatedPublishResponse.body?.code ?? 'missing'}`, blocker) + + const finalListResponse = await context.request('GET', PRICE_BOOK_PATH) + const finalVersions = context.apiSuccess(module, '重复发布被拒绝后回查版本状态', finalListResponse, '', blocker) + const finalActive = finalVersions?.filter?.((version) => version.status === 'ACTIVE') || [] + context.assert(module, '重复发布未改变唯一生效版本', finalListResponse, + finalActive.length === 1 && String(finalActive[0]?.id) === versionId, + `active=${finalActive.map((item) => item.id).join(',') || 'none'}`, blocker) + + finishScenario(context, module, scenario, start) +} diff --git a/scripts/admin-test-env-modules/push-preload-stability.mjs b/scripts/admin-test-env-modules/push-preload-stability.mjs new file mode 100644 index 000000000..3ab5e5d9f --- /dev/null +++ b/scripts/admin-test-env-modules/push-preload-stability.mjs @@ -0,0 +1,109 @@ +function id(value) { return value == null ? null : String(value) } +function number(value) { return value == null || Number.isNaN(Number(value)) ? null : Number(value) } +function text(value) { return value == null ? null : String(value) } +function bool(value) { return value === true } + +function sorted(values, key) { + return [...(Array.isArray(values) ? values : [])] + .sort((left, right) => key(left).localeCompare(key(right))) +} + +function planItem(item) { + return { + id: id(item?.id), loadingPlanId: id(item?.loadingPlanId), inboundId: id(item?.inboundId), + inboundDetailId: id(item?.inboundDetailId), accountId: id(item?.accountId), orderId: id(item?.orderId), + packageCount: number(item?.packageCount), loadSequence: number(item?.loadSequence), + frozenAt: text(item?.frozenAt), version: number(item?.version), deleted: bool(item?.deleted), + } +} + +function detailItem(item) { + return { + itemId: id(item?.itemId), accountId: id(item?.accountId), inboundId: id(item?.inboundId), + inboundDetailId: id(item?.inboundDetailId), packageCount: number(item?.packageCount), + palletCount: number(item?.palletCount), loadSequence: number(item?.loadSequence), + frozenAt: text(item?.frozenAt), + } +} + +function readiness(value) { + return { + preloadPushed: bool(value?.preloadPushed), containerNoReady: bool(value?.containerNoReady), + preloadLinkCount: number(value?.preloadLinkCount), confirmedPreloadCount: number(value?.confirmedPreloadCount), + oceanBookingId: id(value?.oceanBookingId), oceanBookingStatus: text(value?.oceanBookingStatus), + pdaTaskId: id(value?.pdaTaskId), pdaTaskStatus: text(value?.pdaTaskStatus), + confirmationStatus: text(value?.confirmationStatus), canConfirm: bool(value?.canConfirm), + readyForPda: bool(value?.readyForPda), + blockingReasons: sorted(value?.blockingReasons, (item) => text(item) || '').map(text), + } +} + +function history(value) { + return { + historyId: id(value?.historyId), itemId: id(value?.itemId), actionType: text(value?.actionType), + fromStatus: text(value?.fromStatus), toStatus: text(value?.toStatus), + beforeSnapshot: text(value?.beforeSnapshot), afterSnapshot: text(value?.afterSnapshot), + operationReason: text(value?.operationReason), occurredAt: text(value?.occurredAt), + } +} + +function link(value) { + return { + id: id(value?.id), tenantId: id(value?.tenantId), inboundId: id(value?.inboundId), + preloadPlanId: id(value?.preloadPlanId), preloadStatus: text(value?.preloadStatus), + preloadVersion: number(value?.preloadVersion), loadingPlanId: id(value?.loadingPlanId), + containerBookingId: id(value?.containerBookingId), containerNo: text(value?.containerNo), + loadingPlanStatus: text(value?.loadingPlanStatus), loadingPlanVersion: number(value?.loadingPlanVersion), + allocatedPackageCount: number(value?.allocatedPackageCount), linkedAt: text(value?.linkedAt), + } +} + +export function projectPushPreloadStability({ plan, detail, links, task }) { + const summary = detail?.summary || {} + return { + plan: { + id: id(plan?.id), bookingId: id(plan?.bookingId), status: text(plan?.status), + containerNo: text(plan?.containerNo), voyageNo: text(plan?.voyageNo), version: number(plan?.version), + items: sorted(plan?.items, (item) => id(item?.id) || '').map(planItem), + }, + detail: { + summary: { + planId: id(summary?.planId), containerBookingId: id(summary?.containerBookingId), + containerNo: text(summary?.containerNo), cabinetNo: text(summary?.cabinetNo), + status: text(summary?.status), version: number(summary?.version), + skuCount: number(summary?.skuCount), totalPackageCount: number(summary?.totalPackageCount), + readiness: readiness(summary?.readiness), + }, + items: sorted(detail?.items, (item) => id(item?.itemId) || '').map(detailItem), + histories: sorted(detail?.histories, + (item) => `${id(item?.historyId) || ''}:${text(item?.occurredAt) || ''}`).map(history), + }, + links: sorted(links, (item) => id(item?.id) || '').map(link), + task: { http: number(task?.http), code: text(task?.code) }, + } +} + +function display(value) { + if (value === undefined) return 'missing' + return JSON.stringify(value) +} + +export function diffPushPreloadStability(before, after) { + const changes = [] + function visit(left, right, path) { + if (Object.is(left, right)) return + if (Array.isArray(left) && Array.isArray(right)) { + const length = Math.max(left.length, right.length) + for (let index = 0; index < length; index += 1) visit(left[index], right[index], `${path}[${index}]`) + return + } + if (left && right && typeof left === 'object' && typeof right === 'object') { + const keys = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort() + for (const key of keys) visit(left[key], right[key], path ? `${path}.${key}` : key) + return + } + changes.push(`${path}:${display(left)}->${display(right)}`) + } + visit(before, after, '') + return changes +} diff --git a/scripts/admin-test-env-modules/resource-allocation-recent-contract.test.mjs b/scripts/admin-test-env-modules/resource-allocation-recent-contract.test.mjs new file mode 100644 index 000000000..d93f08f2c --- /dev/null +++ b/scripts/admin-test-env-modules/resource-allocation-recent-contract.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' + +function source(name) { + return readFileSync(new URL(name, import.meta.url), 'utf8') +} + +test('measurement HTTP acceptance preserves whole-ticket slot identities across confirmations', () => { + const measurement = source('./inbound-measurement.mjs') + assert.match(measurement, /逐张确认后回查整票库位块/) + assert.match(measurement, /扩容只追加且既有槽身份不漂移/) + assert.match(measurement, /asId\(verified\.guide\?\.blockId\) === previous\.blockId/) + assert.match(measurement, /verified\.slots\.length >= previous\.slots\.length && priorSlotsStable/) + assert.match(measurement, /asId\(current\.reservationId\) === asId\(prior\.reservationId\)/) + assert.match(measurement, /asId\(current\.locationId\) === asId\(prior\.locationId\)/) +}) + +test('smart-loading HTTP acceptance covers by-booking reads and bidirectional quantity changes', () => { + const loading = source('./phase3-loading.mjs') + assert.match(loading, /计划详情由服务端返回权威总箱数和剩余箱数/) + assert.match(loading, /authoritativeRemaining === authoritativeTotal - assignedPackageCount/) + assert.match(loading, /计划已有明细后按柜次读取智能装柜详情/) + assert.match(loading, /按柜次详情返回同一计划及权威箱数且数量守恒/) + assert.match(loading, /packageCount: authoritativeTotal \+ 1/) + assert.match(loading, /调整箱数超过服务端权威总箱数返回A0422/) + assert.match(loading, /未拣动态增量明细允许向下调整数量/) + assert.match(loading, /Number\(decreasedItem\?\.remainingPackageCount\) === authoritativeTotal - 1/) + assert.match(loading, /向下调整验证后恢复两箱供已拣下限校验/) + assert.match(loading, /packageCount: Number\(pickedDynamicPlanItem\?\.minPackageCount\) - 1/) + assert.match(loading, /Number\(afterOverAuthority\?\.version\) === Number\(reassignedPlan\?\.version\)/) + assert.match(loading, /Number\(afterOverAuthorityItem\?\.remainingPackageCount\) === authoritativeRemaining/) +}) diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs new file mode 100644 index 000000000..18f427499 --- /dev/null +++ b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs @@ -0,0 +1,133 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { rejected, classifyRace, stableState, verifyNoMutation, exactReceiptLedger } from './return-boundary-scenarios.mjs' +import { run as createMerchant } from './merchant-qualification-fixture.mjs' + +const response = (code = '200', http = 200) => ({ http, body: { code }, requestId: 'request', traceId: 'trace' }) + +test('拒绝断言只接受明确业务错误,不把网络故障、越权、成功当成参数拒绝', () => { + for (const code of ['A0400', 'A0422']) assert.equal(rejected(response(code), [400, 422]), true) + for (const code of ['200', 'A0403', 'A0409', 'S0500', 'S0503']) assert.equal(rejected(response(code), [400, 422]), false) + for (const http of [0, 401, 403, 500, 502, 503]) assert.equal(rejected(response('error', http), [400, 422]), false) + assert.equal(rejected({ ...response('A0409'), traceId: '' }, [409]), false) +}) + +test('并发判定区分双成功、双失败、服务故障与唯一成功', () => { + assert.deepEqual(classifyRace([response(), response('A0409')]), { successes: 1, conflicts: 1, unexpected: 0 }) + assert.deepEqual(classifyRace([response(), response()]), { successes: 2, conflicts: 0, unexpected: 0 }) + assert.deepEqual(classifyRace([response('A0409'), response('A0409')]), { successes: 0, conflicts: 2, unexpected: 0 }) + assert.deepEqual(classifyRace([response(), response('S0500', 500)]), { successes: 1, conflicts: 0, unexpected: 1 }) +}) + +test('库存快照保留订单、批次、SKU、来源与状态变化,仅忽略排序及展示时间', () => { + const baseline = [{ returnNo: 'R1', receiptBatchNo: 'B1', quantity: 2, status: 'AVAILABLE', skuTypes: 1, items: [{ skuCode: 'A', quantity: 2 }], updatedAt: 'old' }] + assert.deepEqual(stableState(baseline), stableState([{ ...baseline[0], updatedAt: 'new' }])) + for (const change of [{ returnNo: 'R2' }, { receiptBatchNo: 'B2' }, { quantity: 1 }, { status: 'MERGED' }, { skuTypes: 0 }, { items: [] }]) { + assert.notDeepEqual(stableState(baseline), stableState([{ ...baseline[0], ...change }])) + } +}) + +test('每个失败请求都立即前后回查,即使接口错误类型不符也检查落账', async () => { + for (const code of ['A0409', '200', 'S0500']) { + let quantity = 2 + const events = [], checks = [] + await verifyNoMutation({ name: '拒绝操作', snapshot: async () => { events.push('snapshot'); return { quantity } }, + action: async () => { events.push('action'); quantity -= 1; return response(code) }, + expected: result => rejected(result, [409]), check: (name, result, passed) => checks.push(passed) }) + assert.deepEqual(events, ['snapshot', 'action', 'snapshot']) + assert.deepEqual(checks, [code === 'A0409', false]) + } +}) + +test('缺少快照不得执行危险动作或伪造未落账通过', async () => { + let called = false + await assert.rejects(verifyNoMutation({ snapshot: async () => { throw new Error('snapshot unavailable') }, + action: async () => { called = true }, check: () => {}, expected: () => true }), /snapshot unavailable/) + assert.equal(called, false) +}) + +test('扩展由退货主模块调用且不进入换标出库,保留各并发响应诊断及清理', async () => { + const [source, main] = await Promise.all(['return-boundary-scenarios.mjs', 'phase4-overseas-return-receipt.mjs'] + .map(file => readFile(new URL(file, import.meta.url), 'utf8'))) + assert.match(main, /await runReturnBoundaries\(context, \{ merchantToken, warehouse, picker, access \}\)/) + for (const name of ['跨客户隔离', '跨仓隔离', '并发签收', '并发合箱', '多单全量合箱与来源追溯', '多候选与输入边界']) assert.ok(source.includes(`scenario('${name}'`)) + assert.match(source, /recordRace\('并发签收'/) + assert.match(source, /recordRace\('并发合箱'/) + assert.match(source, /recordRace\('并发部分合箱'/) + assert.match(source, /concurrencyRounds: 3/) + assert.match(source, /publishEnvironment: false/) + assert.match(source, /finally[\s\S]*disablePicker/) + assert.match(source, /executed: false, passed: false/) + assert.doesNotMatch(source, /\bfetch\s*\(|process\.env|relabel-applications|outbound-orders|outbound-tasks|\b(?:INSERT|UPDATE|SELECT)\s/i) +}) + +test('第二客户准备运行真实fixture编排但不替换主客户内存与环境凭据', async () => { + const names = ['MERCHANT_TEST_USERNAME', 'MERCHANT_TEST_PASSWORD'] + const saved = names.map(name => process.env[name]) + try { + process.env.MERCHANT_TEST_USERNAME = 'primary-user' + process.env.MERCHANT_TEST_PASSWORD = 'primary-placeholder' + const primary = { context: { merchantQualificationFixture: { tenantId: 'A' } }, createdResources: {} } + const secondary = Object.create(primary) + Object.assign(secondary, { marker: 'OFFLINE-20260915-B', options: {}, context: { ...primary.context }, createdResources: {}, + findAvailablePhone: async () => '00000000000', + assert: (module, name, result, condition) => assert.ok(condition, name), + apiSuccess: (module, name, result) => { assert.equal(result.body.code, '200', name); return result.body.data }, + requestWithBody: async () => ({ body: { code: '200', data: {} } }), + request: async (method, path) => { + let data + if (path.endsWith('/iam/tenants')) data = { id: 'B', administratorUsername: 'secondary-user', temporaryPassword: 'secondary-placeholder' } + else if (path.endsWith('/submit-review')) data = { status: 'PENDING' } + else if (path.endsWith('/iam/reviews')) data = [{ id: 'review-B', tenantId: 'B' }] + else if (path.endsWith('/dimension-initialization')) data = { status: 'READY' } + else if (path.endsWith('/approve')) data = {} + else if (path.endsWith('/qualifications')) data = ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'].map(qualificationType => ({ qualificationType, status: 'APPROVED' })) + else throw new Error(`Unexpected fixture request: ${method} ${path}`) + return { http: 200, body: { code: '200', data } } + } }) + await createMerchant(secondary, { publishEnvironment: false }) + assert.equal(primary.context.merchantQualificationFixture.tenantId, 'A') + assert.equal(secondary.context.merchantQualificationFixture.tenantId, 'B') + assert.equal(process.env.MERCHANT_TEST_USERNAME, 'primary-user') + assert.equal(process.env.MERCHANT_TEST_PASSWORD, 'primary-placeholder') + assert.ok(!JSON.stringify(secondary.createdResources).includes('secondary-placeholder')) + await createMerchant(secondary) + assert.equal(process.env.MERCHANT_TEST_USERNAME, 'secondary-user') + } finally { + names.forEach((name, index) => { if (saved[index] === undefined) delete process.env[name]; else process.env[name] = saved[index] }) + } +}) + +test('流水断言逐行核对归属和业务引用,不能仅凭合计相等通过', () => { + const a = { customerId: 'A', overseasWarehouseId: 'W1', skuCode: 'SKU', movementType: 'RECEIPT', referenceNo: 'RR-1', totalDelta: 2, availableDelta: 2 } + const b = { ...a, referenceNo: 'RR-2', totalDelta: 1, availableDelta: 1 } + assert.equal(exactReceiptLedger({ total: 2, records: [b, a] }, [a, b]), true) + for (const change of [{ customerId: 'B' }, { overseasWarehouseId: 'W2' }, { skuCode: 'OTHER' }, + { referenceNo: 'RR-1' }, { movementType: 'MERGE' }, { totalDelta: 2 }, { availableDelta: 0 }]) { + assert.equal(exactReceiptLedger({ total: 2, records: [a, { ...b, ...change }] }, [a, b]), false) + } + assert.equal(exactReceiptLedger({ total: 3, records: [a, b] }, [a, b]), false) + assert.equal(exactReceiptLedger({ total: 0, records: [] }, []), true) + assert.equal(exactReceiptLedger(undefined, []), false) +}) + +test('真实退货验收检查五类完整编号且保留同键重放检查', async () => { + const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8') + for (const prefix of ['RT', 'RETU', 'RR', 'RB', 'RC']) { + assert.ok(source.includes(`/^${prefix}-[0-9A-Z]{25}$/`), prefix) + } + assert.ok(source.includes('同键并发合箱后重试仍返回原箱')) + assert.ok(source.includes('签收重放不重复产生流水')) +}) + +test('高价值扩展覆盖混合回滚、同键合箱、同码隔离、权限和流水且仍逐次回查', async () => { + const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8') + for (const name of ['混合明细整笔回滚', '同键并发合箱', '相同SKU跨客户跨仓隔离', '身份入口权限', '签收流水与余额核对']) { + assert.ok(source.includes(`scenario('${name}'`), name) + } + for (const evidence of ['后项超收', '后项未预报', '后项超库存', '后项不存在', 'additionalSummaries', + '同键并发合箱复用同一目标箱', '同码A签收不改变B客户或异仓数据', '无登录', '超收失败不留下流水']) assert.ok(source.includes(evidence), evidence) + assert.match(source, /snapshot: withLedger[\s\S]*签收重放不重复产生流水/) + assert.match(source, /sameKeyResponses/) +}) diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs new file mode 100644 index 000000000..8b935b53f --- /dev/null +++ b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs @@ -0,0 +1,416 @@ +import { run as createMerchant } from './merchant-qualification-fixture.mjs' +import { createPicker, disablePicker } from './phase4-overseas-pda.mjs' + +const MODULE = 'phase4-overseas-return-receipt' +const OPTIONS = { scenario: 'return-isolation-concurrency-boundaries', deploymentBlocker: true } +const ORDERS = '/api/merchant/v1/last-leg/return-orders' +const INVENTORY = '/api/merchant/v1/last-leg/return-inventory' +const RECEIPTS = '/api/pda/v1/last-leg/return-receipts' +const MERGE = '/api/pda/v1/last-leg/return-inventory/cartons/merge' +const PLATFORM_INVENTORY = '/api/platform/v1/last-leg/return-inventory' +const rows = data => Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] +const ok = response => response?.http === 200 && String(response?.body?.code) === '200' +export function rejected(response, statuses) { + const code = String(response?.body?.code || '') + return Boolean(response?.requestId && response?.traceId) + && statuses.some(status => response?.http === status + || (response?.http === 200 && [String(status), `A0${status}`].includes(code))) +} +export function classifyRace(responses) { + return { successes: responses.filter(ok).length, + conflicts: responses.filter(response => rejected(response, [409])).length, + unexpected: responses.filter(response => !ok(response) && !rejected(response, [409])).length } +} +export function stableState(value) { + if (Array.isArray(value)) return value.map(stableState).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort() + .filter(key => !['createdAt', 'updatedAt', 'receivedAt', 'generatedAt'].includes(key)) + .map(key => [key, stableState(value[key])])) + return value +} + +export async function verifyNoMutation({ snapshot, action, check, name, expected }) { + const before = await snapshot() + const response = await action() + check(name, response, expected(response)) + const after = await snapshot() + check(`${name}后立即核对订单批次箱及SKU均不变`, response, + JSON.stringify(stableState(before)) === JSON.stringify(stableState(after))) + return response +} + +export function exactReceiptLedger(data, expected) { + const project = row => ({ customerId: String(row.customerId), overseasWarehouseId: String(row.overseasWarehouseId), + skuCode: row.skuCode, movementType: row.movementType, referenceNo: row.referenceNo, + totalDelta: Number(row.totalDelta), availableDelta: Number(row.availableDelta) }) + return Number(data?.total) === expected.length && rows(data).length === expected.length + && JSON.stringify(stableState(rows(data).map(project))) === JSON.stringify(stableState(expected.map(project))) +} + +export async function runReturnBoundaries(context, { merchantToken, warehouse, picker, access }) { + let sequence = 0 + const prefix = `RBX-${context.marker.replace(/[^A-Z0-9]/gi, '').slice(-18)}` + const key = name => `${prefix}-${name}-${++sequence}` + const extraPickers = [] + let foreignPicker + let failures = 0 + const resources = context.createdResources.returnBoundaryScenarios = { returnNos: [], cartonMarks: [], cases: [], concurrencyRounds: 3 } + function check(name, response, passed, evidence = '') { + if (!passed) failures += 1 + context.assert(MODULE, name, response, Boolean(passed), evidence, OPTIONS) + } + const request = (token, method, path, body) => context.requestOverseas(method, path, body, {}, token) + async function good(token, method, name, path, body) { + const response = await request(token, method, path, body) + check(name, response, ok(response)) + if (!ok(response)) throw new Error(`${name}: HTTP=${response?.http}, code=${response?.body?.code}`) + return response.body.data + } + const url = (path, values) => `${path}?${new URLSearchParams(values)}` + async function newOrder(name, sku, quantity, token = merchantToken, warehouseId = warehouse.id, + items = [{ skuCode: sku, productNameZh: '边界验收商品', expectedQuantity: quantity }]) { + const data = await good(token, 'POST', `准备${name}退货单`, ORDERS, { + overseasWarehouseId: warehouseId, warehouseContactName: '退货边界验收', warehouseContactPhone: '00000000000', + customerReferenceNo: `${prefix}-${name}`, needRelabel: false, idempotencyKey: key('ORDER'), + items, + }) + resources.returnNos.push(data.order.returnNo) + check('新退货单及退货主单采用独立完整编号', null, + /^RT-[0-9A-Z]{25}$/.test(data.order.returnNo) && /^RETU-[0-9A-Z]{25}$/.test(data.order.lastlegOrderNo)) + return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId } + } + function receiptBody(order, quantity = order.quantity, idempotencyKey = key('RECEIPT')) { + return { returnNo: order.returnNo, startedBy: 'RETURN_NO', items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey } + } + async function receive(order, operator = picker) { + const data = await good(operator.token, 'POST', '准备边界场景实收库存', `${RECEIPTS}/confirm`, receiptBody(order)) + check('签收单批次及自动箱唛采用独立完整编号', null, + /^RR-[0-9A-Z]{25}$/.test(data.receiptNo) && /^RB-[0-9A-Z]{25}$/.test(data.receiptBatchNo) + && /^RC-[0-9A-Z]{25}$/.test(data.cartonMark)) + order.cartonMark = data.cartonMark + order.batch = data.receiptBatchNo + resources.cartonMarks.push(data.cartonMark) + return data + } + function mergeBody(orders, sku, quantity, idempotencyKey = key('MERGE')) { + return { returnNos: orders.map(order => order.returnNo), items: [{ skuCode: sku, quantity }], + length: 30, width: 20, height: 10, weight: 1.2, idempotencyKey } + } + async function snapshot(orders) { + const result = [] + for (const order of orders) { + const detail = await good(order.token, 'GET', '读取订单数量与签收批次快照', `${ORDERS}/${order.returnNo}`) + const cartons = await good(order.token, 'GET', '读取全部状态箱快照', url(`${INVENTORY}/cartons`, { returnNo: order.returnNo, page: 1, size: 200 })) + const items = [] + for (const carton of rows(cartons)) items.push(await good(order.token, 'GET', '读取箱内SKU快照', `${INVENTORY}/cartons/${carton.cartonMark}`)) + const summary = await good(order.token, 'GET', '读取SKU数量及所在箱数快照', url(`${INVENTORY}/summary`, { skuCode: order.sku, overseasWarehouseId: order.warehouseId, page: 1, size: 200 })) + const additionalSummaries = [] + for (const sku of order.skus.filter(sku => sku !== order.sku)) additionalSummaries.push(await good(order.token, 'GET', + '读取其他SKU库存快照', url(`${INVENTORY}/summary`, { skuCode: sku, overseasWarehouseId: order.warehouseId, page: 1, size: 200 }))) + result.push({ detail, cartons, items, summary, additionalSummaries }) + } + return result + } + async function unchanged(name, orders, action, statuses) { + return verifyNoMutation({ snapshot: () => snapshot(orders), action, check, name, + expected: response => rejected(response, statuses) }) + } + async function scenario(name, fn) { + const before = failures + try { await fn(); resources.cases.push({ name, executed: true, passed: failures === before }) } + catch (error) { check(`${name}场景未完成`, null, false, error.message); resources.cases.push({ name, executed: false, passed: false }) } + } + function recordRace(name, responses) { + for (const [index, response] of responses.entries()) { + check(`${name}请求${index + 1}仅成功或业务冲突`, response, ok(response) || rejected(response, [409])) + } + return classifyRace(responses) + } + try { + // The second customer owns its own context and never replaces primary credentials. + const secondary = Object.create(context) + secondary.marker = `${context.marker}-B` + secondary.options = { ...context.options, customerName: '' } + secondary.context = { ...context.context } + secondary.createdResources = {} + await createMerchant(secondary, { publishEnvironment: false }) + const fixtureB = secondary.context.merchantQualificationFixture + resources.secondaryCustomer = secondary.createdResources.merchantQualificationFixture + const loginB = await context.request('POST', '/api/auth/login', { loginId: fixtureB.username, password: fixtureB.password }, '') + check('第二隔离客户登录取得海外令牌', loginB, ok(loginB) && Boolean(loginB.body?.data?.overseasAccessToken)) + if (!ok(loginB) || !loginB.body?.data?.overseasAccessToken) return + const tokenB = loginB.body.data.overseasAccessToken + const warehouseResponse = await context.request('GET', '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE') + const otherWarehouse = rows(warehouseResponse?.body?.data).find(row => String(row.id) !== String(warehouse.id)) + check('跨仓场景具备第二个真实有效海外仓', warehouseResponse, ok(warehouseResponse) && Boolean(otherWarehouse)) + const sameWarehousePicker = await createPicker(context, 93, warehouse.id, access) + if (sameWarehousePicker?.membershipId) extraPickers.push([sameWarehousePicker, 93]) + check('并发场景具备第二个同仓操作员', null, Boolean(sameWarehousePicker?.token)) + + await scenario('跨客户隔离', async () => { + const a = await newOrder('客户A', `${prefix}-ISO-A`, 1) + const b = await newOrder('客户B', `${prefix}-ISO-B`, 1, tokenB) + await receive(a); await receive(b) + for (const [name, path] of [ + ['退货单详情', `${ORDERS}/${b.returnNo}`], + ['箱明细', `${INVENTORY}/cartons/${b.cartonMark}`], + ['箱标签', `${INVENTORY}/cartons/${b.cartonMark}/label`], + ]) await unchanged(`客户A禁止读取客户B${name}`, [a, b], () => request(merchantToken, 'GET', path), [403, 404]) + for (const [name, path, filter] of [ + ['退货单列表', ORDERS, { returnNo: b.returnNo }], + ['箱列表', `${INVENTORY}/cartons`, { returnNo: b.returnNo }], + ['SKU汇总', `${INVENTORY}/summary`, { skuCode: b.sku }], + ]) { + const data = await good(merchantToken, 'GET', `客户A查询客户B${name}`, url(path, { ...filter, customerId: fixtureB.tenantId, page: 1, size: 20 })) + check(`伪造客户筛选不能泄露${name}`, null, Number(data.total) === 0 && rows(data).length === 0) + } + await unchanged('不同客户完成单禁止合箱', [a, b], () => request(picker.token, 'POST', MERGE, mergeBody([a, b], a.sku, 1)), [409]) + }) + + await scenario('跨仓隔离', async () => { + if (!otherWarehouse) throw new Error('缺少第二仓,不能跳过跨仓验证') + foreignPicker = await createPicker(context, 94, String(otherWarehouse.id), access) + if (foreignPicker?.membershipId) extraPickers.push([foreignPicker, 94]) + if (!foreignPicker?.token) throw new Error('第二仓操作员未就绪') + const order = await newOrder('异仓', `${prefix}-WAREHOUSE`, 1, merchantToken, String(otherWarehouse.id)) + await unchanged('非绑定仓禁止开始签收', [order], () => request(picker.token, 'POST', `${RECEIPTS}/start`, { returnNo: order.returnNo }), [404]) + await unchanged('非绑定仓禁止确认签收', [order], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order)), [404]) + const candidates = await good(picker.token, 'POST', '本仓查询异仓SKU候选', `${RECEIPTS}/sku-candidates`, { skuCode: order.sku }) + check('SKU候选不串仓', null, Array.isArray(candidates.candidates) && candidates.candidates.length === 0) + await receive(order, foreignPicker) + await unchanged('非绑定仓禁止读取箱标签', [order], () => request(picker.token, 'GET', `/api/pda/v1/last-leg/return-inventory/cartons/${order.cartonMark}/label`), [404]) + await unchanged('非绑定仓禁止合箱', [order], () => request(picker.token, 'POST', MERGE, mergeBody([order], order.sku, 1)), [404]) + }) + + await scenario('并发签收', async () => { + if (!sameWarehousePicker?.token) throw new Error('第二同仓操作员未就绪') + for (let round = 1; round <= resources.concurrencyRounds; round += 1) { + const order = await newOrder(`并发签收${round}`, `${prefix}-RACE-RECEIPT-${round}`, 1) + const responses = await Promise.all([picker, sameWarehousePicker].map(operator => request(operator.token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order)))) + const outcome = recordRace('并发签收', responses) + check('两个操作员抢收最后一件仅一次成功', responses[0], outcome.successes === 1 && outcome.conflicts === 1 && outcome.unexpected === 0, JSON.stringify(outcome)) + const [state] = await snapshot([order]) + check('并发签收仅一批一箱一件', null, state.detail.receipts.length === 1 && Number(state.detail.order.receivedQuantity) === 1 + && Number(state.cartons.total) === 1 && Number(rows(state.summary)[0]?.availableQuantity) === 1) + const replayOrder = await newOrder(`并发同键${round}`, `${prefix}-SAME-KEY-${round}`, 1) + const body = receiptBody(replayOrder) + const sameKeyResponses = await Promise.all([1, 2].map(() => request(picker.token, 'POST', `${RECEIPTS}/confirm`, body))) + for (const [index, response] of sameKeyResponses.entries()) check(`同键并发请求${index + 1}成功`, response, ok(response)) + check('同键并发签收返回同一成功结果', sameKeyResponses[0], sameKeyResponses.every(ok) + && sameKeyResponses[0].body.data.receiptNo === sameKeyResponses[1].body.data.receiptNo) + const beforeRetry = await snapshot([replayOrder]) + check('同键并发仅生成一批一箱一件', null, beforeRetry[0].detail.receipts.length === 1 + && Number(beforeRetry[0].detail.order.receivedQuantity) === 1 && Number(beforeRetry[0].cartons.total) === 1 + && rows(beforeRetry[0].summary).some(row => row.skuCode === replayOrder.sku && Number(row.availableQuantity) === 1)) + const retry = await request(picker.token, 'POST', `${RECEIPTS}/confirm`, body) + const accepted = sameKeyResponses.find(ok)?.body.data + check('模拟成功响应丢失后原键重试复用原签收', retry, ok(retry) && accepted + && ['receiptNo', 'receiptBatchNo', 'cartonMark'].every(field => retry.body.data[field] === accepted[field])) + check('重试后签收和库存不重复', retry, JSON.stringify(stableState(beforeRetry)) === JSON.stringify(stableState(await snapshot([replayOrder])))) + } + }) + + await scenario('并发合箱', async () => { + if (!sameWarehousePicker?.token) throw new Error('第二同仓操作员未就绪') + for (let round = 1; round <= resources.concurrencyRounds; round += 1) { + const order = await newOrder(`并发合箱${round}`, `${prefix}-RACE-MERGE-${round}`, 1) + await receive(order) + const responses = await Promise.all([picker, sameWarehousePicker].map(operator => request(operator.token, 'POST', MERGE, mergeBody([order], order.sku, 1)))) + const outcome = recordRace('并发合箱', responses) + check('两个操作员抢合最后一件仅一次成功', responses[0], outcome.successes === 1 && outcome.conflicts === 1 && outcome.unexpected === 0, JSON.stringify(outcome)) + const state = await snapshot([order]) + check('并发合箱无负数且SKU数量箱数均为一', null, rows(state[0].summary).some(row => row.skuCode === order.sku && Number(row.availableQuantity) === 1 && Number(row.cartonQuantity) === 1) + && Number(state[0].cartons.total) === 2 + && rows(state[0].cartons).filter(row => row.status === 'AVAILABLE' && Number(row.totalQuantity) === 1).length === 1 + && rows(state[0].cartons).filter(row => row.status === 'MERGED' && Number(row.totalQuantity) === 0).length === 1) + const partial = await newOrder(`并发部分合箱${round}`, `${prefix}-RACE-PARTIAL-${round}`, 3) + await receive(partial) + const partialResponses = await Promise.all([picker, sameWarehousePicker].map(operator => request(operator.token, 'POST', MERGE, mergeBody([partial], partial.sku, 2)))) + const partialOutcome = recordRace('并发部分合箱', partialResponses) + check('三件库存同时各取两件仅一次成功', partialResponses[0], partialOutcome.successes === 1 + && partialOutcome.conflicts === 1 && partialOutcome.unexpected === 0, JSON.stringify(partialOutcome)) + const [partialState] = await snapshot([partial]) + check('并发部分合箱保持原箱一件新箱两件总量三件', null, Number(partialState.cartons.total) === 2 + && rows(partialState.cartons).every(row => row.status === 'AVAILABLE') + && rows(partialState.cartons).some(row => row.cartonMark === partial.cartonMark && Number(row.totalQuantity) === 1) + && rows(partialState.cartons).some(row => row.cartonMark !== partial.cartonMark && Number(row.totalQuantity) === 2) + && rows(partialState.summary).some(row => row.skuCode === partial.sku && Number(row.availableQuantity) === 3 && Number(row.cartonQuantity) === 2)) + } + }) + + await scenario('多单全量合箱与来源追溯', async () => { + const sku = `${prefix}-MULTI` + const a = await newOrder('多单A', sku, 2) + const b = await newOrder('多单B', sku, 3) + const unused = await newOrder('未取货来源', `${prefix}-UNUSED`, 1) + await receive(a); await receive(b); await receive(unused) + const result = await good(picker.token, 'POST', '同客户多单全量合箱', MERGE, mergeBody([a, b, unused], sku, 5)) + resources.cartonMarks.push(result.cartonMark) + const target = await good(merchantToken, 'GET', '回查多单目标箱', `${INVENTORY}/cartons/${result.cartonMark}`) + check('多单同SKU合并为一行五件', null, Number(target.totalQuantity) === 5 && target.items.length === 1 && Number(target.items[0].quantity) === 5) + check('来源单和批次仅含实际消耗且去重', null, + JSON.stringify(String(target.sourceReturnNo).split(',').sort()) === JSON.stringify([a.returnNo, b.returnNo].sort()) + && JSON.stringify(String(target.sourceBatchNo).split(',').sort()) === JSON.stringify([a.batch, b.batch].sort())) + for (const source of [a, b]) { + const carton = await good(merchantToken, 'GET', '回查已耗尽来源箱', `${INVENTORY}/cartons/${source.cartonMark}`) + check('耗尽来源箱为MERGED且件数SKU数清零', null, carton.status === 'MERGED' && Number(carton.totalQuantity) === 0 && Number(carton.skuTypes) === 0 && carton.items.length === 0) + } + const untouched = await good(merchantToken, 'GET', '回查未取货来源箱', `${INVENTORY}/cartons/${unused.cartonMark}`) + check('选中但未取货来源保持可用一件', null, untouched.status === 'AVAILABLE' && Number(untouched.totalQuantity) === 1) + const summary = await good(merchantToken, 'GET', '回查全量合箱SKU汇总', url(`${INVENTORY}/summary`, { skuCode: sku, page: 1, size: 20 })) + check('全量合箱后仍五件但仅一个可用箱', null, rows(summary).some(row => row.skuCode === sku && Number(row.availableQuantity) === 5 && Number(row.cartonQuantity) === 1)) + }) + + await scenario('混合明细整笔回滚', async () => { + const skuA = `${prefix}-ATOMIC-A`, skuB = `${prefix}-ATOMIC-B` + const order = await newOrder('混合明细', skuA, 2, merchantToken, warehouse.id, + [skuA, skuB].map(skuCode => ({ skuCode, expectedQuantity: 2 }))) + const makeReceipt = (secondSku, quantity) => ({ ...receiptBody(order, 1), items: [ + { skuCode: skuA, receivedQuantity: 1 }, { skuCode: secondSku, receivedQuantity: quantity }] }) + for (const [name, secondSku, quantity] of [['后项超收', skuB, 3], ['后项未预报', `${prefix}-ATOMIC-UNKNOWN`, 1]]) { + await unchanged(`混合签收${name}必须整笔失败`, [order], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, makeReceipt(secondSku, quantity)), [409]) + } + await good(picker.token, 'POST', '混合明细正常签收准备合箱', `${RECEIPTS}/confirm`, { + ...receiptBody(order), items: [skuA, skuB].map(skuCode => ({ skuCode, receivedQuantity: 2 })) }) + for (const [name, secondSku, quantity] of [['后项超库存', skuB, 3], ['后项不存在', `${prefix}-ATOMIC-UNKNOWN`, 1]]) { + await unchanged(`混合合箱${name}必须整笔回滚`, [order], () => request(picker.token, 'POST', MERGE, { + ...mergeBody([order], skuA, 1), items: [{ skuCode: skuA, quantity: 1 }, { skuCode: secondSku, quantity }] }), [409]) + } + const [state] = await snapshot([order]) + check('混合失败后两SKU均保持两件且仅一个来源箱', null, Number(state.cartons.total) === 1 + && [state.summary, ...state.additionalSummaries].every(page => rows(page).length === 1 && Number(rows(page)[0].availableQuantity) === 2)) + }) + + await scenario('同键并发合箱', async () => { + for (let round = 1; round <= resources.concurrencyRounds; round += 1) { + const order = await newOrder(`同键合箱${round}`, `${prefix}-MERGE-KEY-${round}`, 2) + await receive(order) + const body = mergeBody([order], order.sku, 1) + const responses = await Promise.all([1, 2].map(() => request(picker.token, 'POST', MERGE, body))) + for (const [index, response] of responses.entries()) check(`同键并发合箱请求${index + 1}成功`, response, ok(response)) + check('同键并发合箱复用同一目标箱', responses[0], responses.every(ok) + && /^RC-[0-9A-Z]{25}$/.test(responses[0].body.data?.cartonMark) && responses[0].body.data.cartonMark === responses[1].body.data.cartonMark) + const [state] = await snapshot([order]) + check('同键并发合箱只扣一次且原箱新箱各一件', null, Number(state.cartons.total) === 2 + && rows(state.cartons).every(row => Number(row.totalQuantity) === 1 && row.status === 'AVAILABLE') + && rows(state.summary).some(row => row.skuCode === order.sku && Number(row.availableQuantity) === 2 && Number(row.cartonQuantity) === 2)) + const original = responses.find(ok)?.body.data?.cartonMark + const replay = await request(picker.token, 'POST', MERGE, body) + check('同键并发合箱后重试仍返回原箱', replay, ok(replay) && Boolean(original) && replay.body.data?.cartonMark === original) + check('同键合箱重放后全部数据不变', replay, JSON.stringify(stableState([state])) === JSON.stringify(stableState(await snapshot([order])))) + } + }) + + await scenario('相同SKU跨客户跨仓隔离', async () => { + if (!foreignPicker?.token || !otherWarehouse) throw new Error('第二仓操作员未就绪') + const sku = `${prefix}-SHARED-SKU` + const a = await newOrder('同码客户A本仓', sku, 2) + const b = await newOrder('同码客户B本仓', sku, 3, tokenB) + const c = await newOrder('同码客户A异仓', sku, 5, merchantToken, String(otherWarehouse.id)) + for (const [operator, expected] of [[picker, [a, b]], [foreignPicker, [c]]]) { + const found = await good(operator.token, 'POST', '查询同码不同归属的PDA候选', `${RECEIPTS}/sku-candidates`, { skuCode: sku }) + check('PDA同码候选按仓隔离且各单数量独立', null, found.candidates.length === expected.length + && expected.every(order => found.candidates.some(row => row.returnNo === order.returnNo + && Number(row.skuExpectedQuantity) === order.quantity && Number(row.skuReceivedQuantity) === 0))) + } + const beforeOthers = await snapshot([b, c]) + await receive(a) + check('同码A签收不改变B客户或异仓数据', null, + JSON.stringify(stableState(beforeOthers)) === JSON.stringify(stableState(await snapshot([b, c])))) + await receive(b); await receive(c, foreignPicker) + for (const order of [a, b, c]) { + const data = await good(order.token, 'GET', '查询同码客户仓库维度库存', url(`${INVENTORY}/summary`, { + skuCode: sku, overseasWarehouseId: order.warehouseId, page: 1, size: 20 })) + check('同码库存只返回当前客户当前仓的准确数量', null, Number(data.total) === 1 && rows(data).length === 1 + && rows(data)[0].skuCode === sku && String(rows(data)[0].overseasWarehouseId) === String(order.warehouseId) + && Number(rows(data)[0].availableQuantity) === order.quantity && Number(rows(data)[0].cartonQuantity) === 1) + } + for (const [token, expected] of [[merchantToken, [a, c]], [tokenB, [b]]]) { + const data = await good(token, 'GET', '客户按同码查询自己全部退货单', url(ORDERS, { skuCode: sku, page: 1, size: 20 })) + check('客户同码列表不夹入其他客户单据', null, Number(data.total) === expected.length && rows(data).length === expected.length + && expected.every(order => rows(data).some(row => row.returnNo === order.returnNo))) + } + await unchanged('相同SKU也不能跨客户合箱', [a, b, c], () => request(picker.token, 'POST', MERGE, mergeBody([a, b], sku, 1)), [409]) + await unchanged('相同SKU也不能跨仓合箱', [a, b, c], () => request(picker.token, 'POST', MERGE, mergeBody([a, c], sku, 1)), [404]) + }) + + await scenario('身份入口权限', async () => { + const order = await newOrder('权限边界', `${prefix}-AUTH`, 1) + for (const [name, token, statuses] of [['客户', merchantToken, [403]], ['无登录', '', [401]], ['平台管理员', context.overseasToken, [403]]]) { + await unchanged(`${name}不能调用PDA签收`, [order], () => request(token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order)), statuses) + } + await receive(order) + for (const [name, token, statuses] of [['客户', merchantToken, [403]], ['无登录', '', [401]]]) { + await unchanged(`${name}不能调用PDA合箱`, [order], () => request(token, 'POST', MERGE, mergeBody([order], order.sku, 1)), statuses) + } + for (const [name, token] of [['客户', merchantToken], ['PDA', picker.token]]) { + for (const path of [PLATFORM_INVENTORY, `${PLATFORM_INVENTORY}/cartons/${order.cartonMark}`, `${PLATFORM_INVENTORY}/cartons/${order.cartonMark}/label`, `${PLATFORM_INVENTORY}/movements`]) { + const validPath = path === PLATFORM_INVENTORY || path.endsWith('/movements') + ? url(path, { page: 1, size: 20, customerId: context.context.merchantQualificationFixture.tenantId, skuCode: order.sku }) : path + await unchanged(`${name}禁止读取平台退货入口${path.split('/').at(-1)}`, [order], () => request(token, 'GET', validPath), [403]) + } + } + }) + + await scenario('签收流水与余额核对', async () => { + const order = await newOrder('流水核对', `${prefix}-LEDGER`, 3) + const customerId = context.context.merchantQualificationFixture.tenantId + const ledger = () => good(context.overseasToken, 'GET', '读取本轮签收流水', url(`${PLATFORM_INVENTORY}/movements`, { + customerId, overseasWarehouseId: warehouse.id, skuCode: order.sku, page: 1, size: 200 })) + check('未签收前本轮SKU无流水', null, exactReceiptLedger(await ledger(), [])) + const body = receiptBody(order, 2) + const signed = await good(picker.token, 'POST', '流水场景首次实收两件', `${RECEIPTS}/confirm`, body) + const entry = { customerId, overseasWarehouseId: warehouse.id, skuCode: order.sku, + movementType: 'RECEIPT', referenceNo: signed.receiptNo, totalDelta: 2, availableDelta: 2 } + check('首次签收只生成一条两件流水且引用签收单', null, exactReceiptLedger(await ledger(), [entry])) + const withLedger = async () => ({ inventory: await snapshot([order]), ledger: await ledger() }) + await verifyNoMutation({ snapshot: withLedger, action: () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, body), + check, name: '签收重放不重复产生流水', expected: result => ok(result) && result.body.data?.receiptNo === signed.receiptNo }) + await verifyNoMutation({ snapshot: withLedger, action: () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order, 2)), + check, name: '超收失败不留下流水', expected: result => rejected(result, [409]) }) + const final = await good(picker.token, 'POST', '流水场景第二批实收一件', `${RECEIPTS}/confirm`, receiptBody(order, 1)) + const data = await ledger() + check('两批签收流水业务引用唯一且分别两件一件', null, exactReceiptLedger(data, + [entry, { ...entry, referenceNo: final.receiptNo, totalDelta: 1, availableDelta: 1 }]) && signed.receiptNo !== final.receiptNo) + const [state] = await snapshot([order]) + check('流水增量合计等于订单实收和SKU余额', null, rows(data).reduce((sum, row) => sum + Number(row.totalDelta), 0) === 3 + && rows(data).reduce((sum, row) => sum + Number(row.availableDelta), 0) === 3 + && Number(state.detail.order.receivedQuantity) === 3 && rows(state.summary).some(row => Number(row.availableQuantity) === 3 && Number(row.totalQuantity) === 3)) + }) + + await scenario('多候选与输入边界', async () => { + const sku = `${prefix}-CANDIDATE` + const a = await newOrder('候选A', sku, 1) + const b = await newOrder('候选B', sku, 2) + const found = await good(picker.token, 'POST', '同SKU查询两张待签收单', `${RECEIPTS}/sku-candidates`, { skuCode: sku }) + check('同SKU两候选各自保留预报数量', null, found.candidates.length === 2 + && found.candidates.some(row => row.returnNo === a.returnNo && Number(row.skuExpectedQuantity) === 1 && Number(row.skuReceivedQuantity) === 0) + && found.candidates.some(row => row.returnNo === b.returnNo && Number(row.skuExpectedQuantity) === 2 && Number(row.skuReceivedQuantity) === 0)) + await receive(a) + const remaining = await good(picker.token, 'POST', '一单完成后回查同SKU候选', `${RECEIPTS}/sku-candidates`, { skuCode: sku }) + check('完成单移除而未收单数量不变', null, remaining.candidates.length === 1 + && remaining.candidates[0].returnNo === b.returnNo && Number(remaining.candidates[0].skuExpectedQuantity) === 2 + && Number(remaining.candidates[0].skuReceivedQuantity) === 0) + for (const [name, patch] of [ + ['已存在箱号', { cartonMark: a.cartonMark }], + ['缺少幂等键', { idempotencyKey: '' }], + ['超长快递单号', { trackingNo: 'X'.repeat(129) }], + ]) await unchanged(`拒绝${name}`, [a, b], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, { ...receiptBody(b, 1), ...patch }), name === '已存在箱号' ? [409] : [400, 422]) + await receive(b) + await unchanged('拒绝合箱重复SKU', [a, b], () => request(picker.token, 'POST', MERGE, + { ...mergeBody([a, b], sku, 1), items: [{ skuCode: sku, quantity: 1 }, { skuCode: sku, quantity: 1 }] }), [400, 422]) + await unchanged('拒绝小数合箱数量', [a, b], () => request(picker.token, 'POST', MERGE, + mergeBody([a, b], sku, 1.5)), [400, 422]) + // Isolate fractional receipt from later fixtures: a server defect must not + // consume another case's pending quantity and hide its verification. + const fractional = await newOrder('小数实收', `${prefix}-FRACTIONAL`, 2) + await unchanged('拒绝小数实收', [fractional], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, + receiptBody(fractional, 1.5)), [400, 422]) + }) + } finally { + for (const [operator, index] of extraPickers.reverse()) { + try { await disablePicker(context, operator, index) } + catch (error) { check(`停用临时PDA${index}失败`, null, false, error.message) } + } + } +} diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs new file mode 100644 index 000000000..c39789d35 --- /dev/null +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -0,0 +1,151 @@ +const MODULE = 'service-center-flow' +const OPTIONS = { scenario: 'service-center-cross-flow', deploymentBlocker: true } +const OCEAN = '/api/platform/v1/ocean-bookings' +const TRUCK = '/api/platform/v1/truck-appointments' + +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } + +async function requestWithTransientRetry(context, method, path, body) { + let response = null + for (let attempt = 1; attempt <= 3; attempt += 1) { + response = await context.request(method, path, body) + if (response?.http !== 0) return response + if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, 500 * attempt)) + } + return response +} + +async function actionRequest(context, path, endpoint, version) { + try { + const response = await fetch(`${context.options.gateway}${path}/${endpoint}`, { + method: 'POST', + headers: { + Accept: 'application/json', Authorization: `Bearer ${context.token}`, + 'Content-Type': 'application/json; charset=utf-8', + 'Idempotency-Key': `${context.marker}-flow-${endpoint}`.slice(0, 128), + }, + body: JSON.stringify({ expectedVersion: version, reason: context.marker }), + signal: AbortSignal.timeout(60_000), + }) + const raw = await response.text(); let payload = null + try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } + return { method: 'POST', path: `${path}/${endpoint}`, http: response.status, body: payload, + raw: payload === null ? raw.slice(0, 500) : null, + requestId: payload?.requestId || response.headers.get('x-request-id'), + traceId: payload?.traceId || response.headers.get('x-trace-id') } + } catch (error) { + return { method: 'POST', path: `${path}/${endpoint}`, http: 0, body: null, + raw: String(error), requestId: null, traceId: null } + } +} + +async function uploadDispatchOrder(context, appointmentId) { + const path = `${TRUCK}/${appointmentId}/attachments/upload?attachmentType=DISPATCH_ORDER` + 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 containerFlow = context.createdResources?.phase3Loading + || context.createdResources?.phase3ContainerLoading + const ocean = containerFlow?.oceanBookingId + ? { id: id(containerFlow.oceanBookingId), finalStatus: 'RELEASED' } + : context.createdResources?.oceanBooking + if (!ocean?.id || ocean.finalStatus !== 'RELEASED') { + context.addStep(MODULE, '跨服务链路前置订舱终态可用', null, false, + '本轮海运订舱模块未返回 RELEASED 隔离记录,未伪造关联拖车预约', OPTIONS) + return + } + + const suffix = context.marker.replace(/\D/g, '').slice(-10) + const oceanResponse = await context.request('GET', `${OCEAN}/${ocean.id}`) + const oceanDetail = context.apiSuccess(MODULE, '读取跨服务链路已放舱订舱', oceanResponse, `oceanBookingId=${ocean.id}`, OPTIONS) + const booking = oceanDetail?.oceanBooking || oceanDetail + context.assert(MODULE, '已放舱订舱可直接创建订舱级拖车预约', oceanResponse, + id(booking?.id) === id(ocean.id) && booking?.status === 'RELEASED', + `oceanBookingId=${id(booking?.id) || 'missing'}, status=${booking?.status || 'missing'}`, OPTIONS) + const carrierResponse = await requestWithTransientRetry(context, 'GET', + '/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&truckingScope=DOMESTIC&page=1&size=20') + const carrierPage = context.apiSuccess(MODULE, '读取 ACTIVE TRUCKING 承运商事实源', carrierResponse, '', OPTIONS) + const carrier = carrierPage?.records?.find?.((item) => item?.id && item?.name && item?.truckingScope === 'DOMESTIC') + context.assert(MODULE, '跨服务拖车使用正式 ACTIVE TRUCKING 承运商', carrierResponse, + Boolean(carrier?.id && carrier?.name), `carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS) + if (!carrier) return + const payload = { + outboundOrderId: null, + oceanBookingId: ocean.id, + warehouseId: id(booking?.warehouseId), + 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', + packageCount: 1, grossWeightKg: 1, volumeCbm: 0.1, palletCount: 0, poNo: `FLOW-PO-${suffix}`, + plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1, + currency: 'RMB', remark: context.marker, expectedVersion: 0, + } + const response = await context.request('POST', TRUCK, payload) + const appointment = context.apiSuccess(MODULE, '创建关联海运订舱的拖车预约', response, `oceanBookingId=${ocean.id}`, OPTIONS) + const appointmentId = id(appointment?.id) + context.assert(MODULE, '拖车预约仅关联订舱且版本和审计字段完整', response, + Boolean(appointmentId) && id(appointment?.oceanBookingId) === id(ocean.id) && Number.isFinite(Number(appointment?.version)) + && !Object.hasOwn(appointment || {}, 'containerBookingId') + && !Object.hasOwn(appointment || {}, 'loadingPlanId') + && Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName), + `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 dispatchUpload = await uploadDispatchOrder(context, appointmentId) + const dispatchAttachment = context.apiSuccess(MODULE, '跨服务拖车上传有效派车单附件', dispatchUpload, '', OPTIONS) + if (!dispatchAttachment?.id) return + let version = Number(appointment.version) + for (const [name, endpoint, status] of [ + ['提交跨链路预约', 'submit', 'PENDING_DISPATCH'], + ['派发跨链路拖车', 'dispatch', 'DISPATCHED'], + ['跨链路开始提货', 'pickup', 'PICKUP_IN_PROGRESS'], + ['跨链路拖车到仓', 'arrive', 'ARRIVED_WAREHOUSE'], + ['跨链路开始装货', 'load', 'LOADING'], + ]) { + const action = await actionRequest(context, path, endpoint, version) + const changed = context.apiSuccess(MODULE, name, action, '', OPTIONS) + context.assert(MODULE, `${name}状态正确`, action, changed?.status === status, + `status=${changed?.status || 'missing'}, version=${changed?.version ?? 'missing'}`, OPTIONS) + if (!changed) return + version = Number(changed.version) + } + const detail = await context.request('GET', `${TRUCK}/${appointmentId}`) + const final = context.apiSuccess(MODULE, '回查跨服务链路离仓前状态', detail, '', OPTIONS) + context.assert(MODULE, '跨服务链路离仓前状态持久化且关联订舱未漂移', detail, + final?.status === 'LOADING' && id(final?.oceanBookingId) === id(ocean.id) + && !Object.hasOwn(final || {}, 'containerBookingId') + && !Object.hasOwn(final || {}, 'loadingPlanId'), + `status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS) + + const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`) + const history = context.apiSuccess(MODULE, '查询跨服务链路拖车预约状态历史', historyResponse, '', OPTIONS) + const actions = Array.isArray(history) ? history.filter((item) => item?.action !== 'CREATE') : [] + const expectedActions = ['SUBMIT', 'DISPATCH', 'PICKUP', 'ARRIVE', 'LOAD'] + context.assert(MODULE, '跨服务链路历史包含完整状态链、操作者、时间和备注', historyResponse, + expectedActions.every((action) => actions.some((item) => item.action === action + && id(item.appointmentId) === appointmentId && Boolean(item.operatorId) + && Boolean(item.operatorName) && Boolean(item.createdAt) && item.reason === context.marker)), + `history=${history?.length ?? 'n/a'}, actions=${actions.map((item) => item.action).join(',')}`, + OPTIONS) + if (final?.status === 'LOADING') { + context.createdResources.serviceCenterFlow = { + appointmentId, oceanBookingId: ocean.id, + containerBookingId: null, + loadingPlanId: null, + marker: context.marker, finalStatus: 'LOADING', + } + } +} diff --git a/scripts/admin-test-env-modules/smart-loading-channel-group.mjs b/scripts/admin-test-env-modules/smart-loading-channel-group.mjs new file mode 100644 index 000000000..1da739ef1 --- /dev/null +++ b/scripts/admin-test-env-modules/smart-loading-channel-group.mjs @@ -0,0 +1,16 @@ +export function channelGroup(channelCode) { + const value = String(channelCode || '').trim().toUpperCase() + if (['MATSON_CLX', 'MATSON_MAX', 'EXX', 'HEDE', 'OA_LA', 'OA_CHICAGO', + 'OA_HOUSTON', 'OA_SAVANNAH', 'OA_NEW_YORK_TRUCK'].includes(value)) return value + if (value.startsWith('EXX_') || ['EXXHK', 'EXXHWK', 'NYEXX'].includes(value)) return 'EXX' + if (value.startsWith('MATSON_CLX_') || ['ZBYHK', 'ZBYHWK', 'ZBYHP'].includes(value)) return 'MATSON_CLX' + if (value.startsWith('MATSON_MAX_') || ['BYHK', 'BYHWK', 'BYHP'].includes(value)) return 'MATSON_MAX' + if (value.startsWith('HEDE_') || ['HDHK', 'HDHWK', 'HDHP'].includes(value)) return 'HEDE' + if (value.startsWith('OA_LA_') || value === 'OA_PARCEL' + || ['PCHK', 'PCHWK', 'PCKK', 'PCHP'].includes(value)) return 'OA_LA' + if (value.startsWith('OA_CHICAGO_') || value === 'CPHT') return 'OA_CHICAGO' + if (value.startsWith('OA_HOUSTON_') || value === 'HPHK') return 'OA_HOUSTON' + if (value.startsWith('OA_SAVANNAH_') || value === 'SPHK') return 'OA_SAVANNAH' + if (['OA_NEW_YORK_TRUCK', 'NPHK', 'NPHWK'].includes(value)) return 'OA_NEW_YORK_TRUCK' + throw new Error(`unsupported smart-loading channel group: ${channelCode}`) +} 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..d54cc4b96 --- /dev/null +++ b/scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs @@ -0,0 +1,9 @@ +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('supplemental boundaries do not mutate PDA block locations through retired occupancy APIs', () => { + assert.doesNotMatch(source, /inventoryMutationCleanup|temporary-occupancies|occupancyId|\/rebind/) +}) diff --git a/scripts/admin-test-env-modules/supplemental-boundaries.mjs b/scripts/admin-test-env-modules/supplemental-boundaries.mjs new file mode 100644 index 000000000..ec4d559e5 --- /dev/null +++ b/scripts/admin-test-env-modules/supplemental-boundaries.mjs @@ -0,0 +1,131 @@ +const MODULE = 'supplemental-boundaries' +const OPTIONS = { scenario: 'admin-boundary-automation', deploymentBlocker: true } + +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function failure(response) { + return [400, 401, 403, 404, 409].includes(response?.http) + || (response?.http === 200 && codeOf(response) !== '200') +} +function missingRoute(response) { + return response?.http === 404 || response?.http === 405 + || /Not Found|No handler|No static resource/i.test(String(response?.raw || response?.body?.message || '')) +} + +async function multipart(context, path, bytes, fileName, contentType = 'application/octet-stream') { + const form = new FormData() + form.append('file', new Blob([bytes], { type: contentType }), fileName) + try { + 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 content = await response.text() + let body = null + try { body = content ? JSON.parse(content) : null } catch { /* bounded raw evidence below */ } + return { + method: 'POST', path, http: response.status, body, + raw: body === null ? content.slice(0, 500) : null, + requestId: body?.requestId || response.headers.get('x-request-id'), + traceId: body?.traceId || response.headers.get('x-trace-id'), + } + } catch (error) { + return { method: 'POST', path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } + } +} + +function warehousePayload(context, suffix) { + return { + tenantId: 0, warehouseCode: `BW${suffix}`, warehouseName: `边界验收仓-${suffix}`, + warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', + contactName: '边界验收员', contactPhone: '13800000003', contactEmail: `boundary-${suffix}@example.com`, + status: 'ACTIVE', remark: context.marker, + address: { addressType: 'WAREHOUSE', countryCode: 'CN', stateProvince: '广东省', city: '深圳市', district: '南山区', postalCode: '518000', addressLine1: '边界验收地址', addressLine2: null, latitude: null, longitude: null }, + capabilities: [{ capabilityCode: 'RECEIVING', capabilityName: '收货', enabled: true, cutoffTime: '16:00:00', description: context.marker }], + } +} + +export async function run(context) { + const start = context.steps.length + const suffix = context.marker.replace(/\D/g, '').slice(-12) + let warehouseId = '' + let locationId = '' + let warehousePayloadValue + let locationPayload + let locationVersion = 0 + + // Create only marker-owned warehouse/location resources for the one + // executable optimistic-lock scenario. Pallets and master-data records have + // no version field in the public DTO, so they are not falsely claimed here. + warehousePayloadValue = warehousePayload(context, suffix) + const createWarehouse = await context.request('POST', '/api/platform/v1/warehouse-admin', warehousePayloadValue) + warehouseId = id(context.apiSuccess(MODULE, '创建边界验收隔离仓库', createWarehouse, '', OPTIONS)) + if (warehouseId) { + locationPayload = { + warehouseId, zoneCode: `BZ${suffix.slice(-5)}`, zoneName: '边界区', + locationCode: `BL${suffix}`, locationName: '边界库位', locationType: 'SHELF', + maxWeightKg: 1000, status: 'AVAILABLE', remark: context.marker, + } + const createLocation = await context.request('POST', '/api/platform/v1/locations?tenantId=0', locationPayload) + locationId = id(context.apiSuccess(MODULE, '创建边界验收隔离库位', createLocation, '', OPTIONS)) + } + if (locationId) { + const locationPath = `/api/platform/v1/locations/${locationId}?tenantId=0&warehouseId=${warehouseId}` + const detail = await context.request('GET', locationPath) + const data = context.apiSuccess(MODULE, '读取隔离库位版本', detail, '', OPTIONS) + locationVersion = Number(data?.location?.version) + const firstUpdate = await context.request('PUT', locationPath, { ...locationPayload, locationName: `${locationPayload.locationName}-编辑`, version: locationVersion }) + context.apiSuccess(MODULE, '更新隔离库位以推进版本', firstUpdate, `version=${locationVersion}`, OPTIONS) + const stale = await context.request('PUT', locationPath, { ...locationPayload, locationName: `${locationPayload.locationName}-STALE`, version: locationVersion }) + const conflict = stale.http === 200 && ['A0409', '409'].includes(codeOf(stale)) + context.addStep(MODULE, '库位旧 version PUT 返回冲突', stale, conflict, `HTTP=${stale.http}, code=${codeOf(stale)}`, OPTIONS) + const after = await context.request('GET', locationPath) + const afterData = context.apiSuccess(MODULE, '冲突后回查库位', after, '', OPTIONS) + context.assert(MODULE, '库位冲突未造成部分更新', after, + afterData?.location?.locationName === `${locationPayload.locationName}-编辑`, `name=${afterData?.location?.locationName || 'missing'}`, OPTIONS) + locationVersion = Number(afterData?.location?.version) + } + + // Import boundary probes use only in-memory bytes and dry-run where supported. + const bogusXlsx = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00]) + const empty = new Uint8Array() + const probes = [ + ['仓库空文件', '/api/platform/v1/warehouse-admin/import?tenantId=0&dryRun=true', empty, `${context.marker}.xlsx`], + ['仓库缺列伪 XLSX', '/api/platform/v1/warehouse-admin/import?tenantId=0&dryRun=true', bogusXlsx, `${context.marker}-missing.xlsx`], + ['快递公司错误扩展名', '/api/platform/v1/express-companies/import?tenantId=0', new TextEncoder().encode(context.marker), `${context.marker}.txt`], + ['往来单位非 XLSX', '/api/platform/v1/partners/import?tenantId=0', new TextEncoder().encode(context.marker), `${context.marker}.txt`], + ] + for (const [name, path, bytes, fileName] of probes) { + const response = await multipart(context, path, bytes, fileName) + context.addStep(MODULE, `${name}安全失败且不模拟导入成功`, response, failure(response), + `HTTP=${response.http}, code=${codeOf(response)}, bytes=${bytes.length}`, { scenario: 'import-boundaries', deploymentBlocker: missingRoute(response) }) + } + + // Invalid customer/price routes and authentication boundaries are read-only. + const invalidFee = await context.request('GET', '/api/platform/v1/iam/tenants/999999999999999999/overseas-storage-fees') + context.addStep(MODULE, '错误客户海外仓费用 ID 安全失败', invalidFee, failure(invalidFee), + `HTTP=${invalidFee.http}, code=${codeOf(invalidFee)}`, OPTIONS) + for (const [name, path] of [ + ['库位列表未授权', '/api/platform/v1/locations?page=1&size=1&tenantId=0'], + ['下载中心非法页码', '/api/platform/v1/files/export-tasks?page=0&size=1'], + ['船期价格非法状态', '/api/platform/v1/fba-pricing/carrier-rates/price-books?status=INVALID'], + ]) { + const response = await context.request('GET', path, undefined, name.includes('未授权') ? '' : context.token) + const passed = name.includes('未授权') ? response.http === 401 : failure(response) + context.addStep(MODULE, `${name}边界响应正确`, response, passed, `HTTP=${response.http}, code=${codeOf(response)}`, OPTIONS) + } + + if (locationId) { + const disable = await context.request('PATCH', `/api/platform/v1/locations/${locationId}/status?tenantId=0&warehouseId=${warehouseId}`, { status: 'DISABLED', version: locationVersion }) + context.apiSuccess(MODULE, '停用隔离库位以便清理', disable, '', OPTIONS) + const remove = await context.request('DELETE', `/api/platform/v1/locations/${locationId}?tenantId=0&warehouseId=${warehouseId}&version=${locationVersion + 1}`) + context.apiSuccess(MODULE, '删除隔离库位', remove, '', OPTIONS) + } + if (warehouseId) { + const disableWarehouse = await context.request('PATCH', `/api/platform/v1/warehouse-admin/${warehouseId}/status?tenantId=0&status=INACTIVE`) + context.apiSuccess(MODULE, '停用隔离仓库', disableWarehouse, '', OPTIONS) + } + + const steps = context.steps.slice(start) + context.scenarios.push({ name: 'admin-boundary-automation', module: MODULE, success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker), passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length }) +} diff --git a/scripts/admin-test-env-modules/supplemental.mjs b/scripts/admin-test-env-modules/supplemental.mjs new file mode 100644 index 000000000..28bef19f7 --- /dev/null +++ b/scripts/admin-test-env-modules/supplemental.mjs @@ -0,0 +1,239 @@ +// Admin HTTP acceptance gaps that are intentionally independent from the CRUD +// modules. This module is read-only: it never creates or mutates business data. + +const HEALTH_ENDPOINTS = [ + { path: '/api/platform/v1/orders/status', service: 'order-service' }, + { path: '/api/platform/v1/first-leg/status', service: 'firstleg-service' }, + { path: '/api/platform/v1/files/status', service: 'file-service' }, + { path: '/api/platform/v1/warehouses/status', service: 'warehouse-service', allows503: true }, +] + +function codeOf(response) { + return response?.body?.code == null ? 'missing' : String(response.body.code) +} + +function isMissingRoute(response) { + return response?.http === 404 || response?.http === 405 + || /Not Found|No handler|No static resource/i.test(String(response?.raw || response?.body?.message || '')) +} + +function isBusinessFailure(response) { + const code = codeOf(response) + return [400, 401, 403, 404, 409].includes(response?.http) + || (response?.http === 200 && code !== '200') +} + +function pageShape(data) { + return data && Array.isArray(data.records) + && Number(data.current ?? data.page ?? 1) >= 1 + && Number(data.size ?? 0) >= 0 +} + +function xlsx(response) { + return response?.http === 200 && response.bytes?.length > 4 + && response.bytes[0] === 0x50 && response.bytes[1] === 0x4b +} + +async function checkPage(context, module, name, path, options = {}) { + const response = await context.request('GET', path) + const data = context.apiSuccess(module, `${name}分页查询`, response, `path=${path}`, options) + context.assert(module, `${name}分页结构与记录数组正确`, response, pageShape(data), + `records=${data?.records?.length ?? 'n/a'}, current=${data?.current ?? data?.page ?? 'n/a'}, size=${data?.size ?? 'n/a'}`, options) + return { response, data } +} + +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 + // success, so retain that signal in the report. + for (const endpoint of HEALTH_ENDPOINTS) { + const response = await context.request('GET', endpoint.path) + const notReadyEnvelope = endpoint.allows503 && response.http === 503 && codeOf(response) === '200' + const data = notReadyEnvelope + ? response.body?.data + : context.apiSuccess(module, `${endpoint.service} 状态响应使用统一成功体`, response, + `HTTP=${response.http}, code=${codeOf(response)}`, isMissingRoute(response) ? blocker : {}) + if (notReadyEnvelope) { + context.addStep(module, `${endpoint.service} readiness 失败仍返回统一响应体`, response, true, + `HTTP=503, code=${codeOf(response)}, status=${data?.status || 'missing'}`) + } + const shape = data && data.service === endpoint.service && typeof data.status === 'string' + && (endpoint.service !== 'warehouse-service' || typeof data.message === 'string') + const ready = response.http === 200 && codeOf(response) === '200' && data?.status === 'UP' + const notReady = endpoint.allows503 && response.http === 503 && codeOf(response) === '200' + && data && data.service === endpoint.service && data.status !== 'UP' && typeof data.message === 'string' + context.assert(module, `${endpoint.service} 状态字段完整且 readiness 语义明确`, response, + shape && (ready || notReady), + `service=${data?.service || 'missing'}, status=${data?.status || 'missing'}, readiness=${ready ? 'UP' : notReady ? 'NOT_READY' : 'invalid'}`, + isMissingRoute(response) ? blocker : {}) + } + + // Gateway must reject both absent and malformed bearer credentials before a + // platform handler is reached. This specifically guards against accidental + // route-level permitAll changes. No token or response body is persisted. + for (const [name, token] of [['未携带令牌', ''], ['伪造令牌', 'invalid-admin-token']]) { + const response = await context.request('GET', '/api/platform/v1/orders/status', undefined, token) + const rejected = response.http === 401 && ['A0401', '401'].includes(codeOf(response)) + context.addStep(module, `平台接口${name}返回 401`, response, rejected, + `HTTP=${response.http}, code=${codeOf(response)}`) + } + + // A route that exists but is not protected is an authorization regression; + // keep the check separate so a service outage does not look like a pass. + const protectedResponse = await context.request('GET', '/api/platform/v1/orders/status', undefined, '') + context.assert(module, '未认证请求不返回业务成功码', protectedResponse, + !(protectedResponse.http === 200 && codeOf(protectedResponse) === '200'), + `HTTP=${protectedResponse.http}, code=${codeOf(protectedResponse)}`) + + // Warehouse/master-data read-only boundary checks. All resources are + // platform-public (tenantId=0); passing a customer scope must fail instead + // of silently widening or narrowing the result set. + const warehousePage = await checkPage(context, module, '仓库', + '/api/platform/v1/warehouse-admin?page=1&size=5&status=ACTIVE', { scenario: 'master-data-readonly' }) + const warehouseId = String(warehousePage.data?.records?.[0]?.id || '') + if (warehouseId) { + await checkPage(context, module, '仓库筛选', + `/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=0&warehouseType=${encodeURIComponent(warehousePage.data.records[0].warehouseType || 'DOMESTIC')}`, + { scenario: 'master-data-readonly' }) + } + 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=${encodeURIComponent(customerId)}`], + ]) { + const response = await context.request('GET', path) + context.addStep(module, `${name}安全失败`, response, isBusinessFailure(response), + `HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'master-data-readonly', deploymentBlocker: isMissingRoute(response) }) + } + + const locationBase = '/api/platform/v1/locations?page=1&size=5&tenantId=0' + const palletBase = '/api/platform/v1/pallets?page=1&size=5&tenantId=0' + const locations = await checkPage(context, module, '库位', locationBase, { scenario: 'warehouse-resources' }) + const pallets = await checkPage(context, module, '托盘', palletBase, { scenario: 'warehouse-resources' }) + if (warehouseId) { + await checkPage(context, module, '库位仓库筛选', `${locationBase}&warehouseId=${warehouseId}`, { scenario: 'warehouse-resources' }) + await checkPage(context, module, '托盘仓库筛选', `${palletBase}&warehouseId=${warehouseId}`, { scenario: 'warehouse-resources' }) + } + const resourceScopeChecks = [ + ['库位客户作用域', `/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) { + const response = await context.request('GET', path) + context.addStep(module, `${name}安全失败`, response, isBusinessFailure(response), + `HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'warehouse-resources', deploymentBlocker: isMissingRoute(response) }) + } + // A stale version is checked without identifying or mutating an existing + // resource; the sentinel ID must be rejected as not found/invalid. + for (const [name, path] of [ + ['库位过期版本/不存在资源', '/api/platform/v1/locations/999999999999?tenantId=0&warehouseId=999999999999'], + ['托盘占用资源保护边界', '/api/platform/v1/pallets/999999999999?tenantId=0&warehouseId=999999999999'], + ]) { + const response = await context.request('GET', path) + context.addStep(module, `${name}不泄露或误修改数据`, response, isBusinessFailure(response), + `HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'warehouse-resources', deploymentBlocker: isMissingRoute(response) }) + } + + // Shared express/partner master data: status/type filters and safe template + // downloads. We do not upload arbitrary or real customer workbooks. + for (const [label, base, invalid] of [ + ['快递公司', '/api/platform/v1/express-companies', 'UNKNOWN'], + ['往来单位', '/api/platform/v1/partners', 'UNKNOWN_TYPE'], + ]) { + await checkPage(context, module, label, `${base}?page=1&size=5&tenantId=0&status=ACTIVE`, { scenario: 'shared-master-data' }) + const inactive = await checkPage(context, module, `${label}停用筛选`, `${base}?page=1&size=5&tenantId=0&status=INACTIVE`, { scenario: 'shared-master-data' }) + context.assert(module, `${label}状态枚举过滤不混入其他状态`, inactive.response, + inactive.data?.records?.every?.((item) => item.status === 'INACTIVE'), `records=${inactive.data?.records?.length ?? 'n/a'}`, { scenario: 'shared-master-data' }) + const invalidPath = label === '往来单位' + ? `${base}?page=1&size=5&tenantId=0&types=${invalid}` + : `${base}?page=1&size=5&tenantId=0&status=${invalid}` + const invalidResponse = await context.request('GET', invalidPath) + context.addStep(module, `${label}无效筛选参数安全失败`, invalidResponse, isBusinessFailure(invalidResponse), + `HTTP=${invalidResponse.http}, code=${codeOf(invalidResponse)}`, { scenario: 'shared-master-data', deploymentBlocker: isMissingRoute(invalidResponse) }) + } + for (const [label, path] of [ + ['仓库导入模板', '/api/platform/v1/warehouse-admin/import-template'], + ['快递公司导入模板', '/api/platform/v1/express-companies/import-template'], + ['往来单位导入模板', '/api/platform/v1/partners/import-template'], + ]) { + const response = await context.requestBinary('GET', path) + context.addStep(module, `${label}为 XLSX 且不记录二进制`, response, xlsx(response), + `HTTP=${response.http}, bytes=${response.bytes?.length ?? 0}, contentType=${response.contentType || 'missing'}`, + { scenario: 'shared-master-data', deploymentBlocker: isMissingRoute(response) }) + } + + // Price-book lifecycle is read-only here; pricing.mjs remains the sole owner + // of uploads/publication. Verify all supported status filters and source + // download evidence from an already existing active/history version. + const pricePath = '/api/platform/v1/fba-pricing/carrier-rates/price-books' + for (const status of ['PENDING_CONFIRMATION', 'ACTIVE', 'SUPERSEDED']) { + const response = await context.request('GET', `${pricePath}?status=${status}`) + const versions = context.apiSuccess(module, `船期价格表 ${status} 状态过滤`, response, `count=${Array.isArray(response.body?.data) ? response.body.data.length : 'n/a'}`, { scenario: 'price-book-readonly', deploymentBlocker: isMissingRoute(response) }) + context.assert(module, `船期价格表 ${status} 结果状态一致`, response, + Array.isArray(versions) && versions.every((item) => item.status === status), `count=${versions?.length ?? 'n/a'}`, { scenario: 'price-book-readonly', deploymentBlocker: isMissingRoute(response) }) + } + const activeVersionsResponse = await context.request('GET', `${pricePath}?status=ACTIVE`) + const activeVersions = context.apiSuccess(module, '读取现有生效价格版本用于下载安全检查', activeVersionsResponse, '', { scenario: 'price-book-readonly', deploymentBlocker: isMissingRoute(activeVersionsResponse) }) + const activeVersionId = String(activeVersions?.[0]?.id || '') + if (activeVersionId) { + const sourceResponse = await context.requestBinary('GET', `${pricePath}/${encodeURIComponent(activeVersionId)}/source`) + context.addStep(module, '现有生效价格源文件下载为 XLSX', sourceResponse, xlsx(sourceResponse), + `versionId=${activeVersionId}, bytes=${sourceResponse.bytes?.length ?? 0}, contentType=${sourceResponse.contentType || 'missing'}`, { scenario: 'price-book-readonly' }) + } else { + context.assert(module, '存在可供安全下载核验的生效价格版本', activeVersionsResponse, false, '当前无 ACTIVE 价格版本,无法安全执行源文件下载核验', { scenario: 'price-book-readonly', deploymentBlocker: true }) + } + + // Download center: list/status pagination and negative access checks only; + // downloads.mjs owns task creation and successful-file polling. + for (const status of ['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED', 'EXPIRED']) { + const response = await context.request('GET', `/api/platform/v1/files/export-tasks?page=1&size=5&status=${status}`) + const data = context.apiSuccess(module, `下载中心 ${status} 状态分页`, response, '', { scenario: 'download-readonly', deploymentBlocker: isMissingRoute(response) }) + context.assert(module, `下载中心 ${status} 过滤结果状态一致`, response, + pageShape(data) && data.records.every((item) => item.status === status), `records=${data?.records?.length ?? 'n/a'}`, { scenario: 'download-readonly', deploymentBlocker: isMissingRoute(response) }) + } + for (const [name, path] of [ + ['不存在下载任务详情', '/api/platform/v1/files/export-tasks/999999999999'], + ['不存在下载任务文件', '/api/platform/v1/files/export-tasks/999999999999/download'], + ]) { + const response = await context.request('GET', path, undefined, context.token) + context.addStep(module, `${name}安全失败`, response, isBusinessFailure(response), `HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'download-readonly', deploymentBlocker: isMissingRoute(response) }) + } + const unauthDownload = await context.request('GET', '/api/platform/v1/files/export-tasks/999999999999/download', undefined, '') + context.addStep(module, '未授权下载请求被拒绝', unauthDownload, unauthDownload.http === 401, + `HTTP=${unauthDownload.http}, code=${codeOf(unauthDownload)}`, { scenario: 'download-readonly' }) + + // IAM lists are intentionally read-only. The RBAC endpoints are list-shaped + // arrays (not paginated in the current contract); retain that fact instead of + // pretending pagination exists, and verify built-in markers are exposed. + for (const [label, path, builtInField] of [ + ['平台用户', '/api/platform/v1/iam/users?page=1&size=5', 'protectedAccount'], + ['平台角色', '/api/platform/v1/iam/roles', 'roleType'], + ['菜单权限', '/api/platform/v1/iam/permissions', 'builtIn'], + ]) { + const response = await context.request('GET', path) + const data = context.apiSuccess(module, `${label}只读列表`, response, '', { scenario: 'iam-readonly', deploymentBlocker: isMissingRoute(response) }) + const records = label === '平台用户' ? data?.records : data + context.assert(module, `${label}列表结构正确且包含内置保护标识`, response, + label === '平台用户' ? pageShape(data) + : Array.isArray(records) && records.some((item) => label === '平台角色' + ? item.roleType === 'SYSTEM' + : Object.hasOwn(item, builtInField)), + `records=${records?.length ?? 'n/a'}, builtInField=${builtInField}`, { scenario: 'iam-readonly', deploymentBlocker: isMissingRoute(response) }) + } + for (const [name, path] of [ + ['角色列表伪造令牌', '/api/platform/v1/iam/roles'], + ['菜单权限未携带令牌', '/api/platform/v1/iam/permissions'], + ]) { + const response = await context.request('GET', path, undefined, name.includes('伪造') ? 'invalid-admin-token' : '') + context.addStep(module, `${name}拒绝访问`, response, response.http === 401, + `HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'iam-readonly' }) + } +} diff --git a/scripts/admin-test-env-modules/suppliers-contract.test.mjs b/scripts/admin-test-env-modules/suppliers-contract.test.mjs new file mode 100644 index 000000000..e07505397 --- /dev/null +++ b/scripts/admin-test-env-modules/suppliers-contract.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +const source=readFileSync(new URL('./suppliers.mjs',import.meta.url),'utf8') +const orchestrator=readFileSync(new URL('../admin-test-env-acceptance.mjs',import.meta.url),'utf8') +const openapi=readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml',import.meta.url),'utf8') + +test('supplier acceptance covers full CRUD-like lifecycle, carrier matrix and boundary failures',()=>{ + for(const marker of ['列表分页与订舱筛选','查询四类供应商统计','新增供应商','查询供应商详情','编辑供应商','停用供应商','重复名称返回冲突','tenantId非0被拒绝','非BOOKING携带carrierCodes返回A0400并保留链路ID','客户账号调用平台供应商接口被拒绝','requestId','traceId']) assert.match(source,new RegExp(marker)) + assert.match(source,/carrierCodes:\['MATSON','COSCO'\]/) + assert.match(source,/sameCarrierCodes\(detail\?\.carrierCodes,\['MATSON','COSCO'\]\)/) + assert.doesNotMatch(source,/scope\s*:/) +}) +test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/CONFIGURED_MODULES = \[[^\]]*'suppliers'/s)) +test('supplier fixture is retained only as inactive because API has no delete contract',()=>{assert.match(source,/finalStatus:inactive\?'INACTIVE'/);assert.doesNotMatch(source,/context\.request\('DELETE'/)}) +test('supplier OpenAPI exposes the internal active category validation contract without scope',()=>{ + for(const marker of ['/internal/warehouse/v1/suppliers/{supplierId}','X-Internal-Service-Token','requireActive','InternalSnapshot','SupplierPageResponse','platform:supplier:list']) assert.match(openapi,new RegExp(marker.replace(/[{}]/g,'\\$&'))) + assert.doesNotMatch(openapi,/\bscope\b/) + assert.doesNotMatch(openapi,/platform:supplier:view/) +}) diff --git a/scripts/admin-test-env-modules/suppliers.mjs b/scripts/admin-test-env-modules/suppliers.mjs new file mode 100644 index 000000000..c4714fb59 --- /dev/null +++ b/scripts/admin-test-env-modules/suppliers.mjs @@ -0,0 +1,69 @@ +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 || '' +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 } +function records(page) { return Array.isArray(page?.records) ? page.records : [] } +function id(value) { return value == null ? '' : String(value) } +function traced(response) { return Boolean(response?.requestId && response?.traceId) } +function sameCarrierCodes(actual, expected) { + return Array.isArray(actual) && [...actual].sort().join('|') === [...expected].sort().join('|') +} +function query(values = {}) { const p = new URLSearchParams(); for (const [k,v] of Object.entries(values)) if (v !== '' && v != null) p.set(k,String(v)); return `${BASE}${p.size ? `?${p}` : ''}` } +function payload(marker, overrides = {}) { return { name:`供应商验收-${marker}`,category:'BOOKING',carrierCodes:['MATSON','COSCO'],region:'宁波',legalRepresentative:'验收法人',contactName:'验收联系人',phone:'13800000000',email:'supplier-acceptance@example.com',address:'验收地址',businessLicenseNo:`ACPT${marker}`.slice(0,64),licenseFileId:null,status:'ACTIVE',settlementPeriod:'MONTHLY_30',remark:marker,...overrides } } + +export async function run(context) { + const marker=context.marker.replace(/[^0-9A-Za-z]/g,'').slice(-20) + const listResponse=await context.request('GET',query({tenantId:0,category:'BOOKING',page:1,size:20})) + const list=context.apiSuccess(MODULE,'列表分页与订舱筛选',listResponse,'category=BOOKING',OPTIONS) + context.assert(MODULE,'列表分页结构完整',listResponse,Array.isArray(list?.records)&&Number(list?.page)===1&&Number(list?.size)===20&&Number(list?.total)>=0&&Number(list?.pages)>=0,`records=${records(list).length}, total=${list?.total??'missing'}`,OPTIONS) + + const statsResponse=await context.request('GET',`${BASE}/stats?tenantId=0`) + const stats=context.apiSuccess(MODULE,'查询四类供应商统计',statsResponse,'',OPTIONS) + context.assert(MODULE,'统计字段完整',statsResponse,['booking','customs','trucking','certificate'].every(k=>Number(stats?.[k])>=0),JSON.stringify(stats),OPTIONS) + + const createBody=payload(marker) + const createResponse=await context.request('POST',`${BASE}?tenantId=0`,createBody) + const supplierId=id(context.apiSuccess(MODULE,'新增供应商',createResponse,`name=${createBody.name}`,OPTIONS)) + context.assert(MODULE,'新增返回供应商ID',createResponse,Boolean(supplierId),`supplierId=${supplierId||'missing'}`,OPTIONS) + if(!supplierId)return + let inactive=false + try { + const detailResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`) + const detail=context.apiSuccess(MODULE,'查询供应商详情',detailResponse,'',OPTIONS) + context.assert(MODULE,'详情字段与平台作用域及多船司关联正确',detailResponse,id(detail?.id)===supplierId&&id(detail?.tenantId)==='0'&&detail?.name===createBody.name&&!Object.hasOwn(detail,'scope')&&sameCarrierCodes(detail?.carrierCodes,['MATSON','COSCO']),`id=${id(detail?.id)}, tenantId=${id(detail?.tenantId)}, carrierCodes=${JSON.stringify(detail?.carrierCodes)}, scopeExposed=${Object.hasOwn(detail||{},'scope')}`,OPTIONS) + + const duplicateResponse=await context.request('POST',`${BASE}?tenantId=0`,payload(`${marker}D`,{name:createBody.name})) + context.assert(MODULE,'重复名称返回冲突并保留链路ID',duplicateResponse,code(duplicateResponse,'A0409')&&traced(duplicateResponse),`code=${duplicateResponse.body?.code}, requestId=${duplicateResponse.requestId||'missing'}, traceId=${duplicateResponse.traceId||'missing'}`,OPTIONS) + + const tenantResponse=await context.request('GET',query({tenantId:1,page:1,size:20})) + context.assert(MODULE,'tenantId非0被拒绝',tenantResponse,code(tenantResponse,'A0400')&&traced(tenantResponse),`code=${tenantResponse.body?.code}, requestId=${tenantResponse.requestId||'missing'}, traceId=${tenantResponse.traceId||'missing'}`,OPTIONS) + + const nonBookingCarrierResponse=await context.request('POST',`${BASE}?tenantId=0`,payload(`${marker}NB`,{name:`非订舱供应商非法船司-${marker}`,category:'CUSTOMS',carrierCodes:['MATSON']})) + context.assert(MODULE,'非BOOKING携带carrierCodes返回A0400并保留链路ID',nonBookingCarrierResponse,code(nonBookingCarrierResponse,'A0400')&&traced(nonBookingCarrierResponse),`code=${nonBookingCarrierResponse.body?.code}, requestId=${nonBookingCarrierResponse.requestId||'missing'}, traceId=${nonBookingCarrierResponse.traceId||'missing'}`,OPTIONS) + + const merchantLogin=await context.request('POST','/api/auth/login',{loginId:MERCHANT_USERNAME,password:MERCHANT_PASSWORD},'') + const merchantToken=ok(merchantLogin)?merchantLogin.body?.data?.accessToken:'' + const forbidden=merchantToken?await context.request('GET',query({tenantId:0,page:1,size:20}),undefined,merchantToken):merchantLogin + context.assert(MODULE,'客户账号调用平台供应商接口被拒绝',forbidden,Boolean(merchantToken)&&(forbidden.http===403||code(forbidden,'A0403'))&&traced(forbidden),`HTTP=${forbidden.http}, code=${forbidden.body?.code??'missing'}, requestId=${forbidden.requestId||'missing'}, traceId=${forbidden.traceId||'missing'}`,OPTIONS) + + const updateBody=payload(marker,{name:`供应商验收已编辑-${marker}`,contactName:'编辑联系人',settlementPeriod:'MONTHLY_45'}) + const updateResponse=await context.request('PUT',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`,updateBody) + context.apiSuccess(MODULE,'编辑供应商',updateResponse,'',OPTIONS) + const editedResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`) + const edited=context.apiSuccess(MODULE,'回查编辑结果',editedResponse,'',OPTIONS) + context.assert(MODULE,'编辑字段及多船司关联已持久化',editedResponse,edited?.name===updateBody.name&&!Object.hasOwn(edited,'scope')&&edited?.contactName==='编辑联系人'&&edited?.settlementPeriod==='MONTHLY_45'&&sameCarrierCodes(edited?.carrierCodes,['MATSON','COSCO']),`name=${edited?.name||'missing'}, carrierCodes=${JSON.stringify(edited?.carrierCodes)}, scopeExposed=${Object.hasOwn(edited||{},'scope')}`,OPTIONS) + + const disableResponse=await context.request('PATCH',`${BASE}/${encodeURIComponent(supplierId)}/status?tenantId=0`,{status:'INACTIVE'}) + context.apiSuccess(MODULE,'停用供应商',disableResponse,'',OPTIONS); inactive=ok(disableResponse) + const inactiveResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`) + const inactiveDetail=context.apiSuccess(MODULE,'回查停用状态',inactiveResponse,'',OPTIONS) + context.assert(MODULE,'停用状态已持久化',inactiveResponse,inactiveDetail?.status==='INACTIVE',`status=${inactiveDetail?.status||'missing'}`,OPTIONS) + } finally { + if(!inactive){const cleanup=await context.request('PATCH',`${BASE}/${encodeURIComponent(supplierId)}/status?tenantId=0`,{status:'INACTIVE'}); inactive=ok(cleanup); context.assert(MODULE,'异常路径停用验收供应商',cleanup,inactive,`supplierId=${supplierId}`,OPTIONS)} + context.createdResources.suppliers={marker:context.marker,supplierId,finalStatus:inactive?'INACTIVE':'UNKNOWN',retained:true} + } +} diff --git a/scripts/admin-test-env-modules/system.mjs b/scripts/admin-test-env-modules/system.mjs new file mode 100644 index 000000000..9b2ad4273 --- /dev/null +++ b/scripts/admin-test-env-modules/system.mjs @@ -0,0 +1,192 @@ +function id(value) { return value == null ? '' : String(value) } + +export async function run(context) { + const module = 'system' + const suffix = context.marker.replace(/\D/g, '').slice(-12) + + const permissionsResponse = await context.request('GET', '/api/platform/v1/iam/permissions') + const permissions = context.apiSuccess(module, '查询菜单权限资源', permissionsResponse) + context.assert(module, '权限资源返回数组', permissionsResponse, Array.isArray(permissions), + `count=${Array.isArray(permissions) ? permissions.length : 'n/a'}`) + + const permissionPayload = { + type: 'BUTTON', + code: `platform:acceptance:${suffix}`, + name: `Admin验收权限-${suffix}`, + parentId: null, + routePath: null, + httpMethod: null, + sortNo: 9999, + } + const createPermissionResponse = await context.request('POST', '/api/platform/v1/iam/permissions', permissionPayload) + const permission = context.apiSuccess(module, '创建自定义权限资源', createPermissionResponse) + const permissionId = id(permission?.id) + context.assert(module, '权限创建字段正确', createPermissionResponse, + permissionId && permission?.permissionCode === permissionPayload.code && permission?.builtIn === false, + `permissionId=${permissionId || 'missing'}`) + if (!permissionId) throw new Error('权限资源创建失败,系统模块无法继续') + context.createdResources.systemPermission = { id: permissionId, code: permissionPayload.code } + + const additionalPermissionIds = [] + for (const definition of [ + { type: 'MENU', routePath: `/acceptance-${suffix}`, httpMethod: null }, + { type: 'PAGE', routePath: `/acceptance-${suffix}/detail`, httpMethod: null }, + { type: 'API', routePath: `/api/platform/v1/acceptance/${suffix}`, httpMethod: 'GET' }, + ]) { + const payload = { + type: definition.type, + code: `platform:acceptance:${definition.type.toLowerCase()}:${suffix}`, + name: `Admin验收${definition.type}-${suffix}`, + parentId: null, + routePath: definition.routePath, + httpMethod: definition.httpMethod, + sortNo: 9999, + } + const response = await context.request('POST', '/api/platform/v1/iam/permissions', payload) + const created = context.apiSuccess(module, `创建 ${definition.type} 权限资源`, response) + const createdId = id(created?.id) + context.assert(module, `${definition.type} 类型与端点字段正确回显`, response, + createdId && created?.permissionType === definition.type && created?.routePath === definition.routePath + && (created?.httpMethod ?? null) === definition.httpMethod, + `permissionId=${createdId || 'missing'}, type=${created?.permissionType || 'missing'}, method=${created?.httpMethod ?? 'null'}`) + if (createdId) additionalPermissionIds.push(createdId) + } + + const updatePermissionResponse = await context.request('PUT', `/api/platform/v1/iam/permissions/${permissionId}`, { + name: `${permissionPayload.name}-已编辑`, parentId: null, routePath: null, httpMethod: null, sortNo: 9998, + }) + const updatedPermission = context.apiSuccess(module, '编辑自定义权限资源', updatePermissionResponse) + context.assert(module, '权限编辑正确回显', updatePermissionResponse, + updatedPermission?.permissionName === `${permissionPayload.name}-已编辑` && Number(updatedPermission?.sortNo) === 9998, + `permissionId=${permissionId}`) + for (const status of ['DISABLED', 'ACTIVE']) { + const response = await context.request('PUT', `/api/platform/v1/iam/permissions/${permissionId}/status`, { status }) + const changed = context.apiSuccess(module, `${status === 'ACTIVE' ? '启用' : '停用'}自定义权限`, response) + context.assert(module, `权限状态回显 ${status}`, response, changed?.status === status, `status=${changed?.status ?? 'missing'}`) + } + + const rolePayload = { roleCode: `ADMIN_ACCEPT_${suffix}`, roleName: `Admin验收角色-${suffix}`, description: context.marker } + const createRoleResponse = await context.request('POST', '/api/platform/v1/iam/roles', rolePayload) + const role = context.apiSuccess(module, '创建平台自定义角色', createRoleResponse) + const roleId = id(role?.id) + context.assert(module, '角色创建字段正确', createRoleResponse, + roleId && role?.roleCode === rolePayload.roleCode && role?.roleType === 'CUSTOM', `roleId=${roleId || 'missing'}`) + if (!roleId) throw new Error('角色创建失败,系统模块无法继续') + context.createdResources.systemRole = { id: roleId, code: rolePayload.roleCode, finalStatus: 'DISABLED', retainedBecauseAssigned: true } + + const editRoleResponse = await context.request('PUT', `/api/platform/v1/iam/roles/${roleId}`, { + roleName: `${rolePayload.roleName}-已编辑`, description: `${context.marker}-EDITED`, + }) + const editedRole = context.apiSuccess(module, '编辑平台自定义角色', editRoleResponse) + context.assert(module, '角色编辑正确回显', editRoleResponse, + editedRole?.roleName === `${rolePayload.roleName}-已编辑`, `roleId=${roleId}`) + const grantResponse = await context.request('PUT', '/api/platform/v1/iam/roles/permissions', { roleId, permissionIds: [permissionId] }) + context.apiSuccess(module, '角色全量保存权限', grantResponse) + const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles') + const roles = context.apiSuccess(module, '回查平台角色列表', rolesResponse) + const listedRole = roles?.find?.((item) => id(item.id) === roleId) + context.assert(module, '角色权限 ID 正确回显', rolesResponse, + listedRole && listedRole.permissionIds?.map(id).includes(permissionId), + `permissionIds=${listedRole?.permissionIds?.map(id).join(',') || 'missing'}`) + for (const status of ['DISABLED', 'ACTIVE']) { + const response = await context.request('PUT', `/api/platform/v1/iam/roles/${roleId}/status`, { status }) + const changed = context.apiSuccess(module, `${status === 'ACTIVE' ? '启用' : '停用'}自定义角色`, response) + context.assert(module, `角色状态回显 ${status}`, response, changed?.status === status, `status=${changed?.status ?? 'missing'}`) + } + + const optionsResponse = await context.request('GET', '/api/platform/v1/iam/users/options') + const options = context.apiSuccess(module, '查询内部用户可选平台角色', optionsResponse) + context.assert(module, '用户选项包含平台与测试角色', optionsResponse, + options?.tenants?.some((item) => id(item.id) === '0') && options?.roles?.some((item) => id(item.id) === roleId), + `tenants=${options?.tenants?.length ?? 'n/a'}, roles=${options?.roles?.length ?? 'n/a'}`) + + const phone = await context.findAvailablePhone(module) + const username = `admin_accept_${suffix}` + const initialPassword = `Accept${suffix}A1` + const resetPassword = `Reset${suffix}B2` + const userPayload = { + username, displayName: `Admin验收用户-${suffix}`, phone, + email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], + password: initialPassword, status: 'ACTIVE', + } + const createUserResponse = await context.request('POST', '/api/platform/v1/iam/users', userPayload) + const user = context.apiSuccess(module, '创建平台内部用户', createUserResponse) + const membershipId = id(user?.membershipId) + context.assert(module, '内部用户账号、平台范围和角色正确', createUserResponse, + membershipId && user?.username === username && id(user?.tenantId) === '0' && user?.roleIds?.map(id).includes(roleId), + `membershipId=${membershipId || 'missing'}`) + if (!membershipId) throw new Error('平台内部用户创建失败,系统模块无法继续') + context.createdResources.systemUser = { membershipId, username, finalStatus: 'DISABLED' } + + const pageResponse = await context.request('GET', `/api/platform/v1/iam/users?page=1&size=20&keyword=${username}`) + const page = context.apiSuccess(module, '按账号查询平台内部用户', pageResponse) + const listedUser = page?.records?.find((item) => id(item.membershipId) === membershipId) + const missingUserFields = listedUser ? requiredUserFields().filter((field) => !Object.hasOwn(listedUser, field)) : requiredUserFields() + context.assert(module, '用户列表包含新账号且字段完整', pageResponse, + listedUser && missingUserFields.length === 0 + && (!Object.hasOwn(listedUser, 'lastLoginAt') || typeof listedUser.lastLoginAt === 'string'), + `records=${page?.records?.length ?? 'n/a'}, missing=${missingUserFields.join(',') || 'none'}, lastLoginAt=${listedUser?.lastLoginAt ?? 'omitted'}`) + + const updateUserPayload = { + displayName: `${userPayload.displayName}-已编辑`, phone, + email: `edited-${userPayload.email}`, tenantId: '0', roleIds: [roleId], + } + const updateUserResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}`, updateUserPayload) + const updatedUser = context.apiSuccess(module, '编辑平台内部用户', updateUserResponse) + context.assert(module, '用户编辑正确回显', updateUserResponse, + updatedUser?.displayName === updateUserPayload.displayName && updatedUser?.email === updateUserPayload.email, + `membershipId=${membershipId}`) + + const loginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: initialPassword }, '') + const loginPassed = loginResponse.http === 200 && String(loginResponse.body?.code) === '200' && Boolean(loginResponse.body?.data?.accessToken) + context.addStep(module, '新建内部用户可使用初始密码登录', loginResponse, loginPassed, `username=${username}`) + + const resetResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/password`, { newPassword: resetPassword }) + context.apiSuccess(module, '重置平台内部用户密码', resetResponse) + const resetLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: resetPassword }, '') + const resetLoginPassed = resetLoginResponse.http === 200 && String(resetLoginResponse.body?.code) === '200' && Boolean(resetLoginResponse.body?.data?.accessToken) + context.addStep(module, '内部用户使用重置密码登录', resetLoginResponse, resetLoginPassed, `username=${username}`) + if (context.options.modules.includes('phase4-cross-region-events')) { + const restrictedOverseasToken = resetLoginResponse.body?.data?.overseasAccessToken || '' + context.assert(module, '受限角色登录同时签发海外Token', resetLoginResponse, + 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) + context.assert(module, '内部用户停用状态正确', disableUserResponse, disabledUser?.status === 'DISABLED', `status=${disabledUser?.status ?? 'missing'}`) + const disabledLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: resetPassword }, '') + context.addStep(module, '停用用户禁止登录', disabledLoginResponse, + disabledLoginResponse.http === 200 && String(disabledLoginResponse.body?.code) !== '200', + `HTTP=${disabledLoginResponse.http}, code=${disabledLoginResponse.body?.code ?? 'missing'}`) + + const clearPermissionResponse = await context.request('PUT', '/api/platform/v1/iam/roles/permissions', { roleId, permissionIds: [] }) + context.apiSuccess(module, '清空测试角色权限', clearPermissionResponse) + const deleteRoleResponse = await context.request('DELETE', `/api/platform/v1/iam/roles/${roleId}`) + context.expectConflict(module, '已分配成员的角色禁止删除', deleteRoleResponse, 'system-role-protection') + const finalDisableRoleResponse = await context.request('PUT', `/api/platform/v1/iam/roles/${roleId}/status`, { status: 'DISABLED' }) + const finalRole = context.apiSuccess(module, '清理时停用测试角色', finalDisableRoleResponse) + context.assert(module, '保留测试角色处于停用状态', finalDisableRoleResponse, finalRole?.status === 'DISABLED', + `status=${finalRole?.status ?? 'missing'}`) + const deletePermissionResponse = await context.request('DELETE', `/api/platform/v1/iam/permissions/${permissionId}`) + context.apiSuccess(module, '删除临时权限资源', deletePermissionResponse) + for (const additionalPermissionId of additionalPermissionIds) { + const response = await context.request('DELETE', `/api/platform/v1/iam/permissions/${additionalPermissionId}`) + context.apiSuccess(module, '删除四类型验收权限资源', response, `permissionId=${additionalPermissionId}`) + } +} + +function requiredUserFields() { + return ['membershipId', 'userId', 'authUserId', 'username', 'displayName', 'phone', 'email', + 'tenantId', 'tenantName', 'memberType', 'status', 'roleIds', 'roleNames', 'protectedAccount'] +} diff --git a/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs b/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs new file mode 100644 index 000000000..d750c8115 --- /dev/null +++ b/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs @@ -0,0 +1,41 @@ +export const IMMUTABLE_TAILLEG_EVENT_FIXTURE = Object.freeze({ + eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b', + status: 'PROCESSED', + payloadMutable: false, + reusableForAcceptance: false, +}) + +export const TAILLEG_ORDER_NUMBER = Object.freeze({ + pattern: /^LMO-\d{6}-\d{4}$/, + timeZone: 'Asia/Shanghai', + sharedBy: 'containerNo', +}) + +export function validOwnerSnapshots(orders, expectedCount = orders?.length) { + if (!Array.isArray(orders) || orders.length !== expectedCount || orders.length === 0) return false + const owners = new Set() + for (const order of orders) { + const customerId = String(order?.customerId ?? '').trim() + const customerName = String(order?.customerName ?? '').trim() + if (!customerId || !customerName) return false + owners.add(`${customerId}\u0000${customerName}`) + } + return owners.size === 1 +} + +export function validContainerOrderGroup(rows) { + if (!Array.isArray(rows) || rows.length === 0) return false + const containerNos = new Set(rows.map((row) => String(row?.containerNo ?? '').trim())) + const orderNos = new Set(rows.map((row) => String(row?.orderNo ?? '').trim())) + const sourceInboundIds = rows.map((row) => String(row?.sourceInboundId ?? '').trim()) + return containerNos.size === 1 && !containerNos.has('') + && orderNos.size === 1 && TAILLEG_ORDER_NUMBER.pattern.test([...orderNos][0]) + && sourceInboundIds.every(Boolean) && new Set(sourceInboundIds).size === rows.length +} + +export function validEventReplay(before, after) { + return Number(before?.orderCount) === Number(after?.orderCount) + && String(before?.orderNo ?? '') === String(after?.orderNo ?? '') + && Number(before?.detailCount) === Number(after?.detailCount) + && Number(before?.historyCount) === Number(after?.historyCount) +} diff --git a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs new file mode 100644 index 000000000..1b2d0c2db --- /dev/null +++ b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { + CONTAINER_CONVERT_ENDPOINT, + CONTAINER_CONVERT_EXPECTED_ORDERS, + CONTAINER_SUMMARY_ENDPOINT, + groupOrdersByCustomer, + stableReplay, + validContainerBatchResult, + validContainerSummaryPage, + zeroDrift, +} from './container-inbound-plan-batch-contract.mjs' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') +const scenario = read('phase4-container-inbound-plan-batch.mjs') +const controller = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java') +const service = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java') +const openapi = read('../../lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml') +const adminApi = read('../../../apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') +const adminView = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue') + +function orders() { + return Array.from({ length: 15 }, (_, index) => ({ + orderId: String(9_000 + index), customerId: index < 10 ? '101' : '202', + packageCount: 2, volumeCbm: '1.2500', productQuantity: 20, + palletCount: 1, volumeWeightKg: '18.750', + estimatedArrivalAt: '2026-09-01T08:30:00', actualArrivalAt: null, estimatedWarehouseAt: null, + })) +} + +test('semantic contract creates one independent plan per order even for the same customer', () => { + const source = orders() + assert.equal(CONTAINER_CONVERT_EXPECTED_ORDERS, 15) + assert.equal(groupOrdersByCustomer(source).size, 2) + const result = source.map((order, index) => ({ + customerId: order.customerId, planNo: `LIP-${index + 1}`, status: 'PENDING_ASSIGNMENT', + lines: [{ orderId: order.orderId }], pallets: [{}], allocations: [{}], + })) + assert.equal(validContainerBatchResult(result, source), true) +}) + +test('server container summary remains complete when the order page would contain only ten rows', () => { + assert.equal(CONTAINER_SUMMARY_ENDPOINT, '/api/platform/v1/last-leg/orders/container-summaries') + assert.equal(validContainerSummaryPage({ + records: [{ sourceContainerBookingId: 'CB-1', containerNo: 'C1', totalOrderCount: '15', inboundCodeCount: '15', + missingInboundCodeCount: 0, planCount: 0, plannedInboundCodeCount: 0, pendingPlanCount: 15, + allPlansReady: false }], + page: '1', size: '10', total: '1', pages: '1', + }, { sourceContainerBookingId: 'CB-1', containerNo: 'C1', totalOrderCount: 15, inboundCodeCount: 15, + missingInboundCodeCount: 0, planCount: 0, plannedInboundCodeCount: 0, pendingPlanCount: 15, + allPlansReady: false }), true) +}) + +test('atomic failure and replay helpers reject drift', () => { + const snapshot = { orders: [{ id: '1', status: 'PENDING_ASSIGNMENT' }], plans: [] } + assert.equal(zeroDrift(snapshot, structuredClone(snapshot)), true) + assert.equal(stableReplay({ plans: [{ planNo: 'LIP-1' }] }, { plans: [{ planNo: 'LIP-1' }] }), true) +}) + +test('HTTP scenario freezes normal, invalid, replay, concurrency, partial and PDA evidence', () => { + assert.equal(CONTAINER_CONVERT_ENDPOINT, '/api/platform/v1/last-leg/inbound-plans/convert-container') + for (const marker of ['P1整柜一次转换并逐订单生成独立计划', 'P1任一非法票整柜转换拒绝', + 'P1同键同请求重放', 'P1并发双击A', 'P1部分已转换整柜冲突', + 'P1服务端柜级摘要跨订单分页仍返回15票']) { + assert.match(scenario, new RegExp(marker)) + } + assert.match(scenario, /Promise\.all/) + assert.match(scenario, /Idempotency-Key/) + assert.match(scenario, /requestId/) + assert.match(scenario, /traceId/) +}) + +test('container API uses one stable long string identity', () => { + const endpoint = openapi.slice(openapi.indexOf(` ${CONTAINER_CONVERT_ENDPOINT}:`), + openapi.indexOf(' /api/platform/v1/last-leg/dispatches:', openapi.indexOf(` ${CONTAINER_CONVERT_ENDPOINT}:`))) + assert.match(endpoint, /required: \[sourceContainerBookingId, snapshotVersion, snapshotHash\]/) + assert.match(endpoint, /sourceContainerBookingId:\s*\{ type: string/) + assert.match(controller, /convertContainer/) + assert.match(adminApi, /sourceContainerBookingId: string/) + assert.match(adminView, /const bookingId = preview\.sourceContainerBookingId/) + assert.match(adminView, /convertLastMileContainerToInboundPlans\(\{\s*sourceContainerBookingId: bookingId,\s*snapshotVersion: preview\.snapshotVersion,\s*snapshotHash: preview\.snapshotHash,/) +}) + +test('backend batch conversion is one transaction and never merges same-customer orders', () => { + assert.match(controller, /ApiResponse<[^>]*(?:Batch|List|Container)[^>]*>/) + assert.match(service, /@Transactional[\s\S]{0,120}(?:convertContainer|convertBatch|convert)/) + assert.match(service, /(?:List|Container.*Response|Batch.*Response)/) + assert.match(service, /(?:for \([^)]*(?:order|source)|\.map\([^)]*(?:order|source))/) + assert.doesNotMatch(service, /groupingBy\([^)]*(?:customerId|getCustomerId)/) + assert.match(service, /来源柜存在不能转换的尾程订单/) + assert.match(service, /HttpStatus\.CONFLICT/) +}) + +test('Admin consumes a server-paged container summary and never derives 15 from the current order page', () => { + assert.match(adminApi, /listContainerPlanProgress/) + assert.match(adminApi, /container-summaries/) + assert.match(adminView, /listContainerPlanProgress\(/) + assert.match(adminView, /containerRows\.value\s*=\s*result\.records/) + assert.match(adminView, /containerTotal\.value\s*=\s*result\.total/) + assert.match(adminView, /containerOptions\.value\s*=\s*result\.records/) + assert.match(adminView, /if \(request !== containerOptionRequest\) return/) + assert.doesNotMatch(adminView, /(?:rows|result\.records)\.value?\.(?:reduce|filter|forEach)[\s\S]{0,300}containerNo/) +}) diff --git a/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs b/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs new file mode 100644 index 000000000..56515796a --- /dev/null +++ b/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { + EVENT_BRIDGE_RECOVERY_FIXTURE, + INTERNAL_TOKEN_COHORT_SERVICES, + recoveredEventBridgeFacts, + TEST_EVENT_BRIDGE_URLS, + validInternalTokenCohort, + validTestEventBridgeEnvironment, +} from './event-bridge-pending-recovery.mjs' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') +const cnCompose = read('../../deploy/docker-compose.yml') +const overseasCompose = read('../../deploy/docker-compose.overseas.yml') +const deployBuilt = read('../../deploy/scripts/deploy-built-service.sh') +const deployServices = read('../../deploy/scripts/deploy-services.sh') +const crossRegionHttp = read('phase4-cross-region-events.mjs') + +test('test bridge URLs use stable Compose DNS and internal port 9312 only', () => { + assert.deepEqual(TEST_EVENT_BRIDGE_URLS, { + LASTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-overseas:9312', + FIRSTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-cn:9312', + }) + assert.equal(validTestEventBridgeEnvironment(TEST_EVENT_BRIDGE_URLS), true) + assert.equal(validTestEventBridgeEnvironment({ + ...TEST_EVENT_BRIDGE_URLS, LASTLEG_EVENT_BRIDGE_URL: 'http://172.31.198.8:9313', + }), false) +}) + +test('Compose wires both producers to the stable service aliases used by test env', () => { + assert.match(cnCompose, /LASTLEG_EVENT_BRIDGE_URL:\s*\$\{LASTLEG_EVENT_BRIDGE_URL\}/) + assert.match(overseasCompose, + /FIRSTLEG_EVENT_BRIDGE_URL:\s*\$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.match(cnCompose, /aliases:\s*\[event-bridge-cn\]/) + assert.match(cnCompose, + /FINANCE_FEE_EVENT_CONSUMER_URL:\s*\$\{FINANCE_FEE_EVENT_CONSUMER_URL:-http:\/\/finance:9203\/internal\/finance\/v1\/business-fee-source-events\}/) + assert.match(overseasCompose, /aliases:\s*\[event-bridge-overseas\]/) + for (const source of [cnCompose, overseasCompose]) { + assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*(?:\d{1,3}\.){3}\d{1,3}/) + assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*:9313/) + } +}) + +test('all four recovery services inherit INTERNAL_SERVICE_TOKEN and deploy from one env file', () => { + assert.deepEqual(INTERNAL_TOKEN_COHORT_SERVICES, [ + 'firstleg', 'event-bridge-cn', 'event-bridge-overseas', 'lastleg-service', + ]) + assert.match(cnCompose, /x-common:\s*&common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/) + assert.match(overseasCompose, + /x-overseas-common:\s*&overseas-common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/) + assert.match(cnCompose, /firstleg:[\s\S]*<<:\s*\*common/) + assert.match(cnCompose, /event-bridge-service:[\s\S]*<<:\s*\*common/) + assert.match(overseasCompose, /lastleg-service:[\s\S]*<<:\s*\*overseas-common/) + assert.match(overseasCompose, /event-bridge-service:[\s\S]*<<:\s*\*overseas-common/) + for (const source of [deployBuilt, deployServices]) { + assert.match(source, /--env-file\s+"\$ENV_FILE"/) + } + assert.equal(validInternalTokenCohort({ + firstleg: 'revision-20260827', + 'event-bridge-cn': 'revision-20260827', + 'event-bridge-overseas': 'revision-20260827', + 'lastleg-service': 'revision-20260827', + }), true) + assert.equal(validInternalTokenCohort({ + firstleg: 'revision-20260827', + 'event-bridge-cn': 'revision-20260827', + 'event-bridge-overseas': 'stale-revision', + 'lastleg-service': 'revision-20260827', + }), false) +}) + +test('incident fixture records DNS recovery followed by retry81 token rejection', () => { + assert.deepEqual(EVENT_BRIDGE_RECOVERY_FIXTURE, { + customerCode: 'C10000000', + eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b', + initialOutboxStatus: 'PENDING', + initialRetryCount: 62, + failedTarget: '172.31.198.8:9313', + currentOutboxStatus: 'PENDING', + currentRetryCount: 81, + currentFailureCode: 'A0401', + currentFailureMessage: '内部服务令牌无效', + currentRequestId: '200edca3-428e-446d-977a-ca98a84dd704', + currentTraceId: 'e8e6d620c1cff40caa765e6968f89024', + dockerDnsProven: true, + }) +}) + +test('post-rebuild convergence uses the original pending event with no replay or SQL repair', () => { + assert.equal(recoveredEventBridgeFacts({ + firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, + lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0, + manualReplayCount: 0, sqlMutationCount: 0, + }), true) + assert.equal(recoveredEventBridgeFacts({ + firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, + lastlegInboxEventCount: 1, lastlegOrderCount: 2, cnBridgeDuplicateCount: 0, + manualReplayCount: 0, sqlMutationCount: 0, + }), false) + assert.equal(recoveredEventBridgeFacts({ + firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, + lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0, + manualReplayCount: 1, sqlMutationCount: 0, + }), false) + assert.match(crossRegionHttp, /const delivered = await poll/) + assert.match(crossRegionHttp, /deliveredRows\.length > 0/) + assert.match(crossRegionHttp, /sameIds\(delivered\.response\.body\?\.data, deliveredAgain\.body\?\.data\)/) +}) diff --git a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs new file mode 100644 index 000000000..a78f190b8 --- /dev/null +++ b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { + IMMUTABLE_TAILLEG_EVENT_FIXTURE, + TAILLEG_ORDER_NUMBER, + validContainerOrderGroup, + validEventReplay, + validOwnerSnapshots, +} from './tailleg-owner-order-number-contract.mjs' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') +const eventV2 = read('../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java') +const eventV2Tests = read('../../common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java') +const consumer = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java') +const orderEntity = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java') +const orderSummary = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java') +const orderService = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java') +const generator = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java') +const generatorTests = read('../../lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java') +const consumerTests = read('../../lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java') + +test('new 15-order event requires one non-empty trusted owner snapshot', () => { + const orders = Array.from({ length: 15 }, (_, index) => ({ + sourceInboundId: `inbound-${index + 1}`, customerId: 'customer-1', customerName: '验收货主', + })) + assert.equal(validOwnerSnapshots(orders, 15), true) + assert.equal(validOwnerSnapshots(orders.map((order, index) => index === 7 + ? { ...order, customerName: '' } : order), 15), false) + assert.equal(validOwnerSnapshots(orders.map((order, index) => index === 7 + ? { ...order, customerName: '另一货主' } : order), 15), false) +}) + +test('the processed historical event is immutable and cannot be reused for acceptance', () => { + assert.deepEqual(IMMUTABLE_TAILLEG_EVENT_FIXTURE, { + eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b', + status: 'PROCESSED', payloadMutable: false, reusableForAcceptance: false, + }) +}) + +test('V2 event contract freezes customerId and customerName and rejects a blank or mixed owner', () => { + assert.match(eventV2, /List items,\s*String customerId,\s*String customerName,\s*String orderType/) + assert.match(eventV2, /customerId\s*=\s*customerId\.trim\(\)/) + assert.match(eventV2, /customerName\s*=\s*customerName\.trim\(\)/) + assert.match(eventV2, /orders must share one immutable customerId\/customerName snapshot/) + assert.match(eventV2Tests, /rejectsMissingImmutableCustomerName/) + assert.match(eventV2Tests, /freezesTrimmedOwnerSnapshotAndRejectsMixedOwners/) +}) + +test('Lastleg rejects a missing owner snapshot, persists it, and never calls IAM to repair it', () => { + assert.match(consumer, /OwnerSnapshot owner\s*=\s*requireOwnerSnapshot\(payload\.orders\(\),\s*envelope\.tenantId\(\)\)/) + assert.match(consumer, /requiredOwnerValue\(orders\.get\(0\)\.customerName\(\),\s*"customerName"\)/) + assert.match(consumer, /value\s*==\s*null\s*\|\|\s*value\.isBlank\(\)/) + assert.match(consumer, /order\.setCustomerName\(customerName\)/) + assert.match(orderEntity, /private String customerName;/) + assert.doesNotMatch(consumer, /(?:Iam|IAM|\/iam|CustomerClient|customer.*client)/) +}) + +test('Lastleg query DTO uses the persisted frozen owner name', () => { + assert.match(orderSummary, /String customerName/) + assert.match(orderService, /o\.getCustomerName\(\)/) +}) + +test('new tailleg number is Shanghai daily LMO-yyMMdd-#### with concurrency and cross-day tests', () => { + assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO-260827-0001'), true) + assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO2087379674555461633'), false) + assert.equal(TAILLEG_ORDER_NUMBER.timeZone, 'Asia/Shanghai') + assert.match(generator, /Asia\/Shanghai/) + assert.match(generator, /yyMMdd/) + assert.match(generator, /LMO-/) + assert.match(generator, /%04d|padStart\(4|String\.format/) + assert.match(generatorTests, /(?:concurrent|并发)/i) + assert.match(generatorTests, /(?:cross.?day|next.?day|跨天)/i) + assert.match(generatorTests, /LMO-260827-0001/) + assert.match(generatorTests, /LMO-260828-0001/) +}) + +test('one container with 15 inbound rows shares one stable container order number', () => { + const rows = Array.from({ length: 15 }, (_, index) => ({ + containerNo: 'CONT-15', orderNo: 'LMO-260827-0042', sourceInboundId: `inbound-${index + 1}`, + })) + assert.equal(validContainerOrderGroup(rows), true) + assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14 + ? { ...row, orderNo: 'LMO-260827-0043' } : row)), false) + assert.match(consumer, /String orderNo\s*=\s*orderNoGenerator\.forContainerNo\(payload\.containerNo\(\)\);[\s\S]*for \(int index/) + assert.match(consumer, /createOrder\(envelope, payload, source, orderNo, rawSource, containerNo/) + assert.match(consumerTests, /oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber/) +}) + +test('same event replay adds nothing and preserves the allocated number', () => { + const before = { orderCount: 15, orderNo: 'LMO-260827-0042', detailCount: 30, historyCount: 15 } + assert.equal(validEventReplay(before, { ...before }), true) + assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false) + assert.match(consumer, /if\s*\(existing\s*!=\s*null\)[\s\S]{0,400}return countOrders/) + assert.match(consumerTests, /replayPreservesExistingLmoNumberAndAddsNothing/) + assert.match(consumerTests, /verify\(sequences,never\(\)\)\.nextValue/) +}) diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs new file mode 100644 index 000000000..f7af05110 --- /dev/null +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { activeCustomerIdentity, inspectOceanBookingIdentity, minimalPdfBytes, + platformWarehouseIdentity, truckAppointmentPayload } from './truck-appointments.mjs' + +const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') + +test('PDF fixtures contain actual PDF magic bytes', () => { + const content = Buffer.from(minimalPdfBytes('TRUCK')).toString('ascii') + assert.equal(content.slice(0, 5), '%PDF-') + assert.match(content, /%%EOF\n$/) +}) + +test('platform customer and warehouse helpers remain bounded', () => { + assert.deepEqual(activeCustomerIdentity({ id: '21', tenantName: '现有客户' }), + { customerId: '21', customerName: '现有客户' }) + assert.equal(activeCustomerIdentity({ id: '23' }), null) + assert.deepEqual(platformWarehouseIdentity({ warehouseId: '41' }), { warehouseId: '41', platformTenantId: '0' }) +}) + +test('booking identity contains no customer or container snapshot', () => { + const result = inspectOceanBookingIdentity('RUN', { id: '91', marker: 'RUN', warehouseId: '41' }) + assert.deepEqual(result.identity, { oceanBookingId: '91', warehouseId: '41' }) + for (const field of ['customerId', 'containerBookingId', 'loadingPlanId', 'containerNo', 'cabinetNo']) { + assert.equal(Object.hasOwn(result.identity, field), false) + } +}) + +test('save payload is strictly booking level', () => { + const context = { createdResources: { truckSupplier: { supplierId: '501', supplierName: '正式拖车商' } } } + const payload = truckAppointmentPayload(context, '2', { + oceanBookingId: '91', warehouseId: '41', billOfLadingNo: 'BL-1', + }) + assert.equal(payload.oceanBookingId, '91') + assert.equal(payload.carrierId, '501') + for (const field of ['customerId', 'customerName', 'containerBookingId', 'loadingPlanId', 'containerNo', 'cabinetNo', 'containers']) { + assert.equal(Object.hasOwn(payload, field), false, field) + } +}) + +test('fixture creates and releases booking without creating or linking a container', () => { + for (const label of ['自包含创建 DRAFT 订舱', 'DRAFT订舱不进入拖车候选', + '上传当前有效 BILL_OF_LADING 文件', '放舱自包含订舱', 'RELEASED且资料有效的订舱进入候选']) { + assert.match(source, new RegExp(label)) + } + assert.doesNotMatch(source, /\/api\/platform\/v1\/containers|ocean-booking-link|isoContainerNo|创建自包含模拟柜/) +}) + +test('HTTP lifecycle covers uniqueness idempotency attachments and every action', () => { + for (const label of ['创建后订舱立即退出候选', '同订舱第二张非终态拖车单冲突', + '重复创建未改变版本和状态', '上传并关联DISPATCH_ORDER拖车附件', '查询拖车附件列表', + '删除首次上传的拖车附件引用', '重新引用平台归属拖车附件', + '删除重新引用的拖车附件', '恢复一份DISPATCH_ORDER拖车附件供后续状态流转', + '编辑订舱级拖车为RMB应付费用', '整单推进SUBMIT', 'SUBMIT同键同载荷重放', + 'SUBMIT重放版本零漂移', 'SUBMIT同键异载荷A0409', + '禁止手工离仓或完成拖车', + 'CANCELLED后订舱恢复候选', 'CANCELLED后同订舱可再次新建', + 'EXCEPTION后订舱恢复候选', 'EXCEPTION后同订舱可再次新建']) { + assert.match(source, new RegExp(label), label) + } + assert.match(source, /\['DISPATCH', '派车'\], \['PICKUP', '提柜'\], \['ARRIVE', '到仓'\]/) + assert.match(source, /\['LOAD', '装货'\]/) + assert.match(source, /context\.apiSuccess\(MODULE, `整单推进\$\{name\}`/) +}) + +test('negative matrix covers booking warehouse bill carrier and currency', () => { + for (const label of ['错误订舱拒绝创建', '错误仓库拒绝创建', '错误提单拒绝创建', + '错误承运商拒绝创建', '非法拖车币种拒绝创建']) assert.match(source, new RegExp(label)) +}) + +test('backend and OpenAPI expose no container write or response protocol', () => { + const paths = [ + '../../firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java', + '../../firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java', + '../../firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java', + '../../firstleg/src/main/resources/openapi/truck-appointments-v1.yaml', + ] + const dto = readFileSync(new URL(paths[0], import.meta.url), 'utf8') + assert.match(dto, /@NotNull @Positive Long oceanBookingId/) + assert.doesNotMatch(dto, /SaveRequest\([^)]*(containerBookingId|loadingPlanId|containerNo|cabinetNo)/s) + const openApi = readFileSync(new URL(paths[3], import.meta.url), 'utf8') + for (const field of ['containerBookingId:', 'loadingPlanId:', 'containerNo:', 'cabinetNo:', 'containers:']) { + assert.doesNotMatch(openApi, new RegExp(field)) + } +}) + +test('schema owns one active appointment per ocean booking', () => { + const sql = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql', import.meta.url), 'utf8') + assert.match(sql, /UNIQUE KEY uk_truck_active_ocean_booking\(active_ocean_booking_id\)/) + assert.match(sql, /DROP INDEX uk_truck_active_container/) + assert.match(sql, /visibility VARCHAR\(24\) NOT NULL DEFAULT 'PLATFORM'/) +}) + +test('admin entry keeps truck module independent of loading fixtures', () => { + const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') + assert.match(entry, /\['truck-appointments', \['merchant-qualification-fixture'\]\]/) + assert.doesNotMatch(entry, /\['truck-appointments', \[[^\]]*phase3-loading/) +}) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs new file mode 100644 index 000000000..aa551cb84 --- /dev/null +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -0,0 +1,325 @@ +import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' +import { oceanBookingDraft } from './ocean-booking-fixture.mjs' +import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs' + +const MODULE = 'truck-appointments' +const OPTIONS = { scenario: 'booking-level-truck-appointment-lifecycle', deploymentBlocker: true } +const TRUCKS = '/api/platform/v1/truck-appointments' +const BOOKINGS = '/api/platform/v1/ocean-bookings' +const FORBIDDEN_TRUCK_FIELDS = ['containerBookingId', 'loadingPlanId', 'containerNo', 'cabinetNo', 'containers'] + +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function responseEvidence(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function data(response) { return codeOf(response) === '200' ? response.body.data : null } +function failure(response) { return response?.http === 0 || response?.http >= 400 || (response?.http === 200 && codeOf(response) !== '200') } +function records(value) { return Array.isArray(value) ? value : value?.records || [] } +function hasForbiddenTruckFields(value) { return FORBIDDEN_TRUCK_FIELDS.some((field) => Object.hasOwn(value || {}, field)) } + +export function minimalPdfBytes(label = 'truck-appointment-fixture') { + const safeLabel = String(label).replace(/[^\x20-\x7E]/g, '_').slice(0, 64) + return new TextEncoder().encode(`%PDF-1.4 +% ${safeLabel} +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [] /Count 0 >> +endobj +trailer +<< /Root 1 0 R >> +%%EOF +`) +} + +export function platformWarehouseIdentity(item) { + return { warehouseId: id(item?.warehouseId || item?.id || item?.warehouse?.id), platformTenantId: '0' } +} + +export function activeCustomerIdentity(item) { + const customerId = id(item?.id || item?.tenantId) + const customerName = String(item?.tenantName || item?.customerName || item?.name || '').trim() + return customerId && customerName ? { customerId, customerName } : null +} + +async function request(context, method, path, body, headers = {}) { + try { + const response = await fetch(`${context.options.gateway}${path}`, { + method, headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000), + }) + const raw = await response.text(); let parsed = null + try { parsed = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } + return { method, path, http: response.status, body: parsed, raw: parsed == null ? raw.slice(0, 500) : null, + requestId: parsed?.requestId || response.headers.get('x-request-id'), traceId: parsed?.traceId || response.headers.get('x-trace-id') } + } catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } +} + +export function inspectOceanBookingIdentity(marker, resource, detail = null) { + if (!resource?.id || resource.marker !== marker) return { identity: null, reason: '订舱不属于本轮' } + const booking = detail?.oceanBooking || resource + if (id(booking.id) !== id(resource.id)) return { identity: null, reason: '订舱详情资源归属不一致' } + return { identity: { oceanBookingId: id(resource.id), warehouseId: id(booking.warehouseId) }, reason: null, missingFields: [] } +} + +export function truckAppointmentPayload(context, suffix, identity, version = 0, overrides = {}) { + const supplier = identity.truckSupplier || context.createdResources?.truckSupplier || {} + return { outboundOrderId: null, oceanBookingId: identity.oceanBookingId, warehouseId: identity.warehouseId, + carrierId: id(supplier.supplierId), carrierName: supplier.supplierName, + billOfLadingNo: identity.billOfLadingNo, fleetContact: '车队联系人', fleetPhone: '13800000004', + driverName: '司机验收', driverPhone: '13800000005', vehicleNo: `粤B-${suffix.slice(-6)}`, + storeName: `装柜点-${suffix}`, storeContact: '仓库联系人', storePhone: '13800000006', + storeAddress: '深圳市南山区验收地址', storeCity: '深圳', storeZipcode: '518000', + packageCount: 0, grossWeightKg: 0, volumeCbm: 0, palletCount: 0, poNo: `PO-${suffix}`, + plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0, + currency: 'RMB', remark: `订舱级拖车-${suffix}`, expectedVersion: version, ...overrides } +} + +async function activeSupplier(context, category, suffix) { + const carrierFilter = category === 'BOOKING' ? '&carrierCode=MATSON' : category === 'TRUCKING' ? '&truckingScope=DOMESTIC' : '' + const list = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&category=${category}&status=ACTIVE${carrierFilter}&page=1&size=100`) + const existing = records(data(list)).find((item) => item?.category === category && item?.status === 'ACTIVE' + && (category !== 'TRUCKING' || item?.truckingScope === 'DOMESTIC') + && (category !== 'BOOKING' || item?.carrierCodes?.includes?.('MATSON'))) + if (existing) return { supplierId: id(existing.id), supplierName: existing.name } + const created = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', { + name: `${category}-验收-${suffix}`, category, truckingScope: category === 'TRUCKING' ? 'DOMESTIC' : null, carrierCodes: category === 'BOOKING' ? ['MATSON'] : [], + region: '深圳', legalRepresentative: '验收法人', contactName: '验收联系人', phone: '13800000000', + email: 'truck-acceptance@example.com', address: '深圳市验收地址', businessLicenseNo: `TRUCK-${suffix}`.slice(0, 64), + licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker, + }) + const value = data(created); const supplierId = id(value?.id || value) + context.apiSuccess(MODULE, `创建 ACTIVE ${category} 供应商`, created, '', OPTIONS) + return supplierId ? { supplierId, supplierName: value?.name || `${category}-验收-${suffix}` } : null +} + +async function bookingMaster(context, suffix) { + const fixtureCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId + || context.context?.customerId || context.customerId) + const customersResponse = await request(context, 'GET', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE') + const candidates = records(data(customersResponse)) + const customer = activeCustomerIdentity(candidates.find((item) => + fixtureCustomerId && id(item?.id || item?.tenantId) === fixtureCustomerId) || candidates[0]) + if (!customer) { + context.addStep(MODULE, '通过正式平台客户查询解析 ACTIVE 订舱客户', customersResponse, false, + `preferredCustomerId=${fixtureCustomerId || 'missing'}`, OPTIONS) + return null + } + let lastWarehousesResponse = null + for (let page = 1; page <= 100; page++) { + const warehousesResponse = await request(context, 'GET', + `/api/platform/v1/warehouse-admin?tenantId=0&warehouseType=DOMESTIC&status=ACTIVE&page=${page}&size=100`) + lastWarehousesResponse = warehousesResponse + const warehousePage = data(warehousesResponse); const warehouseRows = records(warehousePage) + for (const item of warehouseRows) { + const { warehouseId } = platformWarehouseIdentity(item) + if (!warehouseId) continue + const channelsResponse = await request(context, 'GET', `${BOOKINGS}/channels?originWarehouseId=${warehouseId}`) + const channelRows = records(data(channelsResponse)); const channel = channelRows[0] + if (channel?.channelCode) return { ...customer, warehouseId, channelCode: channel.channelCode, channelName: channel.channelName } + } + const total = Number(warehousePage?.total ?? warehouseRows.length) + if (warehouseRows.length === 0 || page * 100 >= total) break + } + context.addStep(MODULE, '解析具备有效海运渠道的平台仓库', lastWarehousesResponse, false, + `platformTenantId=0, customerId=${customer.customerId}`, OPTIONS) + return null +} + +async function candidate(context, identity) { + const response = await request(context, 'GET', `${TRUCKS}/candidates?keyword=${encodeURIComponent(identity.oceanBookingNo)}&page=1&size=200`) + const page = data(response) + return { response, item: records(page).find((value) => id(value?.oceanBookingId) === identity.oceanBookingId) } +} + +async function fixture(context) { + const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-10) || 'TRUCK' + const master = await bookingMaster(context, suffix); if (!master?.customerId) return null + const bookingAgent = await activeSupplier(context, 'BOOKING', suffix) + const truckSupplier = await activeSupplier(context, 'TRUCKING', suffix) + const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS) + if (!bookingAgent || !truckSupplier || !carrier?.id) return null + const draft = oceanBookingDraft(context, suffix, master, { + bookingAgentId: bookingAgent.supplierId, bookingAgentName: bookingAgent.supplierName, + shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON', + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 }], + }) + const createdResponse = await request(context, 'POST', BOOKINGS, { expectedVersion: 0, data: draft }, + { 'Idempotency-Key': `${context.marker}-truck-booking-create`.slice(0, 128) }) + const created = context.apiSuccess(MODULE, '自包含创建 DRAFT 订舱', createdResponse, '', OPTIONS) + const bookingId = id(created?.oceanBooking?.id); if (!bookingId) return null + const identity = { suffix, oceanBookingId: bookingId, oceanBookingNo: created.oceanBooking.bookingNo, + warehouseId: master.warehouseId, billOfLadingNo: created.oceanBooking.billOfLadingNo, truckSupplier } + const draftCandidate = await candidate(context, identity) + context.assert(MODULE, 'DRAFT订舱不进入拖车候选', draftCandidate.response, !draftCandidate.item, + `oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS) + + const form = new FormData(); form.append('expectedVersion', String(created.oceanBooking.version)) + form.append('documentType', 'BILL_OF_LADING') + form.append('file', new Blob([minimalPdfBytes(`${context.marker}-BILL_OF_LADING`)], { type: 'application/pdf' }), `bill-${suffix}.pdf`) + const uploadResponse = await context.requestMultipart('POST', `${BOOKINGS}/${bookingId}/documents/upload`, form, + context.token, { 'Idempotency-Key': `${context.marker}-truck-booking-file`.slice(0, 128) }) + const uploaded = context.apiSuccess(MODULE, '上传当前有效 BILL_OF_LADING 文件', uploadResponse, '', OPTIONS) + const releaseResponse = await request(context, 'POST', `${BOOKINGS}/${bookingId}/release`, + { expectedVersion: Number(uploaded?.bookingVersion), reason: context.marker }, + { 'Idempotency-Key': `${context.marker}-truck-booking-release`.slice(0, 128) }) + const released = context.apiSuccess(MODULE, '放舱自包含订舱', releaseResponse, '', OPTIONS) + if (released?.oceanBooking?.status !== 'RELEASED') return null + identity.billOfLadingNo = released.oceanBooking.billOfLadingNo + const releasedCandidate = await candidate(context, identity) + context.assert(MODULE, 'RELEASED且资料有效的订舱进入候选', releasedCandidate.response, + Boolean(releasedCandidate.item) && !hasForbiddenTruckFields(releasedCandidate.item), + `oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS) + return identity +} + +async function action(context, path, appointment, name, reason) { + const response = await request(context, 'POST', `${path}/${name.toLowerCase()}`, + { expectedVersion: Number(appointment?.version), reason }, + { 'Idempotency-Key': `${context.marker}-truck-${name.toLowerCase()}`.slice(0, 128) }) + return context.apiSuccess(MODULE, `整单推进${name}`, response, '', OPTIONS) +} + +export async function run(context) { + const identity = await fixture(context) + context.assert(MODULE, '订舱级拖车模块自包含前置链完整', null, + Boolean(identity?.oceanBookingId && identity?.oceanBookingNo && identity?.truckSupplier?.supplierId), + `oceanBookingNo=${identity?.oceanBookingNo || 'missing'}`, OPTIONS) + if (!identity) return + context.createdResources.truckSupplier = identity.truckSupplier + const base = truckAppointmentPayload(context, identity.suffix, identity) + context.assert(MODULE, '创建请求完全不含柜次协议字段', null, + !hasForbiddenTruckFields(base) && !Object.hasOwn(base, 'customerId') && !Object.hasOwn(base, 'customerName'), + Object.keys(base).join(','), OPTIONS) + for (const [label, payload] of [ + ['错误订舱拒绝创建', { ...base, oceanBookingId: (BigInt(identity.oceanBookingId) + 1n).toString() }], + ['错误仓库拒绝创建', { ...base, warehouseId: (BigInt(identity.warehouseId) + 1n).toString() }], + ['错误提单拒绝创建', { ...base, billOfLadingNo: `WRONG-${identity.suffix}` }], + ['错误承运商拒绝创建', { ...base, carrierId: (BigInt(identity.truckSupplier.supplierId) + 1n).toString() }], + ['非法拖车币种拒绝创建', { ...base, currency: 'CNY' }], + ]) { + const response = await request(context, 'POST', TRUCKS, payload) + context.addStep(MODULE, label, response, failure(response), `code=${codeOf(response)}`, OPTIONS) + } + + const createdResponse = await request(context, 'POST', TRUCKS, base) + let appointment = context.apiSuccess(MODULE, '创建合法订舱级拖车预约', createdResponse, '', OPTIONS) + const appointmentId = id(appointment?.id); if (!appointmentId) return + const path = `${TRUCKS}/${appointmentId}` + context.assert(MODULE, '创建响应完全无柜次协议且费用正确', createdResponse, + truckAppointmentFieldGaps(appointment).length === 0 + && !hasForbiddenTruckFields(appointment) && appointment?.currency === 'RMB' + && Number(appointment?.payableAmount) === 0, + `appointmentNo=${appointment?.appointmentNo || 'missing'}, fieldGaps=${truckAppointmentFieldGaps(appointment).join('|') || 'none'}`, OPTIONS) + const afterCreateCandidate = await candidate(context, identity) + context.assert(MODULE, '创建后订舱立即退出候选', afterCreateCandidate.response, !afterCreateCandidate.item, + `appointmentNo=${appointment.appointmentNo}`, OPTIONS) + const duplicateBefore = await request(context, 'POST', TRUCKS, base) + context.addStep(MODULE, '同订舱第二张非终态拖车单冲突', duplicateBefore, failure(duplicateBefore), + `code=${codeOf(duplicateBefore)}, appointmentNo=${appointment.appointmentNo}`, OPTIONS) + const unchangedResponse = await request(context, 'GET', path) + const unchanged = context.apiSuccess(MODULE, '重复创建冲突后回查零漂移', unchangedResponse, '', OPTIONS) + context.assert(MODULE, '重复创建未改变版本和状态', unchangedResponse, + Number(unchanged?.version) === Number(appointment.version) && unchanged?.status === 'DRAFT', '', OPTIONS) + + const attachmentForm = new FormData(); attachmentForm.append('attachmentType', 'DISPATCH_ORDER') + attachmentForm.append('file', new Blob([minimalPdfBytes(`${context.marker}-DISPATCH_ORDER`)], { type: 'application/pdf' }), + `dispatch-order-${identity.suffix}.pdf`) + const uploadAttachmentResponse = await context.requestMultipart('POST', `${path}/attachments/upload`, + attachmentForm, context.token) + const attachment = context.apiSuccess(MODULE, '上传并关联DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS) + const attachmentListResponse = await request(context, 'GET', `${path}/attachments`) + const attachmentList = context.apiSuccess(MODULE, '查询拖车附件列表', attachmentListResponse, '', OPTIONS) + context.assert(MODULE, '附件列表包含本轮运营附件', attachmentListResponse, + records(attachmentList).some((item) => id(item?.id) === id(attachment?.id) && item?.attachmentType === 'DISPATCH_ORDER'), + `attachmentId=${id(attachment?.id) || 'missing'}`, OPTIONS) + if (!attachment?.id || !attachment?.fileId) return + const deleteUploadedResponse = await request(context, 'DELETE', + `${path}/attachments/${encodeURIComponent(id(attachment?.id))}`) + context.addStep(MODULE, '删除首次上传的拖车附件引用', deleteUploadedResponse, + deleteUploadedResponse.http === 200 && codeOf(deleteUploadedResponse) === '200', + responseEvidence(deleteUploadedResponse), OPTIONS) + const referencedResponse = await request(context, 'POST', `${path}/attachments`, { + fileId: id(attachment?.fileId), attachmentType: 'DISPATCH_ORDER', + fileName: attachment?.fileName || `dispatch-order-${identity.suffix}.pdf`, + fileHash: attachment?.fileHash || null, + }) + const referenced = context.apiSuccess(MODULE, '重新引用平台归属拖车附件', referencedResponse, '', OPTIONS) + const referencedListResponse = await request(context, 'GET', `${path}/attachments`) + const referencedList = context.apiSuccess(MODULE, '回查重新引用的拖车附件', referencedListResponse, '', OPTIONS) + context.assert(MODULE, '附件列表包含重新引用的DISPATCH_ORDER', referencedListResponse, + records(referencedList).some((item) => id(item?.id) === id(referenced?.id) + && id(item?.fileId) === id(attachment?.fileId) && item?.attachmentType === 'DISPATCH_ORDER'), + `attachmentId=${id(referenced?.id) || 'missing'}, fileId=${id(attachment?.fileId) || 'missing'}`, OPTIONS) + const deleteReferencedResponse = await request(context, 'DELETE', + `${path}/attachments/${encodeURIComponent(id(referenced?.id))}`) + context.addStep(MODULE, '删除重新引用的拖车附件', deleteReferencedResponse, + deleteReferencedResponse.http === 200 && codeOf(deleteReferencedResponse) === '200', + responseEvidence(deleteReferencedResponse), OPTIONS) + const replacementForm = new FormData(); replacementForm.append('attachmentType', 'DISPATCH_ORDER') + replacementForm.append('file', new Blob([minimalPdfBytes(`${context.marker}-DISPATCH_ORDER-REPLACEMENT`)], + { type: 'application/pdf' }), `dispatch-order-replacement-${identity.suffix}.pdf`) + const replacementResponse = await context.requestMultipart('POST', `${path}/attachments/upload`, + replacementForm, context.token) + const replacement = context.apiSuccess(MODULE, '恢复一份DISPATCH_ORDER拖车附件供后续状态流转', + replacementResponse, '', OPTIONS) + context.assert(MODULE, '恢复附件使用新引用且仍指向平台文件', replacementResponse, + Boolean(id(replacement?.id) && id(replacement?.fileId)) && id(replacement?.id) !== id(attachment?.id), + `attachmentId=${id(replacement?.id) || 'missing'}, fileId=${id(replacement?.fileId) || 'missing'}`, OPTIONS) + + const editedResponse = await request(context, 'PUT', path, + truckAppointmentPayload(context, identity.suffix, identity, Number(appointment.version), { currency: 'RMB', payableAmount: 12.34 })) + appointment = context.apiSuccess(MODULE, '编辑订舱级拖车为RMB应付费用', editedResponse, '', OPTIONS) + context.assert(MODULE, '编辑响应费用正确且无柜次协议', editedResponse, + appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 12.34 && !hasForbiddenTruckFields(appointment), '', OPTIONS) + + const submitKey = `${context.marker}-truck-submit`.slice(0, 128) + const submitBody = { expectedVersion: Number(appointment.version), reason: '幂等提交' } + const submitResponse = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey }) + appointment = context.apiSuccess(MODULE, '整单推进SUBMIT', submitResponse, '', OPTIONS) + const replayResponse = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey }) + const replay = context.apiSuccess(MODULE, 'SUBMIT同键同载荷重放', replayResponse, '', OPTIONS) + context.assert(MODULE, 'SUBMIT重放版本零漂移', replayResponse, + Number(replay?.version) === Number(appointment.version) && replay?.status === appointment.status, '', OPTIONS) + const conflictResponse = await request(context, 'POST', `${path}/submit`, { ...submitBody, reason: '异载荷' }, + { 'Idempotency-Key': submitKey }) + context.addStep(MODULE, 'SUBMIT同键异载荷A0409', conflictResponse, + codeOf(conflictResponse) === 'A0409', `code=${codeOf(conflictResponse)}`, OPTIONS) + + for (const [name, reason] of [['DISPATCH', '派车'], ['PICKUP', '提柜'], ['ARRIVE', '到仓'], + ['LOAD', '装货']]) { + appointment = await action(context, path, appointment, name, reason) + context.assert(MODULE, `${name}响应无柜次协议`, null, Boolean(appointment) && !hasForbiddenTruckFields(appointment), + `status=${appointment?.status || 'missing'}`, OPTIONS) + } + const finalDetailResponse = await request(context, 'GET', path) + const finalDetail = context.apiSuccess(MODULE, '回查待离仓订舱级拖车详情', finalDetailResponse, '', OPTIONS) + context.assert(MODULE, '未确认国内离仓时保持装柜中且详情无柜次协议', finalDetailResponse, + finalDetail?.status === 'LOADING' && truckAppointmentFieldGaps(finalDetail).length === 0 + && !hasForbiddenTruckFields(finalDetail) + && id(finalDetail?.oceanBookingId) === identity.oceanBookingId, + `appointmentNo=${finalDetail?.appointmentNo || 'missing'}, status=${finalDetail?.status || 'missing'}, fieldGaps=${truckAppointmentFieldGaps(finalDetail).join('|') || 'none'}`, OPTIONS) + for (const obsoleteAction of ['depart', 'complete']) { + const rejected = await request(context, 'POST', `${path}/${obsoleteAction}`, + { expectedVersion: Number(finalDetail.version) }, { 'Idempotency-Key': `${context.marker}-${obsoleteAction}`.slice(0, 128) }) + context.assert(MODULE, '禁止手工离仓或完成拖车', rejected, codeOf(rejected) === 'A0409', '', OPTIONS) + } + let rebuilt = finalDetail + const cancelled = await action(context, `${TRUCKS}/${id(rebuilt?.id)}`, rebuilt, 'CANCEL', '取消测试单') + const afterCancelCandidate = await candidate(context, identity) + context.assert(MODULE, 'CANCELLED后订舱恢复候选', afterCancelCandidate.response, + cancelled?.status === 'CANCELLED' && Boolean(afterCancelCandidate.item), '', OPTIONS) + const afterCancelResponse = await request(context, 'POST', TRUCKS, base) + rebuilt = context.apiSuccess(MODULE, 'CANCELLED后同订舱可再次新建', afterCancelResponse, '', OPTIONS) + const excepted = await action(context, `${TRUCKS}/${id(rebuilt?.id)}`, rebuilt, 'EXCEPTION', '异常测试单') + const afterExceptionCandidate = await candidate(context, identity) + context.assert(MODULE, 'EXCEPTION后订舱恢复候选', afterExceptionCandidate.response, + excepted?.status === 'EXCEPTION' && Boolean(afterExceptionCandidate.item), '', OPTIONS) + const finalDraftResponse = await request(context, 'POST', TRUCKS, base) + const finalDraft = context.apiSuccess(MODULE, 'EXCEPTION后同订舱可再次新建', finalDraftResponse, '', OPTIONS) + context.createdResources.truckAppointment = { id: id(finalDraft?.id), appointmentNo: finalDraft?.appointmentNo, + marker: context.marker, finalStatus: finalDraft?.status, oceanBookingId: identity.oceanBookingId } +} diff --git a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs new file mode 100644 index 000000000..9e3317b52 --- /dev/null +++ b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs @@ -0,0 +1,86 @@ +const PLATFORM = '/api/platform/v1/last-leg/warehouse-transfers' +const MERCHANT = '/api/merchant/v1/last-leg/warehouse-transfers' +const MODULE = 'phase4-overseas-client-services' +const OPTIONS = { scenario: 'warehouse-transfer-review', deploymentBlocker: true } +const ok = r => r?.http === 200 && String(r.body?.code) === '200' +const rejected = (r, status) => r?.http === status || (r?.http === 200 && String(r.body?.code) === `A0${status}`) +const evidence = r => `HTTP=${r?.http}, code=${r?.body?.code}, requestId=${r?.requestId}, traceId=${r?.traceId}` + +// Called by the registered client-services module, never a standalone fixture runner. +export async function verifyWarehouseTransferReview(context, token, customerId, sourceBody, transferNo) { + if (!customerId || String(customerId) === '0' + || context.context?.merchantQualificationFixture?.marker !== context.marker) { + context.addStep(MODULE, '转运审核缺少本轮客户作用域', null, false, 'No historical fallback', OPTIONS) + return false + } + const scoped = no => `${PLATFORM}/${encodeURIComponent(no)}?customerId=${encodeURIComponent(customerId)}` + const reviewPath = no => `${PLATFORM}/${encodeURIComponent(no)}/review?customerId=${encodeURIComponent(customerId)}` + async function call(method, path, body, merchantToken) { + return context.requestOverseas(method, path, body, {}, merchantToken) + } + const initial = await call('GET', scoped(transferNo)) + context.addStep(MODULE, '平台转运详情按客户读取待审核单', initial, + ok(initial) && initial.body.data?.status === 'PENDING_REVIEW', evidence(initial), OPTIONS) + if (!ok(initial)) return false + const denied = await call('POST', reviewPath(transferNo), { decision: 'APPROVE' }, token) + context.addStep(MODULE, '客户Token没有平台专用转运审核权限', denied, rejected(denied, 403), evidence(denied), OPTIONS) + const missingScope = await call('POST', `${PLATFORM}/${encodeURIComponent(transferNo)}/review`, { decision: 'APPROVE' }) + context.addStep(MODULE, '平台转运审核缺客户作用域拒绝', missingScope, rejected(missingScope, 400), evidence(missingScope), OPTIONS) + const invalid = await call('POST', reviewPath(transferNo), { decision: 'INVALID' }) + context.addStep(MODULE, '非法审核决定拒绝', invalid, rejected(invalid, 400), evidence(invalid), OPTIONS) + const unchanged = await call('GET', scoped(transferNo)) + context.addStep(MODULE, '审核负向后状态和分配零漂移', unchanged, + ok(unchanged) && unchanged.body.data?.status === 'PENDING_REVIEW' + && JSON.stringify(unchanged.body.data?.allocations) === JSON.stringify(initial.body.data?.allocations), evidence(unchanged), OPTIONS) + + // Existing-stock approval must not be confused with inbound-transit PDA receipt. + const approved = await call('POST', reviewPath(transferNo), { decision: 'APPROVE', remark: context.marker }) + context.addStep(MODULE, '已有库存转运通过为APPROVED且主单待分配', approved, + ok(approved) && approved.body.data?.status === 'APPROVED' + && approved.body.data?.orderStatus === 'PENDING_ASSIGNMENT', evidence(approved), OPTIONS) + if (!ok(approved)) return false + const replay = await call('POST', reviewPath(transferNo), { decision: 'APPROVE', remark: context.marker }) + context.addStep(MODULE, '同决定重放不推进状态', replay, + ok(replay) && replay.body.data?.status === 'APPROVED' && replay.body.data?.transferNo === transferNo, evidence(replay), OPTIONS) + const reverse = await call('POST', reviewPath(transferNo), { decision: 'REJECT', remark: '反向审核应拒绝' }) + context.addStep(MODULE, '已通过转运不能反向驳回', reverse, rejected(reverse, 409), evidence(reverse), OPTIONS) + const readback = await call('GET', `${MERCHANT}/${encodeURIComponent(transferNo)}`, undefined, token) + context.addStep(MODULE, '客户回查审核通过且未产生出库', readback, + ok(readback) && readback.body.data?.status === 'APPROVED' && readback.body.data?.actualShipAt == null + && Array.isArray(readback.body.data?.allocations) && readback.body.data.allocations.length === 0, evidence(readback), OPTIONS) + + for (const decision of ['APPROVE', 'REJECT']) { + const body = { ...sourceBody, inboundCode: null, sourceLastlegOrderIds: [], + idempotencyKey: `${context.marker}-TRANSIT-${decision}`.slice(0, 255) } + const created = await call('POST', MERCHANT, body, token) + const no = created.body?.data?.transferNo + context.addStep(MODULE, `创建本轮新到仓转运${decision}对照`, created, + ok(created) && Boolean(no) && created.body.data?.status === 'PENDING_REVIEW', evidence(created), OPTIONS) + if (!ok(created) || !no) return false + const result = await call('POST', reviewPath(no), { decision, remark: `${context.marker}-审核原因` }) + const expected = decision === 'APPROVE' ? 'WAIT_PDA_RECEIVING' : 'REJECTED' + const expectedOrder = decision === 'APPROVE' ? 'WAIT_DELIVERY' : 'CANCELLED' + const detail = await call('GET', `${MERCHANT}/${encodeURIComponent(no)}`, undefined, token) + context.addStep(MODULE, `新到仓转运${decision}回查${expected}`, detail, + ok(result) && ok(detail) && detail.body.data?.status === expected + && detail.body.data?.orderType === 'WAREHOUSE_TRANSFER' + && detail.body.data?.orderStatus === expectedOrder + && detail.body.data?.reviewDecision === `${decision}ED`.replace('APPROVEED', 'APPROVED') + && detail.body.data?.reviewRemark === `${context.marker}-审核原因` + && Boolean(detail.body.data?.reviewedBy) && Boolean(detail.body.data?.reviewedAt) + && (detail.body.data?.pallets || []).length === 0, evidence(detail), OPTIONS) + context.createdResources.phase4OverseasClientServices[`transit${decision}`] = { transferNo: no, status: detail.body?.data?.status } + } + const reasonFixture = await call('POST', MERCHANT, { ...sourceBody, inboundCode: null, sourceLastlegOrderIds: [], + idempotencyKey: `${context.marker}-TRANSIT-REASON`.slice(0, 255) }, token) + const reasonNo = reasonFixture.body?.data?.transferNo + context.addStep(MODULE, '创建空驳回原因独立反向票', reasonFixture, ok(reasonFixture) && Boolean(reasonNo), evidence(reasonFixture), OPTIONS) + if (!ok(reasonFixture) || !reasonNo) return false + const emptyReason = await call('POST', reviewPath(reasonNo), { decision: 'REJECT', remark: '' }) + context.addStep(MODULE, '空驳回原因应拒绝', emptyReason, rejected(emptyReason, 400), evidence(emptyReason), OPTIONS) + const reasonReadback = await call('GET', scoped(reasonNo)) + context.addStep(MODULE, '空原因拒绝后仍待审核', reasonReadback, + ok(reasonReadback) && reasonReadback.body.data?.status === 'PENDING_REVIEW', evidence(reasonReadback), OPTIONS) + context.createdResources.phase4OverseasClientServices.reasonValidationTransferNo = reasonNo + return true +} diff --git a/scripts/admin-test-env-modules/weight-pallet-options-p0-contract.test.mjs b/scripts/admin-test-env-modules/weight-pallet-options-p0-contract.test.mjs new file mode 100644 index 000000000..91868df68 --- /dev/null +++ b/scripts/admin-test-env-modules/weight-pallet-options-p0-contract.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') + +const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java') +const service = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java') +const warehouseClient = read('../../firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java') +const serviceTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java') +const openapi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml') +const adminApi = read('../../../apps/admin-web/src/modules/inbounds/weight/api/weightOcr.ts') +const adminPanel = read('../../../apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue') +const adminBehavior = read('../../../apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts') + +function endpointBlock(source, marker, nextMarker) { + const start = source.indexOf(marker) + assert.notEqual(start, -1, `missing contract marker: ${marker}`) + const end = source.indexOf(nextMarker, start + marker.length) + return source.slice(start, end === -1 ? source.length : end) +} + +test('backend Controller exposes a keyword-filtered bounded page instead of an unbounded array', () => { + const block = endpointBlock(controller, '@GetMapping("/weight-pallet-options")', '@BusinessAudit') + assert.match(block, /ApiResponse>/) + assert.doesNotMatch(block, /ApiResponse>/) + assert.match(block, /\bpage\b/) + assert.match(block, /\bsize\b/) + assert.match(block, /\bkeyword\b/) + + const boundedSize = [...block.matchAll(/@Max\((\d+)\)/g)].map((match) => Number(match[1])) + assert.ok(boundedSize.some((maximum) => maximum > 0 && maximum <= 200), + 'size must have a finite Controller maximum no greater than 200') +}) + +test('Firstleg forwards the bound to Warehouse instead of loading the full warehouse and slicing in memory', () => { + const clientBlock = endpointBlock(warehouseClient, '@GetMapping("/pallets/snapshots")', '@PostMapping') + assert.match(clientBlock, /ApiResponse>/) + assert.doesNotMatch(clientBlock, /ApiResponse>/) + for (const parameter of ['page', 'size', 'keyword']) assert.match(clientBlock, new RegExp(`"${parameter}"`)) + + const serviceBlock = endpointBlock(service, + 'public PageResult weightPalletOptions(', + '@Transactional') + assert.match(serviceBlock, /loadPalletSnapshotPage\(inbound, page, size, keyword\)/) + assert.match(service, /palletSnapshots\([\s\S]{0,300}?page[\s\S]{0,80}?size[\s\S]{0,80}?keyword/) + assert.doesNotMatch(serviceBlock, /\.stream\(\)[\s\S]*?\.skip\(/) +}) + +test('OpenAPI publishes page, size, keyword and a finite maximum without an array data contract', () => { + const block = endpointBlock(openapi, + ' /api/platform/v1/first-leg/inbounds/{inboundId}/weight-pallet-options:', + ' /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results:') + for (const parameter of ['page', 'size', 'keyword']) { + assert.match(block, new RegExp(`name: ${parameter}`)) + } + const maxima = [...block.matchAll(/maximum:\s*(\d+)/g)].map((match) => Number(match[1])) + assert.ok(maxima.some((maximum) => maximum > 0 && maximum <= 200), + 'OpenAPI size parameter must declare a finite maximum no greater than 200') + const dataLine = block.match(/data:\s*\{[^\n]+/)?.[0] || '' + assert.doesNotMatch(dataLine, /type:\s*array/) + assert.match(block, /(?:records|WeightPalletOptionPage|PageResult)/) +}) + +test('Admin API requests a bounded remote-search page and does not type the response as an array', () => { + const block = endpointBlock(adminApi, 'export async function getWeightPalletOptions', 'export async function uploadWeightImage') + assert.match(block, /\bpage\b/) + assert.match(block, /\bsize\b/) + assert.match(block, /\bkeyword\b/) + assert.doesNotMatch(block, /ApiEnvelope/) + assert.match(block, /(?:records|PageResult|PageResource|PageResponse)/) +}) + +test('Admin skips candidates without weight edit permission or an editable inbound state', () => { + const loader = endpointBlock(adminPanel, 'async function loadPalletOptions', 'function chooseFile') + const gate = endpointBlock(adminPanel, 'const canLoadPalletOptions', 'const virtualPalletOptions') + assert.match(gate, /canEdit\.value/) + assert.match(gate, /editable\.value/) + assert.match(loader, /!canLoadPalletOptions\.value/) +}) + +test('Admin behavior tests prove both permission and inbound-state request suppression', () => { + const cases = [...adminBehavior.matchAll(/it\((['"`])([\s\S]*?)\1,\s*async\s*\(\)\s*=>\s*\{([\s\S]*?)\n\s*\}\)/g)] + const suppressionCases = cases.filter(([, , title, body]) => /getWeightPalletOptions/.test(body) + && /not\.toHaveBeenCalled/.test(body)) + assert.ok(suppressionCases.some(([, , title]) => /edit|permission|权限/i.test(title)), + 'missing behavior case: no weight:edit must not request pallet candidates') + assert.ok(suppressionCases.some(([, , title]) => /status|editable|状态/i.test(title)), + 'missing behavior case: non-editable inbound status must not request pallet candidates') +}) + +test('Admin remote search is bounded and never accumulates every candidate', () => { + assert.match(adminPanel, /\bremote\b/) + assert.match(adminPanel, /:remote-method=/) + const sizes = [...adminPanel.matchAll(/(?:PALLET|pallet).{0,40}(?:PAGE_SIZE|pageSize)\s*=\s*(\d+)/g)] + .map((match) => Number(match[1])) + assert.ok(sizes.some((size) => size > 0 && size <= 200), + 'Admin remote search page size must be finite and no greater than 200') + assert.doesNotMatch(adminPanel, /palletOptions\.value\.(?:push|unshift)\s*\(/) + assert.doesNotMatch(adminPanel, /palletOptions\.value\s*=\s*\[\s*\.\.\.palletOptions\.value/) + assert.match(adminBehavior, /(?:remote search|远程搜索|keyword).{0,220}(?:size|page)/is) +}) + +test('Admin uses a virtualized selector so a large response cannot create one DOM option per candidate', () => { + assert.match(adminPanel, / { + const cards = endpointBlock(adminPanel, '
') + assert.match(cards, /row\.palletCode/) + assert.match(cards, /row\.palletType/) + assert.match(cards, /row\.palletTareWeightKg/) + assert.match(adminBehavior, /(?:historical|history|历史|失去候选|no longer eligible).{0,220}(?:palletCode|托盘编码|TP-)/is) +}) + +test('backend detail reads the stored historical pallet snapshot without revalidating current eligibility', () => { + assert.match(serviceTests, /detailUsesStoredPalletSnapshotAfterCandidateBecomesUnavailable/) + assert.match(serviceTests, /verify\(warehousePallets, never\(\)\)\.palletSnapshot/) + assert.match(serviceTests, /verify\(warehousePallets, never\(\)\)\.palletSnapshots/) +}) + +test('backend evidence fails closed for Warehouse non-2xx and network errors', () => { + assert.match(serviceTests, /weightPalletOptionsFailsClosedOnWarehouseNon2xxResponse/) + assert.match(serviceTests, /weightPalletOptionsFailsClosedOnWarehouseNetworkError/) + assert.match(serviceTests, /FeignException/) + assert.match(serviceTests, /RetryableException/) +}) diff --git a/scripts/admin-test-env-modules/weight-pallet-page-response-contract.test.mjs b/scripts/admin-test-env-modules/weight-pallet-page-response-contract.test.mjs new file mode 100644 index 000000000..d91ffbb58 --- /dev/null +++ b/scripts/admin-test-env-modules/weight-pallet-page-response-contract.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { normalizeWeightPalletOptionPage } from './inbound-weight-ocr.mjs' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') +const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java') +const pageResult = read('../../common-core/src/main/java/com/baiyue/common/core/api/PageResult.java') +const longJackson = read('../../common-web/src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java') +const serviceTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java') +const openapi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml') +const adminApi = read('../../../apps/admin-web/src/modules/inbounds/weight/api/weightOcr.ts') +const adminApiTests = read('../../../apps/admin-web/src/modules/inbounds/weight/api/weightOcr.test.ts') +const adminPanel = read('../../../apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue') +const adminBehavior = read('../../../apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts') + +function endpointBlock(source, marker, nextMarker) { + const start = source.indexOf(marker) + assert.notEqual(start, -1, `missing marker: ${marker}`) + const end = source.indexOf(nextMarker, start + marker.length) + return source.slice(start, end === -1 ? source.length : end) +} + +test('formal success accepts two records and string pagination numbers', () => { + const records = [{ palletId: '1' }, { palletId: '2' }] + assert.deepEqual(normalizeWeightPalletOptionPage({ records, page: '1', size: '20', total: '2', pages: '1' }), + { records, page: 1, size: 20, total: 2, pages: 1 }) +}) + +test('formal empty page remains a successful page instead of an error', () => { + assert.deepEqual(normalizeWeightPalletOptionPage({ records: [], page: '1', size: '20', total: '0', pages: '0' }), + { records: [], page: 1, size: 20, total: 0, pages: 0 }) +}) + +test('malformed array and invalid page metadata are rejected by the HTTP contract', () => { + assert.equal(normalizeWeightPalletOptionPage([]), null) + assert.equal(normalizeWeightPalletOptionPage({ records: [], page: 1, size: 20, total: -1, pages: 0 }), null) + assert.equal(normalizeWeightPalletOptionPage({ records: [], page: 1, size: 101, total: 0, pages: 0 }), null) +}) + +test('Controller DTO and response envelope use PageResult records/page/size/total/pages', () => { + const block = endpointBlock(controller, '@GetMapping("/weight-pallet-options")', '@BusinessAudit') + assert.match(block, /ApiResponse>/) + assert.match(pageResult, /record PageResult\(List records, long page, long size, long total, long pages\)/) +}) + +test('OpenAPI documents the page object and permits runtime string pagination numbers', () => { + const endpoint = endpointBlock(openapi, + ' /api/platform/v1/first-leg/inbounds/{inboundId}/weight-pallet-options:', + ' /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results:') + assert.match(endpoint, /data: \{\$ref: '#\/components\/schemas\/WeightPalletOptionPage'\}/) + const schema = endpointBlock(openapi, ' WeightPalletOptionPage:', ' CreateWeightResultRequest:') + assert.match(schema, /required: \[records, page, size, total, pages\]/) + for (const field of ['page', 'size', 'total', 'pages']) { + assert.match(schema, new RegExp(`${field}:[^\n]*(?:oneOf|string)`), + `${field} must permit the string form emitted by the shared Long serializer`) + } + assert.match(longJackson, /serializerByType\(Long\.TYPE, ToStringSerializer\.instance\)/) +}) + +test('Admin reads records, normalizes numeric strings, and never treats data as an array', () => { + const block = endpointBlock(adminApi, 'export async function getWeightPalletOptions', 'export async function uploadWeightImage') + assert.match(block, /ApiEnvelope/) + assert.match(block, /records:\s*page\.records/) + assert.match(block, /normalizedPageNumber\(page\.(?:page|size|total|pages)/) + assert.doesNotMatch(block, /ApiEnvelope/) + assert.match(adminPanel, /palletOptions\.value = result\.records/) +}) + +test('Admin behavior covers two-record success and an empty successful page', () => { + assert.match(adminApiTests, /formal paged pallet response[\s\S]{0,900}records[\s\S]{0,300}page: '1'/) + assert.match(adminBehavior, /no pallet options[\s\S]{0,500}palletPage\(\[\]\)/) +}) + +test('Admin behavior exposes a body code business error as a candidate load failure', () => { + assert.match(adminBehavior, /getWeightPalletOptions\)\.mockRejectedValueOnce\(new ApiResponseError\([^)]*'A0500'/) + assert.match(adminBehavior, /可选托盘加载失败|托盘服务暂不可用/) +}) + +test('Admin behavior separately covers a non-2xx HTTP or network error', () => { + assert.match(adminBehavior, /(?:HTTP error|HTTP错误|network error|网络错误)[\s\S]{0,700}(?:AxiosError|HTTP_5|status)/i) +}) + +test('Admin aborts and ignores an expired candidate response', () => { + assert.match(adminBehavior, /in-flight pallet request[\s\S]{0,900}signal\?\.aborted[\s\S]{0,300}过期托盘/) +}) + +test('backend preserves failure evidence for Warehouse non-2xx and network errors', () => { + assert.match(serviceTests, /weightPalletOptionsFailsClosedOnWarehouseNon2xxResponse/) + assert.match(serviceTests, /weightPalletOptionsFailsClosedOnWarehouseNetworkError/) + assert.match(serviceTests, /仓库服务暂不可用/) +}) diff --git a/scripts/admin-test-env-modules/批量待分柜数据生成指南.md b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md new file mode 100644 index 000000000..093e204a9 --- /dev/null +++ b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md @@ -0,0 +1,449 @@ +# 批量待分柜数据生成指南 + +本文说明如何复用 `scripts/admin-test-env-acceptance.mjs`,通过正式 HTTP 业务接口批量生成停在“测量完成待分柜”的头程订单。生成链路包含客户登录、下单、平台审核、入库、称重、测量、测量审核,不直接写业务数据库,也不模拟成功。 + +固定“IT测试”客户和“义乌仓”、其余业务字段随机、每票独立随机 `1–300` 箱并尽力生成 8,000 票的专项入口,见 `IT测试客户八千票待分柜数据生成方案.md` 和 `../it-test-wait-split-bulk.mjs`。 + +## 适用范围 + +- 用于开发、联调或经明确授权的验收环境准备人工操作数据。 +- 生成结果固定为入库单 `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 等链路可用。 +- 测量托盘只复用仓库已有 `TP-001`、`YW-TP-002`;脚本不创建托盘主档。库位由正式仓储接口分配,普通位不足时使用公共位 `A0`,脚本不创建库位主档。 + +账号、密码、环境域名和 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`;后续报关、国内出仓和海外接柜另按对应节点扩展。 + +## 失败、重跑与批量拆分 + +- `admin-test-env-acceptance.mjs --wait-split-count` 的普通批次遇到第一条失败即停止;`it-test-wait-split-bulk.mjs` 会记录该票失败并继续尝试后续票。两种入口都不会自动删除或回滚已经成功创建的订单。 +- 先查看日志和 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/it-test-wait-split-bulk.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/script-documentation-contract.test.mjs scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs 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/admin-test-env-request-retry-contract.test.mjs b/scripts/admin-test-env-request-retry-contract.test.mjs new file mode 100644 index 000000000..8e02b0188 --- /dev/null +++ b/scripts/admin-test-env-request-retry-contract.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' + +function runContext() { + return new AcceptanceRun({ gateway: 'http://acceptance.invalid', overseasGateway: 'http://overseas.invalid' }) +} + +test('read-only HTTP requests retry transient network failures', async () => { + const originalFetch = globalThis.fetch + let attempts = 0 + globalThis.fetch = async () => { + attempts += 1 + if (attempts < 3) throw new TypeError('fetch failed') + return new Response(JSON.stringify({ code: '200', data: { ready: true } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + try { + const response = await runContext().request('GET', '/api/readiness') + assert.equal(attempts, 3) + assert.equal(response.http, 200) + assert.equal(response.body?.data?.ready, true) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('mutating HTTP requests never retry an ambiguous network failure', async () => { + const originalFetch = globalThis.fetch + let attempts = 0 + globalThis.fetch = async () => { + attempts += 1 + throw new TypeError('fetch failed') + } + try { + const response = await runContext().request('POST', '/api/orders', { value: 1 }) + assert.equal(attempts, 1) + assert.equal(response.http, 0) + assert.equal(response.raw, 'fetch failed') + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/scripts/admin-test-phone-fixture-contract.test.mjs b/scripts/admin-test-phone-fixture-contract.test.mjs new file mode 100644 index 000000000..07a0221ae --- /dev/null +++ b/scripts/admin-test-phone-fixture-contract.test.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./admin-test-env-acceptance.mjs', import.meta.url), 'utf8') + +test('admin acceptance phone fixture resolves a currently available deterministic candidate', () => { + assert.match(source, /const seed = `\$\{this\.marker\}:\$\{this\.phoneSequence\}:\$\{attempt\}`/) + assert.match(source, /administrator-phone-availability\?phone=/) + assert.match(source, /attempt < 100/) + assert.doesNotMatch(source, /phoneSequence % 10\)/) +}) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 new file mode 100644 index 000000000..1b89c166a --- /dev/null +++ b/scripts/agents-index-contract.ps1 @@ -0,0 +1,1204 @@ +[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 +$businessIndexPath = Join-Path $workspaceRoot 'docs/业务快速索引.md' +$businessIndex = Read-RequiredText $businessIndexPath +Assert-Contains $rootAgents 'docs/业务快速索引.md' '根按需业务索引入口' +Assert-MarkdownTableUnique $businessIndexPath '## 业务快速路由表' 0 '详细业务路由表' +Assert-MarkdownTableUnique $businessIndexPath '## 常用说法与歧义分流' 0 '业务歧义分流表' +Assert-Contains $businessIndex '## 业务快速路由表' '详细业务表存在' +Assert-Contains $businessIndex '## 常用说法与歧义分流' '业务歧义分流存在' +$adminAgents = Read-RequiredText $adminAgentsPath +$adminOverseasAgentsPath = Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/AGENTS.md' +$adminOverseasAgents = Read-RequiredText $adminOverseasAgentsPath +Assert-Contains $adminAgents 'src/modules/overseas-warehouse/AGENTS.md' 'Admin 海外仓按需指引入口' +Assert-MarkdownTableUnique $adminOverseasAgentsPath '## 页面与业务快速索引' 0 'Admin 海外仓模块表' +$clientAgents = Read-RequiredText $clientAgentsPath +$pdaAgents = Read-RequiredText $pdaAgentsPath +$overseasPdaAgents = Read-RequiredText $overseasPdaAgentsPath +$backendAgents = Read-RequiredText $backendAgentsPath + +# 固定样本只检查目标索引行和真实文件,不递归搜索;字符数是读取量指标,不是耗时或 Token 实测。 +$lookupSamples = @( + @{ Key = '客户专属价格簿'; Text = $adminAgents; Entry = 'modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue'; Base = 'apps/admin-web/src' }, + @{ Key = '入库扣货、解除与凭证历史'; Text = $adminAgents; Entry = 'modules/unified-orders/pages/UnifiedOrdersView.vue'; Base = 'apps/admin-web/src' }, + @{ Key = '国内/海外 PDA 独立人员'; Text = $adminAgents; Entry = 'modules/iam/pages/InternalUsersView.vue'; Base = 'apps/admin-web/src' }, + @{ Key = '尾程订单控制'; Text = $adminOverseasAgents; Entry = 'pages/LastMileOrdersView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' }, + @{ Key = '排车查询'; Text = $adminOverseasAgents; Entry = 'pages/DispatchesView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' }, + @{ Key = '海外出库与派送'; Text = $adminOverseasAgents; Entry = 'pages/OutboundsView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' }, + @{ Key = '异常退回与客户回访'; Text = $adminOverseasAgents; Entry = 'pages/ExceptionOrdersView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' }, + @{ Key = '库存余额'; Text = $adminOverseasAgents; Entry = 'pages/InventoryView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' }, + @{ Key = '退货入库签收与合箱'; Text = $overseasPdaAgents; Entry = 'src/pages/return-receipt/return-receipt.vue'; Base = 'apps/pda-overseas-uniapp' }, + @{ Key = '退货入库签收与合箱'; Text = $overseasPdaAgents; Entry = 'src/pages/return-packing/return-packing.vue'; Base = 'apps/pda-overseas-uniapp' } +) +$lookupCharacters = 0 +foreach ($sample in $lookupSamples) { + $hits = @($sample.Text -split '\r?\n' | Where-Object { $_.StartsWith('| ' + $sample.Key) }) + $script:checked++ + if ($hits.Count -ne 1) { + Add-Failure "检索样本必须唯一命中: $($sample.Key),实际 $($hits.Count) 行" + continue + } + Assert-Contains $hits[0] $sample.Entry "检索样本 $($sample.Key)" + Assert-Path ($sample.Base + '/' + $sample.Entry) + $lookupCharacters += $hits[0].Length +} +Write-Host "定向检索样本:$($lookupSamples.Count) 项,命中行共 $lookupCharacters 字符(不含上层指引读取量,非端到端性能测量)。" + +$scriptsAgents = Read-RequiredText (Join-Path $backendRoot 'scripts/AGENTS.md') +$engineeringConstraints = Read-RequiredText (Join-Path $workspaceRoot 'docs/AI工程执行与协作约束.md') +$docsIndex = Read-RequiredText (Join-Path $workspaceRoot 'docs/README.md') +$testTaskRegistry = Read-RequiredText (Join-Path $workspaceRoot 'docs/独立任务协作登记.md') +$devTaskRegistry = Read-RequiredText (Join-Path $workspaceRoot 'docs/开发环境独立任务协作登记.md') +$mapperScanContract = Read-RequiredText (Join-Path $backendRoot 'scripts/mapper-scan-contract.mjs') + +# 当前未上线阶段的无兼容原则必须覆盖全部实现与运行状态,不能再次缩窄为文件或数据库专项规则。 +Assert-Contains $rootAgents '源码结构、接口与 DTO、字段、状态机、权限、配置键、数据库结构与初始化 SQL、事件、路由、页面、Mock/Fixture、测试和脚本' '根无兼容收敛范围' +Assert-Contains $rootAgents '数据库记录、上传文件、缓存、会话、消息、搜索索引和其他运行状态均视为可丢弃' '根开发运行状态范围' +Assert-Contains $rootAgents '后端 MyBatis Mapper 新增、移动或扫描遗漏' '根 Mapper 快速入口' +Assert-Contains $rootAgents 'node scripts/mapper-scan-contract.mjs' '根 Mapper 静态门禁' +Assert-Contains $businessIndex '脚本管理、全量验证与测试造数' '根脚本管理快速入口' +Assert-Contains $rootAgents 'baiyue-firstleg/scripts/AGENTS.md' '根脚本最近指引' +Assert-Contains $engineeringConstraints '禁止为旧代码、旧调用或开发环境存量保留旧接口/旧字段别名' '工程无兼容执行规则' +Assert-Contains $engineeringConstraints '不授权自动重置数据库、删除数据/文件、执行 SQL、部署或修改环境' '无兼容安全边界' +Assert-Contains $clientAgents '不得继续扩展 `LEGACY_MANUAL`、旧系统入仓码、C02 不可用回退旧 API' 'Client 头程无兼容边界' +Assert-Contains $backendAgents '所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`' '后端 Mapper 注册规则' +Assert-Contains $backendAgents 'Mapper 新增、移动、Bean 缺失与扫描遗漏' '后端 Mapper 快速路由' +Assert-Contains $backendAgents '脚本管理、全量验证、测试造数与诊断' '后端脚本管理快速路由' +Assert-Contains $backendAgents 'scripts/it-test-wait-split-bulk.mjs' '后端批量待分柜造数入口' +Assert-Contains $backendAgents 'scripts/create-pending-review-orders.mjs' '后端待审核订单造数入口' +Assert-Contains $backendAgents 'scripts/client-quick-order-test-env-acceptance.mjs' '后端 Client 快速下单入口' +Assert-Contains $backendAgents 'service-center-flow' '后端服务中心验收模块' +Assert-Contains $scriptsAgents 'admin-test-env-acceptance.mjs' '脚本指引 Admin 全量入口' +Assert-Contains $scriptsAgents 'client-test-env-acceptance.mjs' '脚本指引 Client 全量入口' +# 开发与测试独立任务必须按保存项目、cwd 和登记表完全隔离,task_id 不得跨表复用。 +Assert-Contains $rootAgents 'docs/开发环境独立任务协作登记.md' '根开发协作登记入口' +Assert-Contains $rootAgents 'docs/独立任务协作登记.md' '根测试协作登记入口' +Assert-Contains $engineeringConstraints '不得同时加载两表后混合选取任务' '工程协作登记选择门禁' +Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务协作登记.md)' '文档测试登记入口' +Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口' +# 当前测试登记已明确清空;历史 projectId/task_id 不得成为恢复调度的依据。 +Assert-Contains $testTaskRegistry '当前无登记任务' '测试登记清空明示' +Assert-Contains $testTaskRegistry '清空全部任务登记与调度规则' '测试登记清空状态' +Assert-Contains $testTaskRegistry '不根据旧登记、历史消息或历史台账自动派工、恢复任务或回传进度' '测试登记禁止历史派工' +Assert-Contains $testTaskRegistry '不作为当前调度或授权依据' '测试登记清空授权边界' +Assert-NotContains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记不得隐式启用' +$checked++ +if ($testTaskRegistry -match '(?m)^\|\s*`[0-9a-f-]{36}`\s*\|') { + Add-Failure '测试登记不得残留任务行' +} +Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控' +Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控' +Assert-NotContains $testTaskRegistry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发 projectId' +Assert-Contains $devTaskRegistry 'projectId `6f874ec3-d3b2-49bd-8625-327f4d685f98`' '开发登记保存项目' +Assert-Contains $devTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global-dev`' '开发登记工作目录' +Assert-Contains $devTaskRegistry '`01a06c0b-0515-7c60-b329-77342b54e666`' '开发环境全流程总控' +Assert-NotContains $devTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '开发登记测试总控' +Assert-NotContains $devTaskRegistry 'c64729ff-6710-43c6-8ef2-d84e9666cc1e' '开发登记测试 projectId' + +# 业务文档已由开发批次目录收敛为稳定业务域,根快速路由不得继续引用已删除阶段目录。 +foreach ($businessDocsEntry in @( + 'docs/订单与客户/README.md', + 'docs/入库测量与计费/README.md', + 'docs/国内货柜履约/README.md', + 'docs/海外仓业务/README.md' +)) { + Assert-Contains $businessIndex $businessDocsEntry "根业务域入口 $businessDocsEntry" +} +foreach ($removedPhaseDocsEntry in @('docs/一期/', 'docs/二期/', 'docs/三期/', 'docs/四期/')) { + Assert-NotContains $rootAgents $removedPhaseDocsEntry "根已删除阶段目录 $removedPhaseDocsEntry" +} +Assert-NotContains $rootAgents '不含出库和派送' '根海外仓全流程范围' + +# 新增稳定业务能力必须从根表和目标部署单元直接命中。 +Assert-Contains $businessIndex '入库扣货、解除与凭证' '根入库扣货路由' +Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货路由' +Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货路由' +Assert-Contains $businessIndex '客户资金账户、充值、授信与业务扣费' '根客户资金账户路由' +Assert-Contains $adminAgents '客户资金账户与柜级公共费用分摊' 'Admin 客户资金账户路由' +Assert-Contains $clientAgents '客户资金账户、授信与充值申请' 'Client 客户资金账户路由' +Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户资金账户路由' +Assert-Contains $businessIndex '顶层授权快照' '根海外顶层授权快照' +Assert-Contains $backendAgents '顶层授权快照' '后端海外顶层授权快照' +Assert-Contains $businessIndex '目的港码头拖车与还空预警' '根目的港码头拖车路由' +Assert-Contains $businessIndex '与订舱级拖车预约是不同生命周期' '根两类拖车边界' +Assert-Contains $businessIndex '尾程订单控制与柜级入仓计划转换' '根尾程订单控制路由' +Assert-Contains $adminAgents '目的港码头拖车与还空预警' 'Admin 目的港码头拖车路由' +Assert-Contains $adminAgents '尾程订单控制与柜级入仓计划转换' 'Admin 尾程订单控制路由' +Assert-Contains $backendAgents '目的港码头拖车与还空预警' '后端目的港码头拖车路由' +Assert-Contains $backendAgents '尾程订单控制、柜级预览与入仓计划转换' '后端尾程订单控制路由' +$testTaskIds = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value }) +$devTaskIds = @([regex]::Matches($devTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value }) +foreach ($sharedTaskId in @($testTaskIds | Where-Object { $devTaskIds -contains $_ } | Select-Object -Unique)) { + $script:checked++ + Add-Failure "开发/测试登记复用了 task_id: $sharedTaskId" +} +Assert-Contains $scriptsAgents 'client-quick-order-test-env-acceptance.mjs' '脚本指引 Client 快速下单入口' +Assert-Contains $scriptsAgents 'admin-test-env-modules/service-center-flow.mjs' '脚本指引服务中心验收模块' +Assert-Contains $scriptsAgents 'admin-test-env-modules/phase4-tail-order-controls.mjs' '脚本指引尾程单控制模块' +Assert-Contains $scriptsAgents 'admin-test-env-modules/phase5-terminal-drayage.mjs' '脚本指引目的港码头拖车模块' +Assert-Contains $scriptsAgents 'script-documentation-contract.test.mjs' '脚本说明防漏门禁' +Assert-Contains $scriptsAgents '不得散落到 `src/test/scripts/`' '服务局部脚本目录边界' +Assert-Path 'baiyue-firstleg/scripts/AGENTS.md' +Assert-Path 'baiyue-firstleg/scripts/README.md' +Assert-Path 'baiyue-firstleg/scripts/script-documentation-contract.test.mjs' +Assert-Contains $mapperScanContract 'annotationClass = Mapper.class' 'Mapper 根包注解过滤合同' +Assert-Contains $mapperScanContract '禁止分散维护 MapperScan' 'Mapper 单一扫描入口合同' +$firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md') +$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') +$warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md') +$coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml') +Assert-Contains $businessIndex 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口' +Assert-Contains $adminAgents '成员数是入仓片段数,不等于订单票数' 'Admin 同柜成员语义' +Assert-Contains $backendAgents 'loading/coload/controller/PlatformColoadGroupController.java' '后端同柜不可拆组入口' +Assert-Contains $firstlegAgents '服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数' 'Firstleg 同柜建组边界' +Assert-Contains $warehouseAgents 'scripts/warehouse-public-master-data-contract.test.mjs' 'Warehouse 局部脚本入口' +Assert-Contains $coloadOpenApi '允许跨客户及不同目的仓港成组,仅要求国内仓和渠道一致' '同柜资格 OpenAPI' +Assert-Contains $coloadOpenApi '服务端解析每个订单全部 WAIT_SPLIT' '同柜订单展开 OpenAPI' +Assert-Contains $rootAgents '用户可见页面、提示和错误不得展示或以内部数据库 ID 作为业务编号回退' '根业务编号展示边界' +Assert-Contains $adminAgents '/loading-plans/{planId}/lifecycle' 'Admin 装柜清单生命周期入口' +Assert-Contains $adminAgents '合柜报关 / 同柜不可拆组' 'Admin 合柜报关别名入口' +Assert-Contains $clientAgents 'api/inboundCodeAssociations.ts' 'Client 入仓码业务关系入口' +Assert-Contains $pdaAgents 'tests/page-contract.test.mjs' '国内 PDA 页面合同入口' +Assert-Contains $overseasPdaAgents 'tests/business-identifiers-ui.test.mjs' '海外 PDA 内部 ID 隔离合同' +$orderAgents = Read-RequiredText (Join-Path $backendRoot 'order/AGENTS.md') +Assert-Contains $orderAgents '`channelCode` 直接来自 Order/Firstleg 权威快照' 'Order 稳定渠道编码' +Assert-Contains $orderAgents 'InboundCodeAssociationService.java' 'Order 入仓码业务关系入口' +Assert-Contains $firstlegAgents '/loading-plans/{planId}/lifecycle' 'Firstleg 装柜生命周期入口' +Assert-Contains $lastlegAgents 'LastlegOrderCommandService.java' 'Lastleg 尾程订单命令入口' +Assert-Contains $lastlegAgents '固定八行完整快照' 'Lastleg 人工节点快照边界' +Assert-Contains $lastlegAgents '/api/platform/v1/last-leg/inbound-plans/convert-container' 'Lastleg 整柜转换正式入口' + +# 本轮新增的批量下单、测量计费、智能装柜、账单联查与导出重试必须从最近指引直达。 +$fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md') +$financeAgents = Read-RequiredText (Join-Path $backendRoot 'finance/AGENTS.md') +Assert-Contains $businessIndex '客户头程订单批量导入' '根批量下单快速路由' +Assert-Contains $orderAgents 'MerchantBatchImportService.java' 'Order 批量导入入口' +Assert-Contains $orderAgents '仓库目录必须翻页读取全部候选' 'Order 批量模板全量仓库约束' +Assert-Contains $clientAgents '中文批量导入' 'Client 批量导入入口' +Assert-Contains $firstlegAgents '客户四档箱规与默认算法' 'Firstleg 客户箱规入口' +Assert-Contains $firstlegAgents '`ADD/MULTIPLY`' 'Firstleg 箱规算法约束' +Assert-Contains $adminAgents '客户箱规策略与计价主数据' 'Admin 箱规计费入口' +Assert-Contains $backendAgents '全局智能装柜候选、柜次、占位与移除' '后端全局智能装柜路由' +Assert-Contains $firstlegAgents '必须成员完整、满箱并作为单个分页与分配单元' 'Firstleg 智能装柜组分页边界' +Assert-Contains $adminAgents 'GlobalSmartLoadingView.vue' 'Admin 全局智能装柜入口' +Assert-Contains $financeAgents 'StatementLine.inboundCode' 'Finance 账单入仓码字段' +Assert-Contains $clientAgents '账单行只显示服务端 `inboundCode`' 'Client 账单入仓码联查边界' +Assert-Contains $fileAgents '`PENDING` 且已有失败尝试' 'File 导出退避重试边界' +Assert-Contains $adminAgents 'attemptCount/maxAttempts/nextRetryAt' 'Admin 导出重试状态入口' +Assert-Path 'baiyue-firstleg/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java' +Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java' +Assert-Path 'apps/admin-web/src/modules/container-loading/pages/GlobalSmartLoadingView.vue' +Assert-Path 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' + +# 平台修改客户原始申报必须能从各层索引直达,并锁住 Order -> Firstleg 的版本化同步与渠道门禁。 +$orderOpenApi = Read-RequiredText (Join-Path $backendRoot 'order/src/main/resources/openapi/order-v1.yaml') +$firstlegOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/firstleg-v1.yaml') +$orderDeclarationService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java') +$unifiedOrderQueryService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java') +$firstlegDeclarationService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java') +$adminDeclarationEditor = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue') +Assert-Contains $businessIndex '平台修改客户原始申报与履约渠道' '根订单申报快速路由' +Assert-Contains $adminAgents '客户原始申报资料编辑与渠道变更' 'Admin 订单申报快速路由' +Assert-Contains $backendAgents '平台修改客户原始申报、履约渠道变更与跨服务同步' '后端订单申报快速路由' +Assert-Contains $orderAgents '平台版本化修改客户原始申报' 'Order 申报编辑入口' +Assert-Contains $firstlegAgents 'Order 申报同步与渠道变更履约门禁' 'Firstleg 申报同步入口' +Assert-Contains $orderOpenApi '/api/platform/v1/orders/{orderId}/declaration:' 'Order 申报编辑 OpenAPI' +Assert-Contains $orderOpenApi '不覆盖实测、箱码或已确认费用' 'Order 申报与履约事实边界' +Assert-Contains $firstlegOpenApi '/internal/firstleg/v1/inbounds/order-declaration-updated:' 'Firstleg 申报同步 OpenAPI' +Assert-Contains $orderDeclarationService 'OrderStatus.DRAFT, OrderStatus.REJECTED, OrderStatus.SUBMITTED, OrderStatus.APPROVED' 'Order 申报可编辑状态' +Assert-Contains $orderDeclarationService 'ORDER_DECLARATION_UPDATED_V1' 'Order 申报版本事件' +Assert-Contains $firstlegDeclarationService 'mapper.byEvent(event.tenantId(), event.eventId())' 'Firstleg 申报 eventId 幂等' +Assert-Contains $firstlegDeclarationService 'event.version() < current.orderVersion()' 'Firstleg 申报版本单调性' +Assert-Contains $adminDeclarationEditor '仓库/PDA 实测数据不会被覆盖' 'Admin 申报事实边界提示' +Assert-Contains $businessIndex '统一列表仍固定显示 `PENDING_REVIEW` 并提供审核动作' '根待审核订单优先状态' +Assert-Contains $orderAgents '统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER`' 'Order 待审核优先状态' +Assert-Contains $unifiedOrderQueryService 'order.getStatus() == OrderStatus.SUBMITTED' 'Order 待审核状态判断' +Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : completed ? "COMPLETED" : summary.mainStatus()' 'Order 待审核主状态优先于完成态与履约投影' +Assert-Contains $unifiedOrderQueryService 'pendingReview ? "APPROVE_ORDER" : completed ? null : summary.nextAction()' 'Order 待审核动作优先且完成态无下一动作' + +# 批量审核、客户专属价格簿和柜级阶梯计价已经成为稳定跨端能力,必须从根表直达且锁住服务端权威规则。 +$batchReviewPreviewService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java') +$fbaPricingOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/fba-pricing-v1.yaml') +$fbaPricingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java') +$inboundFeeDetailService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java') +$adminUnifiedOrdersApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/api/unifiedOrders.ts') +$adminCarrierRatesView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue') +Assert-Contains $businessIndex '头程订单、批量审核预览与入仓码关系' '根批量审核预览路由' +Assert-Contains $adminAgents '订单列表、批量审核预览、详情、商品图片与入库费用' 'Admin 批量审核预览路由' +Assert-Contains $backendAgents '平台订单批量审核预览' '后端批量审核预览路由' +Assert-Contains $orderAgents '平台批量审核预览' 'Order 批量审核预览入口' +Assert-Contains $orderOpenApi '/api/platform/v1/orders/first-leg/batch-review-preview:' 'Order 批量审核预览 OpenAPI' +Assert-Contains $batchReviewPreviewService '固定两次批量查询生成审核弹窗快照' 'Order 批量审核禁止逐票查询' +Assert-Contains $adminUnifiedOrdersApi '/platform/v1/orders/first-leg/batch-review-preview' 'Admin 批量审核预览 API' +Assert-Contains $businessIndex '客户专属报价、柜级阶梯运费与费用调整' '根客户专属报价路由' +Assert-Contains $adminAgents '客户专属价格簿、柜级阶梯运费与受控调价' 'Admin 客户专属报价路由' +Assert-Contains $backendAgents '客户专属价格簿、柜级阶梯运费与受控调价' '后端客户专属报价路由' +Assert-Contains $firstlegAgents '客户专属价格簿、柜级阶梯运费与调价边界' 'Firstleg 客户专属报价入口' +Assert-Contains $fbaPricingOpenApi '计价时优先使用客户专属 ACTIVE 版本,不存在时回退公共 ACTIVE 版本' '价格簿作用域优先级' +Assert-Contains $fbaPricingOpenApi '计费量只能在系统原计费量上下10%范围内(含边界)' '计费量调整边界' +Assert-Contains $fbaPricingService 'recalculateUnlockedContainerCustomerFees' '柜级阶梯费用重算入口' +Assert-Contains $firstlegAgents '一件代发 `WAIT_PALLETIZE` 可在预装确认前试算' 'Firstleg 一件代发预装前计价边界' +Assert-Contains $fbaPricingService '"WAIT_PALLETIZE"' '一件代发托盘化状态可计价' +Assert-Contains $fbaPricingService 'preserveProcessStatusDuringPricing' '计价保留履约状态入口' +Assert-Contains $inboundFeeDetailService 'PRIVATE_LAST_MILE_DELIVERY_FEE_CODE' '私人仓尾程派送费稳定代码' +Assert-Contains $adminCarrierRatesView 'customerId' 'Admin 价格簿客户作用域' +Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocator.java' + +# 海外异常处理必须贯通证据、原出库来源和库存恢复,不能退化为仅修改派送状态。 +$exceptionOrderService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java') +$inventoryCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java') +Assert-Contains $businessIndex '退回海外仓时先把实收恢复到指定真实托盘/库位' '根海外异常库存恢复路由' +Assert-Contains $adminOverseasAgents '库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进' 'Admin 海外异常原子边界' +Assert-Contains $backendAgents '海外异常退回、库存恢复与客户回访' '后端海外异常库存恢复路由' +Assert-Contains $lastlegAgents 'ExceptionEvidenceFileGateway.java' 'Lastleg 异常证据入口' +Assert-Contains $lastlegAgents '`RETURN_STOCK_IN` 流水' 'Lastleg 退回库存流水边界' +Assert-Contains $fileAgents 'businessType=OVERSEAS_EXCEPTION_EVIDENCE' 'File 海外异常证据归属' +Assert-Contains $exceptionOrderService 'restoreReturnedInventory' '海外异常退回库存恢复调用' +Assert-Contains $exceptionOrderService 'delivery.setStatus("RETURNED")' '海外异常完成派送状态' +Assert-Contains $inventoryCommandService 'ReturnedInventoryRestoreRequest' '海外退回库存命令入口' +Assert-Path 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java' + +# 智能装柜已扩展到四态候选、Order 待审核补齐、提前占位与未拣整批移除;费用确认和会话恢复也必须可定向命中。 +$pendingReviewOrderService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java') +$merchantInboundBillingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java') +$clientSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/session.ts') +$clientHttp = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/http.ts') +Assert-Contains $businessIndex '`PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING`' '根智能装柜四态候选' +Assert-Contains $businessIndex '能否加入以服务端 `canAssign` 为准' '根智能装柜资格边界' +Assert-Contains $businessIndex '人工加入允许跨渠道,智能推荐仍按柜次渠道' '根智能装柜渠道边界' +Assert-Contains $backendAgents '/pending-review-assignments' '后端待审核装柜占位入口' +Assert-Contains $backendAgents '/unpicked-items' '后端未拣整批移除入口' +Assert-Contains $firstlegAgents '任一成员已拣则整组保留' 'Firstleg 同柜组整批移除边界' +Assert-Contains $orderAgents 'PendingReviewOrderQueryService.java' 'Order 待审核装柜候选入口' +Assert-Contains $adminAgents '按钮资格以服务端 `canAssign` 为准' 'Admin 智能装柜资格边界' +Assert-Contains $firstlegOpenApi '未传或空数组表示 PENDING_REVIEW、WAIT_MEASURE、WAIT_SPLIT、' '智能装柜四态 OpenAPI' +Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/pending-review-assignments:' '待审核占位 OpenAPI' +Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/unpicked-items:' '未拣整批移除 OpenAPI' +Assert-Contains $pendingReviewOrderService 'orders.pendingReviewPage' 'Order 待审核候选权威查询' +Assert-Contains $pendingReviewOrderService '"PENDING_REVIEW"' 'Order 待审核候选状态映射' +Assert-Contains $businessIndex '客户入库结果与头程费用确认' '根客户入库费用确认入口' +Assert-Contains $firstlegAgents 'expectedFeeVersion' 'Firstleg 客户费用版本确认' +Assert-Contains $clientAgents 'components/InboundBillingPanel.vue' 'Client 入库费用确认入口' +Assert-Contains $firstlegOpenApi '/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing/confirm:' '客户费用确认 OpenAPI' +Assert-Contains $merchantInboundBillingService 'pricing.confirmByCustomer' 'Firstleg 客户费用确认服务' +Assert-Contains $clientAgents '“记住账号”只记登录标识' 'Client 记住账号边界' +Assert-Contains $clientSession 'localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)' 'Client 刷新令牌持久化' +Assert-Contains $clientHttp "'/auth/refresh'" 'Client 公开刷新入口' +Assert-Path 'baiyue-firstleg/order/src/main/java/com/baiyue/order/controller/InternalPendingReviewOrderController.java' +Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java' +Assert-Path 'apps/client-web/src/modules/first-leg/components/InboundBillingPanel.vue' + +# 称重确认后的整票库位块和智能装柜暂估数量已成为跨端权威事实,索引必须直达并锁住回退顺序。 +$measurementLocationOccupationService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java') +$inboundMeasurementService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java') +$measurementSubmissionService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java') +$smartLoadingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java') +$inboundSchema = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/db/phase2-inbound-schema.sql') +$adminWeightPanel = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue') +$clientOrdersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/first-leg/views/FirstLegOrdersView.vue') +Assert-Contains $businessIndex '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由' +Assert-Contains $backendAgents '称重确认、整票库位块与 PDA 资源结算' '后端称重库位块路由' +Assert-Contains $firstlegAgents 'MeasurementLocationOccupationService.java' 'Firstleg 称重库位块入口' +Assert-Contains $firstlegAgents '最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值' 'Firstleg 库位块测量批次体积选择' +Assert-Contains $firstlegAgents '再无有效值才查询 Order 最新客户申报体积' 'Firstleg 库位块体积回退顺序' +Assert-Contains $firstlegAgents '库位指引失败只记录警告,不阻断称重确认' 'Firstleg 称重库位非阻断语义' +Assert-Contains $firstlegAgents '`ensureForSubmission` 再次补齐和校验' 'Firstleg 提交库位补偿入口' +Assert-Contains $firstlegAgents '槽位与称重唯一键必须包含 `inbound_id`' 'Firstleg 部分拣货快照身份' +Assert-Contains $adminAgents '`locationCode/slotStatus/sharedOverflow`' 'Admin 权威称重库位状态' +Assert-Contains $measurementLocationOccupationService 'orderCargo.measurementDeclaration' 'Firstleg Order 申报体积回退' +Assert-Contains $measurementLocationOccupationService 'measuredVolume.max(declaredVolume)' 'Firstleg 测量批次体积取较大值' +Assert-Contains $measurementLocationOccupationService 'ensureForSubmission' 'Firstleg 提交库位补齐实现' +Assert-Contains $inboundMeasurementService 'ensureLocationWithoutBlocking' 'Firstleg 称重库位非阻断实现' +Assert-Contains $measurementSubmissionService 'locations.ensureForSubmission' 'Firstleg 提交库位补齐调用' +Assert-Contains $firstlegOpenApi '测量批次冻结的申报体积、Order 最新客户申报体积' '称重确认体积回退 OpenAPI' +Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_slot (tenant_id, inbound_id, block_id, slot_no)' '部分拣货块槽快照唯一键' +Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_weight (tenant_id, inbound_id, weight_result_id)' '部分拣货称重快照唯一键' +Assert-Contains $adminWeightPanel 'row.locationCode' 'Admin 称重卡片库位消费' +Assert-Contains $adminWeightPanel 'putawayStatusLabel(row.slotStatus)' 'Admin 称重卡片槽位状态' +Assert-NotContains $adminWeightPanel 'class="putaway-guide"' 'Admin 已移除整票预留引导区' +Assert-Contains $businessIndex '`totalPackageCount/remainingPackageCount`' '根智能装柜暂估数量' +Assert-Contains $firstlegAgents '`totalPackageCount/remainingPackageCount`' 'Firstleg 智能装柜暂估数量' +Assert-Contains $adminAgents '`totalPackageCount/remainingPackageCount`' 'Admin 智能装柜暂估数量' +Assert-Contains $firstlegOpenApi 'required: [id, packageCount, totalPackageCount, remainingPackageCount' '智能装柜计划数量 OpenAPI' +Assert-Contains $smartLoadingService 'repository.measuredPackageCount' '智能装柜已审核测量数量' +Assert-NotContains $firstlegAgents '| 客户费用查询 |' 'Firstleg 重复客户费用入口' +Assert-Contains $clientAgents '所有订单行均可进入计费重弹框' 'Client 独立计费重入口' +Assert-Contains $clientOrdersView 'dialog-only' 'Client 独立计费重弹框实现' +Assert-Path 'baiyue-firstleg/warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java' + +# 企业联系信息与账号登录标识已经解耦,资质审核不得重新把法人身份证设为必填。 +$authAgents = Read-RequiredText (Join-Path $backendRoot 'auth/AGENTS.md') +$iamAgents = Read-RequiredText (Join-Path $backendRoot 'iam/AGENTS.md') +$iamOpenApi = Read-RequiredText (Join-Path $backendRoot 'iam/src/main/resources/openapi/iam-v1.yaml') +$authAccountService = Read-RequiredText (Join-Path $backendRoot 'auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java') +$iamMemberService = Read-RequiredText (Join-Path $backendRoot 'iam/src/main/java/com/baiyue/iam/service/MemberService.java') +$adminCustomersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/customers/pages/CustomersView.vue') +$clientCompanyProfile = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/CompanyProfileView.vue') +$clientMembersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/MembersView.vue') +Assert-Contains $businessIndex '公司联系电话/邮箱是企业资料,不作为成员登录标识' '根企业联系人边界' +Assert-Contains $adminAgents '公司联系电话只作企业联系信息,不修改管理员/成员登录手机号' 'Admin 企业联系人边界' +Assert-Contains $clientAgents '成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计' 'Client 成员登录手机号边界' +Assert-Contains $authAgents '登录标识与企业联系人解耦' 'Auth 登录标识入口' +Assert-Contains $iamAgents '公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一' 'IAM 企业联系人边界' +Assert-Contains $iamAgents '审核必填资质仅营业执照与统一社会信用代码,法人身份证选填' 'IAM 必填资质边界' +Assert-Contains $iamOpenApi '公司联系电话,不作为登录标识且不要求跨企业唯一' 'IAM 公司联系电话 OpenAPI' +Assert-Contains $authAccountService 'tokenSessionStore.revokeAll(account.getId())' 'Auth 登录标识变更会话撤销' +Assert-Contains $iamMemberService 'authClient.revokeSessions' 'IAM 成员变更会话撤销' +Assert-Contains $adminCustomersView '不会修改企业管理员登录手机号' 'Admin 公司联系电话提示' +Assert-Contains $clientCompanyProfile '不会修改任何成员的登录手机号' 'Client 公司联系电话提示' +Assert-Contains $clientMembersView '用于该成员手机号登录;修改后会同步认证账号并退出该成员现有会话' 'Client 成员登录手机号提示' +$fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md') +$qualificationOpenApi = Read-RequiredText (Join-Path $backendRoot 'file/src/main/resources/openapi/qualification-required-package-v1.yaml') +Assert-Contains $businessIndex '法人身份证选填,可在已有营业执照后单独补传或替换' '根法人身份证单独上传语义' +Assert-Contains $fileAgents '法人身份证选填,客户可在已有营业执照后通过 `/api/merchant/v1/files/qualifications` 单独新增或替换' 'File 法人身份证单独上传入口' +Assert-Contains $qualificationOpenApi '/api/merchant/v1/files/qualifications:' '客户单文件资质 OpenAPI' +Assert-Contains $qualificationOpenApi 'LEGAL_PERSON_ID 可在已有营业执照后单独新增或替换' '客户法人身份证单独上传契约' +Assert-Contains $qualificationOpenApi 'BUSINESS_LICENSE 不允许走此入口' '营业执照整包上传边界' +Assert-Contains $clientCompanyProfile '法人身份证已保存,尚未提交审核' 'Client 法人身份证单独上传反馈' +Assert-NotContains $clientCompanyProfile '当前文件服务尚未开放法人身份证单独上传或替换' 'Client 已移除法人身份证旧阻断' +$truckOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/truck-appointments-v1.yaml') +$terminalDrayageOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml') +$terminalDrayageService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java') +$lastlegOrderCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java') +$adminTerminalDrayages = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/container-loading/api/terminalDrayages.ts') +$adminLastMileOrders = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') +$loadingListsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/loading-lists-v1.yaml') +$pdaLoadingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/api/pda-loading.js') +$cabinetEvidenceService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java') +Assert-Contains $businessIndex '拖车预约由 `RELEASED` 订舱拥有,不按客户、柜次、装柜计划或真实柜号归属' '根订舱级拖车语义' +Assert-Contains $businessIndex '证据以 `taskId + warehouseId` 进入任务专用 API' '根柜级证据入口' +Assert-Contains $adminAgents '订舱级拖车预约' 'Admin 拖车快速入口' +Assert-Contains $adminAgents '不选择或提交客户、柜次、装柜计划或真实集装箱号' 'Admin 拖车订舱级边界' +Assert-Contains $backendAgents '订舱级拖车' '后端拖车快速入口' +Assert-Contains $firstlegAgents '请求、实体、查询、响应和附件不得恢复 `customerId/customerName`' 'Firstleg 拖车无客户边界' +Assert-Contains $firstlegAgents '公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`' 'Firstleg 拖车无柜次边界' +Assert-Contains $firstlegAgents '每订舱通过查询锁与唯一约束最多一张非终态预约' 'Firstleg 拖车唯一有效预约' +Assert-Contains $fileAgents '`businessType=TRUCK_APPOINTMENT_ATTACHMENT`' 'File 拖车平台附件入口' +Assert-Contains $truckOpenApi 'required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]' '拖车订舱级请求字段' +Assert-NotContains $truckOpenApi 'customerId:' '拖车 OpenAPI 客户字段' +Assert-NotContains $truckOpenApi 'containerBookingId:' '拖车 OpenAPI 柜次字段' +Assert-NotContains $truckOpenApi 'loadingPlanId:' '拖车 OpenAPI 装柜计划字段' +Assert-Contains $truckOpenApi 'Each booking may have at most one non-terminal appointment' '拖车订舱级唯一性' +Assert-Contains $truckOpenApi 'PDA loading completion alone does not complete the appointment' '拖车与 PDA 生命周期解耦' +Assert-Contains $truckOpenApi 'excluding bookings that already have an active or completed truck appointment' '拖车创建候选过滤' +Assert-Contains $firstlegAgents '目的港码头拖车与还空预警' 'Firstleg 目的港码头拖车入口' +Assert-Contains $firstlegAgents '按卸柜完成时间加 72 小时' 'Firstleg 还空预警口径' +Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:' '码头拖车车队命令 OpenAPI' +Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:' '码头拖车还柜命令 OpenAPI' +Assert-Contains $terminalDrayageService 'EMPTY_RETURN_WARNING_HOURS = 72' '码头拖车 72 小时预警实现' +Assert-Contains $terminalDrayageService 'context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)' '码头拖车权威截止时间实现' +Assert-Contains $adminTerminalDrayages 'allowedActions' 'Admin 码头拖车服务端动作消费' +Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间快照' '尾程人工节点完整快照实现' +Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现' +Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold' 'Admin 尾程暂扣入口' +Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes' 'Admin 尾程人工节点入口' +Assert-Contains $adminOverseasAgents '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界' +Assert-Contains $lastlegAgents '接柜仓编码/名称以该 ID 当前目录快照为准' 'Lastleg 稳定接柜仓身份边界' +Assert-Contains $lastlegOrderCommandService '同一幂等键不能用于不同请求' '尾程命令请求哈希重放边界' +Assert-Contains $loadingListsOpenApi '且真实集装箱号已经补录' '确认预装真实柜号门禁' +Assert-Contains $loadingListsOpenApi '可在 EDITING 或 LOADING 阶段首次补录真实集装箱号' '预装真实柜号首次补录阶段' +Assert-Contains $adminAgents '创建柜次时自动生成唯一预装计划' 'Admin 自动预装计划流程' +Assert-Contains $loadingListsOpenApi 'PDA 创建前置条件是否全部满足,包含真实集装箱号已录入' 'PDA task 真实柜号门禁' +Assert-Contains $adminAgents '确认预装和创建 PDA 任务前必须补齐' 'Admin 真实柜号确认门禁' +Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口' +Assert-Contains $pdaLoadingApi '/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=' '国内 PDA 证据查询 API' +Assert-NotContains $pdaLoadingApi '/inbounds/${encodeURIComponent(inboundId)}/loading/evidence' '国内 PDA 旧入仓证据 API' +Assert-Contains $businessIndex '上传以幂等键绑定包含文件内容的请求哈希' '根装柜证据上传重放边界' +Assert-Contains $firstlegAgents '完全重放返回当前视图且不得再次上传 File' 'Firstleg 装柜证据重放边界' +Assert-Contains $cabinetEvidenceService 'uploadHash(warehouseId, expectedVersion, evidenceType, sealNo, remark, multipart)' '装柜证据上传请求哈希' +Assert-Contains $cabinetEvidenceService 'digest.update(multipart.getBytes())' '装柜证据文件内容哈希' +Assert-Matches $cabinetEvidenceService '(?s)mapper\.byOperation\(taskId, key\).{0,500}?return view\(scope\);.{0,500}?files\.upload' '装柜证据上传先查重放再调用 File' +$mapperModuleAgents = @( + @{ Module = 'audit-service'; Application = 'BaiyueAuditApplication.java' }, + @{ Module = 'auth'; Application = 'BaiyueAuthApplication.java' }, + @{ Module = 'event-bridge-service'; Application = 'EventBridgeApplication.java' }, + @{ Module = 'file'; Application = 'FileServiceApplication.java' }, + @{ Module = 'firstleg'; Application = 'BaiyueFirstlegApplication.java' }, + @{ Module = 'iam'; Application = 'BaiyueIamApplication.java' }, + @{ Module = 'lastleg-service'; Application = 'BaiyueLastlegApplication.java' }, + @{ Module = 'order'; Application = 'BaiyueOrderApplication.java' }, + @{ Module = 'warehouse'; Application = 'BaiyueWarehouseApplication.java' }, + @{ Module = 'warehouse-overseas-service'; Application = 'BaiyueWarehouseOverseasApplication.java' } +) +foreach ($entry in $mapperModuleAgents) { + $moduleAgents = Read-RequiredText (Join-Path $backendRoot "$($entry.Module)/AGENTS.md") + Assert-Contains $moduleAgents $entry.Application "$($entry.Module) Mapper 启动入口" + Assert-Contains $moduleAgents 'scripts/mapper-scan-contract.mjs' "$($entry.Module) Mapper 静态门禁" +} + +# 部署单元必须能从根索引进入。 +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 拆柜收货开关' +Assert-Matches $overseasWorkbench "(?s)name:\s*'托盘管理'.{0,300}?available:\s*true.{0,200}?route:\s*'/pages/pallet/pallet'" 'Overseas PDA 托盘管理开关' +Assert-NotContains $overseasWorkbench "key: 'stocktake'" 'Overseas PDA 隐藏库存盘点入口' +foreach ($enabledOperation in @( + @{ Name = '商品移库'; Route = '/pages/move/move' }, + @{ Name = '换标列表'; Route = '/pages/relabel/relabel\?op=relabelReceive' }, + @{ Name = '换标执行'; Route = '/pages/relabel/relabel\?op=relabelExecute' }, + @{ Name = '换标复核'; Route = '/pages/relabel/relabel\?op=relabelReview' }, + @{ Name = '库存查询'; Route = '/pages/query/query\?op=inventoryQuery' }, + @{ Name = '库位查询'; Route = '/pages/query/query\?op=locationQuery' }, + @{ Name = '托盘查询'; Route = '/pages/query/query\?op=palletQuery' } +)) { + $enabledPattern = "(?s)name:\s*'$([regex]::Escape($enabledOperation.Name))'.{0,300}?available:\s*true.{0,200}?route:\s*'$($enabledOperation.Route)'" + Assert-Matches $overseasWorkbench $enabledPattern "Overseas PDA 开放入口 $($enabledOperation.Name)" +} + +# 文档声明为正式开放的 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 'booking channel belonging to channelGroupCode' '订舱关联候选筛选语义' + +$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 订舱列表无详情扇出合同' + +# 海外出库、派送、异常和回访必须从根路由贯通到 Admin 与 Lastleg;PDA 后端存在不等于设备端已挂载。 +$adminOverseasRoutes = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/routes.ts') +$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') +foreach ($overseasFulfillmentRoute in @( + 'overseas-warehouse/outbounds', + 'overseas-warehouse/delivery-orders', + 'overseas-warehouse/exception-orders', + 'overseas-warehouse/customer-visits' +)) { + Assert-Contains $adminOverseasRoutes "path: '$overseasFulfillmentRoute'" "Admin 海外履约正式路由 $overseasFulfillmentRoute" +} +foreach ($overseasFulfillmentRootEntry in @( + 'OutboundsView.vue', 'DeliveryOrdersView.vue', 'ExceptionOrdersView.vue', 'CustomerVisitsView.vue' +)) { + Assert-Contains $businessIndex $overseasFulfillmentRootEntry '根海外履约正式业务路由' +} +Assert-Contains $adminAgents '海外排车、出库与派送' 'Admin 海外出库派送入口' +Assert-Contains $adminAgents '海外异常退回与客户回访' 'Admin 海外异常回访入口' +Assert-Contains $backendAgents '海外排车执行、PDA 出库、派送与一件代发' '后端海外出库派送入口' +Assert-Contains $backendAgents '一件代发 SKU/箱唛与暂扣跨区协同' '后端一件代发跨区协同入口' +Assert-Contains $lastlegAgents '海外仓全流程连续覆盖' 'Lastleg 海外仓全流程范围' +Assert-Contains $overseasPdaAgents '排车拣货与发车' '海外 PDA 排车入口' +Assert-Contains $overseasPdaAgents '普通/一件代发出库' '海外 PDA 出库入口' + +# 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 || '/gateway-api'" '国内 PDA H5 同源 API' +Assert-Contains $domesticPdaVite "'/gateway-api'" '国内 PDA H5 独立代理前缀' +Assert-Contains $domesticPdaVite 'path.replace(/^\/gateway-api/, ''/api'')' '国内 PDA H5 Gateway 重写' +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-Matches $adminAcceptanceEntry "(?s)const\s+OPTIONAL_MODULES\s*=\s*\[[^\]]*'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 $warehouseAgents '/internal/warehouses/0/{warehouseId}/active' '国内 Warehouse FBA 内部校验入口' +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 货柜详情确认离仓' + +# 装柜完成后的整柜报关必须保持客户退税资料、平台冻结/完成与 File 二进制边界一致。 +$firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md') +$containerCustomsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/customs-v1.yaml') +$adminContainerCustomsApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.ts') +$clientContainerCustomsApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/first-leg/api/customs.ts') +$customsFileOpenApi = Read-RequiredText (Join-Path $backendRoot 'file/src/main/resources/openapi/customs-file-and-export-v1.yaml') +Assert-Contains $businessIndex 'ContainerCustomsPanel.vue' '根整柜报关 Admin 入口' +Assert-Contains $businessIndex 'CustomsDocumentsDialog.vue' '根整柜报关 Client 入口' +Assert-Contains $adminAgents 'components/ContainerCustomsPanel.vue' 'Admin 整柜报关入口' +Assert-Contains $adminAgents 'complete-all-buy' 'Admin 全买单柜入口' +Assert-Contains $clientAgents 'components/CustomsDocumentsDialog.vue' 'Client 退税报关资料入口' +Assert-Contains $clientAgents '开始后只读冻结资料' 'Client 报关冻结边界' +Assert-Contains $firstlegAgents 'customs/container/controller/PlatformContainerCustomsController.java' 'Firstleg 平台整柜报关入口' +Assert-Contains $firstlegAgents 'customs/container/controller/MerchantContainerCustomsController.java' 'Firstleg 客户整柜报关入口' +Assert-Contains $containerCustomsOpenApi '/api/merchant/v1/customs/loading-plans/{planId}/documents/upload:' '客户退税资料上传契约' +Assert-Contains $containerCustomsOpenApi '/api/merchant/v1/customs/documents/{documentId}/inbounds:' '客户资料入仓码范围契约' +Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/{planId}/start:' '平台开始报关冻结契约' +Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/upload:' '平台完成文件契约' +Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/{planId}/complete-all-buy:' '全买单柜完成契约' +Assert-Contains $containerCustomsOpenApi 'enum: [TAX_REFUND, BUY_DECLARATION]' '整柜报关税务模式' +Assert-Contains $adminContainerCustomsApi "const basePath = '/platform/v1/customs/loading-plans'" 'Admin 整柜报关真实调用' +Assert-Contains $clientContainerCustomsApi '/merchant/v1/customs/orders/' 'Client 订单报关真实调用' +Assert-Contains $clientContainerCustomsApi 'Idempotency-Key' 'Client 报关写入幂等' +Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_SOURCE' 'File 报关源文件类别' +Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_RESULT' 'File 报关完成文件类别' + +# 平台商品图片和海外 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 签收照片缓存边界' + +# 尾程订单按来源柜展示服务端计划进度;整柜确认必须绑定预览快照并编排真实托盘批次。 +$lastlegBusinessOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml') +$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') +$adminLastMileApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') +$containerConversionCoordinator = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java') +$warehousePalletBatchService = Read-RequiredText (Join-Path $backendRoot 'warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java') +$overseasWarehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md') +$overseasPdaReceivingAdapter = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/api/receiving-adapter.js') +Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/orders/container-summaries:' 'Lastleg 柜级摘要契约' +Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/convert-container:' 'Lastleg 整柜计划转换契约' +Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/container-preview:' 'Lastleg 整柜计划预览契约' +Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划快照版本' +Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希' +Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份' +Assert-Contains $adminOverseasAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口' +Assert-Contains $businessIndex '尾程订单控制与柜级入仓计划转换' '根柜级计划入口' +Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围' +Assert-Contains $adminOverseasAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围' +Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围' +Assert-Contains $lastlegBusinessOpenApi '返回全部稳定来源柜' 'Lastleg 全柜工作台 OpenAPI' +Assert-Contains $adminOverseasAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选' +Assert-Contains $adminOverseasAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口' +Assert-Contains $adminOverseasAgents '/inbound-plans/container-preview' 'Admin 整柜预览目标入口' +Assert-Contains $adminOverseasAgents '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界' +Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用' +Assert-Contains $adminLastMileApi '/platform/v1/last-leg/inbound-plans/container-preview' 'Admin 柜级预览真实调用' +Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用' +Assert-Contains $lastlegBusinessOpenApi 'ContainerPlanProgressSummary' 'Lastleg 柜级计划进度摘要模型' +Assert-Contains $lastlegBusinessOpenApi 'ContainerPlanProgressDetail' 'Lastleg 柜级计划进度详情模型' +Assert-Contains $lastlegBusinessOpenApi 'allPlansReady' 'Lastleg 全柜计划就绪事实' +Assert-Contains $lastlegBusinessOpenApi 'plannedArrivalAt' 'Lastleg 计划到仓冻结事实' +Assert-Contains $lastlegAgents 'service/FbaWarehouseValidator.java' 'Lastleg FBA 权威仓校验入口' +Assert-Contains $lastlegAgents 'ContainerInboundPlanConversionCoordinator.java' 'Lastleg 柜级计划 Saga 入口' +Assert-Contains $containerConversionCoordinator 'warehouse.ensurePalletBatch' 'Lastleg 托盘批次预留' +Assert-Contains $containerConversionCoordinator 'warehouse.confirmPalletBatch' 'Lastleg 托盘批次确认' +Assert-Contains $containerConversionCoordinator 'warehouse.compensatePalletBatch' 'Lastleg 托盘批次补偿' +Assert-Contains $warehousePalletBatchService 'Only a RESERVED pallet batch can be confirmed' '海外资源批次确认状态门禁' +Assert-Contains $overseasWarehouseAgents '同柜重放不得产生第二批资源' '海外资源批次幂等边界' +Assert-Contains $overseasPdaAgents 'assigned/mergeAllowed/urgent/mergeHint' '海外 PDA 计划标志索引' +Assert-Contains $overseasPdaAgents '不在设备端重新分配' '海外 PDA 预留托盘边界' +Assert-Contains $overseasPdaReceivingAdapter 'mergeAllowed: value.mergeAllowed === true' '海外 PDA 合托可选事实适配' + +# 国内 PDA 当前以页面、API 和作业状态合同覆盖部分拣货及库位不足反馈。 +Assert-Contains $pdaAgents 'tests/page-contract.test.mjs' '国内 PDA 页面合同' +Assert-Contains $pdaAgents 'tests/pda-loading-api.test.mjs' '国内 PDA API 合同' +# 国内柜费分摊与海外费用跨区入池必须能从根索引直达,并锁住归属、金额、版本与直接已付隔离。 +$financeAgents = Read-RequiredText (Join-Path $backendRoot 'finance/AGENTS.md') +$eventBridgeAgents = Read-RequiredText (Join-Path $backendRoot 'event-bridge-service/AGENTS.md') +$commonEventAgents = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/AGENTS.md') +$adminFinanceAgents = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/finance/AGENTS.md') +$containerFeeOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/container-fees-v1.yaml') +$containerFeeController = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java') +$containerFeeService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java') +$overseasFinanceOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml') +$businessFeeEvent = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java') +$eventTypes = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java') +$overseasDispatcher = Read-RequiredText (Join-Path $backendRoot 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java') +$financeEventConsumer = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java') +$financePhase1Service = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java') +Assert-Contains $businessIndex '财务管理与柜级公共费用分摊' '根柜费分摊快速路由' +Assert-Contains $businessIndex '海外业务费用来源与客户财务' '根海外费用快速路由' +Assert-Contains $adminAgents '财务、客户资金账户与柜级公共费用分摊' 'Admin 柜费分摊快速路由' +Assert-Contains $backendAgents '柜级公共费用跨客户分摊' '后端柜费分摊快速路由' +Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海外费用快速路由' +Assert-Contains $firstlegAgents '单行入口拒绝分摊组' 'Firstleg 分摊组原子边界' +Assert-Contains $adminFinanceAgents '`allocationGroupId` 整组审核、驳回、调整或作废' 'Admin 分摊组操作边界' +Assert-Contains $containerFeeOpenApi '/fee-details/allocation-groups:' '柜费分摊组创建 OpenAPI' +Assert-Matches $containerFeeOpenApi '各行按\s+HALF_UP\s+保留两位后必须大于零,\s*落库金额合计必须等于两位小数的总额' '柜费舍入后正数与金额守恒' +Assert-Contains $containerFeeOpenApi '分摊组不可经单行入口修改' '柜费分摊整组操作契约' +Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/approve")' '柜费分摊整组审核入口' +Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/adjust")' '柜费分摊整组调整入口' +Assert-Contains $containerFeeService '分摊客户、订单及入仓信息与柜内真实归属不一致' '柜费分摊真实归属校验' +Assert-Contains $containerFeeService '调整必须包含分摊组全部费用行' '柜费分摊完整调整校验' +Assert-Contains $lastlegAgents '海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务' 'Lastleg 五类海外费用归属' +# 在 BusinessType 枚举内逐项核对,新增类型或调整顺序不应使原有覆盖误报。 +$businessTypeEnumMatch = [regex]::Match($overseasFinanceOpenApi, '(?s)BusinessType:\s*\{\s*type:\s*string,\s*enum:\s*\[([^\]]*)\]') +Assert-Matches $overseasFinanceOpenApi 'BusinessType:\s*\{\s*type:\s*string,\s*enum:' '海外业务类型枚举定义' +$businessTypeValues = @($businessTypeEnumMatch.Groups[1].Value.Split(',') | ForEach-Object { $_.Trim() }) +foreach ($businessType in @('OVERSEAS_INBOUND', 'OVERSEAS_PROCUREMENT', 'PROCUREMENT_FEE', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'WAREHOUSE_SERVICE', 'OVERSEAS_CONTAINER', 'LASTLEG_ORDER', 'DELIVERY_ORDER', 'RELABEL_ORDER')) { + $checked++ + if ($businessType -notin $businessTypeValues) { Add-Failure "海外 BusinessType 枚举缺少: $businessType" } +} +Assert-Contains $overseasFinanceOpenApi '审核通过与作废均事务性写入Outbox' '海外费用可靠发布契约' +Assert-Contains $commonEventAgents '海外业务费用来源变更 V1' '公共海外费用事件入口' +Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"' '海外费用事件类型' +Assert-Contains $businessFeeEvent 'int sourceVersion' '海外费用事件来源版本' +Assert-Contains $businessFeeEvent 'List paymentVoucherFileIds' '海外费用事件付款证据快照' +Assert-Contains $businessFeeEvent 'String sourceHash' '海外费用事件来源哈希' +Assert-Contains $eventBridgeAgents '`BUSINESS_FEE_SOURCE_CHANGED` 与 `WALLET_COMMAND_REQUESTED` 定向投 Finance' 'Event Bridge Finance 定向路由' +Assert-Contains $eventBridgeAgents '/internal/finance/v1/business-fee-source-events' 'Event Bridge Finance 内部入口' +Assert-Contains $overseasDispatcher 'EventTypes.BUSINESS_FEE_SOURCE_CHANGED' 'Event Bridge 海外费用事件 Finance 路由集合' +Assert-Contains $overseasDispatcher 'EventTypes.WALLET_COMMAND_REQUESTED' 'Event Bridge 海外资金指令 Finance 路由集合' +Assert-Matches $overseasDispatcher '(?s)FINANCE_EVENT_TYPES\.contains\(eventType\).{0,150}?financeEventClient\.deliver\(event\)' 'Event Bridge Finance 事件定向投递' +Assert-Contains $financeAgents '`eventId + payloadHash` 防重放冲突' 'Finance 海外事件幂等边界' +Assert-Contains $financeAgents '`PAYABLE + DIRECT_PAID` 审核版本必须生成 `BUSINESS_DIRECT_PAID`' 'Finance 直接已付隔离边界' +Assert-Contains $businessIndex '入库扣货、解除与凭证' '根入库扣货快速路由' +Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货快速路由' +Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货快速路由' +Assert-Contains $firstlegAgents '入库单扣货、履约门禁与凭证' 'Firstleg 入库扣货入口' +Assert-Contains $businessIndex '客户资金账户、充值、授信与业务扣费' '根客户钱包快速路由' +Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户钱包快速路由' +Assert-Contains $lastlegAgents '海外业务资金指令与结果' 'Lastleg 跨区钱包入口' +Assert-Contains $lastlegAgents '拆柜打托成本与客户应收分摊' 'Lastleg 拆柜打托费用入口' +Assert-Contains $clientAgents '换标申请必须提交正数 CNY `chargeAmount`' 'Client 换标钱包金额边界' +Assert-Contains $businessIndex '海外 Token 不携带角色或权限集' '根海外小 Token 边界' +Assert-Contains $backendAgents '海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集' '后端海外小 Token 边界' +Assert-Contains $financeEventConsumer 'envelope.payloadHash().equals(row.get("payload_hash"))' 'Finance 海外事件 Payload 哈希幂等' +Assert-Contains $financeEventConsumer "cost_origin='BUSINESS_DIRECT_PAID'" 'Finance 海外直接已付成本投影' +Assert-Contains $financePhase1Service "f.settlement_mode='ON_ACCOUNT'" 'Finance AP 只纳入账期应付' +Assert-Contains $financePhase1Service "'FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID'" 'Finance 已付成本汇总' +Assert-Contains $scriptsAgents 'Finance 扩展 HTTP 模块' '脚本指引 Finance 扩展模块' +foreach ($financeModule in @('firstleg-container-fee-allocation', 'finance-direct-paid-isolation', 'phase4-overseas-finance-cross-region')) { + Assert-Contains $adminAcceptanceEntry "'$financeModule'" "Admin 总入口 $financeModule 注册" + Assert-Contains $adminAcceptanceModuleIndex "$financeModule.mjs" "Admin 模块索引 $financeModule" +} + +# 国内 PDA 部分拣货必须展示真实可用库位事实,并在无库位时保持页面整洁。 +Assert-Contains $pdaAgents 'tests/partial-layout.test.mjs' '国内 PDA 部分拣货布局合同' +Assert-Contains $pdaAgents 'tests/picking-location-display.test.mjs' '国内 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/lastMileOrders.ts', + 'apps/pda-overseas-uniapp/src/api/pallet.js', + 'apps/pda-overseas-uniapp/src/pages/pallet/pallet.vue', + 'apps/pda-overseas-uniapp/tests/pallet-operations.test.mjs', + 'apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue', + '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/components/ContainerCustomsPanel.vue', + 'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.ts', + 'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.test.ts', + 'apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts', + 'apps/admin-web/src/modules/loading-lists/pages/PreloadPlanDetailView.lifecycle.test.ts', + 'apps/admin-web/src/modules/unified-orders/api/coLoadGroups.ts', + 'apps/admin-web/src/modules/unified-orders/pages/UnifiedOrdersView.vue', + 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts', + 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue', + 'apps/admin-web/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue', + 'apps/admin-web/src/modules/ocean-bookings/components/OceanBookingPrintDialog.vue', + 'apps/admin-web/src/modules/trucking/components/TruckAppointmentForm.vue', + 'apps/admin-web/src/modules/trucking/api/platformTrucking.ts', + '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/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue', + 'apps/client-web/src/modules/first-leg/api/customs.ts', + 'apps/client-web/src/modules/first-leg/api/customs.test.mjs', + 'apps/client-web/src/modules/first-leg/api/inboundCodeAssociations.ts', + 'apps/client-web/src/modules/first-leg/components/InboundCodeAssociationDialog.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/tests/page-contract.test.mjs', + 'apps/pda-uniapp/tests/pda-loading-api.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/pages/dispatch/dispatch.vue', + 'apps/pda-overseas-uniapp/src/pages/outbound/outbound.vue', + 'apps/pda-overseas-uniapp/src/api/fulfillment.js', + 'apps/pda-overseas-uniapp/src/stores/fulfillment.js', + 'apps/pda-overseas-uniapp/tests/fulfillment-workflows.test.mjs', + '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/tests/business-identifiers-ui.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/scripts/mapper-scan-contract.mjs', + 'baiyue-firstleg/scripts/mapper-scan-contract.test.mjs', + 'baiyue-firstleg/firstleg/src/test/java/com/baiyue/firstleg/MapperRegistrationArchitectureTest.java', + 'baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/MapperRegistrationArchitectureTest.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/controller/DispatchController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java', + 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/OverseasInboundOrderDetainedChangedV1.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java', + 'baiyue-firstleg/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.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/customs/container/controller/PlatformContainerCustomsController.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java', + 'baiyue-firstleg/firstleg/src/main/resources/openapi/customs-v1.yaml', + 'baiyue-firstleg/firstleg/src/main/resources/openapi/coload-groups-v1.yaml', + 'baiyue-firstleg/firstleg/src/main/resources/openapi/loading-lists-v1.yaml', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java', + 'baiyue-firstleg/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml', + 'baiyue-firstleg/file/src/main/resources/openapi/customs-file-and-export-v1.yaml', + 'baiyue-firstleg/order/src/main/java/com/baiyue/order/service/InboundCodeAssociationService.java', + 'baiyue-firstleg/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java', + 'baiyue-firstleg/order/src/main/resources/openapi/order-v1.yaml', + '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/dto/ContainerPlanProgressSummary.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressDetail.java', + 'baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java', + 'baiyue-firstleg/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml', + '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/FirstLegLoadingPlanCompletedV3.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/独立任务协作登记.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, + $adminOverseasAgentsPath, + $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 路由、客户中文批量下单、箱规算法与测量计费、一件代发预装前计价、称重整票库位块与暂估箱数校准、入库扣货履约门禁、全局智能装柜组分页、客户账单入仓码联查、客户钱包与跨区资金指令、导出退避任务重试、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、海外仓全流程范围、柜级待处理计划筛选与范围漂移、柜级全柜工作台、计划快照与托盘 Saga、海外 PDA 多计划标志、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外小 Token 与安全投影、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、同步 Trace、关键入口和排除目录。" -ForegroundColor Green +exit 0 diff --git a/scripts/agents-index-registry-contract.test.mjs b/scripts/agents-index-registry-contract.test.mjs new file mode 100644 index 000000000..cba8c5e78 --- /dev/null +++ b/scripts/agents-index-registry-contract.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./agents-index-contract.ps1', import.meta.url), 'utf8') + +test('cleared test registry is mandatory and cannot inherit historical task authorization', () => { + for (const token of [ + "Assert-Contains $testTaskRegistry '当前无登记任务'", + "Assert-Contains $testTaskRegistry '清空全部任务登记与调度规则'", + "Assert-Contains $testTaskRegistry '不根据旧登记、历史消息或历史台账自动派工、恢复任务或回传进度'", + "Assert-Contains $testTaskRegistry '不作为当前调度或授权依据'", + "Assert-NotContains $testTaskRegistry '已重新启用测试环境总控调度'", + ]) assert.ok(source.includes(token), token) + assert.match(source, /if \(\$testTaskRegistry -match '\(\?m\)\^\\\|\\s\*`\[0-9a-f-\]\{36\}`\\s\*\\\|'\)/) +}) + +test('cleared registry rejects every retired controller and project identity', () => { + for (const retired of [ + '01a0194a-259c-7171-8967-074731a27618', + '01a06c0b-0515-7c60-b329-77342b54e666', + '6f874ec3-d3b2-49bd-8625-327f4d685f98', + ]) assert.match(source, new RegExp(`Assert-NotContains \\$testTaskRegistry '${retired}'`)) +}) diff --git a/scripts/audit-deployment-contract.test.mjs b/scripts/audit-deployment-contract.test.mjs new file mode 100644 index 000000000..09dd959af --- /dev/null +++ b/scripts/audit-deployment-contract.test.mjs @@ -0,0 +1,26 @@ +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('基础 Compose 显式配置审计服务地址,避免静默退化为日志输出', async () => { + const compose = await read('deploy/docker-compose.yml') + + assert.match(compose, + /BAIYUE_AUDIT_ENDPOINT: \$\{BAIYUE_AUDIT_ENDPOINT:-http:\/\/audit-service:9700\/internal\/audit\/events\}/) + assert.doesNotMatch(compose, /^\s+AUDIT_SERVICE_URL:/m) + assert.match(compose, /^ audit-service:\s*$/m) + assert.match(compose, /container_name: firstleg-\$\{DEPLOY_ENV:-test\}-audit/) + assert.match(compose, /networks: \[firstleg-network, infra-network\]/) +}) + +test('可靠审计仍由独立 Compose 显式启用,不强制基础环境依赖 RocketMQ', async () => { + const baseCompose = await read('deploy/docker-compose.yml') + const reliableCompose = await read('deploy/docker-compose.audit.yml') + + assert.match(baseCompose, /AUDIT_OUTBOX_ENABLED: \$\{AUDIT_OUTBOX_ENABLED:-false\}/) + assert.match(reliableCompose, /AUDIT_OUTBOX_ENABLED: "true"/) + assert.match(reliableCompose, /AUDIT_MQ_CONSUMER_ENABLED: "true"/) +}) diff --git a/scripts/auth-login-payload-contract.test.mjs b/scripts/auth-login-payload-contract.test.mjs new file mode 100644 index 000000000..ed88b9afc --- /dev/null +++ b/scripts/auth-login-payload-contract.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const callers = [ + 'scripts/admin-test-env-acceptance.mjs', + 'scripts/client-test-env-acceptance.mjs', + 'scripts/client-phase2-test-env-acceptance.mjs', + 'scripts/create-pending-review-orders.mjs', + 'scripts/pda-final-complete-diagnostic.mjs', + 'scripts/admin-test-env-modules/domestic-container.mjs', + 'scripts/admin-test-env-modules/fee-items.mjs', + 'scripts/admin-test-env-modules/finance-downloads.mjs', + 'scripts/admin-test-env-modules/finance-reconciliation-full.mjs', + 'scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs', + 'scripts/admin-test-env-modules/orders.mjs', + 'scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs', + 'scripts/admin-test-env-modules/phase3-customs.mjs', + 'scripts/admin-test-env-modules/phase3-loading.mjs', + 'scripts/admin-test-env-modules/phase3-order-splitting.mjs', + 'scripts/admin-test-env-modules/phase4-overseas-business.mjs', + 'scripts/admin-test-env-modules/phase4-overseas-client-services.mjs', + 'scripts/admin-test-env-modules/phase4-overseas-master-data.mjs', + 'scripts/admin-test-env-modules/phase4-overseas-pda.mjs', + 'scripts/admin-test-env-modules/suppliers.mjs', + 'scripts/admin-test-env-modules/system.mjs', +] + +test('all acceptance login callers use the canonical loginId request field', async () => { + let loginCount = 0 + for (const file of callers) { + const source = await readFile(resolve(file), 'utf8') + const canonical = source.match(/\/api\/auth\/login[\s\S]{0,180}loginId\s*:/g) || [] + assert.ok(canonical.length > 0, `${file} must send loginId`) + loginCount += canonical.length + assert.doesNotMatch(source, + /\/api\/auth\/login[\s\S]{0,180}(?:\busername\s*:|\{\s*username\s*,)/, + `${file} must not send removed username`) + } + assert.ok(loginCount >= 20, `expected broad login coverage, got ${loginCount}`) +}) diff --git a/scripts/backend-full-regression.ps1 b/scripts/backend-full-regression.ps1 new file mode 100644 index 000000000..0fa1ee893 --- /dev/null +++ b/scripts/backend-full-regression.ps1 @@ -0,0 +1,84 @@ +[CmdletBinding()] +param( + [switch]$DryRun, + [string]$MavenCommand = "mvn" +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$pom = Join-Path $repoRoot "pom.xml" +$mapperScanContract = Join-Path $repoRoot "scripts/mapper-scan-contract.mjs" +$requiredTests = @( + "scripts/gateway-route-contract.test.mjs", + "scripts/mapper-scan-contract.test.mjs", + "firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java", + "firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java", + "firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java", + "firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java", + "firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java", + "firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java", + "order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java", + "iam/src/test/java/com/baiyue/iam/BaiyueIamApplicationContractTest.java", + "iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java", + "iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java", + "iam/src/test/java/com/baiyue/iam/notification/NotificationTemplateRendererTest.java", + "iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java", + "iam/src/test/java/com/baiyue/iam/notification/NotificationBusinessEventConsumerTest.java", + "iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java", + "iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java", + "gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java", + "file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java", + "file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java", + "file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java", + "firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java", + "firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java", + "firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java", + "firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java", + "firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java", + "warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java", + "warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java" +) +$mavenArgs = @("-f", $pom, "-pl", "order,firstleg,iam,gateway,file,warehouse", "-am", "test") + +Write-Host "Backend regression root: $repoRoot" +Write-Host "Scope: order + firstleg + iam + gateway + file + warehouse Maven reactor (includes all module tests)" +Write-Host "Database/API access: none" + +if (-not (Test-Path -LiteralPath $pom)) { + Write-Error "Missing Maven pom: $pom" + exit 2 +} +if (-not (Test-Path -LiteralPath $mapperScanContract)) { + Write-Error "Missing Mapper scan contract: $mapperScanContract" + exit 2 +} + +$missing = @($requiredTests | Where-Object { -not (Test-Path -LiteralPath (Join-Path $repoRoot $_)) }) +if ($missing.Count -gt 0) { + Write-Error ("Missing required regression coverage:`n" + ($missing -join "`n")) + exit 2 +} + +Write-Host ("Command: {0} {1}" -f $MavenCommand, ($mavenArgs -join " ")) +if ($DryRun) { + Write-Host "DRY-RUN PASS: prerequisites and coverage files are present; Maven was not started." + exit 0 +} + +Push-Location $repoRoot +try { + & node --test (Join-Path $repoRoot "scripts/gateway-route-contract.test.mjs") + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & node $mapperScanContract + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & $MavenCommand @mavenArgs + $code = $LASTEXITCODE +} finally { + Pop-Location +} +if ($code -ne 0) { + Write-Error "Maven regression failed with exit code $code" + exit $code +} +Write-Host "Backend regression PASS" +exit 0 diff --git a/scripts/client-notifications-contract.test.mjs b/scripts/client-notifications-contract.test.mjs new file mode 100644 index 000000000..18f361df9 --- /dev/null +++ b/scripts/client-notifications-contract.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const scriptsDir = dirname(fileURLToPath(import.meta.url)) +const source = readFileSync(resolve(scriptsDir, 'client-test-env-acceptance.mjs'), 'utf8') + +test('Client full acceptance executes the real notification inbox flow', () => { + assert.match(source, /await isolationModule\(\)\s+await notificationInboxModule\(\)\s+await accountSecurityModule\(\)/) + assert.match(source, /async function notificationInboxModule\(\)/) + for (const permission of [ + 'merchant:notification:view', + 'merchant:notification:read', + 'merchant:notification:read-all', + ]) assert.match(source, new RegExp(`['"]${permission}['"]`)) +}) + +test('notification delivery targets only the newly registered customer owner', () => { + assert.match(source, /tenantId:\s*String\(report\.resources\.tenantId\)/) + assert.match(source, /recipientType:\s*['"]MEMBERSHIP['"]/) + assert.match(source, /recipientIds:\s*\[String\(report\.resources\.membershipId\)\]/) + assert.match(source, /await sendMessage\(1\)/) + assert.match(source, /await sendMessage\(2\)/) + assert.match(source, /other client cannot read first message detail/) + assert.match(source, /other client cannot see acceptance messages in inbox/) + assert.match(source, /\['A0403', 'A0404'\]\.includes\(String\(json\?\.code\)\)/) +}) + +test('notification flow covers unread, single read, read-all, and cleanup', () => { + for (const contract of [ + /status=UNREAD/, + /status=READ/, + /\/unread-count/, + /\$\{firstMessageId\}\/read/, + /\/read-all/, + /single-message read is idempotent/, + /cleanup keeps acceptance messages read/, + /cleanup leaves no unread acceptance messages/, + /targetStatus:\s*['"]DISABLED['"]/, + /retainedAsRead:\s*true/, + ]) assert.match(source, contract) +}) diff --git a/scripts/client-phase2-test-env-acceptance.mjs b/scripts/client-phase2-test-env-acceptance.mjs new file mode 100644 index 000000000..877ddad91 --- /dev/null +++ b/scripts/client-phase2-test-env-acceptance.mjs @@ -0,0 +1,1008 @@ +#!/usr/bin/env node + +/** + * Client phase-two test-environment acceptance flow. + * + * The platform account is used only to discover deterministic inbound fixtures. + * All phase-two product assertions are executed with the Client account. + * + * Run directly and enter credentials when prompted: + * node scripts/client-phase2-test-env-acceptance.mjs + */ + +import { mkdir, writeFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { resolve } from 'node:path' +import { createInterface } from 'node:readline/promises' + +const args = parseArgs(process.argv.slice(2)) +const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY) +const clientUsername = args['client-username'] ?? process.env.MERCHANT_TEST_USERNAME + ?? await promptText('二期客户端账号(必须由正式注册/建档产生)', '') +const clientPassword = args['client-password'] ?? process.env.MERCHANT_TEST_PASSWORD + ?? await promptSecret('二期客户端密码') +const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USERNAME + ?? await promptText('平台夹具查询账号', '') +const platformPassword = args['platform-password'] ?? process.env.ADMIN_TEST_PASSWORD + ?? await promptSecret('平台夹具查询密码') +const forcedOrderId = args['order-id'] ?? process.env.CLIENT_PHASE2_ORDER_ID ?? null +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 (!gateway || !clientUsername || !clientPassword || !platformUsername || !platformPassword) { + console.error('ADMIN_TEST_GATEWAY, an onboarded Client account, and platform credentials are required') + process.exit(2) +} +if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { + console.error('--timeout-ms must be at least 1000') + process.exit(2) +} + +const now = new Date() +const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}` +const reportPath = resolve(reportDir, `client-phase2-test-env-${marker}.json`) +const report = { + marker, + gateway, + generatedAt: now.toISOString(), + completedAt: null, + success: false, + fatal: null, + testAccount: { username: clientUsername }, + expectedTerminalState: { + processStatus: 'WAIT_SPLIT', + measureReviewStatus: 'APPROVED', + feeStatus: 'SENT', + }, + resources: { + tenantId: null, + terminalInboundId: null, + terminalOrderId: null, + terminalInboundCode: null, + terminalMeasurementId: null, + pendingOrderId: null, + foreignOrderId: null, + measurementImageFileIds: [], + weightImageFileIds: [], + }, + steps: [], +} + +let clientToken +let platformToken +let terminalFixture +let pendingFixture +let foreignFixture +let platformTerminalDetail +let platformMeasurement +let dimensionRules = [] +let merchantBilling + +try { + await sessionModule() + await fixtureDiscoveryModule() + await unpublishedVisibilityModule() + await terminalBillingModule() + await unifiedOrderModule() + await isolationModule() + report.success = report.steps.every((step) => step.passed) +} catch (error) { + report.fatal = sanitizeError(error) + report.success = false +} finally { + report.completedAt = new Date().toISOString() + await mkdir(reportDir, { recursive: true }) + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') + printSummary() +} + +process.exitCode = report.success ? 0 : 1 + +async function sessionModule() { + const clientLogin = await apiStep('phase2-session', 'client login', { + method: 'POST', path: '/api/auth/login', + body: { loginId: clientUsername, password: clientPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'client accessToken') + }, () => ({ username: clientUsername })) + clientToken = clientLogin.json.data.accessToken + + const profile = await apiStep('phase2-session', 'load current enterprise profile', { + method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: clientToken, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.id, 'enterprise id') + assertEqual(json.data?.status, 'ACTIVE', 'enterprise status') + }, ({ json }) => ({ tenantId: json.data.id, tenantName: json.data.tenantName, status: json.data.status })) + report.resources.tenantId = String(profile.json.data.id) + + await apiStep('phase2-session', 'client has order view permission', { + method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: clientToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(arrayData(json.data).includes('merchant:order:view'), 'merchant:order:view permission is required') + }, ({ json }) => ({ permissionCount: arrayData(json.data).length })) + + const platformLogin = await apiStep('phase2-fixture', 'platform fixture account login', { + method: 'POST', path: '/api/auth/login', + body: { loginId: platformUsername, password: platformPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'platform accessToken') + }, () => ({ username: platformUsername })) + platformToken = platformLogin.json.data.accessToken +} + +async function fixtureDiscoveryModule() { + const ownInbounds = await apiStep('phase2-fixture', 'discover current enterprise inbound fixtures', { + method: 'GET', + path: `/api/platform/inbounds?tenantId=${report.resources.tenantId}&page=1&size=200`, + token: platformToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records), 'inbound records must be an array') + }, ({ json }) => ({ total: json.data?.total, statusCounts: statusCounts(json.data?.records) })) + + const ownRows = pageRecords(ownInbounds.json.data) + terminalFixture = forcedOrderId + ? ownRows.find((row) => String(row.orderId) === String(forcedOrderId)) + : ownRows.find(isTerminalFixture) + if (!terminalFixture) { + throw new Error(forcedOrderId + ? `Configured orderId=${forcedOrderId} is not visible in tenant ${report.resources.tenantId}` + : `Tenant ${report.resources.tenantId} has no WAIT_SPLIT + APPROVED + SENT fixture`) + } + assertTerminalFixture(terminalFixture) + report.resources.terminalInboundId = String(terminalFixture.id) + report.resources.terminalOrderId = String(terminalFixture.orderId) + report.resources.terminalInboundCode = terminalFixture.inboundCode + + pendingFixture = ownRows.find((row) => String(row.orderId) !== report.resources.terminalOrderId + && !isTerminalFixture(row)) + report.resources.pendingOrderId = pendingFixture == null ? null : String(pendingFixture.orderId) + + await softApiStep('phase2-fixture', 'terminal state filters find selected inbound', { + method: 'GET', + path: `/api/platform/inbounds?tenantId=${report.resources.tenantId}` + + '&processStatus=WAIT_SPLIT&measureReviewStatus=APPROVED&feeStatus=SENT&page=1&size=200', + token: platformToken, + }, ({ json }) => { + requireSuccess(json) + const row = pageRecords(json.data) + .find((item) => String(item.id) === report.resources.terminalInboundId) + assertPresent(row, 'terminal inbound filter row') + assertTerminalFixture(row) + }, ({ json }) => ({ total: json.data?.total, inboundId: report.resources.terminalInboundId })) + const detail = await apiStep('phase2-fixture', 'load platform terminal inbound reference detail', { + method: 'GET', + path: `/api/platform/inbounds/${report.resources.terminalInboundId}?tenantId=${report.resources.tenantId}`, + token: platformToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.summary?.id), report.resources.terminalInboundId, 'inbound id') + assertEqual(String(json.data?.summary?.orderId), report.resources.terminalOrderId, 'order id') + assertTerminalFixture(json.data?.summary) + }, ({ json }) => ({ + inboundId: json.data.summary.id, + orderId: json.data.summary.orderId, + processStatus: json.data.summary.processStatus, + measureReviewStatus: json.data.summary.measureReviewStatus, + feeStatus: json.data.summary.feeStatus, + })) + platformTerminalDetail = detail.json.data + + const dimensionConfig = await apiStep('phase2-measurement-fixture', + 'load four customer dimension weighting rules', { + method: 'GET', + path: `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${report.resources.tenantId}`, + token: platformToken, + }, ({ json }) => { + requireSuccess(json) + const rules = arrayData(json.data?.rules) + assertEqual(rules.length, 4, 'dimension rule count') + assertEqual(new Set(rules.map((rule) => rule.boxType)).size, 4, 'dimension box type count') + for (const rule of rules) assertDimensionRule(rule) + }, ({ json }) => ({ + tenantId: json.data?.tenantId, + rules: arrayData(json.data?.rules).map((rule) => ({ + boxType: rule.boxType, + inner: dimensions(rule, 'inner'), + edgeAdditionCm: rule.edgeAdditionCm, + outer: dimensions(rule, 'outer'), + volumeCbm: rule.volumeCbm, + version: rule.version, + })), + })) + dimensionRules = dimensionConfig.json.data.rules + + const measurement = await apiStep('phase2-measurement-fixture', + 'load approved terminal measurement detail', { + method: 'GET', + path: `/api/platform/v1/first-leg/inbounds/${report.resources.terminalInboundId}` + + `/measurement?tenantId=${report.resources.tenantId}`, + token: platformToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.inboundId), report.resources.terminalInboundId, 'measurement inbound id') + assertEqual(json.data?.processStatus, 'WAIT_SPLIT', 'measurement process status') + assertEqual(json.data?.measureReviewStatus, 'APPROVED', 'measurement review status') + assertEqual(json.data?.measurementStatus, 'APPROVED', 'measurement batch status') + assertPresent(json.data?.measurementId, 'measurement id') + assertTrue(arrayData(json.data?.items).length > 0, 'measurement items must not be empty') + assertTrue(arrayData(json.data?.weightResults).length > 0, 'weight results must not be empty') + }, ({ json }) => ({ + inboundId: json.data?.inboundId, + measurementId: json.data?.measurementId, + measurementStatus: json.data?.measurementStatus, + actualPackageCount: json.data?.actualPackageCount, + actualGrossWeightKg: json.data?.actualGrossWeightKg, + actualVolumeCbm: json.data?.actualVolumeCbm, + volumeWeightKg: json.data?.volumeWeightKg, + itemCount: arrayData(json.data?.items).length, + weightResultCount: arrayData(json.data?.weightResults).length, + })) + platformMeasurement = measurement.json.data + report.resources.terminalMeasurementId = String(platformMeasurement.measurementId) + + await localStep('phase2-measurement-fixture', 'carton measurements contain complete traceable data', () => { + for (const item of platformMeasurement.items) { + assertStringId(item.id, 'measurement item id') + assertStringId(item.inboundDetailId, 'measurement inbound detail id') + assertPresent(item.itemNo, 'measurement item number') + assertPresent(item.boxCode, 'measurement box code') + assertPresent(item.machineNo, 'measurement machine number') + assertPresent(item.deviceMeasuredAt, 'device measured time') + assertStringId(item.intelligentImageFileId, 'measurement image file id') + assertTrue(Number(item.packageCount) > 0, 'measurement package count must be positive') + assertEqual(Number(item.confirmedPackageCount), Number(item.packageCount), + 'confirmed package count') + for (const field of ['lengthCm', 'widthCm', 'heightCm', 'unitVolumeCbm', 'totalVolumeCbm']) { + assertTrue(Number(item[field]) > 0, `${field} must be positive`) + } + } + }, { itemCount: platformMeasurement.items.length }) + + await localStep('phase2-measurement-fixture', 'carton volume and package aggregates are consistent', () => { + const packageCount = sum(platformMeasurement.items, 'packageCount') + const volumeCbm = sum(platformMeasurement.items, 'totalVolumeCbm') + const volumeWeightKg = sum(platformMeasurement.items, 'volumeWeightKg') + assertNumberEqual(packageCount, platformMeasurement.actualPackageCount, 'measurement package aggregate') + assertApproxEqual(volumeCbm, platformMeasurement.actualVolumeCbm, 0.000001, + 'measurement volume aggregate') + assertApproxEqual(volumeWeightKg, platformMeasurement.volumeWeightKg, 0.001, + 'measurement volume weight aggregate') + }, { + actualPackageCount: platformMeasurement.actualPackageCount, + actualVolumeCbm: platformMeasurement.actualVolumeCbm, + volumeWeightKg: platformMeasurement.volumeWeightKg, + }) + + await localStep('phase2-measurement-fixture', 'confirmed weight results form an independent weight line', () => { + for (const weight of platformMeasurement.weightResults) { + assertStringId(weight.id, 'weight result id') + assertStringId(weight.fileId, 'weight image file id') + assertEqual(weight.recognitionStatus, 'CONFIRMED', 'weight recognition status') + assertPresent(weight.confirmedAt, 'weight confirmation time') + assertTrue(Number(weight.grossWeightKg) > 0, 'gross weight must be positive') + assertTrue(Number(weight.netWeightKg) > 0, 'net weight must be positive') + } + assertApproxEqual(sum(platformMeasurement.weightResults, 'grossWeightKg'), + platformMeasurement.actualGrossWeightKg, 0.001, 'measurement gross weight aggregate') + assertApproxEqual(sum(platformMeasurement.weightResults, 'netWeightKg'), + platformMeasurement.actualNetWeightKg, 0.001, 'measurement net weight aggregate') + }, { + actualGrossWeightKg: platformMeasurement.actualGrossWeightKg, + weightResultCount: platformMeasurement.weightResults.length, + }) + + await softLocalStep('phase2-measurement-fixture', + 'carton billing volume uses current customer dimension weighting', () => { + for (const item of platformMeasurement.items) { + const expected = resolveWeightedDimensions(item, dimensionRules) + assertApproxEqual(item.unitVolumeCbm, expected.unitVolumeCbm, 0.000001, + `${item.itemNo} weighted unit volume`) + } + }, { + measurementId: platformMeasurement.measurementId, + items: platformMeasurement.items.map((item) => ({ + itemNo: item.itemNo, + measured: dimensions(item), + storedOuter: dimensions(item, 'outer'), + unitVolumeCbm: item.unitVolumeCbm, + expected: resolveWeightedDimensions(item, dimensionRules), + })), + }) + + await apiStep('phase2-client-security', 'client cannot read platform measurement workspace directly', { + method: 'GET', + path: `/api/platform/v1/first-leg/inbounds/${report.resources.terminalInboundId}` + + `/measurement?tenantId=${report.resources.tenantId}`, + token: clientToken, + }, ({ status, json }) => { + assertEqual(status, 403, 'HTTP status') + assertTrue(json?.code == null || String(json.code) === 'A0403', + `expected absent/A0403 business code, actual=${json?.code}`) + }, ({ json }) => ({ rejectionCode: json?.code })) + + const platformImageFileId = platformMeasurement.items[0]?.intelligentImageFileId + await apiStep('phase2-client-security', 'client cannot bypass billing publication to read platform image', { + method: 'GET', + path: `/api/platform/v1/files/measurements/${report.resources.tenantId}` + + `/${report.resources.terminalInboundId}/${platformImageFileId}`, + token: clientToken, + }, ({ status, json }) => { + assertEqual(status, 403, 'HTTP status') + assertTrue(json?.code == null || String(json.code) === 'A0403', + `expected absent/A0403 business code, actual=${json?.code}`) + }, ({ json }) => ({ fileId: platformImageFileId, rejectionCode: json?.code })) + + const allTerminal = await apiStep('phase2-fixture', 'discover foreign terminal fixture for isolation', { + method: 'GET', + path: '/api/platform/inbounds?page=1&size=200', + token: platformToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records), 'terminal fixture records must be an array') + }, ({ json }) => ({ total: json.data?.total })) + foreignFixture = pageRecords(allTerminal.json.data) + .find((row) => isTerminalFixture(row) && String(row.tenantId) !== report.resources.tenantId) + report.resources.foreignOrderId = foreignFixture == null ? null : String(foreignFixture.orderId) +} + +async function unpublishedVisibilityModule() { + if (!pendingFixture) { + recordSkipped('phase2-client-visibility', 'unfinished inbound billing remains unavailable', + 'Current enterprise has no non-terminal inbound fixture') + return + } + await apiStep('phase2-client-visibility', 'unfinished inbound billing remains unavailable', { + method: 'GET', + path: `/api/merchant/v1/first-leg/orders/${pendingFixture.orderId}/inbound-billing`, + token: clientToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0404', 'business code') + }, ({ json }) => ({ + orderId: pendingFixture.orderId, + processStatus: pendingFixture.processStatus, + feeStatus: pendingFixture.feeStatus, + rejectionCode: json?.code, + })) + + await apiStep('phase2-client-visibility', 'unknown order inbound billing is rejected', { + method: 'GET', path: '/api/merchant/v1/first-leg/orders/1/inbound-billing', token: clientToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0404', 'business code') + }, ({ json }) => ({ orderId: 1, rejectionCode: json?.code })) +} + +async function terminalBillingModule() { + const response = await softApiStep('phase2-client-billing', + 'terminal inbound billing is visible to client', { + method: 'GET', + path: `/api/merchant/v1/first-leg/orders/${report.resources.terminalOrderId}/inbound-billing`, + token: clientToken, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.inbound?.summary, 'merchant inbound summary') + assertPresent(json.data?.latestFee, 'merchant latest fee') + assertEqual(String(json.data.inbound.summary.id), report.resources.terminalInboundId, 'inbound id') + assertEqual(String(json.data.inbound.summary.orderId), report.resources.terminalOrderId, 'order id') + assertTerminalFixture(json.data.inbound.summary) + assertEqual(json.data.latestFee.feeStatus, 'SENT', 'latest fee status') + }, ({ json }) => ({ + inboundId: json?.data?.inbound?.summary?.id, + orderId: json?.data?.inbound?.summary?.orderId, + processStatus: json?.data?.inbound?.summary?.processStatus, + measureReviewStatus: json?.data?.inbound?.summary?.measureReviewStatus, + feeStatus: json?.data?.latestFee?.feeStatus, + rejectionCode: json?.code, + })) + if (!response) { + const reason = 'Terminal billing endpoint did not return a visible aggregate' + for (const name of [ + 'terminal billing aggregate contract', + 'published measurement summary matches approved platform snapshot', + 'published carton measurements match platform snapshot', + 'published carton volumes follow current weighting rules', + 'published weight results match confirmed platform weights', + 'weight and volume billing lines are independently consistent', + 'published fee detail matches confirmed fee snapshot', + 'client aggregate excludes platform internal collections', + ]) recordSkipped('phase2-client-billing', name, reason) + recordSkipped('phase2-client-images', 'published measurement and weight image access', reason) + return + } + merchantBilling = response.json.data + + await softLocalStep('phase2-client-billing', 'terminal billing aggregate contract', () => { + const summary = merchantBilling.inbound.summary + const fee = merchantBilling.latestFee + assertStringId(summary.id, 'inbound summary id') + assertStringId(summary.orderId, 'inbound order id') + assertStringId(fee.feeId, 'fee id') + assertStringId(fee.inboundId, 'fee inbound id') + assertEqual(String(fee.inboundId), String(summary.id), 'fee inbound relation') + assertEqual(fee.inboundCode, summary.inboundCode, 'fee inbound code') + assertEqual(fee.customerId == null ? null : String(fee.customerId), report.resources.tenantId, + 'fee customer isolation') + assertTrue(Number(fee.actualWeightKg) > 0, 'actual weight must be positive') + assertTrue(Number(fee.actualVolumeCbm) > 0, 'actual volume must be positive') + assertTrue(Number(fee.chargeableQuantity) > 0, 'chargeable quantity must be positive') + assertTrue(Number(fee.unitPrice) >= 0, 'unit price must not be negative') + assertMoneyEqual(Number(fee.subtotalAmount) + Number(fee.additionalAmount), + Number(fee.totalAmount), 'fee amount composition') + assertEqual(fee.chargeUnit, fee.chargeMode, 'fee charge unit') + assertTrue(['KG', 'CBM'].includes(fee.chargeMode), 'charge mode must be KG or CBM') + }, { + inboundId: report.resources.terminalInboundId, + feeId: merchantBilling.latestFee.feeId, + feeNo: merchantBilling.latestFee.feeNo, + chargeMode: merchantBilling.latestFee.chargeMode, + chargeableQuantity: merchantBilling.latestFee.chargeableQuantity, + totalAmount: merchantBilling.latestFee.totalAmount, + currency: merchantBilling.latestFee.currency, + }) + + await softLocalStep('phase2-client-billing', 'client aggregate excludes platform internal collections', () => { + assertEqual(arrayData(merchantBilling.inbound.internalNotes).length, 0, 'merchant internal note count') + assertEqual(arrayData(merchantBilling.inbound.statusHistory).length, 0, 'merchant status history count') + for (const file of arrayData(merchantBilling.inbound.files)) { + assertTrue(['PUBLIC', 'MERCHANT'].includes(file.visibility), + `merchant file visibility must be PUBLIC or MERCHANT, actual=${file.visibility}`) + } + assertEqual(merchantBilling.inbound.publicRemark ?? null, + platformTerminalDetail.publicRemark ?? null, 'public remark') + }, { + internalNoteCount: arrayData(merchantBilling.inbound.internalNotes).length, + statusHistoryCount: arrayData(merchantBilling.inbound.statusHistory).length, + visibleFileCount: arrayData(merchantBilling.inbound.files).length, + }) + + await softLocalStep('phase2-client-billing', 'published measurement summary matches approved platform snapshot', () => { + const summary = merchantBilling.measurementSummary + assertPresent(summary?.measurementId, 'measurement id') + assertEqual(String(summary.measurementId), String(platformMeasurement.measurementId), 'measurement id') + assertEqual(summary.measurementStatus, platformMeasurement.measurementStatus, 'measurement status') + assertNumberEqual(summary.actualPackageCount, platformMeasurement.actualPackageCount, + 'measurement package count') + assertApproxEqual(summary.actualGrossWeightKg, platformMeasurement.actualGrossWeightKg, 0.001, + 'measurement gross weight') + assertApproxEqual(summary.actualNetWeightKg, platformMeasurement.actualNetWeightKg, 0.001, + 'measurement net weight') + assertApproxEqual(summary.actualVolumeCbm, platformMeasurement.actualVolumeCbm, 0.000001, + 'measurement volume') + assertApproxEqual(summary.volumeWeightKg, platformMeasurement.volumeWeightKg, 0.001, + 'measurement volume weight') + }, { + measurementId: merchantBilling.measurementSummary?.measurementId, + measurementStatus: merchantBilling.measurementSummary?.measurementStatus, + actualPackageCount: merchantBilling.measurementSummary?.actualPackageCount, + actualGrossWeightKg: merchantBilling.measurementSummary?.actualGrossWeightKg, + actualVolumeCbm: merchantBilling.measurementSummary?.actualVolumeCbm, + }) + + await softLocalStep('phase2-client-billing', 'published carton measurements match platform snapshot', () => { + const publishedItems = arrayData(merchantBilling.measurementItems) + assertEqual(publishedItems.length, platformMeasurement.items.length, 'published measurement item count') + const platformById = new Map(platformMeasurement.items.map((item) => [String(item.id), item])) + for (const item of publishedItems) { + assertStringId(item.id, 'published measurement item id') + const expected = platformById.get(String(item.id)) + assertPresent(expected, `platform measurement item ${item.id}`) + for (const field of ['packageCount', 'lengthCm', 'widthCm', 'heightCm', + 'unitVolumeCbm', 'totalVolumeCbm', 'volumeWeightKg']) { + assertApproxEqual(item[field], expected[field], 0.000001, `${item.itemNo} ${field}`) + } + } + }, { measurementItemCount: arrayData(merchantBilling.measurementItems).length }) + + await softLocalStep('phase2-client-billing', 'published carton volumes follow current weighting rules', () => { + for (const item of arrayData(merchantBilling.measurementItems)) { + const expected = resolveWeightedDimensions(item, dimensionRules) + assertApproxEqual(item.unitVolumeCbm, expected.unitVolumeCbm, 0.000001, + `${item.itemNo} published weighted unit volume`) + } + }, { + items: arrayData(merchantBilling.measurementItems).map((item) => ({ + itemNo: item.itemNo, + measured: dimensions(item), + unitVolumeCbm: item.unitVolumeCbm, + expected: resolveWeightedDimensions(item, dimensionRules), + })), + }) + + await softLocalStep('phase2-client-billing', 'published weight results match confirmed platform weights', () => { + const publishedWeights = arrayData(merchantBilling.weightResults) + assertEqual(publishedWeights.length, platformMeasurement.weightResults.length, + 'published weight result count') + const platformById = new Map(platformMeasurement.weightResults.map((item) => [String(item.id), item])) + for (const weight of publishedWeights) { + assertStringId(weight.id, 'published weight result id') + const expected = platformById.get(String(weight.id)) + assertPresent(expected, `platform weight result ${weight.id}`) + assertEqual(String(weight.fileId), String(expected.fileId), 'weight image file id') + assertEqual(weight.recognitionStatus, 'CONFIRMED', 'published weight status') + assertApproxEqual(weight.grossWeightKg, expected.grossWeightKg, 0.001, 'published gross weight') + assertApproxEqual(weight.netWeightKg, expected.netWeightKg, 0.001, 'published net weight') + } + assertApproxEqual(sum(publishedWeights, 'grossWeightKg'), + merchantBilling.measurementSummary.actualGrossWeightKg, 0.001, 'published weight aggregate') + assertApproxEqual(sum(publishedWeights, 'netWeightKg'), + merchantBilling.measurementSummary.actualNetWeightKg, 0.001, 'published net weight aggregate') + }, { weightResultCount: arrayData(merchantBilling.weightResults).length }) + + await softLocalStep('phase2-client-billing', + 'weight and volume billing lines are independently consistent', () => { + const summary = merchantBilling.measurementSummary + const basis = merchantBilling.billingBasis + const fee = merchantBilling.latestFee + assertNumberEqual(basis.actualWeightKg, summary.actualNetWeightKg, 'billing actual weight') + assertNumberEqual(basis.actualVolumeCbm, summary.actualVolumeCbm, 'billing actual volume') + assertNumberEqual(basis.actualWeightKg, fee.actualWeightKg, 'fee actual weight') + assertNumberEqual(basis.actualVolumeCbm, fee.actualVolumeCbm, 'fee actual volume') + assertNumberEqual(basis.convertedQuantity, fee.convertedQuantity, 'billing converted quantity') + assertNumberEqual(basis.chargeableQuantity, fee.chargeableQuantity, 'billing chargeable quantity') + assertTrue(['ACTUAL_WEIGHT', 'VOLUME_WEIGHT', 'EQUAL', 'ACTUAL_VOLUME', + 'WEIGHT_CONVERTED_VOLUME'].includes(basis.chargeableBasis), 'chargeable basis must be supported') + }, { + actualWeightKg: merchantBilling.billingBasis?.actualWeightKg, + actualVolumeCbm: merchantBilling.billingBasis?.actualVolumeCbm, + convertedQuantity: merchantBilling.billingBasis?.convertedQuantity, + chargeableQuantity: merchantBilling.billingBasis?.chargeableQuantity, + chargeableBasis: merchantBilling.billingBasis?.chargeableBasis, + }) + + await softLocalStep('phase2-client-billing', 'published fee detail matches confirmed fee snapshot', () => { + const detail = merchantBilling.publishedFeeDetail + const fee = merchantBilling.latestFee + assertPresent(detail?.id, 'published fee detail id') + assertEqual(String(detail.inboundId), report.resources.terminalInboundId, 'published fee inbound id') + assertEqual(String(detail.sourceFeeId), String(fee.feeId), 'published source fee id') + assertEqual(Number(detail.sourceFeeVersion), Number(fee.feeVersion), 'published source fee version') + assertEqual(String(detail.customerId), report.resources.tenantId, 'published fee customer id') + assertEqual(detail.publicationStatus, 'PUBLISHED', 'published fee publication status') + assertPresent(detail.publishedAt, 'published fee time') + assertMoneyEqual(detail.amount, fee.totalAmount, 'published fee amount') + assertNumberEqual(detail.chargeableQuantity, fee.chargeableQuantity, 'published chargeable quantity') + assertEqual(detail.chargeUnit, fee.chargeUnit, 'published charge unit') + }, { + publishedFeeDetailId: merchantBilling.publishedFeeDetail?.id, + sourceFeeId: merchantBilling.publishedFeeDetail?.sourceFeeId, + publicationStatus: merchantBilling.publishedFeeDetail?.publicationStatus, + amount: merchantBilling.publishedFeeDetail?.amount, + }) + + await softLocalStep('phase2-client-billing', 'measurement and billing basis are internally consistent', () => { + const basis = merchantBilling.billingBasis + const fee = merchantBilling.latestFee + assertNumberEqual(basis.actualWeightKg, fee.actualWeightKg, 'billing actual weight') + assertNumberEqual(basis.actualVolumeCbm, fee.actualVolumeCbm, 'billing actual volume') + assertNumberEqual(basis.convertedQuantity, fee.convertedQuantity, 'billing converted quantity') + assertNumberEqual(basis.chargeableQuantity, fee.chargeableQuantity, 'billing chargeable quantity') + }, { + measurementItemCount: arrayData(merchantBilling.measurementItems).length, + weightResultCount: arrayData(merchantBilling.weightResults).length, + chargeableBasis: merchantBilling.billingBasis?.chargeableBasis, + }) + + const measurementImages = arrayData(merchantBilling.measurementImages) + const weightImages = arrayData(merchantBilling.weightImages) + report.resources.measurementImageFileIds = measurementImages.map((item) => String(item.fileId)) + report.resources.weightImageFileIds = weightImages.map((item) => String(item.fileId)) + const images = [...measurementImages, ...weightImages] + if (images.length === 0) { + recordSkipped('phase2-client-images', 'published measurement and weight image access', + 'Terminal billing aggregate contains no image references') + } else { + for (const image of images.slice(0, 4)) { + await apiStep('phase2-client-images', `download ${image.imageType} image ${image.fileId}`, { + method: 'GET', path: apiPath(image.previewUrl), token: clientToken, responseType: 'bytes', + }, ({ status, bytes, headers }) => { + assertEqual(status, 200, 'HTTP status') + assertTrue(bytes?.length > 0, 'downloaded image must not be empty') + assertTrue((headers.get('content-type') ?? '').startsWith('image/'), + 'download content type must be an image') + }, ({ bytes, headers }) => ({ + fileId: image.fileId, + imageType: image.imageType, + sizeBytes: bytes.length, + contentType: headers.get('content-type'), + })) + } + } + + await apiStep('phase2-client-images', 'unreferenced inbound image is rejected', { + method: 'GET', + path: `/api/merchant/v1/first-leg/orders/${report.resources.terminalOrderId}/inbound-billing/images/1`, + token: clientToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0404', 'business code') + }, ({ json }) => ({ fileId: 1, rejectionCode: json?.code })) +} + +async function unifiedOrderModule() { + const orderNo = terminalFixture.orderNo + await softApiStep('phase2-unified-orders', 'unified order list exposes phase-two terminal state', { + method: 'GET', + path: `/api/merchant/v1/orders?page=1&size=50&orderNo=${encodeURIComponent(orderNo)}`, + token: clientToken, + }, ({ json }) => { + requireSuccess(json) + const row = pageRecords(json.data) + .find((item) => String(item.orderId) === report.resources.terminalOrderId) + assertPresent(row, 'unified terminal order row') + assertEqual(row.mainStatus, 'WAIT_SPLIT', 'unified main status') + assertEqual(String(row.inboundId), report.resources.terminalInboundId, 'unified inbound id') + assertEqual(row.measurement?.reviewStatus, 'APPROVED', 'unified measurement review status') + assertTrue(Number(row.actualGrossWeightKg) > 0, 'unified actual gross weight must be positive') + assertTrue(Number(row.actualVolumeCbm) > 0, 'unified actual volume must be positive') + }, ({ json }) => { + const row = pageRecords(json.data) + .find((item) => String(item.orderId) === report.resources.terminalOrderId) + return { orderId: row?.orderId, mainStatus: row?.mainStatus, + inboundId: row?.inboundId, measurementReviewStatus: row?.measurement?.reviewStatus, + actualGrossWeightKg: row?.actualGrossWeightKg, actualVolumeCbm: row?.actualVolumeCbm } + }) + + await softApiStep('phase2-unified-orders', 'unified order detail exposes phase-two summary and timeline', { + method: 'GET', path: `/api/merchant/v1/orders/${report.resources.terminalOrderId}`, token: clientToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.summary?.orderId), report.resources.terminalOrderId, 'unified order id') + assertEqual(json.data?.summary?.mainStatus, 'WAIT_SPLIT', 'unified main status') + assertEqual(String(json.data?.summary?.inboundId), report.resources.terminalInboundId, + 'unified inbound id') + assertEqual(json.data?.summary?.measurement?.reviewStatus, 'APPROVED', + 'unified measurement review status') + assertTrue(Array.isArray(json.data?.timeline), 'unified timeline must be an array') + }, ({ json }) => ({ + orderId: json.data?.summary?.orderId, + mainStatus: json.data?.summary?.mainStatus, + timelineCount: arrayData(json.data?.timeline).length, + })) +} + +async function isolationModule() { + if (!foreignFixture) { + recordSkipped('phase2-isolation', 'cross-enterprise terminal billing is rejected', + 'No foreign WAIT_SPLIT + APPROVED + SENT fixture is visible to the platform account') + return + } + await apiStep('phase2-isolation', 'cross-enterprise terminal billing is rejected', { + method: 'GET', + path: `/api/merchant/v1/first-leg/orders/${foreignFixture.orderId}/inbound-billing`, + token: clientToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertTrue(['A0403', 'A0404'].includes(String(json?.code)), + `expected A0403/A0404, actual=${json?.code}`) + }, ({ json }) => ({ + orderId: foreignFixture.orderId, + tenantId: foreignFixture.tenantId, + rejectionCode: json?.code, + })) +} + +async function apiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) { + const startedAt = new Date().toISOString() + const started = Date.now() + let response + try { + response = await httpRequest(request) + validate(response) + report.steps.push(makeStep(module, name, true, startedAt, started, response, evidenceBuilder(response))) + console.log(`PASS [${module}] ${name}`) + return response + } catch (error) { + let evidence = null + if (response) { + try { evidence = evidenceBuilder(response) } catch { evidence = defaultEvidence(response) } + } + const step = makeStep(module, name, false, startedAt, started, response, evidence) + step.error = sanitizeError(error) + report.steps.push(step) + console.error(`FAIL [${module}] ${name}: ${error.message}`) + throw error + } +} + +async function softApiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) { + try { return await apiStep(module, name, request, validate, evidenceBuilder) } catch { return null } +} + +async function localStep(module, name, validate, evidence = null) { + const startedAt = new Date().toISOString() + const started = Date.now() + try { + validate() + report.steps.push(makeStep(module, name, true, startedAt, started, null, evidence)) + console.log(`PASS [${module}] ${name}`) + } catch (error) { + const step = makeStep(module, name, false, startedAt, started, null, evidence) + step.error = sanitizeError(error) + report.steps.push(step) + console.error(`FAIL [${module}] ${name}: ${error.message}`) + throw error + } +} + +async function softLocalStep(module, name, validate, evidence = null) { + try { await localStep(module, name, validate, evidence) } catch { } +} + +async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) { + const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-phase2-${randomUUID()}`, ...headers } + if (token) requestHeaders.Authorization = `Bearer ${token}` + let requestBody = body + if (body != null && !(body instanceof FormData)) { + requestHeaders['Content-Type'] = 'application/json' + requestBody = JSON.stringify(body) + } + const response = await fetch(`${gateway}${path}`, { + method, headers: requestHeaders, body: requestBody, signal: AbortSignal.timeout(timeoutMs), + }) + const result = { status: response.status, headers: response.headers, json: null, bytes: null, text: null } + if (responseType === 'bytes') { + result.bytes = new Uint8Array(await response.arrayBuffer()) + return result + } + const text = await response.text() + result.text = text + if (text) { + try { result.json = JSON.parse(text) } + catch { throw new Error(`Expected JSON but received ${text.slice(0, 200)}`) } + } + return result +} + +function makeStep(module, name, passed, startedAt, started, response, evidence) { + return { + module, name, passed, skipped: false, startedAt, durationMs: Date.now() - started, + http: response?.status ?? null, + code: response?.json?.code == null ? null : String(response.json.code), + message: response?.json?.message ?? null, + requestId: headerOrBody(response, 'x-request-id', 'requestId'), + traceId: headerOrBody(response, 'x-trace-id', 'traceId'), + evidence, + } +} + +function recordSkipped(module, name, reason) { + report.steps.push({ + module, name, passed: true, skipped: true, startedAt: new Date().toISOString(), durationMs: 0, + http: null, code: null, message: reason, requestId: null, traceId: null, evidence: null, + }) + console.log(`SKIP [${module}] ${name}: ${reason}`) +} + +function requireSuccess(json) { + assertPresent(json, 'response body') + assertEqual(String(json.code), '200', 'business code') +} + +function isTerminalFixture(row) { + return row?.processStatus === 'WAIT_SPLIT' + && row?.measureReviewStatus === 'APPROVED' + && row?.feeStatus === 'SENT' +} + +function assertTerminalFixture(row) { + assertPresent(row, 'terminal inbound fixture') + assertEqual(row.processStatus, 'WAIT_SPLIT', 'process status') + assertEqual(row.measureReviewStatus, 'APPROVED', 'measurement review status') + assertEqual(row.feeStatus, 'SENT', 'fee status') +} + +function assertDimensionRule(rule) { + assertPresent(rule?.boxType, 'dimension box type') + assertTrue(Number(rule.version) >= 0, `${rule.boxType} version must not be negative`) + for (const field of ['innerLengthCm', 'innerWidthCm', 'innerHeightCm', 'edgeAdditionCm', + 'outerLengthCm', 'outerWidthCm', 'outerHeightCm', 'volumeCbm']) { + assertTrue(Number(rule[field]) > 0, `${rule.boxType} ${field} must be positive`) + } + const edgePair = Number(rule.edgeAdditionCm) * 2 + assertApproxEqual(rule.outerLengthCm, Number(rule.innerLengthCm) + edgePair, 0.000001, + `${rule.boxType} outer length`) + assertApproxEqual(rule.outerWidthCm, Number(rule.innerWidthCm) + edgePair, 0.000001, + `${rule.boxType} outer width`) + assertApproxEqual(rule.outerHeightCm, Number(rule.innerHeightCm) + edgePair, 0.000001, + `${rule.boxType} outer height`) + assertApproxEqual(rule.volumeCbm, + Number(rule.outerLengthCm) * Number(rule.outerWidthCm) * Number(rule.outerHeightCm) / 1_000_000, + 0.0005, `${rule.boxType} configured volume`) +} + +function resolveWeightedDimensions(item, rules) { + const measured = [Number(item.lengthCm), Number(item.widthCm), Number(item.heightCm)] + .sort((left, right) => right - left) + const candidates = [...arrayData(rules)].sort((left, right) => + Number(left.innerLengthCm) * Number(left.innerWidthCm) * Number(left.innerHeightCm) + - Number(right.innerLengthCm) * Number(right.innerWidthCm) * Number(right.innerHeightCm)) + const rule = candidates.find((candidate) => { + const inner = [Number(candidate.innerLengthCm), Number(candidate.innerWidthCm), + Number(candidate.innerHeightCm)].sort((left, right) => right - left) + return measured.every((value, index) => value <= inner[index]) + }) + if (!rule) { + const rawVolume = measured.reduce((total, value) => total * value, 1) / 1_000_000 + return { boxType: null, ruleVersion: null, outer: dimensions(item), + unitVolumeCbm: round(rawVolume, 3) } + } + const edgePair = Number(rule.edgeAdditionCm) * 2 + const outer = [Number(rule.innerLengthCm) + edgePair, Number(rule.innerWidthCm) + edgePair, + Number(rule.innerHeightCm) + edgePair] + return { + boxType: rule.boxType, + ruleVersion: rule.version, + outer, + unitVolumeCbm: round(outer.reduce((total, value) => total * value, 1) / 1_000_000, 3), + } +} + +function dimensions(value, prefix = '') { + const field = (name) => `${prefix}${prefix ? name[0].toUpperCase() + name.slice(1) : name}` + return [value?.[field('lengthCm')] ?? null, value?.[field('widthCm')] ?? null, + value?.[field('heightCm')] ?? null] +} + +function sum(values, field) { + return arrayData(values).reduce((total, value) => total + Number(value?.[field] ?? 0), 0) +} + +function round(value, scale) { + const factor = 10 ** scale + return Math.round((Number(value) + Number.EPSILON) * factor) / factor +} + +function statusCounts(values) { + const counts = {} + for (const row of arrayData(values)) { + const key = `${row.processStatus ?? 'UNKNOWN'}|${row.measureReviewStatus ?? 'NONE'}|${row.feeStatus ?? 'NONE'}` + counts[key] = (counts[key] ?? 0) + 1 + } + return counts +} + +function assertStringId(value, label) { + assertPresent(value, label) + assertEqual(typeof value, 'string', `${label} type`) + assertTrue(/^\d+$/.test(value), `${label} must contain only digits`) +} + +function assertNumberEqual(actual, expected, label) { + assertEqual(Number(actual), Number(expected), label) +} + +function assertApproxEqual(actual, expected, tolerance, label) { + const actualNumber = Number(actual) + const expectedNumber = Number(expected) + if (!Number.isFinite(actualNumber) || !Number.isFinite(expectedNumber) + || Math.abs(actualNumber - expectedNumber) > tolerance) { + throw new Error(`${label}: expected=${format(expected)} actual=${format(actual)}`) + } +} + +function assertMoneyEqual(actual, expected, label) { + assertEqual(Math.round(Number(actual) * 100), Math.round(Number(expected) * 100), label) +} + +function defaultEvidence({ json }) { + if (!json || json.data == null) return null + if (typeof json.data !== 'object') return { data: json.data } + return null +} + +function pageRecords(data) { + if (Array.isArray(data)) return data + return arrayData(data?.records ?? data?.items ?? data?.content) +} + +function arrayData(value) { return Array.isArray(value) ? value : [] } + +function headerOrBody(response, headerName, bodyName) { + return response?.headers?.get(headerName) + ?? response?.json?.[bodyName] + ?? response?.json?.data?.[bodyName] + ?? null +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) throw new Error(`${label}: expected=${format(expected)} actual=${format(actual)}`) +} + +function assertPresent(value, label) { + if (value == null || value === '') throw new Error(`${label} is required`) +} + +function assertTrue(value, message) { + if (!value) throw new Error(message) +} + +function apiPath(value) { + const url = new URL(String(value), `${gateway}/`) + return `${url.pathname}${url.search}` +} + +function printSummary() { + const failed = report.steps.filter((step) => !step.passed) + const skipped = report.steps.filter((step) => step.skipped) + console.log(JSON.stringify({ + success: report.success, + marker, + passed: report.steps.filter((step) => step.passed && !step.skipped).length, + failed: failed.length, + skipped: skipped.length, + reportPath, + }, null, 2)) +} + +function parseArgs(values) { + const parsed = {} + for (let index = 0; index < values.length; index += 1) { + const value = values[index] + if (!value.startsWith('--')) continue + const [rawKey, inlineValue] = value.slice(2).split('=', 2) + parsed[rawKey] = inlineValue ?? values[index + 1] + if (inlineValue == null) index += 1 + } + return parsed +} + +function compactTimestamp(value) { + return value.toISOString().replace(/[-:TZ.]/g, '').slice(0, 14) +} + +function trimSlash(value) { return String(value).replace(/\/+$/, '') } + +function format(value) { return typeof value === 'string' ? JSON.stringify(value) : String(value) } + +function sanitizeError(error) { + return { name: error?.name ?? 'Error', message: error?.message ?? String(error) } +} + +async function promptText(label, defaultValue = '') { + if (!process.stdin.isTTY || !process.stdout.isTTY) return defaultValue + const reader = createInterface({ input: process.stdin, output: process.stdout }) + try { + const suffix = defaultValue ? ` [${defaultValue}]` : '' + const value = await reader.question(`${label}${suffix}: `) + return value.trim() || defaultValue + } finally { + reader.close() + } +} + +async function promptSecret(label) { + if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined + const input = process.stdin + const output = process.stdout + output.write(`${label}: `) + input.setRawMode(true) + input.resume() + input.setEncoding('utf8') + let value = '' + try { + for await (const chunk of input) { + for (const character of chunk) { + if (character === '\r' || character === '\n') { + output.write('\n') + return value + } + if (character === '\u0003') throw new Error('Input cancelled') + if (character === '\u007f' || character === '\b') { + if (value.length > 0) value = value.slice(0, -1) + continue + } + value += character + } + } + return value + } finally { + input.setRawMode(false) + input.pause() + } +} diff --git a/scripts/client-quick-order-test-env-acceptance.contract.test.mjs b/scripts/client-quick-order-test-env-acceptance.contract.test.mjs new file mode 100644 index 000000000..f4cf19672 --- /dev/null +++ b/scripts/client-quick-order-test-env-acceptance.contract.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const source = await readFile(new URL('./client-quick-order-test-env-acceptance.mjs', import.meta.url), 'utf8') + +test('quick-order HTTP acceptance uses the same endpoint and skeletal cargo payload as Client', () => { + assert.match(source, /\/api\/merchant\/v1\/orders\/first-leg\/batch/) + assert.match(source, /cargoes: Array\.from\(\{ length: cargoCount \}/) + assert.match(source, /cargoName: '', packageType: 'CARTON', packageCount: index \+ 1, grossWeightKg: 0, volumeCbm: 0/) + assert.match(source, /destinationType: 'FBA'/) + assert.match(source, /destinationType: 'OVERSEAS'/) + assert.match(source, /destinationType: 'PRIVATE'/) + assert.match(source, /addresses\?addressType=PRIVATE_DESTINATION/) + assert.match(source, /privateDestinationAddressId: String\(address\.id\)/) +}) + +test('quick-order HTTP acceptance verifies expanded row, cargo, isolation, retry, and idempotency behavior', () => { + assert.match(source, /expected succeeded=5/) + assert.match(source, /cargo count mismatch/) + assert.match(source, /unowned private address must fail closed with A0404/) + assert.match(source, /duplicate clientRowId must fail independently with A0409/) + assert.match(source, /draft must belong to the authenticated enterprise/) + assert.match(source, /same idempotency key replays the same quick-order drafts/) + assert.match(source, /idempotent replay returned different order IDs/) + assert.match(source, /same row id and idempotency key reject a changed payload/) + assert.match(source, /a corrected failed row succeeds under a fresh batch key/) + assert.match(source, /--random-count <1-50>/) + assert.match(source, /create \$\{randomCount\} randomized quick-order drafts/) + assert.match(source, /verify all \$\{randomCount\} randomized drafts/) + assert.match(source, /replay all \$\{randomCount\} randomized drafts idempotently/) + assert.match(source, /randomInt\(1, 6\)/) +}) diff --git a/scripts/client-quick-order-test-env-acceptance.mjs b/scripts/client-quick-order-test-env-acceptance.mjs new file mode 100644 index 000000000..1c53dbe6a --- /dev/null +++ b/scripts/client-quick-order-test-env-acceptance.mjs @@ -0,0 +1,404 @@ +#!/usr/bin/env node + +import { randomInt, randomUUID } from 'node:crypto' +import { mkdir, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const args = parseArgs(process.argv.slice(2)) +if (args.help) { + console.log('Usage: node scripts/client-quick-order-test-env-acceptance.mjs [--report-dir ] [--timeout-ms ] [--random-count <1-50>]') + process.exit(0) +} + +const gateway = trimSlash(process.env.ADMIN_TEST_GATEWAY) +const username = process.env.MERCHANT_TEST_USERNAME +const password = process.env.MERCHANT_TEST_PASSWORD +const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000) +const randomCount = Number(args['random-count'] ?? 0) +const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports') +const marker = `${compactTimestamp()}-${randomUUID().slice(0, 8)}` +const reportPath = resolve(reportDir, `client-quick-order-test-env-${marker}.json`) +const report = { + marker, + generatedAt: new Date().toISOString(), + completedAt: null, + success: false, + fatal: null, + resources: { batchKey: null, randomBatchKey: null, orderNos: [] }, + steps: [], +} + +if (!gateway || !username || !password) { + console.error('ADMIN_TEST_GATEWAY, MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD are required') + process.exit(2) +} +if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { + console.error('--timeout-ms must be at least 1000') + process.exit(2) +} +if (!Number.isInteger(randomCount) || randomCount < 0 || randomCount > 50) { + console.error('--random-count must be an integer between 1 and 50') + process.exit(2) +} + +let token +try { + await step('unauthenticated batch request is rejected', async () => { + const response = await rawRequest('/api/merchant/v1/orders/first-leg/batch', { + method: 'POST', body: [], headers: { 'Idempotency-Key': `quick-order-anonymous-${marker}` }, + }) + assert([401, 403].includes(response.status), `expected HTTP 401/403, got ${response.status}`) + return { httpStatus: response.status } + }) + + const login = await step('merchant login', async () => { + const response = await jsonRequest('/api/auth/login', { + method: 'POST', body: { loginId: username, password }, authenticated: false, + }) + requireSuccess(response) + assert(response.data?.accessToken, 'accessToken is required') + token = response.data.accessToken + return { authenticated: true } + }) + assert(login.authenticated, 'login did not complete') + + const profile = await step('load authoritative enterprise profile', async () => { + const response = await jsonRequest('/api/merchant/v1/iam/tenant/profile') + requireSuccess(response) + assert(response.data?.tenantName?.trim(), 'tenantName is required') + return { tenantId: String(response.data.id), tenantName: response.data.tenantName.trim() } + }) + const policy = await step('load inbound-code policy', async () => { + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/inbound-code-policy') + requireSuccess(response) + assert(['AUTO', 'LEGACY_MANUAL'].includes(response.data?.mode), `unexpected policy ${response.data?.mode}`) + return response.data + }) + let privateAddresses = [] + await step('load customer-owned private destination addresses', async () => { + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/addresses?addressType=PRIVATE_DESTINATION') + requireSuccess(response) + privateAddresses = arrayData(response.data) + assert(privateAddresses.length > 0, 'at least one customer-owned private destination address is required') + return { count: privateAddresses.length } + }) + const domestic = await loadWarehouses('DOMESTIC') + const fba = await loadWarehouses('FBA') + const overseas = await loadWarehouses('OVERSEAS') + assert(domestic.length > 0, 'at least one ACTIVE domestic warehouse is required') + assert(fba.length > 0, 'at least one ACTIVE FBA warehouse is required') + assert(overseas.length > 0, 'at least one ACTIVE overseas warehouse is required') + + let receiving + let channel + for (const warehouse of domestic) { + const response = await jsonRequest(`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${warehouse.id}`) + requireSuccess(response) + const candidate = arrayData(response.data).find((item) => arrayData(item.pricingTaxModes).length > 0) + if (candidate) { receiving = warehouse; channel = candidate; break } + } + assert(receiving && channel, 'no domestic warehouse has an active pricing channel') + + const fbaDestination = (warehouse) => ({ + destinationType: 'FBA', destinationCountryCode: warehouse.countryCode, destinationWarehouseId: warehouse.id, + destinationWarehouseName: warehouse.warehouseName, amazonWarehouseCode: warehouse.warehouseCode, + }) + const overseasDestination = (warehouse) => ({ + destinationType: 'OVERSEAS', destinationCountryCode: warehouse.countryCode, + destinationWarehouseId: warehouse.id, destinationWarehouseName: warehouse.warehouseName, + }) + const privateDestination = (address) => ({ + destinationType: 'PRIVATE', destinationCountryCode: address.countryCode, + destinationWarehouseName: address.addressName, privateDestinationAddressId: String(address.id), + }) + const invalidChannelRowId = `quick-order-invalid-channel-${marker}` + const invalidPrivateRowId = `quick-order-invalid-private-${marker}` + const rows = [ + { clientRowId: `quick-order-fba-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, + fbaDestination(fba[0]), 'FBA') }, + { clientRowId: `quick-order-fba-multi-cargo-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, + fbaDestination(fba[1] ?? fba[0]), 'FBA-MULTI', 3) }, + { clientRowId: `quick-order-overseas-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, + overseasDestination(overseas[1] ?? overseas[0]), 'OVERSEAS') }, + { clientRowId: `quick-order-private-a-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, + privateDestination(privateAddresses[0]), 'PRIVATE-A') }, + { clientRowId: `quick-order-private-b-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, + privateDestination(privateAddresses[1] ?? privateAddresses[0]), 'PRIVATE-B') }, + { clientRowId: invalidChannelRowId, draft: { + ...quickDraft(profile.tenantName, policy, receiving, channel, fbaDestination(fba[0]), 'INVALID'), + channelCode: `INVALID-${marker}`, channelName: '不存在的渠道', + } }, + { clientRowId: invalidPrivateRowId, draft: quickDraft(profile.tenantName, policy, receiving, channel, { + destinationType: 'PRIVATE', destinationCountryCode: 'US', destinationWarehouseName: '越权私人仓', + privateDestinationAddressId: '9223372036854775807', + }, 'INVALID-PRIVATE') }, + { clientRowId: `quick-order-fba-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, + fbaDestination(fba[0]), 'DUPLICATE') }, + ] + const expectedCargoCounts = new Map(rows.slice(0, 5).map((item) => [item.clientRowId, item.draft.cargoes.length])) + const expectedDestinationTypes = new Map(rows.slice(0, 5).map((item) => [item.clientRowId, item.draft.destinationType])) + + const batchKey = `quick-order-http-${randomUUID()}` + report.resources.batchKey = batchKey + const created = await step('create five varied quick-order drafts while isolating three invalid rows', async () => { + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { + method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey }, + }) + requireSuccess(response) + assert(response.data?.total === 8, `expected total=8, got ${response.data?.total}`) + const rowEvidence = arrayData(response.data?.items).map((item) => ({ + clientRowId: item.clientRowId, success: item.success, orderId: item.orderId, orderNo: item.orderNo, + errorCode: item.errorCode, errorMessage: item.errorMessage, + })) + assert(response.data?.succeeded === 5, + `expected succeeded=5, got ${response.data?.succeeded}; rows=${JSON.stringify(rowEvidence)}`) + assert(response.data?.failed === 3, + `expected failed=3, got ${response.data?.failed}; rows=${JSON.stringify(rowEvidence)}`) + const invalidChannel = response.data.items.find((item) => item.clientRowId === invalidChannelRowId) + const invalidPrivate = response.data.items.find((item) => item.clientRowId === invalidPrivateRowId) + const duplicate = response.data.items[7] + assert(invalidChannel && !invalidChannel.success && invalidChannel.errorCode === 'A0409', + 'invalid channel row must fail independently with A0409') + assert(invalidPrivate && !invalidPrivate.success && invalidPrivate.errorCode === 'A0404', + `unowned private address must fail closed with A0404; actual=${JSON.stringify(invalidPrivate)}`) + assert(duplicate && !duplicate.success && duplicate.errorCode === 'A0409', + 'duplicate clientRowId must fail independently with A0409') + return { ...response.data, items: rowEvidence } + }) + + const successfulItems = created.items.filter((item) => item.success) + for (const item of successfulItems) { + await step(`verify created draft ${item.clientRowId}`, async () => { + assert(item.orderId != null, 'successful row must include orderId') + assert(item.orderNo, 'successful row must include orderNo') + const response = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`) + requireSuccess(response) + assert(response.data?.order?.status === 'DRAFT', `expected DRAFT, got ${response.data?.order?.status}`) + assert(response.data?.order?.customerName === profile.tenantName, 'draft must belong to the authenticated enterprise') + assert(response.data?.order?.destinationType === expectedDestinationTypes.get(item.clientRowId), + `destination type mismatch for ${item.clientRowId}`) + assert(arrayData(response.data?.cargoes).length === expectedCargoCounts.get(item.clientRowId), + `cargo count mismatch for ${item.clientRowId}`) + report.resources.orderNos.push(item.orderNo) + return { orderNo: item.orderNo, status: response.data.order.status, + destinationType: response.data.order.destinationType, cargoCount: response.data.cargoes.length } + }) + } + + await step('same idempotency key replays the same quick-order drafts', async () => { + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { + method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey }, + }) + requireSuccess(response) + const originalIds = successfulItems.map((item) => String(item.orderId)) + const replayIds = response.data.items.filter((item) => item.success).map((item) => String(item.orderId)) + assert(JSON.stringify(replayIds) === JSON.stringify(originalIds), 'idempotent replay returned different order IDs') + return { replayedOrderNos: response.data.items.filter((item) => item.success).map((item) => item.orderNo) } + }) + + await step('same row id and idempotency key reject a changed payload without overwriting the draft', async () => { + const changed = structuredClone(rows[0]) + changed.draft.cargoes[0].packageCount = 99 + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { + method: 'POST', body: [changed], headers: { 'Idempotency-Key': batchKey }, + }) + requireSuccess(response) + assert(response.data?.succeeded === 0 && response.data?.failed === 1, + 'changed payload with the same idempotency key must fail') + assert(response.data?.items?.[0]?.errorCode === 'A0409', + `expected idempotency conflict A0409, got ${response.data?.items?.[0]?.errorCode}`) + return { errorCode: response.data.items[0].errorCode, errorMessage: response.data.items[0].errorMessage } + }) + + await step('a corrected failed row succeeds under a fresh batch key', async () => { + const corrected = { + clientRowId: invalidChannelRowId, + draft: quickDraft(profile.tenantName, policy, receiving, channel, fbaDestination(fba[0]), 'RETRY'), + } + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { + method: 'POST', body: [corrected], headers: { 'Idempotency-Key': `quick-order-retry-${randomUUID()}` }, + }) + requireSuccess(response) + const item = response.data?.items?.[0] + assert(response.data?.succeeded === 1 && item?.success && item?.orderId != null, + `corrected row did not succeed: ${JSON.stringify(response.data)}`) + const detail = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`) + requireSuccess(detail) + assert(detail.data?.order?.status === 'DRAFT', 'corrected row must remain a draft') + assert(detail.data?.order?.customerName === profile.tenantName, + 'corrected draft must belong to the authenticated enterprise') + report.resources.orderNos.push(item.orderNo) + return { orderNo: item.orderNo, status: detail.data.order.status } + }) + + if (randomCount > 0) { + const destinationBuilders = [ + () => fbaDestination(fba[randomInt(fba.length)]), + () => overseasDestination(overseas[randomInt(overseas.length)]), + () => privateDestination(privateAddresses[randomInt(privateAddresses.length)]), + ] + const randomRows = Array.from({ length: randomCount }, (_, index) => { + const destination = destinationBuilders[index % destinationBuilders.length]() + const cargoCount = randomInt(1, 6) + const draft = quickDraft(profile.tenantName, policy, receiving, channel, destination, + `RANDOM-${index + 1}`, cargoCount) + draft.cargoes.forEach((cargo) => { + cargo.packageCount = randomInt(1, 31) + cargo.totalQuantity = cargo.packageCount * randomInt(1, 11) + cargo.unitsPerPackage = Math.max(1, Math.floor(cargo.totalQuantity / cargo.packageCount)) + }) + return { clientRowId: `quick-order-random-${index + 1}-${marker}`, draft } + }) + const randomBatchKey = `quick-order-random-http-${randomUUID()}` + report.resources.randomBatchKey = randomBatchKey + const randomCreated = await step(`create ${randomCount} randomized quick-order drafts`, async () => { + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { + method: 'POST', body: randomRows, headers: { 'Idempotency-Key': randomBatchKey }, + }) + requireSuccess(response) + assert(response.data?.total === randomCount, `expected random total=${randomCount}, got ${response.data?.total}`) + assert(response.data?.succeeded === randomCount && response.data?.failed === 0, + `random batch failures: ${JSON.stringify(response.data?.items?.filter((item) => !item.success))}`) + return response.data.items.map((item, index) => ({ + clientRowId: item.clientRowId, orderId: item.orderId, orderNo: item.orderNo, + destinationType: randomRows[index].draft.destinationType, + cargoCount: randomRows[index].draft.cargoes.length, + })) + }) + + await step(`verify all ${randomCount} randomized drafts`, async () => { + const evidence = [] + for (const item of randomCreated) { + const response = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`) + requireSuccess(response) + assert(response.data?.order?.status === 'DRAFT', `random draft ${item.orderNo} is not DRAFT`) + assert(response.data?.order?.customerName === profile.tenantName, + `random draft ${item.orderNo} is outside the authenticated enterprise`) + assert(response.data?.order?.destinationType === item.destinationType, + `random draft ${item.orderNo} destination type mismatch`) + assert(arrayData(response.data?.cargoes).length === item.cargoCount, + `random draft ${item.orderNo} cargo count mismatch`) + report.resources.orderNos.push(item.orderNo) + evidence.push({ orderNo: item.orderNo, destinationType: item.destinationType, cargoCount: item.cargoCount }) + } + return evidence + }) + + await step(`replay all ${randomCount} randomized drafts idempotently`, async () => { + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { + method: 'POST', body: randomRows, headers: { 'Idempotency-Key': randomBatchKey }, + }) + requireSuccess(response) + const originalIds = randomCreated.map((item) => String(item.orderId)) + const replayIds = response.data?.items?.map((item) => String(item.orderId)) + assert(JSON.stringify(replayIds) === JSON.stringify(originalIds), + 'randomized idempotent replay returned different order IDs') + return { replayed: replayIds.length } + }) + } + + report.success = report.steps.every((item) => item.passed) +} catch (error) { + report.fatal = sanitizeError(error) + report.success = false +} finally { + report.completedAt = new Date().toISOString() + await mkdir(reportDir, { recursive: true }) + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') + console.log(JSON.stringify({ success: report.success, reportPath, orderNos: report.resources.orderNos, fatal: report.fatal })) +} + +process.exitCode = report.success ? 0 : 1 + +function quickDraft(customerName, policy, receiving, channel, destination, suffix, cargoCount = 1) { + const inboundCode = policy.mode === 'LEGACY_MANUAL' ? `QO${compactTimestamp()}${suffix.slice(0, 3)}` : undefined + return { + customerName, + transportMode: 'SEA', tradeTerm: 'FOB', expectedShipDate: undefined, customerRemark: `快速下单 HTTP 验收 ${marker} ${suffix}`, + pickupMethod: 'SELF_EXPRESS', receivingWarehouseId: receiving.id, receivingWarehouseName: receiving.warehouseName, + channelCode: channel.channelCode, channelName: channel.channelName, + pricingTaxMode: channel.pricingTaxModes[0], taxMode: 'BUY_DECLARATION', containerMode: 'FULL_CONTAINER', + customsInspection: null, inboundCode, ...destination, + cargoes: Array.from({ length: cargoCount }, (_, index) => ({ + cargoName: '', packageType: 'CARTON', packageCount: index + 1, grossWeightKg: 0, volumeCbm: 0, + currency: 'USD', dangerousGoods: false, woodenProduct: false, skuNo: `SKU-${suffix}-${index + 1}-${marker}`, + totalQuantity: index + 1, unitsPerPackage: 1, + })), + } +} + +async function loadWarehouses(type) { + const all = [] + for (let page = 1; ; page += 1) { + const response = await jsonRequest(`/api/merchant/v1/warehouses?page=${page}&size=200&status=ACTIVE&warehouseType=${type}`) + requireSuccess(response) + all.push(...pageRecords(response.data)) + if (page >= Number(response.data?.pages ?? 1)) return all + } +} + +async function step(name, action) { + const startedAt = new Date().toISOString() + try { + const evidence = await action() + report.steps.push({ name, passed: true, startedAt, completedAt: new Date().toISOString(), evidence }) + return evidence + } catch (error) { + report.steps.push({ name, passed: false, startedAt, completedAt: new Date().toISOString(), error: sanitizeError(error) }) + throw error + } +} + +async function rawRequest(path, { method = 'GET', body, headers = {}, authenticated = true } = {}) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + const requestHeaders = { ...headers } + if (authenticated && token) requestHeaders.Authorization = `Bearer ${token}` + let requestBody = body + if (body != null && !(body instanceof FormData)) { + requestHeaders['Content-Type'] = 'application/json' + requestBody = JSON.stringify(body) + } + try { + return await fetch(`${gateway}${path}`, { method, headers: requestHeaders, body: requestBody, signal: controller.signal }) + } finally { + clearTimeout(timer) + } +} + +async function jsonRequest(path, options) { + const response = await rawRequest(path, options) + const text = await response.text() + let json + try { json = text ? JSON.parse(text) : {} } + catch { throw new Error(`${path} returned non-JSON HTTP ${response.status}`) } + if (!response.ok) throw new Error(`${path} HTTP ${response.status} code=${json.code ?? '—'} requestId=${json.requestId ?? '—'} traceId=${json.traceId ?? '—'}`) + return json +} + +function requireSuccess(response) { + if (String(response?.code) !== '200') { + throw new Error(`business failure code=${response?.code ?? '—'} message=${response?.message ?? '—'} requestId=${response?.requestId ?? '—'} traceId=${response?.traceId ?? '—'}`) + } +} + +function parseArgs(values) { + const result = {} + for (let index = 0; index < values.length; index += 1) { + const value = values[index] + if (value === '--help') { result.help = true; continue } + if (!value.startsWith('--')) throw new Error(`Unknown argument: ${value}`) + const key = value.slice(2) + const next = values[++index] + if (!next || next.startsWith('--')) throw new Error(`--${key} requires a value`) + result[key] = next + } + return result +} + +function trimSlash(value) { return String(value ?? '').replace(/\/+$/, '') } +function compactTimestamp() { return new Date().toISOString().replace(/\D/g, '').slice(0, 14) } +function pageRecords(value) { return value?.records ?? value?.content ?? value?.items ?? [] } +function arrayData(value) { return Array.isArray(value) ? value : [] } +function assert(condition, message) { if (!condition) throw new Error(message) } +function sanitizeError(error) { return { name: error?.name ?? 'Error', message: String(error?.message ?? error) } } diff --git a/scripts/client-test-env-acceptance-contract.test.mjs b/scripts/client-test-env-acceptance-contract.test.mjs new file mode 100644 index 000000000..576890d63 --- /dev/null +++ b/scripts/client-test-env-acceptance-contract.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8') + +test('client registration distinguishes incomplete-registration replay from credential conflicts', () => { + assert.match(source, /same credentials replay the incomplete registration/) + assert.match(source, /\['accountId', 'tenantId', 'membershipId'\]/) + assert.match(source, /String\(json\.data\[field\]\), String\(registration\.json\.data\[field\]\)/) + assert.match(source, /duplicate username with different credentials is rejected/) + assert.match(source, /body: \{ \.\.\.registrationBody, password: `Different/) +}) diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs new file mode 100644 index 000000000..ad6943a31 --- /dev/null +++ b/scripts/client-test-env-acceptance.mjs @@ -0,0 +1,2315 @@ +#!/usr/bin/env node + +/** + * Client test-environment acceptance flow. + * + * Run directly and enter the platform/client test credentials when prompted. + * Optional environment variables for CI: + * ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD, + * MERCHANT_TEST_USERNAME, MERCHANT_TEST_PASSWORD, CLIENT_TEST_REPORT_DIR + * + * Example: + * node scripts/client-test-env-acceptance.mjs + */ + +import { mkdir, writeFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { resolve } from 'node:path' +import { createInterface } from 'node:readline/promises' + +const args = parseArgs(process.argv.slice(2)) +const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY) +const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USERNAME + ?? await promptText('平台审核账号', '') +const platformPassword = args['platform-password'] ?? process.env.ADMIN_TEST_PASSWORD + ?? await promptSecret('平台审核密码') +const existingClientUsername = args['existing-client-username'] ?? process.env.MERCHANT_TEST_USERNAME + ?? await promptText('跨客户隔离测试账号(必须由正式注册/建档产生)', '') +const existingClientPassword = args['existing-client-password'] ?? process.env.MERCHANT_TEST_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 (!gateway || !platformUsername || !platformPassword || !existingClientUsername || !existingClientPassword) { + console.error('ADMIN_TEST_GATEWAY, platform credentials, and an independently onboarded client account are required') + process.exit(2) +} +if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { + console.error('--timeout-ms must be at least 1000') + process.exit(2) +} + +const now = new Date() +const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}` +const username = `client_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64) +const temporaryUsername = `renamed_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64) +const clientPassword = `Client${randomUUID().replaceAll('-', '').slice(0, 14)}9!` +const phoneSuffix = `${Date.now()}`.slice(-8) +const legalPhone = `139${phoneSuffix}` +const companyName = `客户端全链路测试企业-${marker}`.slice(0, 128) +const creditCode = `91${compactTimestamp(now)}${randomUUID().replaceAll('-', '').slice(0, 2).toUpperCase()}`.slice(0, 18) +const reportPath = resolve(reportDir, `client-test-env-${marker}.json`) + +const report = { + marker, + gateway, + generatedAt: now.toISOString(), + completedAt: null, + success: false, + fatal: null, + testAccount: { username, companyName, legalPhone }, + resources: { + tenantId: null, + membershipId: null, + reviewId: null, + businessLicenseQualificationId: null, + businessLicenseDocumentUrl: null, + productImageFileId: null, + enterpriseAddressId: null, + receivingWarehouseId: null, + destinationWarehouseId: null, + channelCode: null, + roleId: null, + testMembershipId: null, + notificationTemplateId: null, + notificationMessageIds: [], + orderIds: [], + }, + steps: [], + futureModules: ['measurement', 'ocr', 'mojia', 'billing'], +} + +let merchantToken +let merchantRefreshToken +let platformToken +let existingClientToken +let ownerPasswordNeedsRestore = false +let temporaryOwnerPassword + +try { + await registrationModule() + await merchantBeforeApprovalModule() + await qualificationAndReviewModule() + await merchantAfterApprovalModule() + await profileManagementModule() + await loginIdentifierModule() + await rbacAndMemberModule() + await orderLifecycleModule() + await isolationModule() + await notificationInboxModule() + await accountSecurityModule() + report.success = report.steps.every((step) => step.passed) +} catch (error) { + report.fatal = sanitizeError(error) + report.success = false +} finally { + if (ownerPasswordNeedsRestore) await recoverOwnerPassword() + report.completedAt = new Date().toISOString() + await mkdir(reportDir, { recursive: true }) + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') + printSummary() +} + +process.exitCode = report.success ? 0 : 1 + +async function registrationModule() { + const registrationBody = { + username, + password: clientPassword, + displayName: `测试管理员-${marker}`.slice(0, 64), + companyName, + legalPerson: '测试法人', + phone: legalPhone, + contactEmail: `${username}@example.com`, + countryCode: 'CN', + address: '广东省深圳市南山区测试路 1 号', + } + + const registration = await apiStep('registration', 'enterprise self-registration', { + method: 'POST', path: '/api/auth/register', body: registrationBody, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'tenantStatus') + assertEqual(json.data?.nextStep, 'LOGIN_AND_COMPLETE_QUALIFICATIONS', 'nextStep') + assertPresent(json.data?.tenantId, 'tenantId') + assertPresent(json.data?.membershipId, 'membershipId') + }, ({ json }) => ({ + tenantId: json.data.tenantId, + membershipId: json.data.membershipId, + tenantStatus: json.data.tenantStatus, + nextStep: json.data.nextStep, + })) + report.resources.tenantId = registration.json.data.tenantId + report.resources.membershipId = registration.json.data.membershipId + + await apiStep('registration', 'same credentials replay the incomplete registration without creating another customer', { + method: 'POST', path: '/api/auth/register', body: registrationBody, + }, ({ json }) => { + requireSuccess(json) + for (const field of ['accountId', 'tenantId', 'membershipId']) { + assertPresent(json.data?.[field], field) + assertEqual(String(json.data[field]), String(registration.json.data[field]), `replayed ${field}`) + } + assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'replayed tenantStatus') + }) + + await apiStep('registration', 'duplicate username with different credentials is rejected', { + method: 'POST', path: '/api/auth/register', + body: { ...registrationBody, password: `Different${randomUUID()}9!` }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0409', 'business code') + }) + + const login = await apiStep('registration', 'login before approval', { + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'accessToken') + }, () => ({ username })) + merchantToken = login.json.data.accessToken + merchantRefreshToken = login.json.data.refreshToken + + await apiStep('registration', 'profile is incomplete before qualifications', { + method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.id), String(report.resources.tenantId), 'profile tenant id') + assertEqual(json.data?.status, 'PROFILE_REQUIRED', 'profile status') + }, ({ json }) => ({ id: json.data.id, status: json.data.status, tenantName: json.data.tenantName })) +} + +async function merchantBeforeApprovalModule() { + const masterData = await loadOrderMasterData(merchantToken, 'pre-approval-master-data') + const productImageFileId = await uploadProductImage(merchantToken, 'pre-approval-product-image') + report.resources.productImageFileId = productImageFileId + const payload = buildOrderPayload(masterData, productImageFileId, 'PRE', 'draft before enterprise approval') + + const invalid = structuredClone(payload) + delete invalid.pricingTaxMode + await apiStep('order-validation', 'missing pricingTaxMode is rejected', { + method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken, + headers: { 'Idempotency-Key': newIdempotencyKey('invalid-create') }, body: invalid, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0422', 'business code') + }) + + const createKey = newIdempotencyKey('pre-approval-create') + const create = await apiStep('order-before-approval', 'create draft before approval', { + method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken, + headers: { 'Idempotency-Key': createKey }, body: payload, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data, 'order id') + }, ({ json }) => ({ orderId: json.data, status: 'DRAFT' })) + const orderId = create.json.data + report.resources.orderIds.push(orderId) + + await apiStep('order-before-approval', 'create draft idempotent replay', { + method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken, + headers: { 'Idempotency-Key': createKey }, body: payload, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data), String(orderId), 'replayed order id') + }, ({ json }) => ({ orderId: json.data, replayed: true })) + + await apiStep('order-before-approval', 'submit is blocked before approval', { + method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken, + headers: { 'Idempotency-Key': newIdempotencyKey('pre-approval-submit') }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0403', 'business code') + }, ({ json }) => ({ orderId, rejectionCode: json.code })) +} + +async function qualificationAndReviewModule() { + const png = testPng() + const form = new FormData() + form.append('businessLicense', new Blob([png], { type: 'image/png' }), `business-license-${marker}.png`) + + await apiStep('qualification-review', 'upload required qualification package', { + method: 'POST', + path: `/api/merchant/v1/files/qualifications/required-package?unifiedSocialCreditCode=${encodeURIComponent(creditCode)}`, + token: merchantToken, + body: form, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.businessLicenseQualificationId, 'business license qualification id') + assertTrue(json.data?.legalPersonIdQualificationId == null, 'legal person qualification id must stay optional') + assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'tenant status after upload') + }, ({ json }) => ({ + businessLicenseQualificationId: json.data.businessLicenseQualificationId, + legalPersonIdQualificationId: json.data.legalPersonIdQualificationId, + tenantStatus: json.data.tenantStatus, + })) + + for (const qualificationType of ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE']) { + const optionalForm = new FormData() + optionalForm.append('file', new Blob([png], { type: 'image/png' }), `${qualificationType.toLowerCase()}-${marker}.png`) + await apiStep('qualification-review', `upload optional qualification ${qualificationType}`, { + method: 'POST', + path: `/api/merchant/v1/files/qualifications?qualificationType=${qualificationType}&replace=false`, + token: merchantToken, + body: optionalForm, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.qualificationId, `${qualificationType} qualification id`) + assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'tenant status after optional upload') + }, ({ json }) => ({ qualificationType, qualificationId: json.data.qualificationId, tenantStatus: json.data.tenantStatus })) + } + + const duplicateOptionalForm = new FormData() + duplicateOptionalForm.append('file', new Blob([png], { type: 'image/png' }), `duplicate-freight-${marker}.png`) + await softApiStep('qualification-review', 'duplicate optional qualification requires explicit replacement', { + method: 'POST', + path: '/api/merchant/v1/files/qualifications?qualificationType=FREIGHT_FORWARDER_CERTIFICATE&replace=false', + token: merchantToken, + body: duplicateOptionalForm, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0409', 'business code') + }, ({ json }) => ({ qualificationType: 'FREIGHT_FORWARDER_CERTIFICATE', rejectionCode: json?.code })) + + const replacementOptionalForm = new FormData() + replacementOptionalForm.append('file', new Blob([png], { type: 'image/png' }), `replacement-freight-${marker}.png`) + await apiStep('qualification-review', 'replace optional qualification explicitly', { + method: 'POST', + path: '/api/merchant/v1/files/qualifications?qualificationType=FREIGHT_FORWARDER_CERTIFICATE&replace=true', + token: merchantToken, + body: replacementOptionalForm, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.qualificationId, 'replacement qualification id') + }, ({ json }) => ({ qualificationType: 'FREIGHT_FORWARDER_CERTIFICATE', qualificationId: json.data.qualificationId, replaced: true })) + + await apiStep('qualification-review', 'required qualifications are pending', { + method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertQualificationStatuses(json.data, ['BUSINESS_LICENSE'], 'PENDING') + assertTrue(!arrayData(json.data).some((item) => item.qualificationType === 'LEGAL_PERSON_ID'), + 'optional legal person ID must remain absent') + assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'PENDING') + }, ({ json }) => ({ qualifications: qualificationEvidence(json.data) })) + + await apiStep('qualification-review', 'submit enterprise review', { + method: 'POST', path: '/api/merchant/v1/iam/tenant/qualifications/submit-review', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'PENDING', 'tenant status') + }, ({ json }) => ({ tenantId: json.data.id, status: json.data.status })) + + const platformLogin = await apiStep('qualification-review', 'platform reviewer login', { + method: 'POST', path: '/api/auth/login', body: { loginId: platformUsername, password: platformPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'platform accessToken') + }, () => ({ username: platformUsername })) + platformToken = platformLogin.json.data.accessToken + + const dimensionPath = `/api/platform/v1/iam/tenants/${report.resources.tenantId}/dimension-initialization` + for (let attempt = 0; attempt < 30; attempt += 1) { + const statusResponse = await httpRequest({ method: 'GET', path: dimensionPath, token: platformToken }) + if (String(statusResponse.json?.data?.status) === 'READY') break + if (attempt < 29) await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + await apiStep('qualification-review', 'customer default dimensions are ready before approval', { + method: 'GET', path: dimensionPath, token: platformToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'READY', 'dimension initialization status') + }, ({ json }) => ({ status: json.data.status, attemptCount: json.data.attemptCount })) + + const reviews = await apiStep('qualification-review', 'platform finds pending review', { + method: 'GET', path: '/api/platform/v1/iam/reviews', token: platformToken, + }, ({ json }) => { + requireSuccess(json) + const review = arrayData(json.data).find((item) => String(item.tenantId) === String(report.resources.tenantId)) + assertPresent(review?.id, 'pending review id') + }, ({ json }) => { + const review = arrayData(json.data).find((item) => String(item.tenantId) === String(report.resources.tenantId)) + return { reviewId: review?.id, tenantId: review?.tenantId, status: review?.status } + }) + const review = arrayData(reviews.json.data).find((item) => String(item.tenantId) === String(report.resources.tenantId)) + report.resources.reviewId = review.id + + await apiStep('qualification-review', 'platform sees required qualifications', { + method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/qualifications`, token: platformToken, + }, ({ json }) => { + requireSuccess(json) + assertQualificationStatuses(json.data, ['BUSINESS_LICENSE'], 'PENDING') + assertTrue(!arrayData(json.data).some((item) => item.qualificationType === 'LEGAL_PERSON_ID'), + 'platform must not require a legal person ID record') + }, ({ json }) => ({ qualifications: qualificationEvidence(json.data) })) + + const approval = await apiStep('qualification-review', 'platform approves enterprise', { + method: 'POST', path: `/api/platform/v1/iam/reviews/${review.id}/approve`, token: platformToken, + body: { comment: `客户端全链路自动审核通过 ${marker}` }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.tenantId), String(report.resources.tenantId), 'approved tenant id') + assertEqual(json.data?.administratorCreated, false, 'administratorCreated') + assertTrue(json.data?.administratorUsername == null, 'administratorUsername must not be returned') + assertTrue(json.data?.temporaryPassword == null, 'temporaryPassword must not be returned') + }, ({ json }) => ({ + tenantId: json.data.tenantId, + administratorCreated: json.data.administratorCreated, + generatedCredentialsReturned: Boolean(json.data.administratorUsername || json.data.temporaryPassword), + })) + + await apiStep('qualification-review', 'registered account remains enterprise owner', { + method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/members`, token: platformToken, + }, ({ json }) => { + requireSuccess(json) + const owner = arrayData(json.data).find((item) => item.memberType === 'OWNER') + assertPresent(owner, 'OWNER member') + assertEqual(String(owner.membershipId), String(report.resources.membershipId), 'owner membership id') + assertEqual(owner.username, username, 'owner username') + assertEqual(owner.status, 'ACTIVE', 'owner status') + }, ({ json }) => { + const owner = arrayData(json.data).find((item) => item.memberType === 'OWNER') + return { membershipId: owner?.membershipId, username: owner?.username, memberType: owner?.memberType, status: owner?.status } + }) +} + +async function loginIdentifierModule() { + const staleToken = merchantToken + await apiStep('login-identifiers', 'change username with current-password verification', { + method: 'PUT', path: '/api/merchant/v1/iam/profile/login-identifiers', token: merchantToken, + body: { username: temporaryUsername, phone: legalPhone, currentPassword: clientPassword }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.username, temporaryUsername, 'updated username') + assertEqual(json.data?.phone, legalPhone, 'updated phone') + }, ({ json }) => ({ username: json.data.username, phone: json.data.phone })) + + await apiStep('login-identifiers', 'identifier change revokes the previous access token', { + method: 'GET', path: '/api/merchant/v1/iam/profile', token: staleToken, + }, ({ status, json }) => { + assertEqual(status, 401, 'HTTP status') + assertTrue(String(json?.code || '').length > 0, 'authentication error code must be present') + }, ({ status, json }) => ({ status, rejectionCode: json?.code })) + + const renamedLogin = await apiStep('login-identifiers', 'login with the changed username', { + method: 'POST', path: '/api/auth/login', body: { loginId: temporaryUsername, password: clientPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'accessToken') + }, () => ({ username: temporaryUsername })) + merchantToken = renamedLogin.json.data.accessToken + merchantRefreshToken = renamedLogin.json.data.refreshToken + + await apiStep('login-identifiers', 'restore the original username for the remaining acceptance flow', { + method: 'PUT', path: '/api/merchant/v1/iam/profile/login-identifiers', token: merchantToken, + body: { username, phone: legalPhone, currentPassword: clientPassword }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.username, username, 'restored username') + }, ({ json }) => ({ username: json.data.username })) + + const restoredLogin = await apiStep('login-identifiers', 'login after restoring the original username', { + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'accessToken') + }, () => ({ username })) + merchantToken = restoredLogin.json.data.accessToken + merchantRefreshToken = restoredLogin.json.data.refreshToken +} + +async function merchantAfterApprovalModule() { + const login = await apiStep('merchant-session', 'client re-login after approval', { + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'accessToken') + }, () => ({ username })) + merchantToken = login.json.data.accessToken + merchantRefreshToken = login.json.data.refreshToken + + await apiStep('merchant-session', 'profile is active after approval', { + method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'ACTIVE', 'profile status') + assertEqual(json.data?.unifiedSocialCreditCode, creditCode, 'unified social credit code') + }, ({ json }) => ({ id: json.data.id, status: json.data.status, tenantName: json.data.tenantName })) + + const approvedQualifications = await apiStep('merchant-session', 'required qualifications are approved', { + method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertQualificationStatuses(json.data, ['BUSINESS_LICENSE'], 'APPROVED') + assertTrue(!arrayData(json.data).some((item) => item.qualificationType === 'LEGAL_PERSON_ID'), + 'approved enterprise must not gain a synthetic legal person ID record') + assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'APPROVED') + }, ({ json }) => ({ qualifications: qualificationEvidence(json.data) })) + + const businessLicense = arrayData(approvedQualifications.json.data) + .find((item) => item.qualificationType === 'BUSINESS_LICENSE') + assertPresent(businessLicense?.documentUrl, 'business license documentUrl') + report.resources.businessLicenseQualificationId = businessLicense.id ?? businessLicense.qualificationId + report.resources.businessLicenseDocumentUrl = businessLicense.documentUrl + await apiStep('merchant-session', 'preview approved qualification file', { + method: 'GET', path: apiPath(businessLicense.documentUrl), token: merchantToken, responseType: 'bytes', + }, ({ status, bytes, headers }) => { + assertEqual(status, 200, 'HTTP status') + assertTrue(bytes?.length > 0, 'qualification preview must not be empty') + assertTrue((headers.get('content-type') ?? '').startsWith('image/'), 'qualification preview must return an image') + }, ({ bytes, headers }) => ({ sizeBytes: bytes.length, contentType: headers.get('content-type') })) +} + +async function profileManagementModule() { + const tenantProfile = await apiStep('profile-management', 'load enterprise profile for editing', { + method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.id), String(report.resources.tenantId), 'enterprise id') + }, ({ json }) => ({ id: json.data.id, tenantName: json.data.tenantName, status: json.data.status })) + + const editedTenantName = `${companyName}-已编辑`.slice(0, 128) + const tenantUpdate = { + tenantName: editedTenantName, + legalName: tenantProfile.json.data.legalName || companyName, + legalRepresentative: tenantProfile.json.data.legalRepresentative || '测试法人', + contactName: `企业联系人-${marker}`.slice(0, 64), + contactPhone: legalPhone, + contactEmail: `company_${marker.replaceAll('-', '')}@example.com`, + } + await apiStep('profile-management', 'edit enterprise profile', { + method: 'PUT', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken, body: tenantUpdate, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.tenantName, tenantUpdate.tenantName, 'tenantName') + assertEqual(json.data?.contactName, tenantUpdate.contactName, 'contactName') + assertEqual(json.data?.contactEmail, tenantUpdate.contactEmail, 'contactEmail') + assertEqual(json.data?.status, 'ACTIVE', 'enterprise status') + }, ({ json }) => ({ id: json.data.id, tenantName: json.data.tenantName, contactName: json.data.contactName, status: json.data.status })) + + await apiStep('profile-management', 'enterprise profile edit is persisted', { + method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.tenantName, tenantUpdate.tenantName, 'tenantName') + assertEqual(json.data?.contactEmail, tenantUpdate.contactEmail, 'contactEmail') + }, ({ json }) => ({ id: json.data.id, tenantName: json.data.tenantName, contactEmail: json.data.contactEmail })) + + await softApiStep('profile-validation', 'invalid enterprise contact email is rejected', { + method: 'PUT', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken, + body: { ...tenantUpdate, contactEmail: 'invalid-email' }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0422', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code })) + + await softApiStep('profile-validation', 'blank enterprise address detail is rejected', { + method: 'POST', path: '/api/merchant/v1/iam/tenant/addresses', token: merchantToken, + body: { addressType: 'OPERATING', countryCode: 'CN', detail: ' ', isDefault: true }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0422', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code })) + + const addressBody = { + addressType: 'OPERATING', + countryCode: 'CN', + province: '广东省', + city: '深圳市', + district: '南山区', + detail: `客户端验收地址 ${marker}`, + postalCode: '518000', + isDefault: true, + } + const createdAddress = await apiStep('profile-management', 'add enterprise operating address', { + method: 'POST', path: '/api/merchant/v1/iam/tenant/addresses', token: merchantToken, body: addressBody, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.id, 'enterprise address id') + assertEqual(json.data?.addressType, addressBody.addressType, 'addressType') + assertEqual(json.data?.detail, addressBody.detail, 'address detail') + assertEqual(json.data?.isDefault, true, 'address isDefault') + }, ({ json }) => ({ id: json.data.id, addressType: json.data.addressType, city: json.data.city, isDefault: json.data.isDefault })) + report.resources.enterpriseAddressId = createdAddress.json.data.id + + await apiStep('profile-management', 'enterprise address list contains created address', { + method: 'GET', path: '/api/merchant/v1/iam/tenant/addresses', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + const address = arrayData(json.data).find((item) => String(item.id) === String(report.resources.enterpriseAddressId)) + assertPresent(address, 'created enterprise address') + assertEqual(address.detail, addressBody.detail, 'address detail') + assertEqual(address.postalCode, addressBody.postalCode, 'address postalCode') + }, ({ json }) => ({ addressCount: arrayData(json.data).length, addressId: report.resources.enterpriseAddressId })) + + await apiStep('profile-management', 'platform can read created enterprise address', { + method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/addresses`, token: platformToken, + }, ({ json }) => { + requireSuccess(json) + const address = arrayData(json.data).find((item) => String(item.id) === String(report.resources.enterpriseAddressId)) + assertPresent(address, 'platform enterprise address') + assertEqual(address.detail, addressBody.detail, 'platform address detail') + }, ({ json }) => ({ addressCount: arrayData(json.data).length, addressId: report.resources.enterpriseAddressId })) + + const personal = await apiStep('profile-management', 'load personal profile', { + method: 'GET', path: '/api/merchant/v1/iam/profile', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.username, username, 'personal username') + }, ({ json }) => ({ userId: json.data.userId, username: json.data.username, displayName: json.data.displayName })) + const personalUpdate = { + displayName: `测试企业管理员-${marker}`.slice(0, 64), + phone: legalPhone, + email: `owner_${marker.replaceAll('-', '')}@example.com`, + } + await softApiStep('profile-validation', 'invalid personal email is rejected', { + method: 'PUT', path: '/api/merchant/v1/iam/profile', token: merchantToken, + body: { ...personalUpdate, email: 'invalid-email' }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0422', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code })) + await apiStep('profile-management', 'edit personal profile', { + method: 'PUT', path: '/api/merchant/v1/iam/profile', token: merchantToken, body: personalUpdate, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.userId), String(personal.json.data.userId), 'personal user id') + assertEqual(json.data?.displayName, personalUpdate.displayName, 'displayName') + assertEqual(json.data?.email, personalUpdate.email, 'email') + }, ({ json }) => ({ userId: json.data.userId, username: json.data.username, displayName: json.data.displayName })) + + const relogin = await apiStep('profile-management', 're-login after personal profile token revocation', { + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'accessToken') + }, () => ({ username })) + merchantToken = relogin.json.data.accessToken + merchantRefreshToken = relogin.json.data.refreshToken + + await apiStep('profile-management', 'personal profile edit is persisted', { + method: 'GET', path: '/api/merchant/v1/iam/profile', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.displayName, personalUpdate.displayName, 'displayName') + assertEqual(json.data?.email, personalUpdate.email, 'email') + }, ({ json }) => ({ userId: json.data.userId, username: json.data.username, displayName: json.data.displayName })) +} + +async function rbacAndMemberModule() { + const permissions = await apiStep('rbac-member', 'load merchant permission resources', { + method: 'GET', path: '/api/merchant/v1/iam/permissions', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(arrayData(json.data).length > 0, 'permission resources must not be empty') + }, ({ json }) => ({ permissionCount: arrayData(json.data).length })) + const desiredCodes = ['merchant:order:list', 'merchant:order:view'] + const grantedPermissions = arrayData(permissions.json.data) + .filter((item) => item.status === 'ACTIVE' && desiredCodes.includes(item.permissionCode)) + assertEqual(grantedPermissions.length, desiredCodes.length, 'order permission count') + const permissionIds = grantedPermissions.map((item) => item.id) + const orderListPermissionId = grantedPermissions + .find((item) => item.permissionCode === 'merchant:order:list')?.id + assertPresent(orderListPermissionId, 'merchant:order:list permission id') + + const ownerPermissions = await apiStep('menu-permissions', 'load owner effective permissions', { + method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + const values = arrayData(json.data) + for (const code of requiredOwnerBackendPermissions()) { + assertTrue(values.includes(code), `owner permission is missing: ${code}`) + } + }, ({ json }) => ({ + backendPermissionCount: arrayData(json.data).length, + visibleClientMenus: visibleClientMenus(arrayData(json.data)), + })) + assertSetEqual( + visibleClientMenus(arrayData(ownerPermissions.json.data)), + ['account:profile:view', 'company:member:list', 'company:permission:list', 'company:profile:view', + 'company:role:list', 'dashboard:view', 'notification:inbox:view', 'order:first-leg:list'], + 'owner visible Client menus', + ) + + await apiStep('rbac-member', 'load initial merchant roles', { + method: 'GET', path: '/api/merchant/v1/iam/roles', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data), 'role list must be an array') + }, ({ json }) => ({ roleCount: arrayData(json.data).length })) + + const roleCode = `E2E_${marker.replaceAll('-', '_')}`.slice(0, 64) + const createdRole = await apiStep('rbac-member', 'create custom role', { + method: 'POST', path: '/api/merchant/v1/iam/roles', token: merchantToken, + body: { roleCode, roleName: `订单只读-${marker}`.slice(0, 64), description: `Client E2E ${marker}` }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.id, 'role id') + assertEqual(json.data?.roleCode, roleCode, 'roleCode') + assertEqual(json.data?.roleType, 'CUSTOM', 'roleType') + }, ({ json }) => ({ roleId: json.data.id, roleCode: json.data.roleCode, roleType: json.data.roleType, status: json.data.status })) + const roleId = createdRole.json.data.id + report.resources.roleId = roleId + + await apiStep('rbac-member', 'grant permissions to custom role', { + method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken, + body: { roleId, permissionIds }, + }, ({ json }) => requireSuccess(json), () => ({ roleId, permissionIds, permissionCodes: desiredCodes })) + + await apiStep('rbac-member', 'custom role permission detail is persisted', { + method: 'GET', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertSetEqual(arrayData(json.data?.permissionIds).map(String), permissionIds.map(String), 'role permissionIds') + }, ({ json }) => ({ roleId: json.data.id, roleName: json.data.roleName, permissionIds: json.data.permissionIds })) + + await apiStep('rbac-member', 'edit custom role', { + method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken, + body: { roleName: `订单查询员-${marker}`.slice(0, 64), description: `edited ${marker}` }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.description, `edited ${marker}`, 'role description') + }, ({ json }) => ({ roleId: json.data.id, roleName: json.data.roleName, description: json.data.description })) + + await apiStep('rbac-member', 'disable custom role', { + method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'DISABLED' }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'DISABLED', 'role status') + }, ({ json }) => ({ roleId: json.data.id, status: json.data.status })) + await apiStep('rbac-member', 'enable custom role', { + method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'ACTIVE' }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'ACTIVE', 'role status') + }, ({ json }) => ({ roleId: json.data.id, status: json.data.status })) + + const memberUsername = `member_${compactTimestamp(new Date())}_${randomUUID().slice(0, 5)}`.slice(0, 64) + const memberPassword = `Member${randomUUID().replaceAll('-', '').slice(0, 14)}8!` + const resetMemberPassword = `Reset${randomUUID().replaceAll('-', '').slice(0, 15)}7!` + const memberBody = { + username: memberUsername, + password: memberPassword, + displayName: `测试订单成员-${marker}`.slice(0, 64), + phone: `137${`${Date.now() + 1}`.slice(-8)}`, + email: `${memberUsername}@example.com`, + memberType: 'EMPLOYEE', + memberNo: `EMP-${marker}`.slice(0, 64), + roleIds: [roleId], + } + await softApiStep('member-validation', 'member with weak password and invalid email is rejected', { + method: 'POST', path: '/api/merchant/v1/iam/members', token: merchantToken, + body: { ...memberBody, username: `bad_${randomUUID().slice(0, 8)}`, password: 'password', email: 'invalid-email' }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0422', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code })) + const createdMember = await apiStep('rbac-member', 'create enterprise member with role', { + method: 'POST', path: '/api/merchant/v1/iam/members', token: merchantToken, body: memberBody, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.membershipId, 'membership id') + assertEqual(json.data?.memberType, 'EMPLOYEE', 'memberType') + assertSetEqual(arrayData(json.data?.roleIds).map(String), [String(roleId)], 'member roleIds') + }, ({ json }) => ({ membershipId: json.data.membershipId, memberType: json.data.memberType, status: json.data.status, roleIds: json.data.roleIds })) + const membershipId = createdMember.json.data.membershipId + report.resources.testMembershipId = membershipId + + await apiStep('rbac-member', 'enterprise member list contains created member and roles', { + method: 'GET', path: '/api/merchant/v1/iam/members', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + const member = arrayData(json.data).find((item) => String(item.membershipId) === String(membershipId)) + assertPresent(member, 'created member list row') + assertEqual(member.displayName, memberBody.displayName, 'member displayName') + assertEqual(member.status, 'ACTIVE', 'member status') + assertSetEqual(arrayData(member.roleIds).map(String), [String(roleId)], 'member list roleIds') + }, ({ json }) => ({ memberCount: arrayData(json.data).length, membershipId })) + + await apiStep('rbac-member', 'enterprise member detail matches created fields and roles', { + method: 'GET', path: `/api/merchant/v1/iam/members/${membershipId}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.membershipId), String(membershipId), 'membership id') + assertEqual(json.data?.displayName, memberBody.displayName, 'member displayName') + assertEqual(json.data?.phone, memberBody.phone, 'member phone') + assertEqual(json.data?.email, memberBody.email, 'member email') + assertEqual(json.data?.memberNo, memberBody.memberNo, 'memberNo') + assertSetEqual(arrayData(json.data?.roleIds).map(String), [String(roleId)], 'member detail roleIds') + }, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status, roleIds: json.data.roleIds })) + + await apiStep('rbac-member', 'duplicate member username is rejected', { + method: 'POST', path: '/api/merchant/v1/iam/members', token: merchantToken, body: memberBody, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0409', 'business code') + }, ({ json }) => ({ username: memberUsername, rejectionCode: json.code })) + await apiStep('rbac-member', 'assigned custom role cannot be deleted', { + method: 'DELETE', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0409', 'business code') + }, ({ json }) => ({ roleId, rejectionCode: json.code })) + await apiStep('rbac-member', 'enterprise owner cannot be disabled', { + method: 'PUT', path: `/api/merchant/v1/iam/members/${report.resources.membershipId}/status`, token: merchantToken, + body: { status: 'DISABLED' }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0403', 'business code') + }, ({ json }) => ({ membershipId: report.resources.membershipId, rejectionCode: json.code })) + await apiStep('rbac-member', 'enterprise owner cannot be edited through member API', { + method: 'PUT', path: `/api/merchant/v1/iam/members/${report.resources.membershipId}`, token: merchantToken, + body: { + displayName: '非法修改企业所有者', phone: legalPhone, email: `${username}@example.com`, + memberType: 'OWNER', memberNo: 'OWNER', roleIds: [], + }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0403', 'business code') + }, ({ json }) => ({ membershipId: report.resources.membershipId, rejectionCode: json.code })) + + const memberLogin = await apiStep('rbac-member', 'new enterprise member can login', { + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'member accessToken') + }, () => ({ username: memberUsername })) + let memberToken = memberLogin.json.data.accessToken + + await apiStep('rbac-member', 'member can use granted order list permission', { + method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken, + }, ({ json }) => requireSuccess(json), ({ json }) => ({ total: json.data?.total })) + await apiStep('rbac-member', 'member can view order with granted permission', { + method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.order?.id), String(report.resources.orderIds[0]), 'order id') + }, ({ json }) => ({ orderId: json.data.order.id, status: json.data.order.status })) + + await apiStep('menu-permissions', 'read-only member effective permissions match role', { + method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken, + }, ({ json }) => { + requireSuccess(json) + assertSetEqual(arrayData(json.data), desiredCodes, 'member backend permissions') + assertSetEqual( + visibleClientMenus(arrayData(json.data)), + ['account:profile:view', 'dashboard:view', 'order:first-leg:list'], + 'member visible Client menus', + ) + }, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) })) + + const disabledRole = await softApiStep('menu-permissions', 'disable assigned role', { + method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'DISABLED' }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'DISABLED', 'role status') + }, ({ json }) => ({ roleId: json.data.id, status: json.data.status })) + if (disabledRole) { + await softApiStep('menu-permissions', 'disabled role revokes stale token permission query', { + method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken, + }, ({ json, status }) => { + assertEqual(status, 401, 'HTTP status') + assertEqual(String(json?.code), 'A0401', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message })) + await softApiStep('menu-permissions', 'disabled role blocks stale token order access', { + method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken, + }, ({ json, status }) => { + assertTrue([200, 401, 403].includes(status), `unexpected HTTP status ${status}`) + assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`) + }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message })) + + const disabledRoleLogin = await apiStep('menu-permissions', 'member login succeeds while role is disabled', { + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'member accessToken') + }, () => ({ username: memberUsername })) + memberToken = disabledRoleLogin.json.data.accessToken + await apiStep('menu-permissions', 'disabled role removes member menus and permissions', { + method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(arrayData(json.data).length, 0, 'effective backend permission count') + assertSetEqual(visibleClientMenus(arrayData(json.data)), ['account:profile:view', 'dashboard:view'], 'visible Client menus') + }, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) })) + await softApiStep('menu-permissions', 'disabled role blocks member order list', { + method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken, + }, ({ json, status }) => { + assertTrue([200, 403].includes(status), `unexpected HTTP status ${status}`) + assertEqual(String(json?.code), 'A0403', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code })) + + await apiStep('menu-permissions', 're-enable assigned role', { + method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'ACTIVE' }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'ACTIVE', 'role status') + }, ({ json }) => ({ roleId: json.data.id, status: json.data.status })) + const enabledRoleLogin = await apiStep('menu-permissions', 'member re-login after role is enabled', { + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'member accessToken') + }, () => ({ username: memberUsername })) + memberToken = enabledRoleLogin.json.data.accessToken + await apiStep('menu-permissions', 're-enabled role restores member menus', { + method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken, + }, ({ json }) => { + requireSuccess(json) + assertSetEqual(arrayData(json.data), desiredCodes, 'member backend permissions') + assertSetEqual(visibleClientMenus(arrayData(json.data)), ['account:profile:view', 'dashboard:view', 'order:first-leg:list'], 'visible Client menus') + }, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) })) + } else { + recordSkipped('menu-permissions', 'role disable dependent authorization checks', + 'Role status change failed; dependent checks require a successfully disabled role') + } + + const reducedPermissions = await softApiStep('menu-permissions', 'remove order view permission from assigned role', { + method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken, + body: { roleId, permissionIds: [orderListPermissionId] }, + }, ({ json }) => requireSuccess(json), () => ({ roleId, permissionCodes: [desiredCodes[0]] })) + if (reducedPermissions) { + await softApiStep('menu-permissions', 'permission removal revokes stale member token', { + method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken, + }, ({ json, status }) => { + assertTrue([200, 401, 403].includes(status), `unexpected HTTP status ${status}`) + assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`) + }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message })) + + const reducedPermissionLogin = await apiStep('menu-permissions', 'member re-login after permission removal', { + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'member accessToken') + }, () => ({ username: memberUsername })) + memberToken = reducedPermissionLogin.json.data.accessToken + await apiStep('menu-permissions', 'permission removal is reflected after re-login', { + method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken, + }, ({ json }) => { + requireSuccess(json) + assertSetEqual(arrayData(json.data), [desiredCodes[0]], 'reduced member permissions') + }, ({ json }) => ({ backendPermissions: json.data })) + await apiStep('menu-permissions', 'member without view permission cannot read order detail', { + method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken, + }, ({ json, status }) => { + assertTrue([200, 403].includes(status), `unexpected HTTP status ${status}`) + assertEqual(String(json?.code), 'A0403', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code })) + + await apiStep('menu-permissions', 'restore assigned role permissions', { + method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken, + body: { roleId, permissionIds }, + }, ({ json }) => requireSuccess(json), () => ({ roleId, permissionCodes: desiredCodes })) + const restoredPermissionLogin = await apiStep('menu-permissions', 'member re-login after permission restoration', { + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'member accessToken') + }, () => ({ username: memberUsername })) + memberToken = restoredPermissionLogin.json.data.accessToken + await apiStep('menu-permissions', 'restored permission allows order detail again', { + method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.order?.id), String(report.resources.orderIds[0]), 'order id') + }, ({ json }) => ({ orderId: json.data.order.id })) + } else { + recordSkipped('menu-permissions', 'permission removal dependent authorization checks', + 'Role permission update failed; dependent checks require a successfully reduced permission set') + } + + const editedMemberBody = { + displayName: `已编辑订单成员-${marker}`.slice(0, 64), + phone: memberBody.phone, + email: `edited_${memberUsername}@example.com`, + memberType: 'EMPLOYEE', memberNo: memberBody.memberNo, roleIds: [roleId], + } + await apiStep('rbac-member', 'edit enterprise member', { + method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}`, token: merchantToken, body: editedMemberBody, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.displayName, editedMemberBody.displayName, 'member displayName') + assertEqual(json.data?.email, editedMemberBody.email, 'member email') + }, ({ json }) => ({ membershipId: json.data.membershipId, displayName: json.data.displayName, status: json.data.status })) + + await apiStep('rbac-member', 'reset enterprise member password', { + method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}/password`, token: merchantToken, + body: { newPassword: resetMemberPassword }, + }, ({ json }) => requireSuccess(json), () => ({ membershipId })) + await apiStep('rbac-member', 'member old password is rejected after reset', { + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0401', 'business code') + }, ({ json }) => ({ username: memberUsername, rejectionCode: json.code })) + const resetLogin = await apiStep('rbac-member', 'member can login with reset password', { + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: resetMemberPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'member accessToken') + }, () => ({ username: memberUsername })) + memberToken = resetLogin.json.data.accessToken + + await apiStep('rbac-member', 'disable enterprise member', { + method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}/status`, token: merchantToken, body: { status: 'DISABLED' }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'DISABLED', 'member status') + }, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status })) + await softApiStep('rbac-member', 'disabled member stale token is rejected', { + method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken, + }, ({ json, status }) => { + assertTrue([200, 401, 403].includes(status), `unexpected HTTP status ${status}`) + assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`) + }, ({ json }) => ({ membershipId, rejectionCode: json?.code })) + await apiStep('rbac-member', 'disabled member login is rejected', { + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: resetMemberPassword }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`) + }, ({ json }) => ({ username: memberUsername, rejectionCode: json.code })) + await apiStep('rbac-member', 'enable enterprise member', { + method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}/status`, token: merchantToken, body: { status: 'ACTIVE' }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'ACTIVE', 'member status') + }, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status })) + + const enabledMemberLogin = await apiStep('rbac-member', 'member re-login after account is enabled', { + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: resetMemberPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'member accessToken') + }, () => ({ username: memberUsername })) + memberToken = enabledMemberLogin.json.data.accessToken + await apiStep('rbac-member', 're-enabled member can list orders before roles are cleared', { + method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken, + }, ({ json }) => requireSuccess(json), ({ json }) => ({ total: json.data?.total })) + + await apiStep('rbac-member', 'clear enterprise member roles', { + method: 'PUT', path: '/api/merchant/v1/iam/members/roles', token: merchantToken, + body: { membershipId, roleIds: [] }, + }, ({ json }) => requireSuccess(json), () => ({ membershipId, roleIds: [] })) + await apiStep('rbac-member', 'member role clearing is persisted', { + method: 'GET', path: `/api/merchant/v1/iam/members/${membershipId}/roles`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.membershipId), String(membershipId), 'membership id') + assertEqual(arrayData(json.data?.roleIds).length, 0, 'member role count') + }, ({ json }) => ({ membershipId: json.data.membershipId, roleIds: json.data.roleIds })) + await softApiStep('rbac-member', 'role clearing revokes stale member token', { + method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken, + }, ({ json, status }) => { + assertEqual(status, 401, 'HTTP status') + assertEqual(String(json?.code), 'A0401', 'business code') + }, ({ json }) => ({ rejectionCode: json.code })) + const noRoleLogin = await apiStep('rbac-member', 'member re-login after role clearing', { + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: resetMemberPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'member accessToken') + }, () => ({ username: memberUsername })) + memberToken = noRoleLogin.json.data.accessToken + await apiStep('rbac-member', 'member without roles cannot list orders', { + method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken, + }, ({ json, status }) => { + assertTrue([200, 403].includes(status), `unexpected HTTP status ${status}`) + assertEqual(String(json?.code), 'A0403', 'business code') + }, ({ json }) => ({ rejectionCode: json.code })) + + await apiStep('rbac-member', 'disable test member after verification', { + method: 'PUT', path: `/api/merchant/v1/iam/members/${membershipId}/status`, token: merchantToken, body: { status: 'DISABLED' }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'DISABLED', 'member status') + }, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status })) + await apiStep('rbac-member', 'delete unassigned custom role', { + method: 'DELETE', path: `/api/merchant/v1/iam/roles/${roleId}`, token: merchantToken, + }, ({ json }) => requireSuccess(json), () => ({ roleId })) + report.resources.roleId = null +} + +async function orderLifecycleModule() { + const masterData = await loadOrderMasterData(merchantToken, 'order-master-data') + await estimateAvailablePrice(masterData) + const productImageFileId = await uploadProductImage(merchantToken, 'order-product-image') + report.resources.productImageFileId = productImageFileId + + const invalidImageForm = new FormData() + invalidImageForm.append('file', new Blob(['not-an-image'], { type: 'text/plain' }), `invalid-product-${marker}.txt`) + await softApiStep('order-product-image', 'invalid product image MIME type is rejected', { + method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: invalidImageForm, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0400', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message })) + + const emptyImageForm = new FormData() + emptyImageForm.append('file', new Blob([], { type: 'image/png' }), `empty-product-${marker}.png`) + await softApiStep('order-product-image', 'empty product image is rejected', { + method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: emptyImageForm, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0400', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message })) + + const forgedImageForm = new FormData() + forgedImageForm.append('file', new Blob(['not-a-real-png'], { type: 'image/png' }), `forged-product-${marker}.png`) + await softApiStep('order-product-image', 'forged product image signature is rejected', { + method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: forgedImageForm, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0400', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message })) + + const oversizedImageForm = new FormData() + oversizedImageForm.append('file', new Blob([Buffer.alloc(5 * 1024 * 1024 + 1)], { type: 'image/png' }), `oversized-product-${marker}.png`) + await softApiStep('order-product-image', 'product image over 5MB is rejected', { + method: 'POST', path: '/api/merchant/v1/files/product-images', token: merchantToken, body: oversizedImageForm, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0400', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message })) + + await apiStep('order-lifecycle', 'download own product image', { + method: 'GET', path: `/api/merchant/v1/files/product-images/${productImageFileId}`, token: merchantToken, + responseType: 'bytes', + }, ({ status, bytes, headers }) => { + assertEqual(status, 200, 'HTTP status') + assertTrue(bytes?.length > 0, 'downloaded image must not be empty') + assertTrue((headers.get('content-type') ?? '').startsWith('image/'), 'download must return an image') + }, ({ bytes, headers }) => ({ sizeBytes: bytes.length, contentType: headers.get('content-type') })) + + const payload = buildOrderPayload(masterData, productImageFileId, 'ACTIVE', 'fresh draft after approval') + const incompleteSubmitCases = [ + { + name: 'FBA order missing Amazon identifiers', + mutate(value) { + value.amazonWarehouseCode = null + value.shipmentId = null + value.referenceId = null + }, + }, + { + name: 'FBA cargo missing SKU UPC shipment and reference', + mutate(value) { + value.cargoes[0].skuNo = null + value.cargoes[0].upcNo = null + value.cargoes[0].shipmentId = null + value.cargoes[0].referenceId = null + }, + }, + ] + for (const testCase of incompleteSubmitCases) { + const incompletePayload = structuredClone(payload) + testCase.mutate(incompletePayload) + const draft = await apiStep('order-validation', `${testCase.name} can be saved as a draft`, { + method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken, + headers: { 'Idempotency-Key': newIdempotencyKey('incomplete-draft') }, body: incompletePayload, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data, 'incomplete draft order id') + }, ({ json }) => ({ orderId: json.data, status: 'DRAFT' })) + report.resources.orderIds.push(draft.json.data) + await softApiStep('order-validation', `${testCase.name} is rejected on submit`, { + method: 'POST', path: `/api/merchant/v1/orders/first-leg/${draft.json.data}/submit`, token: merchantToken, + headers: { 'Idempotency-Key': newIdempotencyKey('incomplete-submit') }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0422', 'business code') + }, ({ json }) => ({ orderId: draft.json.data, rejectionCode: json?.code, message: json?.message })) + } + const invalidOrderCases = [ + { + name: 'PICKUP order without a selected pickup address is rejected', + mutate(value) { + value.pickupMethod = 'PICKUP' + value.requestedPickupAt = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19) + value.sender = null + value.senderAddressId = null + }, + }, + { + name: 'PRIVATE destination without receiver is rejected', + mutate(value) { + value.destinationType = 'PRIVATE' + value.receiver = null + }, + }, + { + name: 'invalid product image file ID is rejected', + mutate(value) { value.cargoes[0].productImageFileId = -1 }, + }, + { + name: 'zero package count weight and volume are rejected', + mutate(value) { + value.cargoes[0].packageCount = 0 + value.cargoes[0].grossWeightKg = 0 + value.cargoes[0].volumeCbm = 0 + }, + }, + { + name: 'customer remark over 1000 characters is rejected', + mutate(value) { value.customerRemark = 'x'.repeat(1001) }, + }, + ] + for (const testCase of invalidOrderCases) { + const invalidPayload = structuredClone(payload) + testCase.mutate(invalidPayload) + await softApiStep('order-validation', testCase.name, { + method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken, + headers: { 'Idempotency-Key': newIdempotencyKey('invalid-order') }, body: invalidPayload, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0422', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message })) + } + await softApiStep('order-validation', 'missing Idempotency-Key is rejected', { + method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken, body: payload, + }, ({ json, status }) => { + assertTrue([200, 400].includes(status), `unexpected HTTP status ${status}`) + assertEqual(String(json?.code), 'A0400', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message })) + await softApiStep('order-validation', 'Idempotency-Key over 128 characters is rejected', { + method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken, + headers: { 'Idempotency-Key': 'x'.repeat(129) }, body: payload, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0422', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message })) + const createKey = newIdempotencyKey('approved-create') + const created = await apiStep('order-lifecycle', 'create valid draft after approval', { + method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken, + headers: { 'Idempotency-Key': createKey }, body: payload, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data, 'order id') + }, ({ json }) => ({ orderId: json.data })) + const orderId = created.json.data + report.resources.orderIds.push(orderId) + + await apiStep('order-lifecycle', 'create order idempotent replay', { + method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken, + headers: { 'Idempotency-Key': createKey }, body: payload, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data), String(orderId), 'replayed order id') + }, ({ json }) => ({ orderId: json.data, replayed: true })) + + const conflictingPayload = structuredClone(payload) + conflictingPayload.customerRemark = `idempotency-conflict-${marker}` + await softApiStep('order-boundary', 'same idempotency key with different payload is rejected', { + method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken, + headers: { 'Idempotency-Key': createKey }, body: conflictingPayload, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0409', 'business code') + }, ({ json }) => ({ orderId, rejectionCode: json.code })) + + const editedPayload = structuredClone(payload) + editedPayload.customerRemark = `edited-${marker}` + editedPayload.cargoes[0].cargoRemark = `edited-cargo-${marker}` + editedPayload.cargoes[0].model = `MODEL-EDIT-${marker}`.slice(0, 64) + await apiStep('order-lifecycle', 'edit draft', { + method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, body: editedPayload, + }, ({ json }) => requireSuccess(json), () => ({ orderId, customerRemark: editedPayload.customerRemark })) + + const detail = await apiStep('order-lifecycle', 'order detail preserves complete product fields', { + method: 'GET', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.order?.id), String(orderId), 'order id') + assertEqual(json.data?.order?.status, 'DRAFT', 'order status') + assertEqual(json.data?.order?.customerRemark, editedPayload.customerRemark, 'customer remark') + const cargo = arrayData(json.data?.cargoes)[0] + assertCargo(cargo, editedPayload.cargoes[0]) + }, ({ json }) => ({ + orderId: json.data.order.id, + orderNo: json.data.order.orderNo, + status: json.data.order.status, + cargo: cargoEvidence(json.data.cargoes[0]), + })) + const orderNo = detail.json.data.order.orderNo + + await apiStep('order-lifecycle', 'order list contains edited draft', { + method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&orderNo=${encodeURIComponent(orderNo)}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + const row = pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)) + assertPresent(row, 'order list row') + assertEqual(row.status, 'DRAFT', 'list order status') + assertEqual(row.referenceId, editedPayload.referenceId, 'list referenceId') + assertEqual(row.remark, editedPayload.customerRemark, 'list remark') + }, ({ json }) => { + const row = pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)) + return { total: json.data?.total, orderId: row?.orderId, orderNo: row?.orderNo, status: row?.status } + }) + + await apiStep('order-filters', 'order keyword filter finds draft by order number', { + method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&keyword=${encodeURIComponent(orderNo)}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'keyword-filtered order') + }, ({ json }) => ({ total: json.data?.total, orderId })) + + await apiStep('order-filters', 'DRAFT status filter contains draft', { + method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=50&status=DRAFT', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'draft status-filtered order') + }, ({ json }) => ({ total: json.data?.total, orderId, status: 'DRAFT' })) + + const submitKey = newIdempotencyKey('approved-submit') + await apiStep('order-lifecycle', 'submit order', { + method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken, + headers: { 'Idempotency-Key': submitKey }, + }, ({ json }) => requireSuccess(json), () => ({ orderId })) + + await apiStep('order-lifecycle', 'submit order idempotent replay', { + method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken, + headers: { 'Idempotency-Key': submitKey }, + }, ({ json }) => requireSuccess(json), () => ({ orderId, replayed: true })) + + await apiStep('order-lifecycle', 'submitted status is persisted', { + method: 'GET', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.order?.status, 'SUBMITTED', 'order status') + }, ({ json }) => ({ orderId, orderNo: json.data.order.orderNo, status: json.data.order.status })) + + await apiStep('order-filters', 'SUBMITTED status filter contains submitted order', { + method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=50&status=SUBMITTED', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'submitted status-filtered order') + }, ({ json }) => ({ total: json.data?.total, orderId, status: 'SUBMITTED' })) + + await apiStep('order-filters', 'warehouse channel and destination filters contain submitted order', { + method: 'GET', + path: `/api/merchant/v1/orders/first-leg?page=1&size=50&receivingWarehouseId=${payload.receivingWarehouseId}` + + `&channelName=${encodeURIComponent(payload.channelName)}&destinationType=${payload.destinationType}` + + `&destinationWarehouseId=${payload.destinationWarehouseId}`, + token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'combined-filtered order') + }, ({ json }) => ({ total: json.data?.total, orderId })) + await apiStep('order-filters', 'customer contact filter contains submitted order by customer name', { + method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&customerContact=${encodeURIComponent(payload.customerName)}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'customer-contact-filtered order') + }, ({ json }) => ({ total: json.data?.total, orderId })) + await apiStep('order-filters', 'unmatched keyword returns an empty order page', { + method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&keyword=${randomUUID()}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(pageRecords(json.data).length, 0, 'unmatched order count') + }, ({ json }) => ({ total: json.data?.total })) + await softApiStep('order-validation', 'invalid order status filter is rejected', { + method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20&status=UNKNOWN', token: merchantToken, + }, ({ json, status }) => { + assertTrue([200, 400].includes(status), `unexpected HTTP status ${status}`) + assertEqual(String(json?.code), 'A0422', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message })) + await apiStep('order-boundary', 'unknown order detail is rejected', { + method: 'GET', path: '/api/merchant/v1/orders/first-leg/1', token: merchantToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0404', 'business code') + }, ({ json }) => ({ orderId: 1, rejectionCode: json?.code })) + + await softApiStep('order-boundary', 'submitted order cannot be edited by client', { + method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, body: editedPayload, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0409', 'business code') + }, ({ json }) => ({ orderId, rejectionCode: json.code, message: json.message })) + + await softApiStep('unified-orders', 'unified order list contains submitted order', { + method: 'GET', path: `/api/merchant/v1/orders?page=1&size=50&orderNo=${encodeURIComponent(orderNo)}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + const row = pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)) + assertPresent(row, 'unified order row') + assertEqual(row.mainStatus, 'PENDING_REVIEW', 'unified mainStatus') + }, ({ json }) => { + const row = pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)) + return { total: json.data?.total, orderId: row?.orderId, mainStatus: row?.mainStatus, internalOrderStatus: row?.internalOrderStatus } + }) + await softApiStep('unified-orders', 'unified order detail returns timeline', { + method: 'GET', path: `/api/merchant/v1/orders/${orderId}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.summary?.orderId), String(orderId), 'unified order id') + assertTrue(Array.isArray(json.data?.timeline), 'unified timeline must be an array') + }, ({ json }) => ({ orderId: json.data?.summary?.orderId, mainStatus: json.data?.summary?.mainStatus, timelineCount: arrayData(json.data?.timeline).length })) + + const cancelPayload = buildOrderPayload(masterData, productImageFileId, 'CANCEL', 'draft created for cancellation') + const cancelDraft = await apiStep('order-lifecycle', 'create second draft for cancellation', { + method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken, + headers: { 'Idempotency-Key': newIdempotencyKey('cancel-create') }, body: cancelPayload, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data, 'order id') + }, ({ json }) => ({ orderId: json.data })) + const cancelOrderId = cancelDraft.json.data + report.resources.orderIds.push(cancelOrderId) + + await apiStep('order-lifecycle', 'cancel second draft', { + method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, token: merchantToken, + body: { reason: `客户端自动化取消 ${marker}` }, + }, ({ json }) => requireSuccess(json), () => ({ orderId: cancelOrderId })) + + await apiStep('order-lifecycle', 'cancelled status is persisted', { + method: 'GET', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.order?.status, 'CANCELLED', 'order status') + }, ({ json }) => ({ orderId: cancelOrderId, status: json.data.order.status })) + + await softApiStep('order-boundary', 'cancelled order cannot be cancelled again', { + method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, token: merchantToken, + body: { reason: `重复取消 ${marker}` }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0409', 'business code') + }, ({ json }) => ({ orderId: cancelOrderId, rejectionCode: json.code, message: json.message })) + await softApiStep('order-boundary', 'cancelled order cannot be submitted', { + method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/submit`, token: merchantToken, + headers: { 'Idempotency-Key': newIdempotencyKey('cancelled-submit') }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0409', 'business code') + }, ({ json }) => ({ orderId: cancelOrderId, rejectionCode: json.code, message: json.message })) + await softApiStep('order-boundary', 'invalid order page size is rejected', { + method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=201', token: merchantToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0422', 'business code') + }, ({ json }) => ({ rejectionCode: json.code })) +} + +async function isolationModule() { + if (!existingClientUsername || !existingClientPassword) { + recordSkipped('isolation', 'cross-client order access is rejected', + 'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check') + return + } + const login = await apiStep('isolation', 'existing client login', { + method: 'POST', path: '/api/auth/login', body: { loginId: existingClientUsername, password: existingClientPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'accessToken') + }, () => ({ username: existingClientUsername })) + existingClientToken = login.json.data.accessToken + const orderId = report.resources.orderIds[1] + await apiStep('isolation', 'cross-client order access is rejected', { + method: 'GET', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: existingClientToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`) + }, ({ json }) => ({ orderId, rejectionCode: json.code })) + await softApiStep('isolation', 'cross-client product image access is rejected', { + method: 'GET', path: `/api/merchant/v1/files/product-images/${report.resources.productImageFileId}`, + token: existingClientToken, + }, ({ json, status }) => { + assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`) + }, ({ json }) => ({ fileId: report.resources.productImageFileId, rejectionCode: json?.code })) + await softApiStep('isolation', 'cross-client member detail access is rejected', { + method: 'GET', path: `/api/merchant/v1/iam/members/${report.resources.testMembershipId}`, token: existingClientToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`) + }, ({ json }) => ({ membershipId: report.resources.testMembershipId, rejectionCode: json?.code })) + await apiStep('isolation', 'existing client address list excludes new enterprise address', { + method: 'GET', path: '/api/merchant/v1/iam/tenant/addresses', token: existingClientToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(!arrayData(json.data).some((item) => String(item.id) === String(report.resources.enterpriseAddressId)), + 'cross-client address must not be visible') + }, ({ json }) => ({ addressCount: arrayData(json.data).length, excludedAddressId: report.resources.enterpriseAddressId })) + await apiStep('isolation', 'existing client qualification list excludes new enterprise qualification', { + method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: existingClientToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(!arrayData(json.data).some((item) => String(item.id ?? item.qualificationId) + === String(report.resources.businessLicenseQualificationId)), 'cross-client qualification must not be visible') + }, ({ json }) => ({ qualificationCount: arrayData(json.data).length, + excludedQualificationId: report.resources.businessLicenseQualificationId })) + await softApiStep('isolation', 'cross-client qualification file access is rejected', { + method: 'GET', path: apiPath(report.resources.businessLicenseDocumentUrl), token: existingClientToken, + }, ({ json, status }) => { + assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`) + }, ({ json }) => ({ qualificationId: report.resources.businessLicenseQualificationId, rejectionCode: json?.code })) +} + +async function notificationInboxModule() { + const module = 'notification-inbox' + const templateBase = '/api/platform/v1/notification-templates' + const inboxBase = '/api/merchant/v1/notifications' + const suffix = marker.replace(/[^0-9A-Za-z]/g, '').slice(-24).toUpperCase() + const templateCode = `CLIENT_INBOX_${suffix}`.slice(0, 64) + const category = 'CLIENT_ACCEPTANCE' + let template = null + + try { + const createTemplate = await apiStep(module, 'platform creates client inbox template', { + method: 'POST', path: templateBase, token: platformToken, + body: { + templateCode, + templateName: `客户消息闭环-${marker}`.slice(0, 128), + category, + titleTemplate: '客户消息验收 {{sequence}}', + bodyTemplate: '客户 {{marker}} 的第 {{sequence}} 条真实站内消息。', + variableWhitelist: ['marker', 'sequence'], + }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.id, 'notification template id') + assertEqual(json.data?.status, 'DRAFT', 'notification template status') + assertEqual(json.data?.channel, 'IN_APP', 'notification template channel') + }, ({ json }) => ({ templateId: json.data.id, templateCode: json.data.templateCode, status: json.data.status })) + template = createTemplate.json.data + report.resources.notificationTemplateId = template.id + + const activateTemplate = await apiStep(module, 'platform activates client inbox template', { + method: 'PUT', path: `${templateBase}/${template.id}/status`, token: platformToken, + body: { targetStatus: 'ACTIVE', version: template.version }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'ACTIVE', 'notification template status') + }, ({ json }) => ({ templateId: json.data.id, status: json.data.status, version: json.data.version })) + template = activateTemplate.json.data + + await apiStep(module, 'client normalizes existing inbox messages to read', { + method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Number(json.data?.updatedCount) >= 0, 'updatedCount must be non-negative') + }, ({ json }) => ({ updatedCount: json.data.updatedCount })) + + await apiStep(module, 'client unread baseline is zero', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 0, 'unread baseline') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + + const sendMessage = async (sequence) => apiStep(module, `platform sends client message ${sequence}`, { + method: 'POST', path: '/api/platform/v1/notifications', token: platformToken, + body: { + tenantId: String(report.resources.tenantId), + templateCode, + recipientType: 'MEMBERSHIP', + recipientIds: [String(report.resources.membershipId)], + variables: { marker, sequence: String(sequence) }, + scheduledAt: null, + idempotencyKey: newIdempotencyKey(`client-notification-${sequence}`), + sourceEventId: `client-e2e-notification-${sequence}-${marker}`.slice(0, 128), + }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'SENT', 'notification task status') + assertEqual(Number(json.data?.recipientCount), 1, 'notification recipient count') + }, ({ json }) => ({ taskId: json.data.id, taskNo: json.data.taskNo, status: json.data.status, recipientCount: json.data.recipientCount })) + + await sendMessage(1) + + await apiStep(module, 'client unread count increments after first delivery', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 1, 'unread count after first delivery') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + + const firstUnreadPage = await apiStep(module, 'client filters first message as unread', { + method: 'GET', path: `${inboxBase}?page=1&size=20&status=UNREAD&category=${category}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + const message = pageRecords(json.data).find((item) => item.title === `客户消息验收 1`) + assertPresent(message?.id, 'first unread notification id') + assertEqual(message.status, 'UNREAD', 'first notification status') + }, ({ json }) => ({ total: json.data.total, unreadIds: pageRecords(json.data).map((item) => item.id) })) + const firstMessage = pageRecords(firstUnreadPage.json.data).find((item) => item.title === '客户消息验收 1') + const firstMessageId = firstMessage.id + report.resources.notificationMessageIds.push(firstMessageId) + + await apiStep(module, 'client reads first message detail while it is unread', { + method: 'GET', path: `${inboxBase}/${firstMessageId}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.id), String(firstMessageId), 'first notification id') + assertEqual(json.data?.status, 'UNREAD', 'first notification detail status') + assertTrue(String(json.data?.body).includes(marker), 'first notification body must contain marker') + }, ({ json }) => ({ messageId: json.data.id, status: json.data.status, category: json.data.category })) + + if (existingClientToken) { + await apiStep(module, 'other client cannot read first message detail', { + method: 'GET', path: `${inboxBase}/${firstMessageId}`, token: existingClientToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`) + }, ({ json }) => ({ messageId: firstMessageId, rejectionCode: json.code })) + } else { + recordSkipped(module, 'other client cannot read first message detail', 'Cross-client credentials were not supplied') + } + + await apiStep(module, 'client marks first message read', { + method: 'PUT', path: `${inboxBase}/${firstMessageId}/read`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'READ', 'first notification status after read') + assertPresent(json.data?.readAt, 'first notification readAt') + }, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt })) + + await apiStep(module, 'single-message read is idempotent', { + method: 'PUT', path: `${inboxBase}/${firstMessageId}/read`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'READ', 'idempotent notification status') + }, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt })) + + await apiStep(module, 'client filters first message as read', { + method: 'GET', path: `${inboxBase}?page=1&size=20&status=READ&category=${category}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(pageRecords(json.data).some((item) => String(item.id) === String(firstMessageId) && item.status === 'READ'), + 'read filter must contain first notification') + }, ({ json }) => ({ total: json.data.total, readIds: pageRecords(json.data).map((item) => item.id) })) + + await apiStep(module, 'client unread count returns to zero after single read', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 0, 'unread count after single read') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + + await sendMessage(2) + const secondUnreadPage = await apiStep(module, 'client receives second unread message', { + method: 'GET', path: `${inboxBase}?page=1&size=20&status=UNREAD&category=${category}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + const message = pageRecords(json.data).find((item) => item.title === '客户消息验收 2') + assertPresent(message?.id, 'second unread notification id') + assertEqual(message.status, 'UNREAD', 'second notification status') + }, ({ json }) => ({ total: json.data.total, unreadIds: pageRecords(json.data).map((item) => item.id) })) + const secondMessage = pageRecords(secondUnreadPage.json.data).find((item) => item.title === '客户消息验收 2') + const secondMessageId = secondMessage.id + report.resources.notificationMessageIds.push(secondMessageId) + + if (existingClientToken) { + await apiStep(module, 'other client cannot see acceptance messages in inbox', { + method: 'GET', path: `${inboxBase}?page=1&size=200&category=${category}`, token: existingClientToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + if (['A0403', 'A0404'].includes(String(json?.code))) return + requireSuccess(json) + const foreignIds = new Set(pageRecords(json.data).map((item) => String(item.id))) + assertTrue(!foreignIds.has(String(firstMessageId)) && !foreignIds.has(String(secondMessageId)), + 'other client inbox must exclude acceptance messages') + }, ({ json }) => ({ rejectionCode: json?.code === '200' ? null : json?.code, + visibleCount: json?.code === '200' ? pageRecords(json.data).length : null, + excludedIds: [firstMessageId, secondMessageId] })) + } else { + recordSkipped(module, 'other client cannot see acceptance messages in inbox', 'Cross-client credentials were not supplied') + } + + await apiStep(module, 'client unread count increments after second delivery', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 1, 'unread count after second delivery') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + + await apiStep(module, 'client marks all messages read', { + method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.updatedCount), 1, 'read-all updatedCount') + }, ({ json }) => ({ updatedCount: json.data.updatedCount })) + + await apiStep(module, 'second message detail is read after read-all', { + method: 'GET', path: `${inboxBase}/${secondMessageId}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'READ', 'second notification status after read-all') + assertPresent(json.data?.readAt, 'second notification readAt') + }, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt })) + + await apiStep(module, 'client unread count is zero after read-all', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 0, 'unread count after read-all') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + } finally { + if (merchantToken && report.resources.notificationMessageIds.length > 0) { + await softApiStep(module, 'cleanup keeps acceptance messages read', { + method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Number(json.data?.updatedCount) >= 0, 'cleanup updatedCount must be non-negative') + }, ({ json }) => ({ updatedCount: json?.data?.updatedCount })) + await softApiStep(module, 'cleanup leaves no unread acceptance messages', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 0, 'cleanup unread count') + }, ({ json }) => ({ unreadCount: json?.data?.unreadCount })) + } + if (template?.status === 'ACTIVE') { + const disabled = await softApiStep(module, 'platform disables client inbox template', { + method: 'PUT', path: `${templateBase}/${template.id}/status`, token: platformToken, + body: { targetStatus: 'DISABLED', version: template.version }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'DISABLED', 'notification template final status') + }, ({ json }) => ({ templateId: json?.data?.id, status: json?.data?.status, version: json?.data?.version })) + if (disabled?.json?.data) template = disabled.json.data + } + report.notificationInbox = { + templateId: report.resources.notificationTemplateId, + templateCode, + templateStatus: template?.status ?? null, + messageIds: [...report.resources.notificationMessageIds], + retainedAsRead: true, + } + } +} + +async function accountSecurityModule() { + const consumedRefreshToken = merchantRefreshToken + const rotated = await apiStep('account-security', 'rotate refresh token', { + method: 'POST', path: '/api/auth/refresh', body: { refreshToken: merchantRefreshToken }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'rotated accessToken') + assertPresent(json.data?.refreshToken, 'rotated refreshToken') + }, () => ({ rotated: true })) + merchantToken = rotated.json.data.accessToken + merchantRefreshToken = rotated.json.data.refreshToken + await apiStep('account-security', 'consumed refresh token cannot be replayed', { + method: 'POST', path: '/api/auth/refresh', body: { refreshToken: consumedRefreshToken }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0401', 'business code') + }, ({ json }) => ({ rejectionCode: json?.code })) + + temporaryOwnerPassword = `Temporary${randomUUID().replaceAll('-', '').slice(0, 14)}6!` + await apiStep('account-security', 'change owner password to temporary value', { + method: 'PUT', path: '/api/auth/password', token: merchantToken, + body: { oldPassword: clientPassword, newPassword: temporaryOwnerPassword }, + }, ({ json }) => requireSuccess(json), () => ({ username, passwordChanged: true })) + ownerPasswordNeedsRestore = true + + await apiStep('account-security', 'original password is rejected after change', { + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0401', 'business code') + }, ({ json }) => ({ username, rejectionCode: json.code })) + + const temporaryLogin = await apiStep('account-security', 'temporary password login succeeds', { + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: temporaryOwnerPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'accessToken') + }, () => ({ username })) + merchantToken = temporaryLogin.json.data.accessToken + + await apiStep('account-security', 'restore owner password', { + method: 'PUT', path: '/api/auth/password', token: merchantToken, + body: { oldPassword: temporaryOwnerPassword, newPassword: clientPassword }, + }, ({ json }) => requireSuccess(json), () => ({ username, passwordRestored: true })) + ownerPasswordNeedsRestore = false + temporaryOwnerPassword = undefined + + const restoredLogin = await apiStep('account-security', 'restored owner password login succeeds', { + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'accessToken') + assertPresent(json.data?.refreshToken, 'refreshToken') + }, () => ({ username, passwordRestored: true })) + merchantToken = restoredLogin.json.data.accessToken + merchantRefreshToken = restoredLogin.json.data.refreshToken + + const loggedOutRefreshToken = merchantRefreshToken + await apiStep('account-security', 'logout current client session', { + method: 'POST', path: '/api/auth/logout', token: merchantToken, body: { refreshToken: loggedOutRefreshToken }, + }, ({ json }) => requireSuccess(json), () => ({ username })) + await apiStep('account-security', 'logged-out refresh token is rejected', { + method: 'POST', path: '/api/auth/refresh', body: { refreshToken: loggedOutRefreshToken }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertEqual(String(json?.code), 'A0401', 'business code') + }, ({ json }) => ({ rejectionCode: json.code })) + await apiStep('account-security', 'repeated logout with revoked session is rejected', { + method: 'POST', path: '/api/auth/logout', body: { refreshToken: loggedOutRefreshToken }, + }, ({ json, status }) => { + assertEqual(status, 401, 'HTTP status') + assertEqual(String(json?.code), 'A0401', 'business code') + }, ({ json }) => ({ repeated: true, rejectionCode: json?.code })) +} + +async function recoverOwnerPassword() { + try { + const login = await apiStep('account-security-recovery', 'login with temporary owner password for recovery', { + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: temporaryOwnerPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'recovery accessToken') + }, () => ({ username })) + await apiStep('account-security-recovery', 'restore owner password after interrupted test', { + method: 'PUT', path: '/api/auth/password', token: login.json.data.accessToken, + body: { oldPassword: temporaryOwnerPassword, newPassword: clientPassword }, + }, ({ json }) => requireSuccess(json), () => ({ username, passwordRestored: true })) + ownerPasswordNeedsRestore = false + temporaryOwnerPassword = undefined + } catch (error) { + report.fatal ??= sanitizeError(error) + report.passwordRecovery = { success: false, error: sanitizeError(error) } + } +} + +async function loadOrderMasterData(token, module) { + const receivingWarehouses = await apiStep(module, 'load active domestic receiving warehouses', { + method: 'GET', path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', token, + }, ({ json }) => { + requireSuccess(json) + assertTrue(pageRecords(json.data).length > 0, 'at least one active DOMESTIC warehouse is required') + }, ({ json }) => ({ total: json.data?.total, warehouseCount: pageRecords(json.data).length })) + const receivingCandidates = pageRecords(receivingWarehouses.json.data) + + const destinationWarehouses = await apiStep(module, 'load active FBA destination warehouses', { + method: 'GET', path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', token, + }, ({ json }) => { + requireSuccess(json) + assertTrue(pageRecords(json.data).length > 0, 'at least one active FBA warehouse is required') + }, ({ json }) => ({ total: json.data?.total, warehouseCount: pageRecords(json.data).length })) + const destination = pageRecords(destinationWarehouses.json.data)[0] + let receiving = null + let pricedChannels = [] + for (const candidate of receivingCandidates) { + const response = await httpRequest({ + method: 'GET', path: `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${candidate.id}`, token, + }) + const candidates = String(response?.json?.code) === '200' ? arrayData(response.json.data) : [] + const available = candidates.filter((item) => arrayData(item.pricingTaxModes).length > 0) + if (available.length > 0) { + receiving = candidate + pricedChannels = available + break + } + } + assertPresent(receiving, 'active DOMESTIC warehouse with pricing channels') + report.resources.receivingWarehouseId = receiving.id + report.resources.destinationWarehouseId = destination.id + + await apiStep(module, 'load receiving warehouse detail', { + method: 'GET', path: `/api/merchant/v1/warehouses/${receiving.id}`, token, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.warehouse?.warehouseType, 'DOMESTIC', 'receiving warehouse type') + assertTrue(arrayData(json.data?.addresses).length > 0, 'receiving warehouse address is required') + }, ({ json }) => ({ id: json.data.warehouse.id, name: json.data.warehouse.warehouseName })) + + const channels = await apiStep(module, 'load active pricing channels', { + method: 'GET', path: `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${receiving.id}`, token, + }, ({ json }) => { + requireSuccess(json) + assertTrue(arrayData(json.data).length > 0, 'at least one active pricing channel is required') + }, ({ json }) => ({ channels: arrayData(json.data).map((item) => ({ channelCode: item.channelCode, priceModel: item.priceModel, pricingTaxModes: item.pricingTaxModes })) })) + const channel = pricedChannels.find((item) => arrayData(item.pricingTaxModes).length > 0) + assertPresent(channel, 'pricing channel with pricingTaxModes') + report.resources.channelCode = channel.channelCode + + const partners = await apiStep(module, 'load public shipper candidates', { + method: 'GET', path: '/api/merchant/v1/partners/candidates?type=SHIPPER', token, + }, ({ json }) => { + requireSuccess(json) + }, ({ json }) => ({ candidateCount: arrayData(json.data).length })) + const partner = arrayData(partners.json.data).find((item) => item.status === 'ACTIVE' && arrayData(item.types).includes('SHIPPER')) + + return { + receiving, + destination, + destinationCandidates: pageRecords(destinationWarehouses.json.data), + channel, + channelCandidates: pricedChannels, + partner, + } +} + +async function estimateAvailablePrice(masterData) { + const destinations = masterData.destinationCandidates.slice(0, 30) + const channels = masterData.channelCandidates + let attempt = 0 + for (const channel of channels) { + for (const destination of destinations) { + attempt += 1 + const pricingTaxMode = channel.pricingTaxModes[0] + const estimate = await apiStep('pricing-estimate', `probe configured price ${attempt}`, { + method: 'POST', path: '/api/merchant/v1/first-leg/pricing/estimate', token: merchantToken, + body: { + originWarehouseId: masterData.receiving.id, + channelCode: channel.channelCode, + destinationCode: destination.warehouseCode, + destinationType: 'FBA', + destinationWarehouseId: destination.id, + destinationName: destination.warehouseName, + destinationRegion: null, + pricingTaxMode, + actualWeightKg: 20.5, + actualVolumeCbm: 0.024, + }, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertTrue(['200', 'A0404'].includes(String(json?.code)), `unexpected business code ${json?.code}`) + if (String(json?.code) === '200') { + assertEqual(json.data?.channelCode, channel.channelCode, 'estimate channelCode') + assertEqual(json.data?.pricingTaxMode, pricingTaxMode, 'estimate pricingTaxMode') + assertTrue(Number(json.data?.chargeableQuantity) > 0, 'chargeableQuantity must be positive') + assertTrue(Number(json.data?.unitPrice) >= 0, 'unitPrice must not be negative') + assertTrue(Number(json.data?.estimatedAmount) >= 0, 'estimatedAmount must not be negative') + } + }, ({ json }) => String(json?.code) === '200' ? { + matched: true, + destinationWarehouseId: destination.id, + destinationCode: destination.warehouseCode, + priceBookVersionId: json.data.priceBookVersionId, + channelCode: json.data.channelCode, + priceModel: json.data.priceModel, + pricingTaxMode: json.data.pricingTaxMode, + chargeableQuantity: json.data.chargeableQuantity, + chargeUnit: json.data.chargeUnit, + unitPrice: json.data.unitPrice, + estimatedAmount: json.data.estimatedAmount, + currency: json.data.currency, + } : { + matched: false, + destinationWarehouseId: destination.id, + destinationCode: destination.warehouseCode, + channelCode: channel.channelCode, + code: json?.code, + message: json?.message, + }) + if (String(estimate.json?.code) === '200') return estimate.json.data + } + } + throw new Error(`No configured price matched ${attempt} visible channel/destination combinations`) +} + +async function uploadProductImage(token, module) { + const form = new FormData() + form.append('file', new Blob([testPng()], { type: 'image/png' }), `product-${marker}.png`) + const upload = await apiStep(module, 'upload product image', { + method: 'POST', path: '/api/merchant/v1/files/product-images', token, body: form, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.fileId, 'product image fileId') + assertEqual(json.data?.contentType, 'image/png', 'product image contentType') + }, ({ json }) => ({ fileId: json.data.fileId, fileName: json.data.fileName, contentType: json.data.contentType, sizeBytes: json.data.sizeBytes })) + return upload.json.data.fileId +} + +function buildOrderPayload(masterData, productImageFileId, suffix, remark) { + const cargoShipmentId = `SHIP-${suffix}-${marker}`.slice(0, 64) + const cargoReferenceId = `REF-${suffix}-${marker}`.slice(0, 64) + const unitsPerPackage = 12 + const packageCount = 2 + const totalQuantity = unitsPerPackage * packageCount + const unitDeclaredValue = 8.5 + return { + customerId: masterData.partner?.id ?? null, + customerName: masterData.partner?.partnerName ?? companyName, + transportMode: 'SEA', + tradeTerm: 'FOB', + destinationCountryCode: masterData.destination.countryCode, + destinationWarehouseId: masterData.destination.id, + destinationWarehouseName: masterData.destination.warehouseName, + expectedShipDate: addDays(new Date(), 30), + customerRemark: `${remark} ${marker}`, + pickupMethod: 'SELF_EXPRESS', + receivingWarehouseId: masterData.receiving.id, + receivingWarehouseName: masterData.receiving.warehouseName, + channelCode: masterData.channel.channelCode, + channelName: masterData.channel.channelName, + carrierId: null, + carrierName: null, + taxMode: 'BUY_DECLARATION', + pricingTaxMode: masterData.channel.pricingTaxModes[0], + containerMode: 'LCL', + customsInspection: false, + destinationType: 'FBA', + amazonWarehouseCode: masterData.destination.warehouseCode, + shipmentId: cargoShipmentId, + referenceId: cargoReferenceId, + sender: { + companyName, contactName: '测试发货人', phone: legalPhone, email: `${username}@example.com`, + countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区', + addressLine: '测试路 1 号', postalCode: '518000', + }, + receiver: null, + cargoes: [{ + skuNo: `SKU-${suffix}-${marker}`.slice(0, 64), + cargoName: '蓝牙音箱', + cargoNameEn: 'Bluetooth Speaker', + hsCode: '8518210000', + customsHsCode: '8518210000', + packageType: 'CARTON', + lengthCm: 30, + widthCm: 20, + heightCm: 20, + unitsPerPackage, + packageCount, + grossWeightKg: 20.5, + volumeCbm: 0.024, + totalQuantity, + netWeightKg: 18, + unitDeclaredValue, + declaredValue: unitDeclaredValue * totalQuantity, + currency: 'USD', + material: '塑料 / Plastic', + usage: '音频播放 / Audio playback', + model: `MODEL-${suffix}`, + brand: 'TEST-NO-LOGO', + productLengthCm: 12, + productWidthCm: 8, + productHeightCm: 6, + shipmentId: cargoShipmentId, + referenceId: cargoReferenceId, + upcNo: `123456${`${Date.now()}`.slice(-6)}`.slice(0, 12), + productImageFileId, + dangerousGoods: false, + woodenProduct: false, + cargoRemark: `cargo-${remark} ${marker}`, + }], + } +} + +async function apiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) { + const startedAt = new Date().toISOString() + const started = Date.now() + let response + try { + response = await httpRequest(request) + validate(response) + const step = makeStep(module, name, true, startedAt, started, response, evidenceBuilder(response)) + report.steps.push(step) + console.log(`PASS [${module}] ${name}`) + return response + } catch (error) { + let evidence = null + if (response) { + try { + evidence = evidenceBuilder(response) + } catch { + evidence = defaultEvidence(response) + } + } + const step = makeStep(module, name, false, startedAt, started, response, evidence) + step.error = sanitizeError(error) + report.steps.push(step) + console.error(`FAIL [${module}] ${name}: ${error.message}`) + throw error + } +} + +async function softApiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) { + try { + return await apiStep(module, name, request, validate, evidenceBuilder) + } catch { + return null + } +} + +async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) { + const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-e2e-${randomUUID()}`, ...headers } + if (token) requestHeaders.Authorization = `Bearer ${token}` + let requestBody = body + if (body != null && !(body instanceof FormData)) { + requestHeaders['Content-Type'] = 'application/json' + requestBody = JSON.stringify(body) + } + const response = await fetch(`${gateway}${path}`, { + method, + headers: requestHeaders, + body: requestBody, + signal: AbortSignal.timeout(timeoutMs), + }) + const result = { status: response.status, headers: response.headers, json: null, bytes: null, text: null } + if (responseType === 'bytes') { + result.bytes = new Uint8Array(await response.arrayBuffer()) + return result + } + const text = await response.text() + result.text = text + if (text) { + try { + result.json = JSON.parse(text) + } catch { + throw new Error(`Expected JSON but received ${text.slice(0, 200)}`) + } + } + return result +} + +function makeStep(module, name, passed, startedAt, started, response, evidence) { + return { + module, + name, + passed, + skipped: false, + startedAt, + durationMs: Date.now() - started, + http: response?.status ?? null, + code: response?.json?.code == null ? null : String(response.json.code), + message: response?.json?.message ?? null, + requestId: headerOrBody(response, 'x-request-id', 'requestId'), + traceId: headerOrBody(response, 'x-trace-id', 'traceId'), + evidence, + } +} + +function recordSkipped(module, name, reason) { + report.steps.push({ + module, name, passed: true, skipped: true, startedAt: new Date().toISOString(), durationMs: 0, + http: null, code: null, message: reason, requestId: null, traceId: null, evidence: null, + }) + console.log(`SKIP [${module}] ${name}: ${reason}`) +} + +function requireSuccess(json) { + assertPresent(json, 'response body') + assertEqual(String(json.code), '200', 'business code') +} + +function assertCargo(actual, expected) { + assertPresent(actual, 'cargo detail') + const exactFields = [ + 'skuNo', 'cargoName', 'cargoNameEn', 'hsCode', 'customsHsCode', 'packageType', 'unitsPerPackage', + 'packageCount', 'totalQuantity', 'currency', 'material', 'model', 'brand', 'shipmentId', + 'referenceId', 'upcNo', 'cargoRemark', + ] + for (const field of exactFields) assertEqual(String(actual[field]), String(expected[field]), `cargo.${field}`) + assertEqual(String(actual.productUsage), String(expected.usage), 'cargo.productUsage') + const decimalFields = [ + 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm', 'netWeightKg', + 'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm', + ] + for (const field of decimalFields) assertEqual(Number(actual[field]), Number(expected[field]), `cargo.${field}`) + assertEqual(String(actual.productImageFileId), String(expected.productImageFileId), 'cargo.productImageFileId') +} + +function assertQualificationStatuses(values, requiredTypes, expectedStatus) { + const rows = arrayData(values) + for (const type of requiredTypes) { + const item = rows.find((row) => row.qualificationType === type && row.status === expectedStatus) + assertPresent(item, `${type} qualification`) + } +} + +function qualificationEvidence(values) { + return arrayData(values).map((item) => ({ + qualificationId: item.id ?? item.qualificationId, + qualificationType: item.qualificationType, + status: item.status, + })) +} + +function cargoEvidence(cargo) { + if (!cargo) return null + return { + id: cargo.id, + skuNo: cargo.skuNo, + cargoName: cargo.cargoName, + cargoNameEn: cargo.cargoNameEn, + packageCount: cargo.packageCount, + totalQuantity: cargo.totalQuantity, + grossWeightKg: cargo.grossWeightKg, + volumeCbm: cargo.volumeCbm, + shipmentId: cargo.shipmentId, + referenceId: cargo.referenceId, + productImageFileId: cargo.productImageFileId, + } +} + +function defaultEvidence({ json }) { + if (!json || json.data == null) return null + if (typeof json.data !== 'object') return { data: json.data } + return null +} + +function headerOrBody(response, headerName, bodyName) { + return response?.headers?.get(headerName) + ?? response?.json?.[bodyName] + ?? response?.json?.data?.[bodyName] + ?? null +} + +function pageRecords(data) { + if (Array.isArray(data)) return data + return arrayData(data?.records ?? data?.items ?? data?.content) +} + +function arrayData(value) { + return Array.isArray(value) ? value : [] +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) throw new Error(`${label}: expected=${format(expected)} actual=${format(actual)}`) +} + +function assertPresent(value, label) { + if (value == null || value === '') throw new Error(`${label} is required`) +} + +function assertTrue(value, message) { + if (!value) throw new Error(message) +} + +function assertSetEqual(actual, expected, label) { + const actualValues = [...new Set(actual)].sort() + const expectedValues = [...new Set(expected)].sort() + assertEqual(JSON.stringify(actualValues), JSON.stringify(expectedValues), label) +} + +function requiredOwnerBackendPermissions() { + return [ + 'merchant:order:list', 'merchant:order:view', 'merchant:order:create', 'merchant:order:edit', + 'merchant:order:submit', 'merchant:order:cancel', 'merchant:profile:view', 'merchant:profile:edit', + 'merchant:qualification:upload', 'merchant:member:list', 'merchant:member:create', + 'merchant:member:edit', 'merchant:member:grant-role', 'merchant:member:status', + 'merchant:member:reset-password', 'merchant:role:list', 'merchant:role:create', + 'merchant:role:edit', 'merchant:role:status', 'merchant:role:delete', 'merchant:role:grant', + 'merchant:notification:view', 'merchant:notification:read', 'merchant:notification:read-all', + ] +} + +function visibleClientMenus(backendPermissions) { + const permissions = new Set(backendPermissions) + const menus = ['dashboard:view', 'account:profile:view'] + if (permissions.has('merchant:order:list') || permissions.has('merchant:order:view')) menus.push('order:first-leg:list') + if (permissions.has('merchant:profile:view')) menus.push('company:profile:view') + if (permissions.has('merchant:member:list')) menus.push('company:member:list') + if (permissions.has('merchant:role:list')) menus.push('company:role:list', 'company:permission:list') + if (permissions.has('merchant:notification:view')) menus.push('notification:inbox:view') + return menus.sort() +} + +function newIdempotencyKey(operation) { + return `client-e2e-${operation}-${marker}-${randomUUID()}`.slice(0, 128) +} + +function testPng() { + return Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') +} + +function addDays(value, days) { + const date = new Date(value) + date.setUTCDate(date.getUTCDate() + days) + return date.toISOString().slice(0, 10) +} + +function compactTimestamp(value) { + return value.toISOString().replace(/[-:TZ.]/g, '').slice(0, 14) +} + +function trimSlash(value) { + return String(value).replace(/\/+$/, '') +} + +function apiPath(value) { + const url = new URL(String(value), `${gateway}/`) + return `${url.pathname}${url.search}` +} + +function format(value) { + return typeof value === 'string' ? JSON.stringify(value) : String(value) +} + +function sanitizeError(error) { + return { name: error?.name ?? 'Error', message: error?.message ?? String(error) } +} + +async function promptText(label, defaultValue = '') { + if (!process.stdin.isTTY || !process.stdout.isTTY) return defaultValue + const reader = createInterface({ input: process.stdin, output: process.stdout }) + try { + const suffix = defaultValue ? ` [${defaultValue}]` : '' + const value = await reader.question(`${label}${suffix}: `) + return value.trim() || defaultValue + } finally { + reader.close() + } +} + +async function promptSecret(label) { + if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined + const input = process.stdin + const output = process.stdout + const previousRawMode = input.isRaw + let value = '' + output.write(`${label}: `) + input.setEncoding('utf8') + input.setRawMode(true) + input.resume() + return new Promise((resolveSecret, rejectSecret) => { + const finish = (error) => { + input.off('data', onData) + input.setRawMode(Boolean(previousRawMode)) + input.pause() + output.write('\n') + if (error) rejectSecret(error) + else resolveSecret(value) + } + const onData = (chunk) => { + for (const character of chunk) { + if (character === '\u0003') return finish(new Error('Credential input cancelled')) + if (character === '\r' || character === '\n') return finish() + if (character === '\u007f' || character === '\b') { + if (value.length > 0) { + value = value.slice(0, -1) + output.write('\b \b') + } + continue + } + if (character >= ' ' && character !== '\u007f') { + value += character + output.write('*') + } + } + } + input.on('data', onData) + }) +} + +function parseArgs(values) { + const parsed = {} + for (let index = 0; index < values.length; index += 1) { + const item = values[index] + if (!item.startsWith('--')) throw new Error(`Unknown argument: ${item}`) + const separator = item.indexOf('=') + if (separator > 2) { + parsed[item.slice(2, separator)] = item.slice(separator + 1) + continue + } + const key = item.slice(2) + const next = values[index + 1] + if (next == null || next.startsWith('--')) throw new Error(`Missing value for --${key}`) + parsed[key] = next + index += 1 + } + return parsed +} + +function printSummary() { + const passed = report.steps.filter((step) => step.passed && !step.skipped).length + const failed = report.steps.filter((step) => !step.passed).length + const skipped = report.steps.filter((step) => step.skipped).length + console.log(JSON.stringify({ success: report.success, marker, passed, failed, skipped, reportPath }, null, 2)) +} diff --git a/scripts/container-logging-contract.test.mjs b/scripts/container-logging-contract.test.mjs new file mode 100644 index 000000000..3fa84cdc5 --- /dev/null +++ b/scripts/container-logging-contract.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const applicationConfigs = [ + 'auth/src/main/resources/application.yml', + 'audit-service/src/main/resources/application.yml', + 'event-bridge-service/src/main/resources/application.yml', + 'file/src/main/resources/application.yml', + 'finance/src/main/resources/application.yml', + 'firstleg/src/main/resources/application.yml', + 'gateway/src/main/resources/application.yml', + 'gateway-overseas/src/main/resources/application.yml', + 'iam/src/main/resources/application.yml', + 'integration-service/src/main/resources/application.yml', + 'lastleg-service/src/main/resources/application.yml', + 'order/src/main/resources/application.yml', + 'print/src/main/resources/application.yml', + 'warehouse/src/main/resources/application.yml', + 'warehouse-overseas-service/src/main/resources/application.yml', + 'config/nacos/cn/baiyue-common.yml', + 'config/nacos/local/baiyue-common.yml', + 'config/nacos/overseas/baiyue-common.yml', +]; + +const composeFiles = [ + 'deploy/docker-compose.yml', + 'deploy/docker-compose.overseas.yml', +]; + +const infrastructureComposeFiles = [ + ['deploy/docker-compose.rocketmq.yml', 3], + ['docker-compose-infra.yml', 3], +]; + +test('deployable applications log only to stdout and stderr', () => { + for (const path of applicationConfigs) { + const source = readFileSync(path, 'utf8'); + assert.doesNotMatch(source, /LOG_PATH|LOGGING_FILE_NAME|LOGGING_ROLLING/, path); + } +}); + +test('Compose uses one bounded BaoTa-compatible log stream without application log mounts', () => { + for (const path of composeFiles) { + const source = readFileSync(path, 'utf8'); + assert.match(source, /driver:\s+\$\{DOCKER_LOG_DRIVER:-json-file\}/, path); + assert.match(source, /DOCKER_LOG_MAX_SIZE/, path); + assert.match(source, /DOCKER_LOG_MAX_FILE/, path); + assert.doesNotMatch(source, /LOG_PATH|LOG_ROOT|\/app\/logs/, path); + } + + const domesticCompose = readFileSync(composeFiles[0], 'utf8'); + assert.match(domesticCompose, /FILE_UPLOADS_HOST_DIR[^\n]+:\/data\/files/); +}); + +test('runtime image does not prepare an application log directory', () => { + const dockerfile = readFileSync('deploy/docker/Dockerfile', 'utf8'); + assert.doesNotMatch(dockerfile, /\/app\/logs/); +}); + +test('infrastructure containers also use bounded BaoTa-compatible logs', () => { + for (const [path, expectedServiceCount] of infrastructureComposeFiles) { + const source = readFileSync(path, 'utf8'); + assert.match(source, /x-bounded-logging:\s+&bounded-logging/, path); + assert.match(source, /driver:\s+\$\{DOCKER_LOG_DRIVER:-json-file\}/, path); + assert.match(source, /DOCKER_LOG_MAX_SIZE/, path); + assert.match(source, /DOCKER_LOG_MAX_FILE/, path); + assert.equal( + source.match(/logging:\s+\*bounded-logging/g)?.length, + expectedServiceCount, + path, + ); + assert.doesNotMatch(source, /driver:\s+local/, path); + } +}); diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs new file mode 100644 index 000000000..2dbda3f44 --- /dev/null +++ b/scripts/controller-pipeline-contract.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const pipeline = (await readFile(new URL('../Jenkinsfile', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') + +test('coordinator releases its builder while waiting for downstream jobs', () => { + assert.match(pipeline, /pipeline \{[\s\S]*?agent none/) + assert.match(pipeline, /stage\('确定构建范围'\) \{[\s\S]*?agent \{ label 'java-builder' \}/) + 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')") + const staleReportCleanupIndex = pipeline.indexOf( + "find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete" + ) + const selectedTestIndex = pipeline.indexOf('-pl ${selectedModules} -am test') + + assert.ok(gateIndex >= 0, 'unified test gate must exist') + assert.ok(dispatchIndex > gateIndex, 'unified tests must finish before downstream dispatch') + assert.ok(staleReportCleanupIndex > gateIndex, 'stale Surefire reports must be removed inside the test gate') + assert.ok(selectedTestIndex > staleReportCleanupIndex, 'stale reports must be removed before selected tests run') + assert.match(pipeline, /-pl \$\{selectedModules\} -am test/) + assert.match(pipeline, /junit allowEmptyResults: true, testResults: '\*\*\/target\/surefire-reports\/TEST-\*\.xml'/) + 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', () => { + assert.match(pipeline, /def databaseMigrationPaths = \['deploy\/sql\/'\]/) + assert.match(pipeline, /List serviceRelevantChanges = changed\.findAll/) + assert.match(pipeline, /!databaseMigrationPaths\.any \{ prefix -> path\.startsWith\(prefix\) \}/) + assert.match(pipeline, /env\.DATABASE_CHANGE_DETECTED = databaseChanged \? 'true' : 'false'/) +}) + +test('database-only changes are reported as a separate migration action', () => { + assert.match(pipeline, /仅数据库迁移变更 \| 需单独审核执行/) + assert.match(pipeline, /统一 SQL 须独立审核并执行/) +}) + +test('coordinator cannot initialize test databases', () => { + assert.doesNotMatch(pipeline, /INITIALIZE_TEST_DATABASE/) + 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/create-pending-review-orders-contract.test.mjs b/scripts/create-pending-review-orders-contract.test.mjs new file mode 100644 index 000000000..764b20d91 --- /dev/null +++ b/scripts/create-pending-review-orders-contract.test.mjs @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { PRODUCT_CATALOG, buildOrderPayload, diversifyRoutes, parseArguments, randomOrderOptions, selectSameChannelRoutes, validateCount } from './create-pending-review-orders.mjs' + +const source = readFileSync(new URL('./create-pending-review-orders.mjs', import.meta.url), 'utf8') + +test('uses the configured merchant account and stops at pending review', () => { + assert.match(source, /process\.env\.ADMIN_TEST_GATEWAY/) + assert.match(source, /process\.env\.MERCHANT_TEST_USERNAME/) + assert.match(source, /process\.env\.MERCHANT_TEST_PASSWORD/) + assert.match(source, /const DEFAULT_COUNT = 50/) + assert.doesNotMatch(source, /const CONFIG\s*=/) + assert.doesNotMatch(source, /8\.163\.30\.128/) + assert.doesNotMatch(source, /PASSWORD:\s*'[^']+'/) + assert.match(source, /randomInt\(1, 31\)/) + assert.match(source, /loadPricedRoutes/) + assert.match(source, /destinationWarehouseCode/) + assert.match(source, /channelCode/) + assert.match(source, /pricingTaxMode/) + assert.equal(PRODUCT_CATALOG.length, 8) + assert.match(source, /\/api\/merchant\/v1\/orders\/first-leg\/\$\{encodeURIComponent\(orderId\)\}\/submit/) + assert.match(source, /detail\?\.order\?\.status !== 'SUBMITTED'/) + assert.match(source, /\/api\/merchant\/v1\/orders\/\$\{encodeURIComponent\(orderId\)\}/) + assert.match(source, /unifiedOrder\?\.summary\?\.mainStatus !== 'PENDING_REVIEW'/) + assert.match(source, /mainStatus: unifiedOrder\.summary\.mainStatus/) + assert.doesNotMatch(source, /partners\/candidates\?type=SHIPPER/) + assert.doesNotMatch(source, /\/api\/platform\/v1\/iam\/tenants/) + assert.doesNotMatch(source, /\/approve/) +}) + +test('supports a bounded fixed order count', () => { + assert.equal(validateCount(undefined), 50) + assert.equal(validateCount('1'), 1) + assert.equal(validateCount('10'), 10) + assert.throws(() => validateCount('0'), /between 1 and 100/) + assert.throws(() => validateCount('101'), /between 1 and 100/) + assert.deepEqual(parseArguments(['--count', '5', '--gateway=http://localhost:9000']), { + count: '5', gateway: 'http://localhost:9000', + }) +}) + +test('builds an order payload owned by the fixed profile', () => { + const payload = buildOrderPayload({ + marker: 'PENDING-TEST', + index: 2, + profile: { id: '44', tenantName: '固定测试客户' }, + master: { + receiving: { id: '11', warehouseName: '国内仓' }, + destination: { id: '22', warehouseName: 'ONT8', warehouseCode: 'ONT8', countryCode: 'US' }, + channel: { channelCode: 'SEA-US', channelName: '美西海运' }, + pricingTaxMode: 'TAX_INCLUDED', + }, + product: PRODUCT_CATALOG[0], + packageCount: 7, + options: { + tradeTerm: 'EXW', pickupMethod: 'SELF_TRUCK', taxMode: 'TAX_REFUND', + customsInspection: true, expectedShipDays: 15, + senderLocation: { province: '浙江省', city: '义乌市', district: '稠城街道', postalCode: '322000' }, + }, + productImageFileId: '33', + pickupAddressId: '55', + }) + assert.equal(payload.receivingWarehouseId, '11') + assert.equal(payload.destinationWarehouseId, '22') + assert.equal(payload.customerId, '44') + assert.equal(payload.customerName, '固定测试客户') + assert.equal(payload.cargoes.length, 1) + assert.equal(payload.cargoes[0].productImageFileId, '33') + assert.equal(payload.cargoes[0].woodenProduct, false) + assert.equal(payload.tradeTerm, 'EXW') + assert.equal(payload.pickupMethod, 'SELF_TRUCK') + assert.equal(payload.taxMode, 'TAX_REFUND') + assert.equal(payload.customsInspection, true) + assert.equal(payload.sender, null) + assert.equal(payload.senderAddressId, null) + assert.equal(payload.requestedPickupAt, null) + assert.equal(payload.cargoes[0].cargoName, PRODUCT_CATALOG[0].cargoName) + assert.equal(payload.cargoes[0].packageCount, 7) + assert.equal(payload.cargoes[0].totalQuantity, PRODUCT_CATALOG[0].unitsPerPackage * 7) + assert.equal(payload.cargoes[0].grossWeightKg, Number((PRODUCT_CATALOG[0].cartonGrossWeightKg * 7).toFixed(3))) + assert.equal(payload.cargoes[0].declaredValue, PRODUCT_CATALOG[0].unitDeclaredValue * PRODUCT_CATALOG[0].unitsPerPackage * 7) +}) + +test('uses the selected customer pickup address and appointment for pickup orders', () => { + const payload = buildOrderPayload({ + marker: 'PENDING-PICKUP', index: 1, + profile: { id: '44', tenantName: '固定测试客户' }, + master: { + receiving: { id: '11', warehouseName: '国内仓' }, + destination: { id: '22', warehouseName: 'ONT8', warehouseCode: 'ONT8', countryCode: 'US' }, + channel: { channelCode: 'SEA-US', channelName: '美西海运' }, pricingTaxMode: 'TAX_INCLUDED', + }, + product: PRODUCT_CATALOG[0], packageCount: 1, productImageFileId: '33', pickupAddressId: '55', + options: { + tradeTerm: 'FOB', pickupMethod: 'PICKUP', taxMode: 'BUY_DECLARATION', + customsInspection: false, expectedShipDays: 7, + senderLocation: { province: '广东省', city: '深圳市', district: '南山区', postalCode: '518000' }, + }, + }) + assert.equal(payload.sender, null) + assert.equal(payload.senderAddressId, '55') + assert.match(payload.requestedPickupAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/) +}) + +test('randomizes only supported basic-order fields', () => { + for (let index = 0; index < 100; index += 1) { + const options = randomOrderOptions() + assert.ok(['FOB', 'EXW', 'CIF', 'DDP'].includes(options.tradeTerm)) + assert.ok(['SELF_EXPRESS', 'PICKUP', 'SELF_TRUCK'].includes(options.pickupMethod)) + assert.ok(['BUY_DECLARATION', 'TAX_REFUND'].includes(options.taxMode)) + assert.equal(typeof options.customsInspection, 'boolean') + assert.ok(options.expectedShipDays >= 7 && options.expectedShipDays <= 60) + } +}) + +test('interleaves different logistics combinations before repeating one', () => { + const route = (receivingId, channelCode, pricingTaxMode, destinationCode) => ({ + receiving: { id: receivingId }, channel: { channelCode }, pricingTaxMode, + destination: { warehouseCode: destinationCode }, + }) + const diversified = diversifyRoutes([ + route('1', 'SEA-A', 'TAX_INCLUDED', 'A1'), + route('1', 'SEA-A', 'TAX_INCLUDED', 'A2'), + route('1', 'SEA-B', 'TAX_EXCLUDED', 'B1'), + route('1', 'SEA-B', 'TAX_EXCLUDED', 'B2'), + ]) + const firstKey = `${diversified[0].channel.channelCode}|${diversified[0].pricingTaxMode}` + const secondKey = `${diversified[1].channel.channelCode}|${diversified[1].pricingTaxMode}` + assert.notEqual(firstKey, secondKey) +}) + +test('selects routes from exactly one logistics channel', () => { + const route = (receivingId, channelCode, pricingTaxMode, destinationCode) => ({ + receiving: { id: receivingId }, channel: { channelCode }, pricingTaxMode, + destination: { warehouseCode: destinationCode }, + }) + const selected = selectSameChannelRoutes([ + route('1', 'SEA-A', 'TAX_INCLUDED', 'A1'), + route('1', 'SEA-A', 'TAX_INCLUDED', 'A2'), + route('1', 'SEA-B', 'TAX_EXCLUDED', 'B1'), + ]) + assert.equal(selected.length, 2) + assert.equal(new Set(selected.map((item) => `${item.receiving.id}|${item.channel.channelCode}|${item.pricingTaxMode}`)).size, 1) +}) + +test('parses same-channel as a boolean flag', () => { + assert.deepEqual(parseArguments(['--count', '10', '--same-channel']), { count: '10', 'same-channel': true }) +}) diff --git a/scripts/create-pending-review-orders.mjs b/scripts/create-pending-review-orders.mjs new file mode 100644 index 000000000..9dc46dad4 --- /dev/null +++ b/scripts/create-pending-review-orders.mjs @@ -0,0 +1,508 @@ +#!/usr/bin/env node + +import { randomInt, randomUUID } from 'node:crypto' +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const MAX_COUNT = 100 +const MAX_ROUTE_POOL_SIZE = 40 +const MAX_DESTINATIONS_PER_LOGISTICS = 3 +const TRADE_TERMS = ['FOB', 'EXW', 'CIF', 'DDP'] +const PICKUP_METHODS = ['SELF_EXPRESS', 'PICKUP', 'SELF_TRUCK'] +const TAX_MODES = ['BUY_DECLARATION', 'TAX_REFUND'] +const SENDER_LOCATIONS = [ + { province: '广东省', city: '深圳市', district: '南山区', postalCode: '518000' }, + { province: '广东省', city: '东莞市', district: '南城街道', postalCode: '523000' }, + { province: '浙江省', city: '义乌市', district: '稠城街道', postalCode: '322000' }, + { province: '福建省', city: '厦门市', district: '湖里区', postalCode: '361000' }, +] + +export const PRODUCT_CATALOG = [ + { cargoName: '蓝牙音箱', cargoNameEn: 'Bluetooth speaker', hsCode: '8518210000', material: '塑料和电子元件 / Plastic and electronic components', usage: '音频播放 / Audio playback', brand: 'SOUNDTEST', modelPrefix: 'SPK', lengthCm: 42, widthCm: 32, heightCm: 28, unitsPerPackage: 8, cartonGrossWeightKg: 12.8, unitDeclaredValue: 18.5, productLengthCm: 18, productWidthCm: 10, productHeightCm: 8 }, + { cargoName: '手机保护壳', cargoNameEn: 'Mobile phone case', hsCode: '3926909090', material: '热塑性聚氨酯 / TPU', usage: '手机保护 / Mobile phone protection', brand: 'CASETEST', modelPrefix: 'CASE', lengthCm: 48, widthCm: 38, heightCm: 32, unitsPerPackage: 100, cartonGrossWeightKg: 16.5, unitDeclaredValue: 2.2, productLengthCm: 17, productWidthCm: 8, productHeightCm: 1.5 }, + { cargoName: '不锈钢保温杯', cargoNameEn: 'Stainless steel vacuum bottle', hsCode: '9617009000', material: '不锈钢 / Stainless steel', usage: '饮品保温 / Beverage insulation', brand: 'CUPTEST', modelPrefix: 'CUP', lengthCm: 50, widthCm: 40, heightCm: 32, unitsPerPackage: 24, cartonGrossWeightKg: 15.2, unitDeclaredValue: 9.8, productLengthCm: 8, productWidthCm: 8, productHeightCm: 25 }, + { cargoName: '棉质短袖T恤', cargoNameEn: 'Cotton short sleeve T-shirt', hsCode: '6109100021', material: '棉 / Cotton', usage: '日常穿着 / Daily wear', brand: 'SHIRTTEST', modelPrefix: 'TS', lengthCm: 55, widthCm: 42, heightCm: 35, unitsPerPackage: 60, cartonGrossWeightKg: 18.6, unitDeclaredValue: 6.5, productLengthCm: 30, productWidthCm: 25, productHeightCm: 2 }, + { cargoName: '瑜伽垫', cargoNameEn: 'Yoga mat', hsCode: '9506919000', material: '热塑性弹性体 / TPE', usage: '健身运动 / Fitness exercise', brand: 'YOGATEST', modelPrefix: 'YOGA', lengthCm: 65, widthCm: 45, heightCm: 45, unitsPerPackage: 10, cartonGrossWeightKg: 13.5, unitDeclaredValue: 12, productLengthCm: 61, productWidthCm: 18, productHeightCm: 18 }, + { cargoName: '塑料收纳盒', cargoNameEn: 'Plastic storage box', hsCode: '3924900000', material: '聚丙烯 / Polypropylene', usage: '家居收纳 / Household storage', brand: 'BOXTEST', modelPrefix: 'BOX', lengthCm: 62, widthCm: 48, heightCm: 40, unitsPerPackage: 12, cartonGrossWeightKg: 17.8, unitDeclaredValue: 7.2, productLengthCm: 38, productWidthCm: 28, productHeightCm: 20 }, + { cargoName: 'LED台灯', cargoNameEn: 'LED desk lamp', hsCode: '9405210090', material: '铝和塑料 / Aluminum and plastic', usage: '桌面照明 / Desk lighting', brand: 'LAMPTEST', modelPrefix: 'LAMP', lengthCm: 58, widthCm: 42, heightCm: 36, unitsPerPackage: 12, cartonGrossWeightKg: 14.4, unitDeclaredValue: 15.6, productLengthCm: 35, productWidthCm: 15, productHeightCm: 40 }, + { cargoName: '宠物橡胶玩具', cargoNameEn: 'Pet rubber toy', hsCode: '4016999090', material: '天然橡胶 / Natural rubber', usage: '宠物玩耍 / Pet play', brand: 'PETTEST', modelPrefix: 'PET', lengthCm: 46, widthCm: 36, heightCm: 34, unitsPerPackage: 48, cartonGrossWeightKg: 15.6, unitDeclaredValue: 3.4, productLengthCm: 12, productWidthCm: 7, productHeightCm: 7 }, +] + +const DEFAULT_COUNT = 50 + +export function parseArguments(values) { + const options = {} + const booleanOptions = new Set(['help', 'same-channel']) + for (let index = 0; index < values.length; index += 1) { + const value = values[index] + if (!value.startsWith('--')) throw new Error(`Unknown argument: ${value}`) + const [rawKey, inlineValue] = value.slice(2).split('=', 2) + if (booleanOptions.has(rawKey)) { + if (inlineValue !== undefined) throw new Error(`--${rawKey} does not accept a value`) + options[rawKey] = true + continue + } + const next = inlineValue ?? values[index + 1] + if (inlineValue === undefined) index += 1 + if (next == null || next.startsWith('--')) throw new Error(`--${rawKey} requires a value`) + options[rawKey] = next + } + return options +} + +export function validateCount(value) { + const count = Number(value ?? DEFAULT_COUNT) + if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) { + throw new Error(`--count must be an integer between 1 and ${MAX_COUNT}`) + } + return count +} + +function trimSlash(value) { + return String(value || '').replace(/\/+$/, '') +} + +function compactTimestamp(value = new Date()) { + return value.toISOString().replace(/\D/g, '').slice(0, 14) +} + +function addDays(value, days) { + const date = new Date(value) + date.setUTCDate(date.getUTCDate() + days) + return date.toISOString().slice(0, 10) +} + +function records(value) { + return Array.isArray(value?.records) ? value.records : [] +} + +function items(value) { + return Array.isArray(value) ? value : [] +} + +function shuffled(values) { + const result = [...values] + for (let index = result.length - 1; index > 0; index -= 1) { + const target = randomInt(index + 1) + ;[result[index], result[target]] = [result[target], result[index]] + } + return result +} + +function pickRandom(values) { + return values[randomInt(values.length)] +} + +function round(value, scale) { + return Number(value.toFixed(scale)) +} + +function logisticsKey(route) { + return `${route.receiving.id}|${route.channel.channelCode}|${route.pricingTaxMode}` +} + +export function diversifyRoutes(routes) { + const grouped = new Map() + for (const route of routes) { + const key = logisticsKey(route) + if (!grouped.has(key)) grouped.set(key, []) + grouped.get(key).push(route) + } + const queues = shuffled([...grouped.values()]).map((group) => shuffled(group)) + const result = [] + while (queues.some((queue) => queue.length > 0)) { + for (const queue of queues) { + if (queue.length > 0) result.push(queue.shift()) + } + } + return result +} + +export function selectSameChannelRoutes(routes) { + const grouped = new Map() + for (const route of routes) { + const key = logisticsKey(route) + if (!grouped.has(key)) grouped.set(key, []) + grouped.get(key).push(route) + } + const candidates = [...grouped.values()] + if (candidates.length === 0) throw new Error('No priced route is available for same-channel orders') + const maxDestinationCount = Math.max(...candidates.map((group) => group.length)) + return shuffled(pickRandom(candidates.filter((group) => group.length === maxDestinationCount))) +} + +export function randomOrderOptions() { + return { + tradeTerm: pickRandom(TRADE_TERMS), + pickupMethod: pickRandom(PICKUP_METHODS), + taxMode: pickRandom(TAX_MODES), + customsInspection: randomInt(2) === 1, + expectedShipDays: randomInt(7, 61), + senderLocation: pickRandom(SENDER_LOCATIONS), + } +} + +function requireSuccess(response, action) { + if (response.http !== 200 || String(response.body?.code) !== '200') { + throw new Error(`${action} failed: http=${response.http}, code=${response.body?.code ?? 'missing'}, message=${response.body?.message ?? 'missing'}, requestId=${response.requestId || 'missing'}, traceId=${response.traceId || 'missing'}`) + } + return response.body?.data +} + +async function request(gateway, timeoutMs, method, path, { token, body, headers = {} } = {}) { + const requestHeaders = { Accept: 'application/json', ...headers } + if (token) requestHeaders.Authorization = `Bearer ${token}` + let requestBody + if (body instanceof FormData) { + requestBody = body + } else if (body !== undefined) { + requestHeaders['Content-Type'] = 'application/json' + requestBody = JSON.stringify(body) + } + const response = await fetch(`${gateway}${path}`, { + method, + headers: requestHeaders, + body: requestBody, + signal: AbortSignal.timeout(timeoutMs), + }) + const text = await response.text() + let responseBody = null + try { + responseBody = text ? JSON.parse(text) : null + } catch { + responseBody = { code: 'INVALID_JSON', message: text.slice(0, 500) } + } + return { + http: response.status, + body: responseBody, + requestId: response.headers.get('x-request-id') || responseBody?.requestId, + traceId: response.headers.get('x-trace-id') || responseBody?.traceId, + } +} + +async function loadPricedRoutes(gateway, timeoutMs, token) { + const domesticResponse = await request(gateway, timeoutMs, 'GET', + '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', { token }) + const domestic = records(requireSuccess(domesticResponse, 'Query active domestic warehouses')) + const destinationResponse = await request(gateway, timeoutMs, 'GET', + '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', { token }) + const destinations = records(requireSuccess(destinationResponse, 'Query active FBA warehouses')) + if (domestic.length === 0 || destinations.length === 0) { + throw new Error('No active domestic receiving warehouse or FBA destination warehouse is available') + } + + const routes = [] + receivingLoop: for (const receiving of shuffled(domestic)) { + const channelResponse = await request(gateway, timeoutMs, 'GET', + `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, { token }) + const channels = items(requireSuccess(channelResponse, 'Query active pricing channels')) + .filter((channel) => channel?.channelCode && items(channel.pricingTaxModes).length > 0) + for (const channel of shuffled(channels)) { + for (const pricingTaxMode of shuffled(channel.pricingTaxModes)) { + let logisticsDestinationCount = 0 + for (const destination of shuffled(destinations)) { + const estimateResponse = await request(gateway, timeoutMs, 'POST', + '/api/merchant/v1/first-leg/pricing/estimate', { + token, + body: { + originWarehouseId: receiving.id, + channelCode: channel.channelCode, + destinationCode: destination.warehouseCode, + destinationType: 'FBA', + destinationWarehouseId: destination.id, + destinationName: destination.warehouseName, + pricingTaxMode, + actualWeightKg: 20.5, + actualVolumeCbm: 0.024, + }, + }) + if (estimateResponse.http === 200 && String(estimateResponse.body?.code) === '200') { + routes.push({ receiving, destination, channel, pricingTaxMode }) + logisticsDestinationCount += 1 + if (routes.length >= MAX_ROUTE_POOL_SIZE) break receivingLoop + if (logisticsDestinationCount >= MAX_DESTINATIONS_PER_LOGISTICS) break + } + } + } + } + } + if (routes.length === 0) throw new Error('No priced route is available for the fixed merchant account') + return routes +} + +function testPng() { + return Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64')) +} + +async function uploadProductImage(gateway, timeoutMs, token, marker) { + const form = new FormData() + form.append('file', new Blob([testPng()], { type: 'image/png' }), `pending-order-${marker}.png`) + const response = await request(gateway, timeoutMs, 'POST', + '/api/merchant/v1/files/product-images', { token, body: form }) + const data = requireSuccess(response, 'Upload product image') + if (!data?.fileId) throw new Error('Product image upload did not return fileId') + return data.fileId +} + +async function ensurePickupAddress(gateway, timeoutMs, token, profile, marker) { + const listResponse = await request(gateway, timeoutMs, 'GET', + '/api/merchant/v1/orders/first-leg/addresses?addressType=PICKUP', { token }) + const existing = items(requireSuccess(listResponse, 'Query pickup addresses')) + .find((address) => address?.id) + if (existing) return String(existing.id) + + const location = pickRandom(SENDER_LOCATIONS) + const createResponse = await request(gateway, timeoutMs, 'POST', + '/api/merchant/v1/orders/first-leg/addresses', { + token, + body: { + addressType: 'PICKUP', + addressName: '待审核订单测试取货地址', + companyName: profile.tenantName || `固定客户-${marker}`, + contactName: '订单测试联系人', + phone: '13800138000', + email: 'pending-order@example.com', + countryCode: 'CN', + province: location.province, + city: location.city, + district: location.district, + addressLine: `${marker} 固定客户测试地址`, + postalCode: location.postalCode, + defaultAddress: true, + }, + }) + const addressId = String(requireSuccess(createResponse, 'Create pickup address') ?? '') + if (!addressId) throw new Error('Create pickup address did not return address ID') + return addressId +} + +export function buildOrderPayload({ marker, index, profile, master, product, packageCount, options = randomOrderOptions(), productImageFileId, pickupAddressId }) { + const sequence = String(index).padStart(3, '0') + const shipmentId = `SHIP-${sequence}-${marker}`.slice(0, 64) + const referenceId = `REF-${sequence}-${marker}`.slice(0, 64) + const companyName = profile.tenantName || `固定客户-${marker}` + const totalQuantity = product.unitsPerPackage * packageCount + const grossWeightKg = round(product.cartonGrossWeightKg * packageCount, 3) + const netWeightKg = round(grossWeightKg * 0.9, 3) + const volumeCbm = round(product.lengthCm * product.widthCm * product.heightCm / 1_000_000 * packageCount, 6) + const declaredValue = round(product.unitDeclaredValue * totalQuantity, 2) + return { + customerId: profile.id, + customerName: companyName, + transportMode: 'SEA', + tradeTerm: options.tradeTerm, + destinationCountryCode: master.destination.countryCode || 'US', + destinationWarehouseId: master.destination.id, + destinationWarehouseName: master.destination.warehouseName, + expectedShipDate: addDays(new Date(), options.expectedShipDays), + customerRemark: `基础待审核订单 ${sequence} ${marker} ${options.tradeTerm}`, + pickupMethod: options.pickupMethod, + receivingWarehouseId: master.receiving.id, + receivingWarehouseName: master.receiving.warehouseName, + channelCode: master.channel.channelCode, + channelName: master.channel.channelName, + carrierId: null, + carrierName: null, + taxMode: options.taxMode, + pricingTaxMode: master.pricingTaxMode, + containerMode: 'LCL', + customsInspection: options.customsInspection, + destinationType: 'FBA', + amazonWarehouseCode: master.destination.warehouseCode, + shipmentId, + referenceId, + requestedPickupAt: options.pickupMethod === 'PICKUP' + ? new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19) : null, + senderAddressId: options.pickupMethod === 'PICKUP' ? pickupAddressId : null, + sender: null, + receiver: null, + cargoes: [{ + skuNo: `SKU-${sequence}-${marker}`.slice(0, 64), + cargoName: product.cargoName, + cargoNameEn: product.cargoNameEn, + hsCode: product.hsCode, + customsHsCode: product.hsCode, + packageType: 'CARTON', + lengthCm: product.lengthCm, + widthCm: product.widthCm, + heightCm: product.heightCm, + unitsPerPackage: product.unitsPerPackage, + packageCount, + grossWeightKg, + volumeCbm, + totalQuantity, + netWeightKg, + unitDeclaredValue: product.unitDeclaredValue, + declaredValue, + currency: 'USD', + material: product.material, + usage: product.usage, + model: `${product.modelPrefix}-${sequence}`, + brand: product.brand, + productLengthCm: product.productLengthCm, + productWidthCm: product.productWidthCm, + productHeightCm: product.productHeightCm, + shipmentId, + referenceId, + upcNo: `${Date.now()}${sequence}`.slice(-12), + productImageFileId, + woodenProduct: false, + dangerousGoods: false, + cargoRemark: `待审核订单货物 ${sequence} ${marker}`, + }], + } +} + +function printHelp() { + console.log(`Usage: + node scripts/create-pending-review-orders.mjs --gateway --count <1-${MAX_COUNT}> [--same-channel] + +Environment: + ADMIN_TEST_GATEWAY Test environment Gateway URL + MERCHANT_TEST_PASSWORD Password for the configured fixed account + MERCHANT_TEST_USERNAME Username for the configured fixed account + PENDING_REVIEW_ORDER_COUNT Optional order count, defaults to ${DEFAULT_COUNT} + PENDING_ORDER_REPORT Optional JSON report path + +Command-line arguments override environment variables. Credentials must not be written into this file.`) +} + +export async function main(argv = process.argv.slice(2)) { + const args = parseArguments(argv) + if (args.help) { + printHelp() + return 0 + } + const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY) + if (!gateway) throw new Error('Gateway is required via --gateway or ADMIN_TEST_GATEWAY') + const count = validateCount(args.count ?? process.env.PENDING_REVIEW_ORDER_COUNT) + const username = args.username ?? process.env.MERCHANT_TEST_USERNAME + const password = args.password ?? process.env.MERCHANT_TEST_PASSWORD + if (!username) throw new Error('Merchant username is required via --username or MERCHANT_TEST_USERNAME') + if (!password) throw new Error('Merchant password is required via --password or MERCHANT_TEST_PASSWORD') + const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000) + if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) throw new Error('--timeout-ms must be at least 1000') + + const marker = `PENDING-${compactTimestamp()}-${randomUUID().slice(0, 8)}` + const outputPath = resolve(args.output ?? process.env.PENDING_ORDER_REPORT + ?? `reports/pending-review-orders-${marker}.json`) + const startedAt = new Date().toISOString() + const orders = [] + + const loginResponse = await request(gateway, timeoutMs, 'POST', '/api/auth/login', { + body: { loginId: username, password }, + }) + const login = requireSuccess(loginResponse, 'Merchant login') + if (!login?.accessToken) throw new Error('Merchant login did not return accessToken') + const token = login.accessToken + + const profileResponse = await request(gateway, timeoutMs, 'GET', + '/api/merchant/v1/iam/tenant/profile', { token }) + const profile = requireSuccess(profileResponse, 'Query merchant profile') + if (!profile?.id || profile.status !== 'ACTIVE') { + throw new Error(`Fixed merchant account is not ready: tenantId=${profile?.id ?? 'missing'}, status=${profile?.status ?? 'missing'}`) + } + + const routePool = await loadPricedRoutes(gateway, timeoutMs, token) + const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker) + const pickupAddressId = await ensurePickupAddress(gateway, timeoutMs, token, profile, marker) + const randomizedProducts = shuffled(PRODUCT_CATALOG) + const sameChannel = args['same-channel'] === true + const randomizedRoutes = sameChannel ? selectSameChannelRoutes(routePool) : diversifyRoutes(routePool) + + for (let index = 1; index <= count; index += 1) { + const master = randomizedRoutes[(index - 1) % randomizedRoutes.length] + const product = randomizedProducts[(index - 1) % randomizedProducts.length] + const packageCount = randomInt(1, 31) + const options = randomOrderOptions() + const payload = buildOrderPayload({ marker, index, profile, master, product, packageCount, options, productImageFileId, pickupAddressId }) + const createResponse = await request(gateway, timeoutMs, 'POST', + '/api/merchant/v1/orders/first-leg', { + token, + body: payload, + headers: { 'Idempotency-Key': `${marker}-create-${index}`.slice(0, 128) }, + }) + const orderId = String(requireSuccess(createResponse, `Create order ${index}`) ?? '') + if (!orderId) throw new Error(`Create order ${index} did not return orderId`) + + const submitResponse = await request(gateway, timeoutMs, 'POST', + `/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}/submit`, { + token, + headers: { 'Idempotency-Key': `${marker}-submit-${index}`.slice(0, 128) }, + }) + requireSuccess(submitResponse, `Submit order ${index}`) + + const detailResponse = await request(gateway, timeoutMs, 'GET', + `/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}`, { token }) + const detail = requireSuccess(detailResponse, `Verify order ${index}`) + if (detail?.order?.status !== 'SUBMITTED') { + throw new Error(`Order ${orderId} is not pending review: status=${detail?.order?.status ?? 'missing'}`) + } + const orderNo = String(detail.order.orderNo || '') + if (!orderNo) throw new Error(`Order ${orderId} detail did not return orderNo`) + const unifiedResponse = await request(gateway, timeoutMs, 'GET', + `/api/merchant/v1/orders/${encodeURIComponent(orderId)}`, { token }) + const unifiedOrder = requireSuccess(unifiedResponse, `Verify unified order ${index}`) + if (unifiedOrder?.summary?.mainStatus !== 'PENDING_REVIEW') { + throw new Error(`Order ${orderId} unified status is not pending review: mainStatus=${unifiedOrder?.summary?.mainStatus ?? 'missing'}`) + } + const result = { + index, + orderId, + orderNo, + clientStatus: detail.order.status, + mainStatus: unifiedOrder.summary.mainStatus, + destinationWarehouseId: String(master.destination.id), + destinationWarehouseCode: master.destination.warehouseCode, + destinationWarehouseName: master.destination.warehouseName, + receivingWarehouseId: String(master.receiving.id), + receivingWarehouseName: master.receiving.warehouseName, + channelCode: master.channel.channelCode, + channelName: master.channel.channelName, + pricingTaxMode: master.pricingTaxMode, + tradeTerm: options.tradeTerm, + pickupMethod: options.pickupMethod, + taxMode: options.taxMode, + customsInspection: options.customsInspection, + expectedShipDate: payload.expectedShipDate, + cargoName: product.cargoName, + packageCount, + } + orders.push(result) + console.log(`CREATED ${index}/${count} orderId=${result.orderId} orderNo=${result.orderNo} logistics=${result.channelCode}/${result.pricingTaxMode} destination=${result.destinationWarehouseCode} pickup=${result.pickupMethod} tax=${result.taxMode} inspection=${result.customsInspection} product=${result.cargoName} cartons=${result.packageCount} status=PENDING_REVIEW`) + } + + const report = { + reportName: '固定客户待审核订单批量生成', + success: true, + marker, + username, + tenantId: String(profile.id), + requestedCount: count, + createdCount: orders.length, + sameChannel, + startedAt, + completedAt: new Date().toISOString(), + productImageFileId: String(productImageFileId), + pricedRoutePoolSize: routePool.length, + logisticsPoolSize: new Set(routePool.map(logisticsKey)).size, + productCatalogSize: PRODUCT_CATALOG.length, + orders, + securityNote: '报告不保存密码或访问令牌。', + } + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') + console.log(`REPORT=${outputPath}`) + console.log(`RESULT=PASS created=${orders.length}`) + return 0 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().then((code) => { process.exitCode = code }).catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + }) +} diff --git a/scripts/current-session-overseas-relabel-acceptance.mjs b/scripts/current-session-overseas-relabel-acceptance.mjs new file mode 100644 index 000000000..df617b1d5 --- /dev/null +++ b/scripts/current-session-overseas-relabel-acceptance.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' + +const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' +const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' +const store = process.env.CODEX_BROWSER_LOCAL_STORAGE || join(process.env.APPDATA || '', 'Codex', 'web', 'Codex', 'Default', 'Partitions', 'codex-browser-app', 'Local Storage', 'leveldb') +const outputDir = resolve(process.env.OVERSEAS_CURRENT_SESSION_OUTPUT_DIR || 'reports/current-session-overseas-relabel') +const adminLoginId = process.env.ADMIN_TEST_USERNAME || 'platform_admin' +const adminPassword = process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!' +const marker = `CURRENT-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` +const summary = { marker, startedAt: new Date().toISOString(), scope: 'CURRENT_CODEX_BROWSER_SESSION', passed: false } + +function assert(value, message) { if (!value) throw new Error(message) } +function id(value) { return value == null ? '' : String(value) } +function jwtId(token, ...claimNames) { + try { + const payload = Buffer.from(token.split('.')[1], 'base64url').toString('utf8') + for (const claimName of claimNames) { + const match = payload.match(new RegExp(`"${claimName}"\\s*:\\s*(?:"(\\d+)"|(\\d+))`)) + if (match) return match[1] || match[2] + } + } catch {} + return '' +} +async function api(base, token, method, path, body) { + const response = await fetch(`${base}${path}`, { method, + headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + body: body === undefined ? undefined : JSON.stringify(body) }) + const text = await response.text(); let value + try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } + if (!response.ok || String(value?.code) !== '200') throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`) + return value.data +} +async function rawApi(base, token, method, path, body) { + const response = await fetch(`${base}${path}`, { method, + headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + body: body === undefined ? undefined : JSON.stringify(body) }) + const text = await response.text(); let value + try { value = text ? JSON.parse(text) : null } catch { value = null } + return { http: response.status, code: String(value?.code || ''), data: value?.data } +} +function storedValues(key) { + assert(existsSync(store), `Codex browser local storage not found: ${store}`) + const values = [] + const files = readdirSync(store).filter((name) => /\.(?:log|ldb)$/.test(name)) + .map((name) => ({ name, path: join(store, name) })) + .sort((left, right) => readFileSync(right.path).length - readFileSync(left.path).length) + for (const file of files) { + const source = readFileSync(file.path).toString('latin1') + let offset = 0 + while ((offset = source.indexOf(key, offset)) >= 0) { + const match = source.slice(offset + key.length, offset + key.length + 256).match(/[A-Za-z0-9_-]{48,160}/) + if (match) values.push(match[0]) + offset += key.length + } + } + return [...new Set(values)].reverse() +} +function storedJwt(key) { + for (const file of readdirSync(store).filter((name) => /\.(?:log|ldb)$/.test(name)).reverse()) { + const source = readFileSync(join(store, file)).toString('latin1') + let offset = source.lastIndexOf(key) + while (offset >= 0) { + const match = source.slice(offset + key.length, offset + key.length + 2000) + .match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/) + if (match) return match[0] + offset = source.lastIndexOf(key, offset - 1) + } + } + return '' +} +async function refreshStoredSession(key, label) { + for (const refreshToken of storedValues(key)) { + try { return await api(domestic, '', 'POST', '/api/auth/refresh', { refreshToken }) } catch {} + } + throw new Error(`No valid ${label} refresh token was found in the current Codex browser session`) +} +async function disableStalePdas(adminToken) { + const users = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/users?page=1&size=100&keyword=pda_current_') + for (const user of users.records || []) { + if (String(user.username || '').startsWith('pda_current_') && user.status === 'ACTIVE') { + await api(domestic, adminToken, 'PUT', `/api/platform/v1/iam/users/${id(user.membershipId || user.id)}/status`, { status: 'DISABLED' }) + } + } +} +async function createPda(adminToken, warehouseId, onCreated) { + const roles = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/roles') + const role = roles.find((item) => item.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item.status === 'ACTIVE') + assert(role, 'Active OVERSEAS_WAREHOUSE_PDA_OPERATOR role not found') + const suffix = `${Date.now()}${process.pid}`.slice(-12) + const username = `pda_current_${suffix}` + const password = `PdaCurrent${suffix}A9` + const created = await api(domestic, adminToken, 'POST', '/api/platform/v1/iam/users', { + username, displayName: `当前会话海外PDA验收-${suffix}`, phone: `19${suffix.slice(-9)}`, + email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)], + overseasWarehouseId: warehouseId, password, status: 'ACTIVE', + }) + onCreated({ membershipId: id(created.membershipId), token: null }) + const login = await api(domestic, '', 'POST', '/api/auth/login', { loginId: username, password }) + assert(login.overseasAccessToken, 'Temporary PDA login did not return an overseas token') + onCreated({ membershipId: id(created.membershipId), token: login.overseasAccessToken }) + for (let attempt = 0; attempt < 60; attempt += 1) { + const response = await rawApi(overseas, login.overseasAccessToken, 'GET', '/api/pda/v1/last-leg/receiving-containers/current') + if (response.http === 200 && response.code !== '403' && response.code !== 'A0403') { + return { membershipId: id(created.membershipId), token: login.overseasAccessToken } + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error('Temporary PDA security projection did not converge') +} + +await mkdir(outputDir, { recursive: true }) +let admin +let pda +try { + try { + admin = await refreshStoredSession('cc-admin-refresh-token', 'platform') + } catch { + admin = await api(domestic, '', 'POST', '/api/auth/login', { loginId: adminLoginId, password: adminPassword }) + } + const storedMerchantToken = storedJwt('cc-client-overseas-access-token') + const storedCustomerId = jwtId(storedMerchantToken, 'tenant_id', 'tenantId') + let merchant + try { + merchant = await refreshStoredSession('cc-client-refresh-token', 'merchant') + } catch { + assert(storedCustomerId, 'Current Codex merchant session does not contain a customer id') + const ticket = await api(domestic, admin.accessToken, 'POST', + `/api/platform/v1/iam/tenants/${storedCustomerId}/impersonation`, { reason: `${marker} 当前客户海外API验收` }) + merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) + } + assert(admin.accessToken && admin.overseasAccessToken && merchant.accessToken && merchant.overseasAccessToken, 'Refresh response did not contain both regional tokens') + const customerId = jwtId(merchant.overseasAccessToken, 'tenant_id', 'tenantId') + assert(customerId, 'Current merchant customer id is missing from the overseas token') + const warehouses = await api(domestic, merchant.accessToken, 'GET', '/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200') + let selected = null + for (const warehouse of warehouses.records || []) { + if (warehouse.status !== 'ACTIVE') continue + const candidates = await api(overseas, merchant.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${encodeURIComponent(id(warehouse.id))}`) + if (candidates.some((item) => String(item.inboundOrderNo || '').startsWith('DS-') && Number(item.availablePackages) >= 5 && item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5))) { + selected = warehouse; break + } + } + assert(selected, 'Current merchant has no active overseas warehouse with a five-carton DS inventory source') + await disableStalePdas(admin.accessToken) + pda = await createPda(admin.accessToken, id(selected.id), (created) => { pda = created }) + + const relabelReport = join(outputDir, 'relabel.json') + const fulfillmentReport = join(outputDir, 'fulfillment.json') + const childEnv = { ...process.env, ADMIN_TEST_GATEWAY: domestic, ADMIN_OVERSEAS_GATEWAY: overseas, + ADMIN_OVERSEAS_ACCESS_TOKEN: admin.overseasAccessToken, MERCHANT_OVERSEAS_ACCESS_TOKEN: merchant.overseasAccessToken, + PDA_OVERSEAS_ACCESS_TOKEN: pda.token, OVERSEAS_TEST_CUSTOMER_ID: customerId, + OVERSEAS_TEST_WAREHOUSE_ID: id(selected.id), OVERSEAS_ACCEPTANCE_OUTPUT: relabelReport, + OVERSEAS_ACCEPTANCE_SOURCE: relabelReport, OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport } + for (const script of ['overseas-relabel-real-acceptance.mjs', 'overseas-relabel-fulfillment-acceptance.mjs']) { + const result = spawnSync(process.execPath, [resolve('scripts', script)], { cwd: resolve('.'), env: childEnv, stdio: 'inherit' }) + if (result.error) throw result.error + if (result.status !== 0) throw new Error(`${script} failed with exit code ${result.status}`) + } + Object.assign(summary, { customerId, warehouseId: id(selected.id), warehouseCode: selected.warehouseCode, + relabelReport, fulfillmentReport, passed: true, finishedAt: new Date().toISOString() }) +} finally { + if (admin?.accessToken && pda?.membershipId) { + try { await api(domestic, admin.accessToken, 'PUT', `/api/platform/v1/iam/users/${pda.membershipId}/status`, { status: 'DISABLED' }); summary.pdaCleanup = 'DISABLED' } + catch (error) { summary.pdaCleanup = `FAILED: ${error.message}` } + } + await writeFile(join(outputDir, 'summary.json'), JSON.stringify(summary, null, 2), 'utf8') +} +console.log(JSON.stringify(summary, null, 2)) diff --git a/scripts/database-comment-audit.ps1 b/scripts/database-comment-audit.ps1 new file mode 100644 index 000000000..3e8b5e2f7 --- /dev/null +++ b/scripts/database-comment-audit.ps1 @@ -0,0 +1,230 @@ +[CmdletBinding()] +param( + [string]$RepositoryRoot, + [string[]]$Modules, + [ValidateSet("Text", "Json")] + [string]$Format = "Text", + [switch]$SummaryOnly, + [switch]$FailOnIssues, + [switch]$SelfTest +) + +$ErrorActionPreference = "Stop" + +function Get-SqlComment { + param([string]$Text) + + $match = [regex]::Match($Text, "(?is)\bCOMMENT\s*(?:=\s*)?'(?(?:''|[^'])*)'") + if (-not $match.Success) { + return $null + } + return $match.Groups["value"].Value.Replace("''", "'").Trim() +} + +function Get-CreateTableDefinitions { + param( + [string]$Sql, + [string]$Source + ) + + $pattern = '(?is)\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?`?[A-Za-z_][A-Za-z0-9_$]*`?)\s*\((?.*?)\)\s*(?ENGINE\s*=.*?);' + $mysqlColumnTypes = @( + "BIGINT", "INT", "INTEGER", "MEDIUMINT", "SMALLINT", "TINYINT", + "DECIMAL", "NUMERIC", "FIXED", "FLOAT", "DOUBLE", "REAL", "BIT", "BOOL", "BOOLEAN", + "CHAR", "VARCHAR", "NCHAR", "NVARCHAR", "BINARY", "VARBINARY", + "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT", + "TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB", "ENUM", "SET", + "DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR", "JSON", + "GEOMETRY", "POINT", "LINESTRING", "POLYGON", "MULTIPOINT", + "MULTILINESTRING", "MULTIPOLYGON", "GEOMETRYCOLLECTION" + ) + $matches = [regex]::Matches($Sql, $pattern) + $definitions = @() + + foreach ($match in $matches) { + $columns = @() + foreach ($line in ($match.Groups["body"].Value -split "`r?`n")) { + $columnMatch = [regex]::Match($line, '^\s*(?`?[A-Za-z_][A-Za-z0-9_$]*`?)\s+(?[A-Za-z].*?)\s*,?\s*$') + if (-not $columnMatch.Success) { + continue + } + + $definition = $columnMatch.Groups["definition"].Value + $typeMatch = [regex]::Match($definition, '^(?[A-Za-z]+)\b') + if (-not $typeMatch.Success -or $typeMatch.Groups["type"].Value.ToUpperInvariant() -notin $mysqlColumnTypes) { + continue + } + + $columnName = $columnMatch.Groups["name"].Value.Trim('`') + $columns += [pscustomobject]@{ + Name = $columnName + Comment = Get-SqlComment -Text $line + } + } + + $definitions += [pscustomobject]@{ + Source = $Source + Table = $match.Groups["name"].Value.Trim('`') + Comment = Get-SqlComment -Text $match.Groups["tail"].Value + Columns = $columns + } + } + + return $definitions +} + +function Test-ChineseComment { + param([AllowNull()][string]$Comment) + return -not [string]::IsNullOrWhiteSpace($Comment) -and $Comment -match '[\p{IsCJKUnifiedIdeographs}]' +} + +function Get-AuditResult { + param([object[]]$Definitions) + + $issues = @() + $tableCount = 0 + $commentedTableCount = 0 + $columnCount = 0 + $commentedColumnCount = 0 + + foreach ($definition in $Definitions) { + $tableCount++ + if (-not [string]::IsNullOrWhiteSpace($definition.Comment)) { + $commentedTableCount++ + } + + if ([string]::IsNullOrWhiteSpace($definition.Comment)) { + $issues += [pscustomobject]@{ Code = "MISSING_TABLE_COMMENT"; Source = $definition.Source; Table = $definition.Table; Column = $null } + } elseif (-not (Test-ChineseComment -Comment $definition.Comment)) { + $issues += [pscustomobject]@{ Code = "NON_CHINESE_TABLE_COMMENT"; Source = $definition.Source; Table = $definition.Table; Column = $null } + } + + foreach ($column in $definition.Columns) { + $columnCount++ + if (-not [string]::IsNullOrWhiteSpace($column.Comment)) { + $commentedColumnCount++ + } + + if ([string]::IsNullOrWhiteSpace($column.Comment)) { + $issues += [pscustomobject]@{ Code = "MISSING_COLUMN_COMMENT"; Source = $definition.Source; Table = $definition.Table; Column = $column.Name } + } elseif (-not (Test-ChineseComment -Comment $column.Comment)) { + $issues += [pscustomobject]@{ Code = "NON_CHINESE_COLUMN_COMMENT"; Source = $definition.Source; Table = $definition.Table; Column = $column.Name } + } + } + } + + $tableCoverage = if ($tableCount -eq 0) { 100 } else { [math]::Round(100 * $commentedTableCount / $tableCount, 2) } + $columnCoverage = if ($columnCount -eq 0) { 100 } else { [math]::Round(100 * $commentedColumnCount / $columnCount, 2) } + + return [pscustomobject]@{ + Summary = [pscustomobject]@{ + TableDefinitions = $tableCount + CommentedTables = $commentedTableCount + TableCoveragePercent = $tableCoverage + Columns = $columnCount + CommentedColumns = $commentedColumnCount + ColumnCoveragePercent = $columnCoverage + Issues = $issues.Count + } + Issues = $issues + } +} + +function Invoke-SelfTest { + $validSql = @" +CREATE TABLE IF NOT EXISTS sample_valid ( + id BIGINT NOT NULL COMMENT '主键ID', + status VARCHAR(16) NOT NULL COMMENT '状态:ACTIVE启用、DISABLED停用', + amount DECIMAL(20,6) NOT NULL COMMENT '含税金额;币种由currency_code指定', + and_flag TINYINT(1) NOT NULL COMMENT '附加条件标记:0否、1是', + CONSTRAINT chk_sample_status CHECK ( + status IN ('ACTIVE', 'DISABLED') + AND amount >= 0 + OR (status = 'DISABLED' AND amount = 0) + ) +) ENGINE=InnoDB COMMENT='自测业务表'; +"@ + $invalidSql = @" +CREATE TABLE sample_invalid ( + id BIGINT NOT NULL, + payload JSON NULL COMMENT 'JSON payload' +) ENGINE=InnoDB COMMENT='Sample table'; +"@ + + $valid = Get-AuditResult -Definitions @(Get-CreateTableDefinitions -Sql $validSql -Source "valid.sql") + if ($valid.Summary.TableDefinitions -ne 1 -or $valid.Summary.Columns -ne 4 -or $valid.Summary.Issues -ne 0) { + throw "Self-test failed: valid SQL was not parsed as expected." + } + + $invalid = Get-AuditResult -Definitions @(Get-CreateTableDefinitions -Sql $invalidSql -Source "invalid.sql") + $codes = @($invalid.Issues | ForEach-Object { $_.Code }) + foreach ($expected in @("NON_CHINESE_TABLE_COMMENT", "MISSING_COLUMN_COMMENT", "NON_CHINESE_COLUMN_COMMENT")) { + if ($expected -notin $codes) { + throw "Self-test failed: missing expected issue $expected." + } + } + + Write-Output "SELF-TEST PASS: parser, coverage and Chinese-comment checks are working." +} + +if ($SelfTest) { + Invoke-SelfTest + exit 0 +} + +if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { + $RepositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +} else { + $RepositoryRoot = (Resolve-Path $RepositoryRoot).Path +} + +$dbDirectories = @(Get-ChildItem -LiteralPath $RepositoryRoot -Directory | ForEach-Object { + $candidate = Join-Path $_.FullName "src/main/resources/db" + if (Test-Path -LiteralPath $candidate -PathType Container) { + [pscustomobject]@{ Module = $_.Name; Path = $candidate } + } +}) + +if ($Modules -and $Modules.Count -gt 0) { + $requested = @($Modules | ForEach-Object { $_.ToLowerInvariant() }) + $dbDirectories = @($dbDirectories | Where-Object { $_.Module.ToLowerInvariant() -in $requested }) + $found = @($dbDirectories | ForEach-Object { $_.Module.ToLowerInvariant() }) + $missingModules = @($requested | Where-Object { $_ -notin $found }) + if ($missingModules.Count -gt 0) { + Write-Error ("Unknown modules or missing db directories: " + ($missingModules -join ", ")) + exit 2 + } +} + +$definitions = @() +foreach ($dbDirectory in ($dbDirectories | Sort-Object Module)) { + foreach ($file in (Get-ChildItem -LiteralPath $dbDirectory.Path -Filter "*.sql" -File | Sort-Object FullName)) { + $relativePath = $file.FullName.Substring($RepositoryRoot.Length + 1).Replace('\', '/') + $sql = Get-Content -Raw -LiteralPath $file.FullName + $definitions += @(Get-CreateTableDefinitions -Sql $sql -Source $relativePath) + } +} + +$result = Get-AuditResult -Definitions $definitions +if ($Format -eq "Json") { + $jsonValue = if ($SummaryOnly) { $result.Summary } else { $result } + Write-Output ($jsonValue | ConvertTo-Json -Depth 6) +} else { + Write-Output "Database comment audit" + Write-Output "Repository: $RepositoryRoot" + Write-Output "Database/API access: none" + Write-Output ("Table definitions: {0}, commented: {1}, coverage: {2}%" -f $result.Summary.TableDefinitions, $result.Summary.CommentedTables, $result.Summary.TableCoveragePercent) + Write-Output ("Columns: {0}, commented: {1}, coverage: {2}%" -f $result.Summary.Columns, $result.Summary.CommentedColumns, $result.Summary.ColumnCoveragePercent) + Write-Output ("Issues: {0}" -f $result.Summary.Issues) + if (-not $SummaryOnly) { + foreach ($issue in $result.Issues) { + $target = if ([string]::IsNullOrWhiteSpace($issue.Column)) { $issue.Table } else { "$($issue.Table).$($issue.Column)" } + Write-Output ("[{0}] {1} ({2})" -f $issue.Code, $target, $issue.Source) + } + } +} + +if ($FailOnIssues -and $result.Summary.Issues -gt 0) { + exit 1 +} +exit 0 diff --git a/scripts/db/cargo-discard-pda-incremental.sql b/scripts/db/cargo-discard-pda-incremental.sql new file mode 100644 index 000000000..294117cdf --- /dev/null +++ b/scripts/db/cargo-discard-pda-incremental.sql @@ -0,0 +1,20 @@ +USE baiyue_lastleg; + +ALTER TABLE ll_cargo_discard_item + ADD COLUMN actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数' AFTER discard_packages, + ADD COLUMN pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/COMPLETED' AFTER actual_discard_packages, + ADD COLUMN pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人' AFTER pda_status, + ADD COLUMN pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间' AFTER pda_operator_id; + +INSERT IGNORE INTO ll_order ( + id,order_no,customer_id,order_type,inbound_code,inbound_order_no,source_region,source_inbound_id, + source_order_id,source_order_no,overseas_warehouse_id,overseas_warehouse_name,package_count, + current_status,version,source_event_id,source_loading_plan_id,source_payload_hash,created_at,updated_at +) +SELECT a.id,a.discard_no,a.customer_id,'CARGO_DISCARD',a.inbound_codes,a.discard_no,'LASTLEG_DISCARD', + CONCAT('CARGO_DISCARD:',a.id),CAST(a.id AS CHAR),a.discard_no,a.overseas_warehouse_id,a.overseas_warehouse_name, + a.total_packages,a.status,0,CONCAT('CARGO_DISCARD:',a.id),'CARGO_DISCARD',a.request_hash,a.created_at,a.updated_at +FROM ll_cargo_discard_application a +LEFT JOIN ll_order o ON o.customer_id=a.customer_id AND o.source_region='LASTLEG_DISCARD' + AND o.source_inbound_id=CONCAT('CARGO_DISCARD:',a.id) +WHERE o.id IS NULL; diff --git a/scripts/db/dev-lastleg-relabel-return-incremental.sql b/scripts/db/dev-lastleg-relabel-return-incremental.sql new file mode 100644 index 000000000..fb1e62737 --- /dev/null +++ b/scripts/db/dev-lastleg-relabel-return-incremental.sql @@ -0,0 +1,253 @@ +-- 尾程贴标申请 / 退货换标 dev 增量(MySQL 8.0) +-- 目标数据库:baiyue_lastleg、baiyue_lastlag_warehouse +-- 依据:2026-09-10 本地 127.0.0.1 与 dev 8.163.49.32 的 information_schema 只读对比。 +-- 说明:脚本可重复执行,不修改或删除业务行。 +-- MySQL DDL 会隐式提交,请先备份并完整执行。 + +CREATE DATABASE IF NOT EXISTS baiyue_lastleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +DROP PROCEDURE IF EXISTS _dev_lastleg_relabel_return_patch; +DELIMITER $$ +CREATE PROCEDURE _dev_lastleg_relabel_return_patch() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema=DATABASE() AND table_name='ll_relabel_item') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少基础表 ll_relabel_item,请先执行海外尾程基线'; + END IF; + + -- 贴标申请:FBA目的仓、换标模式、SKU组合明细及原库存快照。 + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code') THEN + ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓编码(与海外操作仓分离)'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='operation_type') THEN + ALTER TABLE ll_relabel_item ADD COLUMN operation_type VARCHAR(32) NOT NULL DEFAULT 'CARTON_LABEL' COMMENT '换标模式:箱唛、SKU或组合换标'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='sku_relabel_items_json') THEN + ALTER TABLE ll_relabel_item ADD COLUMN sku_relabel_items_json JSON NULL COMMENT '同箱SKU换标子明细快照(数量按件)'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_balance_id') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_balance_id BIGINT NULL COMMENT '原库存余额ID'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_position_id') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_position_id BIGINT NULL COMMENT '原库存库位ID'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_id') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_id BIGINT NULL COMMENT '原入仓单ID'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_no') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_no VARCHAR(128) NULL COMMENT '原入仓单号快照'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_code') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_code VARCHAR(128) NULL COMMENT '原进仓编码快照'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_id') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT '原海外中转仓ID'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_code') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_code VARCHAR(128) NULL COMMENT '原海外中转仓编码快照'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_name') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT '原海外中转仓名称快照'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_source_position') THEN + ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id); + END IF; + + -- 退货换标:签收箱出库时不要求库存仓位,并允许箱状态 OUTBOUNDED。 + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pda_pick' AND column_name='inventory_position_id' AND is_nullable='NO') THEN + ALTER TABLE ll_outbound_pda_pick MODIFY COLUMN inventory_position_id BIGINT NULL COMMENT '库存仓位ID,退货箱唛出库为空'; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity') THEN + ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity') THEN + ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((status IN ('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); + END IF; + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status') THEN + ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status') THEN + ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK (status IN ('AVAILABLE','MERGED','OUTBOUNDED')); + END IF; + + -- 出库箱唛快照:用于退货箱唛出库指定箱号。 + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='carton_marks') THEN + ALTER TABLE ll_outbound_pallet_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT '本次出库指定箱唛,逗号分隔'; + END IF; + + -- 本地已有、dev 缺失的海外采购出库记录表。 + 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='海外采购出库记录'; +END$$ +DELIMITER ; +CALL _dev_lastleg_relabel_return_patch(); +DROP PROCEDURE IF EXISTS _dev_lastleg_relabel_return_patch; + +-- 2026-09-10 本地相对 dev 的最近结构差异:换标完成确认、退货仓联系人及派车来源快照。 +DROP PROCEDURE IF EXISTS _dev_lastleg_recent_patch; +DELIMITER $$ +CREATE PROCEDURE _dev_lastleg_recent_patch() +BEGIN + -- 旧本地库可能缺少仓库主数据与退货列表查询字段。 + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_warehouse' AND table_name='wh_warehouse' AND column_name='internal_code') THEN + SET @ddl = 'ALTER TABLE baiyue_warehouse.wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code'; + PREPARE warehouse_internal_code_stmt FROM @ddl; EXECUTE warehouse_internal_code_stmt; DEALLOCATE PREPARE warehouse_internal_code_stmt; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_no') THEN + ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(128) NULL COMMENT '关联尾程订单号' AFTER lastleg_order_id; + END IF; + + -- 非 FBA 去向的换标不要求货件编号。 + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='shipment_no' AND is_nullable='NO') THEN + ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT '货件编号(无FBA去向时为空)'; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='sku_code' AND is_nullable='NO') THEN + ALTER TABLE ll_relabel_item MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU编码(组合换标时可为空)'; + END IF; + + -- 换标 PDA 完工、客户证据确认,以及卡车/自提/快递履约分流。 + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method') THEN + ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填' AFTER contact_phone; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label') THEN + ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要打印面单' AFTER delivery_method; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id') THEN + ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT '客户上传面单文件ID' AFTER print_shipping_label; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name') THEN + ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT '客户上传面单文件名' AFTER shipping_label_file_id; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_url') THEN + ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT '客户上传面单下载地址' AFTER shipping_label_file_name; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='fulfillment_status') THEN + ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT 'WAIT_RELABEL' COMMENT '换标后履约状态' AFTER shipping_label_file_url; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmation_key') THEN + ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT '客户确认完成幂等键' AFTER fulfillment_status; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at') THEN + ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认换标完成时间' AFTER customer_confirmation_key; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='outbound_order_no') THEN + ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT '自提或快递生成的换标出库单号' AFTER fulfillment_status; + END IF; + + -- 退货换标:保存操作中转仓联系人。 + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_name') THEN + ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照' AFTER overseas_warehouse_id; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_phone') THEN + ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照' AFTER warehouse_contact_name; + END IF; + -- 仓储转运允许非 FBA 入仓。 + 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 is_nullable='NO') THEN + ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码'; + END IF; + 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 is_nullable='NO') THEN + ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号'; + END IF; + -- 换标进入派车/出库后保留来源业务和箱唛快照。 + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks') THEN + ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照' AFTER source_stage; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type') THEN + ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT '来源业务类型' AFTER carton_marks; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no') THEN + ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT '来源业务单号' AFTER source_business_type; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='return_carton_id') THEN + ALTER TABLE ll_relabel_item ADD COLUMN return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID' AFTER carton_mark; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_carton_mark') THEN + ALTER TABLE ll_relabel_item ADD COLUMN source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照' AFTER return_carton_id; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='new_carton_mark') THEN + ALTER TABLE ll_relabel_item ADD COLUMN new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛' AFTER source_carton_mark; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_completed_at') THEN + ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT '关联贴标完成时间' AFTER relabel_no; + END IF; + +END$$ +DELIMITER ; +CALL _dev_lastleg_recent_patch(); +DROP PROCEDURE IF EXISTS _dev_lastleg_recent_patch; + +-- 2026-09-13 换标/合托按托盘维度作业:海外仓物理托盘允许没有库位。 +-- 只放宽托盘状态操作审计快照,不改变已有带库位的入仓资源预留逻辑。 +CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastlag_warehouse; +DROP PROCEDURE IF EXISTS _dev_locationless_pallet_patch; +DELIMITER $$ +CREATE PROCEDURE _dev_locationless_pallet_patch() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少基础表 wh_pallet_operation,请先执行海外仓基线'; + END IF; + 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 is_nullable='NO') THEN + ALTER TABLE wh_pallet_operation + MODIFY COLUMN before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空'; + END IF; + 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 is_nullable='NO') THEN + ALTER TABLE wh_pallet_operation + MODIFY COLUMN after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空'; + END IF; +END$$ +DELIMITER ; +CALL _dev_locationless_pallet_patch(); +DROP PROCEDURE IF EXISTS _dev_locationless_pallet_patch; +USE baiyue_lastleg; + +-- 2026-09-14 仓储转运 PDA 打托及换标绑定表(本地新增,dev 缺失时创建)。 +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet ( + id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + sequence_no INT NOT NULL COMMENT '托序号', warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码', + warehouse_location_id BIGINT NULL COMMENT '真实库位ID,无库位托盘为空', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', warehouse_resource_lock_id BIGINT NULL COMMENT '仓储资源锁ID', + carton_count INT NOT NULL COMMENT '本托实收箱数', length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', + height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', + status VARCHAR(32) NOT NULL COMMENT '打托状态', completed_at DATETIME(3) 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_transfer_pallet_seq(transfer_order_id,sequence_no), UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status) +) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实'; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID', + transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_item(transfer_pallet_id,transfer_item_id), KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填'; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型', + customer_carton_mark VARCHAR(255) NOT NULL COMMENT '客户原箱唛', original_sku VARCHAR(128) NOT NULL COMMENT '旧SKU', new_sku VARCHAR(128) NULL COMMENT '新SKU', quantity INT NOT NULL COMMENT '换标件数', + fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓', remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark), KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细'; + +-- 执行后只读核验:所有结果都应为 1。 +SELECT + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name IN ('destination_fba_warehouse_code','operation_type','sku_relabel_items_json','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id','source_inbound_order_no','source_inbound_code','source_warehouse_id','source_warehouse_code','source_warehouse_name')) = 11 AS relabel_fields_ready, + (SELECT is_nullable='YES' FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name='shipment_no') AS relabel_shipment_optional_ready, + (SELECT is_nullable='YES' FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name='sku_code') AS relabel_sku_optional_ready, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name IN ('delivery_method','print_shipping_label','shipping_label_file_id','shipping_label_file_name','shipping_label_file_url','fulfillment_status','outbound_order_no','customer_confirmation_key','customer_confirmed_at')) = 9 AS relabel_fulfillment_ready, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order' AND column_name IN ('warehouse_contact_name','warehouse_contact_phone')) = 2 AS return_order_contact_fields_ready, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_warehouse_transfer_order' AND column_name IN ('fba_warehouse_code','fba_shipment_no') AND is_nullable='YES') = 2 AS transfer_fba_optional_ready, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_dispatch_item' AND column_name IN ('carton_marks','source_business_type','source_business_no')) = 3 AS dispatch_relabel_source_ready, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name IN ('return_carton_id','source_carton_mark','new_carton_mark')) = 3 AS return_relabel_binding_ready, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_outbound_pallet_item' AND column_name='carton_marks') = 1 AS outbound_carton_marks_ready, + (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_overseas_procurement_outbound') = 1 AS procurement_outbound_ready, + (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name IN ('ll_warehouse_transfer_pallet','ll_warehouse_transfer_pallet_item','ll_warehouse_transfer_relabel_binding')) = 3 AS transfer_pallet_tables_ready, + (SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton' AND constraint_name IN ('chk_ll_return_inventory_carton_quantity','chk_ll_return_inventory_carton_status')) = 2 AS return_outbounded_checks_ready, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet_operation' AND column_name IN ('before_location_id','after_location_id') AND is_nullable='YES') = 2 AS locationless_pallet_operation_ready; diff --git a/scripts/db/fix-filter-name-503.sql b/scripts/db/fix-filter-name-503.sql new file mode 100644 index 000000000..b2f3d7a24 --- /dev/null +++ b/scripts/db/fix-filter-name-503.sql @@ -0,0 +1,34 @@ +-- 修复海外仓筛选名称、退货箱号和贴标申请列表 503。 +-- 可重复执行;请使用具备对应库 ALTER 权限的账号执行。 + +USE baiyue_warehouse; +SET @ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'wh_warehouse' + AND column_name = 'internal_code'), + 'SELECT 1', + 'ALTER TABLE wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +USE baiyue_lastleg; +SET @ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'll_return_order' + AND column_name = 'lastleg_order_no'), + 'SELECT 1', + 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(128) NULL COMMENT ''关联尾程订单号'' AFTER lastleg_order_id' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SELECT + (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_warehouse' AND table_name = 'wh_warehouse' AND column_name = 'internal_code') AS warehouse_internal_code_ready, + (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_return_order' AND column_name = 'lastleg_order_no') AS return_lastleg_order_no_ready; diff --git a/scripts/db/inbound-source-pallet-fields.sql b/scripts/db/inbound-source-pallet-fields.sql new file mode 100644 index 000000000..47a9cd779 --- /dev/null +++ b/scripts/db/inbound-source-pallet-fields.sql @@ -0,0 +1,8 @@ +-- 入仓计划与实收的一件代发来源托盘快照字段(目标库:baiyue_lastleg) +USE baiyue_lastleg; + +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='source_pallet_no'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN source_pallet_no VARCHAR(128) NULL COMMENT ''一件代发不可变来源托盘号快照'' AFTER pallet_no')); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; + +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='source_pallet_no'), 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN source_pallet_no VARCHAR(128) NULL COMMENT ''一件代发不可变来源托盘号快照'' AFTER pallet_no')); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql new file mode 100644 index 000000000..f705f966d --- /dev/null +++ b/scripts/db/incremental.sql @@ -0,0 +1,4222 @@ +-- 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), + KEY idx_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 '订单类型', lx_code VARCHAR(128) NULL 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_code VARCHAR(128) NOT NULL DEFAULT 'US-LAX-01' COMMENT '海外接柜仓业务编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称', + receiving_warehouse_version INT NOT NULL DEFAULT 0 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 '备注', + charge_amount DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '换标费用CNY', charge_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', + wallet_status VARCHAR(32) NOT NULL DEFAULT 'FREEZE_PENDING' COMMENT '资金状态', wallet_error_code VARCHAR(64) NULL COMMENT '资金错误码', + wallet_error_message VARCHAR(500) NULL COMMENT '资金错误说明', wallet_command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件ID', + 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) 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) NULL COMMENT '货件编号(无FBA去向时为空)', tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码', new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码', + label_requirement VARCHAR(1000) NULL COMMENT '换标要求', remark 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 '换标文件地址', + carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛', fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛', destination_warehouse_id VARCHAR(128) NULL COMMENT '该箱目的海外仓ID', + 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) 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) 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='转仓货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton ( + id BIGINT NOT NULL COMMENT '预报箱主键', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', + transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + carton_mark VARCHAR(255) NOT NULL COMMENT '系统生成的一箱一码箱唛', carton_index INT NOT NULL COMMENT '全单箱序号', + total_cartons INT NOT NULL COMMENT '首次生成时全单总箱数', status VARCHAR(32) NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收', + 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_carton_mark (tenant_id,carton_mark), + UNIQUE KEY uk_ll_wh_transfer_carton_index (transfer_order_id,carton_index), + KEY idx_ll_wh_transfer_carton_list (tenant_id,transfer_order_id,status) +) 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 NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', + source_pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照', + manifest_version INT NOT NULL COMMENT '整托清单版本', manifest_package_count INT NOT NULL COMMENT '整托总箱数', + manifest_fragment_count INT NOT NULL COMMENT '整托客户分片总数', manifest_hash CHAR(64) 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), + KEY idx_ll_dropship_profile_manifest(source_pallet_no,manifest_hash) +) 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_carton_sku ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', order_id BIGINT NOT NULL COMMENT '尾程订单ID', + profile_id BIGINT NOT NULL COMMENT '一件代发托盘档案ID', carton_id BIGINT NOT NULL COMMENT '一件代发物理箱ID', + sku_code VARCHAR(128) NOT NULL COMMENT '箱内SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称快照', + product_quantity BIGINT 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_carton_sku(carton_id,sku_code), + KEY idx_ll_dropship_carton_sku_order(customer_id,order_id,sku_code), + KEY idx_ll_dropship_carton_sku_profile(customer_id,profile_id,sku_code) +) ENGINE=InnoDB COMMENT='一件代发物理箱内SKU明细'; +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='lx_code'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN lx_code VARCHAR(128) NULL COMMENT ''一件代发领星入库单代码'' AFTER order_type')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; 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 (is_nullable='NO' OR COALESCE(column_comment,'') <> '可选FBA目的仓编码')), 'ALTER TABLE ll_warehouse_transfer_order 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_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 (is_nullable='NO' OR COALESCE(column_comment,'') <> '可选FBA货件编号')), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_shipment_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_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='charge_amount'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN charge_amount DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT ''换标费用CNY'' AFTER notified_package_count')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; 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='charge_currency'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN charge_currency CHAR(3) NOT NULL DEFAULT ''CNY'' COMMENT ''费用币种'' AFTER charge_amount')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; 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_file_id' AND COALESCE(column_comment,'') <> '总换标PDF文件ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN total_file_id VARCHAR(128) NULL COMMENT ''总换标PDF文件ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; 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_file_name' AND COALESCE(column_comment,'') <> '总换标PDF文件名'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN total_file_name VARCHAR(255) NULL COMMENT ''总换标PDF文件名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; 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_file_url' AND COALESCE(column_comment,'') <> '总换标PDF预览地址'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN total_file_url VARCHAR(1000) NULL COMMENT ''总换标PDF预览地址''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; 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='wallet_status'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_status VARCHAR(32) NOT NULL DEFAULT ''FREEZE_PENDING'' COMMENT ''资金状态'' AFTER charge_currency')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; 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='wallet_error_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_error_code VARCHAR(64) NULL COMMENT ''资金错误码'' AFTER wallet_status')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; 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='wallet_error_message'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_error_message VARCHAR(500) NULL COMMENT ''资金错误说明'' AFTER wallet_error_code')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; 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='wallet_command_event_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_command_event_id VARCHAR(64) NULL COMMENT ''当前资金指令事件ID'' AFTER wallet_error_message')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; 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 (is_nullable <> 'YES' OR COALESCE(column_comment,'') <> 'SKU编码(选填)')), 'ALTER TABLE ll_relabel_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_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='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; 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='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; 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='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; 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='destination_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''该箱目的海外仓ID'' AFTER fba_carton_mark')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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_destination'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; 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; + +-- ============================================================================ +-- 2026-09-07 / 贴标申请绑定原入仓库存并拆分FBA目的仓 +-- ============================================================================ +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA目的仓编码(与海外操作仓分离)''')); +PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; +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 is_nullable='NO'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT ''货件编号(无FBA去向时为空)''', 'SELECT 1')); +PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='operation_type'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN operation_type VARCHAR(32) NOT NULL DEFAULT ''CARTON_LABEL'' COMMENT ''换标模式:箱唛、SKU或组合换标''')); +PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; +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_relabel_items_json'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN sku_relabel_items_json JSON NULL COMMENT ''同箱SKU换标子明细快照(数量按件)''')); +PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_balance_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID'' AFTER destination_fba_warehouse_code')); +PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_position_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID'' AFTER source_inventory_balance_id')); +PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID'' AFTER source_inventory_position_id')); +PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_no VARCHAR(128) NULL COMMENT ''原入仓单号快照'' AFTER source_inbound_order_id')); +PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_code VARCHAR(128) NULL COMMENT ''原进仓编码快照'' AFTER source_inbound_order_no')); +PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''原海外中转仓ID'' AFTER source_inbound_code')); +PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_code VARCHAR(128) NULL COMMENT ''原海外中转仓编码快照'' AFTER source_warehouse_id')); +PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''原海外中转仓名称快照'' AFTER source_warehouse_code')); +PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_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_source_position'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id)')); +PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLOCATE PREPARE relabel_inventory_stmt; + +-- 2026-09-09 / 换标PDA完工、客户确认及出库分流 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填'' AFTER contact_phone')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_id VARCHAR(128) NULL COMMENT ''箱唛总文件ID'' AFTER total_file_url')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_name VARCHAR(255) NULL COMMENT ''箱唛总文件名'' AFTER carton_total_file_id')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_url VARCHAR(1000) NULL COMMENT ''箱唛总文件地址'' AFTER carton_total_file_name')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='sku_total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_id VARCHAR(128) NULL COMMENT ''SKU总文件ID'' AFTER carton_total_file_url')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='sku_total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_name VARCHAR(255) NULL COMMENT ''SKU总文件名'' AFTER sku_total_file_id')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='sku_total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_url VARCHAR(1000) NULL COMMENT ''SKU总文件地址'' AFTER sku_total_file_name')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要打印面单'' AFTER delivery_method')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT ''客户上传面单文件ID'' AFTER print_shipping_label')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT ''客户上传面单文件名'' AFTER shipping_label_file_id')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT ''客户上传面单下载地址'' AFTER shipping_label_file_name')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='fulfillment_status'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT ''WAIT_RELABEL'' COMMENT ''换标后履约状态'' AFTER shipping_label_file_url')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='outbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT ''自提或快递生成的换标出库单号'' AFTER fulfillment_status')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +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_confirmation_key'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT ''客户确认完成幂等键'' AFTER fulfillment_status')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +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_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; + +-- 柜级入库计划跨客户批量托盘预留、确认与失败补偿。 +USE baiyue_lastlag_warehouse; +CREATE TABLE IF NOT EXISTS wh_pallet_batch ( + id BIGINT NOT NULL COMMENT '批量托盘预留ID', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + batch_key VARCHAR(160) NOT NULL COMMENT '柜级入库计划批次稳定业务键', request_hash CHAR(64) NOT NULL COMMENT '规范化批量请求SHA-256摘要', + status VARCHAR(32) NOT NULL COMMENT '批次状态:RESERVED、CONFIRMED、COMPENSATED', attempt_no INT NOT NULL DEFAULT 1 COMMENT '同批次重新预留尝试次数', + confirm_command_key VARCHAR(160) NULL COMMENT '确认命令幂等键', confirm_command_hash CHAR(64) NULL COMMENT '确认命令规范化SHA-256摘要', + compensate_command_key VARCHAR(160) NULL COMMENT '补偿命令幂等键', compensate_command_hash CHAR(64) NULL COMMENT '补偿命令规范化SHA-256摘要', + compensate_reason VARCHAR(500) NULL COMMENT '补偿原因', confirmed_at DATETIME(3) NULL COMMENT '确认绑定时间', + compensated_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_wh_pallet_batch_key (warehouse_id,batch_key), + KEY idx_wh_pallet_batch_status (warehouse_id,status,updated_at) +) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘预留与确认记录'; +CREATE TABLE IF NOT EXISTS wh_pallet_batch_item ( + id BIGINT NOT NULL COMMENT '批量托盘明细ID', batch_id BIGINT NOT NULL COMMENT '所属批量托盘预留ID', + customer_id BIGINT NOT NULL COMMENT '实际占用客户ID', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + business_key VARCHAR(100) NOT NULL COMMENT '柜号入库计划序号组成的稳定业务键', inbound_plan_no VARCHAR(128) NOT NULL COMMENT '入库计划号快照', + sequence_no INT NOT NULL COMMENT '计划内托盘序号', pallet_id BIGINT NOT NULL COMMENT '公共实物托盘ID', + pallet_code VARCHAR(64) NOT NULL COMMENT '资源服务生成的规范托盘码快照', location_id BIGINT NOT NULL COMMENT '公共托盘当前预留库位ID', + resource_lock_id BIGINT NOT NULL COMMENT '按客户隔离的资源锁ID', status VARCHAR(32) NOT NULL COMMENT '明细状态:RESERVED、CONFIRMED、RELEASED', + 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_batch_item_business (customer_id,warehouse_id,business_key), + UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), KEY idx_wh_pallet_batch_item_pallet (pallet_id), + KEY idx_wh_pallet_batch_item_batch (batch_id,status) +) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联'; +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type')); +PREPARE ll_outbound_lx_codes_stmt FROM @ddl; EXECUTE ll_outbound_lx_codes_stmt; DEALLOCATE PREPARE ll_outbound_lx_codes_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='lx_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN lx_code VARCHAR(128) NULL COMMENT ''一件代发领星入库单代码''')); +PREPARE ll_inbound_lx_code_stmt FROM @ddl; EXECUTE ll_inbound_lx_code_stmt; DEALLOCATE PREPARE ll_inbound_lx_code_stmt; + +-- ============================================================================ +-- 2026-09-02 / 031 +-- 海外异常退回冻结/幂等与业务费用来源;入仓码为可选业务引用。 +-- ============================================================================ +CREATE TABLE IF NOT EXISTS ll_exception_return_item ( + id BIGINT NOT NULL COMMENT '异常退回明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细ID', + inventory_position_id BIGINT NOT NULL COMMENT '原出库库存位置ID', pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照', + sku_code VARCHAR(128) NULL COMMENT '原出库SKU快照', max_return_packages BIGINT NOT NULL COMMENT '原出库可退箱数上限', + approved_return_packages BIGINT NOT NULL COMMENT '批准退回箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_return_source(customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id), + KEY idx_ll_exception_return_order(customer_id,exception_order_id) +) ENGINE=InnoDB COMMENT='异常退回原出库库存冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_exception_order_command ( + id BIGINT NOT NULL COMMENT '异常处理命令ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', action VARCHAR(32) NOT NULL COMMENT '异常处理动作', + idempotency_key VARCHAR(255) NOT NULL COMMENT '幂等键', request_hash VARCHAR(64) NOT NULL COMMENT '请求内容SHA256哈希', + result_json JSON NOT NULL COMMENT '命令结果快照', created_at DATETIME(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_command_key(customer_id,action,idempotency_key), + KEY idx_ll_exception_command_order(customer_id,exception_order_id,created_at) +) ENGINE=InnoDB COMMENT='异常处理命令幂等审计'; + +CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( + id BIGINT NOT NULL COMMENT '费用版本记录ID', source_fee_id BIGINT NOT NULL COMMENT '跨版本稳定费用来源ID', + source_version INT NOT NULL COMMENT '费用来源版本,从1递增', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', + customer_name VARCHAR(200) NULL COMMENT '客户名称快照', source_type VARCHAR(64) NOT NULL COMMENT '海外费用来源类型', + source_no VARCHAR(128) NOT NULL COMMENT '来源业务编号或入仓码快照', business_type VARCHAR(32) NOT NULL COMMENT '来源业务类型', + business_id BIGINT NULL COMMENT '来源业务记录ID', business_no VARCHAR(128) NOT NULL COMMENT '来源业务编号', + order_id BIGINT NULL COMMENT '关联订单ID', inbound_id BIGINT NULL COMMENT '关联海外入仓计划或入仓记录ID', + inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID', + container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照', + warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照', + fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', + supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照', + supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付', + paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式', + payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照', + business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY', + amount DECIMAL(20,6) NOT NULL COMMENT '费用金额', review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED', + publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED', + review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间', + created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version), + KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at), + CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'), + CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')), + CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')), + CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')), + CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED')) +) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; + +CREATE TABLE IF NOT EXISTS ll_finance_fee_command ( + id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID', + source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key), + KEY idx_ll_finance_fee_command_source(source_fee_id,source_version) +) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计'; +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; +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='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''本次出库指定箱唛,逗号分隔'' AFTER sku_code')); +PREPARE outbound_carton_marks_stmt FROM @ddl; EXECUTE outbound_carton_marks_stmt; DEALLOCATE PREPARE outbound_carton_marks_stmt; + +-- 2026-09-07 / 032 客户退货预报、PDA 签收及仓储费起算批次。 +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_return_order ( + id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照', warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity) +) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_name'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_name VARCHAR(128) NULL COMMENT ''中转仓联系人快照'' AFTER overseas_warehouse_id')); +PREPARE ll_return_order_contact_name_stmt FROM @ddl; EXECUTE ll_return_order_contact_name_stmt; DEALLOCATE PREPARE ll_return_order_contact_name_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_phone VARCHAR(64) NULL COMMENT ''中转仓联系电话快照'' AFTER warehouse_contact_name')); +PREPARE ll_return_order_contact_phone_stmt FROM @ddl; EXECUTE ll_return_order_contact_phone_stmt; DEALLOCATE PREPARE ll_return_order_contact_phone_stmt; +CREATE TABLE IF NOT EXISTS ll_return_order_item ( + id BIGINT NOT NULL COMMENT '退货预报明细ID', return_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) NULL COMMENT '中文品名', expected_quantity BIGINT NOT NULL COMMENT 'SKU预报数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT 'SKU累计实收数量', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code), KEY idx_ll_return_item_sku(sku_code,received_quantity), CONSTRAINT chk_ll_return_item_quantity CHECK(expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity) +) ENGINE=InnoDB COMMENT='客户退货订单的SKU预报与实收明细'; +CREATE TABLE IF NOT EXISTS ll_return_receipt ( + id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', receipt_no VARCHAR(128) NOT NULL COMMENT '签收单号', receipt_batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', tracking_no VARCHAR(128) NULL COMMENT '本次签收包裹的可选快递单号', bound_by VARCHAR(16) NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', status VARCHAR(32) NOT NULL COMMENT '签收状态,当前SIGNED', received_quantity BIGINT NOT NULL COMMENT '本次实收数量', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', operator_name VARCHAR(128) NULL COMMENT 'PDA操作人名称', idempotency_key VARCHAR(255) NOT NULL COMMENT 'PDA确认幂等键', request_hash CHAR(64) NOT NULL COMMENT 'PDA确认请求SHA-256', signed_at DATETIME(3) NOT NULL COMMENT '签收时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_receipt_no(receipt_no), UNIQUE KEY uk_ll_return_receipt_batch(receipt_batch_no), UNIQUE KEY uk_ll_return_receipt_operator_key(operator_id,idempotency_key), KEY idx_ll_return_receipt_order(return_order_id,signed_at), CONSTRAINT chk_ll_return_receipt_quantity CHECK(received_quantity>0), CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN('RETURN_NO','SKU')) +) ENGINE=InnoDB COMMENT='PDA退货实收签收事实'; + +-- 已创建开发库升级为“客户只预报 SKU,快递单号仅属于签收批次”的最终模型。 +DROP PROCEDURE IF EXISTS migrate_return_forecast_sku_only; + +-- 2026-09-09 / 035 为历史退货订单补充尾程主订单关联,并回填已生成的 RETURN_RELABEL 尾程订单。 +USE baiyue_lastleg; +-- 2026-09-09 / 036 为派送单补充 BOL 号字段(不改原始 CREATE TABLE)。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='bol_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN bol_no VARCHAR(128) NULL COMMENT ''派送资料BOL号,不作为文件归属标识''')); +PREPARE ll_delivery_order_bol_no_stmt FROM @ddl; EXECUTE ll_delivery_order_bol_no_stmt; DEALLOCATE PREPARE ll_delivery_order_bol_no_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='documents_json'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN documents_json JSON NULL COMMENT ''派送三类资料当前绑定及权威文件元数据快照'' AFTER bol_no')); +PREPARE ll_delivery_order_documents_json_stmt FROM @ddl; EXECUTE ll_delivery_order_documents_json_stmt; DEALLOCATE PREPARE ll_delivery_order_documents_json_stmt; +SET @ddl = (SELECT IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_id'), + 'SELECT 1', + 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''关联尾程主订单ID'' AFTER customer_reference_no' +)); +PREPARE ll_return_order_lastleg_id_stmt FROM @ddl; EXECUTE ll_return_order_lastleg_id_stmt; DEALLOCATE PREPARE ll_return_order_lastleg_id_stmt; +UPDATE ll_return_order r +JOIN ll_order o ON o.source_order_id = CAST(r.id AS CHAR) + AND o.source_order_no = r.return_no + AND o.order_type = 'RETURN_RELABEL' +SET r.lastleg_order_id = o.id +WHERE r.lastleg_order_id IS NULL; +DELIMITER $$ +CREATE PROCEDURE migrate_return_forecast_sku_only() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_tracking_sku') THEN + ALTER TABLE ll_return_order_item DROP INDEX uk_ll_return_item_order_tracking_sku; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_tracking') THEN + ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_tracking; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_order') THEN + ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_order; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND column_name='tracking_no') THEN + ALTER TABLE ll_return_order_item DROP COLUMN tracking_no; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_sku') THEN + ALTER TABLE ll_return_order_item ADD UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_sku') THEN + ALTER TABLE ll_return_order_item ADD KEY idx_ll_return_item_sku(sku_code,received_quantity); + END IF; + UPDATE ll_return_receipt SET bound_by='RETURN_NO' WHERE bound_by='TRACKING'; + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_receipt' AND constraint_name='chk_ll_return_receipt_bound') THEN + ALTER TABLE ll_return_receipt DROP CHECK chk_ll_return_receipt_bound; + END IF; + ALTER TABLE ll_return_receipt + MODIFY COLUMN tracking_no VARCHAR(128) NULL COMMENT '本次签收包裹的可选快递单号', + MODIFY COLUMN bound_by VARCHAR(16) NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', + ADD CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN('RETURN_NO','SKU')); +END$$ +DELIMITER ; +CALL migrate_return_forecast_sku_only(); +DROP PROCEDURE IF EXISTS migrate_return_forecast_sku_only; +CREATE TABLE IF NOT EXISTS ll_return_receipt_item ( + id BIGINT NOT NULL COMMENT '签收SKU明细ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id BIGINT NOT NULL COMMENT '退货预报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', received_quantity BIGINT NOT NULL COMMENT '本次SKU实收数量', PRIMARY KEY(id), KEY idx_ll_return_receipt_item_receipt(receipt_id), KEY idx_ll_return_receipt_item_order_item(return_order_item_id), CONSTRAINT chk_ll_return_receipt_item_quantity CHECK(received_quantity>0) +) ENGINE=InnoDB COMMENT='PDA退货签收SKU实际数量明细'; +CREATE TABLE IF NOT EXISTS ll_return_receipt_batch ( + id BIGINT NOT NULL COMMENT '退货仓储计费批次ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', storage_billing_status VARCHAR(32) NOT NULL COMMENT '仓储计费状态,初始PENDING_PRICING', storage_started_at DATETIME(3) NOT NULL COMMENT '仓储计费起算时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_batch_no(batch_no), UNIQUE KEY uk_ll_return_batch_receipt(receipt_id), KEY idx_ll_return_batch_billing(customer_id,overseas_warehouse_id,storage_billing_status,storage_started_at) +) ENGINE=InnoDB COMMENT='每次退货签收生成的仓储费起算批次'; + +-- 2026-09-07 / 033 退货库存、退货箱唛和PDA同客户同SKU合箱。 +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no')); +PREPARE return_receipt_carton_mark_incremental_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_incremental_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_incremental_stmt; +CREATE TABLE IF NOT EXISTS ll_return_inventory_carton ( + id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED')) +) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); +PREPARE return_inventory_carton_check_drop_incremental_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_incremental_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_incremental_stmt; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no')); +PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='length'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''箱长CM'', ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''箱宽CM'', ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''箱高CM'', ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''箱重KG''')); +PREPARE return_inventory_carton_dimension_stmt FROM @ddl; EXECUTE return_inventory_carton_dimension_stmt; DEALLOCATE PREPARE return_inventory_carton_dimension_stmt; +CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item ( + id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_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) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0) +) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量'; +CREATE TABLE IF NOT EXISTS ll_return_inventory_balance ( + id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code), CONSTRAINT chk_ll_return_inventory_balance_quantity CHECK(total_quantity>=available_quantity AND available_quantity>=0 AND carton_quantity>=0) +) ENGINE=InnoDB COMMENT='退货库存按客户海外仓SKU汇总余额'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_balance' AND column_name='total_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_balance ADD COLUMN total_quantity BIGINT NULL COMMENT ''实物总库存,仅实际出库扣减'' AFTER product_name_zh')); +PREPARE return_inventory_balance_total_incremental_stmt FROM @ddl; EXECUTE return_inventory_balance_total_incremental_stmt; DEALLOCATE PREPARE return_inventory_balance_total_incremental_stmt; +UPDATE ll_return_inventory_balance b LEFT JOIN (SELECT c.customer_id,c.overseas_warehouse_id,i.sku_code,SUM(i.quantity) merged_quantity FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED' GROUP BY c.customer_id,c.overseas_warehouse_id,i.sku_code) m ON m.customer_id=b.customer_id AND m.overseas_warehouse_id=b.overseas_warehouse_id AND m.sku_code=b.sku_code SET b.total_quantity=b.available_quantity,b.available_quantity=GREATEST(0,b.available_quantity-COALESCE(m.merged_quantity,0)) WHERE b.total_quantity IS NULL; +ALTER TABLE ll_return_inventory_balance MODIFY COLUMN total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减'; +CREATE TABLE IF NOT EXISTS ll_return_inventory_operation ( + id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id) +) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计'; + +-- 2026-09-08 / 034 尾程订单补充海外接柜仓业务编码快照。 +USE baiyue_lastleg; +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_code'), + 'ALTER TABLE ll_order MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NOT NULL DEFAULT ''US-LAX-01'' COMMENT ''海外接柜仓业务编码快照''', + 'ALTER TABLE ll_order ADD COLUMN overseas_warehouse_code VARCHAR(128) NOT NULL DEFAULT ''US-LAX-01'' COMMENT ''海外接柜仓业务编码快照'' AFTER overseas_warehouse_id' +)); +PREPARE ll_order_warehouse_code_stmt FROM @ddl; EXECUTE ll_order_warehouse_code_stmt; DEALLOCATE PREPARE ll_order_warehouse_code_stmt; +SET @ddl = (SELECT IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='receiving_warehouse_version'), + 'ALTER TABLE ll_order MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT ''柜级接柜仓分配乐观锁版本''', + 'ALTER TABLE ll_order ADD COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT ''柜级接柜仓分配乐观锁版本'' AFTER overseas_warehouse_name' +)); +PREPARE ll_order_receiving_warehouse_version_stmt FROM @ddl; EXECUTE ll_order_receiving_warehouse_version_stmt; DEALLOCATE PREPARE ll_order_receiving_warehouse_version_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='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''换标箱唛映射快照'' AFTER source_stage')); 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_item' AND column_name='source_business_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT ''来源业务类型'' AFTER carton_marks')); 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_item' AND column_name='source_business_no'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT ''来源业务单号'' AFTER source_business_type')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 2026-09-10 / 035 出库PDA完成命令幂等字段与客户范围唯一索引。 +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key' +), 'SELECT 1', + 'ALTER TABLE ll_outbound_pda_task ADD COLUMN completion_idempotency_key VARCHAR(255) NULL COMMENT ''完成命令幂等键'' AFTER status')); +PREPARE outbound_pda_completion_column_stmt FROM @ddl; EXECUTE outbound_pda_completion_column_stmt; DEALLOCATE PREPARE outbound_pda_completion_column_stmt; +SET @index_ready = (SELECT COUNT(*)=2 AND MIN(non_unique)=0 + AND SUM(seq_in_index=1 AND column_name='customer_id')=1 + AND SUM(seq_in_index=2 AND column_name='completion_idempotency_key')=1 + FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key'); +SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key'); +SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists, + 'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_complete_key, ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)', + 'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)')); +PREPARE outbound_pda_completion_index_stmt FROM @ddl; EXECUTE outbound_pda_completion_index_stmt; DEALLOCATE PREPARE outbound_pda_completion_index_stmt; + +-- 2026-09-14 / 038 海外尾程按托盘维度作业,无库位托盘的状态流转审计允许空库位。 +USE baiyue_lastlag_warehouse; +ALTER TABLE wh_pallet_operation + MODIFY COLUMN before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空', + MODIFY COLUMN after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空'; +USE baiyue_lastleg; + +-- 2026-09-10 / 036 补齐仓库候选与退货列表查询依赖的字段,修复旧库升级后的 503。 +USE baiyue_warehouse; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='internal_code'), 'SELECT 1', 'ALTER TABLE wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code')); +PREPARE warehouse_internal_code_stmt FROM @ddl; EXECUTE warehouse_internal_code_stmt; DEALLOCATE PREPARE warehouse_internal_code_stmt; +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(128) NULL COMMENT ''关联尾程订单号'' AFTER lastleg_order_id')); +PREPARE return_order_lastleg_no_stmt FROM @ddl; EXECUTE return_order_lastleg_no_stmt; DEALLOCATE PREPARE return_order_lastleg_no_stmt; + +-- 2026-09-11 / 037 仓储转运 PDA 打托与换标预绑定事实。 +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet ( + id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + sequence_no INT NOT NULL COMMENT '托序号', warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码', + warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', + carton_count INT NOT NULL COMMENT '本托实收箱数', length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', + height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', + status VARCHAR(32) NOT NULL COMMENT '打托状态', completed_at DATETIME(3) 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_transfer_pallet_seq(transfer_order_id,sequence_no), UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status) +) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实'; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID', + transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_item(transfer_pallet_id,transfer_item_id), KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填'; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型', + customer_carton_mark VARCHAR(255) NOT NULL COMMENT '客户原箱唛', original_sku VARCHAR(128) NOT NULL COMMENT '旧SKU', new_sku VARCHAR(128) NULL COMMENT '新SKU', quantity INT NOT NULL COMMENT '换标件数', + fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓', remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark), KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细'; diff --git a/scripts/db/outbound-return.sql b/scripts/db/outbound-return.sql new file mode 100644 index 000000000..eec420544 --- /dev/null +++ b/scripts/db/outbound-return.sql @@ -0,0 +1,10 @@ +-- 退货箱唛出库兼容增量(目标库:baiyue_lastleg) +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pda_pick' AND column_name='inventory_position_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_outbound_pda_pick MODIFY COLUMN inventory_position_id BIGINT NULL COMMENT ''库存仓位ID,退货箱唛出库为空''')); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1')); +PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED')); diff --git a/scripts/db/relabel-fields-only.sql b/scripts/db/relabel-fields-only.sql new file mode 100644 index 000000000..17dab3edb --- /dev/null +++ b/scripts/db/relabel-fields-only.sql @@ -0,0 +1,80 @@ +-- 海外仓换标字段增量(MySQL 8.0) +-- 目标数据库:baiyue_lastleg +-- 用途:为已有 ll_relabel_item 表补齐 FBA目的仓、箱唛/SKU/组合换标字段。 +-- 说明:脚本可重复执行;不会修改或删除业务数据。 + +CREATE DATABASE IF NOT EXISTS baiyue_lastleg + DEFAULT CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +DROP PROCEDURE IF EXISTS _tmp_add_relabel_fields; +DELIMITER $$ + +CREATE PROCEDURE _tmp_add_relabel_fields() +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'll_relabel_item' + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'll_relabel_item 不存在,请先初始化 baiyue_lastleg 基线表'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'll_relabel_item' + AND column_name = 'destination_fba_warehouse_code' + ) THEN + ALTER TABLE ll_relabel_item + ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL + COMMENT 'FBA目的仓编码(与海外操作仓分离)'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'll_relabel_item' + AND column_name = 'operation_type' + ) THEN + ALTER TABLE ll_relabel_item + ADD COLUMN operation_type VARCHAR(32) NOT NULL DEFAULT 'CARTON_LABEL' + COMMENT '换标模式:箱唛、SKU或组合换标'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'll_relabel_item' + AND column_name = 'sku_relabel_items_json' + ) THEN + ALTER TABLE ll_relabel_item + ADD COLUMN sku_relabel_items_json JSON NULL + COMMENT '同箱SKU换标子明细快照(数量按件)'; + END IF; +END$$ + +DELIMITER ; +CALL _tmp_add_relabel_fields(); +DROP PROCEDURE IF EXISTS _tmp_add_relabel_fields; + +SELECT + table_schema, + table_name, + column_name, + column_type, + is_nullable, + column_default, + ordinal_position, + column_comment +FROM information_schema.columns +WHERE table_schema = 'baiyue_lastleg' + AND table_name = 'll_relabel_item' + AND column_name IN ('destination_fba_warehouse_code', 'operation_type', 'sku_relabel_items_json') +ORDER BY ordinal_position; diff --git a/scripts/db/relabel-file-scope-incremental.sql b/scripts/db/relabel-file-scope-incremental.sql new file mode 100644 index 000000000..e458716eb --- /dev/null +++ b/scripts/db/relabel-file-scope-incremental.sql @@ -0,0 +1,14 @@ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_id VARCHAR(128) NULL COMMENT ''箱唛总文件ID'' AFTER total_file_url')); +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_relabel_application' AND column_name='carton_total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_name VARCHAR(255) NULL COMMENT ''箱唛总文件名'' AFTER carton_total_file_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_relabel_application' AND column_name='carton_total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_url VARCHAR(1000) NULL COMMENT ''箱唛总文件地址'' AFTER carton_total_file_name')); +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_relabel_application' AND column_name='sku_total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_id VARCHAR(128) NULL COMMENT ''SKU总文件ID'' AFTER carton_total_file_url')); +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_relabel_application' AND column_name='sku_total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_name VARCHAR(255) NULL COMMENT ''SKU总文件名'' AFTER sku_total_file_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_relabel_application' AND column_name='sku_total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_url VARCHAR(1000) NULL COMMENT ''SKU总文件地址'' AFTER sku_total_file_name')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/scripts/db/relabel-fulfillment-incremental.sql b/scripts/db/relabel-fulfillment-incremental.sql new file mode 100644 index 000000000..a298d32ac --- /dev/null +++ b/scripts/db/relabel-fulfillment-incremental.sql @@ -0,0 +1,30 @@ +-- 换标PDA完工、客户确认和出库分流字段(MySQL 8,可重复执行) +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填'' AFTER contact_phone')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要打印面单'' AFTER delivery_method')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT ''客户上传面单文件ID'' AFTER print_shipping_label')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT ''客户上传面单文件名'' AFTER shipping_label_file_id')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT ''客户上传面单下载地址'' AFTER shipping_label_file_name')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='fulfillment_status'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT ''WAIT_RELABEL'' COMMENT ''换标后履约状态'' AFTER shipping_label_file_url')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='outbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT ''自提或快递生成的换标出库单号'' AFTER fulfillment_status')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +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_confirmation_key'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT ''客户确认完成幂等键'' AFTER fulfillment_status')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; +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_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); +PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt; + +UPDATE ll_relabel_application +SET fulfillment_status = CASE + WHEN status = 'CUSTOMER_CONFIRM_PENDING' THEN 'WAIT_CUSTOMER_CONFIRMATION' + WHEN status = 'READY_FOR_OUTBOUND' AND delivery_method = 'TRUCK' THEN 'WAIT_PRE_DISPATCH' + WHEN status = 'READY_FOR_OUTBOUND' THEN 'WAIT_OUTBOUND_CREATION' + WHEN status = 'COMPLETED' THEN 'COMPLETED' + ELSE COALESCE(NULLIF(fulfillment_status, ''), 'WAIT_RELABEL') +END; diff --git a/scripts/db/security-projection-repair.sql b/scripts/db/security-projection-repair.sql new file mode 100644 index 000000000..6ae257773 --- /dev/null +++ b/scripts/db/security-projection-repair.sql @@ -0,0 +1,146 @@ +-- 修复当前海外环境平台管理员的安全投影。 +-- 适用数据库:baiyue_iam、baiyue_lastleg;脚本可重复执行。 +USE baiyue_lastleg; + +-- 旧环境可能缺少角色或权限快照列;先保证两列都存在。 +SET @ddl = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_security_projection ADD COLUMN roles_json JSON NULL COMMENT ''IAM投影的当前角色快照'' AFTER permission_version', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'll_security_projection' + AND column_name = 'roles_json' +); +PREPARE security_projection_roles_ddl FROM @ddl; +EXECUTE security_projection_roles_ddl; +DEALLOCATE PREPARE security_projection_roles_ddl; + +SET @ddl = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_security_projection ADD COLUMN authorities_json JSON NULL AFTER roles_json', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'll_security_projection' + AND column_name = 'authorities_json' +); +PREPARE security_projection_ddl FROM @ddl; +EXECUTE security_projection_ddl; +DEALLOCATE PREPARE security_projection_ddl; + +UPDATE ll_security_projection +SET roles_json = JSON_ARRAY() +WHERE roles_json IS NULL; + +UPDATE ll_security_projection +SET authorities_json = JSON_ARRAY() +WHERE authorities_json IS NULL; + +-- 从 IAM 当前平台成员重建 USER 与 AUTHORIZATION 投影,避免把修复绑定到某个环境账号 ID。 +INSERT INTO ll_security_projection ( + aggregate_type, aggregate_id, tenant_id, security_status, + permission_version, roles_json, authorities_json, aggregate_version, + payload_hash, last_event_id, changed_at, updated_at +) +SELECT + 'USER', CAST(u.auth_user_id AS CHAR), 0, + IF(u.status = 'ACTIVE' AND m.status = 'ACTIVE', 'ACTIVE', 'DISABLED'), + COALESCE(v.permission_version, 0), JSON_ARRAY(), JSON_ARRAY(), + COALESCE(v.permission_version, 0), + SHA2(CONCAT('environment-repair:', u.auth_user_id, ':user:', COALESCE(v.permission_version, 0)), 256), + CONCAT('repair-', u.auth_user_id, '-u-', DATE_FORMAT(NOW(3), '%Y%m%d%H%i%s%f')), + NOW(3), NOW(3) +FROM baiyue_iam.iam_user u +JOIN baiyue_iam.iam_membership m + ON m.user_id = u.id AND m.tenant_id = 0 AND m.deleted = 0 +LEFT JOIN baiyue_iam.iam_security_subject_version v + ON v.auth_user_id = u.auth_user_id +WHERE u.auth_user_id IS NOT NULL AND u.deleted = 0 + AND EXISTS ( + SELECT 1 + FROM baiyue_iam.iam_membership_role mr + JOIN baiyue_iam.iam_role r ON r.id = mr.role_id + WHERE mr.membership_id = m.id AND mr.tenant_id = m.tenant_id + AND r.app_code = 'PLATFORM' AND r.status = 'ACTIVE' AND r.deleted = 0 + ) +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); + +INSERT INTO ll_security_projection ( + aggregate_type, aggregate_id, tenant_id, security_status, + permission_version, roles_json, authorities_json, aggregate_version, + payload_hash, last_event_id, changed_at, updated_at +) +SELECT + 'AUTHORIZATION', CAST(u.auth_user_id AS CHAR), 0, NULL, + COALESCE(v.permission_version, 0), + COALESCE(( + SELECT JSON_ARRAYAGG(r2.role_code) + FROM baiyue_iam.iam_user u2 + JOIN baiyue_iam.iam_membership m2 ON m2.user_id = u2.id + JOIN baiyue_iam.iam_membership_role mr2 + ON mr2.membership_id = m2.id AND mr2.tenant_id = m2.tenant_id + JOIN baiyue_iam.iam_role r2 ON r2.id = mr2.role_id + WHERE u2.auth_user_id = u.auth_user_id AND m2.tenant_id = 0 + AND m2.status = 'ACTIVE' AND m2.deleted = 0 + AND r2.app_code = 'PLATFORM' AND r2.status = 'ACTIVE' AND r2.deleted = 0 + ), JSON_ARRAY()), + COALESCE(( + SELECT JSON_ARRAYAGG(p2.permission_code) + FROM baiyue_iam.iam_user u2 + JOIN baiyue_iam.iam_membership m2 ON m2.user_id = u2.id + JOIN baiyue_iam.iam_membership_role mr2 + ON mr2.membership_id = m2.id AND mr2.tenant_id = m2.tenant_id + JOIN baiyue_iam.iam_role r2 ON r2.id = mr2.role_id + JOIN baiyue_iam.iam_role_permission rp2 + ON rp2.role_id = r2.id AND rp2.tenant_id = r2.tenant_id + JOIN baiyue_iam.iam_permission p2 + ON p2.id = rp2.permission_id AND p2.tenant_id = rp2.tenant_id + WHERE u2.auth_user_id = u.auth_user_id AND m2.tenant_id = 0 + AND m2.status = 'ACTIVE' AND m2.deleted = 0 + AND r2.app_code = 'PLATFORM' AND r2.status = 'ACTIVE' AND r2.deleted = 0 + AND p2.app_code = 'PLATFORM' AND p2.status = 'ACTIVE' AND p2.deleted = 0 + AND (p2.permission_code LIKE 'platform:overseas-warehouse:%' + OR p2.permission_code LIKE 'platform:overseas-business:%' + OR p2.permission_code LIKE 'platform:overseas-master-data:%' + OR p2.permission_code LIKE 'platform:finance:%' + OR p2.permission_code LIKE 'lastleg:%') + ), JSON_ARRAY()), + COALESCE(v.permission_version, 0), + SHA2(CONCAT('environment-repair:', u.auth_user_id, ':authorization:', COALESCE(v.permission_version, 0)), 256), + CONCAT('repair-', u.auth_user_id, '-a-', DATE_FORMAT(NOW(3), '%Y%m%d%H%i%s%f')), + NOW(3), NOW(3) +FROM baiyue_iam.iam_user u +JOIN baiyue_iam.iam_membership m + ON m.user_id = u.id AND m.tenant_id = 0 AND m.status = 'ACTIVE' AND m.deleted = 0 +LEFT JOIN baiyue_iam.iam_security_subject_version v + ON v.auth_user_id = u.auth_user_id +WHERE u.auth_user_id IS NOT NULL AND u.status = 'ACTIVE' AND u.deleted = 0 + AND EXISTS ( + SELECT 1 FROM baiyue_iam.iam_membership_role mr + JOIN baiyue_iam.iam_role r ON r.id = mr.role_id + WHERE mr.membership_id = m.id AND mr.tenant_id = m.tenant_id + AND r.app_code = 'PLATFORM' AND r.status = 'ACTIVE' AND r.deleted = 0 + ) +ON DUPLICATE KEY UPDATE + tenant_id = VALUES(tenant_id), + permission_version = VALUES(permission_version), + roles_json = VALUES(roles_json), + authorities_json = VALUES(authorities_json), + 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); + +SELECT aggregate_type, aggregate_id, tenant_id, permission_version, + JSON_LENGTH(roles_json) AS role_count, + JSON_LENGTH(authorities_json) AS authority_count, + updated_at +FROM ll_security_projection +WHERE tenant_id = 0 AND aggregate_type IN ('USER', 'AUTHORIZATION') +ORDER BY CAST(aggregate_id AS UNSIGNED), aggregate_type; diff --git a/scripts/db/seed-one-click-dropship-outbound-candidates.sql b/scripts/db/seed-one-click-dropship-outbound-candidates.sql new file mode 100644 index 000000000..e4103b0e9 --- /dev/null +++ b/scripts/db/seed-one-click-dropship-outbound-candidates.sql @@ -0,0 +1,172 @@ +-- 本地验收数据:为 17909216591 对应客户建立 3 张已完成入库的一件代发订单。 +-- 可重复执行;候选数据覆盖海外实物托盘、订单、入库计划、库存余额/仓位、入库实收、一件代发档案、SKU 与箱唛。 +USE baiyue_lastlag_warehouse; + +INSERT INTO wh_pallet ( + id,customer_id,warehouse_id,location_id,pallet_code,pallet_type,capacity_cbm,unit_price, + status,remark,fba_warehouse_code,created_by,created_at,updated_by,updated_at,deleted,version +) VALUES + (2191000000000909901,0,193000000000200003,NULL,'DS-LAX-PALLET-01','STANDARD',2.0000,0.00, + 'OCCUPIED','一件代发本地验收固定托盘','ONT8',1000001,NOW(3),1000001,NOW(3),0,0), + (2191000000000909902,0,193000000000200003,NULL,'DS-LAX-PALLET-02','STANDARD',2.0000,0.00, + 'OCCUPIED','一件代发本地验收固定托盘','ABE2',1000001,NOW(3),1000001,NOW(3),0,0), + (2191000000000909903,0,193000000000200003,NULL,'DS-LAX-PALLET-03','STANDARD',2.0000,0.00, + 'OCCUPIED','一件代发本地验收固定托盘','ABE3',1000001,NOW(3),1000001,NOW(3),0,0) +ON DUPLICATE KEY UPDATE + warehouse_id=VALUES(warehouse_id),pallet_code=VALUES(pallet_code),pallet_type=VALUES(pallet_type), + status=VALUES(status),fba_warehouse_code=VALUES(fba_warehouse_code),deleted=0,updated_at=NOW(3); + +USE baiyue_lastleg; + +DROP TEMPORARY TABLE IF EXISTS _tmp_dropship_fixture; +CREATE TEMPORARY TABLE _tmp_dropship_fixture ( + seq_no INT NOT NULL PRIMARY KEY, + order_id BIGINT NOT NULL, + plan_id BIGINT NOT NULL, + plan_pallet_id BIGINT NOT NULL, + balance_id BIGINT NOT NULL, + position_id BIGINT NOT NULL, + profile_id BIGINT NOT NULL, + sku_inventory_id BIGINT NOT NULL, + receipt_id BIGINT NOT NULL, + warehouse_pallet_id BIGINT NOT NULL, + package_count INT NOT NULL, + order_no VARCHAR(32) NOT NULL, + inbound_code VARCHAR(128) NOT NULL, + plan_no VARCHAR(32) NOT NULL, + pallet_no VARCHAR(64) NOT NULL, + sku_code VARCHAR(128) NOT NULL, + product_name VARCHAR(255) NOT NULL +); + +INSERT INTO _tmp_dropship_fixture VALUES + (1,2191000000000909101,2191000000000909201,2191000000000909301,2191000000000909401,2191000000000909501,2191000000000909601,2191000000000909701,2191000000000909801,2191000000000909901, 6,'DS-IN-20260909-01','DS-RK-20260909-01','DS-PLAN-20260909-01','DS-LAX-PALLET-01','DS-SKU-STORAGE-01','折叠收纳箱'), + (2,2191000000000909102,2191000000000909202,2191000000000909302,2191000000000909402,2191000000000909502,2191000000000909602,2191000000000909702,2191000000000909802,2191000000000909902, 8,'DS-IN-20260909-02','DS-RK-20260909-02','DS-PLAN-20260909-02','DS-LAX-PALLET-02','DS-SKU-LAMP-02','桌面灯具'), + (3,2191000000000909103,2191000000000909203,2191000000000909303,2191000000000909403,2191000000000909503,2191000000000909603,2191000000000909703,2191000000000909803,2191000000000909903,10,'DS-IN-20260909-03','DS-RK-20260909-03','DS-PLAN-20260909-03','DS-LAX-PALLET-03','DS-SKU-RACK-03','厨房置物架'); + +INSERT INTO ll_order ( + id,tenant_id,order_no,customer_id,order_type,lx_code,destination_type,inbound_code, + source_region,source_inbound_id,source_order_id,source_order_no,customer_name, + overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name, + fba_warehouse_code,container_no,container_status,pallet_count,package_count,product_quantity, + inbound_order_no,current_status,source_event_id,source_loading_plan_id,source_payload_hash,created_at,updated_at +) +SELECT order_id,'BAIYUE',order_no,'2093269089353961474','ONE_CLICK_DROPSHIP',CONCAT('LX-',order_no), + 'ONE_CLICK_DROPSHIP',inbound_code,'FIRST_LEG',CONCAT('SEED-DROPSHIP-',seq_no), + CAST(order_id AS CHAR),order_no,'17909216591测试客户','193000000000200003','US-LAX-01','洛杉矶仓', + CASE seq_no WHEN 1 THEN 'ONT8' WHEN 2 THEN 'ABE2' ELSE 'ABE3' END, + CONCAT('DS-CONT-20260909-',LPAD(seq_no,2,'0')),'已拆柜',1,package_count,package_count * 10, + plan_no,'INBOUND_COMPLETED',CONCAT('SEED-DROPSHIP-EVENT-',seq_no), + CONCAT('SEED-DROPSHIP-LOAD-',seq_no),CONCAT('sha256:seed-dropship-',seq_no),NOW(3),NOW(3) +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE current_status='INBOUND_COMPLETED',package_count=VALUES(package_count), + product_quantity=VALUES(product_quantity),updated_at=NOW(3); + +INSERT INTO ll_inbound_plan ( + id,tenant_id,plan_no,customer_id,source_order_id,container_no,overseas_warehouse_id, + planned_arrival_date,pallet_type,assigned_flag,merge_required,urgent_flag,creation_mode, + plan_remark,status,line_count,pallet_count,total_volume_cbm,created_at,updated_at,version +) +SELECT plan_id,'BAIYUE',plan_no,'2093269089353961474',order_id, + CONCAT('DS-CONT-20260909-',LPAD(seq_no,2,'0')),193000000000200003, + CURRENT_DATE,'STANDARD',1,0,0,'AUTO','一件代发出库分页验收数据','COMPLETED',1,1,1.0000,NOW(3),NOW(3),0 +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE status='COMPLETED',assigned_flag=1,updated_at=NOW(3); + +INSERT INTO ll_inbound_plan_pallet ( + id,tenant_id,customer_id,plan_id,pallet_no,source_pallet_no,pallet_type, + destination_warehouse_code,destination_warehouse_name,capacity_cbm,allocated_volume_cbm, + sequence_no,warehouse_pallet_id,assignment_status,locked_at,actual_volume_cbm,actual_package_count +) +SELECT plan_pallet_id,'BAIYUE','2093269089353961474',plan_id,pallet_no, + CONCAT('DS-SOURCE-PALLET-',LPAD(seq_no,2,'0')),'STANDARD', + CASE seq_no WHEN 1 THEN 'ONT8' WHEN 2 THEN 'ABE2' ELSE 'ABE3' END, + 'Amazon FBA',2.0000,1.0000,1,warehouse_pallet_id,'ASSIGNED',NOW(3),1.0000,package_count +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE warehouse_pallet_id=VALUES(warehouse_pallet_id),assignment_status='ASSIGNED', + actual_package_count=VALUES(actual_package_count); + +INSERT INTO ll_inventory_balance ( + id,tenant_id,customer_id,customer_name,overseas_warehouse_id,overseas_warehouse_code, + overseas_warehouse_name,destination_warehouse_code,destination_warehouse_name, + inbound_order_id,inbound_order_no,inbound_code,sku_code,sku_name_zh,product_name_zh, + available_packages,available_quantity,version,created_at,updated_at +) +SELECT balance_id,'BAIYUE','2093269089353961474','17909216591测试客户','193000000000200003', + 'US-LAX-01','洛杉矶仓',CASE seq_no WHEN 1 THEN 'ONT8' WHEN 2 THEN 'ABE2' ELSE 'ABE3' END, + 'Amazon FBA',order_id,plan_no,inbound_code,sku_code,product_name,product_name, + package_count,package_count * 10,0,NOW(3),NOW(3) +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE customer_name=VALUES(customer_name),sku_code=VALUES(sku_code), + available_packages=VALUES(available_packages),available_quantity=VALUES(available_quantity),updated_at=NOW(3); + +INSERT INTO ll_inventory_position ( + id,tenant_id,customer_id,balance_id,overseas_warehouse_id,destination_warehouse_code, + sku_code,pallet_id,pallet_no,batch_no,warehouse_location_code,available_packages, + product_quantity,status,version,created_at,updated_at +) +SELECT position_id,'BAIYUE','2093269089353961474',balance_id,'193000000000200003', + CASE seq_no WHEN 1 THEN 'ONT8' WHEN 2 THEN 'ABE2' ELSE 'ABE3' END, + sku_code,warehouse_pallet_id,pallet_no,CONCAT('DS-BATCH-20260909-',seq_no), + CONCAT('LAX-A-',LPAD(seq_no,2,'0')),package_count,package_count * 10,'OCCUPIED',0,NOW(3),NOW(3) +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE available_packages=VALUES(available_packages),product_quantity=VALUES(product_quantity), + status='OCCUPIED',updated_at=NOW(3); + +INSERT INTO ll_dropship_order_profile ( + id,customer_id,order_id,warehouse_pallet_id,pallet_no,source_pallet_no, + inventory_position_id,status,idempotency_key,version,created_at,updated_at +) +SELECT profile_id,'2093269089353961474',order_id,warehouse_pallet_id,pallet_no, + CONCAT('DS-SOURCE-PALLET-',LPAD(seq_no,2,'0')),position_id,'ACTIVE', + CONCAT('SEED-DROPSHIP-PROFILE-',seq_no),0,NOW(3),NOW(3) +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE warehouse_pallet_id=VALUES(warehouse_pallet_id),pallet_no=VALUES(pallet_no), + inventory_position_id=VALUES(inventory_position_id),status='ACTIVE',updated_at=NOW(3); + +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 +) +SELECT sku_inventory_id,'2093269089353961474',order_id,profile_id,sku_code,product_name, + package_count,package_count * 10,NOW(3),NOW(3) +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE product_name=VALUES(product_name),package_count=VALUES(package_count), + product_quantity=VALUES(product_quantity),updated_at=NOW(3); + +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 +) +SELECT 2191000000001000000 + fixture.seq_no * 100 + numbers.n, + '2093269089353961474',fixture.order_id,fixture.profile_id,fixture.warehouse_pallet_id, + fixture.pallet_no,fixture.sku_code, + CONCAT('DS-CARTON-20260909-',LPAD(fixture.seq_no,2,'0'),'-',LPAD(numbers.n,3,'0')), + numbers.n,'AVAILABLE',0,NOW(3),NOW(3) +FROM _tmp_dropship_fixture fixture +JOIN ( + SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 + UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 +) numbers ON numbers.n <= fixture.package_count +ON DUPLICATE KEY UPDATE warehouse_pallet_id=VALUES(warehouse_pallet_id),pallet_no=VALUES(pallet_no), + sku_code=VALUES(sku_code),status='AVAILABLE',outbound_order_no=NULL,updated_at=NOW(3); + +INSERT INTO ll_inbound_stock_receipt ( + id,tenant_id,customer_id,plan_id,task_id,allocation_id,line_id,order_id,inbound_code, + sku_code,product_name_zh,sku_name_zh,warehouse_pallet_id,pallet_no,source_pallet_no, + warehouse_location_code,batch_no,received_packages,received_quantity,inventory_balance_id, + inventory_position_id,inventory_operation_key,received_at,created_at +) +SELECT receipt_id,'BAIYUE','2093269089353961474',plan_id,plan_id + 10000,plan_id + 20000, + plan_id + 30000,order_id,inbound_code,sku_code,product_name,product_name,warehouse_pallet_id, + pallet_no,CONCAT('DS-SOURCE-PALLET-',LPAD(seq_no,2,'0')),CONCAT('LAX-A-',LPAD(seq_no,2,'0')), + CONCAT('DS-BATCH-20260909-',seq_no),package_count,package_count * 10,balance_id,position_id, + CONCAT('SEED_PDA_STOCK_IN:',plan_id),NOW(3),NOW(3) +FROM _tmp_dropship_fixture +ON DUPLICATE KEY UPDATE received_packages=VALUES(received_packages),received_quantity=VALUES(received_quantity); + +SELECT fixture.order_no,fixture.inbound_code,fixture.pallet_no,fixture.sku_code, + fixture.package_count AS available_packages +FROM _tmp_dropship_fixture fixture +ORDER BY fixture.seq_no; + +DROP TEMPORARY TABLE IF EXISTS _tmp_dropship_fixture; diff --git a/scripts/db/seed-pda-single-sku-return-inventory.sql b/scripts/db/seed-pda-single-sku-return-inventory.sql new file mode 100644 index 000000000..543375a30 --- /dev/null +++ b/scripts/db/seed-pda-single-sku-return-inventory.sql @@ -0,0 +1,137 @@ +-- 本地验收数据:客户 17909216591 / 洛杉矶仓 / 单 SKU 退货单 + PDA 已签收库存。 +-- 可重复执行;已存在的业务单据不会被覆盖或重复累计库存。 +USE baiyue_lastleg; + +START TRANSACTION; + +DROP TEMPORARY TABLE IF EXISTS _seed_pda_single_sku_returns; +CREATE TEMPORARY TABLE _seed_pda_single_sku_returns ( + seq INT NOT NULL PRIMARY KEY, + sku_code VARCHAR(128) NOT NULL, + product_name_zh VARCHAR(255) NOT NULL, + quantity BIGINT NOT NULL +); + +INSERT INTO _seed_pda_single_sku_returns (seq, sku_code, product_name_zh, quantity) VALUES + (1, 'RET-LAX-PHONE-0909', '退货验收手机支架', 6), + (2, 'RET-LAX-LAMP-0909', '退货验收桌面灯具', 8), + (3, 'RET-LAX-RACK-0909', '退货验收厨房置物架', 10), + (4, 'RET-LAX-BAG-0909', '退货验收旅行收纳袋', 12), + (5, 'RET-LAX-FAN-0909', '退货验收便携风扇', 15); + +INSERT IGNORE INTO ll_return_order + (id, customer_id, return_no, overseas_warehouse_id, customer_reference_no, need_relabel, + status, expected_sku_types, expected_quantity, received_quantity, remark, + idempotency_key, request_hash, created_by, created_at, updated_at) +SELECT + 2097400000000000000 + seq, + '2093269089353961474', + CONCAT('RT-LAX-PDA-20260909-0', seq), + '193000000000200003', + CONCAT('17909216591-PDA-RETURN-0', seq), + 0, 'COMPLETED', 1, quantity, quantity, + '单 SKU 退货单,已完成 PDA 签收验收', + CONCAT('seed-pda-single-sku-return-order-20260909-', seq), + SHA2(CONCAT('seed-pda-single-sku-return-order-20260909-', seq), 256), + '209608311713000001', DATE_SUB(NOW(3), INTERVAL (60 - seq) MINUTE), DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE) +FROM _seed_pda_single_sku_returns; + +INSERT IGNORE INTO ll_return_order_item + (id, return_order_id, customer_id, sku_code, product_name_zh, + expected_quantity, received_quantity, created_at, updated_at) +SELECT + 2097400000000000100 + seq, + 2097400000000000000 + seq, + '2093269089353961474', sku_code, product_name_zh, + quantity, quantity, DATE_SUB(NOW(3), INTERVAL (60 - seq) MINUTE), DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE) +FROM _seed_pda_single_sku_returns; + +INSERT IGNORE INTO ll_return_receipt + (id, return_order_id, customer_id, receipt_no, receipt_batch_no, carton_mark, tracking_no, + bound_by, status, received_quantity, operator_id, operator_name, + idempotency_key, request_hash, signed_at, created_at) +SELECT + 2097400000000000200 + seq, + 2097400000000000000 + seq, + '2093269089353961474', + CONCAT('RR-LAX-PDA-20260909-0', seq), + CONCAT('RB-LAX-PDA-20260909-0', seq), + CONCAT('RC-LAX-PDA-20260909-0', seq), + CONCAT('TRACK-17909216591-0909-0', seq), + 'RETURN_NO', 'SIGNED', quantity, + '209608311713000001', '本地PDA验收员', + CONCAT('seed-pda-single-sku-return-receipt-20260909-', seq), + SHA2(CONCAT('seed-pda-single-sku-return-receipt-20260909-', seq), 256), + DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE), DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE) +FROM _seed_pda_single_sku_returns; + +INSERT IGNORE INTO ll_return_receipt_item + (id, receipt_id, return_order_item_id, customer_id, sku_code, received_quantity) +SELECT + 2097400000000000300 + seq, + 2097400000000000200 + seq, + 2097400000000000100 + seq, + '2093269089353961474', sku_code, quantity +FROM _seed_pda_single_sku_returns; + +INSERT IGNORE INTO ll_return_receipt_batch + (id, return_order_id, receipt_id, customer_id, batch_no, overseas_warehouse_id, + storage_billing_status, storage_started_at, created_at) +SELECT + 2097400000000000400 + seq, + 2097400000000000000 + seq, + 2097400000000000200 + seq, + '2093269089353961474', + CONCAT('RB-LAX-PDA-20260909-0', seq), + '193000000000200003', 'PENDING_PRICING', + DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE), DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE) +FROM _seed_pda_single_sku_returns; + +INSERT IGNORE INTO ll_return_inventory_carton + (id, customer_id, overseas_warehouse_id, source_receipt_id, source_batch_no, source_return_no, + carton_mark, status, sku_types, total_quantity, created_at, updated_at) +SELECT + 2097400000000000500 + seq, + '2093269089353961474', '193000000000200003', + 2097400000000000200 + seq, + CONCAT('RB-LAX-PDA-20260909-0', seq), + CONCAT('RT-LAX-PDA-20260909-0', seq), + CONCAT('RC-LAX-PDA-20260909-0', seq), + 'AVAILABLE', 1, quantity, + DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE), DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE) +FROM _seed_pda_single_sku_returns; + +INSERT IGNORE INTO ll_return_inventory_carton_item + (id, carton_id, customer_id, sku_code, product_name_zh, quantity) +SELECT + 2097400000000000600 + seq, + 2097400000000000500 + seq, + '2093269089353961474', sku_code, product_name_zh, quantity +FROM _seed_pda_single_sku_returns; + +INSERT IGNORE INTO ll_return_inventory_balance + (id, customer_id, overseas_warehouse_id, sku_code, product_name_zh, + total_quantity, available_quantity, carton_quantity, created_at, updated_at) +SELECT + 2097400000000000700 + seq, + '2093269089353961474', '193000000000200003', sku_code, product_name_zh, + quantity, quantity, 1, + DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE), DATE_SUB(NOW(3), INTERVAL (45 - seq) MINUTE) +FROM _seed_pda_single_sku_returns; + +DROP TEMPORARY TABLE _seed_pda_single_sku_returns; +COMMIT; + +SELECT o.return_no, o.status, o.expected_sku_types, o.expected_quantity, o.received_quantity, + r.receipt_no, r.status AS receipt_status, r.carton_mark, + b.sku_code, b.product_name_zh, b.total_quantity, b.available_quantity, b.carton_quantity +FROM ll_return_order o +JOIN ll_return_receipt r ON r.return_order_id = o.id +JOIN ll_return_inventory_balance b + ON b.customer_id = o.customer_id + AND b.overseas_warehouse_id = o.overseas_warehouse_id +JOIN ll_return_order_item oi + ON oi.return_order_id = o.id + AND oi.sku_code = b.sku_code +WHERE o.id BETWEEN 2097400000000000001 AND 2097400000000000005 +ORDER BY o.return_no; 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/dimension-ocr-pricing-test-env.ps1 b/scripts/dimension-ocr-pricing-test-env.ps1 new file mode 100644 index 000000000..31e91e572 --- /dev/null +++ b/scripts/dimension-ocr-pricing-test-env.ps1 @@ -0,0 +1,536 @@ +param( + [string]$GatewayBaseUrl = 'http://8.163.30.128:9000', + [string]$PlatformUsername = 'platform_admin', + [string]$MerchantUsername = 'merchant_admin', + [string]$PlatformPassword = 'ChangeMe123!', + [string]$MerchantPassword = 'ChangeMe123!', + [long]$TenantId = 10001, + [string]$ImageDirectory = 'C:\Users\BY-OY\Desktop\上传图片包\测量图片\测量图片', + [long]$ExistingInboundId = 0, + [long]$ExistingOrderId = 0, + [string]$ExistingInboundCode = '', + [int]$ExistingCartonCount = 0, + [switch]$OcrOnly, + [switch]$OcrProbeOnly, + [string]$OutputPath = '' +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$stamp = Get-Date -Format 'yyyyMMddHHmmss' +$marker = "DIM-OCR-PRICE-$stamp" +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputPath = Join-Path $repoRoot "reports\尺寸OCR渠道计费测试环境验收-$stamp.json" +} + +$originWarehouseId = '193000000000200001' +$destinationWarehouseId = '193000000000200003' +$customerId = '191000000000000001' +$successfulImages = @( + 'orig_030e8fd8-8ff3-4713-81f1-33b964887844.jpg', + 'orig_192da04f-1cfb-415b-ae2d-4b4f63fda871.JPG', + 'orig_f4525683-4ceb-4175-92a3-c3f66c8a9423.jpg' +) +$failedImage = 'orig_168c6cb6-ed65-49cc-b799-84c8855be97a.jpg' +$probeImages = @( + 'orig_052f3c40-2dc8-432f-8f23-6961415bcac5.jpg', + 'orig_07220408-d9a6-4950-9c30-e813d4e9d575.jpg', + 'orig_0977314f-330e-4044-9f9c-0697c7af1c9e.jpg', + 'orig_1064d1c3-0d84-43b4-b19d-62e6c4f2d65a.jpg', + 'orig_15925407-b2f6-4c79-a5bc-e96ece7314ed.jpg', + 'orig_172ff266-2e8a-49d1-8cc2-892ff23fbf3b.jpg', + 'orig_1e091ecb-c3c1-46b3-95f4-727f81bfb2fb.jpg', + 'orig_28149aa5-11bf-44d3-a3e6-6d457d5fd188.jpg', + 'orig_31802a7b-294a-476a-ac5b-4c92a87df18e.jpg', + 'orig_3c1f4815-a058-4cdd-9662-094d5a2e1ba7.jpg', + 'orig_435124c6-d380-4f7a-aed8-75388a88685e.jpg', + 'orig_490ac151-87b9-4bc6-8958-8c1aa9ff6cdd.jpg' +) +$scanDimensions = @( + @{ length = 10; width = 10; height = 10; expectedOuter = @(34, 24, 24) }, + @{ length = 35; width = 25; height = 25; expectedOuter = @(44, 34, 34) }, + @{ length = 45; width = 35; height = 35; expectedOuter = @(54, 44, 44) }, + @{ length = 55; width = 45; height = 45; expectedOuter = @(64, 54, 54) } +) + +$steps = [System.Collections.Generic.List[object]]::new() +$runs = [System.Collections.Generic.List[object]]::new() +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "baiyue-dim-ocr-$stamp-$([guid]::NewGuid().ToString('N'))" +$null = New-Item -ItemType Directory -Path $tempRoot + +function New-Key([string]$Action) { + return "$marker-$Action-$([guid]::NewGuid().ToString('N'))" +} + +function Invoke-Api( + [string]$Method, + [string]$Path, + [string]$Token = '', + [object]$Body = $null, + [hashtable]$ExtraHeaders = @{} +) { + $headers = @{} + if (-not [string]::IsNullOrWhiteSpace($Token)) { $headers.Authorization = "Bearer $Token" } + foreach ($entry in $ExtraHeaders.GetEnumerator()) { $headers[$entry.Key] = $entry.Value } + $args = @{ + Method = $Method + Uri = "$GatewayBaseUrl$Path" + Headers = $headers + TimeoutSec = 45 + SkipHttpErrorCheck = $true + } + if ($null -ne $Body) { + $args.ContentType = 'application/json; charset=utf-8' + $jsonText = $Body | ConvertTo-Json -Depth 30 -Compress + $args.Body = [Text.Encoding]::UTF8.GetBytes($jsonText) + } + $response = Invoke-WebRequest @args + $json = $null + if (-not [string]::IsNullOrWhiteSpace($response.Content)) { + try { $json = $response.Content | ConvertFrom-Json } catch { } + } + return [pscustomobject]@{ + http = [int]$response.StatusCode + body = $json + raw = if ($null -eq $json) { [string]$response.Content } else { $null } + requestId = if ($null -ne $json) { $json.requestId } else { $response.Headers['X-Request-Id'] } + traceId = if ($null -ne $json) { $json.traceId } else { $response.Headers['X-Trace-Id'] } + } +} + +function Invoke-Multipart( + [string]$Path, + [string]$Token, + [string]$FilePath, + [object]$Data = $null +) { + $responsePath = Join-Path $tempRoot "$([guid]::NewGuid().ToString('N')).json" + $curlArgs = @('-sS', '-X', 'POST', "$GatewayBaseUrl$Path", '-H', "Authorization: Bearer $Token") + if ($null -ne $Data) { + $dataPath = Join-Path $tempRoot "$([guid]::NewGuid().ToString('N')).json" + $Data | ConvertTo-Json -Depth 20 -Compress | Set-Content -LiteralPath $dataPath -Encoding utf8NoBOM + $curlArgs += @('-F', "data=@$dataPath;type=application/json") + } + $file = Get-Item -LiteralPath $FilePath + $curlArgs += @('-F', "$(if ($null -eq $Data) { 'file' } else { 'image' })=@$($file.FullName);type=image/jpeg") + $curlArgs += @('-o', $responsePath, '-w', '%{http_code}') + $httpText = & curl.exe @curlArgs + if ($LASTEXITCODE -ne 0) { throw "multipart 请求传输失败:$Path" } + $content = Get-Content -LiteralPath $responsePath -Raw + $json = $null + if (-not [string]::IsNullOrWhiteSpace($content)) { + try { $json = $content | ConvertFrom-Json } catch { } + } + return [pscustomobject]@{ + http = [int]$httpText + body = $json + raw = if ($null -eq $json) { $content } else { $null } + requestId = if ($null -ne $json) { $json.requestId } else { $null } + traceId = if ($null -ne $json) { $json.traceId } else { $null } + } +} + +function Add-Step([string]$Run, [string]$Name, $Response, [bool]$Passed, [string]$Evidence = '') { + $step = [pscustomobject]@{ + run = $Run + name = $Name + passed = $Passed + http = $Response.http + code = if ($null -ne $Response.body) { [string]$Response.body.code } else { $null } + message = if ($null -ne $Response.body) { [string]$Response.body.message } else { [string]$Response.raw } + requestId = $Response.requestId + traceId = $Response.traceId + evidence = $Evidence + } + $steps.Add($step) + Write-Host "[$(if ($Passed) { 'PASS' } else { 'FAIL' })][$Run] $Name $Evidence" +} + +function Require-Success([string]$Run, [string]$Name, $Response, [string]$Evidence = '') { + $passed = $Response.http -eq 200 -and [string]$Response.body.code -eq '200' + Add-Step $Run $Name $Response $passed $Evidence + if (-not $passed) { + throw "$Run / $Name failed: HTTP=$($Response.http), code=$($Response.body.code), message=$($Response.body.message)" + } + return $Response.body.data +} + +function New-Cargo([int]$Index) { + $dimension = $scanDimensions[$Index - 1] + return [ordered]@{ + cargoName = "测试产品$Index" + cargoNameEn = "Dimension Test Product $Index" + hsCode = '8518210000' + customsHsCode = '8518210000' + packageType = 'CARTON' + packageCount = 1 + grossWeightKg = 5 + netWeightKg = 4.5 + volumeCbm = [decimal]($dimension.length * $dimension.width * $dimension.height / 1000000) + lengthCm = $dimension.length + widthCm = $dimension.width + heightCm = $dimension.height + declaredValue = 100 + unitDeclaredValue = 100 + currency = 'USD' + dangerousGoods = $false + cargoRemark = "$marker tier-$Index" + skuNo = "SKU-$stamp-$Index" + upcNo = "UPC-$stamp-$Index" + unitsPerPackage = 1 + totalQuantity = 1 + material = '塑料 / Plastic' + usage = '测试 / Test' + model = "DIM-$Index" + brand = 'BAIYUE-E2E' + } +} + +function New-OrderPayload([string]$PricingTaxMode) { + $cargoes = @() + 1..4 | ForEach-Object { $cargoes += New-Cargo $_ } + return [ordered]@{ + customerId = $customerId + customerName = '深圳示例贸易有限公司' + transportMode = 'SEA' + tradeTerm = 'FOB' + destinationCountryCode = 'US' + destinationWarehouseId = $destinationWarehouseId + destinationWarehouseName = '洛杉矶仓' + expectedShipDate = (Get-Date).AddDays(10).ToString('yyyy-MM-dd') + customerRemark = "$marker $PricingTaxMode" + sender = [ordered]@{ + companyName = '深圳示例贸易有限公司' + contactName = '测试员' + phone = '13800138000' + email = 'e2e@example.com' + countryCode = 'CN' + province = '浙江省' + city = '义乌市' + district = '义乌市' + addressLine = '测试环境全链路地址' + postalCode = '322000' + } + receiver = $null + cargoes = $cargoes + pickupMethod = 'PICKUP' + receivingWarehouseId = $originWarehouseId + receivingWarehouseName = '义乌仓' + channelCode = 'EXX_TRUCK' + channelName = 'EXX_TRUCK' + carrierId = $null + carrierName = $null + taxMode = 'BUY_DECLARATION' + pricingTaxMode = $PricingTaxMode + containerMode = 'LCL' + customsInspection = $false + destinationType = 'OVERSEAS' + amazonWarehouseCode = $null + shipmentId = "SHIP-$stamp" + referenceId = "$marker-$PricingTaxMode" + } +} + +function Create-ApprovedInbound([string]$RunName, [string]$MerchantToken, [string]$PlatformToken, + [string]$PricingTaxMode) { + $create = Invoke-Api POST '/api/merchant/v1/orders/first-leg' $MerchantToken (New-OrderPayload $PricingTaxMode) @{ + 'Idempotency-Key' = New-Key "create-$PricingTaxMode" + } + $orderId = [string](Require-Success $RunName '创建订单' $create) + $submit = Invoke-Api POST "/api/merchant/v1/orders/first-leg/$orderId/submit" $MerchantToken $null @{ + 'Idempotency-Key' = New-Key "submit-$PricingTaxMode" + } + $null = Require-Success $RunName '提交订单' $submit "orderId=$orderId" + $approve = Invoke-Api POST "/api/platform/v1/orders/first-leg/$orderId/approve?tenantId=$TenantId" $PlatformToken + $null = Require-Success $RunName '审核订单' $approve "orderId=$orderId" + + $detail = $null + $detailResponse = $null + for ($attempt = 1; $attempt -le 20; $attempt++) { + $detailResponse = Invoke-Api GET "/api/platform/v1/orders/$orderId`?tenantId=$TenantId" $PlatformToken + if ($detailResponse.http -eq 200 -and [string]$detailResponse.body.code -eq '200' -and + $null -ne $detailResponse.body.data.summary.inboundId) { + $detail = $detailResponse.body.data + break + } + Start-Sleep -Seconds 3 + } + $ready = $null -ne $detail -and $null -ne $detail.summary.inboundId + Add-Step $RunName '生成入库单' $detailResponse $ready $(if ($ready) { "inboundId=$($detail.summary.inboundId)" } else { '60秒内未生成' }) + if (-not $ready) { throw "$RunName 入库单未生成" } + return [pscustomobject]@{ + orderId = $orderId + inboundId = [string]$detail.summary.inboundId + inboundCode = [string]$detail.summary.inboundCode + } +} + +function Initialize-CartonLabels([string]$RunName, [string]$PlatformToken, [string]$InboundId) { + $response = Invoke-WebRequest -Method POST -Uri "$GatewayBaseUrl/api/platform/inbounds/$InboundId/carton-label-pdf?tenantId=$TenantId" ` + -Headers @{ Authorization = "Bearer $PlatformToken" } -TimeoutSec 60 -SkipHttpErrorCheck + $contentType = [string]$response.Headers['Content-Type'] + $passed = [int]$response.StatusCode -eq 200 -and $contentType.StartsWith('application/pdf') + $fakeResponse = [pscustomobject]@{ + http = [int]$response.StatusCode + body = $null + raw = if ($passed) { $null } else { [string]$response.Content } + requestId = $response.Headers['X-Request-Id'] + traceId = $response.Headers['X-Trace-Id'] + } + Add-Step $RunName '生成并冻结箱码' $fakeResponse $passed "contentType=$contentType" + if (-not $passed) { throw "$RunName 箱码 PDF 生成失败" } +} + +function Scan-Cartons([string]$RunName, [string]$PlatformToken, [string]$InboundId, + [string]$InboundCode, [int]$TotalCartons = 4) { + $results = @() + for ($index = 1; $index -le 4; $index++) { + $dimension = $scanDimensions[$index - 1] + $imageName = if ($index -le 3) { $successfulImages[$index - 1] } else { $failedImage } + $scan = Invoke-Multipart "/api/platform/v1/first-leg/inbounds/$InboundId/measurement/carton-scans?tenantId=$TenantId" ` + $PlatformToken (Join-Path $ImageDirectory $imageName) @{ + boxCode = "$InboundCode#$TotalCartons-$index" + length = $dimension.length + width = $dimension.width + height = $dimension.height + weight = 5 + $index + machineNo = 'MOJIA-E2E-01' + measuredAt = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss') + } + $saved = Require-Success $RunName "墨家箱测量-$index" $scan "boxCode=$InboundCode#$TotalCartons-$index" + $results += $saved + } + return $results +} + +function Set-RepresentativeCartonCounts([string]$RunName, [string]$PlatformToken, [string]$InboundId, + [object[]]$Scans, [int]$TotalCartons) { + if ($TotalCartons -eq $Scans.Count) { return } + if ($TotalCartons % $Scans.Count -ne 0) { + throw "总箱数 $TotalCartons 不能平均分配给 $($Scans.Count) 条测量记录" + } + $representativeCount = [int]($TotalCartons / $Scans.Count) + $detailResponse = Invoke-Api GET "/api/platform/v1/first-leg/inbounds/$InboundId/measurement?tenantId=$TenantId" $PlatformToken + $detail = Require-Success $RunName '读取箱数确认前测量版本' $detailResponse + $version = [int]$detail.version + foreach ($scan in $Scans) { + $update = Invoke-Api PUT "/api/platform/v1/first-leg/inbounds/$InboundId/measurement/items/$($scan.itemId)/confirmed-package-count?tenantId=$TenantId" ` + $PlatformToken @{ measurementVersion = $version; confirmedPackageCount = $representativeCount } + $updated = Require-Success $RunName "确认代表箱数-$($scan.boxCode)" $update "confirmedPackageCount=$representativeCount" + $version = [int]$updated.measurementVersion + } +} + +function Upload-And-ConfirmWeight([string]$RunName, [string]$PlatformToken, [string]$InboundId, + [string]$ImageName, [object]$ManualWeight) { + $upload = Invoke-Multipart "/api/platform/v1/files/measurements/$TenantId/$InboundId" $PlatformToken ` + (Join-Path $ImageDirectory $ImageName) + $uploaded = Require-Success $RunName "OCR-$ImageName" $upload + $ocrEvidence = "status=$($uploaded.recognitionStatus), weight=$($uploaded.weightValue), confidence=$($uploaded.confidence), error=$($uploaded.error)" + Add-Step $RunName "OCR结果-$ImageName" $upload ($uploaded.recognitionStatus -in @('RECOGNIZED', 'FAILED')) $ocrEvidence + + if ($uploaded.recognitionStatus -eq 'FAILED') { + $retry = Invoke-Api POST "/api/platform/v1/files/measurements/$TenantId/$InboundId/$($uploaded.fileId)/recognize" $PlatformToken + $retried = Require-Success $RunName "OCR重试-$ImageName" $retry + Add-Step $RunName "OCR重试结果-$ImageName" $retry ($retried.recognitionStatus -in @('RECOGNIZED', 'FAILED')) ` + "status=$($retried.recognitionStatus), error=$($retried.error)" + $uploaded = $retried + } + + $create = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$InboundId/weight-results?tenantId=$TenantId" $PlatformToken @{ + fileId = [string]$uploaded.fileId + ocrProvider = 'BAIDU_OCR_TEST_ENV' + ocrRawText = if ($uploaded.ocrRawText) { [string]$uploaded.ocrRawText } else { [string]$uploaded.error } + ocrWeightValue = $uploaded.weightValue + ocrWeightUnit = $uploaded.weightUnit + confidence = $uploaded.confidence + } + $weight = Require-Success $RunName "登记重量-$ImageName" $create + $confirmBody = @{ version = [int]$weight.version } + if ($null -ne $ManualWeight) { + $confirmBody.adjustedWeightValue = [decimal]$ManualWeight + $confirmBody.adjustedWeightUnit = 'KG' + $confirmBody.adjustmentReason = "$marker OCR失败人工读数" + } + $confirm = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$InboundId/weight-results/$($weight.id)/confirm?tenantId=$TenantId" ` + $PlatformToken $confirmBody + return Require-Success $RunName "确认重量-$ImageName" $confirm +} + +function Probe-Ocr([string]$PlatformToken, [string]$InboundId, [string]$ImageName) { + $upload = Invoke-Multipart "/api/platform/v1/files/measurements/$TenantId/$InboundId" $PlatformToken ` + (Join-Path $ImageDirectory $ImageName) + $uploaded = Require-Success 'OCR-PROBE' "OCR-$ImageName" $upload + $evidence = "status=$($uploaded.recognitionStatus), weight=$($uploaded.weightValue), confidence=$($uploaded.confidence), raw=$($uploaded.ocrRawText), error=$($uploaded.error)" + Add-Step 'OCR-PROBE' "OCR结果-$ImageName" $upload ($uploaded.recognitionStatus -in @('RECOGNIZED', 'FAILED')) $evidence + return $uploaded +} + +function Complete-MeasurementAndPrice([string]$RunName, [string]$PlatformToken, [string]$MerchantToken, + $Reference, [string[]]$WeightImages, [object]$ManualWeight, + [bool]$AllowPricingFailure = $false) { + foreach ($image in $WeightImages) { + $manual = if ($image -eq $failedImage) { $ManualWeight } else { $null } + $null = Upload-And-ConfirmWeight $RunName $PlatformToken $Reference.inboundId $image $manual + } + + $measurementResponse = Invoke-Api GET "/api/platform/v1/first-leg/inbounds/$($Reference.inboundId)/measurement?tenantId=$TenantId" $PlatformToken + $measurement = Require-Success $RunName '读取测量汇总' $measurementResponse + $outer = @($measurement.items | Sort-Object boxCode | ForEach-Object { + @([decimal]$_.outerLengthCm, [decimal]$_.outerWidthCm, [decimal]$_.outerHeightCm) + }) + $expectedOuter = @($scanDimensions | ForEach-Object { $_.expectedOuter }) + $weighted = ($outer | ConvertTo-Json -Compress) -eq ($expectedOuter | ConvertTo-Json -Compress) + Add-Step $RunName '验证四档尺寸加权' $measurementResponse $weighted ` + "actualOuter=$($outer | ConvertTo-Json -Compress), expectedOuter=$($expectedOuter | ConvertTo-Json -Compress), actualVolumeCbm=$($measurement.actualVolumeCbm)" + + $submit = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$($Reference.inboundId)/measurement/submit?tenantId=$TenantId" ` + $PlatformToken @{ measurementId = [string]$measurement.measurementId; version = [int]$measurement.version } + $submitted = Require-Success $RunName '提交测量审核' $submit + $inboundResponse = Invoke-Api GET "/api/platform/inbounds/$($Reference.inboundId)?tenantId=$TenantId" $PlatformToken + $inbound = Require-Success $RunName '读取待审核入库单' $inboundResponse + $approve = Invoke-Api POST "/api/platform/v1/inbounds/$($Reference.inboundId)/measurements/approve?tenantId=$TenantId" ` + $PlatformToken @{ + measurementId = [string]$measurement.measurementId + expectedVersion = [int]$inbound.summary.version + reason = $null + } @{ 'Idempotency-Key' = New-Key "measurement-approve-$RunName" } + $null = Require-Success $RunName '审核测量' $approve + + $feeResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($Reference.inboundId)/fees/calculate?tenantId=$TenantId" ` + $PlatformToken @{} @{ 'Idempotency-Key' = New-Key "fee-$RunName" } + $feePassed = $feeResponse.http -eq 200 -and [string]$feeResponse.body.code -eq '200' + Add-Step $RunName '渠道计费' $feeResponse $feePassed $(if ($feePassed) { + "chargeUnit=$($feeResponse.body.data.chargeUnit), quantity=$($feeResponse.body.data.chargeableQuantity), total=$($feeResponse.body.data.totalAmount)" + } else { + "code=$($feeResponse.body.code), message=$($feeResponse.body.message)" + }) + if (-not $feePassed -and -not $AllowPricingFailure) { + throw "$RunName / 渠道计费 failed: code=$($feeResponse.body.code), message=$($feeResponse.body.message)" + } + $fee = if ($feePassed) { $feeResponse.body.data } else { $null } + $billing = $null + if ($feePassed) { + $confirmResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($Reference.inboundId)/fees/confirm?tenantId=$TenantId" ` + $PlatformToken $null @{ 'Idempotency-Key' = New-Key "fee-confirm-$RunName" } + $confirmedFee = Require-Success $RunName '确认费用' $confirmResponse + if ([string]$confirmedFee.feeStatus -ne 'CONFIRMED') { + throw "$RunName / 确认费用状态异常: feeStatus=$($confirmedFee.feeStatus)" + } + + $sendResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($Reference.inboundId)/fees/send?tenantId=$TenantId" ` + $PlatformToken $null @{ 'Idempotency-Key' = New-Key "fee-send-$RunName" } + $fee = Require-Success $RunName '发布费用' $sendResponse + if ([string]$fee.feeStatus -ne 'SENT') { + throw "$RunName / 发布费用状态异常: feeStatus=$($fee.feeStatus)" + } + + $billingResponse = Invoke-Api GET "/api/merchant/v1/first-leg/orders/$($Reference.orderId)/inbound-billing" $MerchantToken + $billing = Require-Success $RunName '客户费用快照' $billingResponse + } + return [pscustomobject]@{ + orderId = $Reference.orderId + inboundId = $Reference.inboundId + inboundCode = $Reference.inboundCode + measurement = $measurement + submitted = $submitted + fee = $fee + billing = $billing + dimensionWeightingPassed = $weighted + } +} + +$fatal = $null +try { + $requiredImages = if ($OcrProbeOnly) { $probeImages } else { @($successfulImages + $failedImage) } + foreach ($imageName in $requiredImages) { + $imagePath = Join-Path $ImageDirectory $imageName + if (-not (Test-Path -LiteralPath $imagePath)) { throw "找不到测试图片:$imagePath" } + } + + $platformLogin = Invoke-Api POST '/api/auth/login' '' @{ username = $PlatformUsername; password = $PlatformPassword } + $platform = Require-Success 'GLOBAL' '平台登录' $platformLogin + $merchantLogin = Invoke-Api POST '/api/auth/login' '' @{ username = $MerchantUsername; password = $MerchantPassword } + $merchant = Require-Success 'GLOBAL' '客户登录' $merchantLogin + + if ($OcrProbeOnly) { + if ($ExistingInboundId -le 0) { throw 'OCR probe 模式必须提供 ExistingInboundId' } + $ocrResults = @() + foreach ($image in $probeImages) { + $ocrResults += Probe-Ocr $platform.accessToken ([string]$ExistingInboundId) $image + } + $runs.Add([pscustomobject]@{ + inboundId = [string]$ExistingInboundId + ocrResults = $ocrResults + }) + } elseif ($OcrOnly) { + if ($ExistingInboundId -le 0) { throw 'OCR-only 模式必须提供 ExistingInboundId' } + $weightResults = @() + foreach ($image in @($successfulImages + $failedImage)) { + $manual = if ($image -eq $failedImage) { [decimal]128 } else { $null } + $weightResults += Upload-And-ConfirmWeight 'OCR' $platform.accessToken ([string]$ExistingInboundId) $image $manual + } + $measurementResponse = Invoke-Api GET "/api/platform/v1/first-leg/inbounds/$ExistingInboundId/measurement?tenantId=$TenantId" $platform.accessToken + $measurement = Require-Success 'OCR' '读取确认后重量汇总' $measurementResponse + $runs.Add([pscustomobject]@{ + inboundId = [string]$ExistingInboundId + weightResults = $weightResults + actualGrossWeightKg = $measurement.actualGrossWeightKg + measurement = $measurement + }) + } elseif ($ExistingInboundId -gt 0) { + if ($ExistingOrderId -le 0 -or [string]::IsNullOrWhiteSpace($ExistingInboundCode) -or $ExistingCartonCount -lt 4) { + throw '恢复模式必须同时提供 ExistingOrderId、ExistingInboundCode 和至少 4 箱的 ExistingCartonCount' + } + $reference = [pscustomobject]@{ + orderId = [string]$ExistingOrderId + inboundId = [string]$ExistingInboundId + inboundCode = $ExistingInboundCode + } + Initialize-CartonLabels 'EXISTING' $platform.accessToken $reference.inboundId + $scans = @(Scan-Cartons 'EXISTING' $platform.accessToken $reference.inboundId ` + $reference.inboundCode $ExistingCartonCount) + Set-RepresentativeCartonCounts 'EXISTING' $platform.accessToken $reference.inboundId $scans $ExistingCartonCount + $existingRun = Complete-MeasurementAndPrice 'EXISTING' $platform.accessToken $merchant.accessToken $reference ` + @($successfulImages + $failedImage) ([decimal]128) $true + $runs.Add($existingRun) + } else { + $kgReference = Create-ApprovedInbound 'KG' $merchant.accessToken $platform.accessToken 'TAX_INCLUDED' + Initialize-CartonLabels 'KG' $platform.accessToken $kgReference.inboundId + $null = Scan-Cartons 'KG' $platform.accessToken $kgReference.inboundId $kgReference.inboundCode + $kgRun = Complete-MeasurementAndPrice 'KG' $platform.accessToken $merchant.accessToken $kgReference ` + $successfulImages $null + $runs.Add($kgRun) + + $cbmReference = Create-ApprovedInbound 'CBM' $merchant.accessToken $platform.accessToken 'TAX_EXCLUDED' + Initialize-CartonLabels 'CBM' $platform.accessToken $cbmReference.inboundId + $null = Scan-Cartons 'CBM' $platform.accessToken $cbmReference.inboundId $cbmReference.inboundCode + $cbmRun = Complete-MeasurementAndPrice 'CBM' $platform.accessToken $merchant.accessToken $cbmReference ` + @($failedImage) ([decimal]128) + $runs.Add($cbmRun) + } +} catch { + $fatal = $_.Exception.Message + Write-Host "[FATAL] $fatal" +} finally { + $report = [ordered]@{ + marker = $marker + gateway = $GatewayBaseUrl + tenantId = [string]$TenantId + generatedAt = (Get-Date).ToString('o') + success = $null -eq $fatal -and @($steps | Where-Object { -not $_.passed }).Count -eq 0 + fatal = $fatal + runs = $runs + steps = $steps + } + $reportDirectory = Split-Path -Parent $OutputPath + if (-not (Test-Path -LiteralPath $reportDirectory)) { $null = New-Item -ItemType Directory -Path $reportDirectory } + $report | ConvertTo-Json -Depth 40 | Set-Content -LiteralPath $OutputPath -Encoding utf8NoBOM + if ((Resolve-Path -LiteralPath $tempRoot).Path.StartsWith([System.IO.Path]::GetTempPath(), [System.StringComparison]::OrdinalIgnoreCase)) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force + } + Write-Host "REPORT=$OutputPath" +} + +if ($null -ne $fatal) { exit 1 } +if (@($steps | Where-Object { -not $_.passed }).Count -gt 0) { exit 2 } diff --git a/scripts/export-center-test-env-acceptance.ps1 b/scripts/export-center-test-env-acceptance.ps1 new file mode 100644 index 000000000..8f47229c9 --- /dev/null +++ b/scripts/export-center-test-env-acceptance.ps1 @@ -0,0 +1,171 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^https?://')] + [string]$GatewayBaseUrl, + + [string[]]$AccessTokens = @(), + [ValidateRange(1, 4)] + [int]$TaskCount = 1, + [string[]]$TaskIds = @(), + [ValidateRange(1, 7200)] + [int]$TimeoutSeconds = 3600, + [ValidateRange(1, 60)] + [int]$PollSeconds = 2, + [ValidateRange(0, 200000)] + [int]$MinimumRows = 0, + [string]$Keyword = '', + [ValidateSet('', 'PROFILE_REQUIRED', 'PENDING', 'ACTIVE', 'DISABLED', 'REJECTED')] + [string]$Status = '', + [string]$OutputDirectory = '', + [switch]$CreateOnly, + [switch]$SkipDownload +) + +$ErrorActionPreference = 'Stop' +$baseUrl = $GatewayBaseUrl.TrimEnd('/') +$apiPath = "$baseUrl/api/platform/v1/files/export-tasks" + +if ($AccessTokens.Count -eq 0 -and -not [string]::IsNullOrWhiteSpace($env:ADMIN_ACCESS_TOKEN)) { + $AccessTokens = @($env:ADMIN_ACCESS_TOKEN) +} +if ($AccessTokens.Count -eq 0) { + throw '至少提供一个 -AccessTokens,或通过 ADMIN_ACCESS_TOKEN 环境变量注入。' +} +if ($TaskIds.Count -eq 0 -and $AccessTokens.Count -lt $TaskCount) { + throw "创建 $TaskCount 个并发任务需要至少 $TaskCount 个不同用户的 Access Token,避免 60 秒重复提交合并。" +} +if ($TaskIds.Count -gt 0 -and $AccessTokens.Count -lt $TaskIds.Count) { + throw '恢复验收时每个 TaskId 都需要对应申请人的 Access Token。' +} + +function Get-Headers([string]$Token) { + return @{ Authorization = "Bearer $Token" } +} + +function Invoke-ApiJson([string]$Method, [string]$Uri, [string]$Token, [object]$Body = $null) { + $arguments = @{ + Method = $Method + Uri = $Uri + Headers = Get-Headers $Token + TimeoutSec = 60 + } + if ($null -ne $Body) { + $arguments.ContentType = 'application/json' + $arguments.Body = $Body | ConvertTo-Json -Depth 10 -Compress + } + $response = Invoke-RestMethod @arguments + if ($null -eq $response -or [string]$response.code -ne '200') { + $requestId = if ($response.requestId) { " requestId=$($response.requestId)" } else { '' } + $traceId = if ($response.traceId) { " traceId=$($response.traceId)" } else { '' } + throw "API 失败 method=$Method uri=$Uri code=$($response.code) message=$($response.message)$requestId$traceId" + } + return $response.data +} + +function New-ExportTask([string]$Token, [int]$Index) { + $parameters = [ordered]@{} + if (-not [string]::IsNullOrWhiteSpace($Keyword)) { $parameters.keyword = $Keyword.Trim() } + if (-not [string]::IsNullOrWhiteSpace($Status)) { $parameters.status = $Status } + $payload = @{ + exportType = 'CUSTOMER_LIST' + format = 'XLSX' + taskName = "客户列表验收-$Index" + parameters = $parameters + } + return Invoke-ApiJson POST $apiPath $Token $payload +} + +function Wait-ExportTask([string]$TaskId, [string]$Token) { + $deadline = [DateTimeOffset]::UtcNow.AddSeconds($TimeoutSeconds) + $lastStatus = '' + while ([DateTimeOffset]::UtcNow -lt $deadline) { + $task = Invoke-ApiJson GET "$apiPath/$TaskId" $Token + if ($task.status -ne $lastStatus) { + Write-Host "taskId=$TaskId status=$($task.status) processed=$($task.processedRows) total=$($task.totalRows)" + $lastStatus = $task.status + } + if ($task.status -in @('SUCCEEDED', 'FAILED', 'EXPIRED')) { return $task } + Start-Sleep -Seconds $PollSeconds + } + throw "等待导出任务超时 taskId=$TaskId timeoutSeconds=$TimeoutSeconds" +} + +function Save-And-VerifyXlsx([object]$Task, [string]$Token, [string]$Directory) { + $target = Join-Path $Directory "$($Task.id).xlsx" + $response = Invoke-WebRequest -UseBasicParsing -Uri "$apiPath/$($Task.id)/download" ` + -Headers (Get-Headers $Token) -TimeoutSec 600 -OutFile $target -PassThru + $contentType = [string]$response.Headers['Content-Type'] + if ($contentType.ToLowerInvariant().Contains('json')) { + $payload = Get-Content -Raw -LiteralPath $target | ConvertFrom-Json + throw "下载返回业务错误 taskId=$($Task.id) code=$($payload.code) message=$($payload.message) requestId=$($payload.requestId) traceId=$($payload.traceId)" + } + $stream = [IO.File]::OpenRead($target) + try { + $signature = New-Object byte[] 4 + if ($stream.Read($signature, 0, 4) -ne 4 -or $signature[0] -ne 0x50 -or $signature[1] -ne 0x4B ` + -or $signature[2] -ne 0x03 -or $signature[3] -ne 0x04) { + throw "下载结果不是有效 XLSX/ZIP taskId=$($Task.id) contentType=$contentType" + } + } finally { + $stream.Dispose() + } + return [pscustomobject]@{ Path = $target; Bytes = (Get-Item -LiteralPath $target).Length; ContentType = $contentType } +} + +$createdTasks = [System.Collections.Generic.List[object]]::new() +if ($TaskIds.Count -eq 0) { + for ($index = 0; $index -lt $TaskCount; $index++) { + $task = New-ExportTask $AccessTokens[$index] ($index + 1) + $createdTasks.Add([pscustomobject]@{ Task = $task; Token = $AccessTokens[$index] }) + Write-Host "created taskId=$($task.id) owner=$($task.requestedBy) status=$($task.status)" + } + $uniqueIds = @($createdTasks | ForEach-Object { [string]$_.Task.id } | Sort-Object -Unique) + if ($uniqueIds.Count -ne $createdTasks.Count) { + throw '多个创建请求被重复提交规则合并;容量验收需使用不同申请用户,或等待 60 秒后重试。' + } +} else { + for ($index = 0; $index -lt $TaskIds.Count; $index++) { + $createdTasks.Add([pscustomobject]@{ Task = [pscustomobject]@{ id = $TaskIds[$index] }; Token = $AccessTokens[$index] }) + } +} + +if ($CreateOnly) { + $createdTasks | ForEach-Object { [pscustomobject]@{ taskId = [string]$_.Task.id } } | ConvertTo-Json + exit 0 +} + +$temporaryOutput = [string]::IsNullOrWhiteSpace($OutputDirectory) +if ($temporaryOutput) { + $OutputDirectory = Join-Path ([IO.Path]::GetTempPath()) "baiyue-export-acceptance-$([Guid]::NewGuid())" +} +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + +$results = [System.Collections.Generic.List[object]]::new() +try { + foreach ($entry in $createdTasks) { + $task = Wait-ExportTask ([string]$entry.Task.id) $entry.Token + if ($task.status -ne 'SUCCEEDED') { + throw "导出任务未成功 taskId=$($task.id) status=$($task.status) failure=$($task.failureMessage)" + } + if ([long]$task.processedRows -lt $MinimumRows) { + throw "导出行数未达到门槛 taskId=$($task.id) processed=$($task.processedRows) minimum=$MinimumRows" + } + $artifact = $null + if (-not $SkipDownload) { $artifact = Save-And-VerifyXlsx $task $entry.Token $OutputDirectory } + $results.Add([pscustomobject]@{ + taskId = [string]$task.id + status = $task.status + processedRows = [long]$task.processedRows + sizeBytes = [long]$task.sizeBytes + downloadedBytes = if ($artifact) { [long]$artifact.Bytes } else { 0 } + file = if ($artifact) { $artifact.Path } else { $null } + }) + } + $results | ConvertTo-Json -Depth 5 + Write-Host "EXPORT_CENTER_ACCEPTANCE_PASSED=$($results.Count)" +} finally { + if ($temporaryOutput -and (Test-Path -LiteralPath $OutputDirectory)) { + Remove-Item -LiteralPath $OutputDirectory -Recurse -Force + } +} diff --git a/scripts/finance-deployment-contract.test.mjs b/scripts/finance-deployment-contract.test.mjs new file mode 100644 index 000000000..17a496a53 --- /dev/null +++ b/scripts/finance-deployment-contract.test.mjs @@ -0,0 +1,32 @@ +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 build finance as an independent service', async () => { + const controller = await read('Jenkinsfile') + const service = await read('Jenkinsfile.service') + + assert.match(controller, /'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance'/) + assert.match(controller, /'finance': 'SERVICE_FINANCE'/) + assert.match(controller, /booleanParam\(name: 'SERVICE_FINANCE'/) + assert.match(service, /'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance'/) + assert.match(service, /'finance': 'finance\/target\/finance-service-1\.0\.0-SNAPSHOT\.jar'/) +}) + +test('Compose and deploy script expose finance with fail-closed database config', async () => { + const compose = await read('deploy/docker-compose.yml') + const serviceMap = await read('deploy/service-map.yml') + const deployScript = await read('deploy/scripts/deploy-services.sh') + + assert.match(compose, /^ finance:\s*$/m) + assert.match(compose, /image: \$\{IMAGE_REPOSITORY\}\/finance:\$\{IMAGE_TAG\}/) + assert.match(compose, /SERVER_PORT: 9203/) + assert.match(compose, /FINANCE_DB_URL: \$\{FINANCE_DB_URL\}/) + assert.doesNotMatch(compose, /FINANCE_WALLET_DEFAULT_CREDIT_LIMIT/) + assert.match(serviceMap, /^ finance:\s*$/m) + assert.match(serviceMap, /jar: finance\/target\/finance-service-1\.0\.0-SNAPSHOT\.jar/) + assert.match(deployScript, /FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD/) +}) diff --git a/scripts/frontend-full-regression.ps1 b/scripts/frontend-full-regression.ps1 new file mode 100644 index 000000000..3baa4a15b --- /dev/null +++ b/scripts/frontend-full-regression.ps1 @@ -0,0 +1,69 @@ +[CmdletBinding()] +param( + [switch]$DryRun, + [switch]$SkipAdmin, + [switch]$SkipClient, + [switch]$SkipPda +) + +$ErrorActionPreference = 'Stop' +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path + +function Invoke-FrontendStep { + param([string]$Name, [string]$Directory, [string]$Task) + $display = "pnpm --dir `"$Directory`" $Task" + Write-Host " [$Name] $display" + if ($DryRun) { return } + & pnpm --dir $Directory $Task + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +function Invoke-PdaTests { + param([string]$Directory) + $pattern = Join-Path $Directory 'tests/*.test.mjs' + Write-Host " [test] node --test `"$pattern`"" + if ($DryRun) { return } + & node --test $pattern + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +if ($SkipAdmin -and $SkipClient -and $SkipPda) { Write-Error 'At least one of Admin, Client or PDA must be selected.'; exit 2 } +if ((-not $SkipAdmin -or -not $SkipClient) -and -not (Get-Command pnpm -ErrorAction SilentlyContinue)) { + Write-Error 'pnpm was not found on PATH.'; exit 2 +} +if (-not $SkipPda -and -not (Get-Command node -ErrorAction SilentlyContinue)) { + Write-Error 'node was not found on PATH.'; exit 2 +} + +$targets = @() +if (-not $SkipAdmin) { $targets += @{ Name = 'Admin'; Directory = Join-Path $repoRoot 'apps/admin-web' } } +if (-not $SkipClient) { $targets += @{ Name = 'Client'; Directory = Join-Path $repoRoot 'apps/client-web' } } + +Write-Host 'Frontend full regression (local only)' +Write-Host "Repository: $repoRoot" +Write-Host "Mode: $(if ($DryRun) { 'DryRun' } else { 'Execute' })" +Write-Host 'Gateway/database/deployment: not used' + +foreach ($target in $targets) { + $packageJson = Join-Path $target.Directory 'package.json' + if (-not (Test-Path -LiteralPath $packageJson)) { Write-Error "$($target.Name) package.json not found: $packageJson"; exit 2 } + Write-Host "[$($target.Name)]" + Invoke-FrontendStep 'test' $target.Directory 'test' + Invoke-FrontendStep 'typecheck' $target.Directory 'typecheck' +} + +if (-not $SkipPda) { + $pdaTargets = @( + @{ Name = 'Domestic PDA'; Directory = Join-Path $repoRoot 'apps/pda-uniapp' }, + @{ Name = 'Overseas PDA'; Directory = Join-Path $repoRoot 'apps/pda-overseas-uniapp' } + ) + foreach ($pdaTarget in $pdaTargets) { + if (-not (Test-Path -LiteralPath (Join-Path $pdaTarget.Directory 'tests'))) { + Write-Error "$($pdaTarget.Name) tests directory not found: $($pdaTarget.Directory)"; exit 2 + } + Write-Host "[$($pdaTarget.Name)]" + Invoke-PdaTests $pdaTarget.Directory + } +} + +Write-Host 'Frontend regression completed.' diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs new file mode 100644 index 000000000..74939ae8a --- /dev/null +++ b/scripts/full-regression-entry-contract.test.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { normalizeModuleOrder } from './admin-test-env-acceptance.mjs' + +const scriptsDir = dirname(fileURLToPath(import.meta.url)) +const backendScript = readFileSync(resolve(scriptsDir, 'backend-full-regression.ps1'), 'utf8') +const frontendScript = readFileSync(resolve(scriptsDir, 'frontend-full-regression.ps1'), 'utf8') +const adminAcceptance = readFileSync(resolve(scriptsDir, 'admin-test-env-acceptance.mjs'), 'utf8') + +test('frontend full regression includes domestic and overseas PDA node tests by default', () => { + assert.match(frontendScript, /\[switch\]\$SkipPda/) + assert.match(frontendScript, /apps\/pda-uniapp/) + assert.match(frontendScript, /apps\/pda-overseas-uniapp/) + assert.match(frontendScript, /Domestic PDA/) + assert.match(frontendScript, /Overseas PDA/) + assert.match(frontendScript, /node --test/) + assert.match(frontendScript, /tests\/\*\.test\.mjs/) +}) + +test('backend full regression includes file reactor and critical file/container coverage', () => { + assert.match(backendScript, /-pl["',\s\S]*order,firstleg,iam,gateway,file/) + for (const required of [ + 'file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java', + 'file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java', + 'file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java', + 'firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java', + 'firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java', + 'firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java', + 'firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java', + 'firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java', + ]) assert.match(backendScript, new RegExp(required.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) +}) + +test('backend full regression rejects Mapper registration drift before Maven', () => { + assert.match(backendScript, /scripts\/mapper-scan-contract\.mjs/) + assert.match(backendScript, /& node \$mapperScanContract[\s\S]*& \$MavenCommand @mavenArgs/) +}) + +test('test-environment full acceptance registers container overview module', () => { + const modulePath = resolve(scriptsDir, 'admin-test-env-modules', 'phase3-container-overview.mjs') + if (!adminAcceptance.includes('phase3-container-overview') || !existsSync(modulePath)) { + assert.fail('default acceptance module phase3-container-overview is not registered yet') + } +}) + +test('test-environment full acceptance executes warehouse-scoped booking channel isolation', () => { + const loadingModulePath = resolve(scriptsDir, 'admin-test-env-modules', 'phase3-container-loading.mjs') + const fixturePath = resolve(scriptsDir, 'admin-test-env-modules', 'ocean-booking-fixture.mjs') + assert.match(adminAcceptance, /CONFIGURED_MODULES\s*=\s*\[[^\]]*'phase3-container-loading'/s) + assert.ok(existsSync(loadingModulePath), 'phase3-container-loading module is missing') + assert.ok(existsSync(fixturePath), 'shared ocean booking fixture is missing') + + const loadingModule = readFileSync(loadingModulePath, 'utf8') + const fixture = readFileSync(fixturePath, 'utf8') + assert.match(loadingModule, /publishPlatformLogisticsChannels\(context, warehouseId, MODULE\)/) + assert.match(loadingModule, /selectPlatformChannelIsolationPair/) + assert.match(loadingModule, /BLOCKED_FIXTURE/) + assert.match(fixture, /\/api\/platform\/v1\/ocean-bookings\/channels/) + assert.match(fixture, /createdResources\.platformLogisticsChannels/) +}) + +test('test-environment full acceptance keeps the DRAFT to RELEASED booking chain', () => { + const loadingIndex = adminAcceptance.indexOf("'phase3-container-loading'") + const bookingIndex = adminAcceptance.indexOf("'ocean-bookings'") + assert.ok(loadingIndex >= 0 && bookingIndex > loadingIndex, + 'ocean-bookings must run after the assigned-customer container fixture') + + const bookingModule = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'ocean-bookings.mjs'), 'utf8') + assert.match(bookingModule, /创建订舱 DRAFT(不生成真实柜号\/货柜分配)/) + assert.match(bookingModule, /containerRequirements/) + assert.match(bookingModule, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/) + assert.match(bookingModule, /RELEASED 编辑拒绝且零写入/) + assert.doesNotMatch(bookingModule, /realContainerBookingId|CHANGE_PENDING|CANCELLED/) +}) + +test('test-environment loading chains create the bridge before opening PDA after all prerequisites', () => { + const containerLoading = readFileSync( + resolve(scriptsDir, 'admin-test-env-modules', 'phase3-container-loading.mjs'), 'utf8') + const bridgeLoading = readFileSync( + resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8') + + assert.match(containerLoading, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/) + assert.doesNotMatch(containerLoading, /realContainerBookingId/) + + const pushIndex = bridgeLoading.indexOf('分配后自动建立预装关联且柜次计划可读') + const absentIndex = bridgeLoading.indexOf('无号 push 成功后 PDA 查询明确 A0404') + const releaseIndex = bridgeLoading.indexOf('三期订舱 RELEASED 冻结运输快照') + const linkIndex = bridgeLoading.indexOf('模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号') + const confirmIndex = bridgeLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务') + const pdaIndex = bridgeLoading.indexOf('读取柜次级 PDA 唯一事实任务') + assert.ok(pushIndex >= 0 && absentIndex > pushIndex + && releaseIndex > absentIndex && linkIndex > releaseIndex && confirmIndex > linkIndex && pdaIndex > confirmIndex) + assert.match(bridgeLoading, /codeOf\(missingContainerTaskResponse\) === 'A0404'/) + assert.match(bridgeLoading, /分配后自动建立预装关联且柜次计划可读/) + assert.match(bridgeLoading, /bookingConfirmed\?\.oceanBooking\?\.status === 'RELEASED'/) + assert.match(bridgeLoading, /loading-plans\/\$\{loadingPlanId\}\/confirm/) + assert.match(bridgeLoading, /readiness\?\.readyForPda === true/) + assert.match(bridgeLoading, /计划级确认同 key 异参返回冲突[\s\S]*已确认计划使用新 key 重复确认返回冲突/) + assert.doesNotMatch(bridgeLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) + + const containerPushIndex = containerLoading.indexOf('分配后自动建立预装关联且计划可读') + const containerReleaseIndex = containerLoading.indexOf('订舱放舱为 RELEASED 且不预生成真实货柜') + const containerLinkIndex = containerLoading.indexOf('首次关联 RELEASED 订舱需求并补真实柜号') + const containerConfirmIndex = containerLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务') + const containerPdaIndex = containerLoading.indexOf('查询本轮柜次 PDA 拣货任务') + assert.ok(containerPushIndex >= 0 && containerReleaseIndex > containerPushIndex + && containerLinkIndex > containerReleaseIndex + && containerConfirmIndex > containerLinkIndex + && containerPdaIndex > containerConfirmIndex) + assert.match(containerLoading, /loading-plans\/\$\{planId\}\/confirm/) + assert.match(containerLoading, /readiness\?\.readyForPda === true/) + assert.match(containerLoading, /计划级确认同 key 异参返回冲突[\s\S]*已确认计划使用新 key 重复确认返回冲突/) + assert.doesNotMatch(containerLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) +}) + +test('default loading chain carries the three loading-list remarks contract', () => { + const loading = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8') + assert.match(adminAcceptance, /CONFIGURED_MODULES\s*=\s*\[[^\]]*'phase3-loading'/s) + assert.match(loading, /装柜明细客户备注与最新内部备注来源精确且不冒用货物备注/) + assert.match(loading, /warehouse-remark/) + assert.match(loading, /仓库备注相同 operationId 与原请求精确重放/) + assert.match(loading, /仓库备注旧版本冲突后版本与备注事实零漂移/) + assert.match(loading, /preservedRemarkItem\?\.warehouseRemark === warehouseRemark/) +}) + +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', + 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-customs', + 'phase3-loading-finalize', + ]) + assert.deepEqual(focused, [ + 'merchant-qualification-fixture', 'orders', 'inbound-measurement', + 'phase3-container-loading', 'phase3-loading', 'phase3-container-overview', + 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize', + 'phase3-customs', + ]) + + const loading = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8') + const evidenceGate = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-evidence-gate.mjs'), 'utf8') + const convergence = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-pda-convergence.mjs'), 'utf8') + const customs = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-customs.mjs'), 'utf8') + const finalize = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading-finalize.mjs'), 'utf8') + + assert.match(loading, /context\.createdResources\.phase3Loading = \{[\s\S]*marker: context\.marker/) + for (const field of ['inboundId', 'loadingPlanId', 'containerBookingId', 'canonicalPdaTaskId']) { + assert.match(loading, new RegExp(`context\\.createdResources\\.phase3Loading = \\{[\\s\\S]*${field}`)) + } + assert.match(loading, /const evidenceTypes = \['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'\]/) + assert.match(evidenceGate, /fixture\??\.marker !== context\.marker/) + assert.match(evidenceGate, /taskId[\s\S]*inboundId[\s\S]*loadingPlanId[\s\S]*containerBookingId/) + assert.match(convergence, /pending 与 detail 使用同一 workflowStatus 和未完成数量事实/) + assert.match(convergence, /AWAITING_REVIEW 已全拣任务 actions 为空且不再提交 batch/) + assert.match(convergence, /AWAITING_REVIEW 可进入装柜取证只读入口/) + assert.match(customs, /createdResources\?\.phase3Loading[\s\S]*container\?\.finalStatus/) + assert.match(finalize, /fixture\?\.marker !== context\.marker/) + assert.doesNotMatch(finalize, /BLOCKED_CONTRACT/) + assert.ok(finalize.indexOf('const taskPath =') + < finalize.indexOf('结算递增 inboundVersion 后 final complete 必须使用最新版本')) + assert.match(finalize, /结算或版本冲突失败后 task\/plan\/inbound\/operation 全部零写入/) + 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), [ + 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', + ]) + assert.deepEqual(focused.slice(-5), [ + 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', + 'finance-direct-paid-isolation', 'finance-downloads', + ]) +}) diff --git a/scripts/gateway-route-contract.test.mjs b/scripts/gateway-route-contract.test.mjs new file mode 100644 index 000000000..585001bfb --- /dev/null +++ b/scripts/gateway-route-contract.test.mjs @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict' +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const read = path => readFileSync(resolve(root, path), 'utf8') + +// Deliberately limited to the repository's explicit short-form Path routes. +// Unsupported route syntax fails instead of silently skipping coverage. +function routes(source) { + return [...source.matchAll(/^\s*- id:\s*([^\r\n]+)([\s\S]*?)(?=^\s*- id:|^\S|$(?![\s\S]))/gm)].map(([, id, body]) => { + const uri = body.match(/uri:\s*lb:\/\/([\w-]+)/)?.[1] + const paths = body.match(/Path=([^\r\n"\]]+)/)?.[1]?.trim().split(',') + assert.ok(uri && paths?.length, `Unsupported route: ${id}`) + assert.ok(!/\b(?:Method|Host|RewritePath|SetPath|PrefixPath)=/.test(body), `Extend checker for conditional/rewrite route: ${id}`) + assert.ok(!/\border\s*:/.test(body), `Extend checker for explicit route order: ${id}`) + return { id: id.trim(), uri, paths } + }) +} + +function matches(pattern, path) { + const parts = pattern.split('/') + const actual = path.split('/') + for (let i = 0; i < parts.length; i++) { + if (parts[i] === '**') return true + if (actual[i] === undefined) return false + if (parts[i] === '*' || /^\{[^}]+\}$/.test(parts[i])) continue + if (parts[i] !== actual[i]) return false + } + return parts.length === actual.length +} + +function javaFiles(dir) { + if (!existsSync(dir)) return [] + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const path = resolve(dir, entry.name) + return entry.isDirectory() ? javaFiles(path) : entry.name.endsWith('.java') ? [path] : [] + }) +} + +function controllerPaths(source, file) { + const clean = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') + if (!/@(?:RestController|Controller)\b/.test(clean)) return [] + const header = clean.split(/\bclass\s+/)[0] + const mapping = header.match(/@RequestMapping\s*\(([^)]*)\)/) + const extract = args => { + for (const [, name, value] of clean.matchAll(/static\s+final\s+String\s+(\w+)\s*=\s*"([^"\r\n]*)"\s*;/g)) { + args = args.replace(new RegExp(`\\b${name}\\b`, 'g'), `"${value}"`) + } + while (/"[^"\r\n]*"\s*\+\s*"[^"\r\n]*"/.test(args)) { + args = args.replace(/"([^"\r\n]*)"\s*\+\s*"([^"\r\n]*)"/g, '"$1$2"') + } + const paths = [...args.matchAll(/"(\/[^"\r\n]*)"/g)].map(m => m[1]) + assert.ok(paths.length || !args.trim() || /^(?:produces|consumes|method)\s*=/.test(args.trim()), `Non-literal mapping requires checker support: ${file}`) + assert.ok(!args.replace(/"[^"\r\n]*"/g, '').includes('+'), `Concatenated mapping requires checker support: ${file}`) + return paths.length ? paths : [''] + } + const bases = mapping ? extract(mapping[1]) : [''] + const body = clean.slice(header.length) + const methods = [...body.matchAll(/@(?:Get|Post|Put|Delete|Patch|Request)Mapping\b(?:\s*\(([^)]*)\))?/g)] + if (!methods.length) return bases + return methods.flatMap(([, args = '']) => extract(args).flatMap(path => bases.map(base => `${base}${path}`))) +} + +function verify(path, service, table, file) { + if (path.includes('{app}')) { + for (const app of ['platform', 'merchant']) verify(path.replace('{app}', app), service, table, file) + return + } + // Internal service-token endpoints must never be forwarded, regardless of owner. + const hit = table.find(route => route.paths.some(pattern => matches(pattern, path))) + if (path.startsWith('/internal/')) { + assert.equal(hit, undefined, `Internal endpoint exposed: ${file} ${path}`) + } else if (path.startsWith('/api/') || path.startsWith('/openapi/')) { + assert.ok(hit, `Missing Gateway route: ${file} ${path}`) + assert.equal(hit.uri, service, `Wrong destination: ${file} ${path} via ${hit.id}`) + } +} + +const domestic = routes(read('gateway/src/main/resources/application.yml')) +const overseas = routes(read('gateway-overseas/src/main/resources/application-overseas.yml')) +const modules = [...read('pom.xml').matchAll(/([^<]+)<\/module>/g)].map(m => m[1]) + +test('local regression and root CI require route checks before Maven/dispatch', () => { + const local = read('scripts/backend-full-regression.ps1') + assert.ok(local.indexOf('& node --test') < local.indexOf('& $MavenCommand')) + assert.match(local, /gateway-route-contract\.test\.mjs/) + const pipeline = read('Jenkinsfile') + const gate = pipeline.slice(pipeline.indexOf("stage('Gateway 路由静态门禁')"), pipeline.indexOf("stage('统一 Maven 测试门禁')")) + assert.match(gate, /node --test scripts\/gateway-route-contract\.test\.mjs/) + assert.doesNotMatch(gate, /SKIP_TESTS|catchError|returnStatus|\|\| true/) +}) + +test('Controller prefixes route to their owning service; internal prefixes stay private', () => { + let count = 0 + for (const module of modules) { + if (module.startsWith('common-') || module.startsWith('gateway') || module === 'platform-bom') continue + const app = `${module}/src/main/resources/application.yml` + if (!existsSync(resolve(root, app))) continue + const service = read(app).match(/application:\s*\r?\n\s+name:\s*([\w-]+)/)?.[1] + assert.ok(service, `Missing application name: ${module}`) + const tables = ['lastleg-service', 'warehouse-overseas-service'].includes(module) + ? [overseas] : ['file', 'print'].includes(module) ? [domestic, overseas] : [domestic] + for (const file of javaFiles(resolve(root, module, 'src/main/java'))) { + for (const path of controllerPaths(readFileSync(file, 'utf8'), file)) { + for (const table of tables) verify(path, service, table, file) + count++ + } + } + } + assert.ok(count > 100, `Unexpectedly small Controller inventory: ${count}`) +}) + +test('removing container shipping nodes route reproduces the missing registration', () => { + const broken = domestic.map(route => ({ ...route, paths: route.paths.filter(p => p !== '/api/platform/v1/container-shipping-nodes/**') })) + assert.throws(() => verify('/api/platform/v1/container-shipping-nodes', 'firstleg-service', broken, 'fixture'), /Missing Gateway route/) +}) + +test('wrong target and internal exposure are rejected', () => { + assert.throws(() => verify('/api/new', 'firstleg-service', [{ id: 'wrong', uri: 'order-service', paths: ['/api/**'] }], 'fixture'), /Wrong destination/) + assert.throws(() => verify('/internal/private', 'firstleg-service', [{ id: 'broad', uri: 'firstleg-service', paths: ['/**'] }], 'fixture'), /Internal endpoint exposed/) +}) + +test('path variables, exact paths and segment boundaries are respected', () => { + assert.ok(matches('/api/items/{id}/**', '/api/items/123/action')) + assert.ok(matches('/api/items/**', '/api/items')) + assert.ok(!matches('/api/items/**', '/api/items-other')) + assert.ok(!matches('/api/items', '/api/items/action')) + assert.deepEqual(controllerPaths('@RestController @RequestMapping(value = {"/api/a", "/api/b"}) public class Example {}', 'fixture'), ['/api/a', '/api/b']) + assert.throws(() => controllerPaths('@RestController @RequestMapping(API_PATH) public class Example {}', 'fixture'), /Non-literal/) + assert.deepEqual(controllerPaths('@RestController @RequestMapping("/api") public class Example { @GetMapping("/items") void list() {} }', 'fixture'), ['/api/items']) + assert.deepEqual(controllerPaths('@RestController public class Example { static final String PATH = "/api/items"; @GetMapping(PATH) void list() {} }', 'fixture'), ['/api/items']) + assert.throws(() => verify('/api/item', 'firstleg-service', [{ id: 'shadow', uri: 'order-service', paths: ['/api/**'] }, { id: 'correct', uri: 'firstleg-service', paths: ['/api/item'] }], 'fixture'), /Wrong destination/) +}) 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/http-script-environment-contract.test.mjs b/scripts/http-script-environment-contract.test.mjs new file mode 100644 index 000000000..2e534e6ad --- /dev/null +++ b/scripts/http-script-environment-contract.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8') +const client = read('./client-test-env-acceptance.mjs') +const clientPhase2 = read('./client-phase2-test-env-acceptance.mjs') +const pendingOrders = read('./create-pending-review-orders.mjs') +const readme = read('./README.md') +const agents = read('./AGENTS.md') + +const activeHttpScripts = [client, clientPhase2, pendingOrders] +const removedEnvironmentVariables = [ + 'CLIENT_TEST_GATEWAY', 'CLIENT_PHASE2_TEST_GATEWAY', + 'PLATFORM_USERNAME', 'PLATFORM_PASSWORD', + 'EXISTING_CLIENT_USERNAME', 'EXISTING_CLIENT_PASSWORD', + 'CLIENT_PHASE2_USERNAME', 'CLIENT_PHASE2_PASSWORD', +] + +test('active Client and data-generation scripts share the canonical environment variables', () => { + for (const source of activeHttpScripts) { + assert.match(source, /process\.env\.ADMIN_TEST_GATEWAY/) + assert.match(source, /process\.env\.MERCHANT_TEST_USERNAME/) + assert.match(source, /process\.env\.MERCHANT_TEST_PASSWORD/) + } + assert.match(client, /process\.env\.ADMIN_TEST_USERNAME/) + assert.match(client, /process\.env\.ADMIN_TEST_PASSWORD/) + assert.match(clientPhase2, /process\.env\.ADMIN_TEST_USERNAME/) + assert.match(clientPhase2, /process\.env\.ADMIN_TEST_PASSWORD/) +}) + +test('active scripts no longer carry the retired variables, fixed Gateway, or source password config', () => { + for (const source of activeHttpScripts) { + for (const variable of removedEnvironmentVariables) assert.doesNotMatch(source, new RegExp(`process\\.env\\.${variable}`)) + assert.doesNotMatch(source, /8\.163\.30\.128/) + } + assert.doesNotMatch(pendingOrders, /const CONFIG\s*=/) + assert.doesNotMatch(pendingOrders, /PASSWORD:\s*'[^']+'/) +}) + +test('documentation names both external environment files and requires explicit selection', () => { + for (const source of [readme, agents]) { + assert.match(source, /chongchong-global-dev-acceptance\.env/) + assert.match(source, /chongchong-global-test-acceptance\.env/) + assert.match(source, /--env-file/) + } + assert.match(readme, /不会自行查找或加载上述文件/) + assert.match(readme, /ADMIN_OVERSEAS_GATEWAY/) +}) diff --git a/scripts/inbound-fee-unified-sql-contract.test.mjs b/scripts/inbound-fee-unified-sql-contract.test.mjs new file mode 100644 index 000000000..eb1f1971c --- /dev/null +++ b/scripts/inbound-fee-unified-sql-contract.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { readInitialization, table } from './sql-baseline-contract-helpers.mjs' + +const firstleg = readFileSync(new URL('../firstleg/src/main/resources/db/phase2-inbound-schema.sql', import.meta.url), 'utf8') +const fee = table(readInitialization(), 'baiyue_firstleg', 'fl_inbound_fee') + +test('统一初始化与模块入库费用均直接定义计费基准和容差', () => { + for (const source of [fee, firstleg]) { + assert.match(source, /baseline_chargeable_quantity DECIMAL\(18,6\) NOT NULL/i) + assert.match(source, /tolerance_percent DECIMAL\(5,2\) NOT NULL DEFAULT '?10\.00'?/i) + } + assert.doesNotMatch(readInitialization(), /SET baseline_chargeable_quantity = chargeable_quantity|MODIFY COLUMN baseline_chargeable_quantity/) +}) + +test('最终建表保留入库费用唯一身份和基准非空约束', () => { + assert.match(fee, /PRIMARY KEY \(id\)/) + assert.match(fee, /UNIQUE KEY/) + assert.match(fee, /baseline_chargeable_quantity decimal\(18,6\) NOT NULL/i) + assert.match(fee, /tolerance_percent decimal\(5,2\) NOT NULL/i) +}) diff --git a/scripts/inbound-hold-unified-sql-contract.test.mjs b/scripts/inbound-hold-unified-sql-contract.test.mjs new file mode 100644 index 000000000..47a0b0845 --- /dev/null +++ b/scripts/inbound-hold-unified-sql-contract.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { readInitialization, table, seedRows, roleAuthorities } from './sql-baseline-contract-helpers.mjs' + +const unified = readInitialization() +const firstleg = readFileSync(new URL('../firstleg/src/main/resources/db/phase2-inbound-schema.sql', import.meta.url), 'utf8') +const iam = readFileSync(new URL('../iam/src/main/resources/db/inbound-hold-permission.sql', import.meta.url), 'utf8') + +test('统一初始化与模块扣货快照和不可变历史结构一致', () => { + const order = table(unified, 'baiyue_firstleg', 'fl_inbound_order') + const history = table(unified, 'baiyue_firstleg', 'fl_inbound_hold_record') + for (const source of [order + history, firstleg]) { + assert.match(source, /current_hold_reason VARCHAR\(1000\)[^\n]*COMMENT '当前扣货理由快照'/i) + assert.match(source, /hold_action VARCHAR\(16\)[^\n]*NOT NULL/i) + assert.match(source, /reason VARCHAR\(1000\)[^\n]*NOT NULL/i) + assert.match(source, /evidence_file_id BIGINT UNSIGNED (?:DEFAULT )?NULL/i) + assert.match(source, /KEY idx_hold_inbound_time \(tenant_id,\s*inbound_id,\s*operated_at\)/i) + } + assert.doesNotMatch(unified, /MODIFY COLUMN current_hold_reason/) +}) + +test('扣货权限保持独立且仅默认授予平台管理员', () => { + assert.match(iam, /\(5102032, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:hold'/) + const permission = seedRows(unified, 'iam_permission').find(r => r.permission_code === 'platform:firstleg:inbound:hold') + assert.equal(permission.id, '5102032') + assert.equal(permission.parent_id, '5102030') + assert.equal(permission.permission_type, 'BUTTON') + assert.deepEqual(seedRows(unified, 'iam_role_permission').filter(r => r.permission_id === permission.id).map(r => r.role_id), ['4000001']) + assert.ok(roleAuthorities(unified, 'PLATFORM_ADMIN').has(permission.permission_code)) + for (const role of ['ORDER_OPERATOR', 'WAREHOUSE_OPERATOR', 'WAREHOUSE_TALLY']) { + assert.ok(!roleAuthorities(unified, role).has(permission.permission_code), role) + } +}) diff --git a/scripts/it-test-wait-split-bulk.mjs b/scripts/it-test-wait-split-bulk.mjs new file mode 100644 index 000000000..b8b9f3328 --- /dev/null +++ b/scripts/it-test-wait-split-bulk.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +/** + * 为已授权的非生产环境尽力生成 IT测试/义乌仓 WAIT_SPLIT 数据。 + * + * 每票随机 1-300 箱(可通过参数缩小),失败会记录后继续;不会自动清理已写入数据。 + * 测量前置只复用 TP-001/YW-TP-002,不创建托盘;库位由正式仓储接口分配, + * 普通位不足时由服务端落公共位 A0,脚本不创建库位。 + * 账号、密码和 Gateway 只从 admin-test-env-acceptance.mjs 支持的环境变量读取。 + */ +import { randomInt } from 'node:crypto' +import { appendFile, mkdir, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import process from 'node:process' + +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' + +function argumentsMap(argv) { + const values = new Map() + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + if (!argument.startsWith('--')) throw new Error(`无法识别的参数: ${argument}`) + const name = argument.slice(2) + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error(`参数 --${name} 缺少值`) + values.set(name, value) + index += 1 + } + return values +} + +function positiveInteger(value, fallback, name) { + const parsed = Number(value ?? fallback) + if (!Number.isInteger(parsed) || parsed < 1) throw new Error(`${name}必须是正整数`) + return parsed +} + +function increment(record, key) { + const normalized = String(key || 'UNKNOWN') + record[normalized] = Number(record[normalized] || 0) + 1 +} + +async function main() { + const values = argumentsMap(process.argv.slice(2)) + const count = positiveInteger(values.get('count'), 8000, 'count') + const batchSize = positiveInteger(values.get('batch-size'), 100, 'batch-size') + const startIndex = positiveInteger(values.get('start-index'), 1, 'start-index') + const boxMin = positiveInteger(values.get('box-min'), 1, 'box-min') + const boxMax = positiveInteger(values.get('box-max'), 300, 'box-max') + if (boxMax < boxMin || boxMax > 300) throw new Error('箱数范围必须满足1 <= box-min <= box-max <= 300') + const customerName = values.get('customer-name') || 'IT测试' + const originWarehouseName = values.get('origin-warehouse-name') || '义乌仓' + const workerId = values.get('worker-id') || 'worker-1' + const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14) + const outputDirectory = resolve(values.get('output-dir') || `reports/it-test-wait-split-8000-${timestamp}`) + const journalPath = resolve(outputDirectory, 'results.jsonl') + const summaryPath = resolve(outputDirectory, 'summary.json') + const failureDirectory = resolve(outputDirectory, 'failures') + await mkdir(failureDirectory, { recursive: true }) + + const summary = { + workerId, customerName, originWarehouseName, requested: count, startIndex, batchSize, + boxMin, boxMax, attempted: 0, succeeded: 0, failed: 0, totalPackages: 0, totalPallets: 0, + minPackages: null, maxPackages: null, channels: {}, destinationTypes: {}, + processId: process.pid, startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), outputDirectory, + } + await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8') + console.log(`OUTPUT_DIR=${outputDirectory}`) + + for (let offset = 0; offset < count; offset += 1) { + const sequence = startIndex + offset + const packageCount = randomInt(boxMin, boxMax + 1) + const randomValue = randomInt(1, 2_147_483_647) + const batchNo = Math.floor(offset / batchSize) + 1 + const planned = { event: 'planned', sequence, batchNo, packageCount, randomValue, + plannedAt: new Date().toISOString() } + await appendFile(journalPath, `${JSON.stringify(planned)}\n`, 'utf8') + + let report = null + let thrown = null + try { + const options = validateExecutionOptions(parseArguments([ + '--modules', 'orders,inbound-measurement', + '--customer-name', customerName, + '--origin-warehouse-name', originWarehouseName, + '--order-profile-mode', 'randomized', + '--route-mode', 'random', + '--wait-split-count', '1', + '--wait-split-package-count', String(packageCount), + '--wait-split-sequence', String(sequence), + '--wait-split-random-value', String(randomValue), + '--quiet', 'true', + ])) + options.markerNamespace = workerId + report = await new AcceptanceRun(options).run() + } catch (error) { + thrown = error instanceof Error ? error.message : String(error) + } + + const fixture = report?.createdResources?.waitSplitFixtures?.find?.( + (item) => Number(item.sequence) === sequence) + const completed = Boolean(fixture?.processStatus === 'WAIT_SPLIT' + && fixture?.reviewStatus === 'APPROVED' + && Number(fixture?.totalPackageCount) === packageCount + && Number(fixture?.palletCount) === Math.ceil(packageCount / 30) + && fixture?.receivingWarehouseName === originWarehouseName) + const result = { + event: 'result', sequence, batchNo, packageCount, completed, + orderId: fixture?.orderId || null, orderNo: fixture?.orderNo || null, + inboundId: fixture?.inboundId || null, inboundCode: fixture?.inboundCode || null, + palletCount: fixture?.palletCount || null, + channelCode: fixture?.channelCode || null, channelName: fixture?.channelName || null, + destinationType: fixture?.destinationType || null, + destinationWarehouseCode: fixture?.destinationWarehouseCode || null, + reportSuccess: report?.success ?? false, assertionFailures: report?.summary?.failed ?? null, + deploymentBlockers: report?.deploymentBlockers || [], error: thrown, + finishedAt: new Date().toISOString(), + } + await appendFile(journalPath, `${JSON.stringify(result)}\n`, 'utf8') + summary.attempted += 1 + if (completed) { + summary.succeeded += 1 + summary.totalPackages += packageCount + summary.totalPallets += Number(fixture.palletCount) + summary.minPackages = summary.minPackages === null ? packageCount : Math.min(summary.minPackages, packageCount) + summary.maxPackages = summary.maxPackages === null ? packageCount : Math.max(summary.maxPackages, packageCount) + increment(summary.channels, fixture.channelCode) + increment(summary.destinationTypes, fixture.destinationType) + } else { + summary.failed += 1 + if (report) { + await writeFile(resolve(failureDirectory, `sequence-${String(sequence).padStart(5, '0')}.json`), + `${JSON.stringify(report, null, 2)}\n`, 'utf8') + } + } + summary.updatedAt = new Date().toISOString() + summary.averagePackages = summary.succeeded > 0 + ? Number((summary.totalPackages / summary.succeeded).toFixed(3)) : null + summary.averagePallets = summary.succeeded > 0 + ? Number((summary.totalPallets / summary.succeeded).toFixed(3)) : null + if (summary.attempted % batchSize === 0 || !completed || summary.attempted === count) { + await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8') + console.log(`PROGRESS attempted=${summary.attempted}/${count} succeeded=${summary.succeeded} failed=${summary.failed} batch=${batchNo}`) + } + } + + summary.finishedAt = new Date().toISOString() + await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8') + console.log(`RESULT attempted=${summary.attempted} succeeded=${summary.succeeded} failed=${summary.failed}`) +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json new file mode 100644 index 000000000..2e8d955f3 --- /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": 49, + "coveredServices": 10, + "codeFirstServices": 5 + }, + "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..841efff6e --- /dev/null +++ b/scripts/java-quality-summary.test.mjs @@ -0,0 +1,92 @@ +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, 49) + assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/loading-evidence-v1.yaml')) + assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/overseas-relabel-document-v1.yaml')) + assert.ok(summary.openapi.files.includes('finance/src/main/resources/openapi/finance-container-settlement-v1.yaml')) + assert.ok(summary.openapi.files.includes('warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml')) + assert.equal(baseline.openapi.documents, summary.openapi.documents) + assert.equal(summary.openapi.coveredServices.length, 10) + assert.equal(summary.openapi.codeFirstServices.length, 5) + 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 new file mode 100644 index 000000000..dbb65e998 --- /dev/null +++ b/scripts/local-db-full-acceptance.ps1 @@ -0,0 +1,129 @@ +param( + [switch]$DryRun, + [Alias('Host')][string]$TargetHost = '127.0.0.1', + [int]$Port = 3306, + [string]$MysqlExe = 'C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe', + [string]$User = 'root', + [ValidateRange(1, 10)][int]$Rounds = 2 +) + +$ErrorActionPreference = 'Stop' +$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$Workspace = (Resolve-Path (Join-Path $Repo '..')).Path +$ReportRoot = Join-Path $Workspace '.codex-tmp\local-db-full-acceptance' +$DomesticSql = Join-Path $Repo 'deploy\sql\头程数据库统一增量-MySQL8.sql' +$OverseasSql = Join-Path $Repo 'deploy\sql\海外区域统一增量-MySQL8.sql' +$Schemas = @( + 'baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_warehouse', + 'baiyue_firstleg', 'baiyue_file', 'baiyue_audit', 'baiyue_event_bridge', + 'baiyue_finance', 'baiyue_lastleg', 'baiyue_lastlag_warehouse', + 'baiyue_event_bridge_overseas' +) + +if ($TargetHost -notin @('127.0.0.1', 'localhost')) { + [Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.') + exit 2 +} +if (-not (Test-Path -LiteralPath $MysqlExe)) { + [Console]::Error.WriteLine("mysql executable does not exist: $MysqlExe") + exit 2 +} +foreach ($sqlFile in @($DomesticSql, $OverseasSql)) { + if (-not (Test-Path -LiteralPath $sqlFile)) { + [Console]::Error.WriteLine("Unified SQL does not exist: $sqlFile") + exit 2 + } +} + +$DomesticText = [IO.File]::ReadAllText($DomesticSql) +$OverseasText = [IO.File]::ReadAllText($OverseasSql) +if (-not $DomesticText.Contains("SELECT 'PASS' AS migration_status")) { + [Console]::Error.WriteLine('Domestic unified SQL is missing its final PASS statement.') + exit 2 +} +foreach ($marker in @( + '-- BEGIN INLINE EMPTY-DATABASE BASELINE: LASTLEG', + '-- BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE', + "SELECT 'PASS' AS migration_status" +)) { + if (-not $OverseasText.Contains($marker)) { + [Console]::Error.WriteLine("Overseas unified SQL is missing marker: $marker") + exit 2 + } +} + +$DomesticHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $DomesticSql).Hash +$OverseasHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $OverseasSql).Hash +Write-Output "Target: $TargetHost`:$Port" +Write-Output "Rounds: $Rounds" +Write-Output "Domestic SQL: $DomesticHash / $([IO.File]::ReadAllLines($DomesticSql).Length) lines" +Write-Output "Overseas SQL: $OverseasHash / $([IO.File]::ReadAllLines($OverseasSql).Length) lines" +Write-Output "Reset scope: $($Schemas -join ', ')" +Write-Output 'Execution order per round: clear exact schemas -> domestic unified SQL -> overseas unified SQL -> final PASS and schema checks' +if ($DryRun) { + Write-Output 'DRY-RUN PASS (mysql not contacted; no database writes)' + exit 0 +} + +New-Item -ItemType Directory -Path $ReportRoot -Force | Out-Null +$StagedDomestic = Join-Path $ReportRoot 'domestic.sql' +$StagedOverseas = Join-Path $ReportRoot 'overseas.sql' +Copy-Item -LiteralPath $DomesticSql -Destination $StagedDomestic -Force +Copy-Item -LiteralPath $OverseasSql -Destination $StagedOverseas -Force +if ((Get-FileHash -Algorithm SHA256 -LiteralPath $StagedDomestic).Hash -ne $DomesticHash -or + (Get-FileHash -Algorithm SHA256 -LiteralPath $StagedOverseas).Hash -ne $OverseasHash) { + [Console]::Error.WriteLine('Failed to stage byte-identical SQL files under ASCII filenames.') + exit 2 +} + +$MysqlCommon = @( + '--protocol=TCP', "--host=$TargetHost", "--port=$Port", "--user=$User", + '--default-character-set=utf8mb4', '--batch' +) +& $MysqlExe @MysqlCommon '--skip-column-names' '--execute=SELECT VERSION(), @@hostname, @@port;' | Out-Null +if ($LASTEXITCODE -ne 0) { + [Console]::Error.WriteLine('Cannot connect to the local MySQL target. Configure authentication outside this script, for example with MYSQL_PWD for the current process.') + exit $LASTEXITCODE +} + +$DropSql = ($Schemas | ForEach-Object { "DROP DATABASE IF EXISTS ``$_```;" }) -join ' ' +for ($round = 1; $round -le $Rounds; $round++) { + $RoundRoot = Join-Path $ReportRoot ("round-{0}" -f $round) + New-Item -ItemType Directory -Path $RoundRoot -Force | Out-Null + $Stopwatch = [Diagnostics.Stopwatch]::StartNew() + + & $MysqlExe @MysqlCommon "--execute=$DropSql" *> (Join-Path $RoundRoot '00-clear.log') + if ($LASTEXITCODE -ne 0) { + [Console]::Error.WriteLine("Round $round failed while clearing the exact project schemas.") + exit $LASTEXITCODE + } + + $DomesticLog = Join-Path $RoundRoot '10-domestic.log' + $DomesticSource = $StagedDomestic.Replace('\', '/') + & $MysqlExe @MysqlCommon '--database=mysql' "--execute=source $DomesticSource" *> $DomesticLog + if ($LASTEXITCODE -ne 0 -or -not (Select-String -LiteralPath $DomesticLog -Pattern '^PASS\s+统一增量全部执行完成' -Quiet)) { + [Console]::Error.WriteLine("Round $round domestic unified SQL failed. See $DomesticLog") + exit $(if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 2 }) + } + + $OverseasLog = Join-Path $RoundRoot '20-overseas.log' + $OverseasSource = $StagedOverseas.Replace('\', '/') + & $MysqlExe @MysqlCommon '--database=mysql' "--execute=source $OverseasSource" *> $OverseasLog + if ($LASTEXITCODE -ne 0 -or -not (Select-String -LiteralPath $OverseasLog -Pattern '^PASS\s+海外财务P0费用来源字段已就绪$' -Quiet)) { + [Console]::Error.WriteLine("Round $round overseas unified SQL failed. See $OverseasLog") + exit $(if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 2 }) + } + + $SchemaList = "'" + ($Schemas -join "','") + "'" + $SchemaCount = (& $MysqlExe @MysqlCommon '--skip-column-names' "--execute=SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name IN ($SchemaList);").Trim() + if ($LASTEXITCODE -ne 0 -or [int]$SchemaCount -ne $Schemas.Count) { + [Console]::Error.WriteLine("Round $round schema verification failed: $SchemaCount/$($Schemas.Count)") + exit 2 + } + + $Stopwatch.Stop() + Write-Output "ROUND $round PASS / $SchemaCount schemas / $([Math]::Round($Stopwatch.Elapsed.TotalSeconds, 1)) seconds" +} + +Write-Output 'FULL LOCAL TWO-REGION DATABASE ACCEPTANCE PASS' +exit 0 diff --git a/scripts/local-env.ps1 b/scripts/local-env.ps1 index d14fb33f5..0722fa530 100644 --- a/scripts/local-env.ps1 +++ b/scripts/local-env.ps1 @@ -1,6 +1,7 @@ $env:NACOS_SERVER_ADDR = '127.0.0.1:8848' $env:NACOS_NAMESPACE = 'public' $env:NACOS_GROUP = 'BAIYUE' +$env:SPRING_CLOUD_NACOS_CONFIG_ENCODE = 'UTF-8' $env:REDIS_HOST = '127.0.0.1' $env:REDIS_PORT = '6379' $env:SPRING_DATA_REDIS_HOST = '127.0.0.1' @@ -11,11 +12,11 @@ $env:ORDER_DB_PASSWORD = '123456' $env:FIRSTLEG_DB_PASSWORD = '123456' $env:WAREHOUSE_DB_PASSWORD = '123456' $env:FILE_DB_PASSWORD = '123456' -$env:PRINT_DB_PASSWORD = '123456' $env:IAM_SERVICE_URL = 'http://127.0.0.1:9201' +$env:WAREHOUSE_SERVICE_URL = 'http://127.0.0.1:9205' $env:JWT_SECRET = 'phase1-local-test-jwt-secret-at-least-32-bytes' $env:INTERNAL_SERVICE_TOKEN = 'phase1-local-internal-token-at-least-32-bytes' $env:SWAGGER_ENABLED = 'true' $env:CORS_ALLOWED_ORIGINS = 'http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174,http://192.168.110.149:5173,http://192.168.110.149:5174' $env:FILE_STORAGE_ROOT = 'D:/baiyue-firstleg-runtime/uploads' -Write-Host '头程本地环境已加载:Nacos 8848、Redis 6379、MySQL 密码、JWT、Swagger 和本地跨域白名单' +Write-Host '头程本地环境已加载:Nacos 8848、Redis 6379、有数据库服务的 MySQL 密码、JWT、Swagger 和本地跨域白名单' diff --git a/scripts/mapper-scan-contract.mjs b/scripts/mapper-scan-contract.mjs new file mode 100644 index 000000000..e90d33d08 --- /dev/null +++ b/scripts/mapper-scan-contract.mjs @@ -0,0 +1,122 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +export const repositoryRoot = resolve(scriptDir, '..') + +function filesBelow(root, predicate) { + if (!statSafe(root)?.isDirectory()) return [] + const result = [] + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (entry.name === 'target' || entry.name === '.git') continue + const path = join(root, entry.name) + if (entry.isDirectory()) result.push(...filesBelow(path, predicate)) + else if (predicate(path)) result.push(path) + } + return result +} + +function statSafe(path) { + try { return statSync(path) } catch { return null } +} + +function packageName(source) { + return source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1] || '' +} + +function mapperInterfaces(javaFiles) { + return javaFiles.flatMap((path) => { + const source = readFileSync(path, 'utf8') + const names = [...source.matchAll(/\b(?:public\s+)?interface\s+(\w*Mapper)\b/g)].map((match) => match[1]) + return names.map((name) => ({ path, source, packageName: packageName(source), name })) + }) +} + +function mapperScanAnnotation(source) { + const start = source.indexOf('@MapperScan') + if (start < 0) return '' + const end = source.indexOf('\n', start) + if (source[start + '@MapperScan'.length] !== '(') return source.slice(start, end < 0 ? undefined : end) + let depth = 0 + for (let index = start + '@MapperScan'.length; index < source.length; index += 1) { + if (source[index] === '(') depth += 1 + else if (source[index] === ')' && --depth === 0) return source.slice(start, index + 1) + } + return source.slice(start) +} + +function moduleNames(root) { + const pom = readFileSync(join(root, 'pom.xml'), 'utf8') + return [...pom.matchAll(/([^<]+)<\/module>/g)].map((match) => match[1].trim()) +} + +export function auditMapperScanning(root = repositoryRoot) { + const failures = [] + let mapperCount = 0 + let persistenceModuleCount = 0 + + for (const moduleName of moduleNames(root)) { + const moduleRoot = join(root, moduleName) + const javaRoot = join(moduleRoot, 'src', 'main', 'java') + const javaFiles = filesBelow(javaRoot, (path) => path.endsWith('.java')) + const mappers = mapperInterfaces(javaFiles) + if (!mappers.length) continue + + persistenceModuleCount += 1 + mapperCount += mappers.length + const mapperNames = new Map() + for (const mapper of mappers) { + const previous = mapperNames.get(mapper.name) + if (previous) { + failures.push(`${moduleName}: 根包扫描下 Mapper Bean 名称重复 ${mapper.name}(${relative(root, previous)}、${relative(root, mapper.path)})`) + } else mapperNames.set(mapper.name, mapper.path) + if (!/@Mapper\b/.test(mapper.source)) { + failures.push(`${moduleName}: ${mapper.packageName}.${mapper.name} 缺少 @Mapper(${relative(root, mapper.path)})`) + } + } + + const applications = javaFiles.filter((path) => /@SpringBootApplication\b/.test(readFileSync(path, 'utf8'))) + if (applications.length !== 1) { + failures.push(`${moduleName}: 期望且只能有一个 @SpringBootApplication,实际 ${applications.length} 个`) + continue + } + const applicationPath = applications[0] + const applicationSource = readFileSync(applicationPath, 'utf8') + const rootPackage = packageName(applicationSource) + const scan = mapperScanAnnotation(applicationSource) + if (!scan) failures.push(`${moduleName}: 启动类缺少统一 @MapperScan`) + else { + if (!scan.includes(`"${rootPackage}"`)) failures.push(`${moduleName}: @MapperScan 必须覆盖服务根包 ${rootPackage}`) + if (!/annotationClass\s*=\s*Mapper\.class/.test(scan)) failures.push(`${moduleName}: @MapperScan 必须使用 annotationClass = Mapper.class`) + } + + for (const path of javaFiles) { + if (path === applicationPath) continue + if (/@MapperScan\b/.test(readFileSync(path, 'utf8'))) { + failures.push(`${moduleName}: 禁止分散维护 MapperScan(${relative(root, path)})`) + } + } + + for (const mapper of mappers) { + if (!(mapper.packageName === rootPackage || mapper.packageName.startsWith(`${rootPackage}.`))) { + failures.push(`${moduleName}: ${mapper.packageName}.${mapper.name} 不在服务扫描根包 ${rootPackage} 内`) + } + } + } + + return { failures, mapperCount, persistenceModuleCount } +} + +function main() { + const result = auditMapperScanning(repositoryRoot) + if (result.failures.length) { + console.error(`Mapper 扫描合同失败,共 ${result.failures.length} 项:`) + result.failures.forEach((failure) => console.error(`- ${failure}`)) + process.exitCode = 1 + return + } + console.log(`Mapper 扫描合同通过:${result.persistenceModuleCount} 个持久化模块、${result.mapperCount} 个 Mapper 均使用 @Mapper,并由服务根包注解过滤扫描。`) +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main() diff --git a/scripts/mapper-scan-contract.test.mjs b/scripts/mapper-scan-contract.test.mjs new file mode 100644 index 000000000..950b8a06c --- /dev/null +++ b/scripts/mapper-scan-contract.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { auditMapperScanning, repositoryRoot } from './mapper-scan-contract.mjs' + +test('every production MyBatis mapper is annotated and covered by one root scan', () => { + const result = auditMapperScanning(repositoryRoot) + assert.deepEqual(result.failures, []) + assert.ok(result.persistenceModuleCount >= 10) + assert.ok(result.mapperCount >= 200) +}) diff --git a/scripts/migrate-local-qualification-file.ps1 b/scripts/migrate-local-qualification-file.ps1 new file mode 100644 index 000000000..6f14dd4c1 --- /dev/null +++ b/scripts/migrate-local-qualification-file.ps1 @@ -0,0 +1,86 @@ +param( + [Parameter(Mandatory = $true)][string]$ImagePath, + [Parameter(Mandatory = $true)][string]$StorageRoot, + [Parameter(Mandatory = $true)][UInt64]$TenantId, + [Parameter(Mandatory = $true)][UInt64]$QualificationId, + [Parameter(Mandatory = $true)][string]$OldDocumentUrl, + [string]$OutputDirectory = ".\reports\qualification-migration" +) + +$ErrorActionPreference = "Stop" +$source = (Resolve-Path -LiteralPath $ImagePath).Path +$file = Get-Item -LiteralPath $source +if ($file.Length -le 0 -or $file.Length -gt 5MB) { + throw "资质图片必须大于 0 字节且不超过 5 MB。" +} + +$bytes = [System.IO.File]::ReadAllBytes($source) +$isJpeg = $bytes.Length -ge 3 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xD8 -and $bytes[2] -eq 0xFF +$pngSignature = [byte[]](0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) +$isPng = $bytes.Length -ge 8 +for ($index = 0; $isPng -and $index -lt $pngSignature.Length; $index++) { + $isPng = $bytes[$index] -eq $pngSignature[$index] +} +if (-not $isJpeg -and -not $isPng) { + throw "资质文件签名不是有效的 JPG 或 PNG。" +} + +$extension = if ($isPng) { ".png" } else { ".jpg" } +$fileName = "{0}{1}" -f ([guid]::NewGuid().ToString()), $extension +$qualificationDirectory = Join-Path (Join-Path $StorageRoot "qualifications") $TenantId +$target = Join-Path $qualificationDirectory $fileName +New-Item -ItemType Directory -Force -Path $qualificationDirectory | Out-Null +Copy-Item -LiteralPath $source -Destination $target -ErrorAction Stop + +$sourceHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $source).Hash +$targetHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $target).Hash +if ($sourceHash -ne $targetHash) { + Remove-Item -LiteralPath $target -Force + throw "复制后的文件校验和不一致,迁移已终止并清理目标文件。" +} + +$newUrl = "/api/merchant/v1/files/qualifications/$fileName" +$escapedOldUrl = $OldDocumentUrl.Replace("'", "''") +$escapedNewUrl = $newUrl.Replace("'", "''") +$output = [System.IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Force -Path $output | Out-Null + +$forwardSql = @" +START TRANSACTION; +UPDATE baiyue_iam.iam_tenant_qualification +SET document_url = '$escapedNewUrl' +WHERE id = $QualificationId + AND tenant_id = $TenantId + AND document_url = '$escapedOldUrl' + AND deleted = 0; +SELECT ROW_COUNT() AS affected_rows; +-- 仅当 affected_rows = 1 且平台预览校验成功时,由执行人员显式 COMMIT;否则 ROLLBACK 并删除:$target +"@ +$rollbackSql = @" +START TRANSACTION; +UPDATE baiyue_iam.iam_tenant_qualification +SET document_url = '$escapedOldUrl' +WHERE id = $QualificationId + AND tenant_id = $TenantId + AND document_url = '$escapedNewUrl' + AND deleted = 0; +SELECT ROW_COUNT() AS affected_rows; +-- 仅当 affected_rows = 1 且旧文件可用时,由执行人员显式 COMMIT;否则 ROLLBACK。 +"@ + +$forwardPath = Join-Path $output "qualification-$QualificationId-forward.sql" +$rollbackPath = Join-Path $output "qualification-$QualificationId-rollback.sql" +[System.IO.File]::WriteAllText($forwardPath, $forwardSql, [System.Text.UTF8Encoding]::new($false)) +[System.IO.File]::WriteAllText($rollbackPath, $rollbackSql, [System.Text.UTF8Encoding]::new($false)) + +[pscustomobject]@{ + TenantId = $TenantId + QualificationId = $QualificationId + DocumentUrl = $newUrl + PhysicalFile = $target + Sha256 = $targetHash + ForwardSql = $forwardPath + RollbackSql = $rollbackPath + MerchantPreview = $newUrl + PlatformPreview = "/api/platform/v1/files/qualifications/$TenantId/$fileName" +} diff --git a/scripts/mixed-container-outbound-test-env.mjs b/scripts/mixed-container-outbound-test-env.mjs new file mode 100644 index 000000000..9f5a48cf4 --- /dev/null +++ b/scripts/mixed-container-outbound-test-env.mjs @@ -0,0 +1,168 @@ +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' +import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs' +import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' + +// Explicit retained fixture continuation, not a new fixture generator. +const [sourcePath, containerNo, label, outputPath] = process.argv.slice(2) +if (!sourcePath || !containerNo || !['MIXED-FBA', 'MIXED-DROPSHIP'].includes(label) || !outputPath) { + throw new Error('Usage: source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report; explicit test authorization required') +} +const options = parseArguments([]) +validateExecutionOptions(options) +const context = new AcceptanceRun(options) +const source = JSON.parse(readFileSync(resolve(sourcePath), 'utf8')) +const record = source.createdResources?.dropshipPalletMatrix?.records?.find(row => row.label === label) +if (!record || record.overseas?.plan?.containerNo !== containerNo || !record.overseas?.receiving?.receivingVerified) { + throw new Error('Fixture must be the explicitly selected, completed mixed-container receipt') +} +const inboundCode = record.source.inboundCode +const customerId = record.overseas.plan.customerId +const output = resolve(outputPath) +const result = existsSync(output) ? JSON.parse(readFileSync(output, 'utf8')) : { + marker: context.marker, containerNo, inboundCode, label, source: resolve(sourcePath), events: [], +} +if (result.inboundCode !== inboundCode || result.containerNo !== containerNo || result.label !== label) throw new Error('Checkpoint scope mismatch') +context.marker = result.marker +const save = () => writeFileSync(output, JSON.stringify({ ...result, updatedAt: new Date().toISOString() }, null, 2)) +const base = '/api/platform/v1/last-leg' +async function api(method, path, body, token) { + const response = await context.requestOverseas(method, path, body, {}, token || context.overseasToken) + result.events.push({ method, path, http: response.http, code: response.body?.code, + message: response.body?.message, requestId: response.requestId, traceId: response.traceId }) + save() + if (response.http !== 200 || String(response.body?.code) !== '200') throw new Error(`${method} ${path}: ${response.body?.message}`) + return response.body.data +} +let picker +try { + const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') + if (!login.body?.data?.overseasAccessToken) throw new Error('Platform login failed') + context.token = login.body.data.accessToken + context.overseasToken = login.body.data.overseasAccessToken + if (label === 'MIXED-DROPSHIP') { + const candidatePath = `${base}/outbound-orders/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${record.overseas.warehouseId}` + if (!result.task) { + const candidates = await api('GET', candidatePath) + const selected = candidates.filter(row => row.inboundCode === inboundCode) + const inputs = selected.map(row => { + const pallet = record.task.pallets.find(p => p.palletNo === row.palletNo) + const cartonMarks = (pallet?.cartons || []).filter(c => String(c.inboundId) === String(record.source.inboundId)).map(c => c.boxCode) + if (cartonMarks.length !== Number(row.availablePackages) || row.customerId !== customerId) throw new Error('Dropship pallet scope mismatch') + return { inventoryPositionId: row.inventoryPositionId, packages: cartonMarks.length, sku: row.skuCode, cartonMarks } + }) + if (inputs.length !== 2 || inputs.reduce((n, i) => n + i.packages, 0) !== 4) throw new Error('Expected exactly two pallets and four cartons') + result.selected = selected + result.inputs = inputs + result.createBody = { recipient: `${context.marker} TEST ONLY`, items: inputs, remark: `${context.marker}-test-only`, idempotencyKey: `${context.marker}-OUTBOUND` } + save() + result.task = await api('POST', `${base}/outbound-orders/one-click-dropship?customerId=${customerId}`, result.createBody) + save() + } + const before = await api('GET', `${base}/outbound-orders/${result.task.outboundNo}`) + if (before.outbound.status !== 'OUTBOUNDED') { + const access = await resolvePickerAccess(context) + picker = access ? await createPicker(context, 9, record.overseas.warehouseId, access) : null + if (!picker?.token) throw new Error('PDA operator unavailable') + result.pickerMembershipId = picker.membershipId + save() + const path = `/api/pda/v1/last-leg/outbound-tasks/${result.task.taskNo}` + const detail = await api('GET', path, undefined, picker.token) + for (const [index, candidate] of result.selected.entries()) { + const item = detail.items.find(i => i.palletNo === candidate.palletNo && i.inboundCode === inboundCode && i.skuCode === candidate.skuCode) + if (!item || !item.positions.some(p => String(p.inventoryPositionId) === String(candidate.inventoryPositionId))) throw new Error('PDA source position mismatch') + const body = { outboundPalletItemId: item.outboundPalletItemId, inventoryPositionId: candidate.inventoryPositionId, + packages: result.inputs[index].packages, cartonMarks: result.inputs[index].cartonMarks, idempotencyKey: `${context.marker}-PICK-${index}` } + await api('POST', `${path}/pick`, body, picker.token) + await api('POST', `${path}/pick`, body, picker.token) + } + result.completedTask = await api('POST', `${path}/complete`, { idempotencyKey: `${context.marker}-COMPLETE` }, picker.token) + } + result.outbound = await api('GET', `${base}/outbound-orders/${result.task.outboundNo}`) + result.remainingCandidates = (await api('GET', candidatePath)).filter(row => row.inboundCode === inboundCode) + result.finalStatus = result.outbound.outbound.status === 'OUTBOUNDED' && result.remainingCandidates.length === 0 + ? 'OUTBOUND_PASSED_DELIVERY_NOT_SUPPORTED' : 'OUTBOUND_FAILED' + } else { + const candidates = await api('GET', `${base}/dispatches/candidates?sourceStage=RECEIVED&orderNo=${encodeURIComponent(record.overseas.summary.orderNo)}`) + const selected = candidates.filter(row => row.inboundCode === inboundCode && row.containerNo === containerNo) + if (!result.dispatchNo) { + if (selected.reduce((sum, row) => sum + Number(row.packageCount), 0) !== 4 || selected.some(row => row.fbaWarehouseCode !== 'ABE4')) throw new Error('Live FBA candidate quantity or destination changed') + // The caller must retain the checkpoint after every mutation; do not retry creation without checking live candidates. + const dispatch = await api('POST', `${base}/dispatches/pre-arrange`, { planPalletIds: selected.map(row => row.planPalletId) }) + result.dispatchNo = dispatch.dispatchNo + save() + } + const dispatchPath = `${base}/dispatches/${encodeURIComponent(result.dispatchNo)}` + let dispatch = await api('GET', dispatchPath) + if (!result.transfer) { + await api('PUT', dispatchPath, { vehiclePlate: `TEST-${context.marker.slice(-8)}`, driverName: 'HTTP测试司机', + driverPhone: '19900000000', carrierName: 'HTTP测试车队', estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString() }) + result.transfer = await api('POST', `${dispatchPath}/transfer-delivery`, { + idempotencyKey: `${context.marker}-TRANSFER`, plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(), + deliveryMethod: 'TRUCK_PALLET', carrierName: 'HTTP测试车队', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', + vehicleType: 'TRUCK', vehicleSize: '15托', driverName: 'HTTP测试司机', driverPhone: '19900000000', + vehiclePlate: `TEST-${context.marker.slice(-8)}`, appointmentAccount: 'SELF', remark: `${context.marker}-test-only`, + fees: [{ feeType: 'DELIVERY_FEE', feeName: '测试派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 1, remark: `${context.marker}-test-only` }], + }) + save() + } + dispatch = await api('GET', dispatchPath) + if (dispatch.status !== 'DEPARTED') { + const access = await resolvePickerAccess(context) + picker = access ? await createPicker(context, 8, record.overseas.warehouseId, access) : null + if (!picker?.token) throw new Error('PDA operator unavailable') + result.pickerMembershipId = picker.membershipId + save() + if (dispatch.status !== 'READY_TO_DEPART' && !await pickAll(context, picker.token, result.dispatchNo)) throw new Error('PDA picking failed') + result.departure = await api('POST', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}/depart`, + { idempotencyKey: `${context.marker}-DEPART`, remark: `${context.marker}-test-only` }, picker.token) + save() + } + const deliveries = result.transfer.deliveries + if (!Array.isArray(deliveries) || deliveries.length !== 1 || Number(result.transfer.packageCount) !== 4) throw new Error('Unexpected transfer quantities') + result.deliveries = [] + for (const [index, delivery] of deliveries.entries()) { + const path = `${base}/delivery-orders/${delivery.deliveryNo}?customerId=${customerId}` + const detail = await api('GET', path) + if (detail.order.status !== 'DELIVERED') { + if (detail.order.status !== 'DELIVERING') await api('PUT', `${base}/delivery-orders/${delivery.deliveryNo}/status?customerId=${customerId}`, + { status: 'DELIVERING', expectedVersion: detail.order.version, remark: `${context.marker}-test-only` }) + if (!await saveReceiptAndComplete(context, delivery, customerId, index)) throw new Error('Receipt signature failed') + } + result.deliveries.push(await api('GET', path)) + save() + } + result.outbound = await api('GET', `${base}/outbound-orders/${result.transfer.outboundNo}`) + for (let attempt = 0; attempt < 30; attempt++) { + const response = await context.request('GET', `/api/platform/inbounds?tenantId=${customerId}&inboundCode=${inboundCode}&page=1&size=20`) + if (String(response.body?.code) !== '200') throw new Error('Domestic inbound query failed') + result.domesticInbound = response.body.data.records.find(row => row.inboundCode === inboundCode) + if (result.domesticInbound?.processStatus === 'DELIVERED') break + await new Promise(resolve => setTimeout(resolve, 1000)) + } + if (!result.domesticInbound?.orderId) throw new Error('Missing domestic source order') + const order = await context.request('GET', `/api/platform/v1/orders/${result.domesticInbound.orderId}?tenantId=${customerId}`) + result.domesticOrder = order.body?.data?.summary + result.finalStatus = result.domesticInbound?.processStatus === 'DELIVERED' && result.domesticOrder?.mainStatus === 'COMPLETED' + && result.outbound?.outbound?.status === 'OUTBOUNDED' ? 'PASSED' : 'STATE_CALLBACK_FAILED' + } +} catch (error) { + result.error = error.message + result.finalStatus = 'BLOCKED' + process.exitCode = 1 +} finally { + try { + await disablePicker(context, picker, label === 'MIXED-DROPSHIP' ? 9 : 8) + } catch (error) { + result.cleanupError = error.message + result.finalStatus = 'CLEANUP_FAILED' + } + result.checks = context.steps + if (context.steps.some(step => step.passed === false)) result.finalStatus = 'CHECK_FAILED' + if (result.finalStatus !== 'PASSED') process.exitCode = 1 + save() + console.log(JSON.stringify({ containerNo, inboundCode, finalStatus: result.finalStatus, error: result.error, + dispatchNo: result.dispatchNo, outboundNo: result.transfer?.outboundNo || result.task?.outboundNo, + deliveryNos: result.transfer?.deliveries?.map(row => row.deliveryNo), report: output })) +} diff --git a/scripts/mojia-measurement-scenario.mjs b/scripts/mojia-measurement-scenario.mjs new file mode 100644 index 000000000..1cf226be5 --- /dev/null +++ b/scripts/mojia-measurement-scenario.mjs @@ -0,0 +1,197 @@ +import { readFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCENARIO = 'mojia-measurement' +const DEFAULT_IMAGE = resolve( + dirname(fileURLToPath(import.meta.url)), '..', '..', 'apps', 'pda-uniapp', 'static', 'c7.png') + +function success(response) { + return response?.http === 200 && String(response?.body?.code) === '200' +} + +function data(response) { + return response?.body?.data ?? null +} + +function record(run, name, response, passed, evidence = '') { + return run.addStep(SCENARIO, name, response, passed, evidence) +} + +function responseOf(http, body, raw = null) { + return { + method: 'POST', + path: null, + http, + body, + raw, + requestId: body?.requestId ?? null, + traceId: body?.traceId ?? null, + } +} + +async function multipartRequest(run, path, fields, imagePath) { + const form = new FormData() + form.set('data', JSON.stringify(fields)) + form.set('image', new Blob([await readFile(imagePath)], { type: 'image/png' }), 'mojia-e2e.png') + try { + const response = await fetch(`${run.options.gateway}${path}`, { + method: 'POST', + headers: { Accept: 'application/json', Authorization: `Bearer ${run.token}` }, + body: form, + signal: AbortSignal.timeout(30_000), + }) + const text = await response.text() + let body = null + try { body = text ? JSON.parse(text) : null } catch { /* Keep raw response for the report. */ } + return { + method: 'POST', path, http: response.status, body, + raw: body === null ? text.slice(0, 500) : null, + requestId: body?.requestId ?? response.headers.get('x-request-id'), + traceId: body?.traceId ?? response.headers.get('x-trace-id'), + } + } catch (error) { + return responseOf(0, null, error instanceof Error ? error.message : String(error)) + } +} + +function requireData(run, name, response, evidence = '') { + record(run, name, response, success(response) && data(response) !== null, evidence) + if (!success(response) || data(response) === null) { + throw new Error(`${name} failed: code=${response?.body?.code}, message=${response?.body?.message}`) + } + return data(response) +} + +/** + * Run the internal platform simulation for the MoJia carton measurement flow. + * The final external MoJia key/signature contract is intentionally not defined here. + * + * @param {object} run Existing AcceptanceRun instance from admin-test-env-acceptance.mjs. + * @param {object} [options] + * @param {string|number} [options.tenantId] Customer scope for selecting the test inbound. + * @param {string|number} [options.inboundId] Explicit WAIT_MEASURE inbound, preferred for CI. + * @param {string} [options.imagePath] PNG/JPG evidence image path. + */ +export async function runMojiaMeasurementScenario(run, options = {}) { + const tenantId = options.tenantId ?? process.env.MOJIA_TEST_TENANT_ID + const imagePath = options.imagePath ?? process.env.MOJIA_TEST_IMAGE ?? DEFAULT_IMAGE + let inboundId = options.inboundId ?? process.env.MOJIA_TEST_INBOUND_ID + + if (!tenantId && !inboundId) { + const response = responseOf(0, { code: 'DEPLOYMENT_BLOCKED', message: '缺少墨家测试 tenantId 或 inboundId' }) + record(run, '墨家测量测试数据配置', response, false, '请传入 MOJIA_TEST_TENANT_ID 或 MOJIA_TEST_INBOUND_ID') + return false + } + + try { + if (!inboundId) { + const response = await run.request( + 'GET', '/api/platform/inbounds?processStatus=WAIT_MEASURE&page=1&size=200') + const rows = requireData(run, '查找墨家 WAIT_MEASURE 入库单', response) + const candidate = rows.records?.find((row) => String(row.tenantId) === String(tenantId) + && row.totalPackageCount == null) + if (!candidate) { + record(run, '墨家测量测试数据配置', response, false, + `tenantId=${tenantId} 下没有未冻结总箱数的 WAIT_MEASURE 入库单`) + return false + } + inboundId = String(candidate.id) + } + + const detailPath = `/api/platform/inbounds/${inboundId}?tenantId=${tenantId}` + const detailResponse = await run.request('GET', detailPath) + const detail = requireData(run, '查询墨家测试入库单', detailResponse, `inboundId=${inboundId}`) + const summary = detail.summary + if (summary.processStatus !== 'WAIT_MEASURE' || summary.totalPackageCount != null) { + record(run, '墨家测试入库单状态校验', detailResponse, false, + `processStatus=${summary.processStatus}, totalPackageCount=${summary.totalPackageCount}`) + return false + } + const resolvedTenantId = String(summary.tenantId ?? tenantId) + + const printPath = `/api/platform/inbounds/${inboundId}/carton-label-pdf?tenantId=${resolvedTenantId}` + const printResponse = await run.requestBinary('POST', printPath) + record(run, '墨家测试生成稳定箱唛 PDF', printResponse, + printResponse.http === 200 && printResponse.bytes?.length > 0 + && printResponse.contentType?.toLowerCase().includes('application/pdf'), + `inboundId=${inboundId}, bytes=${printResponse.bytes?.length ?? 0}`) + + const afterPrintResponse = await run.request('GET', detailPath) + const afterPrint = requireData(run, '回查墨家稳定箱号总数', afterPrintResponse) + const printedTotal = Number(afterPrint.summary.totalPackageCount) + if (!Number.isInteger(printedTotal) || printedTotal < 1) { + throw new Error('打印后没有冻结原始总箱数') + } + const boxCode = `${afterPrint.summary.inboundCode}#${printedTotal}-1` + const scanPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/carton-scans?tenantId=${resolvedTenantId}` + const firstFields = { + boxCode, length: 59.1, width: 40.2, height: 30.3, + machineNo: 'MJ-ADMIN-E2E-01', measuredAt: '2026-08-08T10:00:00', + } + const firstResponse = await multipartRequest(run, scanPath, firstFields, imagePath) + const first = requireData(run, '墨家单箱首次测量上传', firstResponse, `boxCode=${boxCode}`) + const firstPassed = first.created === true && first.confirmedPackageCount === 1 + && String(first.boxCode) === boxCode + record(run, '墨家单箱首次记录语义', firstResponse, firstPassed, + `itemId=${first.itemId}, measurementId=${first.measurementId}`) + if (!firstPassed) throw new Error('首次扫描没有创建单箱记录或默认箱数不是 1') + + const countPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/items/${first.itemId}/confirmed-package-count?tenantId=${resolvedTenantId}` + const countResponse = await run.request('PUT', countPath, { + measurementVersion: first.measurementVersion, + confirmedPackageCount: printedTotal, + }) + const counted = requireData(run, '人工确认墨家单箱代表箱数', countResponse, + `confirmedPackageCount=${printedTotal}`) + if (counted.confirmedPackageCount !== printedTotal) { + throw new Error('人工确认箱数回显不一致') + } + + const rescanResponse = await multipartRequest(run, scanPath, { + boxCode, length: 59.5, width: 41.2, height: 31.3, + machineNo: 'MJ-ADMIN-E2E-02', measuredAt: '2026-08-08T10:01:00', + }, imagePath) + const rescan = requireData(run, '墨家同箱复测覆盖', rescanResponse) + const rescanPassed = rescan.created === false + && String(rescan.itemId) === String(first.itemId) + && rescan.confirmedPackageCount === printedTotal + && Number(rescan.lengthCm) === 59.5 + record(run, '墨家同箱复测保留人工箱数', rescanResponse, rescanPassed, + `itemId=${rescan.itemId}, confirmedPackageCount=${rescan.confirmedPackageCount}`) + if (!rescanPassed) throw new Error('同箱复测没有覆盖设备值或覆盖人工箱数') + + const submitPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/submit?tenantId=${resolvedTenantId}` + const submitResponse = await run.request('POST', submitPath, { + measurementId: rescan.measurementId, + version: rescan.measurementVersion, + }) + const submitted = requireData(run, '提交墨家测量审核', submitResponse) + const submittedPassed = submitted.processStatus === 'MEASURE_PENDING' + && submitted.measureReviewStatus === 'PENDING' + record(run, '墨家测量进入待审核', submitResponse, submittedPassed, + `processStatus=${submitted.processStatus}, reviewStatus=${submitted.measureReviewStatus}`) + if (!submittedPassed) throw new Error('墨家测量提交后状态不正确') + + const blockedResponse = await multipartRequest(run, scanPath, { + boxCode, length: 99.1, width: 99.2, height: 99.3, + machineNo: 'MJ-ADMIN-E2E-BLOCKED', measuredAt: '2026-08-08T10:02:00', + }, imagePath) + const blockedPassed = blockedResponse.body?.code === 'A0409' + && blockedResponse.body?.message?.includes('测量已提交') + record(run, '提交后拒绝墨家设备覆盖', blockedResponse, blockedPassed, + `code=${blockedResponse.body?.code}, message=${blockedResponse.body?.message ?? ''}`) + if (!blockedPassed) throw new Error('提交后没有拒绝墨家设备覆盖') + + run.context ??= {} + run.context.mojiaMeasurement = { + tenantId: resolvedTenantId, inboundId: String(inboundId), boxCode, + measurementId: String(rescan.measurementId), itemId: String(rescan.itemId), + } + return true + } catch (error) { + const response = responseOf(0, null, error instanceof Error ? error.message : String(error)) + record(run, '墨家测量场景执行', response, false, response.raw) + return false + } +} 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..8b8bbb62d --- /dev/null +++ b/scripts/overseas-deployment-contract.test.mjs @@ -0,0 +1,205 @@ +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('local, development and test Nacos publishing remain environment-isolated', async () => { + const publisher = await read('scripts/publish-nacos-config.ps1') + const local = [ + await read('config/nacos/local/gateway-overseas.yml'), + await read('config/nacos/local/lastleg-service.yml'), + await read('config/nacos/local/warehouse-overseas-service.yml'), + ].join('\n') + const remote = [ + await read('config/nacos/overseas/gateway-overseas.yml'), + await read('config/nacos/overseas/lastleg-service.yml'), + await read('config/nacos/overseas/warehouse-overseas-service.yml'), + ].join('\n') + + assert.match(publisher, /ValidateSet\('local', 'dev', 'development', 'test', 'production'\)/) + assert.match(publisher, /必须显式指定非 public 的 Nacos namespace/) + assert.match(local, /127\.0\.0\.1:9100/) + assert.match(local, /127\.0\.0\.1:9303/) + assert.doesNotMatch(remote, /127\.0\.0\.1|password:\s*123456/) + assert.match(remote, /http:\/\/lastleg-service:9303/) + assert.match(remote, /\$\{LASTLEG_DB_URL\}/) + assert.match(remote, /\$\{WAREHOUSE_DB_URL\}/) +}) + +test('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_USERNAME: \$\{OVERSEAS_NACOS_USERNAME\}/) + assert.match(compose, /NACOS_PASSWORD: \$\{OVERSEAS_NACOS_PASSWORD\}/) + assert.match(compose, /NACOS_NAMESPACE: \$\{OVERSEAS_NACOS_NAMESPACE:-prod-overseas\}/) + assert.match(compose, /REGION_CODE: OVERSEAS/) + assert.match(compose, /SPRING_SQL_INIT_MODE: never/) + assert.match(compose, /^ gateway-overseas:\s*$/m) + assert.match(compose, /^ lastleg-service:\s*$/m) + assert.match(compose, /^ warehouse-overseas-service:\s*$/m) + assert.match(compose, /^ event-bridge-service:\s*$/m) + assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312/) + assert.match(compose, /INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/) + assert.match(compose, /LASTLEG_DB_URL: \$\{LASTLEG_DB_URL\}/) + assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) + assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) + assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) + assert.match(compose, /LASTLEG_SECURITY_SERVICE_URL: \$\{LASTLEG_SECURITY_SERVICE_URL:-http:\/\/lastleg-service:9303\}/) + assert.match(compose, /LASTLEG_SECURITY_EVENT_CONSUMER_URL: \$\{LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events\}/) + assert.doesNotMatch(compose, /overseas-security-service|9307|OVERSEAS_SECURITY_DB|OVERSEAS_SECURITY_SERVICE_URL|OVERSEAS_SECURITY_EVENT_CONSUMER_URL/) + assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.match(compose, /FILE_SERVICE_URL: \$\{FILE_SERVICE_URL\}/) + assert.doesNotMatch(compose, /DOMESTIC_FILE_SERVICE_URL/) + assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/) + assert.match(compose, /aliases: \[event-bridge-overseas\]/) +}) + +test('CN and overseas event bridges have explicit opposite-region consumers', async () => { + const cnCompose = await read('deploy/docker-compose.yml') + const overseasCompose = await read('deploy/docker-compose.overseas.yml') + const cn = await read('config/nacos/cn/event-bridge-service.yml') + const overseas = await read('config/nacos/overseas/event-bridge-service.yml') + const firstleg = await read('config/nacos/cn/firstleg-service.yml') + const iam = await read('config/nacos/cn/iam-service.yml') + const iamApplication = await read('iam/src/main/resources/application.yml') + const iamClient = await read('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java') + const lastleg = await read('config/nacos/overseas/lastleg-service.yml') + const gatewayOverseas = await read('config/nacos/overseas/gateway-overseas.yml') + const scripts = `${await read('deploy/scripts/deploy-built-service.sh')}\n${await read('deploy/scripts/deploy-services.sh')}` + assert.match(cn, /EVENT_CONSUMER_URL:http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events/) + assert.match(overseas, /EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/events/) + assert.match(cnCompose, /EVENT_CONSUMER_URL: \$\{FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events\}/) + assert.match(cnCompose, /aliases: \[event-bridge-cn\]/) + assert.match(overseasCompose, /EVENT_CONSUMER_URL: \$\{EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/events\}/) + assert.match(firstleg, /LASTLEG_EVENT_BRIDGE_URL/) + assert.match(iamClient, /url = "\$\{baiyue\.security-event\.event-bridge-url:\}"/) + assert.match(iam, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/) + assert.match(iamApplication, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/) + assert.match(cnCompose, /SECURITY_EVENT_BRIDGE_URL: \$\{SECURITY_EVENT_BRIDGE_URL:-http:\/\/event-bridge-overseas:9312\}/) + assert.match(overseasCompose, /aliases: \[event-bridge-overseas\]/) + assert.match(firstleg, /LASTLEG_OUTBOX_RELAY_LEASE_SECONDS/) + assert.match(lastleg, /CN_EVENT_BRIDGE_URL:\$\{FIRSTLEG_EVENT_BRIDGE_URL\}/) + assert.match(lastleg, /WAREHOUSE_OVERSEAS_SERVICE_URL:http:\/\/warehouse-overseas-service:9305/) + assert.match(lastleg, /domestic-file:\s*\n\s*url: \$\{FILE_SERVICE_URL\}/) + assert.doesNotMatch(lastleg, /DOMESTIC_FILE_SERVICE_URL/) + assert.match(gatewayOverseas, /LASTLEG_SECURITY_SERVICE_URL:http:\/\/lastleg-service:9303/) + assert.match(overseas, /LASTLEG_SECURITY_EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events/) + assert.doesNotMatch(`${gatewayOverseas}\n${overseas}`, /overseas-security-service|9307|OVERSEAS_SECURITY_/) + assert.match(scripts, /FIRSTLEG_EVENT_BRIDGE_URL/) + assert.match(scripts, /LASTLEG_EVENT_BRIDGE_URL/) + assert.match(scripts, /FILE_SERVICE_URL/) + assert.doesNotMatch(scripts, /DOMESTIC_FILE_SERVICE_URL/) + assert.match(scripts, /docker-compose\.overseas\.yml/) + assert.match(scripts, /REGION_CODE=CN 或 REGION_CODE=OVERSEAS/) + assert.match(scripts, /env_file_value REGION_CODE/) + assert.match(scripts, /EVENT_BRIDGE_REGION_OVERRIDE/) + assert.match(scripts, /FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL/) + assert.match(scripts, /COMPOSE_FILE="deploy\/docker-compose\.overseas\.yml"/) + assert.match(scripts, /PROJECT_NAME="firstleg-\$DEPLOY_ENV-overseas"/) + assert.match(scripts, /运行容器镜像与请求镜像不一致/) + assert.match(scripts, /REMOTE_ENV_NAME="\.env\.\$PROJECT_NAME"/) + assert.match(scripts, /REMOTE_COMPOSE_NAME="docker-compose\.\$PROJECT_NAME\.yml"/) + for (const key of [ + 'NACOS_SERVER_ADDR', 'NACOS_USERNAME', 'NACOS_PASSWORD', 'OVERSEAS_NACOS_NAMESPACE', 'INTERNAL_SERVICE_TOKEN', + 'JWT_ISSUER', 'OVERSEAS_JWT_AUDIENCE', 'JWT_JWK_SET_URI' + ]) assert.match(scripts, new RegExp(key)) +}) + +test('domestic Auth receives the overseas JWT signing configuration', async () => { + const compose = await read('deploy/docker-compose.yml') + for (const key of [ + 'OVERSEAS_JWT_ENABLED', 'OVERSEAS_JWT_ISSUER', 'OVERSEAS_JWT_AUDIENCE', + 'OVERSEAS_JWT_RSA_JWK_SET', 'OVERSEAS_JWT_CURRENT_KID', 'OVERSEAS_ACCESS_TOKEN_TTL' + ]) assert.match(compose, new RegExp(`${key}: \\$\\{${key}`)) +}) + +test('domestic event bridge SQL converges legacy tenant columns to the customer entity contract', async () => { + const baseline = await read('event-bridge-service/src/main/resources/db/event-bridge.sql') + const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') + assert.equal((baseline.match(/customer_id VARCHAR\(128\) NOT NULL/g) || []).length, 2) + assert.equal((incremental.slice(0, incremental.indexOf('CREATE TABLE IF NOT EXISTS bridge_delivery_attempt')) + .match(/customer_id VARCHAR\(128\) NOT NULL/g) || []).length, 2) + assert.match(incremental, /ALTER TABLE bridge_inbox RENAME COLUMN tenant_id TO customer_id/) + assert.match(incremental, /ALTER TABLE bridge_outbox RENAME COLUMN tenant_id TO customer_id/) + assert.match(incremental, /bridge_inbox_customer_column = 0 AND @bridge_inbox_tenant_column = 1/) + assert.match(incremental, /bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1/) +}) + +test('both overseas PDF consumers explicitly connect to the existing CN Print service', async () => { + const compose = await read('deploy/docker-compose.overseas.yml') + for (const service of ['lastleg-service', 'warehouse-overseas-service']) { + const block = compose.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [a-z][\\w-]*:|^networks:)`, 'm'))?.[1] + assert.ok(block, `${service} must have a deployment definition`) + assert.match(block, /PRINT_SERVICE_URL: \$\{PRINT_SERVICE_URL:-http:\/\/print:9207\}/, + `${service} cannot discover the CN Print service from its overseas Nacos namespace`) + const config = await read(`${service}/src/main/resources/application.yml`) + assert.match(config, /print:\s*\n\s+url: \$\{PRINT_SERVICE_URL:\}/) + } +}) + +test('domestic unified SQL creates the overseas hold version before queuing its comment', async () => { + const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') + const addColumn = incremental.indexOf('CALL add_inbound_overseas_hold_version();') + const queueComment = incremental.indexOf("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'last_overseas_hold_version'") + + assert.notEqual(addColumn, -1) + assert.notEqual(queueComment, -1) + assert.ok(addColumn < queueComment, + 'existing databases must add last_overseas_hold_version before the strict comment queue reads it') +}) + +test('overseas unified SQL initializes the isolated Event Bridge database before services start', async () => { + const overseas = await read('deploy/sql/海外区域统一增量-MySQL8.sql') + assert.match(overseas, /CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas/) + assert.match(overseas, /USE baiyue_event_bridge_overseas;/) + for (const table of ['bridge_outbox', 'bridge_inbox', 'bridge_delivery_attempt']) { + assert.match(overseas, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)) + } + for (const index of [ + 'uk_bridge_outbox_idempotency', 'idx_bridge_outbox_schedule', + 'uk_bridge_inbox_idempotency', 'idx_bridge_inbox_consume', + 'uk_bridge_attempt', 'idx_bridge_attempt_event' + ]) assert.match(overseas, new RegExp(index)) + assert.match(overseas, /COUNT\(DISTINCT CONCAT\(table_name, ':', index_name\)\)/) + assert.match(overseas, /CALL assert_overseas_event_bridge_schema\(\);/) + assert.ok(overseas.indexOf('USE baiyue_event_bridge_overseas;') < overseas.indexOf('USE baiyue_lastleg;'), + 'overseas Event Bridge schema must be initialized before Lastleg tables') +}) diff --git a/scripts/overseas-relabel-fulfillment-acceptance.mjs b/scripts/overseas-relabel-fulfillment-acceptance.mjs new file mode 100644 index 000000000..d2117ed5b --- /dev/null +++ b/scripts/overseas-relabel-fulfillment-acceptance.mjs @@ -0,0 +1,115 @@ +import { readFile, writeFile } from 'node:fs/promises' + +const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' +const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' +const sourceReport = process.env.OVERSEAS_ACCEPTANCE_SOURCE +const directDispatchNo = process.env.OVERSEAS_DISPATCH_NO +const directRelabelNo = process.env.OVERSEAS_RELABEL_NO +const output = process.env.OVERSEAS_FULFILLMENT_OUTPUT || 'reports/overseas-relabel-fulfillment-acceptance.json' +const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID +const directPlatformToken = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN +const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN +const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN +const marker = `FUL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` +if (!directPlatformToken && (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD)) throw new Error('Missing platform credentials or token') +if (!directMerchantToken && (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD)) throw new Error('Missing merchant token or platform credentials') +if (!directPdaToken && (!process.env.PDA_TEST_USERNAME || !process.env.PDA_TEST_PASSWORD)) throw new Error('Missing PDA credentials or token') +if ((!sourceReport && !directDispatchNo) || !customerId) throw new Error('Missing acceptance source/dispatch or customer id') + +function id(value) { return value == null ? '' : String(value) } +function key(value) { return `${marker}-${value}`.slice(0, 200) } +function assert(value, message) { if (!value) throw new Error(message) } +async function api(base, token, method, path, body) { + const response = await fetch(`${base}${path}`, { method, + headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + body: body === undefined ? undefined : JSON.stringify(body) }) + const text = await response.text(); let value + try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } + if (!response.ok || String(value?.code) !== '200') { + throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`) + } + return value.data +} + +const source = sourceReport ? JSON.parse(await readFile(sourceReport, 'utf8')) : null +const scenario = source?.scenarios.find((value) => value.name === 'SPLIT-TWO-TARGETS') + || { dispatchNo: directDispatchNo, relabelNo: directRelabelNo || '' } +assert(scenario?.dispatchNo, 'Dispatch not found in source report') +const dispatchNo = scenario.dispatchNo +const platformLogin = directPlatformToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { + loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD }) +const pdaLogin = directPdaToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { + loginId: process.env.PDA_TEST_USERNAME, password: process.env.PDA_TEST_PASSWORD }) +const ticket = directMerchantToken ? null : await api(domestic, platformLogin.accessToken, 'POST', + `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程履约验收` }) +const merchantLogin = directMerchantToken ? null : await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) +const platformToken = directPlatformToken || platformLogin.overseasAccessToken +const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken +const pdaToken = directPdaToken || pdaLogin.overseasAccessToken + +await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { + vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', + carrierName: '尾程验收车队', route: `${marker}-ROUTE`, + estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString(), +}) +const transfer = await api(overseas, merchantToken, 'POST', + `/api/merchant/v1/last-leg/delivery-orders?dispatchNo=${encodeURIComponent(dispatchNo)}`, { + idempotencyKey: key('TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(), + deliveryMethod: 'TRUCK_PALLET', waybillNo: `${marker}-WAYBILL`, carrierName: '尾程验收车队', + pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '15托', + driverName: '尾程验收司机', driverPhone: '19900000000', vehiclePlate: `UAT-${marker.slice(-8)}`, + appointmentAccount: 'SELF', remark: marker, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', + direction: 'RECEIVABLE', currency: 'CNY', amount: 10, remark: marker }], + }) +assert(transfer.outboundNo && transfer.deliveryNo, 'Transfer did not create outbound and delivery') + +let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) +assert(Array.isArray(task.items) && task.items.length === 2, 'Expected two PDA pallet items') +for (const [itemIndex, item] of task.items.entries()) { + let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) + for (const [positionIndex, position] of item.positions.entries()) { + if (remaining <= 0) break + const packages = Math.min(remaining, Number(position.availablePackages)) + if (packages <= 0) continue + await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, { + positionId: id(position.id), packages, productQuantity: packages, + idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker, + }) + remaining -= packages + } + assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`) +} +const completed = await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') }) +assert(completed.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART') +const departed = await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`, { idempotencyKey: key('DEPART'), remark: marker }) +assert(departed.status === 'DEPARTED', 'Dispatch did not reach DEPARTED') + +const outbound = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}?page=1&size=20`) +assert(outbound.outbound.status === 'OUTBOUNDED', 'Outbound did not reach OUTBOUNDED') +let delivery = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`) +const delivering = await api(overseas, platformToken, 'PUT', + `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/status?customerId=${customerId}`, { + status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker, + }) +assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING') +delivery = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`) +const delivered = await api(overseas, platformToken, 'POST', + `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/complete?customerId=${customerId}`, { + status: 'DELIVERED', expectedVersion: Number(delivery.order.version), remark: marker, + }) +assert(delivered.deliveryStatus === 'DELIVERED', 'Delivery did not reach DELIVERED') + +const report = { marker, scope: 'OVERSEAS_LASTLEG_ONLY', sourceReport, relabelNo: scenario.relabelNo, + dispatchNo, dispatchStatus: departed.status, outboundNo: transfer.outboundNo, + outboundStatus: outbound.outbound.status, deliveryNo: transfer.deliveryNo, + deliveryStatus: delivered.deliveryStatus, visitId: id(delivered.visitId), shippedPackages: departed.shippedPackages, + passed: true, finishedAt: new Date().toISOString() } +await writeFile(output, JSON.stringify(report, null, 2), 'utf8') +console.log(JSON.stringify(report, null, 2)) diff --git a/scripts/overseas-relabel-real-acceptance.mjs b/scripts/overseas-relabel-real-acceptance.mjs new file mode 100644 index 000000000..f6b5f1879 --- /dev/null +++ b/scripts/overseas-relabel-real-acceptance.mjs @@ -0,0 +1,199 @@ +import { writeFile } from 'node:fs/promises' + +const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' +const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' +const platformUser = process.env.ADMIN_TEST_USERNAME +const platformPassword = process.env.ADMIN_TEST_PASSWORD +const pdaUser = process.env.PDA_TEST_USERNAME +const pdaPassword = process.env.PDA_TEST_PASSWORD +const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID +const warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID +const directPlatformToken = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN +const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN +const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN +const output = process.env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json' +const marker = `TAIL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` + +for (const [name, value] of Object.entries({ customerId, warehouseId })) { + if (!value) throw new Error(`Missing environment variable: ${name}`) +} +if (!directPlatformToken && (!platformUser || !platformPassword)) throw new Error('Missing platform credentials or token') +if (!directMerchantToken && (!platformUser || !platformPassword)) throw new Error('Missing merchant token or platform credentials') +if (!directPdaToken && (!pdaUser || !pdaPassword)) throw new Error('Missing PDA credentials or token') + +const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', scenarios: [], passed: false } +function key(value) { return `${marker}-${value}`.slice(0, 200) } +function id(value) { return value == null ? '' : String(value) } +async function api(base, token, method, path, body, headers = {}) { + const response = await fetch(`${base}${path}`, { + method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...headers }, + body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body), + }) + const text = await response.text() + let value + try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } + if (!response.ok || String(value?.code) !== '200') { + throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`) + } + return value.data +} +async function poll(action, predicate, attempts = 40) { + for (let index = 0; index < attempts; index += 1) { + const value = await action() + if (predicate(value)) return value + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error('Polling timed out') +} +function assert(condition, message) { if (!condition) throw new Error(message) } + +const platformLogin = directPlatformToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { loginId: platformUser, password: platformPassword }) +const pdaLogin = directPdaToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { loginId: pdaUser, password: pdaPassword }) +const ticket = directMerchantToken ? null : await api(domestic, platformLogin.accessToken, 'POST', + `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程真实验收` }) +const merchantLogin = directMerchantToken ? null : await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) +const platformToken = directPlatformToken || platformLogin.overseasAccessToken +const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken +const pdaToken = directPdaToken || pdaLogin.overseasAccessToken +assert(platformToken && merchantToken && pdaToken, 'Missing overseas token') + +const candidates = await api(overseas, merchantToken, 'GET', + `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${warehouseId}`) +const source = candidates.find((item) => String(item.inboundOrderNo || '').startsWith('DS-') + && Number(item.availablePackages) >= 5 + && item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5)) +assert(source, 'No overseas inventory source with at least five available cartons') +const sourcePosition = source.positions.find((position) => Number(position.availablePackages) >= 5) + +async function resolvePallet(palletCode) { + return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) +} + +async function createAndApprove(name, cartonCount) { + const suffix = `${name}-${Date.now()}` + const items = Array.from({ length: cartonCount }, (_, index) => ({ + sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId), + sourceInboundOrderId: id(source.inboundOrderId), skuCode: source.skuCode, + productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1, + shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), + labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`, + fbaCartonMark: `FBA-${suffix}-${index + 1}`, destinationFbaWarehouseCode: source.destinationFbaWarehouseCode, + operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: source.skuCode, + newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }], + })) + const created = await api(overseas, platformToken, 'POST', + `/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, { + customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId, + labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), + contactName: '尾程验收', contactPhone: '13800000006', chargeAmount: 0.01, + remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), deliveryMethod: 'TRUCK', + printShippingLabel: false, items, + }) + const relabelNo = created.application.relabelNo + await poll(() => api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/relabel-applications/${relabelNo}`), + (detail) => detail.application.walletStatus === 'FROZEN') + let detail = await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) }) + for (const [index, item] of detail.items.entries()) { + detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, { + itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark, + expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`), + }) + } + const form = new FormData() + form.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), `${marker}.png`) + const photo = await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, form) + detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, { + photoFileIds: [id(photo.fileId)], expectedVersion: Number(detail.version), + idempotencyKey: key(`${name}-COMPLETE`), remark: marker, + }) + detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/review`, { + decision: 'APPROVE', expectedVersion: Number(detail.version), + idempotencyKey: key(`${name}-APPROVE`), remark: marker, + }) + assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`) + const confirmed = await api(overseas, merchantToken, 'POST', + `/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, { + idempotencyKey: key(`${name}-CONFIRM`), remark: marker, + }) + assert(confirmed.application.status === 'READY_FOR_OUTBOUND', `${relabelNo} did not reach READY_FOR_OUTBOUND`) + return { name, relabelNo, items: confirmed.items, sourceBefore: Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) } +} + +async function printPallet(relabelNo, suffix) { + return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', { + relabelNo, idempotencyKey: key(`PRINT-${suffix}`), + }) +} +async function moveCarton(scenario, item, target, index) { + const sourcePallet = await resolvePallet(sourcePosition.palletNo) + const targetPallet = await resolvePallet(target.palletCode) + const result = await api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', { + idempotencyKey: key(`${scenario.name}-MOVE-${index}`), relabelNo: scenario.relabelNo, + source: { palletCode: sourcePallet.palletCode, expectedVersion: Number(sourcePallet.palletVersion) }, + target: { palletCode: targetPallet.palletCode, expectedVersion: Number(targetPallet.palletVersion) }, + cartonMarks: [item.cartonMark], reason: `${marker}-${scenario.name}`, + }) + assert(result.operationType === 'RELABEL_PALLET_DISASSEMBLE', 'Unexpected pallet operation type') + return result +} + +const fresh = await createAndApprove('NEW-PALLET', 1) +const sharedTarget = await printPallet(fresh.relabelNo, 'SHARED') +const printReplay = await printPallet(fresh.relabelNo, 'SHARED') +assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent') +await moveCarton(fresh, fresh.items[0], sharedTarget, 0) +fresh.targets = [sharedTarget] + +const merged = await createAndApprove('MERGE-EXISTING', 1) +const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) +await moveCarton(merged, merged.items[0], sharedTarget, 0) +const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) +assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase') +merged.targets = [sharedTarget] + +const split = await createAndApprove('SPLIT-TWO-TARGETS', 2) +const splitA = await printPallet(split.relabelNo, 'SPLIT-A') +const splitB = await printPallet(split.relabelNo, 'SPLIT-B') +await moveCarton(split, split.items[0], splitA, 0) +await moveCarton(split, split.items[1], splitB, 1) +split.targets = [splitA, splitB] + +for (const scenario of [fresh, merged, split]) { + const sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) + scenario.sourceAfter = sourceAfter + scenario.targetPallets = scenario.targets.map((target) => ({ palletId: id(target.palletId), palletCode: target.palletCode })) + const candidateRows = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(scenario.relabelNo)}&sourceStage=RECEIVED`) + const targetIds = new Set(scenario.targets.map((target) => id(target.palletId))) + const rows = candidateRows.filter((row) => row.sourceStage === 'RELABEL' && targetIds.has(id(row.warehousePalletId))) + const marks = rows.flatMap((row) => String(row.relabelCartonMarks || '').split(',').filter(Boolean)) + assert(rows.length === targetIds.size, `${scenario.relabelNo} missing smart-dispatch pallet candidate`) + assert(rows.every((row) => !String(row.cartonMarks || '').trim()), `${scenario.relabelNo} exposed normal carton marks`) + assert(scenario.items.every((item) => marks.includes(item.fbaCartonMark)), `${scenario.relabelNo} missing relabel carton mark`) + const dispatch = await api(overseas, platformToken, 'POST', '/api/platform/v1/last-leg/dispatches/pre-arrange', { + planPalletIds: rows.map((row) => id(row.warehousePalletId)), route: `${marker}-${scenario.name}`, + relabelNo: scenario.relabelNo, + }) + scenario.dispatchNo = dispatch.dispatchNo + scenario.dispatchStatus = dispatch.status + scenario.relabelCartonMarks = marks + delete scenario.items + delete scenario.targets +} + +const ledger = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/inventory/ledger?customerId=${customerId}&page=1&size=100&businessType=RELABEL_PALLET_DISASSEMBLE`) +const ledgerRows = ledger.records.filter((row) => String(row.remark || '').includes(marker)) +assert(ledgerRows.some((row) => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger') +assert(ledgerRows.some((row) => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger') +assert(ledgerRows.every((row) => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason') +report.scenarios = [fresh, merged, split] +report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map((row) => Number(row.deltaPackages)) } +report.passed = true +report.finishedAt = new Date().toISOString() +await writeFile(output, JSON.stringify(report, null, 2), 'utf8') +console.log(JSON.stringify(report, null, 2)) diff --git a/scripts/pallet-location-test-env-acceptance.contract.test.mjs b/scripts/pallet-location-test-env-acceptance.contract.test.mjs new file mode 100644 index 000000000..579099afd --- /dev/null +++ b/scripts/pallet-location-test-env-acceptance.contract.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +import { + PALLET_LOCATION_SCENARIOS, + inspectCrossScenarioIsolation, + inspectScenarioReport, + parseMatrixArguments, +} from './pallet-location-test-env-acceptance.mjs' + +const source = await readFile(new URL('./pallet-location-test-env-acceptance.mjs', import.meta.url), 'utf8') + +test('pallet location matrix declares deterministic one, two, and three-image scenarios', () => { + assert.equal(PALLET_LOCATION_SCENARIOS.length, 5) + assert.deepEqual(PALLET_LOCATION_SCENARIOS.map((item) => item.expectedPalletCount), [1, 1, 1, 2, 3]) + assert.match(source, /--modules', 'orders,inbound-measurement'/) + assert.match(source, /--verify-pallet-location-guide', 'true'/) + assert.match(source, /--wait-split-package-count', '18'/) + assert.match(source, /--wait-split-package-count', '12'/) + assert.match(source, /--route-mode', 'random', '--wait-split-random-value', '2'/) + assert.match(source, /第3张确认后扩容只追加且既有槽身份不漂移/) + assert.match(source, /beforeSlots=1, afterSlots=2/) + assert.match(source, /beforeSlots=2, afterSlots=3/) +}) + +test('matrix options select known scenarios without duplicate or unknown names', () => { + const parsed = parseMatrixArguments([ + '--origin-warehouse-name', '测试仓', + '--scenarios', 'single-image-single-slot,two-image-incremental-growth', + '--output=reports/custom.json', + ]) + assert.equal(parsed.originWarehouseName, '测试仓') + assert.deepEqual(parsed.scenarioNames, ['single-image-single-slot', 'two-image-incremental-growth']) + assert.equal(parsed.output, 'reports/custom.json') + assert.throws(() => parseMatrixArguments(['--scenarios', 'unknown']), /未知场景/) + assert.throws(() => parseMatrixArguments(['--scenarios', 'measured-volume-growth,measured-volume-growth']), /重复场景/) +}) + +test('scenario inspection requires deletion release, refill reuse, WAIT_SPLIT, and guide identity', () => { + const scenario = PALLET_LOCATION_SCENARIOS[0] + const requiredNames = [ + '删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY', + '替代图片按新 resultId 复用原空槽', + '固定客户第 1 单停在待分柜', + '报告敏感信息扫描', + ] + const report = { + success: true, + marker: 'TEST', + summary: { total: 10, failed: 0 }, + createdResources: { + inboundMeasurement: { inboundId: '11', measurementId: '12', palletCount: 1, processStatus: 'WAIT_SPLIT' }, + inboundMeasurementLocationGuide: { + inboundId: '11', blockId: '13', reservationId: '14', locationId: '15', + deleteSucceeded: true, slotReused: true, + }, + }, + steps: [ + ...requiredNames.map((name) => ({ name, passed: true })), + { name: '第1张确认后整票库位块计数与槽身份完整', passed: true, + evidence: 'blockId=13, reserved=1, actual=1, empty=0, slots=1' }, + ], + } + const inspected = inspectScenarioReport(scenario, report, 0) + assert.equal(inspected.passed, true) + report.steps.find((step) => step.name.startsWith('删除只解绑')).passed = false + assert.equal(inspectScenarioReport(scenario, report, 0).passed, false) +}) + +test('scenario inspection detects naturally observed shared A0 from evidence without coupling to step wording', () => { + const scenario = PALLET_LOCATION_SCENARIOS[0] + const report = { + success: false, + createdResources: {}, + steps: [{ name: '正式数据 A0 共享溢出观测', passed: true, + evidence: 'sharedOverflow=true, slots=1:reservation:A0' }], + } + assert.equal(inspectScenarioReport(scenario, report, 1).resources.sharedOverflowObserved, true) +}) + +test('three-image scenario rejects a no-op growth assertion even when the base step passed', () => { + const scenario = PALLET_LOCATION_SCENARIOS.at(-1) + const report = { + success: true, + createdResources: { + inboundMeasurement: { palletCount: 3, processStatus: 'WAIT_SPLIT' }, + inboundMeasurementLocationGuide: { + blockId: '1', reservationId: '2', locationId: '3', deleteSucceeded: true, slotReused: true, + }, + }, + steps: [ + { name: '删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY', passed: true }, + { name: '替代图片按新 resultId 复用原空槽', passed: true }, + { name: '固定客户第 1 单停在待分柜', passed: true }, + { name: '报告敏感信息扫描', passed: true }, + { name: '第2张确认后扩容只追加且既有槽身份不漂移', passed: true, + evidence: 'beforeSlots=3, afterSlots=3' }, + { name: '第3张确认后扩容只追加且既有槽身份不漂移', passed: true, + evidence: 'beforeSlots=3, afterSlots=3' }, + ], + } + const inspected = inspectScenarioReport(scenario, report, 0) + assert.equal(inspected.assertions.requiredStepEvidence, false) + assert.deepEqual(inspected.mismatchedStepEvidence, scenario.requiredSteps) +}) + +test('cross-scenario inspection permits shared A0 location but rejects shared reservation or block', () => { + const results = [ + { resources: { inboundId: '1', blockId: '11', reservationId: '21', locationId: 'A0' } }, + { resources: { inboundId: '2', blockId: '12', reservationId: '22', locationId: 'A0' } }, + ] + assert.equal(inspectCrossScenarioIsolation(results).passed, true) + results[1].resources.reservationId = '21' + assert.equal(inspectCrossScenarioIsolation(results).passed, false) +}) + +test('matrix does not disable locations, execute SQL, deploy, or use a shell child process', () => { + assert.doesNotMatch(source, /location-status|batch-disable|UPDATE\s+|DELETE\s+FROM|INSERT\s+INTO/i) + assert.doesNotMatch(source, /jenkins|docker\s+compose|kubectl/i) + assert.match(source, /shell: false/) + assert.match(source, /A0 仅在普通库位自然不足时观测/) +}) diff --git a/scripts/pallet-location-test-env-acceptance.mjs b/scripts/pallet-location-test-env-acceptance.mjs new file mode 100644 index 000000000..39cd89d68 --- /dev/null +++ b/scripts/pallet-location-test-env-acceptance.mjs @@ -0,0 +1,248 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { basename, dirname, extname, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import process from 'node:process' + +export const PALLET_LOCATION_SCENARIOS = Object.freeze([ + { + name: 'single-image-single-slot', + description: '1 箱、1 张称重图,验证最小库位块与删除补传复用', + expectedPalletCount: 1, + args: ['--wait-split-package-count', '1', '--wait-split-random-value', '4'], + expectedGuideEvidence: /reserved=1, actual=1, empty=0, slots=1/, + }, + { + name: 'single-image-two-reserved-slots', + description: '18 箱、1 张称重图,验证体积预留 2 个连续槽且空槽不阻塞', + expectedPalletCount: 1, + args: ['--wait-split-package-count', '18', '--wait-split-random-value', '74'], + expectedGuideEvidence: /reserved=2, actual=1, empty=1, slots=2/, + }, + { + name: 'measured-volume-growth', + description: '12 箱、1 张称重图,验证实测体积跨档后扩为 2 个槽', + expectedPalletCount: 1, + args: ['--wait-split-package-count', '12', '--wait-split-random-value', '87'], + expectedGuideEvidence: /reserved=2, actual=1, empty=1, slots=2/, + }, + { + name: 'two-image-incremental-growth', + description: '2 张称重图,验证第二张只追加槽且原槽身份不漂移', + expectedPalletCount: 2, + args: ['--route-mode', 'random', '--wait-split-random-value', '1'], + requiredSteps: ['第2张确认后扩容只追加且既有槽身份不漂移'], + }, + { + name: 'three-image-incremental-growth', + description: '小体积订单上传 3 张称重图,强制验证库位块 1→2→3 连续扩容', + expectedPalletCount: 3, + args: ['--route-mode', 'random', '--wait-split-random-value', '2'], + requiredSteps: [ + '第2张确认后扩容只追加且既有槽身份不漂移', + '第3张确认后扩容只追加且既有槽身份不漂移', + ], + requiredStepEvidence: [ + { name: '第2张确认后扩容只追加且既有槽身份不漂移', pattern: /beforeSlots=1, afterSlots=2/ }, + { name: '第3张确认后扩容只追加且既有槽身份不漂移', pattern: /beforeSlots=2, afterSlots=3/ }, + ], + }, +]) + +const COMMON_REQUIRED_STEPS = Object.freeze([ + '删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY', + '替代图片按新 resultId 复用原空槽', + '固定客户第 1 单停在待分柜', + '报告敏感信息扫描', +]) + +export function parseMatrixArguments(argv) { + const result = { + help: false, + originWarehouseName: '义乌仓', + scenarioNames: PALLET_LOCATION_SCENARIOS.map((item) => item.name), + output: '', + } + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + if (argument === '--help') { result.help = true; continue } + if (!argument.startsWith('--')) throw new Error(`无法识别的参数: ${argument}`) + const separator = argument.indexOf('=') + const name = separator >= 0 ? argument.slice(2, separator) : argument.slice(2) + const inline = separator >= 0 ? argument.slice(separator + 1) : undefined + const value = inline === undefined ? argv[++index] : inline + if (!value || value.startsWith('--')) throw new Error(`参数 --${name} 缺少值`) + if (name === 'origin-warehouse-name') result.originWarehouseName = value.trim() + else if (name === 'scenarios') result.scenarioNames = value.split(',').map((item) => item.trim()).filter(Boolean) + else if (name === 'output') result.output = value + else throw new Error(`无法识别的参数: --${name}`) + } + if (!result.originWarehouseName) throw new Error('origin-warehouse-name不能为空') + if (result.scenarioNames.length === 0) throw new Error('scenarios至少包含一个场景') + const available = new Set(PALLET_LOCATION_SCENARIOS.map((item) => item.name)) + const unknown = result.scenarioNames.filter((name) => !available.has(name)) + if (unknown.length > 0) throw new Error(`未知场景: ${unknown.join(',')}`) + if (new Set(result.scenarioNames).size !== result.scenarioNames.length) { + throw new Error('scenarios不能包含重复场景') + } + return result +} + +export function inspectScenarioReport(scenario, report, exitCode = 0) { + const steps = Array.isArray(report?.steps) ? report.steps : [] + const stepByName = new Map(steps.map((step) => [step.name, step])) + const requiredSteps = [...COMMON_REQUIRED_STEPS, ...(scenario.requiredSteps || [])] + const missingOrFailedSteps = requiredSteps.filter((name) => stepByName.get(name)?.passed !== true) + const mismatchedStepEvidence = (scenario.requiredStepEvidence || []) + .filter(({ name, pattern }) => !pattern.test(String(stepByName.get(name)?.evidence || ''))) + .map(({ name }) => name) + const measurement = report?.createdResources?.inboundMeasurement || {} + const guide = report?.createdResources?.inboundMeasurementLocationGuide || {} + const guideEvidence = steps + .filter((step) => /整票库位块计数与槽身份完整$/.test(String(step.name || ''))) + .map((step) => String(step.evidence || '')) + const guideEvidenceMatched = !scenario.expectedGuideEvidence + || guideEvidence.some((evidence) => scenario.expectedGuideEvidence.test(evidence)) + const assertions = { + childExitCode: exitCode === 0, + childReportSuccess: report?.success === true, + requiredSteps: missingOrFailedSteps.length === 0, + requiredStepEvidence: mismatchedStepEvidence.length === 0, + expectedPalletCount: Number(measurement.palletCount) === scenario.expectedPalletCount, + waitSplitReached: measurement.processStatus === 'WAIT_SPLIT', + locationIdentityRecorded: Boolean(guide.blockId && guide.reservationId && guide.locationId), + deletedSlotReused: guide.deleteSucceeded === true && guide.slotReused === true, + expectedGuideShape: guideEvidenceMatched, + } + const passed = Object.values(assertions).every(Boolean) + const sharedOverflowObserved = steps.some((step) => + /sharedOverflow=true/.test(String(step.evidence || ''))) + return { + name: scenario.name, + description: scenario.description, + passed, + assertions, + missingOrFailedSteps, + mismatchedStepEvidence, + summary: report?.summary || null, + resources: { + marker: report?.marker || null, + inboundId: measurement.inboundId || guide.inboundId || null, + measurementId: measurement.measurementId || null, + blockId: guide.blockId || null, + reservationId: guide.reservationId || null, + locationId: guide.locationId || null, + palletCount: measurement.palletCount ?? null, + processStatus: measurement.processStatus || null, + sharedOverflowObserved, + }, + } +} + +export function inspectCrossScenarioIsolation(results) { + const fields = ['inboundId', 'blockId', 'reservationId'] + const assertions = Object.fromEntries(fields.map((field) => { + const values = results.map((result) => result.resources?.[field]).filter(Boolean).map(String) + return [`unique${field[0].toUpperCase()}${field.slice(1)}`, values.length === results.length + && new Set(values).size === values.length] + })) + return { passed: Object.values(assertions).every(Boolean), assertions } +} + +function usage() { + console.log(`用法: + node scripts/pallet-location-test-env-acceptance.mjs [选项] + +选项: + --origin-warehouse-name <名称> 固定国内收货仓,默认“义乌仓” + --scenarios <逗号列表> 只执行指定场景,默认执行全部 + --output <报告路径> 汇总报告路径;明细写入同名 -details 目录 + --help 显示帮助 + +可选场景: + ${PALLET_LOCATION_SCENARIOS.map((item) => `${item.name}: ${item.description}`).join('\n ')}`) +} + +async function runChild(scriptPath, args) { + return await new Promise((resolvePromise) => { + const child = spawn(process.execPath, [scriptPath, ...args], { + cwd: process.cwd(), env: process.env, stdio: 'inherit', shell: false, + }) + child.once('error', (error) => resolvePromise({ exitCode: 1, error: error.message })) + child.once('exit', (code, signal) => resolvePromise({ + exitCode: Number.isInteger(code) ? code : 1, + error: signal ? `子进程被信号 ${signal} 终止` : null, + })) + }) +} + +async function main() { + const options = parseMatrixArguments(process.argv.slice(2)) + if (options.help) { usage(); return } + const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14) + const outputPath = resolve(options.output || `reports/pallet-location-http-matrix-${timestamp}.json`) + const extension = extname(outputPath) + const detailsName = `${basename(outputPath, extension)}-details` + const detailsDir = resolve(dirname(outputPath), detailsName) + const runnerPath = resolve('scripts/admin-test-env-acceptance.mjs') + await mkdir(detailsDir, { recursive: true }) + + const selected = options.scenarioNames.map((name) => + PALLET_LOCATION_SCENARIOS.find((scenario) => scenario.name === name)) + const startedAt = new Date().toISOString() + const results = [] + for (const scenario of selected) { + const scenarioReportPath = resolve(detailsDir, `${scenario.name}.json`) + console.log(`\n[PALLET-LOCATION] START ${scenario.name}: ${scenario.description}`) + const child = await runChild(runnerPath, [ + '--modules', 'orders,inbound-measurement', + '--origin-warehouse-name', options.originWarehouseName, + '--verify-pallet-location-guide', 'true', + '--output', scenarioReportPath, + ...scenario.args, + ]) + let report = null + let reportReadError = child.error + try { report = JSON.parse(await readFile(scenarioReportPath, 'utf8')) } + catch (error) { reportReadError ||= error instanceof Error ? error.message : String(error) } + const inspected = inspectScenarioReport(scenario, report, child.exitCode) + results.push({ ...inspected, reportPath: scenarioReportPath, reportReadError }) + console.log(`[PALLET-LOCATION] ${inspected.passed ? 'PASS' : 'FAIL'} ${scenario.name}`) + } + + const isolation = inspectCrossScenarioIsolation(results) + const success = results.every((result) => result.passed) && isolation.passed + const aggregate = { + reportName: '订单测量与连续库位真实 HTTP 扩展矩阵', + generatedAt: new Date().toISOString(), startedAt, success, + originWarehouseName: options.originWarehouseName, + summary: { + totalScenarios: results.length, + passedScenarios: results.filter((result) => result.passed).length, + failedScenarios: results.filter((result) => !result.passed).length, + sharedOverflowObserved: results.some((result) => result.resources.sharedOverflowObserved), + }, + isolation, + results, + coverageNotes: [ + '每个场景使用独立订单、入库单和库位块,并验证跨场景 reservation 不串票。', + 'A0 仅在普通库位自然不足时观测;脚本不会批量停用普通库位制造容量不足。', + 'Warehouse 调用异常时不回滚订单测量主流程由服务单元测试覆盖;真实 HTTP 矩阵不关闭共享服务做故障注入。', + '脚本不执行 SQL、不部署服务、不直接操作数据库。', + ], + } + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, `${JSON.stringify(aggregate, null, 2)}\n`, 'utf8') + console.log(`\nREPORT=${outputPath}`) + console.log(`RESULT=${success ? 'PASS' : 'FAIL'} scenarios=${results.length} passed=${aggregate.summary.passedScenarios} failed=${aggregate.summary.failedScenarios}`) + process.exitCode = success ? 0 : 1 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + }) +} diff --git a/scripts/pda-final-complete-diagnostic.mjs b/scripts/pda-final-complete-diagnostic.mjs new file mode 100644 index 000000000..d83ba5f5c --- /dev/null +++ b/scripts/pda-final-complete-diagnostic.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import process from 'node:process' + +const REQUIRED_EVIDENCE = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] +const value = (name, fallback = '') => { + const prefix = `--${name}=` + const inline = process.argv.find((item) => item.startsWith(prefix)) + if (inline) return inline.slice(prefix.length) + const index = process.argv.indexOf(`--${name}`) + return index >= 0 ? process.argv[index + 1] || fallback : fallback +} +const gateway = value('gateway', process.env.ADMIN_TEST_GATEWAY || 'http://8.163.30.128:9000').replace(/\/$/, '') +const username = value('username', process.env.ADMIN_TEST_USERNAME || 'platform_admin') +const password = value('password', process.env.ADMIN_TEST_PASSWORD) +const taskIds = value('task-ids').split(',').map((item) => item.trim()).filter(Boolean) +const seedEvidence = process.argv.includes('--seed-evidence') +const stamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14) +const marker = `PDA-FINAL-DIAG-${stamp}` +const output = resolve(value('output', `reports/pda-final-complete-diagnostic-${stamp}.json`)) + +if (!password || taskIds.length === 0) { + throw new Error('required: ADMIN_TEST_PASSWORD (or --password) and --task-ids') +} + +let token = '' +const calls = [] +async function call(method, path, body, headers = {}) { + let response + try { + response = await fetch(`${gateway}${path}`, { + method, + headers: { + Accept: 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), + ...headers, + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(60_000), + }) + const raw = await response.text() + let payload + try { payload = raw ? JSON.parse(raw) : null } catch { payload = { raw: raw.slice(0, 1000) } } + const result = { + method, path, ...(body === undefined ? {} : { body }), http: response.status, + code: payload?.code == null ? null : String(payload.code), message: payload?.message || null, + requestId: payload?.requestId || response.headers.get('x-request-id'), + traceId: payload?.traceId || response.headers.get('x-trace-id'), data: payload?.data ?? null, + } + calls.push(result) + return result + } catch (error) { + const result = { method, path, ...(body === undefined ? {} : { body }), http: 0, code: null, + message: String(error), requestId: null, traceId: null, data: null } + calls.push(result) + return result + } +} +async function uploadEvidence(tenantId, inboundId, evidenceType) { + const path = `/api/platform/v1/files/loading-evidence/${encodeURIComponent(tenantId)}/${encodeURIComponent(inboundId)}?evidenceType=${encodeURIComponent(evidenceType)}` + 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]) + const fileName = `${marker}-${inboundId}-${evidenceType.toLowerCase()}.png` + const form = new FormData() + form.append('file', new Blob([png], { type: 'image/png' }), fileName) + try { + const response = await fetch(`${gateway}${path}`, { + method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, + body: form, signal: AbortSignal.timeout(60_000), + }) + const raw = await response.text() + let payload + try { payload = raw ? JSON.parse(raw) : null } catch { payload = { raw: raw.slice(0, 1000) } } + const result = { method: 'POST', path, body: { fileName, evidenceType }, http: response.status, + code: payload?.code == null ? null : String(payload.code), message: payload?.message || null, + requestId: payload?.requestId || response.headers.get('x-request-id'), + traceId: payload?.traceId || response.headers.get('x-trace-id'), data: payload?.data ?? null } + calls.push(result) + return result + } catch (error) { + const result = { method: 'POST', path, body: { fileName, evidenceType }, http: 0, code: null, + message: String(error), requestId: null, traceId: null, data: null } + calls.push(result) + return result + } +} +const ok = (response) => response?.http === 200 && response?.code === '200' +const id = (input) => input == null ? '' : String(input) + +const login = await call('POST', '/api/auth/login', { loginId: username, password }) +login.body = { loginId: username, password: '[REDACTED]' } +if (!ok(login) || !login.data?.accessToken) { + login.data = null + const failedReport = { marker, startedAt: new Date().toISOString(), gateway, taskIds, stopped: true, + fatal: `login failed: HTTP=${login.http}, code=${login.code}, message=${login.message || 'missing'}`, + results: [], calls } + await mkdir(dirname(output), { recursive: true }) + await writeFile(output, `${JSON.stringify(failedReport, null, 2)}\n`, 'utf8') + console.log(JSON.stringify({ output, marker, stopped: true, fatal: failedReport.fatal }, null, 2)) + process.exit(1) +} +token = login.data.accessToken +login.data = { authenticated: true } + +const pendingResponse = await call('GET', '/api/platform/v1/first-leg/pda-loading/tasks/pending') +const pendingRows = ok(pendingResponse) && Array.isArray(pendingResponse.data) ? pendingResponse.data : [] +const results = [] +let stopped = false + +for (const taskId of taskIds) { + if (stopped) break + const pending = pendingRows.find((item) => id(item?.taskId) === taskId) || null + const warehouseId = id(pending?.warehouseId) + const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}` + const detailResponse = warehouseId + ? await call('GET', `${taskPath}?warehouseId=${encodeURIComponent(warehouseId)}`) + : { http: 0, code: null, message: 'task missing from pending list; warehouseId unavailable', data: null } + const detail = detailResponse.data + const planId = id(detail?.loadingPlanId || pending?.loadingPlanId) + const planPath = `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}` + const planBefore = planId ? await call('GET', planPath) : null + const owners = [...new Map((detail?.items || []).map((item) => [ + `${id(item?.tenantId)}:${id(item?.inboundId)}`, + { tenantId: id(item?.tenantId), inboundId: id(item?.inboundId) }, + ])).values()].filter((item) => item.tenantId && item.inboundId) + const inboundBefore = [] + const evidenceBefore = [] + for (const owner of owners) { + inboundBefore.push(await call('GET', + `/api/platform/inbounds/${encodeURIComponent(owner.inboundId)}?tenantId=${encodeURIComponent(owner.tenantId)}`)) + evidenceBefore.push(await call('GET', + `/api/platform/v1/inbounds/${encodeURIComponent(owner.inboundId)}/loading/evidence?tenantId=${encodeURIComponent(owner.tenantId)}`)) + } + const evidenceSeed = [] + if (seedEvidence) { + for (let ownerIndex = 0; ownerIndex < owners.length; ownerIndex += 1) { + const owner = owners[ownerIndex] + const activeTypes = new Set((evidenceBefore[ownerIndex]?.data?.evidences || []) + .filter((item) => item?.status !== 'VOID').map((item) => item?.evidenceType)) + for (const evidenceType of REQUIRED_EVIDENCE.filter((type) => !activeTypes.has(type))) { + const upload = await uploadEvidence(owner.tenantId, owner.inboundId, evidenceType) + const fileId = id(upload.data?.fileId) + if (!ok(upload) || !fileId) { + evidenceSeed.push({ evidenceType, upload, bind: null }) + break + } + const bindPath = `/api/platform/v1/inbounds/${encodeURIComponent(owner.inboundId)}/loading/evidence?tenantId=${encodeURIComponent(owner.tenantId)}` + const bindBody = { expectedVersion: 0, evidenceType, fileId, + fileName: 'client-must-not-override.png', + sealNo: evidenceType === 'SEAL' ? `SEAL-${stamp}` : null, remark: marker } + const bind = await call('POST', bindPath, bindBody, + { 'Idempotency-Key': `${marker}-${owner.inboundId}-${evidenceType}`.slice(0, 128) }) + evidenceSeed.push({ evidenceType, upload, bind }) + if (!ok(bind)) break + } + } + } + const evidenceCurrent = [] + for (const owner of owners) { + evidenceCurrent.push(await call('GET', + `/api/platform/v1/inbounds/${encodeURIComponent(owner.inboundId)}/loading/evidence?tenantId=${encodeURIComponent(owner.tenantId)}`)) + } + const unresolved = (detail?.items || []).filter((item) => ['PENDING', 'PICKING'].includes(item?.itemStatus)) + const evidenceReady = evidenceCurrent.length > 0 && evidenceCurrent.every((response) => { + const activeTypes = new Set((response.data?.evidences || []) + .filter((item) => item?.status !== 'VOID').map((item) => item?.evidenceType)) + return ok(response) && REQUIRED_EVIDENCE.every((type) => activeTypes.has(type)) + }) + const ready = ok(pendingResponse) && ok(detailResponse) && pending + && pending.workflowStatus === 'AWAITING_REVIEW' + && detail?.workflowStatus === 'AWAITING_REVIEW' + && Number(pending?.pendingItemCount) === 0 && unresolved.length === 0 + && Number.isInteger(Number(detail?.version)) && evidenceReady + const item = { + taskId, pending, detail: detail ? { + id: detail.id, version: detail.version, workflowStatus: detail.workflowStatus, + taskStatus: detail.taskStatus, inboundId: detail.inboundId, loadingPlanId: detail.loadingPlanId, + containerBookingId: detail.containerBookingId, + items: (detail.items || []).map((row) => ({ id: row.id, tenantId: row.tenantId, + inboundId: row.inboundId, itemStatus: row.itemStatus, plannedPackageCount: row.plannedPackageCount, + pickedPackageCount: row.pickedPackageCount, version: row.version })), + } : null, + owners, evidenceReady, evidenceSeed: evidenceSeed.map((entry) => ({ evidenceType: entry.evidenceType, + upload: entry.upload ? { http: entry.upload.http, code: entry.upload.code, requestId: entry.upload.requestId, + traceId: entry.upload.traceId, fileId: entry.upload.data?.fileId || null } : null, + bind: entry.bind ? { http: entry.bind.http, code: entry.bind.code, requestId: entry.bind.requestId, + traceId: entry.bind.traceId } : null })), ready, complete: null, + } + if (!ready) { + item.blocker = `pending/detail/evidence not ready; pendingWorkflow=${pending?.workflowStatus || 'missing'}, detailWorkflow=${detail?.workflowStatus || 'missing'}, pendingItemCount=${pending?.pendingItemCount ?? 'missing'}, unresolved=${unresolved.length}, evidenceReady=${evidenceReady}` + results.push(item) + stopped = true + continue + } + const body = { + expectedTaskVersion: Number(detail.version), + operationId: `${marker}-${taskId}`.slice(0, 128), remark: marker, + } + const complete = await call('POST', `${taskPath}/complete`, body, + { 'Idempotency-Key': `${marker}-${taskId}`.slice(0, 128) }) + item.complete = complete + item.taskAfter = await call('GET', `${taskPath}?warehouseId=${encodeURIComponent(warehouseId)}`) + item.planAfter = await call('GET', planPath) + item.inboundAfter = [] + for (const owner of owners) { + item.inboundAfter.push(await call('GET', + `/api/platform/inbounds/${encodeURIComponent(owner.inboundId)}?tenantId=${encodeURIComponent(owner.tenantId)}`)) + } + results.push(item) + if (!ok(complete)) stopped = true +} + +const report = { marker, startedAt: new Date().toISOString(), gateway, taskIds, seedEvidence, stopped, results, + calls: calls.map((entry) => entry.path === '/api/auth/login' ? { ...entry, data: { authenticated: true } } : entry) } +await mkdir(dirname(output), { recursive: true }) +await writeFile(output, `${JSON.stringify(report, null, 2)}\n`, 'utf8') +console.log(JSON.stringify({ output, marker, stopped, results: results.map((item) => ({ + taskId: item.taskId, ready: item.ready, blocker: item.blocker || null, + complete: item.complete ? { http: item.complete.http, code: item.complete.code, + message: item.complete.message, requestId: item.complete.requestId, traceId: item.complete.traceId } : null, +})) }, null, 2)) diff --git a/scripts/phase1-onboarding-order-e2e.ps1 b/scripts/phase1-onboarding-order-e2e.ps1 index bd5d7a50d..083e164e9 100644 --- a/scripts/phase1-onboarding-order-e2e.ps1 +++ b/scripts/phase1-onboarding-order-e2e.ps1 @@ -3,12 +3,16 @@ param( [string]$IamUrl = 'http://127.0.0.1:9501', [string]$FileUrl = 'http://127.0.0.1:9506', [string]$OrderUrl = 'http://127.0.0.1:9504', + [string]$WarehouseUrl = 'http://127.0.0.1:9505', [string]$ImagePath, + [long]$PublicReceivingWarehouseId = 0, + [long]$PublicDestinationWarehouseId = 0, [string]$PlatformPassword = 'ChangeMe123!' ) $ErrorActionPreference = 'Stop' -function Invoke-Json($method, $url, $body = $null, $token = $null) { +function Invoke-Json($method, $url, $body = $null, $token = $null, $extraHeaders = @{}) { $headers = @{}; if ($token) { $headers.Authorization = "Bearer $token" } + foreach ($name in $extraHeaders.Keys) { $headers[$name] = $extraHeaders[$name] } $args = @{ Method=$method; Uri=$url; Headers=$headers; TimeoutSec=20 } if ($null -ne $body) { $args.ContentType='application/json'; $args.Body=$body | ConvertTo-Json -Depth 12 } Invoke-RestMethod @args @@ -20,6 +24,11 @@ function Invoke-Status($method, $url, $body, $token) { function Assert-Equal($name, $actual, $expected) { if ($actual -ne $expected) { throw "$name expected=$expected actual=$actual" } } +function Assert-Success($name, $response) { + if ($null -eq $response -or [string]$response.code -notin @('200', '0')) { + throw "$name failed code=$($response.code) message=$($response.message)" + } +} $stamp = Get-Date -Format 'MMddHHmmss' $username = "phase1_$stamp"; $password = 'Phase1Test123!' @@ -29,49 +38,106 @@ $registration = Invoke-Json POST "$AuthUrl/api/auth/register" @{ countryCode='CN'; address='No. 1 Test Road, Nanshan, Shenzhen' } Assert-Equal 'registration status' $registration.data.tenantStatus 'PROFILE_REQUIRED' +Assert-Equal 'registration next step' $registration.data.nextStep 'LOGIN_AND_COMPLETE_QUALIFICATIONS' $tenantId = [long]$registration.data.tenantId $merchantLogin = Invoke-Json POST "$AuthUrl/api/auth/login" @{username=$username;password=$password} +Assert-Success 'merchant login' $merchantLogin $merchantToken = $merchantLogin.data.accessToken +$platformLogin = Invoke-Json POST "$AuthUrl/api/auth/login" @{username='platform_admin';password=$PlatformPassword} +Assert-Success 'platform login' $platformLogin +$platformToken = $platformLogin.data.accessToken +$partner = Invoke-Json POST "$WarehouseUrl/api/platform/v1/partners?tenantId=$tenantId" @{ + partnerCode="E2E-PARTNER-$stamp"; partnerName="Phase One Partner $stamp"; partnerType='SHIPPER' + types=@('SHIPPER','CONSIGNEE'); contactName='Test Partner'; contactPhone='13800138000' + contactEmail="partner_$stamp@example.com"; countryCode='CN'; province='Guangdong'; city='Shenzhen' + district='Nanshan'; addressLine='No. 1 Test Road'; postalCode='518000'; status='ACTIVE'; remark='Phase one E2E' +} $platformToken +Assert-Success 'create tenant partner' $partner +$partnerId = [long]$partner.data +$warehouse = Invoke-Json POST "$WarehouseUrl/api/platform/v1/warehouse-admin" @{ + tenantId=$tenantId; warehouseCode="E2E-WH-$stamp"; warehouseName="Phase One Receiving Warehouse $stamp" + warehouseType='DOMESTIC'; countryCode='CN'; timezone='Asia/Shanghai'; contactName='Test Warehouse' + contactPhone='13800138000'; contactEmail="warehouse_$stamp@example.com"; status='ACTIVE'; remark='Phase one E2E' + address=@{addressType='WAREHOUSE';countryCode='CN';stateProvince='Guangdong';city='Shenzhen';district='Nanshan';postalCode='518000';addressLine1='No. 1 Test Road';addressLine2=$null;latitude=$null;longitude=$null} + capabilities=@(@{capabilityCode='RECEIVING';capabilityName='Receiving';enabled=$true;cutoffTime='16:00:00';description='Phase one E2E receiving'}) +} $platformToken +Assert-Success 'create tenant receiving warehouse' $warehouse +$warehouseId = [long]$warehouse.data +if ($PublicReceivingWarehouseId -gt 0) { $warehouseId = $PublicReceivingWarehouseId } + $order = @{ - customerId=$tenantId; customerName="Phase One Enterprise $stamp"; transportMode='SEA'; tradeTerm='FOB' - destinationCountryCode='US'; destinationWarehouseId=1001; destinationWarehouseName='ONT8' + customerId=$partnerId; customerName="Phase One Partner $stamp"; transportMode='SEA'; tradeTerm='FOB' + destinationCountryCode='US'; destinationWarehouseId=$(if ($PublicDestinationWarehouseId -gt 0) {$PublicDestinationWarehouseId} else {$null}); destinationWarehouseName='ONT8' expectedShipDate='2026-08-15'; customerRemark='Phase one E2E'; pickupMethod='PICKUP' - receivingWarehouseId=2001; receivingWarehouseName='Shenzhen Receiving Warehouse'; channelCode='SEA-US-FBA' - channelName='US Sea Channel'; taxMode='BUY_DECLARATION'; containerMode='LCL'; customsInspection=$false + receivingWarehouseId=$warehouseId; receivingWarehouseName="Phase One Receiving Warehouse $stamp"; channelCode='SEA-US-FBA' + channelName='US Sea Channel'; carrierId=9900001; carrierName='模拟海运船司'; taxMode='BUY_DECLARATION'; containerMode='LCL'; customsInspection=$false destinationType='FBA'; amazonWarehouseCode='ONT8'; shipmentId="FBA$stamp"; referenceId="REF$stamp" sender=@{companyName="Phase One Enterprise $stamp";contactName='Test Sender';phone='13800138000';email="p$stamp@example.com";countryCode='CN';province='Guangdong';city='Shenzhen';district='Nanshan';addressLine='No. 1 Test Road';postalCode='518000'} receiver=@{companyName='Amazon';contactName='ONT8';phone='1234567890';email='ont8@example.com';countryCode='US';province='CA';city='Moreno Valley';district='Riverside';addressLine='24300 Nandina Ave';postalCode='92551'} cargoes=@(@{cargoName='Bluetooth Speaker';hsCode='8518210000';packageType='CARTON';packageCount=2;grossWeightKg=20.5;volumeCbm=0.2;lengthCm=50;widthCm=40;heightCm=30;declaredValue=200;currency='USD';dangerousGoods=$false;cargoRemark='Phase one test';skuNo="SKU$stamp";upcNo='123456789012';cargoNameEn='Bluetooth Speaker';customsHsCode='8518210000';unitsPerPackage=10;totalQuantity=20;netWeightKg=18;unitDeclaredValue=10;material='Plastic';usage='Audio';model='P1';brand='Demo'}) } -$preApprovalStatus = Invoke-Status POST "$OrderUrl/api/merchant/v1/orders/first-leg" $order $merchantToken -Assert-Equal 'pre-approval order status' $preApprovalStatus 403 +$draftKey = "phase1-draft-$stamp" +$submitKey = "phase1-submit-$stamp" +$draft = Invoke-Json POST "$OrderUrl/api/merchant/v1/orders/first-leg" $order $merchantToken @{'Idempotency-Key'=$draftKey} +Assert-Success 'pre-approval draft create' $draft +$orderId = [long]$draft.data +$preApproval = Invoke-Json POST "$OrderUrl/api/merchant/v1/orders/first-leg/$orderId/submit" $null $merchantToken @{'Idempotency-Key'=$submitKey} +Assert-Equal 'pre-approval submit code' $preApproval.code 'A0403' -$uploadJson = & curl.exe -sS -X POST "$FileUrl/api/merchant/v1/files/qualifications" ` - -H "Authorization: Bearer $merchantToken" -F "file=@$ImagePath;type=image/png" ` - -F "certificateNo=91310101$stamp" -if ($LASTEXITCODE -ne 0) { throw 'qualification upload transport failed' } -$upload = $uploadJson | ConvertFrom-Json -Assert-Equal 'upload API code' $upload.code '0' -Assert-Equal 'upload tenant status' $upload.data.tenantStatus 'PENDING' +$creditCode = "91$((Get-Date).ToString('yyyyMMddHHmmss'))A1" +$qualificationTypes = @('BUSINESS_LICENSE', 'LEGAL_PERSON_ID', 'FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE') +$uploads = @{} +foreach ($qualificationType in $qualificationTypes) { + $query = "qualificationType=$qualificationType&replace=false" + if ($qualificationType -eq 'BUSINESS_LICENSE') { $query += "&certificateNo=$creditCode" } + $uploadJson = & curl.exe -sS -X POST "$FileUrl/api/merchant/v1/files/qualifications?$query" ` + -H "Authorization: Bearer $merchantToken" -F "file=@$ImagePath;type=image/png" + if ($LASTEXITCODE -ne 0) { throw "qualification upload transport failed: $qualificationType" } + $upload = $uploadJson | ConvertFrom-Json + Assert-Success "upload $qualificationType" $upload + Assert-Equal "upload $qualificationType tenant status" $upload.data.tenantStatus 'PROFILE_REQUIRED' + $uploads[$qualificationType] = $upload +} + +$merchantQualifications = Invoke-Json GET "$IamUrl/api/merchant/v1/iam/tenant/qualifications" $null $merchantToken +Assert-Success 'merchant qualification list' $merchantQualifications +$pendingTypes = @($merchantQualifications.data | Where-Object { $_.status -eq 'PENDING' } | ForEach-Object { $_.qualificationType } | Sort-Object -Unique) +Assert-Equal 'pending qualification count' $pendingTypes.Count 4 +foreach ($qualificationType in $qualificationTypes) { + if ($qualificationType -notin $pendingTypes) { throw "missing pending qualification: $qualificationType" } +} + +$submitted = Invoke-Json POST "$IamUrl/api/merchant/v1/iam/tenant/qualifications/submit-review" $null $merchantToken +Assert-Success 'submit enterprise review' $submitted +Assert-Equal 'submitted tenant status' $submitted.data.status 'PENDING' -$platformLogin = Invoke-Json POST "$AuthUrl/api/auth/login" @{username='platform_admin';password=$PlatformPassword} -$platformToken = $platformLogin.data.accessToken $reviews = Invoke-Json GET "$IamUrl/api/platform/v1/iam/reviews" $null $platformToken +Assert-Success 'platform review list' $reviews $review = @($reviews.data) | Where-Object { [long]$_.tenantId -eq $tenantId } | Select-Object -First 1 if ($null -eq $review) { throw 'pending review not found' } $qualifications = Invoke-Json GET "$IamUrl/api/platform/v1/iam/tenants/$tenantId/qualifications" $null $platformToken -Assert-Equal 'qualification count' @($qualifications.data).Count 1 -$fileName = Split-Path $upload.data.documentUrl -Leaf +Assert-Success 'platform qualification list' $qualifications +Assert-Equal 'qualification count' @($qualifications.data).Count 4 +$fileName = Split-Path $uploads['BUSINESS_LICENSE'].data.documentUrl -Leaf $preview = Invoke-WebRequest -UseBasicParsing -Uri "$FileUrl/api/platform/v1/files/qualifications/$tenantId/$fileName" ` -Headers @{Authorization="Bearer $platformToken"} -TimeoutSec 20 Assert-Equal 'platform preview status' $preview.StatusCode 200 -Invoke-Json POST "$IamUrl/api/platform/v1/iam/reviews/$($review.id)/approve" @{comment='Phase one E2E approved'} $platformToken | Out-Null +$approval = Invoke-Json POST "$IamUrl/api/platform/v1/iam/reviews/$($review.id)/approve" @{comment='Phase one E2E approved'} $platformToken +Assert-Success 'approve enterprise review' $approval +Assert-Equal 'registered owner retained' $approval.data.administratorCreated $false -$created = Invoke-Json POST "$OrderUrl/api/merchant/v1/orders/first-leg" $order $merchantToken -$orderId = [long]$created.data -Invoke-Json POST "$OrderUrl/api/merchant/v1/orders/first-leg/$orderId/submit" $null $merchantToken | Out-Null +$approvedQualifications = Invoke-Json GET "$IamUrl/api/merchant/v1/iam/tenant/qualifications" $null $merchantToken +Assert-Success 'approved qualification list' $approvedQualifications +$approvedTypes = @($approvedQualifications.data | Where-Object { $_.status -eq 'APPROVED' } | ForEach-Object { $_.qualificationType } | Sort-Object -Unique) +Assert-Equal 'approved qualification count' $approvedTypes.Count 4 +$merchantProfile = Invoke-Json GET "$IamUrl/api/merchant/v1/iam/tenant/profile" $null $merchantToken +Assert-Success 'merchant profile after approval' $merchantProfile +Assert-Equal 'active tenant status' $merchantProfile.data.status 'ACTIVE' + +$approvedSubmit = Invoke-Json POST "$OrderUrl/api/merchant/v1/orders/first-leg/$orderId/submit" $null $merchantToken @{'Idempotency-Key'=$submitKey} +Assert-Success 'approved order submit' $approvedSubmit $detail = Invoke-Json GET "$OrderUrl/api/merchant/v1/orders/first-leg/$orderId" $null $merchantToken Assert-Equal 'submitted order status' $detail.data.order.status 'SUBMITTED' -[pscustomobject]@{username=$username;tenantId=$tenantId;registerStatus='PROFILE_REQUIRED';preApprovalOrderHttp=$preApprovalStatus;uploadStatus='PENDING';reviewId=$review.id;previewHttp=200;orderId=$orderId;finalOrderStatus='SUBMITTED'} | ConvertTo-Json +[pscustomobject]@{username=$username;tenantId=$tenantId;registerStatus='PROFILE_REQUIRED';partnerId=$partnerId;warehouseId=$warehouseId;draftCreatedBeforeApproval=$true;preApprovalSubmitCode=$preApproval.code;pendingQualificationCount=4;reviewId=$review.id;previewHttp=200;approvedQualificationCount=4;orderId=$orderId;finalOrderStatus='SUBMITTED'} | ConvertTo-Json diff --git a/scripts/phase2-test-env-acceptance.ps1 b/scripts/phase2-test-env-acceptance.ps1 new file mode 100644 index 000000000..d6e30f771 --- /dev/null +++ b/scripts/phase2-test-env-acceptance.ps1 @@ -0,0 +1,446 @@ +param( + [string]$GatewayBaseUrl = 'http://8.163.30.128:9000', + [string]$PlatformUsername = 'platform_admin', + [string]$MerchantUsername = 'merchant_admin', + [string]$PlatformPassword = 'ChangeMe123!', + [string]$MerchantPassword = 'ChangeMe123!', + [long]$TenantId = 10001, + [string]$OutputPath = '' +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$stamp = Get-Date -Format 'yyyyMMddHHmmss' +$marker = "API-E2E-$stamp" +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputPath = Join-Path $repoRoot "reports\二期测试环境API验收-$stamp.json" +} + +$steps = [System.Collections.Generic.List[object]]::new() +$ids = [ordered]@{ tenantId = [string]$TenantId; qualificationReviewId = $null; warehouseId = $null; carrierId = $null; customerRuleId = $null; carrierRateId = $null; orderId = $null; inboundId = $null; measurementId = $null; weightResultId = $null; fileId = $null; feeId = $null } +$versions = [ordered]@{ inbound = $null; measurement = $null; weightResult = $null } +$fatal = $null +$billingDefect = $null + +function New-Key([string]$Action) { + return "$marker-$Action-$([guid]::NewGuid().ToString('N'))" +} + +function Invoke-Api( + [string]$Method, + [string]$Path, + [string]$Token = '', + [object]$Body = $null, + [hashtable]$ExtraHeaders = @{} +) { + $headers = @{} + if (-not [string]::IsNullOrWhiteSpace($Token)) { $headers.Authorization = "Bearer $Token" } + foreach ($entry in $ExtraHeaders.GetEnumerator()) { $headers[$entry.Key] = $entry.Value } + $args = @{ Method = $Method; Uri = "$GatewayBaseUrl$Path"; Headers = $headers; TimeoutSec = 30; SkipHttpErrorCheck = $true } + if ($null -ne $Body) { + $args.ContentType = 'application/json; charset=utf-8' + $jsonText = $Body | ConvertTo-Json -Depth 20 -Compress + $args.Body = [Text.Encoding]::UTF8.GetBytes($jsonText) + } + $response = Invoke-WebRequest @args + $json = $null + if (-not [string]::IsNullOrWhiteSpace($response.Content)) { + try { $json = $response.Content | ConvertFrom-Json } catch { } + } + return [pscustomobject]@{ + http = [int]$response.StatusCode + body = $json + raw = if ($null -eq $json) { $response.Content } else { $null } + requestId = if ($null -ne $json) { $json.requestId } else { $response.Headers['X-Request-Id'] } + traceId = if ($null -ne $json) { $json.traceId } else { $response.Headers['X-Trace-Id'] } + } +} + +function Invoke-Upload([string]$Path, [string]$Token, [string]$FilePath) { + $file = Get-Item -LiteralPath $FilePath + $content = & curl.exe -sS -X POST "$GatewayBaseUrl$Path" ` + -H "Authorization: Bearer $Token" -F "file=@$($file.FullName);type=image/png" + if ($LASTEXITCODE -ne 0) { throw '称重图片上传发生传输错误' } + $json = $null + if (-not [string]::IsNullOrWhiteSpace($content)) { + try { $json = $content | ConvertFrom-Json } catch { } + } + return [pscustomobject]@{ + http = 200 + body = $json + raw = if ($null -eq $json) { [string]$content } else { $null } + requestId = if ($null -ne $json) { $json.requestId } else { $null } + traceId = if ($null -ne $json) { $json.traceId } else { $null } + } +} + +function Invoke-RequiredQualificationUpload([string]$Token, [string]$FilePath, [string]$CreditCode) { + $query = "unifiedSocialCreditCode=$CreditCode&replaceBusinessLicense=true&replaceLegalPersonId=false" + $businessFile = Get-Item -LiteralPath $FilePath + $legalPersonFile = Get-Item -LiteralPath (Join-Path $businessFile.DirectoryName 'c8.png') + $content = & curl.exe -sS -X POST "$GatewayBaseUrl/api/merchant/v1/files/qualifications/required-package?$query" ` + -H "Authorization: Bearer $Token" ` + -F "businessLicense=@$($businessFile.FullName);type=image/png" ` + -F "legalPersonId=@$($legalPersonFile.FullName);type=image/png" + if ($LASTEXITCODE -ne 0) { throw '必备资质包上传发生传输错误' } + $json = $null + if (-not [string]::IsNullOrWhiteSpace($content)) { + try { $json = $content | ConvertFrom-Json } catch { } + } + return [pscustomobject]@{ + http = 200 + body = $json + raw = if ($null -eq $json) { [string]$content } else { $null } + requestId = if ($null -ne $json) { $json.requestId } else { $null } + traceId = if ($null -ne $json) { $json.traceId } else { $null } + } +} + +function Add-Step([string]$Name, $Response, [bool]$Passed, [string]$Evidence = '') { + $code = if ($null -ne $Response.body) { [string]$Response.body.code } else { $null } + $message = if ($null -ne $Response.body) { [string]$Response.body.message } else { [string]$Response.raw } + $steps.Add([pscustomobject]@{ + name = $Name + passed = $Passed + http = $Response.http + code = $code + message = $message + requestId = $Response.requestId + traceId = $Response.traceId + evidence = $Evidence + details = if ($Passed -or $null -eq $Response.body) { $null } else { $Response.body.data } + }) + Write-Host "[$(if ($Passed) { '通过' } else { '失败' })] $Name HTTP=$($Response.http) code=$code $Evidence" +} + +function Require-Success([string]$Name, $Response, [string]$Evidence = '') { + $passed = $Response.http -eq 200 -and [string]$Response.body.code -eq '200' + Add-Step $Name $Response $passed $Evidence + if (-not $passed) { throw "$Name 失败:HTTP=$($Response.http) code=$($Response.body.code) message=$($Response.body.message)" } + return $Response.body.data +} + +function Require-Condition([string]$Name, $Response, [bool]$Condition, [string]$Evidence) { + $passed = $Response.http -eq 200 -and [string]$Response.body.code -eq '200' -and $Condition + Add-Step $Name $Response $passed $Evidence + if (-not $passed) { throw "$Name 断言失败:$Evidence" } +} + +try { + $platformLoginResponse = Invoke-Api POST '/api/auth/login' '' @{ username = $PlatformUsername; password = $PlatformPassword } + $platformLogin = Require-Success '平台登录' $platformLoginResponse + $platformToken = $platformLogin.accessToken + + $merchantLoginResponse = Invoke-Api POST '/api/auth/login' '' @{ username = $MerchantUsername; password = $MerchantPassword } + $merchantLogin = Require-Success '商户登录' $merchantLoginResponse + $merchantToken = $merchantLogin.accessToken + + $healthResponse = Invoke-Api GET '/api/platform/v1/first-leg/status' $platformToken + $null = Require-Success 'Firstleg 服务健康检查' $healthResponse + + $qualificationsResponse = Invoke-Api GET '/api/merchant/v1/iam/tenant/qualifications' $merchantToken + $qualifications = @(Require-Success '查询商户必备资质' $qualificationsResponse) + $approvedTypes = @($qualifications | Where-Object { $_.status -eq 'APPROVED' } | ForEach-Object { $_.qualificationType }) + if ('BUSINESS_LICENSE' -notin $approvedTypes -or 'LEGAL_PERSON_ID' -notin $approvedTypes) { + $e2eUsername = "api_e2e_$stamp" + $e2ePassword = 'ApiE2e123!' + $registerResponse = Invoke-Api POST '/api/auth/register' '' @{ + username = $e2eUsername; password = $e2ePassword; displayName = 'API E2E Tester' + companyName = "API E2E 测试企业 $stamp"; legalPerson = 'API Tester'; legalPhone = '13800138000' + contactEmail = "api-e2e-$stamp@example.com"; countryCode = 'CN'; address = 'API E2E 测试地址' + } + $registration = Require-Success '注册隔离的 API E2E 测试企业' $registerResponse + $TenantId = [long]$registration.tenantId + $ids.tenantId = [string]$TenantId + + $e2eLoginResponse = Invoke-Api POST '/api/auth/login' '' @{ username = $e2eUsername; password = $e2ePassword } + $e2eLogin = Require-Success '登录隔离的 API E2E 测试企业' $e2eLoginResponse + $merchantToken = $e2eLogin.accessToken + + $qualificationImage = Join-Path (Split-Path -Parent $repoRoot) 'apps\pda-uniapp\static\c7.png' + $creditCode = "91${stamp}A1" + $qualificationUploadResponse = Invoke-RequiredQualificationUpload $merchantToken $qualificationImage $creditCode + $null = Require-Success '补齐商户营业执照与法人身份证测试包' $qualificationUploadResponse + + $reviewSubmitResponse = Invoke-Api POST '/api/merchant/v1/iam/tenant/qualifications/submit-review' $merchantToken + $null = Require-Success '提交商户资质审核' $reviewSubmitResponse + $reviewsResponse = Invoke-Api GET '/api/platform/v1/iam/reviews' $platformToken + $reviews = @(Require-Success '查询平台待审核企业' $reviewsResponse) + $review = @($reviews | Where-Object { [string]$_.tenantId -eq [string]$TenantId } | Select-Object -First 1) + if ($review.Count -eq 0) { throw "未找到 tenantId=$TenantId 的资质审核单" } + $ids.qualificationReviewId = [string]$review[0].id + $reviewApproveResponse = Invoke-Api POST "/api/platform/v1/iam/reviews/$($ids.qualificationReviewId)/approve" $platformToken @{ comment = "$marker 测试环境验收补齐" } + $null = Require-Success '平台审核通过商户资质' $reviewApproveResponse + } + + $carrierPayload = [ordered]@{ + partnerCode = "API-E2E-CARRIER-$stamp" + partnerName = "API E2E 测试承运商 $stamp" + partnerType = 'CARRIER' + types = @('CARRIER') + contactName = 'API Tester' + contactPhone = '13800138000' + contactEmail = 'api-e2e@example.com' + countryCode = 'CN' + province = '广东省' + city = '深圳市' + district = '南山区' + addressLine = 'API E2E 测试地址' + postalCode = '518000' + status = 'ACTIVE' + remark = $marker + } + $carrierResponse = Invoke-Api POST '/api/platform/v1/partners?tenantId=0' $platformToken $carrierPayload + $ids.carrierId = [string](Require-Success '创建公共测试承运商' $carrierResponse) + + $warehousePayload = [ordered]@{ + tenantId = 0 + warehouseCode = "API-E2E-WH-$stamp" + warehouseName = "API E2E 收货仓 $stamp" + warehouseType = 'DOMESTIC' + countryCode = 'CN' + timezone = 'UTC+8' + contactName = 'API Tester' + contactPhone = '13800138000' + contactEmail = 'api-e2e@example.com' + status = 'ACTIVE' + remark = $marker + address = [ordered]@{ + addressType = 'WAREHOUSE'; countryCode = 'CN'; stateProvince = '广东省'; city = '深圳市' + district = '南山区'; postalCode = '518000'; addressLine1 = 'API E2E 测试收货仓地址' + addressLine2 = $null; latitude = $null; longitude = $null + } + capabilities = @([ordered]@{ + capabilityCode = 'RECEIVING'; capabilityName = '头程收货'; enabled = $true + cutoffTime = '16:00:00'; description = $marker + }) + } + $warehouseResponse = Invoke-Api POST '/api/platform/v1/warehouse-admin' $platformToken $warehousePayload + $ids.warehouseId = [string](Require-Success '创建启用收货能力的公共测试仓' $warehouseResponse) + + $effectiveFrom = (Get-Date).AddDays(-1).ToString('yyyy-MM-ddTHH:mm:ss') + $customerRuleResponse = Invoke-Api POST "/api/platform/v1/fba-pricing/customer-rules/versions?tenantId=$TenantId" $platformToken @{ + ruleCode = "API-E2E-RULE-$stamp"; customerId = '191000000000000001' + customerName = '深圳示例贸易有限公司'; businessType = 'FBA_SEA'; chargeMode = 'KG' + taxMode = 'TAX_INCLUDED'; conversionFactor = 167; roundingScale = 3 + roundingMode = 'HALF_UP'; currency = 'CNY'; effectiveFrom = $effectiveFrom + effectiveTo = $null; remark = $marker + } + $customerRule = Require-Success '创建客户计费规则版本' $customerRuleResponse + $ids.customerRuleId = [string]$customerRule.id + + $carrierRateResponse = Invoke-Api POST '/api/platform/v1/fba-pricing/carrier-rates/versions' $platformToken @{ + rateCode = "API-E2E-RATE-$stamp"; originWarehouseId = $ids.warehouseId + originWarehouseName = $warehousePayload.warehouseName; businessType = 'FBA_SEA' + channelCode = 'API-E2E-SEA'; carrierId = $ids.carrierId + carrierName = $carrierPayload.partnerName; destinationCode = 'ABE2'; chargeUnit = 'KG' + unitPrice = 2; currency = 'CNY'; additionalFee = 10; effectiveFrom = $effectiveFrom + effectiveTo = $null; remark = $marker + } + $carrierRate = Require-Success '创建承运商 FBA 费率版本' $carrierRateResponse + $ids.carrierRateId = [string]$carrierRate.id + + $sender = [ordered]@{ + companyName = '深圳示例贸易有限公司'; contactName = 'API Tester'; phone = '13800138000' + email = 'api-e2e@example.com'; countryCode = 'CN'; province = '广东省'; city = '深圳市' + district = '南山区'; addressLine = 'API E2E 测试地址'; postalCode = '518000' + } + $orderPayload = [ordered]@{ + customerId = '191000000000000001' + customerName = '深圳示例贸易有限公司' + transportMode = 'SEA' + tradeTerm = 'FOB' + destinationCountryCode = 'US' + destinationWarehouseId = '193000000000000001' + destinationWarehouseName = 'ABE2' + expectedShipDate = (Get-Date).AddDays(10).ToString('yyyy-MM-dd') + customerRemark = "$marker 初始草稿" + sender = $sender + receiver = $null + cargoes = @([ordered]@{ + cargoName = 'API E2E 测试货物'; cargoNameEn = 'API E2E Test Cargo'; hsCode = '8518210000' + customsHsCode = '8518210000'; packageType = 'CARTON'; packageCount = 1 + grossWeightKg = 10; netWeightKg = 9; volumeCbm = 0.06 + lengthCm = 50; widthCm = 40; heightCm = 30; declaredValue = 100; unitDeclaredValue = 100 + currency = 'USD'; dangerousGoods = $false; cargoRemark = $marker + skuNo = "SKU-$stamp"; upcNo = "UPC-$stamp"; unitsPerPackage = 1; totalQuantity = 1 + material = '塑料'; usage = '测试'; model = 'E2E'; brand = 'API-E2E' + }) + pickupMethod = 'PICKUP' + receivingWarehouseId = $ids.warehouseId + receivingWarehouseName = $warehousePayload.warehouseName + channelCode = 'API-E2E-SEA' + channelName = 'API E2E 海运渠道' + carrierId = $null + carrierName = $null + taxMode = 'BUY_DECLARATION' + containerMode = 'LCL' + customsInspection = $false + destinationType = 'FBA' + amazonWarehouseCode = 'ABE2' + shipmentId = "SHIP-$stamp" + referenceId = $marker + } + + $createKey = New-Key 'order-create' + $createResponse = Invoke-Api POST '/api/merchant/v1/orders/first-leg' $merchantToken $orderPayload @{ 'Idempotency-Key' = $createKey } + $ids.orderId = [string](Require-Success '创建头程订单草稿' $createResponse) + + $replayResponse = Invoke-Api POST '/api/merchant/v1/orders/first-leg' $merchantToken $orderPayload @{ 'Idempotency-Key' = $createKey } + $replayedOrderId = [string](Require-Success '重放创建订单幂等请求' $replayResponse) + Require-Condition '创建订单幂等结果一致' $replayResponse ($replayedOrderId -eq $ids.orderId) "orderId=$replayedOrderId" + + $draftDetailResponse = Invoke-Api GET "/api/merchant/v1/orders/first-leg/$($ids.orderId)" $merchantToken + $draftDetail = Require-Success '查询订单草稿' $draftDetailResponse + Require-Condition '订单初始状态为 DRAFT' $draftDetailResponse ($draftDetail.order.status -eq 'DRAFT') "status=$($draftDetail.order.status)" + + $orderPayload.customerRemark = "$marker 编辑后草稿" + $updateResponse = Invoke-Api PUT "/api/merchant/v1/orders/first-leg/$($ids.orderId)" $merchantToken $orderPayload + $null = Require-Success '编辑头程订单草稿' $updateResponse + $editedDetailResponse = Invoke-Api GET "/api/merchant/v1/orders/first-leg/$($ids.orderId)" $merchantToken + $editedDetail = Require-Success '回查编辑后的订单草稿' $editedDetailResponse + Require-Condition '订单编辑内容已保存' $editedDetailResponse ($editedDetail.order.customerRemark -eq $orderPayload.customerRemark) "customerRemark=$($editedDetail.order.customerRemark)" + + $submitResponse = Invoke-Api POST "/api/merchant/v1/orders/first-leg/$($ids.orderId)/submit" $merchantToken $null @{ 'Idempotency-Key' = (New-Key 'order-submit') } + $null = Require-Success '商户提交订单' $submitResponse + + $unifiedPendingResponse = Invoke-Api GET "/api/platform/v1/orders/$($ids.orderId)?tenantId=$TenantId" $platformToken + $unifiedPending = Require-Success '统一订单详情查询待审核状态' $unifiedPendingResponse + Require-Condition '统一订单主状态为 PENDING_REVIEW' $unifiedPendingResponse ($unifiedPending.summary.mainStatus -eq 'PENDING_REVIEW') "mainStatus=$($unifiedPending.summary.mainStatus)" + + $approveOrderResponse = Invoke-Api POST "/api/platform/v1/orders/first-leg/$($ids.orderId)/approve?tenantId=$TenantId" $platformToken + $null = Require-Success '平台审核通过订单' $approveOrderResponse + + $unifiedApprovedResponse = $null + $unifiedApproved = $null + for ($attempt = 1; $attempt -le 20; $attempt++) { + $unifiedApprovedResponse = Invoke-Api GET "/api/platform/v1/orders/$($ids.orderId)?tenantId=$TenantId" $platformToken + if ($unifiedApprovedResponse.http -eq 200 -and [string]$unifiedApprovedResponse.body.code -eq '200' -and $null -ne $unifiedApprovedResponse.body.data.summary.inboundId) { + $unifiedApproved = $unifiedApprovedResponse.body.data + break + } + Start-Sleep -Seconds 3 + } + $inboundReady = $null -ne $unifiedApproved -and $null -ne $unifiedApproved.summary.inboundId + Add-Step '异步生成入库单' $unifiedApprovedResponse $inboundReady $(if ($inboundReady) { "inboundId=$($unifiedApproved.summary.inboundId)" } else { '60 秒内未取得 inboundId' }) + if (-not $inboundReady) { throw '订单审核后 60 秒内未异步生成入库单' } + $ids.inboundId = [string]$unifiedApproved.summary.inboundId + $versions.inbound = [int]$unifiedApproved.summary.inboundVersion + + $inboundDetailResponse = Invoke-Api GET "/api/platform/inbounds/$($ids.inboundId)?tenantId=$TenantId" $platformToken + $inboundDetail = Require-Success '查询入库单详情' $inboundDetailResponse + $versions.inbound = [int]$inboundDetail.summary.version + Require-Condition '入库单进入待测量状态' $inboundDetailResponse ($inboundDetail.summary.processStatus -eq 'WAIT_MEASURE') "processStatus=$($inboundDetail.summary.processStatus), version=$($versions.inbound)" + + $measurementDetailResponse = Invoke-Api GET "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/measurement?tenantId=$TenantId" $platformToken + $null = Require-Success '查询初始测量详情' $measurementDetailResponse + + $draftPayload = [ordered]@{ + measurementId = $null; version = 0; originalDocumentNo = $marker + originalGrossWeightKg = 10; originalVolumeCbm = 0.06; actualGoodsName = 'API E2E 测试货物' + items = @([ordered]@{ + itemNo = 'ITEM-1'; boxSpecification = '50x40x30cm'; packageCount = 1 + lengthCm = 50; widthCm = 40; heightCm = 30; unitWeightKg = 10 + intelligentImageFileId = $null; overLimitWarning = $null + outerLengthCm = 50; outerWidthCm = 40; outerHeightCm = 30; remark = $marker + }) + } + $saveDraftResponse = Invoke-Api PUT "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/measurement/draft?tenantId=$TenantId" $platformToken $draftPayload + $savedDraft = Require-Success '保存测量草稿' $saveDraftResponse + $ids.measurementId = [string]$savedDraft.measurementId + $versions.measurement = [int]$savedDraft.version + + $imagePath = Join-Path (Split-Path -Parent $repoRoot) 'apps\pda-uniapp\static\c7.png' + if (-not (Test-Path -LiteralPath $imagePath)) { throw "找不到测试称重图片:$imagePath" } + $uploadResponse = Invoke-Upload "/api/platform/v1/files/measurements/$TenantId/$($ids.inboundId)" $platformToken $imagePath + $uploaded = Require-Success '上传称重图片并触发 OCR' $uploadResponse + $ids.fileId = [string]$uploaded.fileId + + $weightPayload = [ordered]@{ + fileId = $ids.fileId + ocrProvider = 'API_E2E_MANUAL' + ocrRawText = if ($uploaded.weightValue) { [string]$uploaded.weightValue } else { 'OCR unavailable; manual verification required' } + ocrWeightValue = $uploaded.weightValue + ocrWeightUnit = if ($uploaded.weightValue) { 'KG' } else { $null } + confidence = $uploaded.confidence + } + $weightCreateResponse = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/weight-results?tenantId=$TenantId" $platformToken $weightPayload + $weightResult = Require-Success '登记称重结果' $weightCreateResponse + $ids.weightResultId = [string]$weightResult.id + $versions.weightResult = [int]$weightResult.version + + $correctResponse = Invoke-Api PUT "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/weight-results/$($ids.weightResultId)?tenantId=$TenantId" $platformToken @{ + version = $versions.weightResult; adjustedWeightValue = 10; adjustedWeightUnit = 'KG'; adjustmentReason = "$marker 人工确认" + } + $corrected = Require-Success '人工修正称重结果' $correctResponse + $versions.weightResult = [int]$corrected.version + + $confirmResponse = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/weight-results/$($ids.weightResultId)/confirm?tenantId=$TenantId" $platformToken @{ + version = $versions.weightResult + } + $confirmed = Require-Success '确认称重结果' $confirmResponse + $versions.weightResult = [int]$confirmed.version + + $measurementBeforeSubmitResponse = Invoke-Api GET "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/measurement?tenantId=$TenantId" $platformToken + $measurementBeforeSubmit = Require-Success '提交前回查测量详情' $measurementBeforeSubmitResponse + $versions.measurement = [int]$measurementBeforeSubmit.version + Require-Condition '确认重量汇总为 10KG' $measurementBeforeSubmitResponse ([decimal]$measurementBeforeSubmit.actualGrossWeightKg -eq 10) "actualGrossWeightKg=$($measurementBeforeSubmit.actualGrossWeightKg)" + + $measurementSubmitResponse = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/measurement/submit?tenantId=$TenantId" $platformToken @{ + measurementId = $ids.measurementId; version = $versions.measurement + } + $measurementSubmitted = Require-Success '提交测量审核' $measurementSubmitResponse + $versions.measurement = [int]$measurementSubmitted.version + Require-Condition '测量进入待审核状态' $measurementSubmitResponse ($measurementSubmitted.processStatus -eq 'MEASURE_PENDING' -and $measurementSubmitted.measureReviewStatus -eq 'PENDING') "processStatus=$($measurementSubmitted.processStatus), reviewStatus=$($measurementSubmitted.measureReviewStatus)" + + $pendingInboundResponse = Invoke-Api GET "/api/platform/inbounds/$($ids.inboundId)?tenantId=$TenantId" $platformToken + $pendingInbound = Require-Success '审核前回查入库单版本' $pendingInboundResponse + $versions.inbound = [int]$pendingInbound.summary.version + + $measurementApproveResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($ids.inboundId)/measurements/approve?tenantId=$TenantId" $platformToken @{ + measurementId = $ids.measurementId; expectedVersion = $versions.inbound; reason = $null + } @{ 'Idempotency-Key' = (New-Key 'measurement-approve') } + $null = Require-Success '平台审核通过测量' $measurementApproveResponse + + $approvedInboundResponse = Invoke-Api GET "/api/platform/inbounds/$($ids.inboundId)?tenantId=$TenantId" $platformToken + $approvedInbound = Require-Success '审核后回查入库单' $approvedInboundResponse + $versions.inbound = [int]$approvedInbound.summary.version + Require-Condition '二期边界停在 WAIT_SPLIT' $approvedInboundResponse ($approvedInbound.summary.processStatus -eq 'WAIT_SPLIT' -and $approvedInbound.summary.measureReviewStatus -eq 'APPROVED') "processStatus=$($approvedInbound.summary.processStatus), reviewStatus=$($approvedInbound.summary.measureReviewStatus)" + + $feeResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($ids.inboundId)/fees/calculate?tenantId=$TenantId" $platformToken @{} @{ 'Idempotency-Key' = (New-Key 'fee-calculate') } + $calculatedFee = Require-Success '二期计费接口试算' $feeResponse + $ids.feeId = [string]$calculatedFee.feeId + Require-Condition '计费试算保持 WAIT_SPLIT 主流程边界' $feeResponse ($calculatedFee.nextProcessStatus -eq 'WAIT_SPLIT') "nextProcessStatus=$($calculatedFee.nextProcessStatus)" + + $feeConfirmResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($ids.inboundId)/fees/confirm?tenantId=$TenantId" $platformToken $null @{ 'Idempotency-Key' = (New-Key 'fee-confirm') } + $confirmedFee = Require-Success '确认二期费用快照' $feeConfirmResponse + Require-Condition '费用确认后仍保持 WAIT_SPLIT 主流程边界' $feeConfirmResponse ($confirmedFee.feeStatus -eq 'CONFIRMED' -and $confirmedFee.nextProcessStatus -eq 'WAIT_SPLIT') "feeStatus=$($confirmedFee.feeStatus), nextProcessStatus=$($confirmedFee.nextProcessStatus)" +} +catch { + $fatal = $_.Exception.Message + Write-Host "[中止] $fatal" +} +finally { + $passedCount = @($steps | Where-Object passed).Count + $failedCount = @($steps | Where-Object { -not $_.passed }).Count + $report = [ordered]@{ + reportName = '二期测试环境 API 验收' + scope = @('头程下单', '商户提交', '平台审核', '异步生成入库单', '测量草稿', '称重图片与结果', '人工修正与确认', '测量提交与审核', '计费试算') + excluded = @('分柜', '预装', 'PDA 拣货', '装柜证据', '装柜完成', '派送') + gateway = $GatewayBaseUrl + marker = $marker + startedAt = [datetime]::ParseExact($stamp, 'yyyyMMddHHmmss', $null).ToString('o') + finishedAt = (Get-Date).ToString('o') + success = $null -eq $fatal -and $null -eq $billingDefect -and $failedCount -eq 0 + passedCount = $passedCount + failedCount = $failedCount + ids = $ids + versions = $versions + fatal = $fatal + billingDefect = $billingDefect + steps = $steps + } + $report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $OutputPath -Encoding utf8 + Write-Host "报告:$OutputPath" + $report | ConvertTo-Json -Depth 20 +} 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..0c82de1e1 --- /dev/null +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -0,0 +1,179 @@ +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', + 'phase4-overseas-wallet-and-pallet-cost', + ]) { + assert.match(source, new RegExp(`CONFIGURED_MODULES\\s*=\\s*\\[[^\\]]*'${module}'`, 's')) + } +}) + +test('wallet and destuff cost closure expands the fresh merchant and completed overseas PDA chain', () => { + const modules = parseArguments(['--modules', 'phase4-overseas-wallet-and-pallet-cost']).modules + for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading-finalize', + 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-business']) { + assert.ok(modules.indexOf(prerequisite) >= 0 + && modules.indexOf(prerequisite) < modules.indexOf('phase4-overseas-wallet-and-pallet-cost')) + } +}) + +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('overseas business keeps its marker-scoped customer fixture with reusable credentials configured', () => { + const previousUsername = process.env.MERCHANT_TEST_USERNAME + const previousPassword = process.env.MERCHANT_TEST_PASSWORD + process.env.MERCHANT_TEST_USERNAME = 'reusable-client' + process.env.MERCHANT_TEST_PASSWORD = 'reusable-password' + try { + const modules = parseArguments(['--modules', 'phase4-overseas-business']).modules + assert.ok(modules.includes('merchant-qualification-fixture')) + assert.ok(modules.indexOf('merchant-qualification-fixture') < modules.indexOf('phase4-overseas-business')) + } finally { + if (previousUsername === undefined) delete process.env.MERCHANT_TEST_USERNAME + else process.env.MERCHANT_TEST_USERNAME = previousUsername + if (previousPassword === undefined) delete process.env.MERCHANT_TEST_PASSWORD + else process.env.MERCHANT_TEST_PASSWORD = previousPassword + } +}) + +test('a fixed origin route does not require randomized order fixtures', () => { + const options = parseArguments(['--origin-warehouse-name', '东莞仓', '--modules', 'phase4-overseas-pda']) + assert.equal(options.originWarehouseName, '东莞仓') + assert.equal(options.routeMode, 'fixed') + assert.equal(options.orderProfileMode, 'fixed') + assert.match(source, /else if \(this\.options\.originWarehouseName \|\| this\.options\.channelName \|\| this\.options\.channelCode\)/) + assert.match(source, /this\.context\.waitSplitRoute = \{[\s\S]*randomize: false/) + assert.match(source, /this\.context\.allowAnyExistingPallet = true/) +}) + +test('dual-token refresh replaces the whole session only after a complete response', async () => { + const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300', + 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') + 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) + assert.equal(run.coverageGaps.length, 3) +}) + +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', 'ONE_CLICK_DROPSHIP', true\)/) + assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/) + assert.match(source, /-P4-FBA-STANDARD\(\?:-R\\d\+\)\?:/) + assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) + assert.match(source, /runUntilPickupReady\(this\)/) + const variant = source.slice(source.indexOf('async runPhase4ProducerVariant'), source.indexOf('async ensurePhase4PdaProducerMatrix')) + assert.match(variant, /this\.phase4ProducerAttempts\.get\(label\)/) + assert.match(variant, /attempt === 1 \? '' : `-R\$\{attempt\}`/) + assert.ok(variant.indexOf('ensureInitialCalculated') < variant.indexOf('phase3-loading-phase4-')) + assert.match(variant, /fulfillmentType === 'ONE_CLICK_DROPSHIP'/) + assert.doesNotMatch(variant, /inboundFees\.run\(this\)/) + assert.ok(variant.indexOf('phase3-outbound-dispatch-phase4-') < variant.indexOf('phase3-container-documents-phase4-')) + assert.ok(variant.indexOf('phase3-container-documents-phase4-') < variant.indexOf('phase3-carrier-schedule-queries-phase4-')) + assert.match(source, /\['phase3-container-documents', \['phase3-outbound-dispatch'\]\]/) + assert.doesNotMatch(source, /\['phase3-outbound-dispatch', \['phase3-container-documents'/) + 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/publish-local-nacos-config.ps1 b/scripts/publish-local-nacos-config.ps1 index e982df016..7f249c4ce 100644 --- a/scripts/publish-local-nacos-config.ps1 +++ b/scripts/publish-local-nacos-config.ps1 @@ -12,7 +12,7 @@ $publishUrl = "http://$ServerAddr/nacos/v1/cs/configs" $health = Invoke-WebRequest -UseBasicParsing -Uri $healthUrl -TimeoutSec 5 if ($health.StatusCode -ne 200 -or $health.Content.Trim() -ne 'OK') { - throw "Local Nacos is not ready: $healthUrl" + throw "Nacos not ready: $healthUrl" } $files = Get-ChildItem -LiteralPath $configRoot -File -Filter '*.yml' | Sort-Object Name @@ -32,9 +32,13 @@ foreach ($file in $files) { $body.tenant = $Namespace } + $pairs = foreach ($entry in $body.GetEnumerator()) { + [uri]::EscapeDataString([string]$entry.Key) + '=' + [uri]::EscapeDataString([string]$entry.Value) + } + $form = $pairs -join '&' $result = Invoke-RestMethod -Method Post -Uri $publishUrl ` -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' ` - -Body $body -TimeoutSec 10 + -Body $form -TimeoutSec 10 if ([string]$result -ne 'true') { throw "Publish failed: $($file.Name), Nacos response: $result" } @@ -42,4 +46,4 @@ foreach ($file in $files) { Write-Host "Published $($file.Name) -> $Namespace / $Group" } -Write-Host "Local first-leg Nacos configuration published: $published items." +Write-Host "Published local Nacos configuration: $published items." diff --git a/scripts/publish-nacos-config.ps1 b/scripts/publish-nacos-config.ps1 new file mode 100644 index 000000000..0231e4130 --- /dev/null +++ b/scripts/publish-nacos-config.ps1 @@ -0,0 +1,68 @@ +param( + [ValidateSet('local', 'dev', 'development', 'test', 'production')] + [string]$Environment, + [ValidateSet('cn', 'overseas')] + [string]$Region = 'cn', + [string]$ServerAddr = '127.0.0.1:8848', + [string]$Group = 'BAIYUE', + [string]$Namespace +) + +$ErrorActionPreference = 'Stop' +$projectRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) + +if ($Environment -eq 'local') { + if ($Namespace -and $Namespace -ne 'public') { + throw 'local 环境只能发布到 public namespace' + } + $Namespace = 'public' + $configProfile = 'local' +} else { + if (-not $Namespace -or $Namespace -eq 'public') { + throw "$Environment/$Region 必须显式指定非 public 的 Nacos namespace" + } + $configProfile = $Region +} + +$configRoot = (Resolve-Path (Join-Path $projectRoot "config\nacos\$configProfile")).Path +$healthUrl = "http://$ServerAddr/nacos/v1/console/health/readiness" +$publishUrl = "http://$ServerAddr/nacos/v1/cs/configs" +$health = Invoke-WebRequest -UseBasicParsing -Uri $healthUrl -TimeoutSec 5 +if ($health.StatusCode -ne 200 -or $health.Content.Trim() -ne 'OK') { + throw "Nacos not ready: $healthUrl" +} + +$files = Get-ChildItem -LiteralPath $configRoot -File -Filter '*.yml' | Sort-Object Name +if ($files.Count -eq 0) { + throw "No Nacos configuration found: $configRoot" +} + +$published = 0 +foreach ($file in $files) { + $resolvedFile = (Resolve-Path -LiteralPath $file.FullName).Path + if (-not $resolvedFile.StartsWith($configRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Unexpected config path: $resolvedFile" + } + $body = @{ + dataId = $file.Name + group = $Group + content = Get-Content -LiteralPath $resolvedFile -Raw -Encoding UTF8 + type = 'yaml' + } + if ($Namespace -ne 'public') { + $body.tenant = $Namespace + } + $pairs = foreach ($entry in $body.GetEnumerator()) { + [uri]::EscapeDataString([string]$entry.Key) + '=' + [uri]::EscapeDataString([string]$entry.Value) + } + $result = Invoke-RestMethod -Method Post -Uri $publishUrl ` + -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' ` + -Body ($pairs -join '&') -TimeoutSec 10 + if ([string]$result -ne 'true') { + throw "Publish failed: $($file.Name), Nacos response: $result" + } + $published++ + Write-Host "Published $($file.Name) -> $Namespace / $Group" +} + +Write-Host "Published $Environment/$Region Nacos configuration: $published items." diff --git a/scripts/real-order-xlsx-import.md b/scripts/real-order-xlsx-import.md new file mode 100644 index 000000000..bf6a9d56b --- /dev/null +++ b/scripts/real-order-xlsx-import.md @@ -0,0 +1,68 @@ +# 源订单 Excel 测试导入 + +入口 `real-order-xlsx-test-env.mjs`,离线合同 `real-order-xlsx-test-env.test.mjs`。仅限当前授权的测试环境,不清库、不部署、不修改环境配置、不写数据库。 + +## 配置与命令 + +凭据仍只维护在用户目录的 `chongchong-global-test-acceptance.env`:`ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME`、`ADMIN_TEST_PASSWORD`、`MERCHANT_TEST_USERNAME`、`MERCHANT_TEST_PASSWORD`。不复制到源码或报告。Node 支持 `--env-file`,Python 需已有 `openpyxl`,可用桌面提供的工作区依赖路径。脚本不安装或链接依赖。 + +从后端根执行,按本机实际位置设置参数: + +```powershell +node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/real-order-xlsx-test-env.mjs --input '<源XLSX绝对路径>' --python '' --output-dir '<仓库外的本批次专用目录>' --batch-id trial-001 --expected-host '<测试网关主机名>' --stage preflight +``` + +- `--sheet` 默认 `D1512开始`。 +- `--customer-name` 默认 `IT测试`,必须和登录企业一致;`--origin-warehouse-name` 默认 `义乌仓`,必须唯一存在。 +- `--expected-host` 必须与配置完全相同且以 `test-` 或 `test.` 开头,禁止生产环境。 +- `--stage preflight` 登录并查询目录、托盘和编号策略,不创建业务数据。 +- `--stage create --limit 1` 按源实际箱数、重量、体积尝试创建一票草稿,不伪造图片、尺寸或报关字段,不提交订单。按用户确认规则,源表未注明木制品时 `woodenProduct=false`;其他缺失必填项保留服务端错误,不保证所有源行能创建。 +- `--stage pending-review --allow-test-placeholders true --limit 1` 创建订单并由客户提交,回查客户状态 `SUBMITTED` 和统一状态 `PENDING_REVIEW` 后停止;不审核、不生成入库投影,不调用箱码、测量、称重、托盘或库位接口。这是源订单批量造数的默认推荐阶段。试价返回“无匹配报价”或“未达到最低重量阶梯”时只记录 `pricingWarning`,仍创建并提交到待审核,交由人工审核处理。 +- `--stage wait-split --allow-test-placeholders true --limit 1` 创建完整测试订单并提交、审核、测量,回查 `WAIT_SPLIT`、审核状态、箱数和货重。只用于少量完整链验收,不用于大批量造数。 +- `--limit` 是本次新增尝试数,默认 1;实际处理方式由 `--concurrency` 决定。前三票连续失败且没有成功时停止扩大写入。 +- `--concurrency` 是单进程并发票数,默认 1、最大 10。所有并发票共享一个带串行落盘队列的 `state.json` 和一个批次锁,不得再启动多个进程。测试环境实测 3 路可稳定运行;提高并发前必须重新小批压测,出现服务端 `S0500` 时降低并发,不能用失败率换速度。 + +## 源数据与模拟规则 + +同入仓码多行按实际箱数/毛重/体积求和,分柜明细还须与唯一预报箱数相符;同码客户、渠道、目的仓或报关方式冲突则阻断。缺失或零实际值不以预报替换。无有效入仓码的行排除,源文件保持原样。 + +订单表持久精度为毛重 3 位小数、体积 4 位小数。拆分行先按源实际值求和,再统一按该精度四舍五入后创建和回查;不逐行先取整,避免分摊尾差导致总量漂移。 + +报关方式“买单改退税”取最终退税,“退税改买单”取最终买单;空白或未识别文本阻断,不默认买单。 + +试价计费方式取“计费数据”后的第一个“单位”列:KGS/KG 映射 KG,CBM 保留。包税按服务端契约固定 KG;不包税单位缺失或分柜行冲突则阻断。 + +本脚本是单个已认证客户的造数入口,不能替不同客户下单。源客户及源入仓码保留在订单备注;新入仓码由系统 AUTO 生成,不改变编号策略。原船期、装柜、船节点、价格和历史费用不作为当前流程已完成事实导入,当前计价由测试库有效报价决定。 + +渠道仅去掉包税字样和快递括号后匹配同名渠道,保留原渠道文本到备注。税制必须存在。目的仓按同类型的精确编码/名称匹配,不模糊替代。私人地址缺少结构化映射、海外仓名称不能精确匹配、ZIM 等目录缺失渠道均记录为未匹配;不新增仓库或地址。多 SKU 明细无法从自由文本可靠还原,当前按一条汇总货物导入,不宣称保留逐 SKU 分布。 + +源表没有箱规和图片。按用户确认,总箱数、总货重、总体积为权威目标,长宽高允许推算。读取当前客户四档箱规,按 ADD 加边规则反算基数,使用正式人工外箱尺寸字段提交,不修改客户配置。考虑服务端逐箱四位小数向上取整,将总体积按 0.0001 m³ 整数单位分配为最多两组,每组以一个箱码代表相应箱数。草稿保存后和最终待分柜均核对总箱数、总体积,体积不一致直接失败;源体积超过服务端精度或当前规则无可行尺寸时写订单前阻断,不放宽误差掩盖差异。 + +每 30 箱一张称重图,末托为余数。源毛重解释为含包装货重,按箱数分摊后加服务端托盘皮重作为秤重,使服务端净货重与源毛重一致。不得将秤重总值和源货重混为一谈。推算箱规不是原始实测尺寸。 + +源体积可用三位小数表达时,优先以 0.001 m³ 分组,使各明细体积重和汇总体积重避免逐行向上取整差异;否则按四位精度分组。提交前同时检查明细体积重合计与批次一致,不一致记录服务端取整阻断,不提交审核或伪造总量。 + +产品照片、缺失产品数量/单价/HS/材质/危险品属性等为明确标记的测试占位,不能用于真实报关、计费准确性或实物测量验收。木制品未注明时按用户确认默认非木制品。同一批次只上传一次产品占位图并在订单中复用;箱级测量图和每托称重图仍逐条上传作为正式流程要求的证据。客户箱规每次进程启动只查询一次。多行或超长货件标识不截取为另一个真实 ID,模拟模式用 TEST 标识、草稿模式留空,完整原值仍在源文件中。`create` 模式不添加这些占位值。 + +测量草稿是带版本的幂等更新。仅当该接口返回服务端 `S0500` 时,脚本最多重读两次最新测量版本后重试,并把每次 requestId/traceId 记入订单检查点;业务校验错误、其他接口错误和第三次失败均原样终止该票,不无限重试。 + +只有 `wait-split` 阶段会查询并复用 `TP-001` 和 `YW-TP-002`,任一缺失则停止;不调用托盘/库位新增接口。普通位与公共位 A0 由正式测量链分配。`pending-review` 完全不查询或占用托盘、库位。一件代发后续需要新建工作托盘,与当前禁止新增约束有待确认,因此先记录阻断,不降为普通单或擅自创建工作托盘。 + +## 报告与重跑 + +必须保留批次目录:`preflight.json` 为全部解析/主数据匹配结果;`state.json` 为源文件哈希、批次身份、源行号到新订单/入仓码的对应、每次写入前后的检查点、错误码和 requestId/traceId。该目录包含业务数据,仅在本机仓库外保存,不提交或公开分享。原始 Excel 自身是完整源数据依据。 + +同目录重新运行会跳过所有已经尝试过的票(包括成功、失败和中断票),不会自动重新创建。`pending-review` 仅安全恢复停在试价阶段、尚未创建订单的 `A0404/A0422`,旧错误保留在 `priorAttempts`。载荷规则发生变化后必须使用新的 batch-id 和输出目录,不能用旧创建幂等键发送不同载荷。`limit` 每次从尚未尝试或上述可恢复票继续;不是通用失败恢复器。特别注意:不同阶段不能用同一批状态自动推进已有订单;需使用全新批次选择尚未尝试票。失败/中断保留服务端部分数据,先核对检查点,不删单或自动补偿。任一并发票返回 `A0401` 时,本批最多完成当前并发组便立即熔断,防止令牌失效污染后续订单。批次锁记录进程 PID;异常终止后,下次启动仅在该 PID 已不存在时清理精确的陈旧锁。 + +源文件、客户、网关或批次 ID 改变时拒绝复用目录。并发进程通过 `run.lock` 拒绝;异常终止残留锁,人工确认旧进程已结束后才可移除精确锁文件。 + +用户清库并重新初始化客户、报价、仓库和原有托盘后,指定**新的 batch-id 和全新输出目录**重新运行同一脚本。不能复用旧状态,否则已尝试票会跳过。清库不由本脚本执行。 + +清库后的批量待审核运行固定使用 `--stage pending-review --allow-test-placeholders true --concurrency 10 --limit 10000`。先确认 IT测试客户、义乌仓、可选渠道与目的仓主档已经初始化;报价缺失不阻断待审核创建。运行完成以 `preflight.matched = state.orders`、全部检查点无 `STARTED`、成功项均为 `PENDING_REVIEW` 为完成口径。 + +## 本地检查 + +```powershell +node --check scripts/real-order-xlsx-test-env.mjs +node --test scripts/real-order-xlsx-test-env.test.mjs scripts/script-documentation-contract.test.mjs +``` diff --git a/scripts/real-order-xlsx-test-env.mjs b/scripts/real-order-xlsx-test-env.mjs new file mode 100644 index 000000000..7cf881ead --- /dev/null +++ b/scripts/real-order-xlsx-test-env.mjs @@ -0,0 +1,593 @@ +#!/usr/bin/env node +// Explicitly authorized non-production HTTP import. Never resets a database. +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { mkdir, readFile, writeFile, rename, open, unlink } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' + +const hash = (value) => createHash('sha256').update(value).digest('hex') +const text = (value) => String(value ?? '').trim() +const round = (value, digits = 6) => Number(value.toFixed(digits)) +const positive = (value) => value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) > 0 +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') +const PALLETS = ['TP-001', 'YW-TP-002'] +const declarationMode = (value) => ({ 买单: 'BUY_DECLARATION', 退税: 'TAX_REFUND', + 买单改退税: 'TAX_REFUND', 退税改买单: 'BUY_DECLARATION' })[text(value)] || null + +export function channelName(value) { + return text(value).replace(/\s/g, '').replace(/[((](?:FEDEX|UPS)[))]/gi, '').replace(/不包税|包税/g, '') +} + +export function normalizeRows(rows) { + const header = rows[0].map((value) => text(value).replace(/\s/g, '')) + const required = ['进仓编号', '客户', '渠道', '亚马逊仓', '实际件数', '实际毛重', '实际立方', '预报件数'] + for (const name of required) if (!header.includes(name)) throw Error(`缺少源表列:${name}`) + const get = (row, name) => row[header.indexOf(name)] + const groups = new Map(), excluded = [] + rows.slice(1).forEach((row, offset) => { + if (row.every((value) => value === null || value === '')) return + const code = text(get(row, '进仓编号')).match(/^[A-Z]{2,8}\d{8,16}(?=\s|$)/)?.[0] + if (!code) { excluded.push({ rows: [offset + 2], reason: 'INVALID_SOURCE_CODE' }); return } + const group = groups.get(code) || [] + group.push({ row, rowNumber: offset + 2 }); groups.set(code, group) + }) + const orders = [...groups].map(([sourceCode, entries]) => { + const first = entries[0].row + const distinct = (name) => [...new Set(entries.map(({ row }) => text(get(row, name))))] + const problems = [] + for (const name of ['客户', '渠道', '亚马逊仓']) { + if (distinct(name).length !== 1) problems.push(`CONFLICT_${name}`) + } + if (new Set(entries.map(({ row }) => declarationMode(get(row, '买单/退税')))).size !== 1) problems.push('CONFLICT_买单/退税') + // Missing/zero actuals do not silently become forecast facts. + for (const name of ['实际件数', '实际毛重', '实际立方']) { + if (entries.some(({ row }) => !positive(get(row, name)))) problems.push(`MISSING_${name}`) + } + const sum = (name, digits = 6) => round(entries.reduce((total, { row }) => total + (Number(get(row, name)) || 0), 0), digits) + const packageCount = sum('实际件数') + if (!Number.isInteger(packageCount) || packageCount < 1) problems.push('INVALID_PACKAGE_COUNT') + if (entries.length > 1) { + const forecasts = distinct('预报件数').map((value) => Number(value.match(/^\d+/)?.[0])) + if (forecasts.length !== 1 || forecasts[0] !== packageCount) problems.push('SPLIT_COUNT_MISMATCH') + } + const sourceChannel = text(get(first, '渠道')) + const destination = text(get(first, '亚马逊仓')) + const taxMode = declarationMode(get(first, '买单/退税')) + if (!taxMode) problems.push('UNKNOWN_DECLARATION_MODE') + const pricingTaxMode = sourceChannel.includes('不包税') ? 'TAX_EXCLUDED' + : sourceChannel.includes('包税') ? 'TAX_INCLUDED' : null + if (!pricingTaxMode) problems.push('UNKNOWN_PRICING_TAX_MODE') + const chargeModes = entries.map(({ row }) => ({ KGS: 'KG', KG: 'KG', CBM: 'CBM' })[text(get(row, '单位')).toUpperCase()]) + const chargeMode = pricingTaxMode === 'TAX_INCLUDED' ? 'KG' : chargeModes[0] + if (pricingTaxMode === 'TAX_EXCLUDED' && (!chargeMode || chargeModes.some((mode) => mode !== chargeMode))) problems.push('UNKNOWN_OR_CONFLICTING_CHARGE_MODE') + const destinationType = destination.includes('私人') ? 'PRIVATE' + : /^[A-Z]{3}\d$/.test(destination) ? 'FBA' : 'OVERSEAS' + return { key: hash(sourceCode).slice(0, 24), sourceCode, rows: entries.map((entry) => entry.rowNumber), + sourceCustomer: text(get(first, '客户')), sourceChannel, channelName: channelName(sourceChannel), + destination, destinationType, pricingTaxMode, taxMode, chargeMode, + fulfillmentType: destination.includes('一件代发') ? 'ONE_CLICK_DROPSHIP' : 'STANDARD', + // Match the persisted order schema: kg has 3 decimals and CBM has 4. + // Split source rows can contain longer allocation decimals whose exact sum + // cannot be represented by the order table. + packageCount, weightKg: sum('实际毛重', 3), volumeCbm: sum('实际立方', 4), + cargoName: distinct('品名').filter(Boolean).join(' / '), + sku: distinct('SKU').filter(Boolean).join('\n'), shipmentId: distinct('FBA货件编号').filter(Boolean).join('\n'), + referenceId: distinct('跟踪编号').filter(Boolean).join('\n'), problems } + }) + return { orders, excluded } +} + +export function matchRoute(order, catalog) { + if (order.problems.length) throw Error(order.problems.join('|')) + if (order.destinationType === 'PRIVATE') throw Error('PRIVATE_ADDRESS_REQUIRES_EXPLICIT_MAPPING') + // Do not create work pallets to bypass the user's no-new-pallet constraint. + if (order.fulfillmentType === 'ONE_CLICK_DROPSHIP') throw Error('DROPSHIP_REQUIRES_WORK_PALLET_WORKFLOW') + const channels = catalog.channels.filter((item) => item.channelName === order.channelName + && item.pricingTaxModes?.includes(order.pricingTaxMode)) + if (channels.length !== 1) throw Error('CHANNEL_OR_TAX_MODE_NOT_UNIQUE') + const destinations = catalog.destinations.filter((item) => item.warehouseType === order.destinationType + && (item.warehouseCode === order.destination || item.warehouseName === order.destination)) + if (destinations.length !== 1) throw Error('DESTINATION_NOT_UNIQUE') + return { channel: channels[0], destination: destinations[0], receiving: catalog.receiving } +} + +export function measurementShape(order) { + if (!positive(order.volumeCbm) || !positive(order.weightKg) || !Number.isInteger(order.packageCount) + || order.packageCount < 1) throw Error('INVALID_MEASUREMENT_FACTS') + const edge = round(Math.cbrt(order.volumeCbm * 1e6 / order.packageCount), 3) + return { length: edge, width: edge, height: edge, palletCount: Math.ceil(order.packageCount / 30) } +} + +export function totalPreservingShapes(order, rules) { + measurementShape(order) + if (!Array.isArray(rules) || rules.length !== 4 || rules.some((r) => + ![r.innerLengthCm, r.innerWidthCm, r.innerHeightCm, r.edgeAdditionCm].every(positive))) throw Error('INVALID_DIMENSION_RULES') + const sorted = [...rules].sort((a, b) => a.innerLengthCm * a.innerWidthCm * a.innerHeightCm + - b.innerLengthCm * b.innerWidthCm * b.innerHeightCm) + const choose = (edge) => sorted.find((r) => Math.min(r.innerLengthCm, r.innerWidthCm, r.innerHeightCm) >= edge) || sorted.at(-1) + // Manual outer dimensions use server CEILING(4) per carton. Distribute the + // integer 0.0001 m3 remainder across two groups, not independently rounded boxes. + const scale = Math.abs(Math.round(order.volumeCbm * 1000) / 1000 - order.volumeCbm) < 0.0000001 ? 1000 : 10000 + const ticks = Math.round(order.volumeCbm * scale) + if (Math.abs(ticks / scale - order.volumeCbm) > 0.0000001) throw Error('SOURCE_VOLUME_EXCEEDS_SERVER_PRECISION') + const lower = Math.floor(ticks / order.packageCount), extra = ticks % order.packageCount + const groups = [{ count: order.packageCount - extra, ticks: lower }, { count: extra, ticks: lower + 1 }].filter((g) => g.count) + return groups.map((group) => { + if (group.ticks < 1) throw Error('VOLUME_TOO_SMALL_FOR_CARTON_COUNT') + const unitVolumeCbm = group.ticks / scale + for (const rule of sorted) { + const addition = 2 * Number(rule.edgeAdditionCm) + const edge = round(Math.cbrt((unitVolumeCbm - 0.00005) * 1e6) - addition, 3) + const raw = round((edge + addition) ** 3 / 1e6, 6) + const serverTicks = Math.ceil(raw * 10000 - 1e-8) + if (edge > 0 && choose(edge) === rule && serverTicks === Math.round(unitVolumeCbm * 10000)) { + return { length: edge, width: edge, height: edge, count: group.count, + unitVolumeCbm, edgeAdditionCm: Number(rule.edgeAdditionCm), boxType: rule.boxType } + } + } + throw Error('NO_TOTAL_PRESERVING_DIMENSIONS_FOR_CURRENT_RULES') + }) +} + +function oneValue(value, fallback, max) { + // Multiline/oversized identifiers are not silently truncated into a different real ID. + return value && !/[\r\n]/.test(value) && value.length <= max ? value : fallback +} + +export function buildPayload(order, route, customer, imageId, marker) { + const shape = measurementShape(order) + const shipmentId = oneValue(order.shipmentId, `TEST-SHIP-${marker}`, 64) + const referenceId = oneValue(order.referenceId, `TEST-REF-${marker}`, 64) + const fba = order.destinationType === 'FBA' + return { + customerName: customer, transportMode: 'SEA', tradeTerm: 'FOB', fulfillmentType: 'STANDARD', + pickupMethod: 'SELF_EXPRESS', receivingWarehouseId: route.receiving.id, + receivingWarehouseName: route.receiving.warehouseName, + channelCode: route.channel.channelCode, channelName: route.channel.channelName, + pricingTaxMode: order.pricingTaxMode, taxMode: order.taxMode, containerMode: 'LCL', + customsInspection: false, destinationType: order.destinationType, + destinationWarehouseId: route.destination.id, destinationWarehouseName: route.destination.warehouseName, + destinationCountryCode: route.destination.countryCode, amazonWarehouseCode: fba ? route.destination.warehouseCode : null, + shipmentId: fba ? shipmentId : null, referenceId: fba ? referenceId : null, + customerRemark: `${marker} 测试导入,源入仓码=${order.sourceCode};源客户=${order.sourceCustomer};源渠道=${order.sourceChannel};箱数/重量/体积取源实际值;箱规为等体积推算,照片及缺失产品字段为测试占位;非真实测量证据`.slice(0, 1000), + cargoes: [{ cargoName: oneValue(order.cargoName, '测试导入汇总货物(详见源表)', 128), + cargoNameEn: 'TEST imported goods', skuNo: oneValue(order.sku, `TEST-SKU-${marker}`, 64), + packageType: 'CARTON', packageCount: order.packageCount, grossWeightKg: order.weightKg, + volumeCbm: order.volumeCbm, lengthCm: shape.length, widthCm: shape.width, heightCm: shape.height, + unitsPerPackage: 1, totalQuantity: order.packageCount, netWeightKg: order.weightKg, + hsCode: '0000000000', customsHsCode: '0000000000', unitDeclaredValue: 1, + declaredValue: order.packageCount, currency: 'USD', material: 'TEST_UNKNOWN', usage: '测试流程', + model: 'TEST', brand: 'TEST', productLengthCm: 1, productWidthCm: 1, productHeightCm: 1, + shipmentId: fba ? shipmentId : null, referenceId: fba ? referenceId : null, + upcNo: '000000000000', productImageFileId: imageId, dangerousGoods: false, woodenProduct: false, + cargoRemark: `${marker} 聚合源表行${order.rows.join(',')};商品数量/申报价/材质/HS/属性为测试占位,禁止用于真实报关`, + }], + } +} + +export function buildSourceDraft(order, route, customer, marker) { + // Drafts deliberately retain missing fields. No inferred dimensions, fake photo, + // customs declaration values or dangerous-goods assertions are submitted here. + // User-confirmed import rule: absent wooden-product information means false. + return { + customerName: customer, transportMode: 'SEA', fulfillmentType: order.fulfillmentType, + pickupMethod: 'SELF_EXPRESS', receivingWarehouseId: route.receiving.id, + receivingWarehouseName: route.receiving.warehouseName, + channelCode: route.channel.channelCode, channelName: route.channel.channelName, + pricingTaxMode: order.pricingTaxMode, taxMode: order.taxMode, destinationType: order.destinationType, + destinationWarehouseId: route.destination.id, destinationWarehouseName: route.destination.warehouseName, + destinationCountryCode: route.destination.countryCode, + amazonWarehouseCode: order.destinationType === 'FBA' ? route.destination.warehouseCode : null, + customerRemark: `${marker} 源数据草稿;源入仓码=${order.sourceCode};源客户=${order.sourceCustomer};箱数/重量/体积取源实际值。缺失产品字段和实测证据待补充。`.slice(0, 1000), + cargoes: [{ cargoName: oneValue(order.cargoName, '', 128), skuNo: oneValue(order.sku, null, 64), + packageType: 'CARTON', woodenProduct: false, packageCount: order.packageCount, grossWeightKg: order.weightKg, + volumeCbm: order.volumeCbm, shipmentId: oneValue(order.shipmentId, null, 64), + referenceId: oneValue(order.referenceId, null, 64), + cargoRemark: `${marker} 源表行${order.rows.join(',')};多货物原文请核对源表` }], + } +} + +export function requireSuccess(response) { + if (response?.http !== 200 || String(response?.body?.code) !== '200') { + const error = Error(`HTTP_${response?.http}_CODE_${response?.body?.code || 'MISSING'}`) + error.evidence = { method: response?.method, path: response?.path, http: response?.http, + code: response?.body?.code, message: response?.body?.message, + requestId: response?.requestId, traceId: response?.traceId } + throw error + } + return response.body.data +} + +async function main() { + const args = new Map() + const allowed = new Set(['input', 'sheet', 'python', 'output-dir', 'batch-id', 'stage', 'limit', 'concurrency', 'customer-name', 'origin-warehouse-name', 'allow-test-placeholders', 'expected-host']) + for (let i = 2; i < process.argv.length; i += 2) { + const name = process.argv[i].replace(/^--/, ''), value = process.argv[i + 1] + if (!allowed.has(name) || !value || value.startsWith('--')) throw Error(`无效参数:${name}`) + args.set(name, value) + } + for (const key of ['input', 'python', 'output-dir', 'batch-id', 'expected-host']) if (!args.get(key)) throw Error(`缺少 --${key}`) + const stage = args.get('stage') || 'preflight' + if (!['preflight', 'create', 'pending-review', 'wait-split'].includes(stage)) throw Error('stage 必须为 preflight/create/pending-review/wait-split') + if (['pending-review', 'wait-split'].includes(stage) && args.get('allow-test-placeholders') !== 'true') { + throw Error('此源表缺少提交必填字段,须显式允许测试占位') + } + const limit = Number(args.get('limit') || 1) + if (!Number.isInteger(limit) || limit < 1) throw Error('limit 必须为正整数') + const concurrency = Number(args.get('concurrency') || 1) + if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 10) throw Error('concurrency 必须是1到10的整数') + const options = validateExecutionOptions(parseArguments(['--modules', 'orders,inbound-measurement', '--quiet', 'true'])) + if (new URL(options.gateway).hostname !== args.get('expected-host') || !/^test[-.]/.test(args.get('expected-host'))) throw Error('只允许明确匹配的测试域名') + const input = resolve(args.get('input')) + const bytes = await readFile(input) + const rows = JSON.parse(execFileSync(args.get('python'), ['-c', + 'import openpyxl,json,sys; w=openpyxl.load_workbook(sys.argv[1],read_only=True,data_only=True); print(json.dumps(list(w[sys.argv[2]].values),ensure_ascii=False,default=str)); w.close()', + input, args.get('sheet') || 'D1512开始'], { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024, + env: { ...process.env, PYTHONIOENCODING: 'utf-8' } })) + const normalized = normalizeRows(rows) + const directory = resolve(args.get('output-dir')) + await mkdir(directory, { recursive: true }) + const statePath = resolve(directory, 'state.json') + const identity = { sourceHash: hash(bytes), batchId: args.get('batch-id'), gateway: hash(options.gateway), + customer: args.get('customer-name') || 'IT测试', origin: args.get('origin-warehouse-name') || '义乌仓' } + let state + try { state = JSON.parse(await readFile(statePath, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error } + if (state && JSON.stringify(state.identity) !== JSON.stringify(identity)) throw Error('批次/源文件/目标发生变化,请使用独立输出目录') + state ||= { identity, orders: {}, startedAt: new Date().toISOString() } + const lockPath = resolve(directory, 'run.lock') + const lock = await acquireRunLock(lockPath) + let saveQueue = Promise.resolve() + const save = () => { + saveQueue = saveQueue.then(async () => { + const temporaryPath = `${statePath}.${process.pid}.tmp` + await writeFile(temporaryPath, JSON.stringify(state, null, 2), 'utf8') + for (let attempt = 0; ; attempt++) { + try { await rename(temporaryPath, statePath); break } + catch (error) { + if (!['EPERM', 'EBUSY', 'EACCES'].includes(error.code) || attempt >= 5) throw error + await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1))) + } + } + }) + return saveQueue + } + const ctx = new AcceptanceRun(options) + const api = async (method, path, body, token = ctx.token, headers = {}) => requireSuccess(await ctx.request(method, path, body, token, headers)) + try { + ctx.token = (await api('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '')).accessToken + const merchant = (await api('POST', '/api/auth/login', { loginId: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }, '')).accessToken + if (!ctx.token || !merchant) throw Error('LOGIN_TOKEN_MISSING') + const profile = await api('GET', '/api/merchant/v1/iam/tenant/profile', undefined, merchant) + if (profile.tenantName !== identity.customer || profile.status !== 'ACTIVE') throw Error('CUSTOMER_MISMATCH_OR_INACTIVE') + const list = async (type) => { + const result = [] + for (let page = 1; ; page++) { + const data = await api('GET', `/api/merchant/v1/warehouses?page=${page}&size=200&status=ACTIVE&warehouseType=${type}`, undefined, merchant) + if (!Array.isArray(data.records) || !Number.isFinite(Number(data.total))) throw Error('WAREHOUSE_PAGE_INVALID') + result.push(...data.records) + if (result.length >= Number(data.total)) return result + if (!data.records.length || page >= 100) throw Error('WAREHOUSE_PAGE_INCOMPLETE') + } + } + const origins = (await list('DOMESTIC')).filter((item) => item.warehouseName === identity.origin) + if (origins.length !== 1) throw Error('ORIGIN_NOT_UNIQUE') + const receiving = origins[0] + const channels = await api('GET', `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${receiving.id}`, undefined, merchant) + const catalog = { receiving, channels, destinations: [...await list('FBA'), ...await list('OVERSEAS')] } + const pallets = [] + if (stage === 'wait-split') { + for (const code of PALLETS) { + const page = await api('GET', `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${receiving.id}&palletCode=${encodeURIComponent(code)}`) + const found = page.records?.filter((item) => item.palletCode === code && String(item.warehouseId) === String(receiving.id)) + if (found?.length !== 1) throw Error(`REQUIRED_PALLET_MISSING_${code}`) + pallets.push(found[0]) + } + } + const policy = await api('GET', '/api/merchant/v1/orders/first-leg/inbound-code-policy', undefined, merchant) + if (policy.mode !== 'AUTO') throw Error('INBOUND_CODE_POLICY_NOT_AUTO') + let sharedResources = null + if (['pending-review', 'wait-split'].includes(stage)) { + let dimensionRules = null + if (stage === 'wait-split') { + const dimensionConfig = await api('GET', `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${profile.id}`) + totalPreservingShapes(normalized.orders.find((order) => order.problems.length === 0), dimensionConfig.rules) + dimensionRules = dimensionConfig.rules + } + let productImageFileId = state.sharedResources?.productImageFileId + if (!productImageFileId) { + const image = requireSuccess(await ctx.requestMultipart('POST', '/api/merchant/v1/files/product-images', + { content: PNG, type: 'image/png', name: `TEST-XLSX-${state.identity.batchId}.png` }, merchant)) + productImageFileId = image?.fileId + if (!productImageFileId) throw Error('SHARED_PRODUCT_IMAGE_ID_MISSING') + } + state.sharedResources = { productImageFileId, ...(dimensionRules ? { dimensionRules } : {}) } + sharedResources = state.sharedResources + await save() + } + const candidates = [], rejected = [...normalized.excluded] + for (const order of normalized.orders) { + try { candidates.push({ order, route: matchRoute(order, catalog) }) } + catch (error) { rejected.push({ key: order.key, rows: order.rows, reason: error.message }) } + } + const preflight = { sourceRows: rows.length - 1, orders: normalized.orders.length, + matched: candidates.length, rejected, channelCount: channels.length, palletCodes: PALLETS, + note: '匹配仅验证目录,写入前还会用实际重量体积试价。私人仓、需工作托盘的一件代发及歧义源记录不擅自替代。' } + await writeFile(resolve(directory, 'preflight.json'), JSON.stringify(preflight, null, 2), 'utf8') + console.log(JSON.stringify({ stage, sourceRows: preflight.sourceRows, orders: preflight.orders, matched: candidates.length, rejected: rejected.length })) + await save() + if (stage === 'preflight') return + await executeCandidates({ candidates, limit, concurrency, state, save, api, ctx, merchant, profile, pallets, + sharedResources, stage }) + } finally { + await saveQueue.catch(() => {}) + await lock.close() + await unlink(lockPath) + } +} + +async function acquireRunLock(lockPath) { + try { + const handle = await open(lockPath, 'wx') + await handle.writeFile(String(process.pid), 'utf8') + return handle + } catch (error) { + if (error.code !== 'EEXIST') throw error + const existingPid = Number(await readFile(lockPath, 'utf8').catch(() => '')) + let alive = false + if (Number.isInteger(existingPid) && existingPid > 0) { + try { process.kill(existingPid, 0); alive = true } catch { /* Stale PID. */ } + } + if (alive) throw Error(`批次正在运行,pid=${existingPid}`) + await unlink(lockPath) + const handle = await open(lockPath, 'wx') + await handle.writeFile(String(process.pid), 'utf8') + return handle + } +} + +export async function executeCandidates({ candidates, limit, concurrency = 1, state, save, api, ctx, merchant, profile, pallets, + sharedResources, stage }) { + let attempted = 0, completed = 0, succeeded = 0, failed = 0, skipped = 0, stopReason = null + const pending = [] + for (const { order, route } of candidates) { + // An interrupted write is not permission to create another order. Keep its last + // checkpoint for inspection. A reset database is always a NEW batch/directory. + const previous = state.orders[order.key] + const retryablePricingWarning = stage === 'pending-review' && previous?.status === 'FAILED' + && previous.stage === 'estimate' && !previous.orderId && ['A0404', 'A0422'].includes(previous.evidence?.code) + if (previous && !retryablePricingWarning) { skipped++; continue } + if (pending.length >= limit) break + pending.push({ order, route, previous }) + } + for (let offset = 0; offset < pending.length; offset += concurrency) { + const batch = pending.slice(offset, offset + concurrency) + await Promise.all(batch.map(async ({ order, route, previous }) => { + const marker = `XLSX-${hash(`${state.identity.batchId}:${order.key}`).slice(0, 24)}` + const entry = { rows: order.rows, marker, status: 'STARTED', stage: 'estimate', startedAt: new Date().toISOString(), + ...(previous ? { priorAttempts: [...(previous.priorAttempts || []), { status: previous.status, stage: previous.stage, + error: previous.error, evidence: previous.evidence, finishedAt: previous.finishedAt }] } : {}) } + state.orders[order.key] = entry + await save() + attempted++ + try { + let estimate = null + try { + estimate = await api('POST', '/api/merchant/v1/first-leg/pricing/estimate', { + originWarehouseId: route.receiving.id, channelCode: route.channel.channelCode, + destinationCode: route.destination.warehouseCode, destinationType: order.destinationType, + destinationWarehouseId: route.destination.id, destinationName: route.destination.warehouseName, + pricingTaxMode: order.pricingTaxMode, chargeMode: order.chargeMode, + actualWeightKg: order.weightKg, actualVolumeCbm: order.volumeCbm, + }, merchant) + } catch (error) { + if (stage !== 'pending-review' || !['A0404', 'A0422'].includes(error.evidence?.code)) throw error + entry.pricingWarning = { code: error.evidence.code, message: error.evidence.message, + requestId: error.evidence.requestId, traceId: error.evidence.traceId } + } + if (estimate && (estimate.channelCode !== route.channel.channelCode || estimate.pricingTaxMode !== order.pricingTaxMode)) { + throw Error('ESTIMATE_ROUTE_MISMATCH') + } + let payload + if (stage === 'create') { + payload = buildSourceDraft(order, route, profile.tenantName, marker) + } else { + if (!sharedResources?.productImageFileId) throw Error('SHARED_PRODUCT_IMAGE_MISSING') + if (stage === 'wait-split') { + if (!Array.isArray(sharedResources.dimensionRules)) throw Error('SHARED_DIMENSION_RULES_MISSING') + entry.dimensionPlan = totalPreservingShapes(order, sharedResources.dimensionRules) + } + entry.productImageFileId = sharedResources.productImageFileId + payload = buildPayload(order, route, profile.tenantName, sharedResources.productImageFileId, marker) + } + entry.stage = 'create'; entry.payloadHash = hash(JSON.stringify(payload)); await save() + entry.orderId = String(await api('POST', '/api/merchant/v1/orders/first-leg', payload, merchant, + { 'Idempotency-Key': `${marker}-create` })) + if (!entry.orderId || entry.orderId === 'null' || entry.orderId === 'undefined') throw Error('ORDER_ID_MISSING') + entry.stage = 'draft-created'; await save() + const clientPath = `/api/merchant/v1/orders/first-leg/${entry.orderId}` + const draft = await api('GET', clientPath, undefined, merchant) + if (String(draft.order?.id) !== entry.orderId || !draft.order?.customerRemark?.includes(marker)) throw Error('ORDER_PROVENANCE_MISMATCH') + const cargo = draft.cargoes?.[0] + if (draft.cargoes?.length !== 1 || Number(cargo.packageCount) !== order.packageCount + || Math.abs(Number(cargo.grossWeightKg) - order.weightKg) > 0.001 + || Math.abs(Number(cargo.volumeCbm) - order.volumeCbm) > 0.00001) throw Error('DRAFT_SOURCE_FACTS_MISMATCH') + entry.orderNo = draft.order.orderNo + entry.declarationVerified = true + if (['pending-review', 'wait-split'].includes(stage)) { + entry.stage = 'submit'; await save() + await api('POST', `${clientPath}/submit`, undefined, merchant, { 'Idempotency-Key': `${marker}-submit` }) + } + if (stage === 'pending-review') { + const submitted = await api('GET', clientPath, undefined, merchant) + const unified = await api('GET', `/api/merchant/v1/orders/${entry.orderId}`, undefined, merchant) + if (submitted.order?.status !== 'SUBMITTED' || unified.summary?.mainStatus !== 'PENDING_REVIEW') { + const error = Error('PENDING_REVIEW_NOT_CONFIRMED') + error.evidence = { clientStatus: submitted.order?.status, mainStatus: unified.summary?.mainStatus } + throw error + } + entry.stage = 'PENDING_REVIEW' + entry.verification = { clientStatus: submitted.order.status, mainStatus: unified.summary.mainStatus, + expectedPackages: order.packageCount, actualPackages: Number(submitted.cargoes?.[0]?.packageCount), + expectedCargoWeightKg: order.weightKg, actualCargoWeightKg: Number(submitted.cargoes?.[0]?.grossWeightKg), + expectedVolumeCbm: order.volumeCbm, actualVolumeCbm: Number(submitted.cargoes?.[0]?.volumeCbm) } + } + if (stage === 'wait-split') { + entry.stage = 'approve'; await save() + const platformPath = `/api/platform/v1/orders/first-leg/${entry.orderId}` + const q = `tenantId=${profile.id}` + await api('POST', `${platformPath}/approve?${q}`) + const unified = await poll(async () => api('GET', `/api/platform/v1/orders/${entry.orderId}?${q}`), + (data) => data.summary?.inboundId && ['WAIT_MEASURE', 'MEASURING'].includes(data.summary?.mainStatus)) + entry.inboundId = String(unified.summary.inboundId) + entry.stage = 'inbound-created'; await save() + await api('POST', `${platformPath}/accept?${q}`) + await api('POST', `${platformPath}/start?${q}`) + await measureOrder({ order, entry, ctx, api, pallets, tenantId: String(profile.id), save }) + } + entry.status = 'SUCCEEDED'; succeeded++ + } catch (error) { + entry.status = 'FAILED'; entry.error = error.message; entry.evidence = error.evidence; failed++ + if (error.evidence?.code === 'A0401') stopReason ||= 'AUTHENTICATION_EXPIRED' + } + completed++ + entry.finishedAt = new Date().toISOString(); await save() + if (entry.status === 'FAILED' || completed % 100 === 0 || completed === pending.length) { + console.log(JSON.stringify({ attempted, completed, succeeded, failed, skipped, rows: entry.rows, + stage: entry.stage, status: entry.status, orderNo: entry.orderNo, inboundCode: entry.inboundCode, + error: entry.error, evidence: entry.evidence })) + } + })) + if (stopReason) break + // A small concurrent pilot must expose a common deployment/data defect before multiplying it. + if (failed >= 3 && succeeded === 0) break + } + state.lastRun = { attempted, completed, succeeded, failed, skipped, concurrency, stage, stopReason, + finishedAt: new Date().toISOString() } + await save() + if (failed) process.exitCode = 1 +} + +async function poll(read, predicate) { + let data + for (let i = 0; i < 30; i++) { + data = await read() + if (predicate(data)) return data + if (i < 29) await new Promise((resolve) => setTimeout(resolve, 1000)) + } + const error = Error('PROJECTION_NOT_READY') + error.evidence = { processStatus: data?.processStatus || data?.summary?.mainStatus, + measurementStatus: data?.measurementStatus, measureReviewStatus: data?.measureReviewStatus } + throw error +} + +export async function saveMeasurementDraft({ api, measurementPath, base, q, order, entry, marker, + attempts = 3, wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), onRetry }) { + for (let attempt = 1; attempt <= attempts; attempt++) { + const measured = await api('GET', measurementPath) + if (measured.items?.length !== entry.dimensionPlan.length) throw Error('UNEXPECTED_MEASUREMENT_ITEMS') + const payload = { + measurementId: measured.measurementId, version: Number(measured.version), + actualGoodsName: oneValue(order.cargoName, '测试导入汇总货物', 128), + items: entry.dimensionPlan.map((group) => { + const item = measured.items.find((value) => String(value.id) === group.itemId) + if (!item) throw Error('MEASUREMENT_GROUP_MISSING') + return { inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification, + packageCount: group.count, lengthCm: group.length, widthCm: group.width, heightCm: group.height, + intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null, + outerLengthCm: group.length, outerWidthCm: group.width, outerHeightCm: group.height, + coefficientMode: 'ADD', remark: `${marker} TEST按总体积分组反算箱规,非实测` } + }), + } + try { return await api('PUT', `${base}/measurement/draft?${q}`, payload) } + catch (error) { + if (error.evidence?.code !== 'S0500' || attempt === attempts) throw error + await onRetry?.({ attempt, code: error.evidence.code, requestId: error.evidence.requestId, traceId: error.evidence.traceId }) + await wait(250 * attempt) + } + } + throw Error('MEASUREMENT_DRAFT_RETRY_EXHAUSTED') +} + +async function measureOrder({ order, entry, ctx, api, pallets, tenantId, save }) { + const inboundId = entry.inboundId, q = `tenantId=${tenantId}`, marker = entry.marker + const inboundPath = `/api/platform/inbounds/${inboundId}?${q}` + const base = `/api/platform/v1/first-leg/inbounds/${inboundId}` + const measurementPath = `${base}/measurement?${q}` + const inbound = await api('GET', inboundPath) + if (String(inbound.summary?.tenantId) !== tenantId || String(inbound.summary?.id) !== inboundId) throw Error('INBOUND_PROVENANCE_MISMATCH') + entry.inboundCode = inbound.summary.inboundCode + entry.stage = 'carton-labels'; await save() + const pdf = await ctx.requestBinary('POST', `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}`) + if (pdf.http !== 200 || !pdf.contentType?.includes('pdf') || Buffer.from(pdf.bytes || []).subarray(0, 4).toString() !== '%PDF') throw Error('LABEL_GENERATION_FAILED') + const shape = measurementShape(order) + entry.stage = 'carton-scan'; await save() + let cartonIndex = 1 + for (const group of entry.dimensionPlan) { + const form = new FormData() + form.append('data', new Blob([JSON.stringify({ boxCode: `${entry.inboundCode}#${order.packageCount}-${cartonIndex}`, + length: group.length, width: group.width, height: group.height, + machineNo: 'TEST-XLSX-SIMULATED', measuredAt: new Date().toISOString().slice(0, 19) })], { type: 'application/json' })) + form.append('image', new Blob([PNG], { type: 'image/png' }), 'TEST-simulated-carton.png') + const scan = requireSuccess(await ctx.requestMultipart('POST', `${base}/measurement/carton-scans?${q}`, form)) + if (!scan?.itemId || !scan?.measurementId) throw Error('SCAN_IDS_MISSING') + entry.measurementId = scan.measurementId; await save() + await api('PUT', `${base}/measurement/items/${scan.itemId}/confirmed-package-count?${q}`, + { measurementVersion: Number(scan.measurementVersion), confirmedPackageCount: group.count }) + group.itemId = String(scan.itemId) + cartonIndex += group.count + } + const draft = await saveMeasurementDraft({ api, measurementPath, base, q, order, entry, marker, + onRetry: async (retry) => { entry.retries ||= []; entry.retries.push({ stage: 'measurement-draft', ...retry }); await save() } }) + const draftCheck = await api('GET', measurementPath) + if (Math.abs(Number(draftCheck.actualVolumeCbm) - order.volumeCbm) > 0.0000001 + || Number(draftCheck.actualPackageCount) !== order.packageCount) throw Error('DRAFT_VOLUME_OR_COUNT_MISMATCH') + const itemVolumeWeight = draftCheck.items.reduce((sum, value) => sum + Number(value.volumeWeightKg), 0) + if (Math.abs(itemVolumeWeight - Number(draftCheck.volumeWeightKg)) > 0.0000001) throw Error('SERVER_ITEM_TOTAL_VOLUME_WEIGHT_ROUNDING_MISMATCH') + entry.stage = 'weights'; entry.weights = []; await save() + let netSoFar = 0 + for (let index = 0; index < shape.palletCount; index++) { + const pallet = pallets[Number.parseInt(hash(`${marker}:${index}`).slice(0, 8), 16) % pallets.length] + const upload = requireSuccess(await ctx.requestMultipart('POST', `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, + { content: PNG, type: 'image/png', name: `TEST-weight-${index + 1}.png` })) + const created = await api('POST', `${base}/weight-results?${q}`, { fileId: upload.fileId, palletId: pallet.id, + ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null }) + const tare = Number(created.palletTareWeightKg) + if (created.palletTareWeightKg == null || !Number.isFinite(tare) || tare < 0) throw Error('PALLET_TARE_SNAPSHOT_MISSING') + // Source gross is packed cargo weight, NOT scale weight including the reusable pallet. + const boxes = Math.min(30, order.packageCount - index * 30) + const net = index === shape.palletCount - 1 ? round(order.weightKg - netSoFar, 3) + : round(order.weightKg * boxes / order.packageCount, 3) + const confirmed = await api('POST', `${base}/weight-results/${created.id}/confirm?${q}`, + { version: Number(created.version), adjustedWeightValue: round(net + tare, 3), adjustedWeightUnit: 'KG', + adjustmentReason: `${marker} TEST模拟称重:源货重按箱分摊,再加托盘皮重` }) + if (Math.abs(Number(confirmed.netWeightKg) - net) > 0.001) throw Error('CONFIRMED_NET_WEIGHT_MISMATCH') + netSoFar += net + entry.weights.push({ id: created.id, palletCode: pallet.palletCode, netWeightKg: net, tareWeightKg: tare }); await save() + } + entry.stage = 'measurement-submit'; await save() + await api('POST', `${base}/measurement/submit?${q}`, { measurementId: draft.measurementId, version: Number(draft.version) }) + const pending = await api('GET', inboundPath) + entry.stage = 'measurement-approve'; await save() + await api('POST', `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${q}`, + { measurementId: draft.measurementId, expectedVersion: Number(pending.summary?.version), reason: 'TEST源数据流程试跑' }, ctx.token, + { 'Idempotency-Key': `${marker}-measurement-approve` }) + const final = await poll(() => api('GET', measurementPath), (data) => data.processStatus === 'WAIT_SPLIT') + entry.verification = { processStatus: final.processStatus, measurementStatus: final.measurementStatus, + reviewStatus: final.measureReviewStatus, expectedPackages: order.packageCount, actualPackages: Number(final.actualPackageCount), + expectedCargoWeightKg: order.weightKg, actualNetWeightKg: Number(final.actualNetWeightKg), + sourceVolumeCbm: order.volumeCbm, measuredVolumeCbm: Number(final.actualVolumeCbm), + volumeDifferenceCbm: round(Number(final.actualVolumeCbm) - order.volumeCbm), + sourceVolumeMatched: Math.abs(Number(final.actualVolumeCbm) - order.volumeCbm) <= 0.0000001, + simulatedDimensions: entry.dimensionPlan, confirmedWeightImages: entry.weights.length } + if (final.measureReviewStatus !== 'APPROVED' || final.measurementStatus !== 'APPROVED' + || Number(final.actualPackageCount) !== order.packageCount + || Math.abs(Number(final.actualNetWeightKg) - order.weightKg) > 0.001 + || !entry.verification.sourceVolumeMatched) throw Error('FINAL_SOURCE_FACTS_MISMATCH') + entry.stage = 'WAIT_SPLIT' +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch((error) => { console.error(JSON.stringify({ error: error.message, evidence: error.evidence })); process.exitCode = 1 }) +} diff --git a/scripts/real-order-xlsx-test-env.test.mjs b/scripts/real-order-xlsx-test-env.test.mjs new file mode 100644 index 000000000..1bf1fdfb5 --- /dev/null +++ b/scripts/real-order-xlsx-test-env.test.mjs @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { normalizeRows, channelName, matchRoute, measurementShape, buildSourceDraft, buildPayload, + requireSuccess, executeCandidates, totalPreservingShapes, saveMeasurementDraft } from './real-order-xlsx-test-env.mjs' + +const header = ['进仓编号', '客户', '渠道', '亚马逊仓', '实际件数', '实际毛重', '实际立方', '预报件数', '买单/退税', '品名', 'SKU', 'FBA货件编号', '跟踪编号'] +const row = ['ABCD2606010001', 'Example', '合德海卡包税', 'ABC1', 31, 100, 2, 31, '买单', 'Goods', 'SKU1', 'SHIP1', 'REF1'] +const order = () => normalizeRows([header, row]).orders[0] +const catalog = { receiving: { id: '1', warehouseName: 'Origin' }, + channels: [{ channelName: '合德海卡', channelCode: 'CHANNEL', pricingTaxModes: ['TAX_INCLUDED'] }], + destinations: [{ id: '2', warehouseType: 'FBA', warehouseCode: 'ABC1', warehouseName: 'ABC1', countryCode: 'US' }] } + +test('source counts remain actual, including after forecast differs', () => { + const modified = [...row]; modified[7] = 99 + assert.equal(normalizeRows([header, modified]).orders[0].packageCount, 31) +}) +test('missing and zero actual data do not fall back to forecast', () => { + for (const missing of [null, '', 0]) { + const modified = [...row]; modified[4] = missing + assert.ok(normalizeRows([header, modified]).orders[0].problems.includes('MISSING_实际件数')) + } +}) +test('invalid source codes are excluded, not converted into orders', () => { + const modified = [...row]; modified[0] = 'container subtotal' + const actual = normalizeRows([header, modified]) + assert.equal(actual.orders.length, 0); assert.equal(actual.excluded.length, 1) +}) +test('explicit declaration changes use the stated final mode', () => { + for (const [source, target] of [['买单改退税', 'TAX_REFUND'], ['退税改买单', 'BUY_DECLARATION']]) { + const modified = [...row]; modified[8] = source + const result = normalizeRows([header, modified]).orders[0] + assert.equal(result.taxMode, target); assert.deepEqual(result.problems, []) + } +}) +test('split rows aggregate actual counts once and reconcile forecast', () => { + const a = [...row], b = [...row]; a[4] = 15; b[4] = 16 + const result = normalizeRows([header, a, b]).orders[0] + assert.equal(result.packageCount, 31); assert.deepEqual(result.rows, [2, 3]); assert.deepEqual(result.problems, []) + b[4] = 15 + assert.ok(normalizeRows([header, a, b]).orders[0].problems.includes('SPLIT_COUNT_MISMATCH')) +}) +test('split weight and volume totals use the persisted order precision', () => { + const a = [...row], b = [...row] + a[4] = 90; b[4] = 300; a[7] = 390; b[7] = 390 + a[5] = 1137.461538; b[5] = 3791.538 + a[6] = 7.256538462; b[6] = 24.188 + const result = normalizeRows([header, a, b]).orders[0] + assert.equal(result.weightKg, 4929) + assert.equal(result.volumeCbm, 31.4445) +}) +test('conflicting channels on same inbound code block import', () => { + const b = [...row]; b[2] = '其他包税' + assert.ok(normalizeRows([header, row, b]).orders[0].problems.includes('CONFLICT_渠道')) +}) +test('channel suffix normalization never substitutes another base channel', () => { + assert.equal(channelName('合德海派包税\n(FedEx)'), '合德海派') + assert.equal(channelName('合德海卡不包税'), '合德海卡') + assert.throws(() => matchRoute({ ...order(), channelName: 'Unknown' }, catalog)) +}) +test('excluded-tax estimation uses source billing unit and never guesses missing units', () => { + const modified = [...row]; modified[2] = '合德海卡不包税' + const columns = [...header, '单位'] + const result = normalizeRows([columns, [...modified, 'CBM']]).orders[0] + assert.equal(result.chargeMode, 'CBM'); assert.deepEqual(result.problems, []) + assert.ok(normalizeRows([columns, [...modified, '']]).orders[0].problems.includes('UNKNOWN_OR_CONFLICTING_CHARGE_MODE')) +}) +test('ambiguous destinations and unavailable tax modes fail closed', () => { + assert.throws(() => matchRoute(order(), { ...catalog, destinations: [...catalog.destinations, ...catalog.destinations] })) + assert.throws(() => matchRoute({ ...order(), pricingTaxMode: 'TAX_EXCLUDED' }, catalog)) +}) +test('private addresses and dropship are explicit gaps, not random replacements', () => { + assert.throws(() => matchRoute({ ...order(), destinationType: 'PRIVATE' }, catalog), /PRIVATE_ADDRESS/) + assert.throws(() => matchRoute({ ...order(), fulfillmentType: 'ONE_CLICK_DROPSHIP' }, catalog), /WORK_PALLET/) +}) +test('logical pallet count uses ceiling of boxes divided by 30', () => { + for (const [boxes, expected] of [[1, 1], [30, 1], [31, 2], [300, 10], [1000, 34]]) { + assert.equal(measurementShape({ ...order(), packageCount: boxes }).palletCount, expected) + } +}) +test('inferred groups conserve volume after ADD and server per-carton rounding', () => { + const rules = [[30, 20, 20], [40, 30, 30], [50, 40, 40], [60, 50, 50]].map((dims, index) => + ({ boxType: String(index), innerLengthCm: dims[0], innerWidthCm: dims[1], innerHeightCm: dims[2], edgeAdditionCm: 2 })) + for (const [packageCount, volumeCbm] of [[8, 0.403], [17, 1.545], [7, 0.626], [31, 2], [300, 23.457], [1, 0.1], [1000, 98.7654]]) { + const plan = totalPreservingShapes({ ...order(), packageCount, volumeCbm }, rules) + assert.ok(plan.length <= 2) + assert.equal(plan.reduce((n, g) => n + g.count, 0), packageCount) + const ticks = plan.reduce((n, g) => { + const raw = Number(((g.length + 2 * g.edgeAdditionCm) ** 3 / 1e6).toFixed(6)) + return n + Math.ceil(raw * 10000 - 1e-8) * g.count + }, 0) + assert.equal(ticks, Math.round(volumeCbm * 10000)) + if (Number.isInteger(Math.round(volumeCbm * 1e6) / 1000)) { + const detailWeight = plan.reduce((sum, group) => sum + Math.ceil(group.unitVolumeCbm * group.count * 167 * 1000 - 1e-8), 0) + assert.equal(detailWeight, Math.ceil(volumeCbm * 167 * 1000 - 1e-8)) + } + } + assert.throws(() => totalPreservingShapes({ ...order(), volumeCbm: 0.123456 }, rules), /PRECISION/) +}) +test('source-only draft does not invent measurement or product evidence', () => { + const payload = buildSourceDraft(order(), matchRoute(order(), catalog), 'Example', 'MARKER') + assert.equal(payload.cargoes[0].packageCount, 31) + assert.equal(payload.cargoes[0].grossWeightKg, 100) + assert.equal(payload.cargoes[0].volumeCbm, 2) + assert.equal(payload.cargoes[0].woodenProduct, false) + for (const key of ['lengthCm', 'productImageFileId', 'hsCode', 'dangerousGoods']) assert.equal(key in payload.cargoes[0], false) +}) +test('simulated payload is labeled, preserving source measures without randomization', () => { + const payload = buildPayload(order(), matchRoute(order(), catalog), 'Example', 'image', 'MARKER') + assert.match(payload.customerRemark, /非真实测量证据/) + assert.equal(payload.cargoes[0].volumeCbm, 2) + assert.match(payload.cargoes[0].cargoRemark, /禁止用于真实报关/) +}) +test('HTTP business errors preserve diagnostic evidence', () => { + assert.throws(() => requireSuccess({ http: 200, path: '/example', body: { code: 'A0409' }, requestId: 'req' }), + (error) => error.evidence.code === 'A0409' && error.evidence.requestId === 'req') +}) +test('measurement draft retries a transient server error after refreshing the version', async () => { + let reads = 0, writes = 0 + const retries = [] + const entry = { dimensionPlan: [{ itemId: 'item-1', count: 31, length: 40, width: 30, height: 30 }] } + const api = async (method) => { + if (method === 'GET') return { measurementId: 'measurement-1', version: ++reads, + items: [{ id: 'item-1', inboundDetailId: 'detail-1', itemNo: 1, boxSpecification: 'TEST' }] } + writes++ + if (writes === 1) { const error = Error('temporary'); error.evidence = { code: 'S0500', requestId: 'req-1' }; throw error } + return { measurementId: 'measurement-1', version: 3 } + } + const result = await saveMeasurementDraft({ api, measurementPath: '/measurement', base: '/inbound/1', q: 'tenantId=1', + order: order(), entry, marker: 'MARKER', wait: async () => {}, onRetry: async (retry) => retries.push(retry) }) + assert.equal(result.version, 3) + assert.equal(reads, 2) + assert.equal(writes, 2) + assert.deepEqual(retries.map((retry) => retry.code), ['S0500']) +}) +test('same batch never recreates an attempted order, even when interrupted', async () => { + const value = order(), state = { identity: { batchId: 'test' }, orders: { [value.key]: { status: 'STARTED' } } } + await executeCandidates({ candidates: [{ order: value, route: catalog }], limit: 1, state, save: async () => {}, + api: async () => { throw Error('must not call HTTP') } }) + assert.equal(state.lastRun.attempted, 0); assert.equal(state.lastRun.skipped, 1) +}) +test('pending-review stage submits but never calls approval or measurement APIs', async () => { + const value = order(), route = matchRoute(value, catalog) + const state = { identity: { batchId: 'pending-review' }, orders: {} } + const calls = [] + let detailReads = 0 + const api = async (method, path) => { + calls.push(`${method} ${path}`) + if (path.endsWith('/pricing/estimate')) return { channelCode: route.channel.channelCode, pricingTaxMode: value.pricingTaxMode } + if (method === 'POST' && path === '/api/merchant/v1/orders/first-leg') return 'order-1' + if (path === '/api/merchant/v1/orders/first-leg/order-1' && method === 'GET') { + detailReads++ + return { order: { id: 'order-1', orderNo: 'FL-1', customerRemark: Object.values(state.orders)[0].marker, + status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' }, + cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] } + } + if (path === '/api/merchant/v1/orders/order-1') return { summary: { mainStatus: 'PENDING_REVIEW' } } + if (method === 'POST' && path.endsWith('/submit')) return true + throw Error(`unexpected call ${method} ${path}`) + } + await executeCandidates({ candidates: [{ order: value, route }], limit: 1, concurrency: 1, state, + save: async () => {}, api, merchant: 'merchant', profile: { tenantName: 'Example' }, + sharedResources: { productImageFileId: 'image-1' }, stage: 'pending-review' }) + assert.equal(Object.values(state.orders)[0].stage, 'PENDING_REVIEW') + assert.equal(Object.values(state.orders)[0].verification.mainStatus, 'PENDING_REVIEW') + assert.equal(calls.some((call) => /approve|measurement|inbounds/.test(call)), false) +}) +test('pending-review retries an estimate-only pricing failure and records it as a warning', async () => { + const value = order(), route = matchRoute(value, catalog) + const state = { identity: { batchId: 'pricing-warning' }, orders: { [value.key]: { status: 'FAILED', stage: 'estimate', + error: 'HTTP_200_CODE_A0404', evidence: { code: 'A0404', requestId: 'old-request' }, finishedAt: '2026-01-01T00:00:00Z' } } } + let detailReads = 0 + const api = async (method, path) => { + if (path.endsWith('/pricing/estimate')) { const error = Error('no price'); error.evidence = { code: 'A0404', message: 'no price' }; throw error } + if (method === 'POST' && path === '/api/merchant/v1/orders/first-leg') return 'order-2' + if (path === '/api/merchant/v1/orders/first-leg/order-2' && method === 'GET') { + detailReads++ + return { order: { id: 'order-2', orderNo: 'FL-2', customerRemark: Object.values(state.orders)[0].marker, + status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' }, + cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] } + } + if (path === '/api/merchant/v1/orders/order-2') return { summary: { mainStatus: 'PENDING_REVIEW' } } + if (method === 'POST' && path.endsWith('/submit')) return true + throw Error(`unexpected call ${method} ${path}`) + } + await executeCandidates({ candidates: [{ order: value, route }], limit: 1, concurrency: 1, state, + save: async () => {}, api, merchant: 'merchant', profile: { tenantName: 'Example' }, + sharedResources: { productImageFileId: 'image-1' }, stage: 'pending-review' }) + const entry = state.orders[value.key] + assert.equal(entry.status, 'SUCCEEDED') + assert.equal(entry.pricingWarning.code, 'A0404') + assert.equal(entry.priorAttempts.length, 1) +}) +test('single process respects bounded concurrency while keeping per-order checkpoints', async () => { + const base = order() + const route = matchRoute(base, catalog) + const candidates = Array.from({ length: 7 }, (_, index) => ({ + order: { ...base, key: `key-${index}`, sourceCode: `SRC-${index}`, rows: [index + 2] }, route, + })) + const state = { identity: { batchId: 'concurrency' }, orders: {} } + let active = 0, maximum = 0, id = 0 + const api = async (method, path, body) => { + if (path.endsWith('/pricing/estimate')) { + active++; maximum = Math.max(maximum, active) + await new Promise((resolve) => setTimeout(resolve, 5)) + active-- + return { channelCode: route.channel.channelCode, pricingTaxMode: base.pricingTaxMode } + } + if (method === 'POST') return String(++id) + const marker = path.split('/').at(-1) + return { order: { id: marker, orderNo: `ORDER-${marker}`, customerRemark: Object.values(state.orders) + .find((entry) => entry.orderId === marker)?.marker }, cargoes: [{ packageCount: 31, grossWeightKg: 100, volumeCbm: 2 }] } + } + await executeCandidates({ candidates, limit: 7, concurrency: 3, state, save: async () => {}, api, + profile: { tenantName: 'Example' }, stage: 'create' }) + assert.equal(maximum, 3) + assert.equal(state.lastRun.attempted, 7) + assert.equal(state.lastRun.succeeded, 7) + assert.equal(Object.values(state.orders).filter((entry) => entry.status === 'SUCCEEDED').length, 7) +}) diff --git a/scripts/script-documentation-contract.test.mjs b/scripts/script-documentation-contract.test.mjs new file mode 100644 index 000000000..1d3c00bab --- /dev/null +++ b/scripts/script-documentation-contract.test.mjs @@ -0,0 +1,99 @@ +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' + +const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))) +const scriptsReadme = path.join(repositoryRoot, 'scripts', 'README.md') +const adminModulesReadme = path.join(repositoryRoot, 'scripts', 'admin-test-env-modules', 'README.md') +const adminAcceptanceEntry = path.join(repositoryRoot, 'scripts', 'admin-test-env-acceptance.mjs') +const excludedDirectoryNames = new Set([ + '.git', '.logs', 'dist', 'node_modules', 'output', 'outputs', 'reports', 'target', 'uni_modules', +]) +const allowedDirectoryPrefixes = [ + 'scripts/', + 'deploy/scripts/', + 'deploy/rocketmq/', + 'firstleg/scripts/', + 'lastleg-service/scripts/', + 'warehouse/scripts/', +] + +async function executableFiles(directory, extensions, { excludeTests = true } = {}) { + const entries = await readdir(path.join(repositoryRoot, directory), { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile()) + .filter((entry) => extensions.some((extension) => entry.name.endsWith(extension))) + .filter((entry) => !excludeTests || !entry.name.endsWith('.test.mjs')) + .map((entry) => `${directory}/${entry.name}`.replaceAll('\\', '/')) +} + +async function discoverScriptFiles(directory = repositoryRoot) { + const discovered = [] + const entries = await readdir(directory, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory() && ( + excludedDirectoryNames.has(entry.name) + || entry.name.startsWith('node_modules.') + || entry.name.startsWith('.codex-') + || entry.name.startsWith('.runtime-') + )) continue + const absolute = path.join(directory, entry.name) + if (entry.isDirectory()) { + discovered.push(...await discoverScriptFiles(absolute)) + continue + } + if (!['.mjs', '.ps1', '.sh', '.groovy'].some((extension) => entry.name.endsWith(extension))) continue + discovered.push(path.relative(repositoryRoot, absolute).replaceAll('\\', '/')) + } + return discovered +} + +test('executable scripts only live in documented directories', async () => { + const files = await discoverScriptFiles() + const misplaced = files.filter((file) => !allowedDirectoryPrefixes.some((prefix) => file.startsWith(prefix))) + assert.deepEqual(misplaced, [], `以下脚本位于未登记目录:\n${misplaced.join('\n')}`) +}) + +test('all directly runnable scripts are documented in scripts/README.md', async () => { + const readme = await readFile(scriptsReadme, 'utf8') + const files = [ + ...await executableFiles('scripts', ['.mjs', '.ps1', '.groovy']), + ...await executableFiles('scripts/openapi-validation', ['.mjs']), + ...await executableFiles('deploy/scripts', ['.sh']), + ...await executableFiles('deploy/rocketmq', ['.sh']), + ...await executableFiles('firstleg/scripts', ['.mjs']), + ...await executableFiles('lastleg-service/scripts', ['.mjs']), + ] + + const undocumented = files.filter((file) => { + const relativeToScripts = file.startsWith('scripts/') ? file.slice('scripts/'.length) : file + return !readme.includes(file) && !readme.includes(relativeToScripts) + }) + + assert.deepEqual(undocumented, [], `以下可执行脚本未登记用途与边界:\n${undocumented.join('\n')}`) +}) + +test('README explains internal modules and test scripts are not standalone entries', async () => { + const readme = await readFile(scriptsReadme, 'utf8') + assert.match(readme, /\*\.test\.mjs.*离线 Node 静态合同/) + assert.match(readme, /admin-test-env-modules\/\*\.mjs.*逐模块说明/) + assert.match(readme, /warehouse\/scripts\/warehouse-public-master-data-contract\.test\.mjs/) +}) + +test('all Admin acceptance modules are documented in the module index', async () => { + const [entrySource, readme] = await Promise.all([ + readFile(adminAcceptanceEntry, 'utf8'), + readFile(adminModulesReadme, 'utf8'), + ]) + const registeredModules = [] + for (const list of entrySource.matchAll(/const (?:CONFIGURED|OPTIONAL)_MODULES = \[([\s\S]*?)\]/g)) { + for (const moduleName of list[1].matchAll(/'([^']+)'/g)) registeredModules.push(moduleName[1]) + } + + assert.notEqual(registeredModules.length, 0, '未从 Admin 全量入口读取到模块注册表') + const undocumented = [...new Set(registeredModules)] + .filter((moduleName) => !readme.includes(`\`${moduleName}.mjs\``)) + assert.deepEqual(undocumented, [], `以下 Admin 验收模块未登记用途与边界:\n${undocumented.join('\n')}`) +}) diff --git a/scripts/security-projection-repair-contract.test.mjs b/scripts/security-projection-repair-contract.test.mjs new file mode 100644 index 000000000..be5ebcec7 --- /dev/null +++ b/scripts/security-projection-repair-contract.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const sql = readFileSync(new URL('./db/security-projection-repair.sql', import.meta.url), 'utf8') + +test('security projection repair derives every platform identity from IAM', () => { + assert.match(sql, /FROM baiyue_iam\.iam_user u/) + assert.match(sql, /CAST\(u\.auth_user_id AS CHAR\)/) + assert.doesNotMatch(sql, /9000000001|9000000003/) +}) + +test('security projection repair rebuilds user status and overseas authorization', () => { + assert.match(sql, /'USER'/) + assert.match(sql, /'AUTHORIZATION'/) + assert.match(sql, /platform:overseas-warehouse:/) + assert.match(sql, /platform:overseas-business:/) + assert.match(sql, /platform:overseas-master-data:/) + assert.match(sql, /platform:finance:/) + assert.match(sql, /lastleg:/) + assert.match(sql, /CONCAT\('repair-', u\.auth_user_id, '-[ua]-'/) +}) diff --git a/scripts/sql-baseline-contract-helpers.mjs b/scripts/sql-baseline-contract-helpers.mjs new file mode 100644 index 000000000..92be7086a --- /dev/null +++ b/scripts/sql-baseline-contract-helpers.mjs @@ -0,0 +1,66 @@ +// Offline SQL-test helpers. No database access or command-line entry point. +import { readFileSync } from 'node:fs' + +export function readInitialization(region = 'CN') { + const name = region === 'CN' ? '头程数据库统一增量-MySQL8.sql' : '海外区域统一增量-MySQL8.sql' + return readFileSync(new URL(`../deploy/sql/${name}`, import.meta.url), 'utf8') +} + +export function tableDefinitions(sql) { + const result = new Map() + let schema + for (const match of sql.matchAll(/^USE (\w+);|^CREATE TABLE (\w+) \([\s\S]*?^\) ENGINE=[^\n]+;/gm)) { + if (match[1]) schema = match[1] + else { + const key = `${schema}.${match[2]}` + if (result.has(key)) throw new Error(`Duplicate table definition: ${key}`) + result.set(key, match[0]) + } + } + return result +} + +export function table(sql, schema, name) { + const value = tableDefinitions(sql).get(`${schema}.${name}`) + if (!value) throw new Error(`Missing table: ${schema}.${name}`) + return value +} + +function splitValues(value) { + const items = [] + let start = 0, depth = 0, quoted = false + for (let i = 0; i < value.length; i++) { + const c = value[i] + if (quoted && c === '\\') { i++; continue } + if (c === "'") { + if (quoted && value[i + 1] === "'") { i++; continue } + quoted = !quoted + } + if (quoted) continue + if (c === '(') depth++ + if (c === ')') depth-- + if (c === ',' && depth === 0) { items.push(value.slice(start, i).trim()); start = i + 1 } + } + if (quoted || depth) throw new Error('Unbalanced SQL seed value') + items.push(value.slice(start).trim()) + return items.map(item => item === 'NULL' ? null : item.startsWith("'") + ? item.slice(1, -1).replace(/''/g, "'").replace(/\\([\\'"nrt0])/g, (_, c) => ({ n: '\n', r: '\r', t: '\t', 0: '\0' }[c] ?? c)) + : item) +} + +export function seedRows(sql, name) { + const pattern = new RegExp(`^INSERT INTO ${name} \\(([^\\n]+?)\\) VALUES \\(([^\\n]+)\\);$`, 'gm') + return [...sql.matchAll(pattern)].map(match => { + const keys = match[1].split(',').map(key => key.trim()) + const values = splitValues(match[2]) + if (keys.length !== values.length) throw new Error(`Invalid seed columns: ${name}`) + return Object.fromEntries(keys.map((key, i) => [key, values[i]])) + }) +} + +export function roleAuthorities(sql, roleCode) { + const role = seedRows(sql, 'iam_role').find(row => row.role_code === roleCode) + if (!role) throw new Error(`Missing role: ${roleCode}`) + const granted = new Set(seedRows(sql, 'iam_role_permission').filter(row => row.role_id === role.id).map(row => row.permission_id)) + return new Set(seedRows(sql, 'iam_permission').filter(row => granted.has(row.id)).map(row => row.permission_code)) +} diff --git a/scripts/start-gateway-overseas-local.ps1 b/scripts/start-gateway-overseas-local.ps1 new file mode 100644 index 000000000..2ed797286 --- /dev/null +++ b/scripts/start-gateway-overseas-local.ps1 @@ -0,0 +1,8 @@ +$ErrorActionPreference = 'Stop' + +. (Join-Path $PSScriptRoot 'local-env.ps1') +$env:LASTLEG_SECURITY_SERVICE_URL = 'http://127.0.0.1:9303' + +$maven = 'D:\apache-maven-3.8.8\bin\mvn.cmd' +$module = Join-Path (Split-Path $PSScriptRoot -Parent) 'gateway-overseas' +& $maven -o spring-boot:run -DskipTests diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs new file mode 100644 index 000000000..1c7845f0f --- /dev/null +++ b/scripts/synchronous-dependency-baseline.mjs @@ -0,0 +1,280 @@ +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('lastleg-service/src/main/java/com/baiyue/lastleg/client/PrintPdfClient.java', + 'lastleg-service', 'print-service', ['REQUEST'], 'READ', 'SAFE_READ', '根据已授权排车快照生成 BOL PDF,内部令牌保护,无业务状态写入'), + feign('auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java', + '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/WalletResultEventBridgeClient.java', + 'finance-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '钱包结果 Outbox 按 eventId 回传海外业务'), + feign('finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java', + 'finance-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取并校验供应商快照'), + + feign('firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java', + 'firstleg-service', 'integration-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '船节点定时任务查询,不属于用户请求'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java', + 'firstleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '装柜/船节点 Outbox 按 eventId 投递'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '校验付款凭证元数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/InboundHoldFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '扣货凭证上传无幂等键,元数据与下载可重复'), + 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/OceanBookingFileClient.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/OrderFulfillmentCompletionClient.java', + 'firstleg-service', 'order-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '海外派送完成事件按 eventId/payloadHash 幂等确认来源订单完成'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java', + 'firstleg-service', 'order-service', ['REQUEST'], 'READ', 'SAFE_READ', '智能排柜分页读取待审核订单投影'), + 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/PrintableOrderInboundClient.java', + 'firstleg-service', 'order-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', + '首次打印按客户与订单预留稳定入仓码,重复调用复用已冻结快照'), + 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/WarehousePartnerClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取启用往来单位快照'), + 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/client/DomesticWarehouseDirectoryClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取国内共享仓库权威目录'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '报价创建时读取国内合作仓快照'), + feign('warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/PrintPdfClient.java', + 'warehouse-overseas-service', 'print-service', ['REQUEST'], 'READ', 'SAFE_READ', '按已授权托盘生成标签PDF,内部令牌保护,无业务状态写入'), + 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/FirstlegColoadGroupClient.java', + 'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', '批量读取同柜不可拆组聚合'), + feign('order/src/main/java/com/baiyue/order/client/FinanceWalletClient.java', + 'order-service', 'finance-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/MerchantPricingCatalogClient.java', + 'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', '批量下单读取客户可用物流渠道'), + feign('order/src/main/java/com/baiyue/order/client/MerchantWarehouseCatalogClient.java', + 'order-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '批量下单分页读取启用仓库'), + feign('order/src/main/java/com/baiyue/order/client/OrderBatchFileClient.java', + 'order-service', 'file-service', ['REQUEST'], 'WRITE', 'NONE', '批量导入文件上传无通用幂等键'), + 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/HttpFinanceEventClient.java', + 'event-bridge-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '跨区费用 Inbox 按 eventId 投递 Finance'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java', + 'event-bridge-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', + 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java', + 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'), + http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java', + 'gateway-overseas', 'lastleg-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'), + http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', + 'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '回访附件上传无幂等键,归属校验可重复'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '派送资料上传无幂等键,元数据按客户/派送单/类别校验,当前与历史下载只读'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '异常处理证据上传无幂等键,归属校验可重复'), + 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/HttpDomesticLoadingEvidenceGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '当前PDA会话校验来源柜后,通过内部服务令牌只读下载国内装柜照片'), + 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/procurement/gateway/HttpProcurementFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'WRITE', 'NONE', '采购凭证和签收文件上传无幂等键,禁止客户端自动重试'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '换标PDF上传无幂等键,按客户/业务类型/业务单归属下载只读,传输层不自动重试'), + 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..bb70439d8 --- /dev/null +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -0,0 +1,293 @@ +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->print-service', + 'lastleg-service->warehouse-overseas-service', + 'lastleg-service->warehouse-service', + 'order-service->file-service', + 'order-service->finance-service', + 'order-service->firstleg-service', + 'order-service->iam-service', + 'order-service->warehouse-service', + 'warehouse-overseas-service->print-service', + 'warehouse-overseas-service->warehouse-service' +] + +const expectedBackgroundEdges = [ + 'event-bridge-service->finance-service', + 'event-bridge-service->firstleg-service', + 'event-bridge-service->lastleg-service', + 'file-service->finance-service', + 'file-service->firstleg-service', + 'file-service->iam-service', + 'file-service->order-service', + 'file-service->warehouse-service', + 'finance-service->event-bridge-service', + 'finance-service->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 60 Feign clients have reviewed context, access and idempotency metadata', async () => { + const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') + .map(({ path, source, target }) => ({ path, source, target })) + .sort((left, right) => left.path.localeCompare(right.path)) + assert.equal(reviewed.length, 60) + assert.deepEqual(await discoverFeignEdges(), reviewed) +}) + +test('all 23 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, 23) + 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 six 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/customervisit/gateway/HttpCustomerVisitFileGateway.java', 'getCustomerVisitFile'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java', 'getProcurementFile'], + ['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', 'customer-visit-file', 'procurement-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'], + ['finance-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, Firstleg and Finance form the reviewed REQUEST SCC', () => { + 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/FirstlegColoadGroupClient.java', + 'order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java', + 'order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java', + 'order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java', + 'order/src/main/java/com/baiyue/order/client/MerchantPricingCatalogClient.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', + 'firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java', + 'firstleg/src/main/java/com/baiyue/firstleg/client/PrintableOrderInboundClient.java' + ]) + assert.ok(stronglyConnectedComponents(graphEdges('REQUEST')).some(component => + component.includes('firstleg-service') && component.includes('order-service') + && component.includes('finance-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: 5, paths: [ + 'order-service->finance-service->firstleg-service->file-service->iam-service->auth-service', + 'order-service->finance-service->firstleg-service->file-service->iam-service->warehouse-service' + ] }, + 'firstleg-service': { hops: 5, paths: [ + 'firstleg-service->order-service->finance-service->file-service->iam-service->auth-service', + 'firstleg-service->order-service->finance-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->print-service', + '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 new file mode 100644 index 000000000..229e936e9 --- /dev/null +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -0,0 +1,251 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const pipeline = (await readFile(new URL('../Jenkinsfile.service', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') +const coordinatorPipeline = (await readFile(new URL('../Jenkinsfile', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') +const directDeployScript = (await readFile(new URL('../deploy/scripts/deploy-built-service.sh', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') +const ciMavenSettings = (await readFile(new URL('../deploy/maven/settings-ci.xml', import.meta.url), 'utf8')) + .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, /env\.RESOLVED_ENVIRONMENT == 'test' \|\| params\.SKIP_TESTS/) + assert.match(pipeline, /skipTestExecution \? '-DskipTests' : ''/) + assert.match(pipeline, /-pl \$\{env\.RESOLVED_SERVICE\} -am clean package/) + assert.doesNotMatch(pipeline, /-Dmaven\.test\.skip=true/) +}) + +test('coordinator and service jobs enforce their own deployment environments', () => { + assert.match(coordinatorPipeline, /case 'backend-java-dev':[\s\S]*?return 'development'/) + assert.match(coordinatorPipeline, /case 'backend-java':[\s\S]*?return 'test'/) + assert.match(coordinatorPipeline, /case 'backend-java-prod':[\s\S]*?return 'production'/) + assert.match(coordinatorPipeline, /environmentChoicesForJob\(env\.JOB_BASE_NAME\)/) + assert.match(coordinatorPipeline, /env\.RESOLVED_ENVIRONMENT = resolvedEnvironment/) + assert.match(coordinatorPipeline, /env\.RESOLVED_JOB_PREFIX = resolvedJobPrefix/) + assert.match(pipeline, /startsWith\('baiyue-firstleg-dev-'\)[\s\S]*?return 'development'/) + assert.match(pipeline, /startsWith\('baiyue-firstleg-prod-'\)[\s\S]*?return 'production'/) + assert.match(pipeline, /startsWith\('baiyue-firstleg-'\)[\s\S]*?return 'test'/) + assert.match(pipeline, /environmentChoicesForServiceJob\(env\.JOB_BASE_NAME\)/) + assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT = resolvedEnvironment/) + assert.doesNotMatch(pipeline, /params\.ENVIRONMENT ==/) + assert.doesNotMatch(pipeline, /contains\(params\.ENVIRONMENT\)/) +}) + +test('AUTO advances interrupted baselines and retries only their selected services', () => { + assert.match(coordinatorPipeline, /def parseAutoBuildState\(/) + assert.match(coordinatorPipeline, /def previousAutoBuildState\(/) + assert.match(coordinatorPipeline, /previousAutoBuildState\(currentBuild\.previousBuild, serviceModules\)/) + assert.match(coordinatorPipeline, /state\.startsWith\('PENDING'\)/) + assert.match(coordinatorPipeline, /failedServices\.addAll\(selected\.split\(','\)/) + assert.match(coordinatorPipeline, /git rev-parse HEAD\^/) + 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, /GIT_PREVIOUS_SUCCESSFUL_COMMIT/) + 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(coordinatorPipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS/) + assert.match(coordinatorPipeline, /error\('生产环境禁止跳过测试'\)/) + assert.match(pipeline, /生产环境手工构建禁止跳过测试/) + assert.match(coordinatorPipeline, /refs\/heads\/main:refs\/remotes\/origin\/main/) + assert.match(coordinatorPipeline, /test "\$\(git rev-parse HEAD\)" = "\$\(git rev-parse origin\/main\)"/) + assert.match(pipeline, /git merge-base --is-ancestor "\$\{RESOLVED_SOURCE_COMMIT\}" origin\/main/) +}) + +test('Java builds use the shared builder pool and serialize incremental target deployments', () => { + assert.match(pipeline, /agent \{ label 'java-builder' \}/) + assert.match(pipeline, /\['development', 'test'\]\.contains\(env\.RESOLVED_ENVIRONMENT\)/) + assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) + assert.match(pipeline, /'TARGET_IS_LOCAL=true'/) + assert.match(pipeline, /String deploySlotLabel = "firstleg-\$\{env\.RESOLVED_ENVIRONMENT\}-deploy-slot"/) + assert.match(pipeline, /lock\(label: deploySlotLabel, quantity: 1, inversePrecedence: true\)/) + assert.match(pipeline, /deploy-built-service\.sh/) + assert.doesNotMatch(pipeline, /dev_deploy|test_deploy/) + assert.match(pipeline, /firstleg-production-ssh/) + assert.match(pipeline, /TARGET_SSH_KEY/) + assert.match(directDeployScript, /docker pull "\$IMAGE"/) + assert.match(directDeployScript, /run_ssh docker pull "\$IMAGE"/) + assert.doesNotMatch(directDeployScript, /docker save "\$IMAGE"/) + assert.match(directDeployScript, /up -d --no-deps --pull never/) + assert.doesNotMatch(directDeployScript, /--remove-orphans/) + assert.match(directDeployScript, /--project-name "\$PROJECT_NAME"/) + assert.doesNotMatch(directDeployScript, /overseas-security-service|remove_legacy_overseas_security_container/) + assert.match(directDeployScript, /--pull never/) +}) + +test('production only accepts skipped downstream tests for a coordinator-pinned commit', () => { + assert.match(pipeline, /boolean coordinatorPinnedCommit = params\.SOURCE_COMMIT\?\.trim\(\) as boolean/) + assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS && !coordinatorPinnedCommit/) + assert.match(pipeline, /生产环境手工构建禁止跳过测试/) +}) + +test('development and test deployment locks are acquired before target executors', () => { + const directDeployStage = pipeline.slice( + pipeline.indexOf("stage('部署开发或测试环境')"), + pipeline.indexOf("stage('部署单个服务')") + ) + const serviceLockIndex = directDeployStage.indexOf('lock(resource: serviceDeployResource') + const lockIndex = directDeployStage.indexOf('lock(label: deploySlotLabel') + const nodeIndex = directDeployStage.indexOf('node(targetLabel)') + + assert.match(directDeployStage, /agent none/) + assert.match(directDeployStage, /String targetLabel = env\.RESOLVED_ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) + assert.match(directDeployStage, /String serviceDeployResource = "firstleg-\$\{env\.RESOLVED_ENVIRONMENT\}-\$\{env\.RESOLVED_SERVICE\}-deploy"/) + assert.ok(serviceLockIndex >= 0, 'per-service deployment lock must exist') + assert.ok(lockIndex >= 0, 'deployment slot lock must exist') + assert.ok(nodeIndex >= 0, 'target node allocation must exist') + assert.ok(serviceLockIndex < lockIndex, 'per-service lock must be acquired before the shared deployment slot') + assert.ok(lockIndex < nodeIndex, 'deployment lock must be acquired before target node allocation') +}) + +test('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('Firstleg deployment passes terminal drayage SMTP settings into the container', () => { + const firstlegService = serviceCompose.slice( + serviceCompose.indexOf(' firstleg:\n'), + serviceCompose.indexOf(' finance:\n') + ) + for (const key of [ + 'TERMINAL_DRAYAGE_MAIL_ENABLED', + 'TERMINAL_DRAYAGE_MAIL_HOST', + 'TERMINAL_DRAYAGE_MAIL_PORT', + 'TERMINAL_DRAYAGE_MAIL_USERNAME', + 'TERMINAL_DRAYAGE_MAIL_PASSWORD', + 'TERMINAL_DRAYAGE_MAIL_SSL', + 'TERMINAL_DRAYAGE_MAIL_SCAN_DELAY' + ]) { + assert.match(firstlegService, new RegExp(`^ ${key}: ` + '\\$\\{' + key, 'm')) + } + assert.match(directDeployScript, /TERMINAL_DRAYAGE_MAIL_ENABLED/) + assert.match(directDeployScript, /TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD/) +}) + +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/) + assert.doesNotMatch(ciMavenSettings, /:18081/) +}) + +test('Java service images use the canonical Gitea registry base image by default', () => { + const internalBaseImage = 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine' + + assert.match(coordinatorPipeline, new RegExp(internalBaseImage.replaceAll('.', '\\.'))) + assert.match(pipeline, new RegExp(internalBaseImage.replaceAll('.', '\\.'))) + assert.match(serviceDockerfile, /ARG BASE_IMAGE/) + assert.doesNotMatch(coordinatorPipeline, /docker\.m\.daocloud\.io\/library\/eclipse-temurin/) + assert.doesNotMatch(pipeline, /docker\.m\.daocloud\.io\/library\/eclipse-temurin/) + assert.doesNotMatch(serviceDockerfile, /docker\.m\.daocloud\.io\/library\/eclipse-temurin/) +}) + +test('source workspaces are preserved for incremental Git fetches', () => { + const buildStage = pipeline.slice( + pipeline.indexOf("stage('构建并发布单服务')"), + pipeline.indexOf("stage('部署开发或测试环境')") + ) + const directDeployStage = pipeline.slice( + pipeline.indexOf("stage('部署开发或测试环境')"), + pipeline.indexOf("stage('部署单个服务')") + ) + + assert.match(pipeline, /checkout scm/) + assert.doesNotMatch(buildStage, /cleanWs\(/) + assert.match(directDeployStage, /cleanWs\(/) + assert.equal((pipeline.match(/cleanWs\(/g) ?? []).length, 2) + assert.doesNotMatch(coordinatorPipeline, /cleanWs\(/) +}) + +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/scripts/use-nacos-environment.ps1 b/scripts/use-nacos-environment.ps1 new file mode 100644 index 000000000..ee5f64734 --- /dev/null +++ b/scripts/use-nacos-environment.ps1 @@ -0,0 +1,74 @@ +param( + [ValidateSet('local', 'dev', 'development', 'test')] + [string]$Environment, + [ValidateSet('cn', 'overseas')] + [string]$Region = 'cn', + [string]$NacosServerAddr, + [string]$NacosNamespace, + [string]$NacosUsername, + [string]$NacosPassword +) + +$ErrorActionPreference = 'Stop' +$target = if ($Environment -eq 'development') { 'dev' } else { $Environment } + +$defaults = @{ + local = @{ Server = '127.0.0.1:8848'; CnNamespace = 'public' } + dev = @{ Server = '8.163.49.32:8848'; CnNamespace = $env:DEV_NACOS_NAMESPACE } + test = @{ Server = '8.163.30.128:8848'; CnNamespace = 'd99d7998-5bb0-4ffe-a2a0-1a564ce14463' } +} +$selected = $defaults[$target] +$serverAddr = if ($NacosServerAddr) { $NacosServerAddr } else { $selected.Server } +$namespace = if ($NacosNamespace) { + $NacosNamespace +} elseif ($target -eq 'local') { + 'public' +} elseif ($Region -eq 'overseas') { + $regionNamespace = [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_OVERSEAS_NACOS_NAMESPACE") + if ($regionNamespace) { + $regionNamespace + } else { + [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_NAMESPACE") + } +} else { + $selected.CnNamespace +} +$username = if ($NacosUsername) { + $NacosUsername +} elseif ([Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_USERNAME")) { + [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_USERNAME") +} else { + 'nacos' +} +$password = if ($NacosPassword) { + $NacosPassword +} elseif ([Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_PASSWORD")) { + [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_PASSWORD") +} elseif ($target -eq 'local') { + 'nacos' +} else { + $null +} + +if (-not $namespace) { + throw "$target/$Region 必须通过 -NacosNamespace 或对应的 *_NACOS_NAMESPACE 环境变量指定 namespace" +} +if (-not $password) { + throw "$target 必须通过 -NacosPassword 或 $($target.ToUpperInvariant())_NACOS_PASSWORD 提供 Nacos 密码" +} + +$env:NACOS_SERVER_ADDR = $serverAddr +$env:NACOS_NAMESPACE = $namespace +$env:NACOS_GROUP = 'BAIYUE' +$env:NACOS_USERNAME = $username +$env:NACOS_PASSWORD = $password +$env:SPRING_CLOUD_NACOS_CONFIG_NAMESPACE = $namespace +$env:SPRING_CLOUD_NACOS_DISCOVERY_NAMESPACE = $namespace +$env:SPRING_PROFILES_ACTIVE = if ($Region -eq 'overseas') { 'overseas' } elseif ($target -eq 'local') { 'dev' } else { 'cn' } +$env:BAIYUE_RUNTIME_ENVIRONMENT = $target +$env:BAIYUE_RUNTIME_REGION = $Region + +Write-Host "Runtime environment: $target / $Region" +Write-Host "Nacos: $serverAddr; namespace: $namespace; group: BAIYUE" +Write-Host "Spring profiles: $env:SPRING_PROFILES_ACTIVE" +Write-Host 'Nacos password is loaded into the current process and is not printed.' diff --git a/settings-central.xml b/settings-central.xml new file mode 100644 index 000000000..0a3cdbdb3 --- /dev/null +++ b/settings-central.xml @@ -0,0 +1,12 @@ + + + D:/firstlegRepo + + + aliyun-public + 阿里云 Maven 公共镜像 + https://maven.aliyun.com/repository/public + external:* + + + diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md new file mode 100644 index 000000000..95a4c29b2 --- /dev/null +++ b/warehouse-overseas-service/AGENTS.md @@ -0,0 +1,54 @@ +# Overseas Warehouse Service 模块指引 + +## 职责与边界 + +- Mapper 只由 `BaiyueWarehouseOverseasApplication.java` 对 `com.baiyue.warehouse` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 +- 负责海外库位、托盘及业务资源锁定/释放;只保存国内仓库 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` | +| 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 | +| 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` | +| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。有 FBA 仓码的托盘与国内统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`、`FBA_PALLET_A4_LANDSCAPE_V1`:297×210 mm,顶部 SCK8 等仓码,完整托盘号作为 Code 128 与底部文字,141 等内部编号原样保留;其他托盘使用 `INBOUND_MARK_V1`。模板只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,海外 Compose 必须与 BOL 一样显式注入国内 Print 地址,不能依赖海外 Nacos 发现国内服务;Feign 及熔断包装异常统一返回 `S0503` | +| 内部服务令牌 | `security/InternalServiceTokenGuard.java` | +| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | +| 测试 | `src/test/java/com/baiyue/warehouse/` | + +## 数据与安全规则 + +- 平台和商户接口必须分别执行访问域与租户范围校验;客户端传入的 `tenantId` 不能作为唯一授权依据。 +- 平台维护的海外库位与实物托盘是公共资源,固定使用 `customer_id=0`;平台资源 API 不接收目标客户参数。资源锁记录仍使用真实业务客户 ID 隔离,但其托盘、库位查找和状态更新必须落到公共资源作用域。 +- 资源锁定以业务幂等键和 reservation 标识防重;释放/报废操作必须验证当前状态。 +- 可预留托盘必须满足 `IDLE`,且其绑定库位为 `VIRTUAL + ACTIVE` 或真实库位 `IDLE`;internal resolve 通过 `reservable`、`reservationBlockReason`、`locationType`、`locationStatus` 明示该事实,不得仅凭托盘状态宣称可预留。 +- PDA 托盘状态流转必须在同一事务内锁定托盘及源/目标库位:真实库位与托盘同步 `IDLE/OCCUPIED`,移库原子释放源库位并占用目标库位;虚拟库位始终保持 `ACTIVE`。释放后的可复用托盘保留已回到 `IDLE` 的真实库位关联。 +- 内部资源接口必须校验服务令牌;普通用户不得直接调用。 +- 本服务不得注册仓库主档创建、编辑、状态或商户仓库查询 Controller;所有资源写入先以相同 `warehouseId` 校验国内只读目录。 + +## 数据库表索引与注释约束 + +| 业务域 | 表名 | 中文含义 | +|---|---|---| +| 库位 | `wh_location` | 海外仓库位资源 | +| 托盘 | `wh_pallet` | 海外仓实物托盘资源 | +| 资源锁 | `wh_resource_lock` | 仓储资源锁定记录 | +| 托盘绑定 | `wh_pallet_business_binding` | 托盘业务绑定关系 | +| 托盘操作 | `wh_pallet_operation` | PDA 托盘物理状态流转、幂等与补偿记录 | +| 批量预留 | `wh_pallet_batch` | 柜级入库计划批量托盘预留与确认记录 | +| 批量明细 | `wh_pallet_batch_item` | 跨客户批量托盘明细与资源锁关联 | + +- 新增或修改表时,表 `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..6217a9f92 --- /dev/null +++ b/warehouse-overseas-service/pom.xml @@ -0,0 +1,83 @@ + + + 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 + + + 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 + + + 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..0e0317c21 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/BaiyueWarehouseOverseasApplication.java @@ -0,0 +1,17 @@ +package com.baiyue.warehouse; + +import org.apache.ibatis.annotations.Mapper; +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(basePackages = "com.baiyue.warehouse", annotationClass = Mapper.class) +@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/client/PrintPdfClient.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/PrintPdfClient.java new file mode 100644 index 000000000..1d88dde35 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/PrintPdfClient.java @@ -0,0 +1,33 @@ +package com.baiyue.warehouse.client; + +import java.math.BigDecimal; +import java.util.List; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +/** Reuses the Print service's existing label renderer; no local PDF template. */ +@FeignClient(name = "print-service", contextId = "overseasWarehousePrintPdfClient", + url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs") +public interface PrintPdfClient { + @PostMapping(value = "/fba-pallet-labels", consumes = "application/json") + ResponseEntity generateFbaPalletLabels( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @RequestBody FbaPalletRequest request); + + record FbaPalletRequest(String templateCode, PageSpec pageSpec, String downloadFileName, List labels) {} + record FbaPalletLabel(String fbaWarehouseCode, String palletCode) {} + + @PostMapping(value = "/inbound-marks", consumes = "application/json") + ResponseEntity generateInboundMarks( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @RequestBody Request request); + + record Request(String templateCode, PageSpec pageSpec, String downloadFileName, List