diff --git a/.gitignore b/.gitignore index 533ecf24c..dc4b3792c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ target/ +node_modules/ .docker-context/ *.log .idea/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4dd6604f7..865a9e4b2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -13,6 +13,34 @@ 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 script: @@ -25,10 +53,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 +68,18 @@ 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 + - overseas-security-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 index 2d5d600f2..5a0872a4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,11 +36,13 @@ | 路由、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 状态与权限版本投影 | `overseas-security-service/` | `overseas-security-service/AGENTS.md`;消费三类 IAM 安全事件并为海外 Gateway 提供内部 fail-closed 校验 | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | -| 海外入仓分页、FBA 预约、PDA 卸柜照片 | `lastleg-service/` | `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`、`inboundplan/pda/` | +| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | -| 海外仓 PDA 到仓、卸柜、实收、上架、合托 | `lastleg-service/`、`warehouse-overseas-service/` | 业务流程 `lastleg-service/inboundplan/pda/`;库位、托盘和资源锁归 `warehouse-overseas-service/`;设备端 `../apps/pda-overseas-uniapp/`,公网入口 `gateway-overseas/` | +| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理库位/托盘调用 `warehouse-overseas-service/`,设备端入口是否开放以 `../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-overseas-service/` | `warehouse-overseas-service/AGENTS.md` | | 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` | diff --git a/Jenkinsfile b/Jenkinsfile index 00beafd8a..be2593cce 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ 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' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' ] def serviceCheckboxes = [ @@ -17,6 +17,7 @@ def serviceCheckboxes = [ 'integration-service': 'SERVICE_INTEGRATION', 'lastleg-service': 'SERVICE_LASTLEG', 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', + 'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY', 'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'audit-service': 'SERVICE_AUDIT' ] @@ -68,12 +69,12 @@ pipeline { booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') - booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;由 EVENT_BRIDGE_REGION 选择实例)') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['CN', 'OVERSEAS'], description: '仅部署 event-bridge-service 时生效;明确选择国内或海外实例') + booleanParam(name: 'SERVICE_OVERSEAS_SECURITY', defaultValue: false, description: '海外IAM安全投影服务(overseas-security-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: 'INITIALIZE_TEST_DATABASE', defaultValue: false, description: '仅 test:调度服务前幂等初始化测试数据库') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选') string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址') string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') @@ -109,6 +110,9 @@ pipeline { } env.RESOLVED_JOB_PREFIX = jobPrefix env.DATABASE_CHANGE_DETECTED = 'false' + if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) { + error('生产环境禁止跳过测试') + } List selected = [] if (params.BUILD_MODE == 'ALL') { @@ -147,6 +151,19 @@ pipeline { } selected = selected.unique() + if (params.DEPLOY && selected.contains('event-bridge-service')) { + String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim() + if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { + error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') + } + if (['BOTH', 'SELECT'].contains(bridgeRegion) && + !['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) { + error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS') + } + if (bridgeRegion == 'SELECT') { + echo '兼容旧 Jenkins 参数 SELECT:本次按 BOTH 同时部署国内和海外事件桥' + } + } env.SELECTED_SERVICES = selected.join(',') env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' if (!selected) { @@ -178,15 +195,30 @@ pipeline { } } - stage('测试数据库初始化(可选)') { + stage('统一 Maven 测试门禁') { when { - allOf { - expression { env.RESOLVED_ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE } + expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS } + } + agent { label 'java-builder' } + tools { maven 'maven-3.8.8' } + steps { + script { + String selectedModules = env.SELECTED_SERVICES.split(',') + .collect { it.trim() } + .findAll { it } + .join(',') + echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}" + sh """ + test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\" + mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test + """ + echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试" } } - agent { label 'java-test' } - steps { - sh 'sh deploy/scripts/initialize-test-databases.sh' + post { + always { + junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml' + } } } @@ -199,6 +231,14 @@ pipeline { String service = selectedService branches[service] = { String jobName = "${env.RESOLVED_JOB_PREFIX}${service}" + boolean deployBothBridges = service == 'event-bridge-service' && + ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION) + String downstreamImageTag = deployBothBridges + ? "${env.IMAGE_TAG}-both" + : env.IMAGE_TAG + String downstreamBridgeRegion = deployBothBridges + ? 'CN' + : (service == 'event-bridge-service' ? params.EVENT_BRIDGE_REGION : 'CN') echo "触发单服务 Job: ${jobName}" def downstreamBuild = build( job: jobName, @@ -206,13 +246,16 @@ pipeline { propagate: false, parameters: [ string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT), - string(name: 'IMAGE_TAG', value: env.IMAGE_TAG), + string(name: 'IMAGE_TAG', value: downstreamImageTag), string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT), booleanParam(name: 'DEPLOY', value: params.DEPLOY), - booleanParam(name: 'SKIP_TESTS', value: params.SKIP_TESTS), + // 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。 + booleanParam(name: 'SKIP_TESTS', value: true), string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY), string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE), - string(name: 'EVENT_BRIDGE_REGION', value: params.EVENT_BRIDGE_REGION) + // Choice 参数始终传递所有新旧 Job 都接受的单区值; + // 双区意图通过现有 IMAGE_TAG 参数携带,由下游还原真实镜像标签。 + string(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion) ] ) downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN' diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 379caf60c..88c8bac39 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -1,6 +1,6 @@ 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' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' ] def serviceJars = [ @@ -17,6 +17,7 @@ def serviceJars = [ 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', 'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar', 'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar', + 'overseas-security-service': 'overseas-security-service/target/overseas-security-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' ] @@ -38,7 +39,7 @@ pipeline { choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['CN', 'OVERSEAS'], description: '仅 event-bridge-service 使用;通常由总控 Job 传入') + choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值') string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签') string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') } @@ -76,6 +77,11 @@ pipeline { if (!serviceModules.contains(service)) { error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}") } + boolean deployBothBridges = service == 'event-bridge-service' && + imageTag?.endsWith('-both') + if (deployBothBridges) { + imageTag = imageTag.substring(0, imageTag.length() - '-both'.length()) + } if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) { error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit') } @@ -93,6 +99,18 @@ pipeline { env.RESOLVED_SOURCE_COMMIT = sourceCommit env.RESOLVED_IMAGE_TAG = imageTag env.RESOLVED_IMAGE_REPOSITORY = imageRepository + env.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges ? 'true' : 'false' + + if (service == 'event-bridge-service' && params.DEPLOY) { + String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim() + if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { + error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') + } + if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) && + !['development', 'test'].contains(params.ENVIRONMENT)) { + error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS') + } + } } sh ''' @@ -114,12 +132,15 @@ pipeline { stage('Maven 测试与打包') { steps { script { - if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) { - error('生产环境禁止跳过测试') + boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean + if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) { + error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包') } boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS String testOption = skipTestExecution ? '-DskipTests' : '' - if (params.ENVIRONMENT == 'test') { + if (params.SKIP_TESTS && coordinatorPinnedCommit) { + echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包" + } else if (params.ENVIRONMENT == 'test') { echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行' } sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" @@ -174,28 +195,43 @@ pipeline { steps { script { String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot" + String serviceDeployResource = "firstleg-${params.ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy" String credentialId = "firstleg-${params.ENVIRONMENT}-env" String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test' - lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) { - node(targetLabel) { - deleteDir() - try { - unstash 'deployment-files' - echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}" - withEnv([ - 'TARGET_IS_LOCAL=true', - "EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}" - ]) { + // 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。 + // resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。 + lock(resource: serviceDeployResource, inversePrecedence: true) { + lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) { + node(targetLabel) { + deleteDir() + try { + unstash 'deployment-files' + echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}" + List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && + (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || + ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) + ? ['CN', 'OVERSEAS'] + : [params.EVENT_BRIDGE_REGION] withCredentials([ file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') ]) { - sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + bridgeRegions.each { bridgeRegion -> + if (env.RESOLVED_SERVICE == 'event-bridge-service') { + echo "部署事件桥实例: ${bridgeRegion}" + } + withEnv([ + 'TARGET_IS_LOCAL=true', + "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" + ]) { + sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + } + } } + } finally { + cleanWs(deleteDirs: true, notFailBuild: true) } - } finally { - cleanWs(deleteDirs: true, notFailBuild: true) } } } diff --git a/README.md b/README.md index 884c9de44..604e465ae 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 柏悦头程平台 -本仓库承载国内头程业务,负责统一登录、客户与企业成员、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。 +本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。 > 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 @@ -12,20 +12,24 @@ - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 -- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。 +- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`、`overseas-security-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源及 IAM 安全投影。 +- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 -- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。 +- `platform-bom`:统一管理仓库内公共依赖版本。 ## 本地构建 ```powershell mvn -DskipTests validate +node --test scripts/java-topology-contract.test.mjs mvn test ``` +GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。 + 2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 -本地一期完整联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。 +国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。 国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 @@ -41,4 +45,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com ## 仓库边界 -仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 +仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 7e969404f..b36bad7b2 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -16,6 +16,7 @@ | 统一 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,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 | +| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` | | 注册协作 | `service/MerchantRegistrationService.java` | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` | diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java index 47bc2000a..1061534be 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java @@ -21,4 +21,12 @@ public class AuthorizationSnapshot { 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/controller/InternalAccountController.java b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java index ead027f87..643bb1607 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -3,12 +3,16 @@ package com.baiyue.auth.controller; import com.baiyue.auth.dto.AccountManagementDtos.*; import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AuthService; +import com.baiyue.auth.service.OverseasSessionValidationService; +import com.baiyue.auth.dto.OverseasSessionValidationDtos; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; @@ -18,11 +22,14 @@ import java.security.MessageDigest; /** 仅供可信内部服务调用的账号管理接口。 */ @RestController @RequestMapping("/internal/auth/accounts") +@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs") public class InternalAccountController { @Resource private AccountManagementService service; @Resource private AuthService authService; + @Resource + private OverseasSessionValidationService overseasSessionValidationService; @Value("${baiyue.security.internal-service-token}") private String configuredToken; @@ -64,6 +71,15 @@ public class InternalAccountController { @Valid @RequestBody RevokeAccountSessionsRequest request) { verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null); } + @PostMapping("/overseas-session/validate") + @Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明", + description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.") + public ApiResponse validateOverseasSession( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @Valid @RequestBody OverseasSessionValidationDtos.Request request) { + verify(token); + return ApiResponse.success(overseasSessionValidationService.validate(request)); + } @PutMapping("/{id}/profile") public ApiResponse profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) { diff --git a/auth/src/main/java/com/baiyue/auth/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/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index ec283c23e..f7cbb1ff4 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -111,7 +111,7 @@ public class AuthService { Instant issuedAt = Instant.now(); String jwtId = UUID.randomUUID().toString(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); - JwtClaimsSet claims = JwtClaimsSet.builder() + JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() .issuer(properties.issuer()).audience(List.of(properties.audience())) .id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) .subject(account.getId().toString()) @@ -127,8 +127,11 @@ public class AuthService { .claim("userId", account.getId()) .claim("tenantId", account.getDefaultTenantId()) .claim("appCode", account.getDefaultAppCode()) - .claim("permissionVersion", account.getTokenVersion()) - .build(); + .claim("permissionVersion", account.getTokenVersion()); + if (authorization.getOverseasWarehouseId() != null) { + claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); + } + JwtClaimsSet claims = claimsBuilder.build(); String accessToken = jwtEncoder.encode(JwtEncoderParameters.from( signingHeader(), claims)).getTokenValue(); 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 index 8e2945c5f..4d274f63e 100644 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -51,7 +51,7 @@ public class OverseasTokenService { public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) { Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); - JwtClaimsSet claims = JwtClaimsSet.builder() + 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) @@ -60,8 +60,12 @@ public class OverseasTokenService { .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) .claim("roles", authorization.getRoles()) .claim("authorities", overseasAuthorities(authorization.getPermissions())) - .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()) - .build(); + .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()); 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/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java index aed82fc6f..11db26bf7 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -23,7 +23,10 @@ import java.time.Duration; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -60,7 +63,7 @@ class AuthServiceSmallJwtTest { account.setDefaultAppCode("PLATFORM"); account.setTokenVersion(1); AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", - "WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"), 1L); + "WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(repository.findByUsername("operator")).thenReturn(account); @@ -82,12 +85,13 @@ class AuthServiceSmallJwtTest { .containsEntry("appCode", "PLATFORM") .containsEntry("permissionVersion", 1) .containsEntry("membership_id", 84L) + .containsEntry("overseas_warehouse_id", 900L) .doesNotContainKeys("roles", "authorities", "permissions", "scope"); assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api"); assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256); assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull(); verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), - org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")), + org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")), org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); @@ -123,12 +127,36 @@ class AuthServiceSmallJwtTest { var response = service.refresh("refresh-old"); assertThat(response.accessToken()).isEqualTo("domestic-refreshed"); + assertThat(response.tokenType()).isEqualTo("Bearer"); + assertThat(response.expiresIn()).isEqualTo(1800); + assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old"); + assertThat(response.refreshExpiresIn()).isEqualTo(604800); assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed"); assertThat(response.overseasExpiresIn()).isEqualTo(900); + verify(tokenSessionStore).consume("refresh-old"); + verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L), + org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")), + org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), + org.mockito.ArgumentMatchers.eq(response.refreshToken()), + org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), + org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); verify(authorizationLoader).load(account); verify(overseasTokenService).issue(account, snapshot); } + @Test + void consumedRefreshTokenCannotMintAnotherDomesticOrOverseasPair() { + when(tokenSessionStore.consume("refresh-consumed")).thenReturn(null); + + assertThatThrownBy(() -> service.refresh("refresh-consumed")) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessageContaining("刷新令牌无效或已过期"); + + verify(repository, never()).findById(any()); + verify(jwtEncoder, never()).encode(any()); + verify(overseasTokenService, never()).issue(any(), any()); + } + @Test void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() { AuthAccount account = new AuthAccount(); diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java new file mode 100644 index 000000000..b98b7b637 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java @@ -0,0 +1,77 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.client.AuthorizationSnapshot; +import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason; +import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class OverseasSessionValidationServiceTest { + private final AuthAccountRepository accounts = mock(AuthAccountRepository.class); + private final AuthorizationLoader authorizationLoader = mock(AuthorizationLoader.class); + private final OverseasSessionValidationService service = new OverseasSessionValidationService(); + private AuthAccount account; + + @BeforeEach + void setUp() throws Exception { + inject("accounts", accounts); + inject("authorizationLoader", authorizationLoader); + account = new AuthAccount(); + account.setId(42L); + account.setAccountStatus("ACTIVE"); + account.setTokenVersion(8); + when(accounts.findById(42L)).thenReturn(account); + } + + @Test + void oldTokenIsRejectedAfterWarehouseChangeBumpsAccountVersion() { + assertThat(service.validate(new Request(42L, 7, 900L))) + .extracting("valid", "failureReason") + .containsExactly(false, FailureReason.TOKEN_VERSION_MISMATCH); + } + + @Test + void newlyIssuedTokenWithCurrentWarehousePasses() { + when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L)); + + assertThat(service.validate(new Request(42L, 8, 901L)).valid()).isTrue(); + } + + @Test + void missingOrChangedWarehouseBindingFailsClosed() { + when(authorizationLoader.load(account)).thenReturn(snapshot(8L, null)); + assertThat(service.validate(new Request(42L, 8, null)).failureReason()) + .isEqualTo(FailureReason.WAREHOUSE_UNBOUND); + + when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L)); + assertThat(service.validate(new Request(42L, 8, 900L)).failureReason()) + .isEqualTo(FailureReason.WAREHOUSE_MISMATCH); + } + + @Test + void disabledAccountFailsClosedBeforeIamLookup() { + account.setAccountStatus("DISABLED"); + assertThat(service.validate(new Request(42L, 8, 901L)).failureReason()) + .isEqualTo(FailureReason.ACCOUNT_INVALID); + } + + private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) { + return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA", + Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), version, warehouseId); + } + + private void inject(String fieldName, Object value) throws Exception { + var field = OverseasSessionValidationService.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(service, value); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java index 20fd26464..805788918 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -24,6 +24,7 @@ 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}) @@ -51,7 +52,7 @@ class OverseasTokenServiceTest { String lastlegAuthority = "lastleg:pda:operate"; String domesticAuthority = "platform:firstleg:order:list"; AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", - Set.of("PLATFORM_ADMIN"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L); + Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(accounts.findById(42L)).thenReturn(account); when(authorizationLoader.load(account)).thenReturn(snapshot); @@ -71,9 +72,61 @@ class OverseasTokenServiceTest { .containsEntry("membership_id", 84L) .containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB") .containsEntry("permission_version", 7L) - .containsEntry("roles", Set.of("PLATFORM_ADMIN")) + .containsEntry("token_version", 0) + .containsEntry("overseas_warehouse_id", 900L) + .containsEntry("roles", Set.of("WAREHOUSE_PICKER")) .containsEntry("authorities", Set.of(authority, lastlegAuthority)) .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); assertThat(response.expiresIn()).isEqualTo(900); } + + @Test + void refreshedAuthorizationSnapshotReplacesWarehouseBindingAndTokenIdentity() { + AuthAccount account = new AuthAccount(); + account.setId(43L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(0L); + account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM"); + account.setTokenVersion(8); + AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", + Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L); + AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", + Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L); + Jwt first = org.mockito.Mockito.mock(Jwt.class); + Jwt second = org.mockito.Mockito.mock(Jwt.class); + when(encoder.encode(any())).thenReturn(first, second); + when(first.getTokenValue()).thenReturn("overseas-old-binding"); + when(second.getTokenValue()).thenReturn("overseas-new-binding"); + + var oldToken = service.issue(account, oldBinding); + var newToken = service.issue(account, newBinding); + + ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); + verify(encoder, times(2)).encode(captor.capture()); + var oldClaims = captor.getAllValues().get(0).getClaims(); + var newClaims = captor.getAllValues().get(1).getClaims(); + assertThat(oldClaims.getId()).isNotEqualTo(newClaims.getId()); + assertThat(oldClaims.getClaims()).containsEntry("overseas_warehouse_id", 900L) + .containsEntry("permission_version", 8L); + assertThat(newClaims.getClaims()).containsEntry("overseas_warehouse_id", 901L) + .containsEntry("permission_version", 9L); + assertThat(newClaims.getClaims()).containsEntry("token_version", 8); + assertThat(oldToken.accessToken()).isNotEqualTo(newToken.accessToken()); + } + + @Test + void unboundWarehouseIsOmittedSoPdaIdentityMustFailClosed() { + AuthAccount account = new AuthAccount(); + account.setId(44L); account.setAccountStatus("ACTIVE"); + AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA", + Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 10L, null); + Jwt encoded = org.mockito.Mockito.mock(Jwt.class); + when(encoder.encode(any())).thenReturn(encoded); + when(encoded.getTokenValue()).thenReturn("overseas-unbound"); + + service.issue(account, unbound); + + ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); + verify(encoder).encode(captor.capture()); + assertThat(captor.getValue().getClaims().getClaims()) + .doesNotContainKey("overseas_warehouse_id"); + } } diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index d7c0f6212..bf9e87321 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -14,12 +14,13 @@ | 事件 ID 与业务键 | `EventIdentifiers.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | -| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;冻结来源/根订单与入仓标识、客户、业务类型、逐明细目的仓/备注/托盘号/实装箱号及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | +| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | | 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | -| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | +| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | +| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | 契约说明 | `package-info.java` | -| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java` | +| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` | | 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` | ## 修改与验证 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 7c23de9c9..029056ada 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java @@ -10,6 +10,8 @@ public final class EventTypes { public static final String ORDER_CANCELLED = "OrderCancelled"; public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted"; public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted"; + public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED = + "FirstLegContainerShippingNodeRecorded"; public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded"; public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged"; public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java new file mode 100644 index 000000000..a9a537c41 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java @@ -0,0 +1,47 @@ +package com.baiyue.common.event; + +import java.time.Instant; +import java.util.Map; + +/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */ +public record FirstLegContainerShippingNodeRecordedV1( + String queryId, + String customerId, + String oceanBookingId, + String containerBookingId, + String containerNo, + String standardNode, + int nodeSequence, + Instant observedAt, + Instant actualAt, + String locationCode, + String locationName, + String providerCode, + String providerEventId, + String providerNodeCode, + String sourceReference, + String rawPayloadHash) { + private static final Map STANDARD_NODES = Map.of( + "DEPARTED_WAREHOUSE", 10, "AT_ORIGIN_PORT", 20, "LOADED_ON_VESSEL", 30, + "DEPARTED_ORIGIN_PORT", 40, "IN_TRANSIT", 50, "ARRIVED_DESTINATION_PORT", 60, + "DISCHARGED_FROM_VESSEL", 70, "PICKUP_READY", 80, "PICKED_UP", 90, + "EMPTY_RETURNED", 100); + + public FirstLegContainerShippingNodeRecordedV1 { + if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank() + || oceanBookingId == null || oceanBookingId.isBlank() + || containerBookingId == null || containerBookingId.isBlank() + || containerNo == null || containerNo.isBlank()) { + throw new IllegalArgumentException("船节点交接业务标识不能为空"); + } + if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode)) + || observedAt == null || actualAt == null) { + throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实"); + } + if (providerCode == null || providerCode.isBlank() || providerEventId == null + || providerEventId.isBlank() || providerNodeCode == null || providerNodeCode.isBlank() + || rawPayloadHash == null || !rawPayloadHash.matches("(?i)^[0-9a-f]{64}$")) { + throw new IllegalArgumentException("船节点交接供应商审计字段不完整"); + } + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java index 1b4978c53..7d8dd65bd 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java @@ -16,12 +16,25 @@ public record FirstLegLoadingPlanCompletedV1( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items, String customerId, String orderType, List pallets) { + List items, String customerId, String orderType, List pallets, + String destinationType) { public Order { items = items == null ? List.of() : items; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; pallets = pallets == null ? List.of() : pallets; } + public Order(String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items, String customerId, String orderType, List pallets) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); + } public Order(String sourceInboundId, String rootInboundId, String sourceOrderId, String sourceOrderNo, String rootOrderId, String inboundCode, String destinationWarehouseId, @@ -32,7 +45,7 @@ public record FirstLegLoadingPlanCompletedV1( this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of()); + grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); } } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index 64770e5ef..c42b00ad4 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -15,13 +15,28 @@ public record FirstLegLoadingPlanCompletedV2( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items, String customerId, String orderType, List pallets) { + List items, String customerId, String orderType, List pallets, + /** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */ + String destinationType) { public Order { items = items == null ? List.of() : items; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; pallets = pallets == null ? List.of() : pallets; } + public Order(String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items, String customerId, String orderType, List pallets) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); + } + public Order(String sourceInboundId, String rootInboundId, String sourceOrderId, String sourceOrderNo, String rootOrderId, String inboundCode, String destinationWarehouseId, @@ -32,7 +47,7 @@ public record FirstLegLoadingPlanCompletedV2( this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of()); + grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); } } 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 index 6841c9815..a919d58c5 100644 --- 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 @@ -2,7 +2,10 @@ package com.baiyue.common.event; import java.time.Instant; -/** 海外仓卸柜完成事实;国内订舱 ID 是稳定聚合标识,containerNo 仅用于核对物理柜号。 */ +/** + * 海外仓柜级卸柜完成事实;国内订舱 ID 是稳定聚合与幂等标识,containerNo 用于核对物理柜号。 + * unloadingCompletedAt 是全柜 complete 时刻,不得使用签收拍照上传时刻代替。 + */ public record OverseasContainerUnloadedV1( String sourceContainerBookingId, String containerNo, diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java new file mode 100644 index 000000000..faaa767cb --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java @@ -0,0 +1,49 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FirstLegContainerShippingNodeRecordedV1Test { + @Test + void roundTripsLongBusinessIdsAndActualPickupReadyFact() throws Exception { + var payload = payload(); + var mapper = new ObjectMapper().findAndRegisterModules(); + + var restored = mapper.readValue(mapper.writeValueAsString(payload), + FirstLegContainerShippingNodeRecordedV1.class); + + assertThat(restored).isEqualTo(payload); + assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003"); + assertThat(restored.standardNode()).isEqualTo("PICKUP_READY"); + assertThat(restored.nodeSequence()).isEqualTo(80); + } + + @Test + void rejectsEstimatedOnlyOrIncompleteProviderFacts() { + assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( + "1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80, + Instant.parse("2026-08-25T01:00:00Z"), null, null, null, + "ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64))) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间"); + + assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( + "1", "2", "3", "4", "ZCSU7881970", "ARRIVED", 60, + Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"), + null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64))) + .isInstanceOf(IllegalArgumentException.class); + } + + private static FirstLegContainerShippingNodeRecordedV1 payload() { + return new FirstLegContainerShippingNodeRecordedV1( + "2088000000000000001", "2088000000000000002", "2088000000000000004", + "2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80, + Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"), + "USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE", + "ZIM:evt-1", "a".repeat(64)); + } +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java index 3b5b72757..95a6b394b 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java @@ -47,4 +47,17 @@ class FirstLegLoadingPlanCompletedV1Test { assertThat(restored).isEqualTo(payload); assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId"); } + + @Test + void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception { + String json = """ + {"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1", + "warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100", + "destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]} + """; + + FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class); + + assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS"); + } } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java index b5bb212f4..d15900021 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -40,6 +40,18 @@ class FirstLegLoadingPlanCompletedV2Test { assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); } + @Test + void carriesFrozenDestinationType() { + var order = new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8", + 2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA"); + + var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order))); + + assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA"); + } + @Test void mapsEachOrdinaryCartonNumberToItsSku() { var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2, 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 index 70f843856..0942ac072 100644 --- 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 @@ -20,5 +20,7 @@ class OverseasContainerUnloadedV1Test { assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload); assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt"); + assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt()); + assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt()); } } diff --git a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java index 3a5a42502..de69a8909 100644 --- a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java +++ b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java @@ -75,6 +75,14 @@ public class BaiyueOpenApiAutoConfiguration { .addOperationCustomizer(correlationHeaderCustomizer).build(); } + @Bean + GroupedOpenApi pdaApi(OperationCustomizer correlationHeaderCustomizer) { + return GroupedOpenApi.builder().group("pda") + .displayName("PDA APIs / PDA 作业接口") + .pathsToMatch("/api/pda/**") + .addOperationCustomizer(correlationHeaderCustomizer).build(); + } + private Parameter header(String name, String description, boolean required) { return new HeaderParameter().name(name).description(description).required(required) .schema(new io.swagger.v3.oas.models.media.StringSchema()); diff --git a/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java b/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java new file mode 100644 index 000000000..dcf28b7df --- /dev/null +++ b/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java @@ -0,0 +1,17 @@ +package com.baiyue.common.openapi; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaOpenApiGroupContractTest { + @Test + void pdaGroupOnlyMatchesDeviceApis() { + BaiyueOpenApiAutoConfiguration configuration = new BaiyueOpenApiAutoConfiguration(); + var group = configuration.pdaApi(configuration.correlationHeaderCustomizer()); + + assertThat(group.getGroup()).isEqualTo("pda"); + assertThat(group.getPathsToMatch()).containsExactly("/api/pda/**"); + assertThat(group.getPathsToMatch()).noneMatch(path -> path.contains("internal")); + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java index 1f4927303..1391d548a 100644 --- a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java +++ b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java @@ -9,11 +9,19 @@ import java.util.Set; public record 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) implements Principal { + Long actorMembershipId, boolean passwordChangeRequired, + Long overseasWarehouseId) implements Principal { public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, String username, String appCode, String clientType, Set permissions) { this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType, - permissions, false, null, null, false); + permissions, false, null, null, false, null); + } + public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, + String username, String appCode, String clientType, Set permissions, + boolean impersonated, Long actorUserId, Long actorMembershipId, + boolean passwordChangeRequired) { + this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType, + permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null); } public AuthenticatedUser { permissions = permissions == null ? Set.of() : Set.copyOf(permissions); diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java index 84faded6a..bd5a37ced 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 @@ -43,7 +43,8 @@ public class JwtClaimsMapper { Boolean.TRUE.equals(jwt.getClaim("impersonated")), longClaim(jwt, "actor_user_id"), longClaim(jwt, "actor_membership_id"), - Boolean.TRUE.equals(jwt.getClaim("password_change_required"))); + Boolean.TRUE.equals(jwt.getClaim("password_change_required")), + longClaim(jwt, "overseas_warehouse_id")); List authorities = permissions.stream() .map(SimpleGrantedAuthority::new).toList(); return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities); diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java index 3189cd48a..ce04f51be 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java @@ -81,6 +81,7 @@ class JwtClaimsMapperTest { .claim("actor_user_id", 42L) .claim("actor_membership_id", 43L) .claim("password_change_required", true) + .claim("overseas_warehouse_id", 900L) .build(); AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal(); @@ -88,5 +89,6 @@ class JwtClaimsMapperTest { assertThat(user.actorUserId()).isEqualTo(42L); assertThat(user.actorMembershipId()).isEqualTo(43L); assertThat(user.passwordChangeRequired()).isTrue(); + assertThat(user.overseasWarehouseId()).isEqualTo(900L); } } diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index 23b2f7310..d2312b7cf 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -11,6 +11,8 @@ |---|---| | 链路上下文 | `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 服务传递 | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 | | 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 | | 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` | @@ -23,5 +25,7 @@ - 网关/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。 - 从父目录运行 `mvn -pl common-web -am test`。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/common-web/pom.xml b/common-web/pom.xml index fa892aee9..3a5a14627 100644 --- a/common-web/pom.xml +++ b/common-web/pom.xml @@ -39,6 +39,11 @@ io.micrometer micrometer-registry-prometheus + + io.github.openfeign + feign-core + true + org.springframework.boot spring-boot-starter-test diff --git a/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java b/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java index 976ab0b30..a38dfaf80 100644 --- a/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java +++ b/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java @@ -9,8 +9,11 @@ import org.slf4j.MDC; public final class CorrelationContext { public static final String REQUEST_ID = "requestId"; public static final String TRACE_ID = "traceId"; + public static final String SPAN_ID = "spanId"; + public static final String TRACE_PARENT = "traceparent"; public static final String REQUEST_ID_HEADER = "X-Request-Id"; public static final String TRACE_ID_HEADER = "X-Trace-Id"; + public static final String TRACE_PARENT_HEADER = TraceParent.HEADER; private CorrelationContext() {} @@ -22,6 +25,14 @@ public final class CorrelationContext { return value(request, TRACE_ID); } + public static String spanId(HttpServletRequest request) { + return value(request, SPAN_ID); + } + + public static String traceParent(HttpServletRequest request) { + return value(request, TRACE_PARENT); + } + private static String value(HttpServletRequest request, String key) { Object attribute = request.getAttribute(key); return attribute == null ? MDC.get(key) : attribute.toString(); diff --git a/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java b/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java index b08f7c832..f9e39379d 100644 --- a/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java +++ b/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.Enumeration; import java.util.UUID; import java.util.regex.Pattern; @@ -25,24 +26,60 @@ public class CorrelationIdFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String requestId = acceptedOrGenerated(request.getHeader(CorrelationContext.REQUEST_ID_HEADER), false); - String traceId = acceptedOrGenerated(request.getHeader(CorrelationContext.TRACE_ID_HEADER), true); + String requestId = requestId(request); + TraceParent traceParent = traceParent(request); + String traceId = traceParent.traceId(); request.setAttribute(CorrelationContext.REQUEST_ID, requestId); request.setAttribute(CorrelationContext.TRACE_ID, traceId); + request.setAttribute(CorrelationContext.SPAN_ID, traceParent.parentId()); + request.setAttribute(CorrelationContext.TRACE_PARENT, traceParent.value()); response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId); response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId); + response.setHeader(CorrelationContext.TRACE_PARENT_HEADER, traceParent.value()); try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId); - MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId)) { + MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId); + MDC.MDCCloseable ignoredSpan = MDC.putCloseable(CorrelationContext.SPAN_ID, traceParent.parentId())) { filterChain.doFilter(request, response); } } - private String acceptedOrGenerated(String candidate, boolean compact) { - if (candidate != null && SAFE_ID.matcher(candidate).matches()) { - return candidate; + private String requestId(HttpServletRequest request) { + HeaderValue header = singleHeader(request, CorrelationContext.REQUEST_ID_HEADER, 128); + if (header.valid() && SAFE_ID.matcher(header.value()).matches()) { + return header.value(); + } + return UUID.randomUUID().toString(); + } + + private TraceParent traceParent(HttpServletRequest request) { + HeaderValue standard = singleHeader(request, CorrelationContext.TRACE_PARENT_HEADER, + TraceParent.HEADER_LENGTH); + if (standard.present()) { + if (standard.valid()) { + return TraceParent.parse(standard.value()).map(TraceParent::childOf) + .orElseGet(() -> TraceParent.newRoot(null)); + } + return TraceParent.newRoot(null); + } + + HeaderValue legacy = singleHeader(request, CorrelationContext.TRACE_ID_HEADER, 32); + return TraceParent.newRoot(legacy.valid() ? legacy.value() : null); + } + + private HeaderValue singleHeader(HttpServletRequest request, String name, int maxLength) { + Enumeration values = request.getHeaders(name); + if (values == null || !values.hasMoreElements()) { + return HeaderValue.missing(); + } + String value = values.nextElement(); + boolean duplicate = values.hasMoreElements(); + return new HeaderValue(true, !duplicate && value != null && value.length() <= maxLength, value); + } + + private record HeaderValue(boolean present, boolean valid, String value) { + private static HeaderValue missing() { + return new HeaderValue(false, false, null); } - String id = UUID.randomUUID().toString(); - return compact ? id.replace("-", "") : id; } } diff --git a/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java new file mode 100644 index 000000000..e4223e528 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java @@ -0,0 +1,18 @@ +package com.baiyue.common.web; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** Enables synchronous Feign trace propagation without making Feign a required dependency. */ +@AutoConfiguration(afterName = "org.springframework.cloud.openfeign.FeignAutoConfiguration") +@ConditionalOnClass(name = "feign.RequestInterceptor") +public class FeignTracePropagationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(FeignTraceRequestInterceptor.class) + FeignTraceRequestInterceptor feignTraceRequestInterceptor() { + return new FeignTraceRequestInterceptor(); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java b/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java new file mode 100644 index 000000000..88303c069 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java @@ -0,0 +1,41 @@ +package com.baiyue.common.web; + +import feign.RequestInterceptor; +import feign.RequestTemplate; +import org.slf4j.MDC; +import org.springframework.core.Ordered; + +import java.util.regex.Pattern; + +/** Propagates the trusted synchronous request trace context to every Feign invocation. */ +public final class FeignTraceRequestInterceptor implements RequestInterceptor, Ordered { + private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + @Override + public void apply(RequestTemplate template) { + removeManagedHeaders(template); + + String requestId = MDC.get(CorrelationContext.REQUEST_ID); + if (requestId != null && SAFE_REQUEST_ID.matcher(requestId).matches()) { + template.header(CorrelationContext.REQUEST_ID_HEADER, requestId); + } + + String traceId = MDC.get(CorrelationContext.TRACE_ID); + if (TraceParent.validLegacyTraceId(traceId)) { + TraceParent outbound = TraceParent.outboundChild(traceId); + template.header(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()); + template.header(CorrelationContext.TRACE_PARENT_HEADER, outbound.value()); + } + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } + + private void removeManagedHeaders(RequestTemplate template) { + template.removeHeader(CorrelationContext.REQUEST_ID_HEADER); + template.removeHeader(CorrelationContext.TRACE_ID_HEADER); + template.removeHeader(CorrelationContext.TRACE_PARENT_HEADER); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java new file mode 100644 index 000000000..2ece6f081 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java @@ -0,0 +1,92 @@ +package com.baiyue.common.web; + +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.Optional; +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"); + } + + public static boolean validLegacyTraceId(String value) { + return value != null && LEGACY_TRACE_ID.matcher(value).matches() + && !ZERO_TRACE_ID.equalsIgnoreCase(value); + } + + public String value() { + return "00-" + traceId + '-' + parentId + '-' + traceFlags; + } + + private static boolean validTraceId(String value) { + return value != null && value.matches("[0-9a-f]{32}") && !ZERO_TRACE_ID.equals(value); + } + + private static boolean validParentId(String value) { + return value != null && value.matches("[0-9a-f]{16}") && !ZERO_PARENT_ID.equals(value); + } + + private static String randomNonZero(int byteCount) { + byte[] bytes = new byte[byteCount]; + String value; + do { + RANDOM.nextBytes(bytes); + value = HEX.formatHex(bytes); + } while (value.chars().allMatch(character -> character == '0')); + return value; + } +} diff --git a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 66426422f..06a277bae 100644 --- a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1,3 @@ com.baiyue.common.web.LongIdJacksonConfiguration com.baiyue.common.web.CommonWebAutoConfiguration +com.baiyue.common.web.FeignTracePropagationAutoConfiguration diff --git a/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java index bab2952df..08739af98 100644 --- a/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java @@ -1,6 +1,8 @@ package com.baiyue.common.web; import org.junit.jupiter.api.Test; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -16,6 +18,8 @@ class CommonWebAutoConfigurationTest { contextRunner.run(context -> { assertThat(context).hasSingleBean(GlobalExceptionHandler.class); assertThat(context).hasSingleBean(CorrelationIdFilter.class); + assertThat(CorrelationIdFilter.class.getAnnotation(Order.class).value()) + .isEqualTo(Ordered.HIGHEST_PRECEDENCE); }); } diff --git a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java index 08bb2e583..67d4bd3be 100644 --- a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java @@ -6,6 +6,8 @@ import org.springframework.mock.web.MockFilterChain; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import java.io.IOException; + import static org.assertj.core.api.Assertions.assertThat; @DisplayName("请求链路标识过滤功能") @@ -13,27 +15,87 @@ class CorrelationIdFilterTest { private final CorrelationIdFilter filter = new CorrelationIdFilter(); @Test - void preservesSafeIncomingIds() throws Exception { + void validTraceParentWinsAndMapsToLegacyTraceIdAndMdc() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "req-123"); - request.addHeader(CorrelationContext.TRACE_ID_HEADER, "trace-123"); + request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); MockHttpServletResponse response = new MockHttpServletResponse(); - filter.doFilter(request, response, new MockFilterChain()); + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilter(request, response, chain); assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)).isEqualTo("req-123"); - assertThat(request.getAttribute(CorrelationContext.TRACE_ID)).isEqualTo("trace-123"); + assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) + .isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + assertThat(chain.traceId).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(chain.spanId).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000"); + assertThat(org.slf4j.MDC.get(CorrelationContext.TRACE_ID)).isNull(); + assertThat(org.slf4j.MDC.get(CorrelationContext.SPAN_ID)).isNull(); } @Test - void replacesUnsafeIncomingIds() throws Exception { + void fallsBackToValidLegacyTraceIdOnlyWhenTraceParentIsMissing() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "invalid value\n"); + request.addHeader(CorrelationContext.TRACE_ID_HEADER, "ABCDEF0123456789ABCDEF0123456789"); MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, new MockFilterChain()); + + assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) + .isEqualTo("abcdef0123456789abcdef0123456789"); + assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00"); + } + + @Test + void invalidTraceParentStartsNewTraceWithoutTrustingLegacyTraceId() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-00000000000000000000000000000000-00f067aa0ba902b7-01"); + request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + + assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) + .matches("[0-9a-f]{32}") + .isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .isNotEqualTo("00000000000000000000000000000000"); + } + + @Test + void rejectsDuplicateAndOversizedHeaders() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "a".repeat(129)); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)) .matches("[0-9a-f-]{36}"); assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) - .matches("[0-9a-f]{32}"); + .matches("[0-9a-f]{32}") + .isNotEqualTo("4bf92f3577b34da6a3ce929d0e0e4736") + .isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + } + + private static final class CapturingFilterChain extends MockFilterChain { + private String traceId; + private String spanId; + + @Override + public void doFilter(jakarta.servlet.ServletRequest request, + jakarta.servlet.ServletResponse response) throws IOException { + traceId = org.slf4j.MDC.get(CorrelationContext.TRACE_ID); + spanId = org.slf4j.MDC.get(CorrelationContext.SPAN_ID); + } } } diff --git a/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java new file mode 100644 index 000000000..2aeec044f --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java @@ -0,0 +1,37 @@ +package com.baiyue.common.web; + +import feign.RequestInterceptor; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class FeignTracePropagationAutoConfigurationTest { + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(FeignTracePropagationAutoConfiguration.class)); + + @Test + void registersTraceInterceptorWhenFeignIsPresent() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class); + assertThat(context.getBeansOfType(RequestInterceptor.class)).hasSize(1); + }); + } + + @Test + void doesNotLoadFeignConfigurationWhenFeignIsAbsent() { + contextRunner.withClassLoader(new FilteredClassLoader(RequestInterceptor.class)) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean("feignTraceRequestInterceptor"); + }); + } + + @Test + void backsOffOnlyForAnApplicationProvidedTraceInterceptor() { + contextRunner.withBean(FeignTraceRequestInterceptor.class, FeignTraceRequestInterceptor::new) + .run(context -> assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class)); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java b/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java new file mode 100644 index 000000000..d87b3d445 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java @@ -0,0 +1,80 @@ +package com.baiyue.common.web; + +import feign.RequestTemplate; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.core.Ordered; + +import static org.assertj.core.api.Assertions.assertThat; + +class FeignTraceRequestInterceptorTest { + private final FeignTraceRequestInterceptor interceptor = new FeignTraceRequestInterceptor(); + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void runsLastSoTrustedContextHasFinalHeaderAuthority() { + assertThat(interceptor.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE); + } + + @Test + void overwritesCallerHeadersFromTrustedMdcAndCreatesNewSampledSpan() { + MDC.put(CorrelationContext.REQUEST_ID, "req-trusted-123"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + RequestTemplate template = templateWithSpoofedHeaders(); + + interceptor.apply(template); + + assertThat(template.headers().get(CorrelationContext.REQUEST_ID_HEADER)) + .containsExactly("req-trusted-123"); + assertThat(template.headers().get(CorrelationContext.TRACE_ID_HEADER)) + .containsExactly("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(template.headers().get(CorrelationContext.TRACE_PARENT_HEADER)) + .singleElement().asString() + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void createsDifferentNonZeroSpanForEachSynchronousInvocation() { + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + RequestTemplate first = new RequestTemplate(); + RequestTemplate second = new RequestTemplate(); + + interceptor.apply(first); + interceptor.apply(second); + + assertThat(singleHeader(first, CorrelationContext.TRACE_PARENT_HEADER)) + .isNotEqualTo(singleHeader(second, CorrelationContext.TRACE_PARENT_HEADER)); + } + + @Test + void stripsManagedCallerHeadersWhenTrustedContextIsMissingOrInvalid() { + MDC.put(CorrelationContext.REQUEST_ID, "invalid request id"); + MDC.put(CorrelationContext.TRACE_ID, "not-a-w3c-trace-id"); + RequestTemplate template = templateWithSpoofedHeaders(); + + interceptor.apply(template); + + assertThat(template.headers()).doesNotContainKeys( + CorrelationContext.REQUEST_ID_HEADER, + CorrelationContext.TRACE_ID_HEADER, + CorrelationContext.TRACE_PARENT_HEADER); + } + + private RequestTemplate templateWithSpoofedHeaders() { + RequestTemplate template = new RequestTemplate(); + template.header(CorrelationContext.REQUEST_ID_HEADER, "req-business-parameter"); + template.header(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + template.header(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + return template; + } + + private String singleHeader(RequestTemplate template, String name) { + return template.headers().get(name).iterator().next(); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java new file mode 100644 index 000000000..e59be28aa --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java @@ -0,0 +1,54 @@ +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"); + } +} diff --git a/config/nacos/overseas/baiyue-common.yml b/config/nacos/overseas/baiyue-common.yml index f1ecfa04d..a9e0a75f3 100644 --- a/config/nacos/overseas/baiyue-common.yml +++ b/config/nacos/overseas/baiyue-common.yml @@ -8,6 +8,10 @@ baiyue: 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 diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml index 27c492a4b..bbc198d5c 100644 --- a/config/nacos/overseas/event-bridge-service.yml +++ b/config/nacos/overseas/event-bridge-service.yml @@ -10,6 +10,7 @@ baiyue: data-residency: ${DATA_RESIDENCY:OVERSEAS} event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} + security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://overseas-security-service:9307/internal/overseas-security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/config/nacos/overseas/gateway-overseas.yml b/config/nacos/overseas/gateway-overseas.yml index e16f76e10..11e2deb34 100644 --- a/config/nacos/overseas/gateway-overseas.yml +++ b/config/nacos/overseas/gateway-overseas.yml @@ -1,3 +1,9 @@ springdoc: api-docs.enabled: ${SWAGGER_ENABLED:false} swagger-ui.enabled: ${SWAGGER_ENABLED:false} +baiyue: + security: + overseas-projection: + service-url: ${OVERSEAS_SECURITY_SERVICE_URL:http://overseas-security-service:9307} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml index b4e34c36d..c4d919be3 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/overseas/lastleg-service.yml @@ -10,6 +10,20 @@ lastleg: smart-dispatch-api: enabled: true baiyue: + http: + clients: + unload-photo-file: + connect-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_READ_TIMEOUT:30s} + relabel-photo-file: + connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s} + warehouse-resource: + connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s} + pallet-physical: + connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s} clients: domestic-warehouse: url: ${DOMESTIC_WAREHOUSE_URL} @@ -21,7 +35,7 @@ baiyue: warehouse: base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305} firstleg: - event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL} + 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} diff --git a/config/nacos/overseas/overseas-security-service.yml b/config/nacos/overseas/overseas-security-service.yml new file mode 100644 index 000000000..80565c445 --- /dev/null +++ b/config/nacos/overseas/overseas-security-service.yml @@ -0,0 +1,7 @@ +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:false} + swagger-ui.enabled: ${SWAGGER_ENABLED:false} +baiyue: + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index e807b86e2..f41f67f15 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -37,7 +37,7 @@ services: <<: *overseas-common image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas - ports: ["${GATEWAY_OVERSEAS_PORT:-9300}:9300"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"] environment: <<: *overseas-environment @@ -45,12 +45,16 @@ services: 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} + OVERSEAS_SECURITY_SERVICE_URL: ${OVERSEAS_SECURITY_SERVICE_URL:-http://overseas-security-service:9307} + OVERSEAS_SECURITY_VALIDATION_TIMEOUT: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:-2s} lastleg-service: <<: *overseas-common image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-lastleg - ports: ["${LASTLEG_PORT:-9303}:9303"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"] environment: <<: *overseas-environment @@ -58,7 +62,8 @@ services: LASTLEG_DB_URL: ${LASTLEG_DB_URL} LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} - FIRSTLEG_EVENT_BRIDGE_URL: ${FIRSTLEG_EVENT_BRIDGE_URL} + CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} + FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} FILE_SERVICE_URL: ${FILE_SERVICE_URL} WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} @@ -67,7 +72,7 @@ services: <<: *overseas-common image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas - ports: ["${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"] environment: <<: *overseas-environment @@ -80,7 +85,7 @@ services: <<: *overseas-common image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas - ports: ["${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"] environment: <<: *overseas-environment @@ -89,6 +94,24 @@ services: 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} + OVERSEAS_SECURITY_EVENT_CONSUMER_URL: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:-http://overseas-security-service:9307/internal/overseas-security/v1/events} + networks: + firstleg-overseas-network: + infra-network: + aliases: [event-bridge-overseas] + + overseas-security-service: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/overseas-security-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-overseas-security + ports: ["${PUBLISHED_BIND_ADDRESS:-127.0.0.1}:${OVERSEAS_SECURITY_PORT:-9307}:9307"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/overseas-security-service:/app/logs"] + environment: + <<: *overseas-environment + SERVER_PORT: 9307 + OVERSEAS_SECURITY_DB_URL: ${OVERSEAS_SECURITY_DB_URL} + OVERSEAS_SECURITY_DB_USERNAME: ${OVERSEAS_SECURITY_DB_USERNAME} + OVERSEAS_SECURITY_DB_PASSWORD: ${OVERSEAS_SECURITY_DB_PASSWORD} networks: firstleg-overseas-network: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 385efc81b..4f6700005 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -39,7 +39,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-gateway - ports: ["${GATEWAY_PORT:-9000}:9000"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"] environment: <<: *common-environment @@ -52,7 +52,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-auth - ports: ["${AUTH_PORT:-9100}:9100"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"] environment: <<: *common-environment @@ -71,7 +71,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-iam - ports: ["${IAM_PORT:-9201}:9201"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"] environment: <<: *common-environment @@ -84,7 +84,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-business - ports: ["${FIRSTLEG_PORT:-9202}:9202"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"] environment: <<: *common-environment @@ -110,7 +110,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-finance - ports: ["${FINANCE_PORT:-9203}:9203"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"] environment: <<: *common-environment @@ -123,7 +123,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-order - ports: ["${ORDER_PORT:-9204}:9204"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"] environment: <<: *common-environment @@ -136,7 +136,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-warehouse - ports: ["${WAREHOUSE_PORT:-9205}:9205"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"] environment: <<: *common-environment @@ -149,7 +149,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-file - ports: ["${FILE_PORT:-9206}:9206"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"] volumes: - "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files" - "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs" @@ -173,7 +173,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-print - ports: ["${PRINT_PORT:-9207}:9207"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"] environment: <<: *common-environment @@ -184,7 +184,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-integration - ports: ["${INTEGRATION_PORT:-9210}:9210"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"] environment: <<: *common-environment @@ -234,7 +234,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge - ports: ["${EVENT_BRIDGE_PORT:-9312}:9312"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"] environment: <<: *common-environment @@ -245,6 +245,10 @@ services: EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} SPRING_SQL_INIT_MODE: never + networks: + firstleg-network: + infra-network: + aliases: [event-bridge-cn] audit-service: <<: *common diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 1479c8193..b8d703d8c 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -36,6 +36,16 @@ case "$DEPLOY_ENV" in ;; esac +if [ "$DEPLOY_ENV" = "development" ]; then + case "$SERVICE" in + gateway|gateway-overseas) PUBLISHED_BIND_ADDRESS=0.0.0.0 ;; + *) PUBLISHED_BIND_ADDRESS=127.0.0.1 ;; + esac +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 @@ -57,7 +67,7 @@ env_file_value() { } case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service) + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) COMPOSE_FILE="deploy/docker-compose.overseas.yml" PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" ;; @@ -84,6 +94,22 @@ case "$SERVICE" in ;; 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 + +if [ "$SERVICE" = "overseas-security-service" ]; then + for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + for file in "$ENV_FILE" "$COMPOSE_FILE"; do if [ ! -f "$file" ]; then echo "必需文件不存在: $file" >&2 @@ -101,7 +127,7 @@ if [ "$SERVICE" = "finance" ]; then fi case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service) + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then echo "部署海外服务时,环境文件必须包含非空 $key" >&2 @@ -205,11 +231,27 @@ local_health_state() { 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 "容器健康状态: $health_state(已等待 ${elapsed}s)" + 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 @@ -246,6 +288,11 @@ deploy_local() { 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 @@ -254,6 +301,13 @@ deploy_local() { 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"*) @@ -378,6 +432,14 @@ cd "$remote_dir" export DEPLOY_ENV="$deploy_env" export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" +if [ "$deploy_env" = "development" ]; then + case "$service" in + gateway|gateway-overseas) export PUBLISHED_BIND_ADDRESS=0.0.0.0 ;; + *) export PUBLISHED_BIND_ADDRESS=127.0.0.1 ;; + esac +else + export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" +fi compose() { docker compose \ @@ -441,12 +503,24 @@ if ! compose up -d --no-deps --pull never "$service"; then new_started=false fi if [ "$new_started" = "true" ] && wait_for_health; then + running_image="$(container_image || true)" + if [ "$running_image" != "$new_image" ]; then + echo "运行容器镜像与请求镜像不一致: expected=$new_image actual=${running_image:-missing}" >&2 + exit 1 + fi echo "新镜像健康检查通过: $new_image" compose ps "$service" exit 0 fi echo "新镜像健康检查失败: $new_image" >&2 +failed_container_id="$(remote_container_id || true)" +if [ -n "$failed_container_id" ]; then + echo "新镜像容器最近日志(最多 200 行):" >&2 + docker logs --tail 200 "$failed_container_id" 2>&1 || true +else + echo "新镜像容器不存在,无法读取启动日志" >&2 +fi expected_prefix="$image_repository/$service:" case "$previous_image" in "$expected_prefix"*) previous_tag="${previous_image#"$expected_prefix"}" ;; diff --git a/deploy/scripts/deploy-development-service.sh b/deploy/scripts/deploy-development-service.sh index b1034e2b9..23343f7a4 100644 --- a/deploy/scripts/deploy-development-service.sh +++ b/deploy/scripts/deploy-development-service.sh @@ -64,6 +64,7 @@ if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then cd "$REMOTE_DIR" export DEPLOY_ENV=development export IMAGE_REPOSITORY IMAGE_TAG + export PUBLISHED_BIND_ADDRESS=127.0.0.1 docker compose \ --project-name "$PROJECT_NAME" \ @@ -143,6 +144,7 @@ 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" \ diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index ea21fbb89..abdbafef5 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -39,7 +39,7 @@ env_file_value() { DEPLOY_REGION=CN for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) DEPLOY_REGION=OVERSEAS ;; event-bridge-service) BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then @@ -60,7 +60,7 @@ done if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service|event-bridge-service) ;; *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; esac done @@ -80,6 +80,26 @@ if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then done fi +case " $SERVICES " in + *" overseas-security-service "*) + for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + +case " $SERVICES " in + *" gateway-overseas "*) + if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then + echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2 + exit 2 + fi + ;; +esac + case " $SERVICES " in *" finance "*) for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do diff --git a/deploy/service-map.yml b/deploy/service-map.yml index e22eec804..e78b8a6e5 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -8,6 +8,11 @@ services: jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar port: 9300 regions: [OVERSEAS] + overseas-security-service: + artifactId: overseas-security-service + jar: overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar + port: 9307 + regions: [OVERSEAS] auth: artifactId: auth-service jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar diff --git a/deploy/sql/README.md b/deploy/sql/README.md index e06944ed6..dc681dba9 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -3,13 +3,13 @@ ## 唯一数据库初始化与发布入口 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 -- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先执行 `lastleg-service/src/main/resources/db/lastleg.sql` 基线,再执行该入口。当前包含海外仓报价结构,后续海外主数据继续追加到同一入口,不按日期拆分。 +- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`、`warehouse-overseas-service/src/main/resources/db/warehouse.sql` 与 `overseas-security-service/src/main/resources/db/overseas-security.sql` 基线,再执行该入口。固定入口升级 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 与 `baiyue_overseas_security`,后者保存 IAM 安全事件消费者 Inbox 和可恢复投影;Redis 只作可重建镜像。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 -- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。 +- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建最小登录账号,重复执行不覆盖现有密码。脚本不写入演示订单;正式平台公共仓库主数据已内联到固定入口。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 6f92303cc..e71adf7d2 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -129,7 +129,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox ( event_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + customer_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_version BIGINT NOT NULL COMMENT '聚合版本', @@ -160,7 +160,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox ( event_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + customer_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_version BIGINT NOT NULL COMMENT '聚合版本', @@ -184,6 +184,41 @@ CREATE TABLE IF NOT EXISTS bridge_inbox ( KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until) ) ENGINE=InnoDB COMMENT='跨区域事件已接收箱'; +-- 旧开发库曾使用 tenant_id;当前 Event Bridge 实体统一为 customer_id。幂等改名并保留原数据。 +SET @bridge_inbox_customer_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_inbox' AND column_name = 'customer_id' +); +SET @bridge_inbox_tenant_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_inbox' AND column_name = 'tenant_id' +); +SET @bridge_inbox_column_sql = IF( + @bridge_inbox_customer_column = 0 AND @bridge_inbox_tenant_column = 1, + 'ALTER TABLE bridge_inbox RENAME COLUMN tenant_id TO customer_id', + 'SELECT 1' +); +PREPARE bridge_inbox_column_statement FROM @bridge_inbox_column_sql; +EXECUTE bridge_inbox_column_statement; +DEALLOCATE PREPARE bridge_inbox_column_statement; + +SET @bridge_outbox_customer_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_outbox' AND column_name = 'customer_id' +); +SET @bridge_outbox_tenant_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_outbox' AND column_name = 'tenant_id' +); +SET @bridge_outbox_column_sql = IF( + @bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1, + 'ALTER TABLE bridge_outbox RENAME COLUMN tenant_id TO customer_id', + 'SELECT 1' +); +PREPARE bridge_outbox_column_statement FROM @bridge_outbox_column_sql; +EXECUTE bridge_outbox_column_statement; +DEALLOCATE PREPARE bridge_outbox_column_statement; + CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', @@ -244,7 +279,7 @@ EXECUTE auth_password_change_required_stmt; DEALLOCATE PREPARE auth_password_change_required_stmt; -- 开发/测试环境最小登录基线:一个平台系统管理员和一个 IT 测试客户管理员。 --- 初始密码统一为 ChangeMe123!;只保存 BCrypt 哈希,不写入明文。 +-- 缺失账号首次创建时写入开发基线 BCrypt 哈希;重复执行统一增量绝不覆盖现有密码。 INSERT INTO auth_account ( id, username, password_hash, display_name, account_status, failed_login_count, token_version, password_change_required, default_tenant_id, @@ -255,7 +290,6 @@ INSERT INTO auth_account ( (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0) ON DUPLICATE KEY UPDATE - password_hash = VALUES(password_hash), display_name = VALUES(display_name), account_status = 'ACTIVE', failed_login_count = 0, @@ -404,6 +438,7 @@ CREATE TABLE IF NOT EXISTS iam_membership ( user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID', member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等', member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号', + overseas_warehouse_id BIGINT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开', joined_at DATETIME(3) NULL COMMENT '加入客户企业时间', left_at DATETIME(3) NULL COMMENT '离开客户企业时间', @@ -418,6 +453,13 @@ CREATE TABLE IF NOT EXISTS iam_membership ( KEY idx_membership_tenant (tenant_id, status) ) ENGINE=InnoDB COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体'; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='iam_membership' AND column_name='overseas_warehouse_id'), + 'SELECT 1', 'ALTER TABLE iam_membership ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''海外仓PDA操作员绑定的唯一实际海外仓ID'' AFTER member_no')); +PREPARE iam_membership_overseas_warehouse_stmt FROM @ddl; +EXECUTE iam_membership_overseas_warehouse_stmt; +DEALLOCATE PREPARE iam_membership_overseas_warehouse_stmt; + CREATE TABLE IF NOT EXISTS iam_role ( id BIGINT NOT NULL COMMENT '角色ID', tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色作用域:0为平台预置模板,正数为客户ID和数据隔离键', @@ -541,6 +583,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( container_mode VARCHAR(24) NULL COMMENT '装运方式:FULL_CONTAINER整柜、LCL散货拼箱', customs_inspection TINYINT(1) NULL COMMENT '是否需要商检:0否、1是', destination_type VARCHAR(24) NULL COMMENT '目的仓类型:FBA亚马逊仓、OVERSEAS海外仓、PRIVATE私人地址', + fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', amazon_warehouse_code VARCHAR(32) NULL COMMENT 'Amazon目的仓编码', shipment_id VARCHAR(64) NULL COMMENT '订单级Amazon Shipment ID', reference_id VARCHAR(64) NULL COMMENT '订单级Amazon Reference ID', @@ -562,6 +605,26 @@ CREATE TABLE IF NOT EXISTS ord_order ( KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; +SET @order_fulfillment_type_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND column_name = 'fulfillment_type' + ), + 'SELECT 1', + 'ALTER TABLE baiyue_order.ord_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER destination_type' +); +PREPARE order_fulfillment_type_stmt FROM @order_fulfillment_type_sql; +EXECUTE order_fulfillment_type_stmt; +DEALLOCATE PREPARE order_fulfillment_type_stmt; + +UPDATE baiyue_order.ord_order + SET fulfillment_type = 'STANDARD' + WHERE fulfillment_type IS NULL OR fulfillment_type = ''; +ALTER TABLE baiyue_order.ord_order + MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' + COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'; + -- 既有开发库可能仍保留草稿入仓码 NOT NULL 约束;必须先放宽再清理草稿入仓码。 SET @order_inbound_code_nullable_sql = IF( EXISTS( @@ -2489,6 +2552,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID', delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', channel_code VARCHAR(64) NULL COMMENT '渠道编码', channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID', @@ -2539,6 +2603,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; +SET @inbound_fulfillment_type_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND column_name = 'fulfillment_type' + ), + 'SELECT 1', + 'ALTER TABLE fl_inbound_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER business_type' +); +PREPARE inbound_fulfillment_type_stmt FROM @inbound_fulfillment_type_sql; +EXECUTE inbound_fulfillment_type_stmt; +DEALLOCATE PREPARE inbound_fulfillment_type_stmt; + +UPDATE fl_inbound_order + SET fulfillment_type = 'STANDARD' + WHERE fulfillment_type IS NULL OR fulfillment_type = ''; +ALTER TABLE fl_inbound_order + MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' + COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'; + SET @inbound_global_code_index_exists := ( SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' @@ -4281,8 +4365,10 @@ CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_id'); CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_name'); CALL unified_assert_column('baiyue_order', 'ord_order', 'pricing_tax_mode'); CALL unified_assert_column('baiyue_order', 'ord_order', 'inbound_code'); +CALL unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode'); +CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_channel_catalog'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_origin_alias'); @@ -5602,14 +5688,23 @@ BEGIN SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3)); ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN IF EXISTS (SELECT 1 FROM fl_loading_plan_item item - LEFT JOIN fl_loading_pick_item pick_item - ON pick_item.loading_plan_item_id = item.id - LEFT JOIN fl_loading_pick_task pick_task - ON pick_task.id = pick_item.pick_task_id - AND pick_task.loading_plan_id = OLD.id WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 - AND (pick_task.id IS NULL - OR pick_item.picked_package_count <> item.package_count)) THEN + AND NOT EXISTS ( + SELECT 1 FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id + WHERE pick_item.loading_plan_item_id = item.id + AND pick_task.loading_plan_id = OLD.id + AND pick_task.task_status = 'LOADING' + AND ( + (pick_item.item_status = 'PICKED' + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'PARTIAL' + AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'SKIPPED' + AND pick_item.picked_package_count = 0) + ) + )) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; END IF; SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); @@ -7492,6 +7587,9 @@ CREATE TABLE IF NOT EXISTS fl_preload_plan ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增', items_json JSON NOT NULL COMMENT '可编辑分柜明细JSON快照;人工确认后作为预装依据', remark VARCHAR(1000) NULL COMMENT '预装备注', + confirmed_by BIGINT UNSIGNED NULL COMMENT '确认预装的操作人ID', + confirmed_by_name VARCHAR(64) NULL COMMENT '确认预装的操作人姓名快照', + confirmed_at DATETIME(3) NULL COMMENT '预装确认时间', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建预装清单的操作人ID', created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID', @@ -7504,6 +7602,30 @@ CREATE TABLE IF NOT EXISTS fl_preload_plan ( CONSTRAINT fk_preload_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='订单分柜预装清单'; +DROP PROCEDURE IF EXISTS migrate_preload_confirmation_columns; +DELIMITER $$ +CREATE PROCEDURE migrate_preload_confirmation_columns() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_preload_plan' AND column_name='confirmed_by') THEN + ALTER TABLE fl_preload_plan + ADD COLUMN confirmed_by BIGINT UNSIGNED NULL COMMENT '确认预装的操作人ID' AFTER remark; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_preload_plan' AND column_name='confirmed_by_name') THEN + ALTER TABLE fl_preload_plan + ADD COLUMN confirmed_by_name VARCHAR(64) NULL COMMENT '确认预装的操作人姓名快照' AFTER confirmed_by; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_preload_plan' AND column_name='confirmed_at') THEN + ALTER TABLE fl_preload_plan + ADD COLUMN confirmed_at DATETIME(3) NULL COMMENT '预装确认时间' AFTER confirmed_by_name; + END IF; +END$$ +DELIMITER ; +CALL migrate_preload_confirmation_columns(); +DROP PROCEDURE IF EXISTS migrate_preload_confirmation_columns; + CREATE TABLE IF NOT EXISTS fl_split_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -10428,6 +10550,26 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( CONSTRAINT chk_carrier_schedule_operation_status CHECK (status IN ('PROCESSING','SUCCESS')) ) ENGINE=InnoDB COMMENT='船司查询写操作幂等与请求摘要'; +-- CREATE TABLE IF NOT EXISTS 不会更新已有表的 CHECK。历史开发库仍可能只允许 +-- CREATE/ATTEMPT/MANUAL_RESULT,必须在人工节点和自动节点写入前收敛为当前动作集。 +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check; +DELIMITER $$ +CREATE PROCEDURE migrate_carrier_schedule_history_action_check() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query_history' + AND constraint_name='chk_carrier_schedule_history_action' AND constraint_type='CHECK') THEN + ALTER TABLE fl_carrier_schedule_query_history DROP CHECK chk_carrier_schedule_history_action; + END IF; + ALTER TABLE fl_carrier_schedule_query_history + ADD CONSTRAINT chk_carrier_schedule_history_action CHECK + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED', + 'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP')); +END$$ +DELIMITER ; +CALL migrate_carrier_schedule_history_action_check(); +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check; + DROP TRIGGER IF EXISTS trg_carrier_schedule_query_before_insert; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW @@ -10862,6 +11004,7 @@ DELETE role_permission WHERE role.tenant_id = 0 AND role.app_code = 'PLATFORM' AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY') + AND NOT (role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate') AND expected.permission_code IS NULL; INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) @@ -12342,7 +12485,7 @@ CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'retry_count', CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'source_region', '来源区域'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'status', '消费状态'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'target_region', '目标区域'); -CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'tenant_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); +CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'customer_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'trace_id', '链路标识'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'aggregate_id', '聚合标识'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'aggregate_type', '聚合类型'); @@ -12367,7 +12510,7 @@ CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'retry_count', CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'source_region', '来源区域'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'status', '投递状态'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'target_region', '目标区域'); -CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'tenant_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); +CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'customer_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'trace_id', '链路标识'); CALL queue_column_comment('baiyue_file', 'file_download_log', 'client_ip', '客户端IP'); CALL queue_column_comment('baiyue_file', 'file_download_log', 'downloaded_at', '下载时间'); @@ -16166,6 +16309,7 @@ VALUES (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), @@ -16218,10 +16362,123 @@ INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permissio SELECT 0, role.id, permission.id, NOW(3) FROM baiyue_iam.iam_role role JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' - AND permission.id BETWEEN 5107000 AND 5107299 + AND permission.id BETWEEN 5107000 AND 5107299 AND permission.permission_code<>'lastleg:pda:operate' WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +DELETE role_permission FROM baiyue_iam.iam_role_permission role_permission +JOIN baiyue_iam.iam_role role ON role.id=role_permission.role_id AND role.tenant_id=role_permission.tenant_id +JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id AND permission.tenant_id=role_permission.tenant_id +WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER'); + +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM baiyue_iam.iam_role role +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code='lastleg:pda:operate' +WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS baiyue_iam.assert_overseas_pda_picker_permission; +DELIMITER $$ +CREATE PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission() +BEGIN + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission grant_row + JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE permission.permission_code='lastleg:pda:operate' + AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:WAREHOUSE_PICKER未精确授权'; + END IF; + IF EXISTS (SELECT 1 FROM baiyue_iam.iam_role_permission grant_row + JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非仓库拣货员授权'; + END IF; +END$$ +DELIMITER ; +CALL baiyue_iam.assert_overseas_pda_picker_permission(); +DROP PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission; + +-- 四期仓库后台角色严格最小授权:list 同时覆盖列表与详情,只读角色不得获得任何写权限。 +USE baiyue_iam; +DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission; +CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission ( + role_code VARCHAR(64) NOT NULL, + permission_code VARCHAR(160) NOT NULL, + PRIMARY KEY (role_code, permission_code) +); +INSERT INTO tmp_phase4_warehouse_role_permission (role_code, permission_code) VALUES + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:last-mile-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inbound-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:relabel-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inventory:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:warehouse:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:location:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:pallet:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:purchase:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:truck:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:looseboard:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:last-mile-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:inbound-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:relabel-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:inventory:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:warehouse:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:location:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:pallet:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:purchase:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:truck:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:looseboard:list'); + +DELETE grant_row FROM baiyue_iam.iam_role_permission grant_row +JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id +JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id +LEFT JOIN tmp_phase4_warehouse_role_permission expected + ON expected.role_code=role.role_code AND expected.permission_code=permission.permission_code +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_PICKER','WAREHOUSE_TALLY') + AND permission.permission_code LIKE 'platform:overseas-%' + AND expected.permission_code IS NULL; + +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM tmp_phase4_warehouse_role_permission expected +JOIN baiyue_iam.iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0 +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=expected.permission_code + AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS baiyue_iam.assert_phase4_warehouse_role_minimum; +DELIMITER $$ +CREATE PROCEDURE baiyue_iam.assert_phase4_warehouse_role_minimum() +BEGIN + IF (SELECT COUNT(*) FROM tmp_phase4_warehouse_role_permission) <> 20 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限矩阵数量异常'; + END IF; + IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission + WHERE permission_code REGEXP ':(create|edit|status|export|adjust|delete|review)$') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库只读角色错误获得写权限'; + END IF; + IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission expected + LEFT JOIN baiyue_iam.iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0 + LEFT JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=expected.permission_code AND permission.status='ACTIVE' AND permission.deleted=0 + LEFT JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.tenant_id=0 + AND grant_row.role_id=role.id AND grant_row.permission_id=permission.id + WHERE grant_row.role_id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL baiyue_iam.assert_phase4_warehouse_role_minimum(); +DROP PROCEDURE baiyue_iam.assert_phase4_warehouse_role_minimum; +DROP TEMPORARY TABLE tmp_phase4_warehouse_role_permission; + INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, NOW(3) FROM baiyue_iam.iam_role role diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 6b02596d9..41f10d449 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -95,7 +95,8 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( 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 '审核时间', + 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), @@ -104,15 +105,32 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) ) ENGINE=InnoDB COMMENT='海外采购订单'; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_idempotency_key' +), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER review_remark')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_request_hash' +), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history ( id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', - change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='海外采购操作流水'; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='idempotency_key' +), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + CREATE TABLE IF NOT EXISTS ll_us_truck_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址', @@ -146,11 +164,32 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( 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', + operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_us_truck_history(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='美国卡车订单状态历史'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_us_truck_order_status_history' AND column_name='operation_type'), 'SELECT 1', + 'ALTER TABLE ll_us_truck_order_status_history ADD COLUMN operation_type VARCHAR(64) NOT NULL DEFAULT ''UPDATE'' COMMENT ''具名操作类型'' AFTER customer_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_us_truck_order_status_history' AND column_name='idempotency_key'), 'SELECT 1', + 'ALTER TABLE ll_us_truck_order_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', + idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希', + result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key), + KEY idx_ll_us_truck_operation_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='美国卡车订单幂等操作账'; + CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', @@ -182,11 +221,26 @@ 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', - change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='本土散板操作流水'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_local_looseboard_history' AND column_name='idempotency_key'), 'SELECT 1', + 'ALTER TABLE ll_local_looseboard_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希', + result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key), + KEY idx_ll_local_looseboard_operation_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='本土散板订单幂等操作账'; + -- 尾程主订单状态历史补齐客户隔离键。代码写入 customer_id,已有表需显式升级; -- 先允许 NULL 以便从主订单回填,再收紧为 NOT NULL,脚本可重复执行。 SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() @@ -204,6 +258,22 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE 'ALTER TABLE ll_loading_order_status_history ADD KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at)')); PREPARE phase4_loading_history_customer_stmt FROM @ddl; EXECUTE phase4_loading_history_customer_stmt; DEALLOCATE PREPARE phase4_loading_history_customer_stmt; +-- 尾程订单明细实体、事件消费者和详情查询均使用 customer_id;已有开发库需从主订单回填。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_order_detail' AND column_name='customer_id'), 'SELECT 1', + 'ALTER TABLE ll_order_detail ADD COLUMN customer_id VARCHAR(128) NULL COMMENT ''客户数据隔离键'' AFTER id')); +PREPARE phase4_order_detail_customer_stmt FROM @ddl; EXECUTE phase4_order_detail_customer_stmt; DEALLOCATE PREPARE phase4_order_detail_customer_stmt; +UPDATE ll_order_detail detail +JOIN ll_order orders ON orders.id=detail.order_id +SET detail.customer_id=orders.customer_id +WHERE detail.customer_id IS NULL; +ALTER TABLE ll_order_detail + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_order_detail' AND index_name='idx_ll_detail_customer_order'), 'SELECT 1', + 'ALTER TABLE ll_order_detail ADD KEY idx_ll_detail_customer_order(customer_id,order_id,id)')); +PREPARE phase4_order_detail_customer_stmt FROM @ddl; EXECUTE phase4_order_detail_customer_stmt; DEALLOCATE PREPARE phase4_order_detail_customer_stmt; + -- 四期平台排车查询、智能预排与草稿锁;不开放编辑、发车、PDA 出库或派送。 CREATE TABLE IF NOT EXISTS ll_dispatch_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', @@ -303,7 +373,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( 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 '乐观锁版本', + 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), @@ -315,6 +385,220 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; +-- 头程 V2 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), + 'ALTER TABLE ll_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', + 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); +PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id IS NULL OR l.customer_id=''; +ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND index_name='idx_ll_inbound_line_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD KEY idx_ll_inbound_line_customer_plan(customer_id,plan_id)')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='customer_id'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id IS NULL OR v.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND index_name='idx_ll_inbound_pallet_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD KEY idx_ll_inbound_pallet_customer_plan(customer_id,plan_id)')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='customer_id'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id IS NULL OR a.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +DROP PROCEDURE IF EXISTS assert_phase4_inbound_plan_customer_scope_ready; +DELIMITER $$ +CREATE PROCEDURE assert_phase4_inbound_plan_customer_scope_ready() +BEGIN + IF EXISTS (SELECT 1 FROM ll_inbound_plan_line l LEFT JOIN ll_inbound_plan p ON p.id=l.plan_id WHERE l.customer_id IS NULL OR l.customer_id='' OR p.id IS NULL OR l.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet v LEFT JOIN ll_inbound_plan p ON p.id=v.plan_id WHERE v.customer_id IS NULL OR v.customer_id='' OR p.id IS NULL OR v.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a LEFT JOIN ll_inbound_plan p ON p.id=a.plan_id WHERE a.customer_id IS NULL OR a.customer_id='' OR p.id IS NULL OR a.customer_id<>p.customer_id) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Inbound plan child customer scope migration is incomplete'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase4_inbound_plan_customer_scope_ready(); +DROP PROCEDURE assert_phase4_inbound_plan_customer_scope_ready; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), + 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码''', + 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码'' AFTER inbound_code')); +PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_type'), + 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', + 'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code')); +PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id +SET l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(l.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END) +WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; + +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''来源国内装柜仓ID快照'' AFTER customer_name')); +PREPARE phase4_source_warehouse_id_stmt FROM @ddl; EXECUTE phase4_source_warehouse_id_stmt; DEALLOCATE PREPARE phase4_source_warehouse_id_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_name'), + 'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''来源国内装柜仓名称快照'' AFTER source_warehouse_id')); +PREPARE phase4_source_warehouse_name_stmt FROM @ddl; EXECUTE phase4_source_warehouse_name_stmt; DEALLOCATE PREPARE phase4_source_warehouse_name_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='overseas_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_inbound_plan ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''海外实际收货共享物理仓ID,由PDA认领柜号时冻结'' AFTER container_no')); +PREPARE phase4_plan_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_plan_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_plan_receiving_warehouse_stmt; + +UPDATE ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id +SET o.source_warehouse_id=COALESCE(o.source_warehouse_id,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))), + o.source_warehouse_name=COALESCE(o.source_warehouse_name,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseName'))), + o.overseas_warehouse_id=NULL,o.overseas_warehouse_name=NULL +WHERE o.current_status<>'INBOUND_COMPLETED' + AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId')); + +DROP PROCEDURE IF EXISTS assert_pending_orders_do_not_use_source_warehouse; +DELIMITER $$ +CREATE PROCEDURE assert_pending_orders_do_not_use_source_warehouse() +BEGIN + IF EXISTS(SELECT 1 FROM ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id + WHERE o.current_status<>'INBOUND_COMPLETED' AND o.overseas_warehouse_id IS NOT NULL + AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='未完成尾程单仍将国内来源仓冒充海外实际收货仓'; + END IF; +END$$ +DELIMITER ; +CALL assert_pending_orders_do_not_use_source_warehouse(); +DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( + id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', + receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key), + KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; + +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''操作员Token绑定并冻结的海外实际收货共享物理仓ID'' AFTER container_no')); +PREPARE phase4_session_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_name'), + 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''仓库服务校验后的海外实际收货共享物理仓名称快照'' AFTER overseas_warehouse_id')); +PREPARE phase4_session_receiving_warehouse_name_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_name_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_name_stmt; + +DROP PROCEDURE IF EXISTS assert_active_receiving_session_has_warehouse; +DELIMITER $$ +CREATE PROCEDURE assert_active_receiving_session_has_warehouse() +BEGIN + IF EXISTS(SELECT 1 FROM ll_receiving_container_session WHERE active_container_key IS NOT NULL + AND (overseas_warehouse_id IS NULL OR overseas_warehouse_name IS NULL OR overseas_warehouse_name='')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='存在无法安全推断海外实际收货仓的未完成PDA柜会话'; + END IF; +END$$ +DELIMITER ; +CALL assert_active_receiving_session_has_warehouse(); +DROP PROCEDURE assert_active_receiving_session_has_warehouse; +ALTER TABLE ll_receiving_container_session + MODIFY COLUMN overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='sign_photos'), + 'ALTER TABLE ll_receiving_container_session MODIFY COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表''', + 'ALTER TABLE ll_receiving_container_session ADD COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表'' AFTER status')); +PREPARE phase4_receiving_sign_photos_stmt FROM @ddl; EXECUTE phase4_receiving_sign_photos_stmt; DEALLOCATE PREPARE phase4_receiving_sign_photos_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='photo_file_ids'), + 'ALTER TABLE ll_receiving_container_session DROP COLUMN photo_file_ids', 'SELECT 1')); +PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receiving_drop_raw_photos_stmt; DEALLOCATE PREPARE phase4_receiving_drop_raw_photos_stmt; + +DROP PROCEDURE IF EXISTS assert_receiving_global_locks_migratable; +UPDATE ll_receiving_container_session +SET active_operator_key=NULL,active_container_key=NULL +WHERE status='COMPLETED' AND (active_operator_key IS NOT NULL OR active_container_key IS NOT NULL); +DELIMITER $$ +CREATE PROCEDURE assert_receiving_global_locks_migratable() +BEGIN + IF EXISTS (SELECT operator_key FROM ll_receiving_container_session + WHERE active_operator_key IS NOT NULL GROUP BY operator_key HAVING COUNT(*) > 1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局账号锁迁移失败:同一账号存在多个未完成柜'; + END IF; + IF EXISTS (SELECT UPPER(TRIM(container_no)) FROM ll_receiving_container_session + WHERE active_container_key IS NOT NULL GROUP BY UPPER(TRIM(container_no)) HAVING COUNT(*) > 1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局柜号锁迁移失败:同一柜存在多个未完成会话'; + END IF; +END$$ +DELIMITER ; +CALL assert_receiving_global_locks_migratable(); +DROP PROCEDURE assert_receiving_global_locks_migratable; +UPDATE ll_receiving_container_session +SET active_operator_key=CONCAT('OPERATOR:',operator_key), + active_container_key=CONCAT('CONTAINER:',UPPER(TRIM(container_no))) +WHERE active_operator_key IS NOT NULL OR active_container_key IS NOT NULL; +ALTER TABLE ll_receiving_container_session + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', + MODIFY COLUMN active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + MODIFY COLUMN active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键'; + +CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt ( + id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name VARCHAR(255) NULL COMMENT '产品名称快照', + received_packages INT NOT NULL COMMENT 'SKU实收箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code), + KEY idx_ll_receiving_sku_session (customer_id,session_id,line_id), + KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id) +) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( + id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录'; +DROP PROCEDURE IF EXISTS assert_receiving_operation_global_key_migratable; +DELIMITER $$ +CREATE PROCEDURE assert_receiving_operation_global_key_migratable() +BEGIN + IF EXISTS (SELECT operator_key,idempotency_key FROM ll_receiving_container_operation + GROUP BY operator_key,idempotency_key HAVING COUNT(*) > 1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局幂等键迁移失败:同一操作账号存在重复幂等键'; + END IF; +END$$ +DELIMITER ; +CALL assert_receiving_operation_global_key_migratable(); +DROP PROCEDURE assert_receiving_operation_global_key_migratable; +SET @receiving_operation_global_index = ( + SELECT COUNT(*) FROM ( + SELECT index_name FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation' + AND index_name='uk_ll_receiving_operation' + GROUP BY index_name + HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index)= 'operator_key,idempotency_key' + ) ready_index +); +SET @receiving_operation_named_index = ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation' + AND index_name='uk_ll_receiving_operation' +); +SET @ddl = IF(@receiving_operation_global_index=1, 'SELECT 1', + IF(@receiving_operation_named_index>0, + 'ALTER TABLE ll_receiving_container_operation DROP INDEX uk_ll_receiving_operation, ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)', + 'ALTER TABLE ll_receiving_container_operation ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)')); +PREPARE phase4_receiving_operation_index_stmt FROM @ddl; EXECUTE phase4_receiving_operation_index_stmt; DEALLOCATE PREPARE phase4_receiving_operation_index_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), + 'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', + 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); +PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status')); @@ -388,4 +672,281 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)')); PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; -SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆主数据、海外业务与入仓单目的仓库存结构已就绪' AS message; +-- 海运节点达到可提柜后才允许海外 PDA 认领;装柜完成只建立等待门禁快照。 +CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( + container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', + container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', + status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', + standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', + node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号', + observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间', + actual_at DATETIME(3) NULL COMMENT '节点实际发生时间', + location_code VARCHAR(128) NULL COMMENT '节点地点编码', + location_name VARCHAR(255) NULL COMMENT '节点地点名称', + provider_code VARCHAR(64) NULL COMMENT '船节点供应商编码', + provider_event_id VARCHAR(128) NULL COMMENT '供应商事件ID', + provider_node_code VARCHAR(128) NULL COMMENT '供应商原始节点编码', + source_reference VARCHAR(255) NULL COMMENT '供应商来源引用', + raw_payload_hash VARCHAR(64) NULL COMMENT '供应商原始负载SHA-256', + aggregate_version BIGINT NOT NULL DEFAULT 0 COMMENT '来源查询聚合版本', + handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本', + last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID', + payload_hash VARCHAR(71) NULL COMMENT '最近采纳事件规范化负载摘要', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(container_booking_id), + KEY idx_ll_shipping_gate_candidate(customer_id,container_no,status), + KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence) +) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照'; + +INSERT IGNORE INTO ll_container_shipping_gate ( + container_booking_id,customer_id,ocean_booking_id,container_no,status, + node_sequence,aggregate_version,handover_version,created_at,updated_at +) +SELECT source_container_booking_id,customer_id,'',container_no,'AWAITING_PICKUP_READY', + 0,COALESCE(MAX(source_loading_plan_version),0),COALESCE(MAX(source_handover_version),1),NOW(3),NOW(3) +FROM ll_order +WHERE source_container_booking_id IS NOT NULL AND source_container_booking_id <> '' + AND container_no IS NOT NULL AND container_no <> '' +GROUP BY source_container_booking_id,customer_id,container_no; + +CREATE TABLE IF NOT EXISTS ll_pda_operation_history ( + id BIGINT NOT NULL COMMENT 'PDA作业历史ID', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + customer_id VARCHAR(128) NULL COMMENT '作业涉及的客户标识', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', + operation_type VARCHAR(64) NOT NULL COMMENT '作业类型', + business_key VARCHAR(255) NOT NULL COMMENT '作业业务键', + status VARCHAR(32) NOT NULL COMMENT '作业结果状态', + idempotency_key VARCHAR(255) NULL COMMENT '写操作幂等键', + request_hash VARCHAR(128) NULL COMMENT '写请求内容哈希', + summary VARCHAR(1000) NULL COMMENT '作业结果摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '作业发生时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_history_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_history_operator(overseas_warehouse_id,operator_key,occurred_at), + KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key) +) ENGINE=InnoDB COMMENT='海外PDA真实作业历史'; + +CREATE TABLE IF NOT EXISTS ll_pda_pallet_operation ( + id BIGINT NOT NULL COMMENT 'PDA托盘作业ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '设备幂等键', + operation_type VARCHAR(64) NOT NULL COMMENT '托盘作业类型', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容摘要', + status VARCHAR(32) NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED', + result_json LONGTEXT NULL COMMENT '成功响应快照', remote_transitions_json LONGTEXT NULL COMMENT '实物托盘状态变更回执', + last_error VARCHAR(2000) NULL COMMENT '最后失败原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_pallet_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_pallet_warehouse(overseas_warehouse_id,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA托盘作业幂等、补偿与结果记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_task ( + id BIGINT NOT NULL COMMENT '盘点任务ID', task_no VARCHAR(64) NOT NULL COMMENT '盘点任务号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', scope_type VARCHAR(16) NOT NULL COMMENT '盘点范围类型LOCATION或PALLET', + scope_resource_id BIGINT NOT NULL COMMENT '盘点库位或托盘资源ID', scope_code VARCHAR(128) NOT NULL COMMENT '盘点库位码或托盘码快照', + status VARCHAR(32) NOT NULL COMMENT '盘点任务状态', position_count INT NOT NULL COMMENT '库存位置总数', + counted_count INT NOT NULL DEFAULT 0 COMMENT '已盘库存位置数', difference_count INT NOT NULL DEFAULT 0 COMMENT '存在差异的库存位置数', + created_by VARCHAR(128) NOT NULL COMMENT '创建操作账号标识', submitted_by VARCHAR(128) NULL COMMENT '提交操作账号标识', + submit_reason VARCHAR(500) NULL COMMENT '盘点差异提交原因', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '创建请求哈希', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + submitted_at DATETIME(3) NULL COMMENT '提交完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_no(task_no), + UNIQUE KEY uk_ll_pda_stocktake_create(overseas_warehouse_id,created_by,idempotency_key), + KEY idx_ll_pda_stocktake_list(overseas_warehouse_id,status,created_at) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点任务'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_item ( + id BIGINT NOT NULL COMMENT '盘点明细ID', task_id BIGINT NOT NULL COMMENT '盘点任务ID', customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户标识', + balance_id BIGINT NOT NULL COMMENT '库存余额ID', position_id BIGINT NOT NULL COMMENT '库存位置ID', sku_code VARCHAR(128) NULL COMMENT 'SKU快照', + product_name VARCHAR(255) NULL COMMENT '品名快照', inbound_code VARCHAR(128) NULL COMMENT '入仓码快照', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', location_id BIGINT NOT NULL COMMENT '库位ID快照', location_code VARCHAR(128) NOT NULL COMMENT '库位码快照', + book_packages BIGINT NOT NULL COMMENT '任务创建时账面总箱数', actual_packages BIGINT NULL COMMENT '现场实盘总箱数', + difference_packages BIGINT NULL COMMENT '实盘与账面箱数差异', status VARCHAR(32) NOT NULL COMMENT '盘点明细状态', + position_version BIGINT NOT NULL COMMENT '任务创建时库存位置版本', counted_by VARCHAR(128) NULL COMMENT '盘点操作账号标识', + version BIGINT NOT NULL DEFAULT 0 COMMENT '明细乐观锁版本', counted_at DATETIME(3) NULL COMMENT '盘点时间', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_item(task_id,position_id), KEY idx_ll_pda_stocktake_item_task(task_id,status,id) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点位置明细'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_action ( + id BIGINT NOT NULL COMMENT '盘点动作ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + action_type VARCHAR(32) NOT NULL COMMENT '盘点动作类型', task_id BIGINT NULL COMMENT '盘点任务ID', request_hash VARCHAR(128) NOT NULL COMMENT '动作请求哈希', + status VARCHAR(32) NOT NULL COMMENT '动作处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_stocktake_action(overseas_warehouse_id,operator_key,idempotency_key), KEY idx_ll_pda_stocktake_action_task(task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA盘点动作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_inventory_move ( + id BIGINT NOT NULL COMMENT '移库操作ID', move_no VARCHAR(64) NOT NULL COMMENT '移库操作单号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', pallet_id BIGINT NOT NULL COMMENT '整托移库的实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', source_location_id BIGINT NOT NULL COMMENT '来源库位ID', + source_location_code VARCHAR(128) NOT NULL COMMENT '来源库位码快照', target_location_id BIGINT NOT NULL COMMENT '目标库位ID', + target_location_code VARCHAR(128) NOT NULL COMMENT '目标库位码快照', customer_count INT NOT NULL COMMENT '托盘涉及客户数', + position_count INT NOT NULL COMMENT '迁移库存位置数', package_count BIGINT NOT NULL COMMENT '迁移总箱数', + pallet_version_before INT NOT NULL COMMENT '迁移前托盘版本', pallet_version_after INT NULL COMMENT '迁移后托盘版本', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', reason VARCHAR(500) NOT NULL COMMENT '移库原因', + idempotency_key VARCHAR(128) NOT NULL COMMENT '移库幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '移库请求哈希', + status VARCHAR(32) NOT NULL COMMENT '移库处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_inventory_move_no(move_no), + UNIQUE KEY uk_ll_pda_inventory_move_key(overseas_warehouse_id,operator_key,idempotency_key), + KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_task ( + id BIGINT NOT NULL COMMENT '换标PDA任务ID', application_id BIGINT NOT NULL COMMENT '换标申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号快照', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID', + status VARCHAR(32) NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED', + operator_key VARCHAR(128) NOT NULL COMMENT '认领操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '认领操作员名称快照', + reviewer_key VARCHAR(128) NULL COMMENT '复核账号标识', reviewer_name VARCHAR(255) NULL COMMENT '复核员名称快照', + expected_packages INT NOT NULL COMMENT '申请单通知的应换标箱数', relabelled_packages INT NOT NULL DEFAULT 0 COMMENT '已正常换标箱数', + skipped_packages INT NOT NULL DEFAULT 0 COMMENT '已说明原因跳过箱数', exception_packages INT NOT NULL DEFAULT 0 COMMENT '已登记异常箱数', + completion_photo_file_ids JSON NULL COMMENT '换标完成现场照片文件ID列表', completion_remark VARCHAR(1000) NULL COMMENT '完成提交备注', + review_decision VARCHAR(16) NULL COMMENT '最近复核结论:APPROVE或REWORK', review_remark VARCHAR(1000) NULL COMMENT '最近复核备注', + claimed_at DATETIME(3) NOT NULL COMMENT '任务认领时间', submitted_at DATETIME(3) NULL COMMENT '提交复核时间', + reviewed_at DATETIME(3) NULL COMMENT '最近复核时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_relabel_pda_task_application (customer_id,application_id), + UNIQUE KEY uk_ll_relabel_pda_task_no (relabel_no), KEY idx_ll_relabel_pda_task_warehouse (overseas_warehouse_id,status,updated_at), + KEY idx_ll_relabel_pda_task_operator (operator_key,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA换标现场作业任务'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact ( + id BIGINT NOT NULL COMMENT '逐件换标事实ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', item_id BIGINT NOT NULL COMMENT '换标申请明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', old_label VARCHAR(255) NULL COMMENT '扫描的实物旧标签或箱标', + new_label VARCHAR(255) NULL COMMENT '扫描的换标后新标签', result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION', + exception_code VARCHAR(32) NULL COMMENT '异常编码', reason VARCHAR(1000) NULL COMMENT '跳过或异常原因', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', + occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_old_label (old_label), KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id), + KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id) +) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation ( + id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', operator_key VARCHAR(128) NOT NULL COMMENT '请求操作账号标识', + action VARCHAR(32) NOT NULL COMMENT '幂等操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '设备生成的幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', result_json LONGTEXT NOT NULL COMMENT '成功响应快照JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_operation_key (operator_key,idempotency_key), + KEY idx_ll_relabel_pda_operation_task (customer_id,task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标写操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_history ( + id BIGINT NOT NULL COMMENT '换标PDA任务历史ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + action VARCHAR(32) NOT NULL COMMENT '现场动作类型', from_status VARCHAR(32) NULL COMMENT '动作前任务状态', + to_status VARCHAR(32) NOT NULL COMMENT '动作后任务状态', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', detail_json LONGTEXT NULL COMMENT '动作详情JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_relabel_pda_history_task (customer_id,task_id,created_at,id), + KEY idx_ll_relabel_pda_history_application (customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标任务不可变操作历史'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_cargo_discard_application' AND column_name='review_idempotency_key'), + 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash')); +PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'), + 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key')); +PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; + +-- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。 +-- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。 +USE baiyue_lastlag_warehouse; +CREATE TABLE IF NOT EXISTS wh_pallet_operation ( + id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键', + request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', + before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态', + before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID', + before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本', + operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿', + reason VARCHAR(500) NULL COMMENT '操作原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_operation_key (operation_key), + KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='fba_warehouse_code'), + 'SELECT 1', + 'ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码'' AFTER remark')); +PREPARE phase4_warehouse_pallet_stmt FROM @ddl; +EXECUTE phase4_warehouse_pallet_stmt; +DEALLOCATE PREPARE phase4_warehouse_pallet_stmt; + +SET @warehouse_pallet_fba_ready = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet' + AND column_name='fba_warehouse_code' AND column_type='varchar(128)'); +DROP PROCEDURE IF EXISTS assert_phase4_warehouse_pallet_ready; +DELIMITER $$ +CREATE PROCEDURE assert_phase4_warehouse_pallet_ready() +BEGIN + IF @warehouse_pallet_fba_ready <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='wh_pallet.fba_warehouse_code migration is incomplete'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase4_warehouse_pallet_ready(); +DROP PROCEDURE assert_phase4_warehouse_pallet_ready; + +USE baiyue_lastleg; +DROP PROCEDURE IF EXISTS assert_phase4_pda_operations_ready; +DELIMITER $$ +CREATE PROCEDURE assert_phase4_pda_operations_ready() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_lastleg' AND table_name IN ( + 'll_pda_pallet_operation','ll_pda_stocktake_task','ll_pda_stocktake_item', + 'll_pda_stocktake_action','ll_pda_inventory_move','ll_relabel_pda_task', + 'll_relabel_pda_unit_fact','ll_relabel_pda_operation','ll_relabel_pda_history')) <> 9 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA盘点、移库、托盘或换标表未完整部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet_operation') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA实物托盘操作表未部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase4_pda_operations_ready(); +DROP PROCEDURE assert_phase4_pda_operations_ready; + +CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_overseas_security; +CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( + event_id VARCHAR(64) NOT NULL, event_type VARCHAR(96) NOT NULL, payload_hash CHAR(64) NOT NULL, + payload_json JSON NOT NULL, status VARCHAR(16) NOT NULL, result VARCHAR(32) NULL, + received_at DATETIME(3) NOT NULL, processed_at DATETIME(3) NULL, + PRIMARY KEY (event_id), KEY idx_security_inbox_type_time (event_type,received_at) +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox'; +CREATE TABLE IF NOT EXISTS overseas_security_projection ( + aggregate_type VARCHAR(32) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, tenant_id BIGINT NOT NULL, + security_status VARCHAR(24) NULL, permission_version BIGINT NULL, aggregate_version BIGINT NOT NULL, + payload_hash CHAR(64) NOT NULL, last_event_id VARCHAR(64) NOT NULL, + changed_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL, + 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安全状态与权限版本投影'; + +DROP PROCEDURE IF EXISTS assert_overseas_security_projection_ready; +DELIMITER $$ +CREATE PROCEDURE assert_overseas_security_projection_ready() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_overseas_security' + AND table_name IN ('overseas_security_event_inbox','overseas_security_projection')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外IAM安全Inbox或投影表未完整部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_overseas_security_projection_ready(); +DROP PROCEDURE assert_overseas_security_projection_ready; +SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message; diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md new file mode 100644 index 000000000..0562b97bb --- /dev/null +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -0,0 +1,150 @@ +# 同步调用依赖与失败传播治理基线 + +## 目的与口径 + +本基线覆盖后端服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。它用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 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 | 客户尺寸规则,`REQUEST` | +| 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` | +| 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 | Overseas Security | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;安全投影实时校验,异常失败关闭,不进入在线业务图 | +| Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、5 秒读取超时,配置可覆盖,不自动重试 | +| Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、30 秒读取超时,配置可覆盖;校验响应 code 且失败关闭,不自动重试 | +| Event Bridge | Firstleg / Lastleg / Overseas Security | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Security 投影不进入在线业务图 | +| 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 | +| Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 | + +#### Lastleg 四个有界 RestClient + +四个客户端统一从 `LastlegRestClientFactory` 构建 JDK HTTP 传输,由 `LastlegRestClientProperties` 绑定独立超时。读取超时是单次请求的硬边界;客户端没有 Retry、fallback 或默认成功配置。 + +| 客户端 | 构建与调用路径 | 写操作幂等性 | 默认超时 | +|---|---|---|---| +| `HttpUnloadPhotoFileGateway` | `ReceivingContainerController → ReceivingContainerService → UnloadPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 每次写文件和元数据,没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 | +| `HttpRelabelPhotoFileGateway` | `RelabelPdaController/Service → RelabelPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 | +| `HttpWarehouseResourceGateway` | `InboundPdaWorkflowService`、PDA 仓内查询/盘点及 Dropship → Warehouse Resource/Pallet 内部接口 | 查询可重复;`reserve` 使用 `reservationKey`,`ensureDropship` 使用业务绑定,release/scrap 由服务端状态收敛;尽管服务端具备业务幂等,客户端仍不自动重试 | 连接 3 秒、读取 5 秒 | +| `HttpPalletPhysicalGateway` | `PdaPalletOperationService`、`PdaInventoryMoveService` → `/internal/v1/pda-pallet-transitions/**` | 查询可重复;apply/compensate 使用稳定 `operationKey` 与请求哈希,服务端幂等;客户端仍不自动重试 | 连接 3 秒、读取 5 秒 | + +## 分上下文依赖图 + +### 在线 REQUEST 图 + +在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和海外 Security 校验均不计入在线深度。 + +当前在线强连通分量为: + +`Auth ↔ IAM ↔ Firstleg ↔ Order ↔ File` + +合同冻结 22 条在线服务边;新增在线边或新增强连通分量直接失败。Finance、Lastleg 只指向该分量或叶子服务,不属于在线环;Gateway Overseas→Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。 + +### 后台 BACKGROUND 图 + +后台图包含 Outbox/Inbox、导出 Worker 和定时任务,当前强连通分量为: + +`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg` + +File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Overseas Security 是单向后台叶子边,不形成在线业务环。 + +### 全图 + +全上下文合并后,非平凡强连通分量仍为 8 个服务: + +`Auth ↔ IAM ↔ Order ↔ Firstleg ↔ Finance ↔ File ↔ Event Bridge ↔ Lastleg` + +全图只用于部署影响和故障域观察,不能再用于推导在线调用“无限深度”。 + +### 用户入口在线最长简单路径 + +默认在线预算为最多 2 个下游服务跳数。合同对每个已知用户入口枚举全部最长简单路径;下表列出的具体路径均超过预算: + +| 用户入口 | 最长跳数 | 全部长路径 | +|---|---:|---| +| 认证 API | 5 | `Auth → IAM → Firstleg → Order → File → Integration` | +| IAM API | 4 | `IAM → Firstleg → Order → File → Integration` | +| 订单 API | 4 | `Order → File → IAM → Firstleg → Print`;`Order → File → IAM → Firstleg → Warehouse`;`Order → Firstleg → File → IAM → Auth`;`Order → Firstleg → File → IAM → Warehouse`;`Order → IAM → Firstleg → File → Integration` | +| 头程 API/PDA | 4 | `Firstleg → Order → File → IAM → Auth`;`Firstleg → Order → File → IAM → Warehouse` | +| 财务 API | 5 | `Finance → File → IAM → Firstleg → Order → Warehouse`;`Finance → Firstleg → Order → File → IAM → Auth`;`Finance → Firstleg → Order → File → IAM → Warehouse`;`Finance → IAM → Firstleg → Order → File → Integration` | +| 文件 API | 4 | `File → IAM → Firstleg → Order → Warehouse` | +| 海外尾程 API/PDA | 5 | `Lastleg → File → IAM → Firstleg → Order → Warehouse` | +| 国内仓储 API | 0 | 无下游同步调用 | +| 海外仓储 API | 0 | 无下游同步调用 | + +这些是静态服务边可形成的最长简单路径,不表示每个具体 Controller 都会走完整链;后续入口级调用点治理必须继续以实际 Service 调用证据缩小路径,不能用静态可能性宣称线上已经发生。 + +### 第一个建议退出的 REQUEST 边 + +建议优先将 `IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化从 REQUEST WRITE 改为可靠后台编排。该命令已经携带稳定 `initializationKey` 并提供补偿接口,适合使用持久化 Outbox、显式 `PENDING/FAILED` 结果和人工可诊断重试;不得在失败时返回默认成功。退出此边可把当前五服务在线强连通分量拆为更小的 Auth↔IAM 与 Order↔Firstleg 子环,同时不触碰财务、Event Bridge 传输或四期业务状态机。实施前仍需 IAM/Firstleg 业务负责人确认“客户创建成功”与“默认尺寸已就绪”的产品时点。 + +## 退出顺序 + +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 9c15e66f1..2ed49423a 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -10,7 +10,7 @@ ## 1. 项目定位 -`baiyue-firstleg` 是国内头程业务后端仓库,采用 Maven 多模块单仓结构。仓库内包含 7 个公共模块和 11 个可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 23 个 Maven 模块,其中 7 个是公共/依赖模块,16 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -24,7 +24,7 @@ - SpringDoc OpenAPI / Swagger - GitLab、Jenkins、Docker Compose -本仓库只保存头程代码。尾程业务位于独立 Git 仓库,不允许把尾程源码、数据库或配置重新放回本仓库。 +国内与海外服务共享源码仓库和公共制品,但保持独立数据库、Nacos 命名空间与部署边界;跨区域业务协作使用版本化 Outbox/Inbox 事件,不共享业务数据源。 ## 2. 根目录结构 @@ -39,11 +39,16 @@ baiyue-firstleg/ ├─ common-event-contract/ # 国内外跨区域事件统一契约 │ ├─ gateway/ # 统一 API 网关 +├─ gateway-overseas/ # 海外区域 API 网关 ├─ auth/ # 统一登录与账号凭据 ├─ iam/ # 租户、成员、角色、权限、企业审核 ├─ order/ # 头程订单中心 ├─ firstleg/ # 履约任务、二期入库、测量与计费 ├─ warehouse/ # 仓库主数据、托盘与库位资源 +├─ finance/ # 财务账期、账单、收付款与核销 +├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务 +├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁 +├─ overseas-security-service/ # 海外IAM安全事件Inbox、投影与实时校验 ├─ file/ # 资质、导出与测量图片文件中心 ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 @@ -97,6 +102,7 @@ baiyue-firstleg/ | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | | `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 | | `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 | +| `overseas-security-service` | `overseas-security-service` | 9307 | `baiyue_overseas_security` | IAM安全事件可靠Inbox、单调投影及海外Token即时失效校验 | | `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | @@ -170,7 +176,7 @@ config/nacos/ └─ cn/ # 国内服务器配置模板 ``` -每套目录包含公共配置 `baiyue-common.yml` 和 11 个服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 +`local/`、`cn/`、`overseas/` 按部署区域保存公共配置和该区域需要的服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 ### `deploy/` @@ -179,7 +185,8 @@ deploy/ ├─ docker/Dockerfile # 通用 Java 17 镜像 ├─ env/.env.test.example # 环境变量示例 ├─ scripts/deploy-services.sh # Jenkins 增量部署脚本 -├─ docker-compose.yml # 11 个业务服务编排 +├─ docker-compose.yml # 国内区域服务编排 +├─ docker-compose.overseas.yml # 海外区域服务编排 ├─ docker-compose.rocketmq.yml # 公共导出 RocketMQ 基础设施 ├─ sql/ # 测试/存量环境统一迁移入口与说明 └─ service-map.yml # 模块、JAR、端口映射 @@ -276,6 +283,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会自动重建全部 11 个服务。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 16 个服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 -- 头程与尾程保持两个 Git 仓库、两套数据库和两套运行环境。 +- 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index aa40eee17..14ed05d26 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -12,11 +12,12 @@ |---|---| | 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | | Inbox 接收与幂等 | `service/EventInboxService.java` | -| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=OVERSEAS` 投 Lastleg,`targetRegion=CN` 回投 Firstleg | +| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg,三类 IAM 安全事件按 eventType 投 `overseas-security-service`,传输层不解析 payload | | 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | | 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | -| 人工补投 | `service/EventReplayService.java` | +| 人工补投 | `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` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库 | `src/main/resources/db/event-bridge.sql` | | 测试 | `src/test/java/com/baiyue/eventbridge/` | @@ -27,6 +28,7 @@ - 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。 - 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。 - 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。 +- 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。 ## 修改与验证 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/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java index 575bd2867..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 @@ -51,7 +51,8 @@ public class EventBridgeController { return ApiResponse.success(eventInboxService.receive(envelope)); } - @Operation(summary = "人工补投死信事件") + @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, 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 568192832..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,6 +2,7 @@ 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; @@ -60,4 +61,21 @@ public interface BridgeInboxMapper extends BaseMapper { 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..50a3bbe66 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java @@ -0,0 +1,121 @@ +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.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Set; + +/** Low-cardinality transport metrics for the cross-region event bridge. */ +@Component +@Slf4j +public class EventBridgeMetrics { + public static final String RECEIVE_METRIC = "baiyue.event.bridge.receive"; + public static final String TRANSPORT_METRIC = "baiyue.event.bridge.transport"; + public static final String DELIVERY_LATENCY_METRIC = "baiyue.event.bridge.delivery.latency"; + + private static final Set KNOWN_EVENT_TYPES = Set.of( + EventTypes.ORDER_APPROVED, + EventTypes.ORDER_CANCELLED, + EventTypes.FIRST_LEG_SHIPMENT_DEPARTED, + EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, + EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, + EventTypes.OVERSEAS_CONTAINER_UNLOADED, + EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, + EventTypes.IAM_PERMISSION_VERSION_CHANGED, + EventTypes.LAST_MILE_HANDOFF_ACCEPTED, + EventTypes.LAST_MILE_HANDOFF_REJECTED, + EventTypes.LAST_MILE_MILESTONE_CHANGED, + EventTypes.LAST_MILE_COMPLETED, + EventTypes.TRACKING_MILESTONE_PROJECTED); + + private final MeterRegistry registry; + private final Clock clock; + + 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 25b7fc7c3..f4adf8f3d 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,6 +5,7 @@ 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; @@ -23,6 +24,7 @@ import java.util.Objects; public class EventInboxService { @Resource private InboxStore inboxStore; @Resource private ObjectMapper objectMapper; + @Resource private EventBridgeMetrics metrics; @Value("${baiyue.region:CN}") private String localRegion; @@ -44,6 +46,7 @@ public class EventInboxService { BridgeInbox inbox = toInbox(envelope); try { inboxStore.insert(inbox); + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.RECEIVED); return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt()); } catch (DuplicateKeyException exception) { BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId()); @@ -56,8 +59,10 @@ public class EventInboxService { 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()); } 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 index bc8b76dea..632672e95 100644 --- 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 @@ -1,13 +1,23 @@ 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("人工补投必须提供操作人"); @@ -19,6 +29,22 @@ public class EventReplayService { 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) { 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 index 0aacdc3f3..fe26409f2 100644 --- 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 @@ -42,11 +42,31 @@ public class HttpFirstlegEventClient implements FirstlegEventClient { .header("X-Internal-Service-Token", serviceToken) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - JsonNode responseBody = objectMapper.readTree(response.body()); + 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("头程消费失败: HTTP " + response.statusCode()); + 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 index 3f2a904ab..e3b3dd5b8 100644 --- 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 @@ -45,11 +45,31 @@ public class HttpLastlegEventClient implements LastlegEventClient { .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - JsonNode responseBody = objectMapper.readTree(response.body()); + 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("尾程消费失败: HTTP " + response.statusCode()); + 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..e18cf8714 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java @@ -0,0 +1,56 @@ +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.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +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; + @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 request = HttpRequest.newBuilder(consumerUrl).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 exception) { body = null; } + if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText()) + || !row.getEventId().equals(body.path("data").path("eventId").asText())) { + throw new IllegalStateException("海外安全投影消费失败: HTTP " + response.statusCode()); + } + return String.valueOf(response.statusCode()); + } +} 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 index b795a7019..54cd77867 100644 --- 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 @@ -1,10 +1,12 @@ 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 jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -12,6 +14,7 @@ import org.springframework.stereotype.Component; import java.lang.management.ManagementFactory; import java.time.LocalDateTime; +import java.util.Set; import java.util.UUID; @Component @@ -21,7 +24,9 @@ public class OverseasEventDispatcher { @Resource private BridgeDeliveryAttemptMapper attemptMapper; @Resource private LastlegEventClient eventClient; @Resource private FirstlegEventClient firstlegEventClient; + @Resource private SecurityProjectionEventClient securityProjectionEventClient; @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeMetrics metrics; private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); @Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3000}") @@ -40,26 +45,52 @@ public class OverseasEventDispatcher { int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1; int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); + String responseCode; try { - String responseCode = deliver(event); - if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { - throw new IllegalStateException("事件租约已失效,无法确认投递结果"); - } - finishAttempt(attempt, "DELIVERED", responseCode, null); + responseCode = deliver(event); } catch (Exception exception) { String error = abbreviate(exception.getMessage(), 1000); - inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error); - finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error); + 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={}, reason={}", eventId, retryAttempt, deliveryAttemptNo, 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={}", eventId, deliveryAttemptNo); + return; + } + finishAttempt(attempt, "DELIVERED", responseCode, null); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.DELIVERED); } private String deliver(BridgeInbox event) throws Exception { - return "CN".equals(event.getTargetRegion()) - ? firstlegEventClient.deliver(event) : eventClient.deliver(event); + String eventType = event.getEventType(); + if (eventType == null || eventType.isBlank()) { + throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者"); + } + return switch (event.getTargetRegion()) { + case "CN" -> firstlegEventClient.deliver(event); + case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType) + ? securityProjectionEventClient.deliver(event) : eventClient.deliver(event); + default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion()); + }; } + private static final Set SECURITY_EVENT_TYPES = Set.of( + EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, + EventTypes.IAM_PERMISSION_VERSION_CHANGED); + private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); attempt.setEventId(event.getEventId()); 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/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index e80450011..fc5524546 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -33,6 +33,7 @@ baiyue: event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events} + security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9307/internal/overseas-security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} 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..39a229241 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java @@ -0,0 +1,58 @@ +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 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 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 9ae12a6dc..c8ee93232 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,7 +5,9 @@ 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; @@ -24,6 +26,8 @@ import static org.mockito.Mockito.when; class EventInboxServiceTest { private final ObjectMapper objectMapper = new ObjectMapper(); private final InboxStore store = mock(InboxStore.class); + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry); private final EventInboxService service = service("OVERSEAS"); @Test @@ -33,6 +37,7 @@ class EventInboxServiceTest { assertThat(service.receive(envelope).result()).isEqualTo("已接收"); verify(store).insert(any(BridgeInbox.class)); + assertThat(receiveCount("RECEIVED")).isEqualTo(1.0); } @Test @@ -57,6 +62,7 @@ class EventInboxServiceTest { when(store.findByEventId(envelope.eventId())).thenReturn(existing); assertThat(service.receive(envelope).result()).isEqualTo("重复"); + assertThat(receiveCount("DUPLICATE")).isEqualTo(1.0); } @Test @@ -67,6 +73,7 @@ class EventInboxServiceTest { assertThatThrownBy(() -> service.receive(envelope)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("拒绝覆盖"); + assertThat(receiveCount("CONFLICT")).isEqualTo(1.0); } @Test @@ -113,10 +120,17 @@ class EventInboxServiceTest { EventInboxService service = new EventInboxService(); ReflectionTestUtils.setField(service, "inboxStore", store); ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + ReflectionTestUtils.setField(service, "metrics", metrics); ReflectionTestUtils.setField(service, "localRegion", region); return service; } + private double receiveCount(String result) { + return registry.get(EventBridgeMetrics.RECEIVE_METRIC) + .tags("target_region", "OVERSEAS", "event_type", "OrderApproved", "result", result) + .counter().count(); + } + private EventEnvelope withHash(EventEnvelope source) { String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload())); return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), 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 index 66b740ddf..49c00c1c8 100644 --- 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 @@ -1,23 +1,46 @@ 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 @@ -28,9 +51,21 @@ class EventReplayServiceTest { .isInstanceOf(IllegalStateException.class); } + @Test + void replayOnlyRequeuesDeadStateAndDoesNotRewriteFrozenEvent() throws Exception { + String sql = String.join(" ", BridgeInboxMapper.class + .getMethod("replayDead", String.class, String.class) + .getAnnotation(Update.class).value()).replaceAll("\\s+", " "); + + assertThat(sql).contains("status='RETRY'", "retry_count=0", "status='DEAD'") + .doesNotContain("payload_json=", "payload_hash="); + } + private EventReplayService service() { EventReplayService service = new EventReplayService(); ReflectionTestUtils.setField(service, "inboxMapper", mapper); + ReflectionTestUtils.setField(service, "attemptMapper", attemptMapper); + ReflectionTestUtils.setField(service, "metrics", metrics); return service; } } diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java new file mode 100644 index 000000000..69ed8424a --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java @@ -0,0 +1,26 @@ +package com.baiyue.eventbridge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpFirstlegEventClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void businessFailureIsDiagnosableAndMustNotBeTreatedAsAcknowledged() throws Exception { + var body = objectMapper.readTree(""" + {"code":"A0409","message":"事件摘要冲突","requestId":"request-cn-1","traceId":"trace-cn-1"} + """); + + assertThat(HttpFirstlegEventClient.failureMessage(200, body)) + .isEqualTo("头程消费失败: HTTP 200, code=A0409, message=事件摘要冲突, requestId=request-cn-1, traceId=trace-cn-1"); + } + + @Test + void nonJsonFailureIsStillDiagnosableWithoutRawBody() { + assertThat(HttpFirstlegEventClient.failureMessage(503, null)) + .isEqualTo("头程消费失败: HTTP 503, code=missing, message=non-json response, requestId=missing, traceId=missing"); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java new file mode 100644 index 000000000..b2de28029 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java @@ -0,0 +1,26 @@ +package com.baiyue.eventbridge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpLastlegEventClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void failureIncludesBusinessCodeMessageAndTraceEvidence() throws Exception { + var body = objectMapper.readTree(""" + {"code":"A0400","message":"入仓计划数据无效","requestId":"request-1","traceId":"trace-1"} + """); + + assertThat(HttpLastlegEventClient.failureMessage(200, body)) + .isEqualTo("尾程消费失败: HTTP 200, code=A0400, message=入仓计划数据无效, requestId=request-1, traceId=trace-1"); + } + + @Test + void nonJsonFailureIsStillDiagnosableWithoutLeakingRawBody() { + assertThat(HttpLastlegEventClient.failureMessage(502, null)) + .isEqualTo("尾程消费失败: HTTP 502, code=missing, message=non-json response, requestId=missing, traceId=missing"); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java index 67cb7b3d1..aa9f11d14 100644 --- 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 @@ -1,10 +1,13 @@ 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 io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -12,6 +15,8 @@ import org.springframework.test.util.ReflectionTestUtils; 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.*; @@ -20,10 +25,14 @@ import static org.mockito.Mockito.*; class OverseasEventDispatcherTest { private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); - private final LastlegEventClient eventClient = mock(LastlegEventClient.class); - private final FirstlegEventClient firstlegEventClient = mock(FirstlegEventClient.class); + private final com.baiyue.eventbridge.service.LastlegEventClient eventClient = + mock(com.baiyue.eventbridge.service.LastlegEventClient.class); + private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient = + mock(com.baiyue.eventbridge.service.FirstlegEventClient.class); + private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class); private OverseasEventDispatcher dispatcher; private BridgeInbox event; + private SimpleMeterRegistry registry; @BeforeEach void setUp() { @@ -32,16 +41,21 @@ class OverseasEventDispatcherTest { Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1), Duration.ofSeconds(3), Duration.ofSeconds(10)); dispatcher = new OverseasEventDispatcher(); + registry = new SimpleMeterRegistry(); ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient); + ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient); ReflectionTestUtils.setField(dispatcher, "properties", properties); + ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry)); event = new BridgeInbox(); event.setEventId("event-1"); + event.setEventType(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED); event.setSourceRegion("CN"); event.setTargetRegion("OVERSEAS"); event.setRetryCount(0); + event.setReceivedAt(LocalDateTime.now().minusSeconds(5)); when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); when(inboxMapper.selectById("event-1")).thenReturn(event); when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); @@ -55,27 +69,50 @@ class OverseasEventDispatcherTest { 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); } @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), anyLong(), eq("network down")); + 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 @@ -91,6 +128,7 @@ class OverseasEventDispatcherTest { @Test void cnTargetIsDeliveredToFirstleg() throws Exception { + event.setEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED); event.setSourceRegion("OVERSEAS"); event.setTargetRegion("CN"); when(firstlegEventClient.deliver(event)).thenReturn("200"); @@ -100,12 +138,150 @@ class OverseasEventDispatcherTest { verify(firstlegEventClient).deliver(event); verifyNoInteractions(eventClient); + verifyNoInteractions(securityProjectionEventClient); assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); } + @Test + void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception { + for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED)) { + reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient, securityProjectionEventClient); + event.setEventType(eventType); + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); + when(inboxMapper.selectById("event-1")).thenReturn(event); + when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + when(securityProjectionEventClient.deliver(event)).thenReturn("200"); + + dispatcher.dispatchOne("event-1"); + + verify(securityProjectionEventClient).deliver(event); + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + } + } + + @Test + void unsupportedTargetRegionIsNeverMisroutedToLastleg() { + event.setTargetRegion("UNKNOWN"); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), contains("不支持"))) + .thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + verifyNoInteractions(securityProjectionEventClient); + assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); + } + + @Test + void missingEventTypeFailsWithExplicitDiagnosticAndIsNeverDelivered() { + event.setEventType(null); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), + eq("事件类型不能为空,无法选择下游消费者"))).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + verifyNoInteractions(securityProjectionEventClient); + BridgeDeliveryAttempt attempt = updatedAttempt(); + assertThat(attempt.getStatus()).isEqualTo("RETRY"); + assertThat(attempt.getErrorMessage()).isEqualTo("事件类型不能为空,无法选择下游消费者"); + } + + @Test + void acknowledgedDeliveryWithLostLeaseIsNotRecordedAsRetryOrDead() throws Exception { + when(eventClient.deliver(event)).thenReturn("200"); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(0); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper, never()).markFailed(anyString(), anyString(), anyInt(), anyLong(), anyString()); + BridgeDeliveryAttempt attempt = updatedAttempt(); + assertThat(attempt.getStatus()).isEqualTo("ACKNOWLEDGED_LEASE_LOST"); + assertThat(attempt.getResponseCode()).isEqualTo("200"); + assertThat(attempt.getErrorMessage()).contains("租约已失效"); + assertThat(transportCount("ACKNOWLEDGED_LEASE_LOST")).isEqualTo(1.0); + } + + @Test + void failedDeliveryAfterLeaseTakeoverDoesNotOverwriteNewOwnerState() throws Exception { + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"))) + .thenReturn(0); + + dispatcher.dispatchOne("event-1"); + + assertThat(updatedAttempt().getStatus()).isEqualTo("FAILED_LEASE_LOST"); + assertThat(transportCount("FAILED_LEASE_LOST")).isEqualTo(1.0); + } + + @Test + void downstream503RetriesThenDeliversWithDiagnosticAttemptsInBothDirections() throws Exception { + for (String target : List.of("OVERSEAS", "CN")) { + reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient); + event.setSourceRegion("CN".equals(target) ? "OVERSEAS" : "CN"); + event.setTargetRegion(target); + event.setRetryCount(0); + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); + when(inboxMapper.selectById("event-1")).thenReturn(event); + when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1, 2); + doAnswer(invocation -> { + event.setRetryCount(1); + event.setStatus("RETRY"); + event.setLastError(invocation.getArgument(4)); + event.setNextRetryAt(java.time.LocalDateTime.now().plusSeconds( + ((Number) invocation.getArgument(3)).longValue())); + return 1; + }).when(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), anyString()); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenAnswer(invocation -> { + event.setStatus("PROCESSED"); + event.setLastError(null); + event.setNextRetryAt(null); + return 1; + }); + if ("CN".equals(target)) { + when(firstlegEventClient.deliver(event)) + .thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-cn traceId=trace-cn")) + .thenReturn("200"); + } else { + when(eventClient.deliver(event)) + .thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-overseas traceId=trace-overseas")) + .thenReturn("200"); + } + + dispatcher.dispatchOne("event-1"); + assertThat(event.getStatus()).isEqualTo("RETRY"); + assertThat(event.getNextRetryAt()).isNotNull(); + assertThat(event.getLastError()).contains("HTTP 503", "requestId=", "traceId="); + dispatcher.dispatchOne("event-1"); + + ArgumentCaptor attempts = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); + verify(attemptMapper, times(2)).updateById(attempts.capture()); + assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getAttemptNo) + .containsExactly(1, 2); + assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getStatus) + .containsExactly("RETRY", "DELIVERED"); + assertThat(attempts.getAllValues().get(0).getErrorMessage()) + .contains("HTTP 503", "requestId=", "traceId="); + assertThat(event.getStatus()).isEqualTo("PROCESSED"); + assertThat(event.getLastError()).isNull(); + } + } + private BridgeDeliveryAttempt updatedAttempt() { ArgumentCaptor captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); verify(attemptMapper).updateById(captor.capture()); return captor.getValue(); } + + private double transportCount(String result) { + return registry.get(EventBridgeMetrics.TRANSPORT_METRIC) + .tags("target_region", event.getTargetRegion(), + "event_type", event.getEventType(), "result", result) + .counter().count(); + } } diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index d320f4aaa..86268a298 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -23,7 +23,8 @@ public class InternalBusinessFileService { "LOADING_CUSTOMS_DOCUMENT", "CONTAINER_DELIVERY_ORDER", "CONTAINER_PICKUP_ORDER", - "OVERSEAS_UNLOAD_PHOTO"); + "OVERSEAS_UNLOAD_PHOTO", + "OVERSEAS_RELABEL_PHOTO"); @Resource private FileObjectMapper files; @Resource private BusinessFileStorage storage; diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 7b8c7bcd6..f0cc94a06 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -80,6 +80,22 @@ class InternalBusinessFileServiceTest { verifyNoInteractions(files, storage); } + @Test void overseasRelabelPhotoPersistsExactTaskOwnership() { + MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes()); + when(storage.storeLoading(100L, 81L, file)).thenReturn(new BusinessFileStorage.Stored( + "business-files/100/81/relabel.jpg", "image/jpeg", file.getSize(), "d".repeat(64), "relabel.jpg")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1; + }); + + var result = service.upload(100L, "OVERSEAS_RELABEL_PHOTO", "81", 7L, file); + + assertThat(result).extracting(InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId) + .containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81"); + } + @Test void unsupportedBusinessTypeFailsClosed() { assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L, new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()))) diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 14106f6cf..a9afeb4a0 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -28,10 +28,11 @@ | 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` | | 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 | | PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` | -| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;按最终实装与稳定箱码冻结 V2,真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` | +| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` | | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | diff --git a/firstleg/docs/firstleg-internal-modularization-baseline.md b/firstleg/docs/firstleg-internal-modularization-baseline.md new file mode 100644 index 000000000..7dbecb0d3 --- /dev/null +++ b/firstleg/docs/firstleg-internal-modularization-baseline.md @@ -0,0 +1,200 @@ +# 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` 与完整测试均成功。 diff --git a/firstleg/pom.xml b/firstleg/pom.xml index 02098cbe8..1a35216ef 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -23,6 +23,7 @@ org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest + com.tngtech.archunitarchunittest org.projectlomboklomboktrue org.springframework.bootspring-boot-maven-plugin diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs new file mode 100644 index 000000000..5b252d7a8 --- /dev/null +++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs @@ -0,0 +1,319 @@ +#!/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)(?:\.|$)/], + ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/], + ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/], + ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], + ['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/], + ['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/], + ['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/], + ['inbound', /(?:Inbound|Fulfillment|Assignment|UnifiedInbound|CartonLabel)/], +]; + +function parseArgs(argv) { + const options = { sourceSet: 'main', format: 'json', output: null, check: null }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--source-set') options.sourceSet = argv[++index]; + else if (argument === '--format') options.format = argv[++index]; + else if (argument === '--output') options.output = argv[++index]; + else if (argument === '--check') options.check = argv[++index]; + else if (argument === '--help') { + process.stdout.write('Usage: node scripts/analyze-firstleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--output FILE] [--check CONTRACT.json]\n'); + process.exit(0); + } else throw new Error(`Unknown argument: ${argument}`); + } + if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`); + if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`); + return options; +} + +function walkJavaFiles(root) { + if (!fs.existsSync(root)) return []; + const result = []; + const pending = [root]; + while (pending.length > 0) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) pending.push(absolute); + else if (entry.isFile() && entry.name.endsWith('.java')) result.push(absolute); + } + } + return result.sort(); +} + +function stripCommentsAndStrings(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/\/\/[^\r\n]*/g, ' ') + .replace(/"(?:\\.|[^"\\])*"/g, '""') + .replace(/'(?:\\.|[^'\\])*'/g, "''"); +} + +function classifySlice(fqcn) { + for (const [slice, expression] of sliceRules) { + if (expression.test(fqcn)) return slice; + } + return 'shared-technical'; +} + +function classifyKind(fqcn) { + const packageName = fqcn.slice(0, fqcn.lastIndexOf('.')); + if (packageName.includes('.client')) return 'cross-service-adapter'; + if (/\.(adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter'; + if (/\.(entity|dto)(?:\.|$)/.test(packageName)) return 'data-model'; + return 'business-application'; +} + +function parseSource(file, sourceSet) { + const raw = fs.readFileSync(file, 'utf8'); + const source = stripCommentsAndStrings(raw); + const packageName = source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1]; + const typeName = path.basename(file, '.java'); + if (!packageName) throw new Error(`Missing package declaration: ${file}`); + const fqcn = `${packageName}.${typeName}`; + const imports = [...source.matchAll(/\bimport\s+(?:static\s+)?([\w.]+)(?:\.\*)?\s*;/g)].map(match => match[1]); + return { + file: path.relative(moduleRoot, file).replaceAll(path.sep, '/'), + sourceSet, + packageName, + typeName, + fqcn, + imports, + source, + slice: classifySlice(fqcn), + kind: classifyKind(fqcn), + }; +} + +function collectSources(sourceSet) { + const selected = sourceSet === 'all' ? ['main', 'test'] : [sourceSet]; + return selected.flatMap(set => walkJavaFiles(sourceRoots[set]).map(file => parseSource(file, set))); +} + +function resolveDependencies(sources) { + const knownTypes = new Map(sources.map(source => [source.fqcn, source])); + const typesByPackage = new Map(); + for (const source of sources) { + if (!typesByPackage.has(source.packageName)) typesByPackage.set(source.packageName, []); + typesByPackage.get(source.packageName).push(source); + } + const dependencies = []; + const seen = new Set(); + for (const source of sources) { + const candidates = new Set(); + for (const imported of source.imports) { + if (knownTypes.has(imported)) candidates.add(imported); + else if (imported.startsWith(internalPrefix)) { + const owner = [...knownTypes.keys()].find(type => imported.startsWith(`${type}.`)); + if (owner) candidates.add(owner); + } + } + for (const samePackageType of typesByPackage.get(source.packageName) ?? []) { + if (samePackageType.fqcn !== source.fqcn && new RegExp(`\\b${samePackageType.typeName}\\b`).test(source.source)) { + candidates.add(samePackageType.fqcn); + } + } + for (const targetFqcn of candidates) { + if (targetFqcn === source.fqcn) continue; + const key = `${source.fqcn}->${targetFqcn}`; + if (seen.has(key)) continue; + seen.add(key); + dependencies.push({ source: source.fqcn, target: targetFqcn }); + } + } + return dependencies.sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); +} + +function aggregateEdges(dependencies, nodeOf) { + const counts = new Map(); + for (const dependency of dependencies) { + const source = nodeOf(dependency.source); + const target = nodeOf(dependency.target); + if (source === target) continue; + const key = `${source}\0${target}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts].map(([key, count]) => { + const [source, target] = key.split('\0'); + return { source, target, count }; + }).sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); +} + +function stronglyConnectedComponents(nodes, edges) { + const adjacency = new Map(nodes.map(node => [node, []])); + for (const edge of edges) adjacency.get(edge.source)?.push(edge.target); + const indexByNode = new Map(); + const lowLink = new Map(); + const stack = []; + const onStack = new Set(); + const components = []; + let nextIndex = 0; + function visit(node) { + indexByNode.set(node, nextIndex); + lowLink.set(node, nextIndex); + nextIndex += 1; + stack.push(node); + onStack.add(node); + for (const target of adjacency.get(node) ?? []) { + if (!indexByNode.has(target)) { + visit(target); + lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(target))); + } else if (onStack.has(target)) { + lowLink.set(node, Math.min(lowLink.get(node), indexByNode.get(target))); + } + } + if (lowLink.get(node) === indexByNode.get(node)) { + const component = []; + let current; + do { + current = stack.pop(); + onStack.delete(current); + component.push(current); + } while (current !== node); + components.push(component.sort()); + } + } + for (const node of [...nodes].sort()) if (!indexByNode.has(node)) visit(node); + return components.filter(component => component.length > 1).sort((a, b) => b.length - a.length || a[0].localeCompare(b[0])); +} + +function buildReport(sourceSet) { + const sources = collectSources(sourceSet); + const sourceByFqcn = new Map(sources.map(source => [source.fqcn, source])); + const dependencies = resolveDependencies(sources); + const packages = [...new Set(sources.map(source => source.packageName))].sort(); + const slices = [...new Set(sources.map(source => source.slice))].sort(); + const packageEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).packageName); + const sliceEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).slice); + const packageSccs = stronglyConnectedComponents(packages, packageEdges); + const sliceSccs = stronglyConnectedComponents(slices, sliceEdges); + const directCrossSliceDependencies = dependencies.filter(edge => sourceByFqcn.get(edge.source).slice !== sourceByFqcn.get(edge.target).slice); + const modelLeaks = directCrossSliceDependencies.filter(edge => ['data-model'].includes(sourceByFqcn.get(edge.target).kind)); + const mapperLeaks = directCrossSliceDependencies.filter(edge => /\.mapper(?:\.|$)/.test(sourceByFqcn.get(edge.target).packageName)); + return { + schemaVersion: 1, + generatedFrom: sourceSet, + moduleRoot: 'firstleg', + summary: { + javaFiles: sources.length, + internalClassDependencies: dependencies.length, + packages: packages.length, + slices: slices.length, + crossSliceClassDependencies: directCrossSliceDependencies.length, + crossSliceModelLeaks: modelLeaks.length, + crossSliceMapperLeaks: mapperLeaks.length, + }, + sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])), + classes: sources.map(({ source, ...entry }) => entry), + classDependencies: dependencies, + packageEdges, + sliceEdges, + packageSccs, + sliceSccs, + directCrossSliceDependencies, + modelLeaks, + mapperLeaks, + }; +} + +function renderMarkdown(report) { + const lines = [ + '# Firstleg internal dependency report', '', + `- Source set: ${report.generatedFrom}`, + `- Java files: ${report.summary.javaFiles}`, + `- Internal class dependencies: ${report.summary.internalClassDependencies}`, + `- Package SCCs (>1 node): ${report.packageSccs.length}`, + `- Slice SCCs (>1 node): ${report.sliceSccs.length}`, + '', '## Slice edges', '', '| From | To | Class edges |', '|---|---|---:|', + ...report.sliceEdges.map(edge => `| ${edge.source} | ${edge.target} | ${edge.count} |`), + '', '## Slice SCCs', '', + ...(report.sliceSccs.length ? report.sliceSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), + '', '## Package SCCs', '', + ...(report.packageSccs.length ? report.packageSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), + '', '## Cross-slice model and mapper leaks', '', + ...[...report.modelLeaks, ...report.mapperLeaks] + .filter((edge, index, entries) => entries.findIndex(candidate => candidate.source === edge.source && candidate.target === edge.target) === index) + .map(edge => `- \`${edge.source}\` -> \`${edge.target}\``), + '', + ]; + return lines.join('\n'); +} + +function renderDot(report) { + const lines = ['digraph firstleg_slices {', ' rankdir=LR;']; + for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`); + lines.push('}', ''); + return lines.join('\n'); +} + +function checkContract(report, contractPath) { + const absolute = path.resolve(process.cwd(), contractPath); + const contract = JSON.parse(fs.readFileSync(absolute, 'utf8')); + const allowed = new Set(contract.allowedSliceEdges ?? []); + const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); + const additions = actual.filter(edge => !allowed.has(edge)); + const violations = additions.map(edge => `new slice dependency: ${edge}`); + const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry])); + for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) { + const actualClass = classesByName.get(fqcn); + if (!actualClass) violations.push(`required class missing: ${fqcn}`); + else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`); + } + const 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(`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..9bcb61d64 --- /dev/null +++ b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs @@ -0,0 +1,54 @@ +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, []); +}); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java index b9ed1c6ee..8c071e0be 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -18,7 +18,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; "com.baiyue.firstleg.outbound.dispatch.mapper", "com.baiyue.firstleg.portschedule.mapper", "com.baiyue.firstleg.customs.mapper", - "com.baiyue.firstleg.customerstatistics.mapper" + "com.baiyue.firstleg.customerstatistics.adapter.out.persistence" }) @EnableFeignClients(basePackages = "com.baiyue.firstleg") @EnableScheduling diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 651269bd1..4e299343f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -59,6 +59,9 @@ public class InternalInboundOrderController { || command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank() || command.inboundCode() == null || command.inboundCode().isBlank() || command.inboundCode().length() > 40 + || command.fulfillmentType() == null + || (!"STANDARD".equals(command.fulfillmentType()) + && !"ONE_CLICK_DROPSHIP".equals(command.fulfillmentType())) || command.totalPackageCount() != null && command.totalPackageCount() < 1) { throw new BizException(ResultCode.BAD_REQUEST, "订单入库快照不完整"); } @@ -142,7 +145,10 @@ public class InternalInboundOrderController { || command.customerName() == null || command.customerName().isBlank() || command.warehouseId() == null || command.warehouseId() <= 0 || command.warehouseName() == null || command.warehouseName().isBlank() - || command.businessType() == null || command.businessType().isBlank()) { + || command.businessType() == null || command.businessType().isBlank() + || command.fulfillmentType() == null + || (!"STANDARD".equals(command.fulfillmentType()) + && !"ONE_CLICK_DROPSHIP".equals(command.fulfillmentType()))) { throw new BizException(ResultCode.BAD_REQUEST, "草稿预入库快照不完整"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java similarity index 83% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java index ca99b7399..ae441abed 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java @@ -1,9 +1,9 @@ -package com.baiyue.firstleg.customerstatistics.controller; +package com.baiyue.firstleg.customerstatistics.adapter.in.web; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary; -import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos.TransitSummary; import com.baiyue.firstleg.service.FirstlegScopeGuard; import jakarta.annotation.Resource; import jakarta.validation.constraints.Positive; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java similarity index 85% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java index 0c208dc1e..93f276654 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java @@ -1,14 +1,13 @@ -package com.baiyue.firstleg.customerstatistics.mapper; +package com.baiyue.firstleg.customerstatistics.adapter.out.persistence; +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; -import java.math.BigDecimal; -import java.time.LocalDateTime; - @Mapper -public interface CustomerTransitStatisticsMapper { +public interface CustomerTransitStatisticsMapper extends CustomerTransitStatisticsReader { + @Override @Select(""" SELECT #{customerId} AS customerId, COUNT(*) AS containerCount, @@ -49,8 +48,5 @@ public interface CustomerTransitStatisticsMapper { WHERE x.customer_id = #{customerId} ) scoped """) - SummaryRow summarize(@Param("customerId") long customerId); - - record SummaryRow(Long customerId, Long containerCount, BigDecimal totalTeu, - Long unknownContainerCount, LocalDateTime asOf) {} + SummaryProjection summarize(@Param("customerId") long customerId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java similarity index 75% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java index 485a9572b..71477ce5b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java @@ -1,9 +1,9 @@ -package com.baiyue.firstleg.customerstatistics.service; +package com.baiyue.firstleg.customerstatistics.application; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary; -import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; +import com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos.TransitSummary; +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; @@ -12,11 +12,11 @@ import java.math.BigDecimal; @Service public class CustomerTransitStatisticsService { private static final String DEFINITION = "actual_sailing_at IS NOT NULL 且 actual_warehouse_arrival_at IS NULL;排除取消/失败订舱;客户+containerBookingId去重;拼柜按active LoadingPlanItem.accountId归属,整柜按OceanBooking.customerId归属。TEU:20*=1,40*=2,45*=2.25,未知箱型不计totalTeu。"; - @Resource private CustomerTransitStatisticsMapper mapper; + @Resource private CustomerTransitStatisticsReader reader; public TransitSummary summarize(long customerId) { if (customerId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "customerId必须为正数"); - var row = mapper.summarize(customerId); + var row = reader.summarize(customerId); return new TransitSummary(customerId, value(row == null ? null : row.containerCount()), row == null || row.totalTeu() == null ? BigDecimal.ZERO : row.totalTeu(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java similarity index 84% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java index e123871da..7afb319b1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java @@ -1,4 +1,4 @@ -package com.baiyue.firstleg.customerstatistics.dto; +package com.baiyue.firstleg.customerstatistics.application.model; import java.math.BigDecimal; import java.time.LocalDateTime; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java new file mode 100644 index 000000000..58ece2751 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java @@ -0,0 +1,11 @@ +package com.baiyue.firstleg.customerstatistics.application.port.out; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public interface CustomerTransitStatisticsReader { + SummaryProjection summarize(long customerId); + + record SummaryProjection(Long customerId, Long containerCount, BigDecimal totalTeu, + Long unknownContainerCount, LocalDateTime asOf) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index 38897b09a..94eb4cc89 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.entity.ContainerDocumentEntity; -import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; +import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; import jakarta.annotation.Resource; @@ -9,7 +9,7 @@ import org.springframework.stereotype.Component; @Component public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor { - @Resource private LoadingListRepository repository; + @Resource private CustomsRepository repository; @Override public String code() { return "CUSTOMS_CLEARANCE"; } @@ -18,12 +18,13 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } - ContainerDocumentEntity value = repository.document(context.containerBookingId(), "CUSTOMS_DECLARATION"); + CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(), + context.inboundId(), context.containerBookingId()); if (value == null) return gate(false, "MISSING"); - return gate(value.getCompletedAt() != null, value.getCompletedAt() == null ? "UPLOADED" : "COMPLETED"); + return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus()); } private static GateItem gate(boolean passed, String actual) { - return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "COMPLETED"); + return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "CLEARANCE_COMPLETED"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java index f5a98f09a..e9071df30 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java @@ -17,7 +17,7 @@ public record InboundOrderSummary( Long customerId, String customerName, Long companyId, String companyName, Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, - String businessType, String channelCode, String channelName, + String businessType, String fulfillmentType, String channelCode, String channelName, String taxMode, String pricingTaxMode, String containerMode, Boolean customsInspection, String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName, Boolean holdFlag, String currentHoldReason, @@ -56,6 +56,7 @@ public record InboundOrderSummary( order.getCustomerId(), order.getCustomerName(), order.getCompanyId(), order.getCompanyName(), order.getWarehouseId(), order.getWarehouseName(), order.getDeliveryWarehouseId(), order.getDeliveryWarehouseName(), order.getBusinessType(), + order.getFulfillmentType(), order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getPricingTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java index 92665d4bd..ece4cea5e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java @@ -33,7 +33,8 @@ public record OrderApprovedInboundCommand( String expressTrackingNo, Integer totalPackageCount, String pickupMethod, - LocalDateTime requestedPickupAt + LocalDateTime requestedPickupAt, + String fulfillmentType ) { public OrderApprovedInboundCommand( long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName, @@ -48,7 +49,7 @@ public record OrderApprovedInboundCommand( deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, - expressTrackingNo, totalPackageCount, null, null); + expressTrackingNo, totalPackageCount, null, null, "STANDARD"); } /** 兼容尚未携带订单总箱数的历史事件。 */ public OrderApprovedInboundCommand( @@ -63,7 +64,7 @@ public record OrderApprovedInboundCommand( deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, - expressTrackingNo, null, null, null); + expressTrackingNo, null, null, null, "STANDARD"); } /** 兼容未携带报价包税维度的历史调用方。 */ @@ -79,6 +80,6 @@ public record OrderApprovedInboundCommand( deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, carrierId, carrierName, taxMode, null, containerMode, customsInspection, destinationType, destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, - expressTrackingNo, null, null, null); + expressTrackingNo, null, null, null, "STANDARD"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java index 43290b179..75f165e1b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java @@ -38,6 +38,7 @@ public class InboundOrderEntity extends BaseEntity { private Long deliveryWarehouseId; private String deliveryWarehouseName; private String businessType; + private String fulfillmentType; private String channelCode; private String channelName; private Long carrierId; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index e2703301b..52467a049 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -97,13 +97,19 @@ public class LoadingDeliveryService { @Transactional public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId, String operatorName, String key, CompleteLoadingRequest request) { + return completeLoading(tenantId, inboundId, inboundId, operatorId, operatorName, key, request); + } + + private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId, + long operatorId, String operatorName, String key, + CompleteLoadingRequest request) { String hash = hash(request); LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash); if (replay != null) return replay; Snapshot inbound = requireInbound(tenantId, inboundId); CompletionTarget target = repository.completionTarget(tenantId, inboundId); if (target != null) { - return completeFulfillmentSlice(tenantId, inboundId, operatorId, operatorName, key, request, target); + return completeFulfillmentSlice(tenantId, evidenceInboundId, operatorId, operatorName, key, request, target); } if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) { loadingModelBridge.requireCompleted(tenantId, inboundId); @@ -114,7 +120,7 @@ public class LoadingDeliveryService { loadingModelBridge.requireCompleted(tenantId, inboundId); completionGate.requireReleasedBooking(tenantId, inboundId); requireCompletedPicking(tenantId, inboundId); - requireCompletionEvidence(tenantId, inboundId); + requireCompletionEvidence(tenantId, evidenceInboundId); if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName); return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, @@ -129,13 +135,19 @@ public class LoadingDeliveryService { public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId, long operatorId, String operatorName, String key, String remark) { + return completeAfterPdaSettlement(tenantId, inboundId, inboundId, operatorId, operatorName, key, remark); + } + + public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId, + long evidenceInboundId, long operatorId, + String operatorName, String key, String remark) { CompletionTarget target = repository.completionTarget(tenantId, inboundId); long completionInboundId = target == null ? inboundId : target.completionInboundId(); Snapshot current = repository.findInboundForUpdate(tenantId, completionInboundId); if (current == null || current.version() == null) { throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试"); } - return completeLoading(tenantId, inboundId, operatorId, operatorName, key, + return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName, key, new CompleteLoadingRequest(current.version(), remark)); } @@ -144,13 +156,17 @@ public class LoadingDeliveryService { * every external/business hard gate must pass before the PDA task is mutated. */ public void requirePdaCompletionReady(long tenantId, long inboundId) { + requirePdaCompletionReady(tenantId, inboundId, inboundId); + } + + public void requirePdaCompletionReady(long tenantId, long inboundId, long evidenceInboundId) { Snapshot inbound = requireInbound(tenantId, inboundId); if (!"LOADING".equals(inbound.processStatus())) { throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING"); } loadingModelBridge.requireStarted(tenantId, inboundId); completionGate.requireReleasedBooking(tenantId, inboundId); - requireCompletionEvidence(tenantId, inboundId); + requireCompletionEvidence(tenantId, evidenceInboundId); } private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId, @@ -167,7 +183,7 @@ public class LoadingDeliveryService { loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId()); completionGate.requireReleasedBooking(tenantId, target.sourceInboundId()); requireCompletedPicking(tenantId, target.sourceInboundId()); - requireCompletionEvidence(tenantId, target.sourceInboundId()); + requireCompletionEvidence(tenantId, evidenceInboundId); if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId, request.expectedVersion()) != 1) throw concurrent(); repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java index 5004229d3..bb29c8c8c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -38,7 +38,7 @@ public interface InboundSplitSettlementMapper { INSERT INTO fl_inbound_order (id,tenant_id,task_id,order_id,order_no,inbound_code,process_status, order_status_snapshot,measure_review_status,fee_status,fee_review_status,notification_status, expected_arrival_at,customer_id,customer_name,company_id,company_name,warehouse_id,warehouse_name, - delivery_warehouse_id,delivery_warehouse_name,business_type,channel_code,channel_name,carrier_id,carrier_name, + delivery_warehouse_id,delivery_warehouse_name,business_type,fulfillment_type,channel_code,channel_name,carrier_id,carrier_name, tax_mode,pricing_tax_mode,container_mode,customs_inspection,destination_type,destination_warehouse_id, destination_code,destination_name,hold_flag,current_hold_reason,total_package_count,package_count, actual_gross_weight_kg,chargeable_weight_kg,actual_volume_cbm,chargeable_volume_cbm,pallet_count,volume_weight_kg, @@ -48,7 +48,7 @@ public interface InboundSplitSettlementMapper { SELECT #{childId},tenant_id,task_id,#{childOrderId},#{childOrderNo},#{childInboundCode}, CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END,order_status_snapshot, 'APPROVED',fee_status,fee_review_status,notification_status,expected_arrival_at,customer_id,customer_name, - company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type, + company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type,fulfillment_type, channel_code,channel_name,carrier_id,carrier_name,tax_mode,pricing_tax_mode,container_mode,customs_inspection, destination_type,destination_warehouse_id,destination_code,destination_name,hold_flag,current_hold_reason, #{packageCount},#{packageCount}, @@ -149,6 +149,34 @@ public interface InboundSplitSettlementMapper { @Param("childMeasurementItemId") long childMeasurementItemId, @Param("lineNo") int lineNo, @Param("packageCount") int packageCount); + @Update(""" + UPDATE fl_inbound_carton_label + SET inbound_id=#{childInboundId}, order_id=#{childOrderId}, inbound_code=#{childInboundCode}, + carton_index=carton_index-#{loadedPackageCount}, + total_carton_count=#{remainingPackageCount}, + detail_package_count=LEAST(detail_package_count,#{remainingPackageCount}) + WHERE tenant_id=#{accountId} AND inbound_id=#{sourceInboundId} AND batch_status='ACTIVE' + ORDER BY carton_index DESC + LIMIT #{remainingPackageCount} + """) + int transferRemainingCartonLabels(@Param("accountId") long accountId, + @Param("sourceInboundId") long sourceInboundId, + @Param("childInboundId") long childInboundId, + @Param("childOrderId") long childOrderId, + @Param("childInboundCode") String childInboundCode, + @Param("loadedPackageCount") int loadedPackageCount, + @Param("remainingPackageCount") int remainingPackageCount); + + @Update(""" + UPDATE fl_inbound_carton_label + SET total_carton_count=#{loadedPackageCount}, + detail_package_count=LEAST(detail_package_count,#{loadedPackageCount}) + WHERE tenant_id=#{accountId} AND inbound_id=#{sourceInboundId} AND batch_status='ACTIVE' + """) + int normalizeLoadedCartonLabels(@Param("accountId") long accountId, + @Param("sourceInboundId") long sourceInboundId, + @Param("loadedPackageCount") int loadedPackageCount); + @Update(""" UPDATE fl_inbound_order SET package_count = GREATEST(COALESCE(package_count, total_package_count) - #{packageCount}, 0), @@ -177,9 +205,10 @@ public interface InboundSplitSettlementMapper { @Update(""" UPDATE fl_inbound_measurement_item - SET package_count=#{packageCount}, - total_volume_cbm=unit_volume_cbm*#{packageCount}, - volume_weight_kg=volume_weight_kg/NULLIF(package_count,0)*#{packageCount}, + SET total_volume_cbm=COALESCE(unit_volume_cbm,0)*#{packageCount}, + volume_weight_kg=CASE WHEN #{packageCount}=0 THEN 0 + ELSE COALESCE(volume_weight_kg/NULLIF(package_count,0)*#{packageCount},0) END, + package_count=#{packageCount}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{measurementItemId} AND tenant_id=#{accountId} """) @@ -218,7 +247,9 @@ public interface InboundSplitSettlementMapper { @Update(""" UPDATE fl_loading_plan_item plan_item JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id = plan_item.id - SET plan_item.package_count = pick_item.picked_package_count, + SET plan_item.pallet_count = CEIL(COALESCE(plan_item.pallet_count,0) + * pick_item.picked_package_count / NULLIF(plan_item.package_count,0)), + plan_item.package_count = pick_item.picked_package_count, plan_item.updated_by = #{operatorId}, plan_item.updated_by_name = #{operatorName}, plan_item.updated_at = CURRENT_TIMESTAMP(3), plan_item.version = plan_item.version + 1 WHERE plan_item.id = #{planItemId} AND plan_item.deleted = 0 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java index 70eecdaf7..3c30e0806 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java @@ -3,6 +3,8 @@ package com.baiyue.firstleg.mapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import lombok.Getter; +import lombok.Setter; import java.util.List; @@ -10,12 +12,15 @@ import java.util.List; @Mapper public interface LoadingCartonManifestMapper { @Select(""" - SELECT box_code FROM fl_inbound_carton_label - WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND inbound_detail_id=#{inboundDetailId} - AND batch_status='ACTIVE' + SELECT inbound_detail_id, box_code FROM fl_inbound_carton_label + WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND batch_status='ACTIVE' ORDER BY carton_index,id """) - List labelPool(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, - @Param("inboundDetailId") long inboundDetailId); + List labelPool(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + @Getter @Setter + class LabelRow { + private Long inboundDetailId; + private String boxCode; + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index 8cb8b2ca0..a5032b3d4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -19,7 +19,7 @@ public interface LoadingCompletedEventMapper { COALESCE(o.root_inbound_id, o.id) AS root_inbound_id, o.order_id AS source_order_id, o.order_no AS source_order_no, COALESCE(root_order.order_id, o.order_id) AS root_order_id, - o.inbound_code, o.business_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, + o.inbound_code, o.business_type, o.fulfillment_type, o.destination_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, o.destination_name AS destination_warehouse_name, i.id AS loading_plan_item_id, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, @@ -73,7 +73,7 @@ public interface LoadingCompletedEventMapper { private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; - private String businessType; + private String businessType; private String fulfillmentType; private String destinationType; private String customerRemark; private String internalRemark; private String warehouseRemark; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index c93a7c73d..2306dafaa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -174,7 +174,13 @@ public interface LoadingListQueryMapper { SELECT i.id AS item_id, i.account_id, i.customer_name, i.order_id, i.order_no, i.inbound_id, i.inbound_detail_id, i.inbound_code, b.container_no, i.sku_code, i.destination_warehouse_name, i.product_name_zh, - product_name_en, product_image_url, package_count, pallet_count, + product_name_en, product_image_url, i.package_count, + COALESCE(NULLIF(i.pallet_count,0), + CEIL(COALESCE(NULLIF(inbound.pallet_count,0), + (SELECT COUNT(*) FROM fl_inbound_weight_result weight + WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id + AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0) + * i.package_count / NULLIF(inbound.total_package_count,0)),0) pallet_count, gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg, product_quantity, units_per_carton, fba_tracking_no, shipment_no, shipping_mark, internal_remark, customer_remark, warehouse_remark, @@ -182,6 +188,7 @@ public interface LoadingListQueryMapper { FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + LEFT JOIN fl_inbound_order inbound ON inbound.id=i.inbound_id AND inbound.tenant_id=i.account_id WHERE i.loading_plan_id = #{planId} AND i.deleted = 0 ORDER BY i.load_sequence, i.id """) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index ea943e0b9..a39cba50e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -72,11 +72,25 @@ public interface LoadingPickItemMapper extends BaseMapper @Param("operatorName") String operatorName); @Select(""" - SELECT COALESCE(SUM(plan_item.package_count),0) expectedCount, + SELECT COALESCE(SUM(CASE + WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0 + WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0 + AND pick_item.picked_package_count<=plan_item.package_count + THEN pick_item.picked_package_count + ELSE plan_item.package_count + END),0) expectedCount, COALESCE(SUM(pick_item.picked_package_count),0) pickedCount, COUNT(DISTINCT pick_task.id) taskCount, COUNT(DISTINCT CASE WHEN pick_task.task_status='COMPLETED' THEN pick_task.id END) completedTaskCount, - SUM(CASE WHEN pick_item.id IS NULL OR pick_item.picked_package_count<>plan_item.package_count THEN 1 ELSE 0 END) unresolvedCount + SUM(CASE + WHEN pick_item.id IS NULL THEN 1 + WHEN pick_item.item_status='PICKED' + AND pick_item.picked_package_count=plan_item.package_count THEN 0 + WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0 + AND pick_item.picked_package_count<=plan_item.package_count THEN 0 + WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0 + ELSE 1 + END) unresolvedCount FROM fl_loading_plan_item plan_item JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id AND inbound_order.tenant_id=#{tenantId} AND inbound_order.deleted=0 @@ -107,10 +121,12 @@ public interface LoadingPickItemMapper extends BaseMapper } @Select(""" - SELECT DISTINCT plan_item.account_id tenantId,plan_item.inbound_id inboundId + SELECT plan_item.account_id tenantId,plan_item.inbound_id inboundId FROM fl_loading_pick_item pick_item JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id WHERE pick_item.pick_task_id=#{taskId} + GROUP BY plan_item.account_id,plan_item.inbound_id + ORDER BY MIN(pick_item.id) """) java.util.List selectInboundOwners(@Param("taskId") long taskId); @@ -214,7 +230,8 @@ public interface LoadingPickItemMapper extends BaseMapper long unresolvedCount(@Param("taskId") long taskId); /** - * 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。 + * 以结算后的装柜计划事实为完成口径,不能只检查当前 PDA 任务中的明细。 + * PARTIAL 在本查询前已把计划箱数收敛为实际拣货箱数;收敛前的严格小于关系由结算计划校验。 */ @Select(""" SELECT COUNT(*) @@ -228,7 +245,15 @@ public interface LoadingPickItemMapper extends BaseMapper WHERE pick_item.loading_plan_item_id = plan_item.id AND pick_task.loading_plan_id = #{planId} AND pick_task.task_status = 'LOADING' - AND pick_item.picked_package_count = plan_item.package_count + AND ( + (pick_item.item_status = 'PICKED' + AND pick_item.picked_package_count = plan_item.package_count) + OR (pick_item.item_status = 'PARTIAL' + AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count = plan_item.package_count) + OR (pick_item.item_status = 'SKIPPED' + AND pick_item.picked_package_count = 0) + ) ) """) long unfinishedCount(@Param("planId") long planId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index e876d46d2..9d1a34e60 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -15,7 +15,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id AND split_item.parent_inbound_id=o.id - AND split_item.source_measurement_item_id=i.id),0) availablePackageCount, + AND split_item.source_inbound_detail_id=i.inbound_detail_id),0) availablePackageCount, COALESCE(o.actual_gross_weight_kg / NULLIF(o.total_package_count, 0), 0) unitGrossWeightKg, COALESCE(o.actual_net_weight_kg / NULLIF(o.total_package_count, 0), 0) unitNetWeightKg, COALESCE(i.unit_volume_cbm,0) unitVolumeCbm @@ -24,11 +24,17 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba AND m.status='APPROVED' AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') - JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id + JOIN ( + SELECT tenant_id,measurement_id,inbound_detail_id,SUM(package_count) package_count, + COALESCE(SUM(COALESCE(total_volume_cbm,COALESCE(unit_volume_cbm,0)*package_count)) + / NULLIF(SUM(package_count),0),0) unit_volume_cbm + FROM fl_inbound_measurement_item + WHERE inbound_detail_id IS NOT NULL AND package_count>0 + GROUP BY tenant_id,measurement_id,inbound_detail_id + ) i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id WHERE o.warehouse_id=#{warehouseId} AND o.process_status='WAIT_SPLIT' AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) - AND i.inbound_detail_id IS NOT NULL AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) @@ -37,8 +43,8 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id AND split_item.parent_inbound_id=o.id - AND split_item.source_measurement_item_id=i.id),0) - ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC + AND split_item.source_inbound_detail_id=i.inbound_detail_id),0) + ORDER BY o.inbounded_at ASC, o.id ASC, i.inbound_detail_id ASC """) List candidates(@Param("warehouseId") long warehouseId); @@ -56,7 +62,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id AND split_item.parent_inbound_id=o.id - AND split_item.source_measurement_item_id=i.id),0) availablePackageCount, + AND split_item.source_inbound_detail_id=i.inbound_detail_id),0) availablePackageCount, COALESCE(o.actual_gross_weight_kg / NULLIF(o.total_package_count, 0), 0) unitGrossWeightKg, COALESCE(o.actual_net_weight_kg / NULLIF(o.total_package_count, 0), 0) unitNetWeightKg, COALESCE(i.unit_volume_cbm,0) unitVolumeCbm @@ -65,13 +71,27 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba AND m.status='APPROVED' AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') - JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id - WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='WAIT_SPLIT' + JOIN ( + SELECT tenant_id,measurement_id,inbound_detail_id,SUM(package_count) package_count, + COALESCE(SUM(COALESCE(total_volume_cbm,COALESCE(unit_volume_cbm,0)*package_count)) + / NULLIF(SUM(package_count),0),0) unit_volume_cbm + FROM fl_inbound_measurement_item + WHERE inbound_detail_id IS NOT NULL AND package_count>0 + GROUP BY tenant_id,measurement_id,inbound_detail_id + ) i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id + WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} + AND (o.process_status='WAIT_SPLIT' OR (o.process_status='LOADING' AND EXISTS ( + SELECT 1 FROM fl_loading_plan_item same_plan_item + WHERE same_plan_item.loading_plan_id=#{planId} + AND same_plan_item.account_id=o.tenant_id + AND same_plan_item.inbound_id=o.id + AND same_plan_item.deleted=0 + ))) AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) AND i.inbound_detail_id=#{detailId} ORDER BY m.id DESC LIMIT 1 FOR UPDATE """) - Candidate candidate(@Param("warehouseId") long warehouseId,@Param("inboundId") long inboundId, - @Param("detailId") long detailId); + Candidate candidate(@Param("planId") long planId,@Param("warehouseId") long warehouseId, + @Param("inboundId") long inboundId,@Param("detailId") long detailId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index 7bee3dbce..9778a6012 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -87,6 +87,25 @@ public interface OutboundDispatchReadMapper { SELECT 'TRUCK_APPOINTMENT',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.reason,h.created_at FROM fl_truck_appointment_history h WHERE h.appointment_id=#{truckAppointmentId} UNION ALL + SELECT 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.note,h.created_at + FROM fl_customs_history h JOIN fl_customs_case customs_case ON customs_case.id=h.customs_case_id + WHERE customs_case.tenant_id=#{tenantId} AND customs_case.order_id=#{orderId} + AND customs_case.inbound_id=#{inboundId} AND customs_case.container_booking_id=#{containerBookingId} + UNION ALL + SELECT 'SAILING_SCHEDULE',h.action,NULL,NULL,h.operator_id,h.operator_name,h.reason,h.created_at + FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule ON port_schedule.id=h.port_schedule_id + WHERE port_schedule.customer_id=#{tenantId} AND port_schedule.ocean_booking_id=#{oceanBookingId} + AND port_schedule.container_booking_id=#{containerBookingId} + AND EXISTS ( + SELECT 1 FROM fl_loading_plan_item schedule_item + JOIN fl_loading_plan schedule_plan ON schedule_plan.id=schedule_item.loading_plan_id + JOIN fl_inbound_order schedule_inbound ON schedule_inbound.id=schedule_item.inbound_id + AND schedule_inbound.tenant_id=schedule_item.account_id AND schedule_inbound.deleted=0 + WHERE schedule_item.deleted=0 AND schedule_plan.container_booking_id=port_schedule.container_booking_id + AND schedule_inbound.tenant_id=#{tenantId} AND schedule_inbound.order_id=#{orderId} + AND schedule_inbound.id=#{inboundId} + ) + UNION ALL SELECT 'OUTBOUND_DISPATCH',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.remark,h.created_at FROM fl_outbound_dispatch_history h WHERE h.tenant_id=#{tenantId} AND h.inbound_id=#{inboundId} AND h.container_booking_id=#{containerBookingId} @@ -95,6 +114,7 @@ public interface OutboundDispatchReadMapper { List timeline(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, @Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId, + @Param("orderId") long orderId, @Param("oceanBookingId") Long oceanBookingId, @Param("truckAppointmentId") Long truckAppointmentId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java index dc882f01c..a0f87004a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -57,6 +57,6 @@ public class OutboundDispatchRepository { } public List timeline(OutboundDispatchReadMapper.Binding binding, Long oceanId, Long truckId) { return reads.timeline(binding.tenantId(), binding.inboundId(), binding.containerBookingId(), - binding.loadingPlanId(), oceanId, truckId); + binding.loadingPlanId(), binding.orderId(), oceanId, truckId); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index 00267667e..fad3762c1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -106,6 +106,11 @@ public class OutboundDispatchService { throw conflict("当前出仓单不在等待装船状态"); } if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent(); + OutboundDispatchReadMapper.IdStatus ocean = repository.ocean(binding.containerBookingId(), binding.tenantId()); + if (carrierSchedules != null && ocean != null && carrierSchedules.hasOutboundDepartureFact( + binding.tenantId(), ocean.id(), binding.containerBookingId())) { + throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓"); + } if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { throw concurrent(); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index 1f3e38eca..274bff0d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -49,8 +49,8 @@ public interface CarrierScheduleQueryMapper extends BaseMapper selectDue(@Param("now") LocalDateTime now, @Param("limit") int limit); @@ -59,8 +59,8 @@ public interface CarrierScheduleQueryMapper extends BaseMapper() + .eq(CarrierScheduleNodeFactEntity::getQueryId, queryId) + .eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey)); + } + public boolean hasActualNode(long queryId, String standardNode) { + return nodes.selectCount(new LambdaQueryWrapper() + .eq(CarrierScheduleNodeFactEntity::getQueryId, queryId) + .eq(CarrierScheduleNodeFactEntity::getStandardNode, standardNode) + .isNotNull(CarrierScheduleNodeFactEntity::getActualAt)) > 0; + } public List exceptions(long id) { return exceptions.selectList(new LambdaQueryWrapper() .eq(CarrierScheduleExceptionFactEntity::getQueryId, id) @@ -79,6 +90,11 @@ public class CarrierScheduleQueryRepository { public boolean insertIfAbsent(CarrierScheduleExceptionFactEntity value) { return exceptions.insertIfAbsent(value) == 1; } + public CarrierScheduleExceptionFactEntity exception(long queryId, String eventKey) { + return exceptions.selectOne(new LambdaQueryWrapper() + .eq(CarrierScheduleExceptionFactEntity::getQueryId, queryId) + .eq(CarrierScheduleExceptionFactEntity::getEventKey, eventKey)); + } public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) { return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java new file mode 100644 index 000000000..5f949eece --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java @@ -0,0 +1,75 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventIdentifiers; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** Writes actual carrier node facts to the domestic transactional Outbox. */ +@Service +public class CarrierScheduleNodeOutboxService { + @Resource private InboundOutboxMapper outbox; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") + private String scheduleZone = "Asia/Shanghai"; + + public void publish(CarrierScheduleQueryEntity query, CarrierScheduleNodeFactEntity fact, + String containerNo) { + if (fact.getActualAt() == null) return; + ZoneId zone = ZoneId.of(scheduleZone); + var payload = new FirstLegContainerShippingNodeRecordedV1( + string(query.getId()), string(query.getCustomerId()), string(query.getOceanBookingId()), + string(query.getContainerBookingId()), containerNo, fact.getStandardNode(), fact.getNodeSequence(), + fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(), + fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(), + fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash()); + JsonNode payloadNode = objectMapper.valueToTree(payload); + String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); + long aggregateVersion = Math.max(0, query.getVersion() == null ? 0 : query.getVersion()); + String eventKey = "CARRIER_NODE:" + query.getId() + ':' + fact.getId(); + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), + EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, + fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS, + string(query.getCustomerId()), "CONTAINER_BOOKING", string(query.getContainerBookingId()), + aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null, + payloadNode, payloadHash).requireValid(); + + InboundOutboxEntity row = new InboundOutboxEntity(); + row.setId(IdWorker.getId()); row.setTenantId(query.getCustomerId()); + row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId()); + row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1); + row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash); + row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0); + row.setCreatedAt(LocalDateTime.now()); + try { outbox.insert(row); } catch (DuplicateKeyException ignored) { } + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { + throw new BizException(ResultCode.INTERNAL_ERROR, "船节点交接事件序列化失败"); + } + } + + private static String string(Long value) { return value == null ? null : String.valueOf(value); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java index 1a497bbc3..3f746a807 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java @@ -71,22 +71,24 @@ public class CarrierScheduleQueryScheduler { return; } var result = response.data(); - List observations = result.observations() == null - ? List.of() : result.observations(); - for (var observation : observations) { - service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest( - result.providerCode(), observation.providerEventId(), observation.providerNodeCode(), - observation.standardNode(), observation.observedAt(), observation.estimatedAt(), - observation.actualAt(), observation.locationCode(), observation.locationName(), - observation.sourceReference(), observation.rawPayloadHash())); - } - List exceptions = result.exceptions() == null - ? List.of() : result.exceptions(); - for (var exception : exceptions) { - service.applyProviderException(candidate.getId(), new ProviderExceptionRequest( - result.providerCode(), exception.providerEventId(), exception.providerStatus(), - exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(), - exception.locationName(), exception.sourceReference(), exception.rawPayloadHash())); + if ("SUCCESS".equals(result.outcome())) { + List observations = result.observations() == null + ? List.of() : result.observations(); + for (var observation : observations) { + service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest( + result.providerCode(), observation.providerEventId(), observation.providerNodeCode(), + observation.standardNode(), observation.observedAt(), observation.estimatedAt(), + observation.actualAt(), observation.locationCode(), observation.locationName(), + observation.sourceReference(), observation.rawPayloadHash())); + } + List exceptions = result.exceptions() == null + ? List.of() : result.exceptions(); + for (var exception : exceptions) { + service.applyProviderException(candidate.getId(), new ProviderExceptionRequest( + result.providerCode(), exception.providerEventId(), exception.providerStatus(), + exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(), + exception.locationName(), exception.sourceReference(), exception.rawPayloadHash())); + } } service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(), result.errorMessage()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index a0c6230b6..4ab755ac0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -59,6 +59,7 @@ public class CarrierScheduleQueryService { @Resource private CarrierScheduleQueryRepository repository; @Resource private PortScheduleRepository portSchedules; @Resource private CarrierScheduleAdapterRegistry adapters; + @Resource private CarrierScheduleNodeOutboxService nodeOutbox; @Resource private ObjectMapper objectMapper; @Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") private String scheduleZone = "Asia/Shanghai"; @@ -127,9 +128,10 @@ public class CarrierScheduleQueryService { if (concurrent != null) return concurrent; throw exception; } - addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", + CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", sha256("OUTBOUND_CONFIRM|" + containerBookingId)); + if (departed != null) nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId)); addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value, operatorId, operatorName); return value; @@ -140,14 +142,17 @@ public class CarrierScheduleQueryService { CarrierScheduleQueryEntity value = requiredLocked(id); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); CarrierScheduleQueryEntity before = copy(value); - if (!addNode(id, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node, + CarrierScheduleNodeFactEntity fact = addNode(id, request.providerCode(), request.providerEventId(), + request.providerNodeCode(), node, request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), - request.locationName(), request.sourceReference(), request.rawPayloadHash())) { + request.locationName(), request.sourceReference(), request.rawPayloadHash()); + if (fact == null) { return detail(value); } boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), 0L, "carrier-scheduler"); CarrierScheduleQueryEntity after = advanced ? required(id) : value; + nodeOutbox.publish(after, fact, requireContainerNo(after.getContainerBookingId())); addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before, after, 0L, "carrier-scheduler"); return detail(after); @@ -180,9 +185,11 @@ public class CarrierScheduleQueryService { CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); CarrierScheduleQueryEntity before = copy(value); String providerEventId = sha256("MANUAL|" + id + '|' + key); - if (!addNode(id, "MANUAL", providerEventId, node.name(), node, request.observedAt(), + CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node, + request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(), - "MANUAL:" + operatorId, hash(operation, id, request))) { + "MANUAL:" + operatorId, hash(operation, id, request)); + if (fact == null) { return complete(id, operatorId, key, detail(value)); } boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), @@ -191,6 +198,7 @@ public class CarrierScheduleQueryService { throw concurrent(); } CarrierScheduleQueryEntity after = required(id); + nodeOutbox.publish(after, fact, requireContainerNo(after.getContainerBookingId())); addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after, operatorId, operatorName); return complete(id, operatorId, key, detail(after)); @@ -221,6 +229,12 @@ public class CarrierScheduleQueryService { return false; } + public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) { + CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId, + containerBookingId); + return query != null && repository.hasActualNode(query.getId(), CarrierScheduleNode.DEPARTED_WAREHOUSE.name()); + } + @Transactional public boolean deferMissedNormalSlot(long id, LocalDateTime scanAt) { CarrierScheduleQueryEntity value = requiredLocked(id); @@ -426,7 +440,8 @@ public class CarrierScheduleQueryService { value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()); } - private boolean addNode(long queryId, String providerCode, String providerEventId, String providerNodeCode, + private CarrierScheduleNodeFactEntity addNode(long queryId, String providerCode, String providerEventId, + String providerNodeCode, CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, String locationCode, String locationName, String sourceReference, String rawPayloadHash) { @@ -439,7 +454,12 @@ public class CarrierScheduleQueryService { fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt); fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); - return repository.insertIfAbsent(fact); + if (repository.insertIfAbsent(fact)) return fact; + CarrierScheduleNodeFactEntity existing = repository.node(queryId, fact.getEventKey()); + if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) { + throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实"); + } + return null; } private boolean addException(long queryId, String providerCode, String providerEventId, @@ -454,7 +474,20 @@ public class CarrierScheduleQueryService { fact.setExceptionCode(code.name()); fact.setActive(active); fact.setObservedAt(observedAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); - return repository.insertIfAbsent(fact); + if (repository.insertIfAbsent(fact)) return true; + CarrierScheduleExceptionFactEntity existing = repository.exception(queryId, fact.getEventKey()); + if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) { + throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有异常事实"); + } + return false; + } + + private String requireContainerNo(long containerBookingId) { + ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); + if (container == null || !StringUtils.hasText(container.getContainerNo())) { + throw conflict("跨区船节点交接要求柜次具备柜号"); + } + return container.getContainerNo().trim().toUpperCase(Locale.ROOT); } private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java index 8bc8cf9f3..8ae36d4cb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java @@ -60,7 +60,8 @@ public class InboundMeasurementRepository { .eq(InboundOrderEntity::getTenantId, inbound.getTenantId()) .eq(InboundOrderEntity::getId, inbound.getId()) .set(InboundOrderEntity::getActualGrossWeightKg, inbound.getActualGrossWeightKg()) - .set(InboundOrderEntity::getActualNetWeightKg, inbound.getActualNetWeightKg())) == 1; + .set(InboundOrderEntity::getActualNetWeightKg, inbound.getActualNetWeightKg()) + .set(InboundOrderEntity::getPalletCount, inbound.getPalletCount())) == 1; } public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java index 3d9de0415..e11916d90 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -15,6 +15,7 @@ import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; import com.baiyue.firstleg.entity.InboundPickupSnapshotEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.mapper.InboundFileMapper; import com.baiyue.firstleg.mapper.InboundInternalNoteMapper; import com.baiyue.firstleg.mapper.InboundOrderMapper; @@ -22,6 +23,7 @@ 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.springframework.stereotype.Repository; import org.springframework.util.StringUtils; @@ -48,6 +50,8 @@ public class InboundOrderRepository { private InboundInternalNoteMapper notes; @Resource private InboundStatusHistoryMapper histories; + @Resource + private InboundWeightResultMapper weights; public PageResult page(Long tenantId, InboundOrderQuery query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() @@ -287,6 +291,18 @@ public class InboundOrderRepository { Collectors.joining("、")))); } + public Map confirmedPalletCountsMap(Long tenantId, Map tenantByInbound) { + if (tenantByInbound.isEmpty()) return Map.of(); + return weights.selectList(new LambdaQueryWrapper() + .eq(tenantId != null, InboundWeightResultEntity::getTenantId, tenantId) + .in(InboundWeightResultEntity::getInboundId, tenantByInbound.keySet()) + .eq(InboundWeightResultEntity::getRecognitionStatus, "CONFIRMED")) + .stream() + .filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) + .collect(Collectors.groupingBy(InboundWeightResultEntity::getInboundId, + java.util.LinkedHashMap::new, Collectors.summingInt(item -> 1))); + } + private static boolean owns(Map tenantByInbound, Long inboundId, Long tenantId) { return inboundId != null && tenantId != null && tenantId.equals(tenantByInbound.get(inboundId)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java index cb8935edd..403f5a5c1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java @@ -13,11 +13,14 @@ import java.util.List; @Repository public class LoadingCompletedOutboxRepository { + private static final List CROSS_REGION_EVENT_TYPES = List.of( + EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, + EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED); @Resource private InboundOutboxMapper mapper; public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { return mapper.selectList(new LambdaQueryWrapper() - .eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt) .or().le(InboundOutboxEntity::getNextRetryAt, now)) @@ -29,15 +32,17 @@ public class LoadingCompletedOutboxRepository { public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) { return mapper.update(null, new LambdaUpdateWrapper() .eq(InboundOutboxEntity::getId, id) - .eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") + .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt) + .or().le(InboundOutboxEntity::getNextRetryAt, now)) .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) .set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker) .set(InboundOutboxEntity::getLockedAt, now)) == 1; } public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper() - .eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); } public void published(long id, String worker, LocalDateTime now) { mapper.update(null, claimedBase(id, worker) .set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now) @@ -48,7 +53,7 @@ public class LoadingCompletedOutboxRepository { .set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null) .set(InboundOutboxEntity::getLockedAt, null).set(InboundOutboxEntity::getLastError, truncate(error))); } private LambdaUpdateWrapper base(long id, String status) { return new LambdaUpdateWrapper() - .eq(InboundOutboxEntity::getId,id).eq(InboundOutboxEntity::getEventType,EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .eq(InboundOutboxEntity::getId,id).in(InboundOutboxEntity::getEventType,CROSS_REGION_EVENT_TYPES) .eq(InboundOutboxEntity::getPublishStatus,status); } private LambdaUpdateWrapper claimedBase(long id,String worker) { return base(id,"PROCESSING").eq(InboundOutboxEntity::getLockedBy,worker); } private static String truncate(String value) { if(value==null)return "unknown"; String v=value.replaceAll("[\\r\\n\\t]+"," "); return v.length()>500?v.substring(0,500):v; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index 5d48d045d..73c5bf050 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -6,11 +6,13 @@ 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.mapper.ContainerBookingMapper; import com.baiyue.firstleg.mapper.LoadingPlanHistoryMapper; import com.baiyue.firstleg.mapper.LoadingPlanItemMapper; import com.baiyue.firstleg.mapper.LoadingPlanMapper; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper; +import com.baiyue.firstleg.mapper.InboundOrderMapper; import org.springframework.stereotype.Repository; import jakarta.annotation.Resource; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -26,10 +28,16 @@ public class SmartLoadingRepository { @Resource private LoadingPlanHistoryMapper histories; @Resource private ContainerBookingMapper bookings; @Resource private SmartLoadingCandidateMapper candidates; + @Resource private InboundOrderMapper inbounds; public List candidates(long warehouseId) { return candidates.candidates(warehouseId); } - public Candidate candidate(long warehouseId, long inboundId, long detailId) { - return candidates.candidate(warehouseId, inboundId, detailId); + public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) { + return candidates.candidate(planId, warehouseId, inboundId, detailId); + } + public InboundOrderEntity inbound(long accountId, long inboundId) { + return inbounds.selectOne(new LambdaQueryWrapper() + .eq(InboundOrderEntity::getTenantId, accountId) + .eq(InboundOrderEntity::getId, inboundId)); } public List activeItemsByInbound(long planId, long accountId, long inboundId) { return items.selectList(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java index 64a88398a..679368549 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java @@ -41,7 +41,7 @@ public class FeePaymentSnapshotValidator { } return Snapshot.empty(); } - if (!"PAYABLE".equals(direction) || !MODES.contains(mode)) { + if (!"PAYABLE".equals(direction) || mode == null || !MODES.contains(mode)) { throw bad("应付费用必须选择ON_ACCOUNT或DIRECT_PAID"); } if ("ON_ACCOUNT".equals(mode)) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index aa46edbec..6fecfa3cd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -330,6 +330,7 @@ public class InboundMeasurementService { repository.listWeights(inbound.getTenantId(), inbound.getId())); inbound.setActualGrossWeightKg(totals.grossWeightKg()); inbound.setActualNetWeightKg(totals.netWeightKg()); + inbound.setPalletCount(totals.palletCount()); if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) { inbound.setProcessStatus("MEASURING"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index 193940b28..ac82be20b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -68,6 +68,7 @@ public class InboundOrderCreationService { inbound.setDeliveryWarehouseId(command.deliveryWarehouseId()); inbound.setDeliveryWarehouseName(command.deliveryWarehouseName()); inbound.setBusinessType(command.businessType()); + inbound.setFulfillmentType(fulfillmentType(command.fulfillmentType())); inbound.setChannelCode(command.channelCode()); inbound.setChannelName(command.channelName()); inbound.setCarrierId(command.carrierId()); @@ -160,6 +161,7 @@ public class InboundOrderCreationService { .set(InboundOrderEntity::getDeliveryWarehouseName, command.deliveryWarehouseName()) .set(InboundOrderEntity::getExpectedArrivalAt, command.expectedArrivalAt()) .set(InboundOrderEntity::getBusinessType, command.businessType()) + .set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType())) .set(InboundOrderEntity::getChannelCode, command.channelCode()) .set(InboundOrderEntity::getChannelName, command.channelName()) .set(InboundOrderEntity::getDestinationType, command.destinationType()) @@ -181,6 +183,7 @@ public class InboundOrderCreationService { .set(InboundOrderEntity::getProcessStatus, "WAIT_MEASURE") .set(InboundOrderEntity::getOrderStatusSnapshot, "APPROVED") .set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount()) + .set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType())) .set(InboundOrderEntity::getUpdatedAt, LocalDateTime.now()) .setSql("version = version + 1"); if (orders.update(null, update) != 1) { @@ -217,6 +220,7 @@ public class InboundOrderCreationService { inbound.setDeliveryWarehouseId(command.deliveryWarehouseId()); inbound.setDeliveryWarehouseName(command.deliveryWarehouseName()); inbound.setBusinessType(command.businessType()); + inbound.setFulfillmentType(fulfillmentType(command.fulfillmentType())); inbound.setChannelCode(command.channelCode()); inbound.setChannelName(command.channelName()); inbound.setCarrierId(command.carrierId()); @@ -241,6 +245,12 @@ public class InboundOrderCreationService { return inbound; } + private static String fulfillmentType(String value) { + if ("STANDARD".equals(value) || "ONE_CLICK_DROPSHIP".equals(value)) return value; + throw new BizException(ResultCode.BAD_REQUEST, + "履约类型必须为 STANDARD 或 ONE_CLICK_DROPSHIP"); + } + private void addHistory(long inboundId, long tenantId, String from, String to, String action, LocalDateTime now) { InboundStatusHistoryEntity history = new InboundStatusHistoryEntity(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java index 30bf94202..f01597951 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java @@ -1,5 +1,6 @@ package com.baiyue.firstleg.service; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventEnvelopeValidator; import com.baiyue.firstleg.client.EventBridgeClient; @@ -15,7 +16,7 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.UUID; -/** 至少一次投递装柜完成跨区事件;事件桥按 eventId 幂等接收。 */ +/** 至少一次投递国内头程跨区事件;事件桥按 eventId + payloadHash 幂等接收。 */ @Component @Slf4j public class LoadingCompletedOutboxRelay { @Resource private LoadingCompletedOutboxRepository repository; @@ -38,22 +39,48 @@ public class LoadingCompletedOutboxRelay { EventEnvelope event=objectMapper.readValue(row.getPayloadJson(),EventEnvelope.class); EventEnvelopeValidator.requireValid(event); var response=client.receive(internalToken,event); - if(response==null || !response.successful() || response.data()==null - || !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件"); + requireAcknowledgement(event, response); repository.published(row.getId(),workerId,LocalDateTime.now()); } catch(Exception exception) { String error=safeError(exception); repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error); - log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),error); + log.warn("头程跨区事件投递失败: eventType={}, eventKey={}, reason={}", + row.getEventType(), row.getEventKey(), error); } } } + private void requireAcknowledgement(EventEnvelope event, + ApiResponse response) { + if(response==null) throw new IllegalStateException("事件桥未返回响应"); + if(!response.successful()) { + throw new IllegalStateException("事件桥拒绝头程跨区事件: code=" + response.code() + + ", message=" + response.message() + ", requestId=" + response.requestId() + + ", traceId=" + response.traceId()); + } + if(response.data()==null || !event.eventId().equals(response.data().eventId())) { + throw new IllegalStateException("事件桥 ACK 不匹配: expectedEventId=" + event.eventId() + + ", actualEventId=" + (response.data()==null?null:response.data().eventId())); + } + } + private String safeError(Exception exception) { - String value=exception.getMessage(); - if(value==null || value.isBlank()) value=exception.getClass().getSimpleName(); + Throwable diagnostic=diagnosticCause(exception); + String message=diagnostic.getMessage(); + String value=diagnostic.getClass().getSimpleName() + + ((message==null || message.isBlank())?"":": " + message); if(internalToken!=null && !internalToken.isBlank()) value=value.replace(internalToken,"***"); value=value.replaceAll("[\\r\\n\\t]+"," "); return value.length()>500?value.substring(0,500):value; } + + private Throwable diagnosticCause(Throwable failure) { + Throwable current=failure; + while(current.getCause()!=null && current.getCause()!=current + && ("NoFallbackAvailableException".equals(current.getClass().getSimpleName()) + || "No fallback available.".equals(current.getMessage()))) { + current=current.getCause(); + } + return current; + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 2d42ae32c..d2c706779 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -16,6 +16,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -30,9 +31,11 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; /** 在装柜完成事务内按客户写跨区业务 Outbox。 */ @Service @@ -88,8 +91,10 @@ public class LoadingCompletedOutboxService { row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark(), null, cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of()))) .toList(); - String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; - List pallets = palletSnapshots(sorted, palletRows); + String fulfillmentType = fulfillmentType(sorted); + String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; + List pallets = dropship(fulfillmentType) + ? palletSnapshots(sorted, palletRows) : List.of(); if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) { throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照"); } @@ -98,40 +103,86 @@ public class LoadingCompletedOutboxService { string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()), first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount), sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType) - ? pallets.size() : sumNullableInt(sorted, Row::getPalletCount), sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), - sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets); + ? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), + sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets, + destinationType(sorted)); } /** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */ private Map> cartonLabelPools(List rows) { Map> result = new java.util.LinkedHashMap<>(); - for (Row row : rows) { - if (dropship(row.getBusinessType())) continue; - if (row.getSkuCode() == null || row.getSkuCode().isBlank()) { + Map> byInbound = rows.stream().filter(row -> !dropship(row.getFulfillmentType())) + .filter(row -> "OVERSEAS".equals(destinationType(List.of(row)))) + .collect(java.util.stream.Collectors.groupingBy( + row -> new LabelPoolKey(row.getAccountId(), row.getSourceInboundId()), + LinkedHashMap::new, java.util.stream.Collectors.toList())); + for (Map.Entry> entry : byInbound.entrySet()) { + List inboundRows = entry.getValue().stream() + .sorted(Comparator.comparing(Row::getLoadingPlanItemId)).toList(); + if (inboundRows.stream().anyMatch(row -> row.getSkuCode() == null || row.getSkuCode().isBlank())) { throw new BizException(ResultCode.CONFLICT, "装柜明细缺少 SKU,不能建立箱唛与 SKU 的对应关系"); } - List labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool(row.getAccountId(), - row.getSourceInboundId(), row.getSourceInboundDetailId()), List.of()); - if (labels.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件"); - if (labels.stream().anyMatch(label -> label == null || label.isBlank()) - || labels.stream().distinct().count() != labels.size()) { + List labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool( + entry.getKey().accountId(), entry.getKey().inboundId()), List.of()); + if (labels.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件"); + } + if (labels.stream().anyMatch(label -> label == null || label.getBoxCode() == null + || label.getBoxCode().isBlank()) + || labels.stream().map(LabelRow::getBoxCode).distinct().count() != labels.size()) { throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛池包含空值或重复箱号"); } - if (labels.size() < Objects.requireNonNullElse(row.getPackageCount(), 0)) { - throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛数量少于实装箱数,不能生成海外交接事件"); + Set used = new HashSet<>(); + for (Row row : inboundRows) { + int loadedPackages = Objects.requireNonNullElse(row.getPackageCount(), 0); + String seed = row.getLoadingPlanId() + ":" + row.getLoadingPlanItemId(); + Comparator stableOrder = Comparator.comparing(label -> EventPayloadHash.sha256( + (seed + ":" + label.getBoxCode()).getBytes(StandardCharsets.UTF_8))); + List selected = new ArrayList<>(); + labels.stream().filter(label -> !used.contains(label.getBoxCode()) + && Objects.equals(label.getInboundDetailId(), row.getSourceInboundDetailId())) + .sorted(stableOrder).limit(loadedPackages).map(LabelRow::getBoxCode).forEach(selected::add); + if (selected.size() < loadedPackages) { + labels.stream().filter(label -> !used.contains(label.getBoxCode()) + && !selected.contains(label.getBoxCode())) + .sorted(stableOrder).limit(loadedPackages - selected.size()) + .map(LabelRow::getBoxCode).forEach(selected::add); + } + if (selected.size() < loadedPackages) { + throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛数量少于实装箱数,不能生成海外交接事件"); + } + used.addAll(selected); + result.put(row.getLoadingPlanItemId(), List.copyOf(selected)); } - int loadedPackages = Objects.requireNonNullElse(row.getPackageCount(), 0); - String seed = row.getLoadingPlanId() + ":" + row.getLoadingPlanItemId(); - List selected = labels.stream() - .sorted(Comparator.comparing(label -> EventPayloadHash.sha256( - (seed + ":" + label).getBytes(StandardCharsets.UTF_8)))) - .limit(loadedPackages) - .toList(); - result.put(row.getLoadingPlanItemId(), selected); } return result; } + private String destinationType(List rows) { + Set values = rows.stream().map(Row::getDestinationType) + .map(value -> value == null ? "" : value.trim().toUpperCase(java.util.Locale.ROOT)) + .collect(java.util.stream.Collectors.toSet()); + if (values.size() != 1 || (!values.contains("FBA") && !values.contains("OVERSEAS"))) { + throw new BizException(ResultCode.CONFLICT, + "装柜入仓单目的类型必须唯一且为 FBA 或 OVERSEAS,不能生成海外交接事件"); + } + return values.iterator().next(); + } + + private String fulfillmentType(List rows) { + Set values = rows.stream().map(Row::getFulfillmentType) + .map(value -> value == null || value.isBlank() ? "STANDARD" : value) + .collect(java.util.stream.Collectors.toSet()); + if (values.size() != 1 + || (!values.contains("STANDARD") && !values.contains("ONE_CLICK_DROPSHIP"))) { + throw new BizException(ResultCode.CONFLICT, + "装柜入仓单履约类型必须唯一且为 STANDARD 或 ONE_CLICK_DROPSHIP"); + } + return values.iterator().next(); + } + + private record LabelPoolKey(long accountId, long inboundId) { } + private List palletSnapshots(List values, List source) { if (source.isEmpty()) return List.of(); Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( @@ -185,8 +236,7 @@ public class LoadingCompletedOutboxService { } private static boolean dropship(String value) { - return value != null && ("ONE_CLICK_DROPSHIP".equalsIgnoreCase(value) - || value.toUpperCase(java.util.Locale.ROOT).contains("DROPSHIP")); + return "ONE_CLICK_DROPSHIP".equals(value); } private record DetailSnapshot(Long detailId, String skuCode, String productName, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 6fda57d36..4faf88928 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -450,16 +450,20 @@ public class LoadingPickService { throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成"); } if (repository.review(taskId) != null) throw conflict("PDA任务已经确认装柜完成"); - List owners = repository.inboundOwners(taskId).stream() + List discoveredOwners = repository.inboundOwners(taskId).stream() .filter(owner -> owner.tenantId() != null && owner.inboundId() != null) + .toList(); + if (discoveredOwners.isEmpty()) throw conflict("PDA任务未关联有效入库单"); + LoadingPickItemMapper.InboundOwner evidenceOwner = discoveredOwners.get(0); + List owners = discoveredOwners.stream() .sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId) .thenComparing(LoadingPickItemMapper.InboundOwner::inboundId)) .toList(); - if (owners.isEmpty()) throw conflict("PDA任务未关联有效入库单"); for (LoadingPickItemMapper.InboundOwner owner : owners) { LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId()); if (snapshot == null) throw conflict("PDA任务关联入库单不存在"); - loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId()); + loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(), + evidenceOwner.inboundId()); } settlementService.settle(taskId, operatorId, operatorName); if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) { @@ -472,8 +476,9 @@ public class LoadingPickService { loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName); repository.syncInboundProjections(taskId, operatorId, operatorName); for (LoadingPickItemMapper.InboundOwner owner : owners) { - loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), operatorId, - operatorName, "PDA-" + operationId + '-' + owner.inboundId(), remark); + loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), + evidenceOwner.inboundId(), operatorId, operatorName, + "PDA-" + operationId + '-' + owner.inboundId(), remark); } loadingListService.publishCompletedEvent(task.getLoadingPlanId()); completeTruckAfterCommit(task.getContainerBookingId(), taskId, operatorId, operatorName); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java index 7207d64e0..f7b6d0a8e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java @@ -185,6 +185,9 @@ public class PdaPickSettlementService { requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId, orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(), remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单"); + splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId, + orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount); + splits.normalizeLoadedCartonLabels(parent.tenantId(), parentInboundId, pickedPackageCount); } requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId, idempotencyKey, rootVersion, parentAvailable, null, remainderChildId == 0L ? null : remainderChildId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index e902a6e79..c03fafb0b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -132,12 +132,19 @@ public class SmartLoadingService { if (replay != null) return replay; LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); - Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId()); + Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), request.inboundId(), + request.inboundDetailId()); if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); - splitPreloadService.ensurePendingForLoadingAllocation(candidate.accountId(), candidate.inboundId(), - operatorId, operatorName); + var task = loadingPickService.mutationSnapshot(planId); + if (task.taskId() != null && "LOADING".equals(task.taskStatus())) { + splitPreloadService.ensureForRunningLoadingAllocation(candidate.accountId(), candidate.inboundId(), + operatorId, operatorName); + } else { + splitPreloadService.ensurePendingForLoadingAllocation(candidate.accountId(), candidate.inboundId(), + operatorId, operatorName); + } OrderCargoClient.CargoBinding cargo = loadCargo(candidate); LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId()); int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); @@ -156,7 +163,8 @@ public class SmartLoadingService { } else { existing.setPackageCount(total); existing.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(total))); existing.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(total))); - existing.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(total))); applyCargo(existing, cargo, total); + existing.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(total))); + existing.setPalletCount(estimatedPalletCount(candidate, total)); applyCargo(existing, cargo, total); existing.setUpdatedBy(operatorId); existing.setUpdatedByName(operatorName); if (repository.update(existing) != 1) throw conflict("装柜草稿已被并发修改"); changed = existing; @@ -293,8 +301,15 @@ public class SmartLoadingService { LoadingPlanItemEntity item = repository.item(planId, itemId); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); + LoadingPickService.MutationSnapshot pickSnapshot = loadingPickService.mutationSnapshot(planId); + int pickedPackageCount = pickSnapshot == null ? 0 + : pickSnapshot.pickedByPlanItem().getOrDefault(itemId, 0); + if (request.packageCount() < pickedPackageCount) { + throw conflict("计划箱数不能低于已拣货箱数"); + } ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); - Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId()); + Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), item.getInboundId(), + item.getInboundDetailId()); if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数"); requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); @@ -306,7 +321,8 @@ public class SmartLoadingService { item.setPackageCount(request.packageCount()); item.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); item.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); - item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); applyCargo(item, cargo, request.packageCount()); + item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); + item.setPalletCount(estimatedPalletCount(candidate, request.packageCount())); applyCargo(item, cargo, request.packageCount()); item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName); if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改"); history(planId, itemId, "ADJUST_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation, @@ -437,8 +453,16 @@ public class SmartLoadingService { i.setGrossWeightKg(unit(c.unitGrossWeightKg()).multiply(BigDecimal.valueOf(count))); i.setNetWeightKg(unit(c.unitNetWeightKg()).multiply(BigDecimal.valueOf(count))); i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count))); + i.setPalletCount(estimatedPalletCount(c, count)); applyCargo(i, cargo, count); i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i; } + private int estimatedPalletCount(Candidate candidate, int packageCount) { + InboundOrderEntity inbound = repository.inbound(candidate.accountId(), candidate.inboundId()); + if (inbound == null || inbound.getPalletCount() == null || inbound.getPalletCount() <= 0 + || inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() <= 0) return 0; + return BigDecimal.valueOf(inbound.getPalletCount()).multiply(BigDecimal.valueOf(packageCount)) + .divide(BigDecimal.valueOf(inbound.getTotalPackageCount()), 0, RoundingMode.CEILING).intValueExact(); + } private void applyCargo(LoadingPlanItemEntity item, OrderCargoClient.CargoBinding cargo, int boxes) { item.setSkuCode(cargo.skuNo()); item.setProductNameEn(cargo.cargoNameEn()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index 219b72682..4f3bd8f81 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -56,6 +56,8 @@ public class UnifiedInboundQueryService { public UnifiedInboundDetail detail(long tenantId, long orderId) { InboundOrderEntity inbound = inbounds.findByOrderId(tenantId, orderId); if (inbound == null) return null; + applyConfirmedPalletCount(inbound, inbounds.confirmedPalletCountsMap(tenantId, + Map.of(inbound.getId(), inbound.getTenantId()))); InboundOrderDetail detail = inbounds.detail(tenantId, inbound); String mainStatus = mainStatus(inbound.getProcessStatus()); boolean includePhaseThree = requiresPhaseThreeSummary(mainStatus); @@ -93,6 +95,8 @@ public class UnifiedInboundQueryService { var transports = operations.findTransports(phaseThreeOwners); Map owners = records.stream().collect(Collectors.toMap( InboundOrderEntity::getId, InboundOrderEntity::getTenantId, (left, right) -> left)); + var confirmedPalletCounts = inbounds.confirmedPalletCountsMap(null, owners); + records.forEach(inbound -> applyConfirmedPalletCount(inbound, confirmedPalletCounts)); var locationCodes = inbounds.locationCodesMap(null, owners); var pickups = inbounds.pickupMap(null, owners); return records.stream().map(inbound -> summary(inbound, null, preloads.get(inbound.getId()), @@ -101,6 +105,14 @@ public class UnifiedInboundQueryService { transports.get(inbound.getId()), locationCodes.get(inbound.getId()), pickups.get(inbound.getId()))).toList(); } + private static void applyConfirmedPalletCount(InboundOrderEntity inbound, + Map confirmedPalletCounts) { + if ((inbound.getPalletCount() == null || inbound.getPalletCount() <= 0) + && confirmedPalletCounts.getOrDefault(inbound.getId(), 0) > 0) { + inbound.setPalletCount(confirmedPalletCounts.get(inbound.getId())); + } + } + private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail, com.baiyue.firstleg.split.entity.PreloadPlanEntity preload, com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 70146cf38..4162cc757 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -68,11 +68,35 @@ public class SplitPreloadService { @Transactional public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId, String operatorName) { + ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, false); + } + + /** + * A running PDA task may restore or add an unpicked allocation after the plan-level preload + * confirmation. CONFIRMING remains immutable; CONFIRMED is accepted because the caller owns + * the active PDA task lock and will synchronise the restored loading item immediately. + */ + @Transactional + public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId, + String operatorName) { + ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, true); + } + + private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId, + String operatorName, boolean running) { InboundSnapshot inbound = requireInbound(tenantId, inboundId); - requireWaitingSplit(inbound, "仅待分柜订单可以建立智能装柜预装计划"); PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); + boolean waitingSplit = isWaitingSplit(inbound.processStatus()); + boolean loading = "LOADING".equals(inbound.processStatus()); + if (!waitingSplit && !(running && loading)) { + throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以建立智能装柜预装计划"); + } + if (loading && plan == null) { + throw new BizException(ResultCode.CONFLICT, "装柜中的订单缺少既有预装计划,不能新增分配"); + } if (plan != null) { - if (!PreloadStatus.PENDING.equals(plan.getStatus())) { + if (!PreloadStatus.PENDING.equals(plan.getStatus()) + && !(running && PreloadStatus.CONFIRMED.equals(plan.getStatus()))) { throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配"); } return; diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index ed6994938..1a5342f3a 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -44,7 +44,7 @@ baiyue: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} lastleg: - event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312} + event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:} clients: order: url: ${ORDER_SERVICE_URL:} diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index df2b3f30c..902aede82 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID', delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', channel_code VARCHAR(64) NULL COMMENT '渠道编码', channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID', @@ -77,6 +78,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; +SET @inbound_fulfillment_type_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND column_name = 'fulfillment_type' + ), + 'SELECT 1', + 'ALTER TABLE fl_inbound_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER business_type' +); +PREPARE inbound_fulfillment_type_stmt FROM @inbound_fulfillment_type_sql; +EXECUTE inbound_fulfillment_type_stmt; +DEALLOCATE PREPARE inbound_fulfillment_type_stmt; + +UPDATE fl_inbound_order + SET fulfillment_type = 'STANDARD' + WHERE fulfillment_type IS NULL OR fulfillment_type = ''; +ALTER TABLE fl_inbound_order + MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' + COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'; + -- 既有开发库若存在跨客户重复码则严格失败,禁止自动覆盖或改写入仓码。 SET @inbound_global_code_index_sql = IF( NOT EXISTS( diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index f5bf83b25..f37916125 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -181,6 +181,26 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( CONSTRAINT chk_carrier_schedule_operation_status CHECK (status IN ('PROCESSING','SUCCESS')) ) ENGINE=InnoDB COMMENT='船司查询写操作幂等与请求摘要'; +-- CREATE TABLE IF NOT EXISTS 不会更新已有表的 CHECK。历史开发库仍可能只允许 +-- CREATE/ATTEMPT/MANUAL_RESULT,必须在人工节点和自动节点写入前收敛为当前动作集。 +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check; +DELIMITER $$ +CREATE PROCEDURE migrate_carrier_schedule_history_action_check() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query_history' + AND constraint_name='chk_carrier_schedule_history_action' AND constraint_type='CHECK') THEN + ALTER TABLE fl_carrier_schedule_query_history DROP CHECK chk_carrier_schedule_history_action; + END IF; + ALTER TABLE fl_carrier_schedule_query_history + ADD CONSTRAINT chk_carrier_schedule_history_action CHECK + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED', + 'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP')); +END$$ +DELIMITER ; +CALL migrate_carrier_schedule_history_action_check(); +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check; + DROP TRIGGER IF EXISTS trg_carrier_schedule_query_before_insert; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index bd13ba015..ab4efc31c 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -813,14 +813,23 @@ BEGIN SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3)); ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN IF EXISTS (SELECT 1 FROM fl_loading_plan_item item - LEFT JOIN fl_loading_pick_item pick_item - ON pick_item.loading_plan_item_id = item.id - LEFT JOIN fl_loading_pick_task pick_task - ON pick_task.id = pick_item.pick_task_id - AND pick_task.loading_plan_id = OLD.id WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 - AND (pick_task.id IS NULL - OR pick_item.picked_package_count <> item.package_count)) THEN + AND NOT EXISTS ( + SELECT 1 FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id + WHERE pick_item.loading_plan_item_id = item.id + AND pick_task.loading_plan_id = OLD.id + AND pick_task.task_status = 'LOADING' + AND ( + (pick_item.item_status = 'PICKED' + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'PARTIAL' + AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'SKIPPED' + AND pick_item.picked_package_count = 0) + ) + )) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; END IF; SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); diff --git a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql index 8004ba31b..2740d4d5c 100644 --- a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql +++ b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql @@ -10,6 +10,9 @@ CREATE TABLE IF NOT EXISTS fl_preload_plan ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增', items_json JSON NOT NULL COMMENT '可编辑分柜明细JSON快照;人工确认后作为预装依据', remark VARCHAR(1000) NULL COMMENT '预装备注', + confirmed_by BIGINT UNSIGNED NULL COMMENT '确认预装的操作人ID', + confirmed_by_name VARCHAR(64) NULL COMMENT '确认预装的操作人姓名快照', + confirmed_at DATETIME(3) NULL COMMENT '预装确认时间', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建预装清单的操作人ID', created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID', @@ -62,6 +65,13 @@ BEGIN END$$ DELIMITER ; +CALL phase3_split_add_column('fl_preload_plan', 'confirmed_by', + 'BIGINT UNSIGNED NULL COMMENT ''确认预装的操作人ID'' AFTER remark'); +CALL phase3_split_add_column('fl_preload_plan', 'confirmed_by_name', + 'VARCHAR(64) NULL COMMENT ''确认预装的操作人姓名快照'' AFTER confirmed_by'); +CALL phase3_split_add_column('fl_preload_plan', 'confirmed_at', + 'DATETIME(3) NULL COMMENT ''预装确认时间'' AFTER confirmed_by_name'); + CALL phase3_split_add_column('fl_inbound_order', 'parent_inbound_id', 'BIGINT UNSIGNED NULL COMMENT ''生成本单的主入库单ID;仅二级子单有值'' AFTER id'); CALL phase3_split_add_column('fl_inbound_order', 'split_level', diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 5cf380ab9..4f6dd8f73 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -9,7 +9,7 @@ paths: summary: List official carrier sources and compliant access modes x-permission: platform:firstleg:port-schedule-query:list responses: - '200': {description: MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors} + '200': {description: 'MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors'} /api/platform/v1/carrier-schedule-queries: get: summary: Query carrier schedule tasks diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index c7a74e31f..aa91a88be 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -18,7 +18,7 @@ paths: description: 成功或业务失败 content: application/json: - schema: {$ref: '#/components/schemas/CustomerRuleVersionSummary'} + schema: {$ref: '#/components/schemas/CustomerRuleVersionListApiResponse'} '403': {description: 无权限} /api/platform/v1/fba-pricing/customer-rules/versions: post: @@ -231,6 +231,44 @@ components: VersionId: {name: versionId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} schemas: + CustomerRuleVersionListApiResponse: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: array + items: {$ref: '#/components/schemas/CustomerRuleVersionResource'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + CustomerRuleVersionResource: + type: object + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + ruleCode: {type: string} + ruleVersion: {type: integer} + customerId: {type: string, pattern: '^[1-9][0-9]*$'} + customerName: {type: string} + businessType: {type: string} + chargeMode: {type: string} + taxMode: {type: string} + conversionFactor: {type: number} + roundingScale: {type: integer} + roundingMode: {type: string} + currency: {type: string} + effectiveFrom: {type: string, format: date-time} + effectiveTo: {type: string, format: date-time, nullable: true} + status: {type: string} + remark: {type: string, nullable: true} + createdBy: {type: string, nullable: true} + createdAt: {type: string, format: date-time, nullable: true} + updatedBy: {type: string, nullable: true} + updatedAt: {type: string, format: date-time, nullable: true} + deleted: {type: boolean, nullable: true} + version: {type: integer, nullable: true} FeeSnapshotApiResponse: type: object required: [code, message, data] @@ -415,7 +453,7 @@ components: required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason] properties: expectedFeeVersion: {type: integer, minimum: 1} - chargeableQuantity: {type: number, exclusiveMinimum: 0, description: 计费量,单位沿用当前费用快照的 chargeUnit} + chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit} unitPrice: {type: number, minimum: 0, description: 人工确认单价} additionalAmount: {type: number, minimum: 0, multipleOf: 0.01} reason: {type: string, minLength: 1, maxLength: 500} @@ -432,7 +470,7 @@ components: businessType: {type: string, maxLength: 32} chargeMode: {type: string, enum: [KG, CBM]} taxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]} - conversionFactor: {type: number, exclusiveMinimum: 0, example: 167} + conversionFactor: {type: number, minimum: 0, exclusiveMinimum: true, example: 167} roundingScale: {type: integer, minimum: 0, maximum: 4} roundingMode: {type: string, enum: [UP, DOWN, CEILING, FLOOR, HALF_UP, HALF_DOWN, HALF_EVEN]} currency: {type: string, pattern: '^[A-Z]{3}$'} @@ -454,7 +492,7 @@ components: destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} destinationName: {type: string, maxLength: 128, nullable: true} chargeUnit: {type: string, enum: [KG, CBM]} - unitPrice: {type: number, exclusiveMinimum: 0} + unitPrice: {type: number, minimum: 0, exclusiveMinimum: true} currency: {type: string, pattern: '^[A-Z]{3}$'} additionalFee: {type: number, minimum: 0} effectiveFrom: {type: string, format: date-time} @@ -578,7 +616,7 @@ components: pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} chargeUnit: {type: string, enum: [KG, CBM]} weightFromKg: {type: number, minimum: 0, nullable: true} - unitPrice: {type: number, exclusiveMinimum: 0} + unitPrice: {type: number, minimum: 0, exclusiveMinimum: true} currency: {type: string, pattern: '^[A-Z]{3}$'} sourceSheetName: {type: string, maxLength: 128} sourceRowNumber: {type: integer, minimum: 1} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index e893811e8..652cd8003 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1112,7 +1112,6 @@ components: notificationStatus: {type: string, nullable: true} version: {type: integer, nullable: true} expectedArrivalAt: {type: string, format: date-time, nullable: true} - requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from fl_inbound_pickup_snapshot.pickup_at} customerId: {type: integer, format: int64, nullable: true} customerName: {type: string, nullable: true} companyId: {type: integer, format: int64, nullable: true} @@ -1122,6 +1121,7 @@ components: deliveryWarehouseId: {type: integer, format: int64, nullable: true} deliveryWarehouseName: {type: string, nullable: true} businessType: {type: string, nullable: true} + fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} taxMode: {type: string, nullable: true} @@ -1466,7 +1466,7 @@ components: adjustmentReason: {type: string, minLength: 1, maxLength: 500, nullable: true} OrderApprovedInboundCommand: type: object - required: [tenantId, orderId, inboundCode, customerName, warehouseId] + required: [tenantId, orderId, inboundCode, customerName, warehouseId, fulfillmentType] properties: tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID} orderId: {type: string, pattern: '^[1-9][0-9]*$'} @@ -1480,6 +1480,7 @@ components: deliveryWarehouseName: {type: string, nullable: true} expectedArrivalAt: {type: string, format: date-time, nullable: true} businessType: {type: string, nullable: true} + fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], description: 独立履约类型;不得复用计费 businessType} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} carrierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 历史兼容的内部运输供应商快照} @@ -1810,6 +1811,7 @@ components: deliveryWarehouseId: {type: string, nullable: true} deliveryWarehouseName: {type: string, nullable: true} businessType: {type: string, nullable: true} + fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} containerMode: {type: string, nullable: true} diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml index 60e832d45..3b70a9548 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -34,7 +34,7 @@ paths: content: application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}} responses: - '200': {description: Business envelope; ownership, type and version failures use business error codes} + '200': {description: 'Business envelope; ownership, type and version failures use business error codes'} /api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda: get: summary: Download one loading evidence photo for the active PDA worker diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index db431dac1..8553a2767 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -60,7 +60,8 @@ paths: '200': {description: Business envelope; gate or version failures use business error code} /api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch: post: - summary: Reverse WAITING_FOR_LOADING to LOADED + summary: Reverse WAITING_FOR_LOADING only before maritime tracking starts + description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so READY_FOR_DISPATCH cannot coexist with maritime tracking. parameters: - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index eef1c0492..165d36799 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -93,7 +93,7 @@ paths: required: true content: {application/json: {schema: {$ref: '#/components/schemas/BatchRequest'}}} responses: - '200': {description: Completed task, identical replay result, or A0409 when any snapshot changed} + '200': {description: 'Completed task, identical replay result, or A0409 when any snapshot changed'} /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/review: post: summary: 人工确认装柜完成的兼容入口 diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 4a953b89d..0d2355f8f 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -8,7 +8,7 @@ paths: get: summary: Query truck appointments parameters: - - {name: customerId, in: query, description: Filter by customer, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: customerId, in: query, description: 'Filter by customer', schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: '200': description: Paged truck appointments with stable booking/container snapshots and attachment count @@ -29,13 +29,13 @@ paths: application/json: schema: {$ref: '#/components/schemas/SaveRequest'} responses: - '200': {description: Created appointment, content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} + '200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} /api/platform/v1/truck-appointments/{id}: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: summary: Query a truck appointment responses: - '200': {description: Appointment detail, content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}} + '200': {description: 'Appointment detail', content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}} put: summary: Update an editable truck appointment with optimistic locking requestBody: @@ -84,20 +84,20 @@ paths: get: summary: Query immutable appointment status history responses: - '200': {description: History entries including action, from/to status, operator, reason and time} + '200': {description: 'History entries including action, from/to status, operator, reason and time'} /api/platform/v1/truck-appointments/{id}/attachments: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: summary: Query attachment references owned by the appointment description: 'Requires platform:firstleg:truck-appointment:list.' responses: - '200': {description: Attachment references, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}} + '200': {description: 'Attachment references', content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}} post: summary: Reference a file already owned by the appointment customer and business scope description: 'Requires platform:firstleg:truck-appointment:edit. The file must belong to customerId + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId.' requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/AttachmentRequest'}}}} responses: - '200': {description: Created attachment reference, content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}} + '200': {description: 'Created attachment reference', content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}} /api/platform/v1/truck-appointments/{id}/attachments/upload: parameters: [{$ref: '#/components/parameters/AppointmentId'}] post: @@ -181,8 +181,8 @@ components: required: [customerId, carrierId, plannedLoadingAt, expectedVersion] properties: outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true} - oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.} - containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE container association. When supplied, customer and warehouse must match.} + oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: 'Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.'} + containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: 'Optional ACTIVE container association. When supplied, customer and warehouse must match.'} loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional loading-plan association. It may be supplied only with containerBookingId and must be that container's current plan.} warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional warehouse scope. Linked booking/container warehouse facts must agree; when omitted the linked fact is frozen if available.} customerId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE IAM customer used for isolation and attachment ownership.} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java index 30474a894..2b9409161 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java @@ -20,7 +20,8 @@ class BaiyueFirstlegApplicationContractTest { .contains("com.baiyue.firstleg.outbound.dispatch.mapper") .contains("com.baiyue.firstleg.portschedule.mapper") .contains("com.baiyue.firstleg.customs.mapper") - .contains("com.baiyue.firstleg.customerstatistics.mapper"); + .contains("com.baiyue.firstleg.customerstatistics.adapter.out.persistence") + .doesNotContain("com.baiyue.firstleg.customerstatistics.mapper"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java new file mode 100644 index 000000000..b22423e5f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.client; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EventBridgeClientDeploymentContractTest { + private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize(); + + @Test + void firstlegBridgeTargetComesFromEnvironmentWithoutLoopbackFallback() throws Exception { + String application = Files.readString(REPOSITORY_ROOT.resolve("firstleg/src/main/resources/application.yml")); + String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/firstleg-service.yml")); + + assertTrue(application.contains("event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:}")); + assertFalse(application.contains("LASTLEG_EVENT_BRIDGE_URL:http://127.0.0.1")); + assertTrue(nacos.contains("event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL}")); + } + + @Test + void cnComposeForwardsTargetAndPublishesStableNetworkAlias() throws Exception { + String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml")); + + assertTrue(compose.contains("LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL}")); + assertTrue(compose.contains("SERVER_PORT: 9312")); + assertTrue(compose.contains("aliases: [event-bridge-cn]")); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index 956e14f4a..a3362b6cc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -4,9 +4,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.web.bind.annotation.RequestParam; +import org.yaml.snakeyaml.Yaml; import java.nio.charset.StandardCharsets; import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -82,9 +85,57 @@ class FbaPricingContractTest { .doesNotContain("format: int64"); } + @Test + @DisplayName("客户规则列表响应锁定数组包络和实体核心字段") + void freezesCustomerRuleVersionListResponse() throws Exception { + String source = new ClassPathResource("openapi/fba-pricing-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + Map api = map(new Yaml().load(source)); + Map paths = map(api.get("paths")); + Map get = map(map(paths.get( + "/api/platform/v1/fba-pricing/customer-rules")).get("get")); + Map success = map(map(get.get("responses")).get("200")); + Map responseSchema = map(map(map(success.get("content")) + .get("application/json")).get("schema")); + assertThat(responseSchema).containsEntry( + "$ref", "#/components/schemas/CustomerRuleVersionListApiResponse"); + + Map schemas = map(map(api.get("components")).get("schemas")); + Map envelope = map(schemas.get("CustomerRuleVersionListApiResponse")); + assertThat(list(envelope.get("required"))).containsExactly("code", "message", "data", "timestamp"); + Map envelopeProperties = map(envelope.get("properties")); + assertThat(envelopeProperties).containsOnlyKeys( + "code", "message", "data", "requestId", "traceId", "timestamp"); + Map data = map(envelopeProperties.get("data")); + assertThat(data).containsEntry("type", "array"); + assertThat(map(data.get("items"))).containsEntry( + "$ref", "#/components/schemas/CustomerRuleVersionResource"); + + Map resource = map(schemas.get("CustomerRuleVersionResource")); + assertThat(map(resource.get("properties"))).containsOnlyKeys( + "id", "tenantId", "ruleCode", "ruleVersion", "customerId", "customerName", + "businessType", "chargeMode", "taxMode", "conversionFactor", "roundingScale", + "roundingMode", "currency", "effectiveFrom", "effectiveTo", "status", "remark", + "createdBy", "createdAt", "updatedBy", "updatedAt", "deleted", "version"); + for (String longField : List.of("id", "tenantId", "customerId", "createdBy", "updatedBy")) { + assertThat(map(map(resource.get("properties")).get(longField))).containsEntry("type", "string"); + } + assertThat(source).doesNotContain("CustomerRuleVersionSummary"); + } + private static Method method(String name) { return java.util.Arrays.stream(PlatformFbaMasterDataController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(name)) .findFirst().orElseThrow(); } + + @SuppressWarnings("unchecked") + private static Map map(Object value) { + return (Map) value; + } + + @SuppressWarnings("unchecked") + private static List list(Object value) { + return (List) value; + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java index d18b7a6e3..8665093a8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java @@ -37,12 +37,17 @@ class UnifiedInboundOpenApiContractTest { assertThat(stringMap(inbound.get("properties"))).containsKeys( "id", "tenantId", "orderId", "inboundCode", "processStatus", "version", "customerId", "warehouseId", "deliveryWarehouseId", "totalPackageCount", - "businessType", "channelCode", "pricingTaxMode", "containerMode", "destinationType", + "businessType", "fulfillmentType", "channelCode", "pricingTaxMode", "containerMode", "destinationType", "holdFlag", "actualGrossWeightKg", "actualNetWeightKg", "actualVolumeCbm", "volumeWeightKg", "chargeableWeightKg", "chargeableVolumeCbm", "palletCount", "inboundedAt", "requestedPickupAt", "expressCompanyCode", "expressCompanyName", "containerNo", "cabinetNo", "estimatedSailingAt", "actualSailingAt", "estimatedPortArrivalAt", "actualPortArrivalAt", "createdByName", "createdAt", "updatedAt"); + + Map approval = stringMap(schemas.get("OrderApprovedInboundCommand")); + assertThat(stringList(approval.get("required"))).contains("fulfillmentType"); + Map fulfillment = stringMap(stringMap(approval.get("properties")).get("fulfillmentType")); + assertThat(stringList(fulfillment.get("enum"))).containsExactly("STANDARD", "ONE_CLICK_DROPSHIP"); } @SuppressWarnings("unchecked") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java new file mode 100644 index 000000000..252da501c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java @@ -0,0 +1,80 @@ +package com.baiyue.firstleg.customerstatistics; + +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +class CustomerStatisticsArchitectureTest { + private static final String FIRSTLEG = "com.baiyue.firstleg.."; + private static final String CUSTOMER_STATISTICS = "com.baiyue.firstleg.customerstatistics.."; + private static final String APPLICATION = "com.baiyue.firstleg.customerstatistics.application.."; + private static final String OUTPUT_PORT = "com.baiyue.firstleg.customerstatistics.application.port.out.."; + private static final String PERSISTENCE = "com.baiyue.firstleg.customerstatistics.adapter.out.persistence.."; + private static final String WEB = "com.baiyue.firstleg.customerstatistics.adapter.in.web.."; + + private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) + .importPackages("com.baiyue.firstleg"); + + @Test + void applicationModelAndPortsStayFreeOfWebAndPersistenceFrameworks() { + noClasses().that().resideInAPackage(APPLICATION) + .should().dependOnClassesThat().resideInAnyPackage( + "org.springframework.web..", + "org.apache.ibatis..", + "com.baomidou.mybatisplus..") + .check(PRODUCTION_CLASSES); + } + + @Test + void applicationOnlyDependsOnItsOwnSliceWithinFirstleg() { + classes().that().resideInAPackage(APPLICATION) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(FIRSTLEG).or(resideInAPackage(APPLICATION))) + .check(PRODUCTION_CLASSES); + } + + @Test + void persistenceAdapterCollaboratesThroughTheApplicationOutputPort() { + classes().that().resideInAPackage(PERSISTENCE) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(CUSTOMER_STATISTICS).or(resideInAPackage(OUTPUT_PORT))) + .andShould().beAssignableTo(CustomerTransitStatisticsReader.class) + .check(PRODUCTION_CLASSES); + } + + @Test + void webAdapterOnlyUsesApplicationAndScopeGuardWithinFirstleg() { + DescribedPredicate scopeGuard = new DescribedPredicate<>("FirstlegScopeGuard") { + @Override + public boolean test(JavaClass javaClass) { + return javaClass.getName().equals(FirstlegScopeGuard.class.getName()); + } + }; + classes().that().resideInAPackage(WEB) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(FIRSTLEG) + .or(resideInAPackage(APPLICATION)) + .or(resideInAPackage(WEB)) + .or(scopeGuard)) + .check(PRODUCTION_CLASSES); + } + + @Test + void otherFirstlegProductionPackagesDoNotDependOnCustomerStatistics() { + noClasses().that().resideOutsideOfPackage(CUSTOMER_STATISTICS) + .should().dependOnClassesThat().resideInAPackage(CUSTOMER_STATISTICS) + .check(PRODUCTION_CLASSES); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java index 33463262f..33793badc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java @@ -1,8 +1,8 @@ package com.baiyue.firstleg.customerstatistics; -import com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController; -import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; -import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController; +import com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper; +import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java index e7028ebb9..04bf83ec8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java @@ -1,8 +1,8 @@ package com.baiyue.firstleg.customerstatistics; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; -import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -19,12 +19,12 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class CustomerTransitStatisticsServiceTest { - @Mock CustomerTransitStatisticsMapper mapper; + @Mock CustomerTransitStatisticsReader reader; @InjectMocks CustomerTransitStatisticsService service; @Test void preservesDecimalTeuAndUnknownCount() { var asOf = LocalDateTime.of(2026, 8, 13, 12, 0); - when(mapper.summarize(88L)).thenReturn(new CustomerTransitStatisticsMapper.SummaryRow( + when(reader.summarize(88L)).thenReturn(new CustomerTransitStatisticsReader.SummaryProjection( 88L, 4L, new BigDecimal("6.25"), 1L, asOf)); var result = service.summarize(88L); @@ -35,7 +35,7 @@ class CustomerTransitStatisticsServiceTest { assertThat(result.unknownContainerCount()).isEqualTo(1); assertThat(result.asOf()).isEqualTo(asOf); assertThat(result.definition()).contains("20*=1", "40*=2", "45*=2.25", "未知箱型"); - verify(mapper).summarize(88L); + verify(reader).summarize(88L); } @Test void rejectsInvalidCustomerBeforeQuery() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index 594eaf569..6695ea5d8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.entity.ContainerDocumentEntity; -import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; +import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class CustomsDispatchGateContributorTest { - @Mock LoadingListRepository repository; + @Mock CustomsRepository repository; @InjectMocks CustomsDispatchGateContributor contributor; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @@ -22,15 +22,16 @@ class CustomsDispatchGateContributorTest { .isEqualTo("INVALID_CUSTOMER_SCOPE"); } - @Test void passesOnlyCompletedSimpleDeclaration() { - ContainerDocumentEntity value = new ContainerDocumentEntity(); - when(repository.document(81L, "CUSTOMS_DECLARATION")).thenReturn(value); + @Test void passesOnlyCanonicalClearanceCompletedCase() { + CustomsCaseEntity value = new CustomsCaseEntity(); + value.setStatus("RECEIPT_AVAILABLE"); + when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value); assertThat(contributor.assess(context()).passed()).isFalse(); - value.setCompletedAt(java.time.LocalDateTime.now()); + value.setStatus("CLEARANCE_COMPLETED"); assertThat(contributor.assess(context())) .satisfies(item -> { assertThat(item.passed()).isTrue(); - assertThat(item.requirement()).isEqualTo("COMPLETED"); + assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED"); }); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index 81158d28a..d4f2eebfd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -45,4 +45,26 @@ class OutboundDispatchReadMapperContractTest { assertThat(oceanSql).contains("booking.customer_id=#{tenantId}").doesNotContain("#{customerId}"); assertThat(truckSql).contains("customer_id=#{tenantId}").doesNotContain("#{customerId}"); } + + @Test + void timelineIncludesCustomsAndSailingHistoriesWithinTheFullOutboundScope() throws Exception { + String sql = String.join("\n", OutboundDispatchReadMapper.class + .getMethod("timeline", long.class, long.class, long.class, long.class, + long.class, Long.class, Long.class) + .getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("SELECT 'CUSTOMS'", "FROM fl_customs_history h JOIN fl_customs_case customs_case") + .contains("customs_case.tenant_id=#{tenantId}", "customs_case.order_id=#{orderId}") + .contains("customs_case.inbound_id=#{inboundId}", + "customs_case.container_booking_id=#{containerBookingId}") + .contains("SELECT 'SAILING_SCHEDULE'", + "FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule") + .contains("port_schedule.customer_id=#{tenantId}", + "port_schedule.ocean_booking_id=#{oceanBookingId}") + .contains("port_schedule.container_booking_id=#{containerBookingId}") + .contains("schedule_inbound.tenant_id=#{tenantId}", "schedule_inbound.order_id=#{orderId}") + .contains("schedule_inbound.id=#{inboundId}") + .contains("ORDER BY occurredAt,module"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 57841d96e..8c4bf583f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -102,6 +102,25 @@ class OutboundDispatchServiceTest { verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); } + @Test + void reverseIsRejectedAfterActualDepartedWarehouseFactExists() { + CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); + OutboundDispatchService service = new OutboundDispatchService(repository, List.of(), carrierSchedules); + stubReadyChain(); + OutboundDispatchEntity state = new OutboundDispatchEntity(); + state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L); + state.setStatus("WAITING_FOR_LOADING"); state.setVersion(1); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); + when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-blocked"), anyString())).thenReturn(true); + when(carrierSchedules.hasOutboundDepartureFact(7L, 66L, 22L)).thenReturn(true); + + assertThatThrownBy(() -> service.reverse(9L, "admin", "reverse-blocked", + new ActionRequest(11L, 22L, 1, "误操作"))) + .isInstanceOf(BizException.class).hasMessageContaining("DEPARTED_WAREHOUSE"); + + verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any()); + } + @Test void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() { OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java index 03a8422c9..e587f38f1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -46,6 +46,7 @@ class OutboundDispatchSqlContractTest { .contains("outbound-dispatches/check-gate:") .contains("outbound-dispatches/confirm-dispatch:") .contains("outbound-dispatches/reverse-dispatch:") + .contains("Once an actual DEPARTED_WAREHOUSE node exists") .contains("expectedVersion") .contains("Idempotency-Key"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index ba178fd07..ed8d27e52 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -1,5 +1,8 @@ package com.baiyue.firstleg.portschedule.repository; +import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; @@ -10,6 +13,26 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class CarrierScheduleQuerySqlContractTest { + @Test + void annotationSqlUsesExecutableComparisonOperators() throws Exception { + String selectDue = String.join("\n", CarrierScheduleQueryMapper.class + .getMethod("selectDue", java.time.LocalDateTime.class, int.class) + .getAnnotation(Select.class).value()); + String claimLease = String.join("\n", CarrierScheduleQueryMapper.class + .getMethod("claimLease", long.class, String.class, java.time.LocalDateTime.class, + java.time.LocalDateTime.class) + .getAnnotation(Update.class).value()); + + assertThat(selectDue) + .contains("next_allowed_at <= #{now}") + .contains("lease_until < #{now}") + .doesNotContain("<"); + assertThat(claimLease) + .contains("next_allowed_at <= #{now}") + .contains("lease_until < #{now}") + .doesNotContain("<"); + } + @Test void definesTaskAttemptsHistoryIdempotencyAndHardLimit() throws Exception { assertThat(moduleSql()) @@ -34,6 +57,10 @@ class CarrierScheduleQuerySqlContractTest { .contains("DISCHARGED_FROM_VESSEL") .contains("PICKED_UP','EMPTY_RETURNED") .contains("CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD") + .contains("CREATE PROCEDURE migrate_carrier_schedule_history_action_check()") + .contains("DROP CHECK chk_carrier_schedule_history_action") + .contains("'MANUAL_NODE','NODE_OBSERVED'") + .contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'") .contains("provider_event_id") .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") .contains("lease_owner") @@ -86,6 +113,10 @@ class CarrierScheduleQuerySqlContractTest { .contains("SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00')") .contains("SET @carrier_schedule_next = CASE") .contains("CREATE PROCEDURE ensure_carrier_schedule_retry_check()") + .contains("CREATE PROCEDURE migrate_carrier_schedule_history_action_check()") + .contains("DROP CHECK chk_carrier_schedule_history_action") + .contains("'MANUAL_NODE','NODE_OBSERVED'") + .contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'") .contains("ADD CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)") .contains("统一迁移失败:船司查询固定槽位字段、索引或尝试次数约束不完整") .doesNotContain("DROP CHECK chk_carrier_schedule_query_attempt") @@ -95,6 +126,20 @@ class CarrierScheduleQuerySqlContractTest { .doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'"); } + @Test + void unifiedOutboxSupportsVersionedCrossRegionCarrierNodeFacts() throws Exception { + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + assertThat(unified) + .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_key'") + .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_version'") + .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'aggregate_sequence'") + .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'payload_hash'") + .contains("uk_inbound_outbox_event_key") + .contains("MODIFY COLUMN payload_hash VARCHAR(71)") + .contains("MODIFY COLUMN payload_json LONGTEXT NOT NULL"); + } + private static String moduleSql() throws Exception { return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql") .getContentAsString(StandardCharsets.UTF_8); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java new file mode 100644 index 000000000..ee314b6d1 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java @@ -0,0 +1,79 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class CarrierScheduleNodeOutboxServiceTest { + private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class); + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + private final CarrierScheduleNodeOutboxService service = new CarrierScheduleNodeOutboxService(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "outbox", mapper); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + ReflectionTestUtils.setField(service, "scheduleZone", "Asia/Shanghai"); + } + + @Test + void writesVersionedActualPickupReadyEnvelopeWithStringIds() throws Exception { + CarrierScheduleQueryEntity query = query(); + CarrierScheduleNodeFactEntity fact = fact(); + + service.publish(query, fact, "ZCSU7881970"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(mapper).insert(captor.capture()); + InboundOutboxEntity row = captor.getValue(); + EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); + assertThat(row.getEventType()).isEqualTo(EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED); + assertThat(envelope.eventVersion()).isEqualTo(1); + assertThat(envelope.aggregateType()).isEqualTo("CONTAINER_BOOKING"); + assertThat(envelope.aggregateId()).isEqualTo("2088000000000000003"); + assertThat(envelope.aggregateVersion()).isEqualTo(7); + assertThat(envelope.handoverVersion()).isEqualTo(8); + assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("PICKUP_READY"); + assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z"); + } + + @Test + void estimatedOnlyFactNeverEntersCrossRegionOutbox() { + CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null); + service.publish(query(), fact, "ZCSU7881970"); + verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); + } + + private static CarrierScheduleQueryEntity query() { + CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); + value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L); + value.setContainerBookingId(2088000000000000003L); value.setOceanBookingId(2088000000000000004L); + value.setVersion(7); return value; + } + + private static CarrierScheduleNodeFactEntity fact() { + CarrierScheduleNodeFactEntity value = new CarrierScheduleNodeFactEntity(); + value.setId(2088000000000000005L); value.setStandardNode("PICKUP_READY"); value.setNodeSequence(80); + value.setObservedAt(LocalDateTime.of(2026, 8, 25, 9, 2)); + value.setActualAt(LocalDateTime.of(2026, 8, 25, 9, 0)); value.setProviderCode("ZIM"); + value.setProviderEventId("evt-1"); value.setProviderNodeCode("AVAILABLE"); + value.setSourceReference("ZIM:evt-1"); value.setRawPayloadHash("a".repeat(64)); return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java index 6c109ac37..8607f987d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java @@ -24,6 +24,7 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; class CarrierScheduleQuerySchedulerTest { @Test @@ -106,6 +107,44 @@ class CarrierScheduleQuerySchedulerTest { verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null); } + @Test + void leaseLoserNeverCallsProviderOrWritesAttempt() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class); + CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class); + CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration); + CarrierScheduleQueryEntity task = task(1L); + when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(false); + + scheduler.execute(task, LocalDateTime.of(2026, 8, 25, 9, 0)); + + verify(integration, never()).query(any(), any()); + verify(service, never()).completeScheduledAttempt(anyLong(), anyString(), any(), any()); + } + + @Test + void retryableProviderResultCannotAdvanceEvenWhenItCarriesStrayFacts() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class); + CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class); + CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration); + CarrierScheduleQueryEntity task = task(1L); task.setCarrierCode("ZIM"); + task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("ZCSU7881970"); + LocalDateTime now = LocalDateTime.of(2026, 8, 25, 9, 0); + var stray = new CarrierScheduleIntegrationClient.Observation("evt-stray", "AVAILABLE", "PICKUP_READY", + now, null, now, "USLAX", "Los Angeles", "ZIM:evt-stray", "a".repeat(64)); + when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true); + when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null, + new CarrierScheduleIntegrationClient.QueryResult("ZIM", "RETRYABLE_FAILURE", List.of(stray), List.of(), + "HTTP_503", "unavailable", now))); + + scheduler.execute(task, now); + + verify(service, never()).applyProviderObservation(anyLong(), any()); + verify(service, never()).applyProviderException(anyLong(), any()); + verify(service).completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "HTTP_503", "unavailable"); + } + private static CarrierScheduleQueryEntity task(long id) { CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(id); return value; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index a38e1799d..e88ed6f9c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -11,6 +11,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExc import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository; @@ -38,11 +39,13 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; class CarrierScheduleQueryServiceTest { private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 10, 12, 0); @Mock private CarrierScheduleQueryRepository repository; @Mock private PortScheduleRepository portSchedules; + @Mock private CarrierScheduleNodeOutboxService nodeOutbox; @InjectMocks private CarrierScheduleQueryService service; @BeforeEach @@ -56,6 +59,9 @@ class CarrierScheduleQueryServiceTest { when(repository.nodes(anyLong())).thenReturn(List.of()); when(repository.exceptions(anyLong())).thenReturn(List.of()); when(repository.histories(anyLong())).thenReturn(List.of()); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("ZCSU7881970"); + when(portSchedules.containerBooking(20L)).thenReturn(container); } @Test @@ -165,6 +171,25 @@ class CarrierScheduleQueryServiceTest { assertThat(task.getNextAllowedAt()).isNull(); } + @Test + void lateActualFactAfterEmptyReturnIsAuditedWithoutRestartingTask() { + CarrierScheduleQueryEntity task = task(0, 4); + task.setStatus("STOPPED"); task.setLatestStandardNode("EMPTY_RETURNED"); + task.setLatestNodeObservedAt(NOW); task.setNextAllowedAt(null); task.setStopReason("已还柜"); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true); + + service.applyProviderObservation(1L, new ProviderObservationRequest("ZIM", "late-discharge", + "DISCHARGED", "DISCHARGED_FROM_VESSEL", NOW.plusHours(1), null, NOW.minusDays(2), + "USLAX", "Los Angeles", "ZIM:late-discharge", "d".repeat(64))); + + assertThat(task.getStatus()).isEqualTo("STOPPED"); + assertThat(task.getLatestStandardNode()).isEqualTo("EMPTY_RETURNED"); + assertThat(task.getNextAllowedAt()).isNull(); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970")); + } + @Test void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() { CarrierScheduleQueryEntity task = task(0, 3); @@ -224,6 +249,47 @@ class CarrierScheduleQueryServiceTest { assertThat(task.getLatestStandardNode()).isNull(); } + @Test + void allFourHoldTypesAndReleaseFactsRemainSeparateFromBusinessProgress() { + CarrierScheduleQueryEntity task = task(0, 1); + task.setLatestStandardNode("IN_TRANSIT"); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class))).thenReturn(true); + + for (String code : List.of("CUSTOMS_HOLD", "TERMINAL_HOLD", "DOCUMENT_HOLD", "UNKNOWN_HOLD")) { + service.applyProviderException(1L, new ProviderExceptionRequest("TRACK_API", code + "-ON", + code, code, true, NOW, null, null, "provider:" + code, "a".repeat(64))); + service.applyProviderException(1L, new ProviderExceptionRequest("TRACK_API", code + "-OFF", + code + " RELEASED", code, false, NOW.plusMinutes(1), null, null, + "provider:" + code + ":released", "b".repeat(64))); + } + + verify(repository, times(8)).insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class)); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + assertThat(task.getLatestStandardNode()).isEqualTo("IN_TRANSIT"); + } + + @Test + void providerEventIdReplayRequiresTheSameRawPayloadHash() { + CarrierScheduleQueryEntity task = task(0, 1); + task.setLatestStandardNode("IN_TRANSIT"); + CarrierScheduleNodeFactEntity existing = new CarrierScheduleNodeFactEntity(); + existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64)); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(false); + when(repository.node(1L, "ZIM:evt-duplicate")).thenReturn(existing); + ProviderObservationRequest replay = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN", + "AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64)); + + service.applyProviderObservation(1L, replay); + + verify(nodeOutbox, never()).publish(any(), any(), anyString()); + ProviderObservationRequest conflict = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN", + "AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "b".repeat(64)); + assertThatThrownBy(() -> service.applyProviderObservation(1L, conflict)) + .isInstanceOf(BizException.class).hasMessageContaining("不同rawPayloadHash"); + } + @Test void everySchedulerCompletionWritesImmutableAttempt() { CarrierScheduleQueryEntity task = task(0, 0); 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/InboundOrderRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java index 66d5a1993..99ef11f61 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java @@ -17,6 +17,7 @@ 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; @@ -43,6 +44,7 @@ class InboundOrderRepositoryTest { @MockBean private InboundFileMapper files; @MockBean private InboundInternalNoteMapper notes; @MockBean private InboundStatusHistoryMapper histories; + @MockBean private InboundWeightResultMapper weights; @Resource private InboundOrderRepository repository; @Test @SuppressWarnings({"rawtypes", "unchecked"}) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java new file mode 100644 index 000000000..4ee884bc3 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java @@ -0,0 +1,38 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LoadingCompletedOutboxRepositoryTest { + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void claimRechecksRetryWindowAfterCandidateSelection() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + InboundOutboxEntity.class); + InboundOutboxMapper mapper = mock(InboundOutboxMapper.class); + LoadingCompletedOutboxRepository repository = new LoadingCompletedOutboxRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + ArgumentCaptor wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + when(mapper.update(isNull(InboundOutboxEntity.class), wrapper.capture())).thenReturn(1); + LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0); + + assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue(); + + assertThat(wrapper.getValue().getSqlSegment()) + .contains("next_retry_at", "<=", "publish_status", "locked_at"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index 76018775b..11e77ed09 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -60,6 +60,9 @@ class LoadingListQueryMapperContractTest { assertThat(items) .contains("i.order_id", "i.order_no", "b.container_no") .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"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java index 47261f748..6a91c556b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -29,6 +29,31 @@ class LoadingPickItemMapperContractTest { assertThat(progress.getUnresolvedCount()).isEqualTo(1); } + @Test + void inboundProgressUsesSettledTerminalQuantitiesAndRejectsNonTerminalItems() throws Exception { + Method method = LoadingPickItemMapper.class.getDeclaredMethod( + "selectInboundProgress", long.class, long.class); + String sql = method.getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0") + .contains("WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0") + .contains("AND pick_item.picked_package_count<=plan_item.package_count") + .contains("THEN pick_item.picked_package_count") + .contains("ELSE plan_item.package_count") + .contains("WHEN pick_item.id IS NULL THEN 1") + .contains("WHEN pick_item.item_status='PICKED'") + .contains("AND pick_item.picked_package_count=plan_item.package_count THEN 0") + .contains("ELSE 1") + .doesNotContain("pick_item.picked_package_count<>plan_item.package_count"); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.item_status='PARTIAL'")).isEqualTo(2); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.item_status='SKIPPED'")).isEqualTo(2); + assertThat(sql).doesNotContain("pick_item.item_status='PENDING' THEN 0") + .doesNotContain("pick_item.item_status='PICKING' THEN 0"); + } + @Test void completionCheckCoversAllActivePlanItems() throws Exception { Method method = LoadingPickItemMapper.class.getDeclaredMethod("unfinishedCount", long.class); @@ -39,7 +64,17 @@ class LoadingPickItemMapperContractTest { .contains("plan_item.deleted = 0") .contains("pick_task.loading_plan_id = #{planId}") .contains("pick_task.task_status = 'LOADING'") - .contains("pick_item.picked_package_count = plan_item.package_count"); + .contains("pick_item.item_status = 'PICKED'") + .contains("pick_item.picked_package_count = plan_item.package_count") + .contains("pick_item.item_status = 'PARTIAL'") + .contains("pick_item.picked_package_count > 0") + .doesNotContain("pick_item.picked_package_count < plan_item.package_count") + .contains("pick_item.item_status = 'SKIPPED'") + .contains("pick_item.picked_package_count = 0") + .doesNotContain("pick_item.item_status = 'PENDING'") + .doesNotContain("pick_item.item_status = 'PICKING'"); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.picked_package_count = plan_item.package_count")).isEqualTo(2); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index d8204c704..a7703c107 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -10,10 +10,33 @@ import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class PdaSplitSettlementSqlContractTest { + @Test + void addsPreloadConfirmationAuditColumnsForIncrementalPdaAllocation() throws Exception { + String moduleSql = new ClassPathResource("db/phase3-split-preload-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unifiedSql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + + assertThat(moduleSql) + .contains("confirmed_by BIGINT UNSIGNED NULL") + .contains("confirmed_by_name VARCHAR(64) NULL") + .contains("confirmed_at DATETIME(3) NULL") + .contains("phase3_split_add_column('fl_preload_plan', 'confirmed_by'") + .contains("phase3_split_add_column('fl_preload_plan', 'confirmed_by_name'") + .contains("phase3_split_add_column('fl_preload_plan', 'confirmed_at'"); + assertThat(unifiedSql) + .contains("CREATE PROCEDURE migrate_preload_confirmation_columns()") + .contains("ADD COLUMN confirmed_by BIGINT UNSIGNED NULL") + .contains("ADD COLUMN confirmed_by_name VARCHAR(64) NULL") + .contains("ADD COLUMN confirmed_at DATETIME(3) NULL"); + } + @Test void protectsLoadedAndRemainderSettlement() throws Exception { String sql = new ClassPathResource("db/phase3-inbound-parent-child-schema.sql") @@ -77,6 +100,7 @@ class PdaSplitSettlementSqlContractTest { assertThat(clone) .contains("#{childOrderId},#{childOrderNo}") .contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END") + .contains("business_type,fulfillment_type") .contains("actual_gross_weight_kg * #{packageCount}") .contains("actual_net_weight_kg * #{packageCount}"); @@ -101,6 +125,29 @@ class PdaSplitSettlementSqlContractTest { .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", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index 813159fb4..f6477f5de 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -26,7 +26,9 @@ class SmartLoadingCandidateMapperContractTest { .contains("m.status='APPROVED'") .contains("SELECT MAX(m2.id)") .contains("m2.status='APPROVED'") - .contains("i.inbound_detail_id IS NOT NULL") + .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("o.warehouse_id=#{warehouseId}") .contains("o.process_status='WAIT_SPLIT'") .contains("unitGrossWeightKg") @@ -36,13 +38,13 @@ class SmartLoadingCandidateMapperContractTest { .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.id ASC"); + .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.inbound_detail_id ASC"); } @Test void detailUsesSameBatchRule() throws Exception { Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod( - "candidate", long.class, long.class, long.class); + "candidate", long.class, long.class, long.class, long.class); String sql = String.join(" ", detail.getAnnotation(Select.class).value()); assertThat(sql) @@ -51,10 +53,18 @@ class SmartLoadingCandidateMapperContractTest { .contains("SELECT MAX(m2.id)") .contains("o.warehouse_id=#{warehouseId}") .contains("o.process_status='WAIT_SPLIT'") + .contains("o.process_status='LOADING'") + .contains("same_plan_item.loading_plan_id=#{planId}") + .contains("same_plan_item.account_id=o.tenant_id") + .contains("same_plan_item.inbound_id=o.id") + .contains("same_plan_item.deleted=0") + .doesNotContain("o.process_status IN ('WAIT_SPLIT','LOADING')") .contains("unitGrossWeightKg") .contains("unitNetWeightKg") .doesNotContain("i.unit_weight_kg") .contains("SELECT SUM(li.package_count)") + .contains("SUM(package_count) package_count") + .contains("GROUP BY tenant_id,measurement_id,inbound_detail_id") .contains("FOR UPDATE"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index f51066705..27c6c17a3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -89,6 +89,18 @@ class SmartLoadingSqlContractTest { } } + @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("仓库备注使用计划先行与明细单表CAS并允许已拣明细仅改备注") void shouldUpdateWarehouseRemarkWithoutMultiTableTriggerConflict() throws Exception { @@ -196,8 +208,6 @@ class SmartLoadingSqlContractTest { .contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'") .contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN") .contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") - .contains("LEFT JOIN fl_loading_pick_item pick_item") - .contains("pick_item.picked_package_count <> item.package_count") .contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") .contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))") .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数") @@ -214,6 +224,23 @@ class SmartLoadingSqlContractTest { .doesNotContain("CONFIRMED") .doesNotContain("ROLLED_BACK") .doesNotContain("WITHDRAWN"); + assertLoadingCompletionTrigger(migration()); + assertLoadingCompletionTrigger(unifiedMigration()); + } + + private static void assertLoadingCompletionTrigger(String sql) { + assertThat(sql) + .contains("AND NOT EXISTS (") + .contains("pick_task.loading_plan_id = OLD.id") + .contains("pick_task.task_status = 'LOADING'") + .contains("pick_item.item_status = 'PICKED'") + .contains("pick_item.item_status = 'PARTIAL'") + .contains("pick_item.picked_package_count > 0") + .contains("pick_item.item_status = 'SKIPPED'") + .contains("pick_item.picked_package_count = 0") + .doesNotContain("pick_item.picked_package_count <> item.package_count"); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.picked_package_count = item.package_count")).isEqualTo(2); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TenantScopedMutationRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TenantScopedMutationRepositoryTest.java index 9d2ef2219..03f7d1c7c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/TenantScopedMutationRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TenantScopedMutationRepositoryTest.java @@ -108,7 +108,9 @@ class TenantScopedMutationRepositoryTest { verify(inboundOrders).update(eq(inbound), wrapper.capture()); assertTenantAndId(wrapper.getValue()); assertThat(((LambdaUpdateWrapper) wrapper.getValue()).getSqlSet()) - .contains("actual_gross_weight_kg"); + .contains("actual_gross_weight_kg") + .contains("actual_net_weight_kg") + .contains("pallet_count"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java index 98ccc8e15..51664c9e3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java @@ -37,6 +37,14 @@ class FeePaymentSnapshotValidatorTest { .isInstanceOf(BizException.class).hasMessageContaining("账期应付不得"); } + @Test + void payableWithoutSettlementModeReturnsBusinessValidationInsteadOfNullPointer() { + assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", null, + null, null, null, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("应付费用必须选择ON_ACCOUNT或DIRECT_PAID"); + } + @Test void directPaidRequiresTimeMethodAndReferenceOrVoucher() { LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index c2439be80..10cb7b82b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -123,6 +123,7 @@ class InboundMeasurementServiceTest { assertThat(weight.getNetWeightKg()).isNull(); assertThat(inbound.getActualGrossWeightKg()).isNull(); assertThat(inbound.getActualNetWeightKg()).isNull(); + assertThat(inbound.getPalletCount()).isZero(); verify(repository).updateInboundWeightSummary(inbound); } @@ -283,6 +284,7 @@ class InboundMeasurementServiceTest { 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)); } @@ -630,6 +632,7 @@ class InboundMeasurementServiceTest { assertThat(inbound.getActualGrossWeightKg()).isNull(); assertThat(inbound.getActualNetWeightKg()).isNull(); + assertThat(inbound.getPalletCount()).isZero(); verify(repository).updateInboundWeightSummary(inbound); } @@ -655,6 +658,7 @@ class InboundMeasurementServiceTest { assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("5.000"); assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("5.000"); + assertThat(inbound.getPalletCount()).isEqualTo(1); verify(repository).updateInboundWeightSummary(inbound); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index 9f6c94123..a9583fa1b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -72,11 +72,35 @@ class InboundOrderCreationServiceTest { assertThat(inboundCaptor.getValue().getCarrierId()).isNull(); assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION"); assertThat(inboundCaptor.getValue().getPricingTaxMode()).isEqualTo("TAX_EXCLUDED"); + assertThat(inboundCaptor.getValue().getFulfillmentType()).isEqualTo("STANDARD"); assertThat(inboundCaptor.getValue().getTotalPackageCount()).isEqualTo(4); assertThat(createdId).isEqualTo(inboundCaptor.getValue().getId()); assertThat(replayedId).isEqualTo(900L); } + @Test + void persistsDropshipFulfillmentTypeWithoutChangingPricingBusinessType() { + OrderApprovedInboundCommand base = approvedCommand("PCHK2608140001"); + OrderApprovedInboundCommand command = new OrderApprovedInboundCommand( + base.tenantId(), base.orderId(), base.orderNo(), base.inboundCode(), base.customerId(), + base.customerName(), base.warehouseId(), base.warehouseName(), base.deliveryWarehouseId(), + base.deliveryWarehouseName(), base.expectedArrivalAt(), base.businessType(), base.channelCode(), + base.channelName(), base.carrierId(), base.carrierName(), base.taxMode(), base.pricingTaxMode(), + base.containerMode(), base.customsInspection(), base.destinationType(), base.destinationWarehouseId(), + base.destinationCode(), base.destinationName(), base.publicRemark(), base.expressCompanyName(), + base.expressTrackingNo(), base.totalPackageCount(), base.pickupMethod(), base.requestedPickupAt(), + "ONE_CLICK_DROPSHIP"); + when(orders.selectOne(any(Wrapper.class))).thenReturn(null); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); + + service.createFromApprovedOrder(command); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOrderEntity.class); + verify(orders).insert(captor.capture()); + assertThat(captor.getValue().getBusinessType()).isEqualTo("FBA"); + assertThat(captor.getValue().getFulfillmentType()).isEqualTo("ONE_CLICK_DROPSHIP"); + } + @Test void shouldRejectReplayWithDifferentInboundCode() { InboundOrderEntity existing = inbound(900L, "RK-OLD", "UNPRICED", null, null); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java index abd8ea172..3970763f6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java @@ -1,6 +1,7 @@ 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; @@ -18,6 +19,7 @@ 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; @@ -73,7 +75,44 @@ class LoadingCompletedOutboxRelayTest { relay.relay(); - verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("failed *** remote")); + 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 { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 87cf1e1a9..461aca213 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -8,6 +8,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -37,7 +38,9 @@ class LoadingCompletedOutboxServiceTest { ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests); ReflectionTestUtils.setField(service,"outboxMapper",outbox); ReflectionTestUtils.setField(service,"objectMapper",mapper); - when(manifests.labelPool(anyLong(), anyLong(), anyLong())).thenReturn(List.of("BOX-POOL-1")); + when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of( + label(101, "BOX-101"), label(102, "BOX-102"), + label(202, "BOX-202"), label(302, "BOX-302"))); } @Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception { @@ -54,8 +57,8 @@ class LoadingCompletedOutboxServiceTest { 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-POOL-1"); - assertThat(first.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); + assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101"); + assertThat(first.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS"); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(first.eventVersion()).isEqualTo(2); } @@ -69,12 +72,39 @@ class LoadingCompletedOutboxServiceTest { .hasMessageContaining("缺少 SKU"); } + @Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception { + Row value = row(11, 100, 101); + value.setDestinationType("FBA"); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(event.payload().at("/orders/0/destinationType").asText()).isEqualTo("FBA"); + assertThat(event.payload().at("/orders/0/items/0/cartonMarks")).isEmpty(); + verify(manifests, org.mockito.Mockito.never()).labelPool(11L, 100L); + } + + @Test void rejectsUnfrozenDestinationType() { + Row value = row(11, 100, 101); + value.setDestinationType(null); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + assertThatThrownBy(() -> service.publish(90L)) + .hasMessageContaining("目的类型必须唯一且为 FBA 或 OVERSEAS"); + verify(outbox, org.mockito.Mockito.never()).insert( + org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); + } + @Test void freezesExactlyLoadedPackagesFromLabelPoolDeterministically() throws Exception { Row value = row(11, 100, 101); value.setPackageCount(2); when(snapshots.rows(90L)).thenReturn(List.of(value)); - when(manifests.labelPool(11L, 100L, 101L)).thenReturn( - List.of("BOX-001", "BOX-002", "BOX-003", "BOX-004")); + 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); @@ -88,6 +118,25 @@ class LoadingCompletedOutboxServiceTest { .isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks")); } + @Test void fillsCollapsedMeasurementDetailFromUnusedInboundLabelsWithoutDuplicates() throws Exception { + Row first = row(11, 100, 101); first.setPackageCount(1); + Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + when(manifests.labelPool(11L, 100L)).thenReturn(List.of( + label(201, "BOX-001"), label(202, "BOX-002"), label(203, "BOX-003"))); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + List firstMarks = mapper.convertValue(event.payload().at("/orders/0/items/0/cartonMarks"), List.class); + List secondMarks = mapper.convertValue(event.payload().at("/orders/0/items/1/cartonMarks"), List.class); + assertThat(firstMarks).hasSize(1); + assertThat(secondMarks).hasSize(2); + assertThat(firstMarks).doesNotContainAnyElementsOf(secondMarks); + } + @Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception { String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); @@ -97,9 +146,9 @@ class LoadingCompletedOutboxServiceTest { } @Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception { - Row first = row(11, 100, 101); first.setBusinessType("ONE_CLICK_DROPSHIP"); + 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.setBusinessType("ONE_CLICK_DROPSHIP"); + Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP"); second.setPackageCount(1); second.setProductQuantity(5); second.setSkuCode("SKU-B"); when(snapshots.rows(90L)).thenReturn(List.of(first, second)); when(snapshots.palletRows(90L)).thenReturn(List.of( @@ -119,18 +168,33 @@ class LoadingCompletedOutboxServiceTest { assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); } - @Test void publishesOrdinaryRecommendedPalletNumbersForTailLegReuse() throws Exception { + @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)); - when(snapshots.palletRows(90L)).thenReturn(List.of( - pallet(11, 100, 1, "FL-PLT-001", 1, 101, "SKU101", 1, 10, "BOX-POOL-1"))); 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/palletNo").asText()).isEqualTo("FL-PLT-001"); + 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) { @@ -140,6 +204,8 @@ class LoadingCompletedOutboxServiceTest { 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.setVolumeCbm(BigDecimal.ONE); return r; } @@ -152,4 +218,8 @@ class LoadingCompletedOutboxServiceTest { row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品"); row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row; } + + private LabelRow label(long detailId, String boxCode) { + LabelRow row = new LabelRow(); row.setInboundDetailId(detailId); row.setBoxCode(boxCode); return row; + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index dbaefc6b3..6279bf6bd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -508,9 +508,12 @@ class LoadingPickServiceTest { when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); - when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); + var incrementalOwner = new LoadingPickItemMapper.InboundOwner(101L, 60L); + when(repository.inboundOwners(20L)).thenReturn(List.of(owner, incrementalOwner)); when(repository.inboundForUpdate(101L, 50L)).thenReturn( new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + when(repository.inboundForUpdate(101L, 60L)).thenReturn( + new LoadingInboundMapper.Snapshot(60L, 101L, "LOADING", 3)); service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator"); service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator"); @@ -519,14 +522,17 @@ class LoadingPickServiceTest { verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); verify(settlementService).settle(20L, 9L, "operator"); verify(repository).syncInboundProjections(20L, 9L, "operator"); - verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L); + verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L, 50L); + verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L, 50L); verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator"); - verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", "PDA-complete-1-50", "checked"); + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 50L, 9L, "operator", + "PDA-complete-1-60", "checked"); verify(loadingListService).publishCompletedEvent(10L); InOrder finalization = inOrder(loadingListService, loadingDeliveryService); finalization.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); - finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", "PDA-complete-1-50", "checked"); finalization.verify(loadingListService).publishCompletedEvent(10L); } @@ -556,7 +562,7 @@ class LoadingPickServiceTest { verify(settlementService).settle(20L, 9L, "operator"); verify(repository).approve(20L, 9L, "operator", "checked"); verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); - verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", "PDA-complete-not-blocked-50", "checked"); verify(loadingListService).publishCompletedEvent(10L); } @@ -574,7 +580,7 @@ class LoadingPickServiceTest { new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单并发修改")) - .when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + .when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", "PDA-complete-finalize-fails-50", "checked"); assertThatThrownBy(() -> service.complete(20L, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java index 306cbe216..131a02042 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java @@ -66,6 +66,9 @@ class PdaPickSettlementServiceTest { verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-1"), eq(12), eq(1), eq("REMAINDER"), eq(9L), eq("operator")); + verify(splits).transferRemainingCartonLabels(eq(10001L), eq(31L), anyLong(), + eq(1002L), eq("IN-001-1"), eq(8), eq(12)); + verify(splits).normalizeLoadedCartonLabels(10001L, 31L, 8); verify(splits).markSourceLoaded(10001L, 31L, 8, 9L, 3); verify(allocations).consume(10001L, 31L, 1L, 8); verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index c26f94ef7..8f39dfa00 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -16,6 +16,7 @@ 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.split.service.SplitPreloadService; @@ -41,6 +42,7 @@ import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.catchThrowableOfType; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -240,7 +242,7 @@ class SmartLoadingServiceTest { LoadingPlanEntity plan = plan("EDITING", 0); when(repository.plan(1)).thenReturn(plan); when(repository.booking(99L)).thenReturn(booking()); - when(repository.candidate(100, 10, 20)).thenReturn(candidate(5)); + when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(5)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4)); BizException error = catchThrowableOfType(() -> service.assign(1, @@ -257,7 +259,7 @@ class SmartLoadingServiceTest { linked.setCustomerId(88L); when(repository.plan(1L)).thenReturn(plan); when(repository.booking(99L)).thenReturn(linked); - when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(5)); + when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(5)); BizException error = catchThrowableOfType(() -> service.assign(1L, new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class); @@ -299,6 +301,9 @@ class SmartLoadingServiceTest { @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", @@ -319,6 +324,7 @@ class SmartLoadingServiceTest { 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("最新内部备注"); @@ -351,8 +357,10 @@ class SmartLoadingServiceTest { when(repository.booking(99)).thenReturn(booking()); when(repository.touch(1, 5, 7, "u")).thenReturn(1); when(repository.items(1)).thenReturn(List.of()); - when(repository.candidate(100, 10, 20)).thenReturn(candidate(10)); + when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); + when(loadingPickService.mutationSnapshot(1L)).thenReturn( + new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of())); 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))); @@ -360,12 +368,16 @@ class SmartLoadingServiceTest { Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-loading"), 7, "u"); assertThat(result.status()).isEqualTo("LOADING"); - var order = inOrder(loadingPickService, repository, loadingModelBridge); + verify(repository).candidate(1L, 100L, 10L, 20L); + var order = inOrder(loadingPickService, splitPreloadService, repository, loadingModelBridge); order.verify(loadingPickService).lockMutable(1L); + order.verify(splitPreloadService).ensureForRunningLoadingAllocation(9L, 10L, 7L, "u"); 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 @@ -382,7 +394,7 @@ class SmartLoadingServiceTest { Plan result = service.assign(1, request, 7, "u"); assertThat(result.status()).isEqualTo("LOADING"); - verify(repository, never()).candidate(anyLong(), anyLong(), anyLong()); + verify(repository, never()).candidate(anyLong(), anyLong(), anyLong(), anyLong()); verify(cargoClient, never()).detail(anyString(), anyLong(), anyLong(), anyLong()); } @@ -581,7 +593,7 @@ class SmartLoadingServiceTest { when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3)); when(repository.item(1L, 8L)).thenReturn(line); when(repository.booking(99L)).thenReturn(booking()); - when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(10)); + when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(10)); when(repository.items(1L)).thenReturn(List.of(line)); when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); when(repository.update(line)).thenReturn(1); @@ -593,6 +605,23 @@ class SmartLoadingServiceTest { assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱"); } + @Test + void pickedQuantityConflictPrecedesCandidateAvailabilityValidation() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 2); + when(repository.plan(1L)).thenReturn(plan); + when(repository.item(1L, 8L)).thenReturn(line); + when(loadingPickService.mutationSnapshot(1L)).thenReturn( + new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of(8L, 2))); + + assertThatThrownBy(() -> service.adjust(1L, 8L, + new AdjustRequest(1, 2, 0, "adjust-picked"), 7L, "u")) + .hasMessageContaining("不能低于已拣货箱数"); + + verify(repository, never()).candidate(anyLong(), anyLong(), anyLong(), anyLong()); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); + } + @Test void preservesDownstreamCode() { stubAssignment(); @@ -850,7 +879,7 @@ class SmartLoadingServiceTest { when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1)); when(repository.booking(99)).thenReturn(booking()); when(repository.items(1)).thenReturn(List.of()); - when(repository.candidate(100, 10, 20)).thenReturn(candidate(10)); + when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); when(repository.touch(1, 0, 7, "u")).thenReturn(1); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java index 6f49e1202..e593f1aa2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -58,8 +58,8 @@ class TaillegRealProducerAcceptanceTest { BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", "40HQ"); jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," + "updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING); - seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "LOADED", "ONT8"); - seedInbound(DROPSHIP_INBOUND, 990022222L, MARKER + "-DROP-IN", "ONE_CLICK_DROPSHIP", "LOADED", "LAX9"); + 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, @@ -86,13 +86,15 @@ class TaillegRealProducerAcceptanceTest { id, 990022901L, planItemId, inboundId, detailId, packages, packages); } - private void seedInbound(long id, long orderId, String code, String type, String status, String destination) { + 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,destination_warehouse_id," + + "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,'真实验收海外仓',?,9001,?,?,?,0.4000,1,1)", - id, CUSTOMER, orderId, MARKER + "-ORDER-" + id, code, status, "真实验收客户", type, - destination, "FBA " + destination, type.equals("ONE_CLICK_DROPSHIP") ? 2 : 3); + "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, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java index d0aee7573..13dcd3121 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java @@ -13,6 +13,7 @@ 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; @@ -71,6 +72,20 @@ class UnifiedInboundQueryServiceTest { 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) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index 267aaaf36..039693cf0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -80,4 +80,62 @@ class SplitPreloadServiceTest { 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 runningLoadingAllocationStillRejectsConfirmingPreload() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); + PreloadPlanEntity plan = new PreloadPlanEntity(); + plan.setStatus("CONFIRMING"); + when(repository.findPlan(10, 20)).thenReturn(plan); + + assertThatThrownBy(() -> service.ensureForRunningLoadingAllocation(10, 20, 7, "admin")) + .hasMessageContaining("已进入确认流程"); + } } diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json new file mode 100644 index 000000000..308cda8bc --- /dev/null +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -0,0 +1,64 @@ +{ + "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->shared-technical", + "booking-customs-trucking->split-loading", + "customer-statistics->shared-technical", + "finance-integration->inbound", + "finance-integration->measurement-pricing", + "finance-integration->shared-technical", + "finance-integration->split-loading", + "inbound->measurement-pricing", + "inbound->outbound", + "inbound->shared-technical", + "inbound->split-loading", + "measurement-pricing->booking-customs-trucking", + "measurement-pricing->finance-integration", + "measurement-pricing->inbound", + "measurement-pricing->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->measurement-pricing", + "split-loading->outbound", + "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" + }, + "applicationDependencyRule": { + "packagePrefix": "com.baiyue.firstleg.customerstatistics.application", + "allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application", + "forbiddenImportPrefixes": [ + "org.springframework.web", + "org.apache.ibatis", + "com.baomidou.mybatisplus" + ] + }, + "sliceSpecificRules": { + "customer-statistics": { + "allowedOutgoing": ["shared-technical"], + "allowedIncoming": [] + } + } +} diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md index db1344368..ddd4f2c28 100644 --- a/gateway-overseas/AGENTS.md +++ b/gateway-overseas/AGENTS.md @@ -13,6 +13,8 @@ | 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` | | Audience 校验 | `config/AudienceJwtValidator.java` | | 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` | +| PDA 换仓即时失效 | `filter/PdaSessionValidationFilter.java`、`HttpPdaSessionValidationClient.java`、`config/PdaSessionValidationProperties.java`;仅 `/api/pda/**` 本地验签后同步核对 Auth,任何异常 fail-closed | +| 海外安全事件即时失效 | `filter/OverseasSecurityProjectionFilter.java`、`HttpOverseasSecurityValidationClient.java`;全部 `/api/**` 本地验签后核对海外安全投影,用户/客户停用与权限版本落后立即拒绝 | | 安全配置项 | `config/GatewaySecurityProperties.java` | | 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` | | 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` | 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 index d75af294b..a23d519e5 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -1,5 +1,6 @@ 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; @@ -23,7 +24,8 @@ import java.util.Map; * 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。 */ @Configuration -@EnableConfigurationProperties(GatewaySecurityProperties.class) +@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class, + OverseasSecurityProjectionProperties.class}) public class GatewaySecurityConfig { @Bean SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, @@ -62,15 +64,20 @@ public class GatewaySecurityConfig { return decoder; } - static Mono writeError(org.springframework.web.server.ServerWebExchange exchange, + 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", exchange.getRequest().getId()); + body.put("requestId", requestId); + body.put("traceId", traceId); body.put("timestamp", Instant.now().toString()); try { byte[] bytes = mapper.writeValueAsBytes(body); 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..9edcfad81 --- /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("必须配置 OVERSEAS_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 index 4133a793a..35ff9d210 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java @@ -12,7 +12,7 @@ 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", "permission_version", "authorities"); + "sub", "tenant_id", "app_code", "token_version", "permission_version", "authorities"); @Override public OAuth2TokenValidatorResult validate(Jwt jwt) { @@ -26,6 +26,7 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { 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"); @@ -36,6 +37,9 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { 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 访问域不一致"); 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..6d75282df --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java @@ -0,0 +1,37 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.OverseasSecurityProjectionProperties; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Component +public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient { + private static final String PATH = "/internal/overseas-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 tokenVersion, 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, tokenVersion, permissionVersion)) + .retrieve().bodyToMono(JsonNode.class).timeout(properties.timeout()).flatMap(body -> { + JsonNode data = body == null ? null : body.path("data"); + if (data == null || data.isMissingNode() || !data.has("valid")) { + return Mono.error(new IllegalStateException("海外安全投影响应无效")); + } + return Mono.just(new Result(data.path("valid").asBoolean(false), + data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null))); + }); + } + + private record Request(long userId, long tenantId, long tokenVersion, 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..cdbb4e727 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java @@ -0,0 +1,74 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.GatewaySecurityConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Component +public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered { + private final OverseasSecurityValidationClient client; + private final ObjectMapper objectMapper; + + public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper) { + this.client = client; + this.objectMapper = objectMapper; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + if (!exchange.getRequest().getURI().getPath().startsWith("/api/")) return chain.filter(exchange); + return exchange.getPrincipal().cast(Authentication.class).filter(Authentication::isAuthenticated) + .ofType(JwtAuthenticationToken.class) + .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "未登录或令牌已失效"))) + .flatMap(authentication -> validate(exchange, chain, authentication)) + .onErrorResume(SecurityProjectionFailure.class, + failure -> write(exchange, failure.code, failure.getMessage())) + .onErrorResume(failure -> write(exchange, "A0401", "海外安全状态校验不可用")); + } + + private Mono validate(ServerWebExchange exchange, GatewayFilterChain chain, + JwtAuthenticationToken authentication) { + Long userId = number(authentication.getToken().getSubject()); + Long tenantId = number(authentication.getToken().getClaim("tenant_id")); + Long tokenVersion = number(authentication.getToken().getClaim("token_version")); + Long permissionVersion = number(authentication.getToken().getClaim("permission_version")); + if (userId == null || tenantId == null || tokenVersion == null || permissionVersion == null) { + return Mono.error(new SecurityProjectionFailure("A0401", "海外令牌安全版本声明缺失")); + } + return client.validate(userId, tenantId, tokenVersion, permissionVersion) + .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用"))) + .flatMap(result -> { + if (result.valid()) return chain.filter(exchange); + if ("USER_DISABLED".equals(result.failureReason())) { + return Mono.error(new SecurityProjectionFailure("A0401", "账号已停用")); + } + return Mono.error(new SecurityProjectionFailure("A0403", "客户或权限状态已变化")); + }); + } + + private Mono write(ServerWebExchange exchange, String code, String message) { + return GatewaySecurityConfig.writeError(exchange, objectMapper, + "A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED, code, message); + } + + private static Long number(Object value) { + if (value instanceof Number number) return number.longValue(); + try { return value == null ? null : Long.valueOf(value.toString()); } + catch (NumberFormatException exception) { return null; } + } + + @Override public int getOrder() { return -95; } + + private static final class SecurityProjectionFailure extends RuntimeException { + private final String code; + private SecurityProjectionFailure(String code, String message) { super(message); this.code = code; } + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java new file mode 100644 index 000000000..2ce22a244 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java @@ -0,0 +1,8 @@ +package com.baiyue.gateway.filter; + +import reactor.core.publisher.Mono; + +public interface OverseasSecurityValidationClient { + Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion); + record Result(boolean valid, String failureReason) { } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java new file mode 100644 index 000000000..b40726567 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java @@ -0,0 +1,10 @@ +package com.baiyue.gateway.filter; + +import reactor.core.publisher.Mono; + +/** 只传递本地验签后提取的稳定 claims,不传递 raw JWT。 */ +public interface PdaSessionValidationClient { + Mono validate(long userId, int tokenVersion, long overseasWarehouseId); + + record Result(boolean valid, String failureReason) { } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java new file mode 100644 index 000000000..4e94ba57b --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java @@ -0,0 +1,92 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.GatewaySecurityConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** 仅对海外 PDA API 执行无缓存的账号版本与仓绑定实时校验。 */ +@Component +public class PdaSessionValidationFilter implements GlobalFilter, Ordered { + private static final String PDA_PREFIX = "/api/pda/"; + private final PdaSessionValidationClient client; + private final ObjectMapper objectMapper; + + public PdaSessionValidationFilter(PdaSessionValidationClient client, ObjectMapper objectMapper) { + this.client = client; + this.objectMapper = objectMapper; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + if (!exchange.getRequest().getURI().getPath().startsWith(PDA_PREFIX)) { + return chain.filter(exchange); + } + return exchange.getPrincipal().cast(Authentication.class) + .filter(Authentication::isAuthenticated) + .ofType(JwtAuthenticationToken.class) + .switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "未登录或令牌已失效"))) + .flatMap(authentication -> validate(exchange, chain, authentication)) + .onErrorResume(InvalidPdaSession.class, failure -> write(exchange, failure.code, failure.getMessage())) + .onErrorResume(failure -> write(exchange, "A0401", "海外PDA会话校验不可用")); + } + + private Mono validate(ServerWebExchange exchange, GatewayFilterChain chain, + JwtAuthenticationToken authentication) { + Long userId = longValue(authentication.getToken().getSubject()); + Integer tokenVersion = intClaim(authentication, "token_version"); + Long warehouseId = longClaim(authentication, "overseas_warehouse_id"); + if (userId == null || tokenVersion == null) { + return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌版本声明缺失")); + } + if (warehouseId == null) { + return Mono.error(new InvalidPdaSession("A0403", "海外PDA账号未绑定仓库")); + } + return client.validate(userId, tokenVersion, warehouseId) + .switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "海外PDA会话校验不可用"))) + .flatMap(result -> { + if (result.valid()) return chain.filter(exchange); + if ("WAREHOUSE_UNBOUND".equals(result.failureReason()) + || "WAREHOUSE_MISMATCH".equals(result.failureReason())) { + return Mono.error(new InvalidPdaSession("A0403", "海外PDA仓库绑定已变化")); + } + return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌已失效")); + }); + } + + private Mono write(ServerWebExchange exchange, String code, String message) { + HttpStatus status = "A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED; + return GatewaySecurityConfig.writeError(exchange, objectMapper, status, code, message); + } + + private static Integer intClaim(JwtAuthenticationToken authentication, String name) { + Object value = authentication.getToken().getClaims().get(name); + if (value instanceof Number number) return number.intValue(); + try { return value == null ? null : Integer.valueOf(value.toString()); } + catch (NumberFormatException ignored) { return null; } + } + + private static Long longClaim(JwtAuthenticationToken authentication, String name) { + return longValue(authentication.getToken().getClaims().get(name)); + } + + private static Long longValue(Object value) { + if (value instanceof Number number) return number.longValue(); + try { return value == null ? null : Long.valueOf(value.toString()); } + catch (NumberFormatException ignored) { return null; } + } + + @Override public int getOrder() { return -90; } + + private static final class InvalidPdaSession extends RuntimeException { + private final String code; + private InvalidPdaSession(String code, String message) { super(message); this.code = code; } + } +} diff --git a/gateway-overseas/src/main/resources/application-overseas.yml b/gateway-overseas/src/main/resources/application-overseas.yml index 5866a7caf..bdcda68d8 100644 --- a/gateway-overseas/src/main/resources/application-overseas.yml +++ b/gateway-overseas/src/main/resources/application-overseas.yml @@ -10,7 +10,7 @@ spring: routes: - id: lastleg-service uri: lb://lastleg-service - predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**"] + 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/**"] @@ -60,6 +60,7 @@ springdoc: 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 } diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml index bbeb5714a..3e7ce51f2 100644 --- a/gateway-overseas/src/main/resources/application.yml +++ b/gateway-overseas/src/main/resources/application.yml @@ -42,6 +42,14 @@ baiyue: issuer: ${JWT_ISSUER:https://auth.example.com} audience: ${JWT_AUDIENCE:lastleg-api} jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json} + pda-session-validation: + auth-url: ${AUTH_SESSION_VALIDATION_URL} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} + overseas-projection: + service-url: ${OVERSEAS_SECURITY_SERVICE_URL} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} permit-paths: - /actuator/health - /swagger-ui/** 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 index 1a054fb2c..345210960 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java @@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat; class OverseasLastlegRouteContractTest { @Test - void platformDispatchPathIsForwardedToLastlegWithoutPrefixRewrite() throws IOException { + void browserAndPdaPathsAreForwardedToLastlegWithoutPrefixRewrite() throws IOException { try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) { assertThat(input).isNotNull(); String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); @@ -22,7 +22,20 @@ class OverseasLastlegRouteContractTest { assertThat(route).contains("uri: lb://lastleg-service") .contains("/api/platform/v1/last-leg/**") + .contains("/api/merchant/v1/last-leg/**") + .contains("/api/pda/v1/last-leg/**") .doesNotContain("StripPrefix", "RewritePath"); } } + + @Test + void swaggerPublishesAnIndependentLastlegPdaGroup() throws IOException { + try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) { + assertThat(input).isNotNull(); + String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + assertThat(yaml).contains("name: lastleg-pda", "url: /lastleg-service/v3/api-docs/pda") + .contains("Path=/lastleg-service/v3/api-docs/**") + .doesNotContain("event-bridge-service/v3/api-docs/pda"); + } + } } diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/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 index 8533dc7c7..fa9c07ea8 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java @@ -21,11 +21,22 @@ class RequiredClaimsJwtValidatorTest { assertThat(validator.validate(jwt(false)).hasErrors()).isTrue(); } + @Test + void rejectsMissingAccountTokenVersion() { + Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", "0").claim("app_code", "PLATFORM") + .claim("permission_version", 3L).claim("authorities", List.of()) + .build(); + assertThat(validator.validate(value).hasErrors()).isTrue(); + } + @Test void rejectsMissingAuthorities() { Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001") .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) - .claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", 3L) + .claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("token_version", 1) + .claim("permission_version", 3L) .build(); assertThat(validator.validate(value).hasErrors()).isTrue(); } @@ -54,6 +65,7 @@ class RequiredClaimsJwtValidatorTest { 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(); } @@ -72,6 +84,7 @@ class RequiredClaimsJwtValidatorTest { .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); 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..ff9f935f0 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java @@ -0,0 +1,83 @@ +package com.baiyue.gateway.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class OverseasSecurityProjectionFilterTest { + private final OverseasSecurityValidationClient client = mock(OverseasSecurityValidationClient.class); + private final OverseasSecurityProjectionFilter filter = new OverseasSecurityProjectionFilter(client, new ObjectMapper()); + + @Test void disabledUserFailsWithA0401() { + when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(false, "USER_DISABLED"))); + var exchange = exchange("/api/platform/v1/last-leg/orders"); + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(body(exchange)).contains("A0401"); + } + + @Test void disabledTenantAndStalePermissionFailWithA0403() { + for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) { + when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(false, reason))); + var exchange = exchange("/api/merchant/v1/last-leg/orders"); + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(body(exchange)).contains("A0403"); + } + } + + @Test void projectionUnavailableFailsClosed() { + when(client.validate(42, 0, 3, 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, 3, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(true, null))); + AtomicBoolean routed = new AtomicBoolean(); + StepVerifier.create(filter.filter(exchange("/api/platform/v1/last-leg/orders"), ignored -> { + routed.set(true); return Mono.empty(); + })).verifyComplete(); + assertThat(routed).isTrue(); + + var isolatedClient = mock(OverseasSecurityValidationClient.class); + var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper()); + StepVerifier.create(isolatedFilter.filter(exchange("/actuator/health"), ignored -> Mono.empty())).verifyComplete(); + verifyNoInteractions(isolatedClient); + } + + private static ServerWebExchange exchange(String path) { + Jwt jwt = Jwt.withTokenValue("verified").header("alg", "RS256").subject("42") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", 0L).claim("token_version", 3L).claim("permission_version", 8L).build(); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(path).build()); + return exchange.mutate().principal(Mono.just(new JwtAuthenticationToken(jwt, + List.of(new SimpleGrantedAuthority("ROLE_PLATFORM_USER"))))).build(); + } + + private static String body(ServerWebExchange exchange) { + return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse()) + .getBodyAsString().block(); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java new file mode 100644 index 000000000..ae9d3aa24 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java @@ -0,0 +1,119 @@ +package com.baiyue.gateway.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class PdaSessionValidationFilterTest { + private final PdaSessionValidationClient client = mock(PdaSessionValidationClient.class); + private final PdaSessionValidationFilter filter = new PdaSessionValidationFilter(client, new ObjectMapper()); + + @Test + void oldTokenIsRejectedImmediatelyAfterWarehouseChange() { + when(client.validate(42L, 7, 900L)).thenReturn(Mono.just( + new PdaSessionValidationClient.Result(false, "TOKEN_VERSION_MISMATCH"))); + var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(7, 900L)); + + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(body(exchange)).contains("A0401", "令牌已失效"); + } + + @Test + void newlyIssuedTokenWithCurrentWarehousePasses() { + when(client.validate(42L, 8, 901L)).thenReturn(Mono.just( + new PdaSessionValidationClient.Result(true, null))); + var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L)); + AtomicBoolean routed = new AtomicBoolean(); + + StepVerifier.create(filter.filter(exchange, ignored -> { + routed.set(true); + return Mono.empty(); + })).verifyComplete(); + + assertThat(routed).isTrue(); + assertThat(exchange.getResponse().getStatusCode()).isNull(); + } + + @Test + void unboundWarehouseIsDeniedWithoutCallingAuth() { + var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, null)); + + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(body(exchange)).contains("A0403", "未绑定仓库"); + verifyNoInteractions(client); + } + + @Test + void authUnavailableFailsClosed() { + when(client.validate(42L, 8, 901L)).thenReturn(Mono.error(new IllegalStateException("timeout"))); + var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L)); + + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(body(exchange)).contains("A0401", "校验不可用"); + } + + @Test + void ordinaryOverseasAdminAndClientPathsDoNotSynchronouslyValidate() { + for (String path : new String[]{"/api/platform/v1/last-leg/orders", "/api/merchant/v1/last-leg/orders"}) { + var exchange = exchange(path, claims(7, 900L)); + AtomicBoolean routed = new AtomicBoolean(); + StepVerifier.create(filter.filter(exchange, ignored -> { + routed.set(true); + return Mono.empty(); + })).verifyComplete(); + assertThat(routed).isTrue(); + } + verifyNoInteractions(client); + } + + private static ServerWebExchange exchange(String path, Map claims) { + Jwt.Builder jwt = Jwt.withTokenValue("already-verified-by-resource-server") + .header("alg", "RS256") + .subject("42") + .issuedAt(Instant.now().minusSeconds(5)) + .expiresAt(Instant.now().plusSeconds(300)); + claims.forEach(jwt::claim); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get(path).build()); + return exchange.mutate() + .principal(Mono.just(new JwtAuthenticationToken(jwt.build(), + List.of(new SimpleGrantedAuthority("ROLE_WAREHOUSE_PICKER"))))) + .build(); + } + + private static Map claims(int tokenVersion, Long warehouseId) { + var claims = new java.util.LinkedHashMap(); + claims.put("token_version", tokenVersion); + if (warehouseId != null) claims.put("overseas_warehouse_id", warehouseId); + return claims; + } + + private static String body(ServerWebExchange exchange) { + return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse()) + .getBodyAsString().block(); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java index 6867ebf88..acc77d13d 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java @@ -40,5 +40,13 @@ public final class AuthorizationDtos { 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/PlatformUserManagementDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java index 7c28c5dd7..79dae468b 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java @@ -20,16 +20,28 @@ public final class PlatformUserManagementDtos { @Email @Size(max = 128) String email, @NotNull Long tenantId, @NotNull List roleIds, + Long overseasWarehouseId, @NotBlank @Size(min = 8, max = 64) @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, - @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } + @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { + public CreateUserRequest(String username, String displayName, String phone, String email, + Long tenantId, List roleIds, String password, String status) { + this(username, displayName, phone, email, tenantId, roleIds, null, password, status); + } + } public record UpdateUserRequest( @NotBlank @Size(max = 64) String displayName, @NotBlank @Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确") String phone, @Email @Size(max = 128) String email, @NotNull Long tenantId, - @NotNull List roleIds) { } + @NotNull List roleIds, + Long overseasWarehouseId) { + public UpdateUserRequest(String displayName, String phone, String email, + Long tenantId, List roleIds) { + this(displayName, phone, email, tenantId, roleIds, null); + } + } public record ChangeStatusRequest(@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64) @@ -40,7 +52,7 @@ public final class PlatformUserManagementDtos { String displayName, String phone, String email, Long tenantId, String tenantName, String memberType, String memberNo, String status, List roleIds, List roleNames, LocalDateTime lastLoginAt, - boolean protectedAccount) { } + boolean protectedAccount, Long overseasWarehouseId) { } public record TenantOption(Long id, String name, String status) { } public record RoleOption(Long id, Long tenantId, String name, String status) { } diff --git a/iam/src/main/java/com/baiyue/iam/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/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index f38fff466..6d57e9632 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -65,4 +65,8 @@ public interface AuthorizationQueryMapper { @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/service/AuthorizationSnapshotService.java b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java index 1592dbcf0..df90f8b1e 100644 --- a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java +++ b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java @@ -47,7 +47,8 @@ public class AuthorizationSnapshotService { request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions), - mapper.findPermissionVersion(request.getUserId())); + mapper.findPermissionVersion(request.getUserId()), + mapper.findOverseasWarehouseId(request.getTenantId(), request.getMembershipId())); } /** diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 73a8e7fe9..296100ffb 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -75,6 +75,7 @@ public class PlatformUserManagementService { long tenantId = request.tenantId(); requirePlatformTenant(tenantId); List roleIds = validateRoles(tenantId, request.roleIds()); + Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId()); long authUserId = IdWorker.getId(); long userId = IdWorker.getId(); long membershipId = IdWorker.getId(); @@ -99,6 +100,7 @@ public class PlatformUserManagementService { membership.setUserId(userId); membership.setMemberType("EMPLOYEE"); membership.setMemberNo(memberNo(tenantId, membershipId)); + membership.setOverseasWarehouseId(overseasWarehouseId); membership.setStatus(request.status()); membership.setJoinedAt(LocalDateTime.now()); membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); @@ -121,6 +123,7 @@ public class PlatformUserManagementService { long newTenantId = request.tenantId(); requirePlatformTenant(newTenantId); List roleIds = validateRoles(newTenantId, request.roleIds()); + Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId()); List oldRoleIds = members.findRoleIds(oldTenantId, membershipId); if (isProtectedPlatformAdmin(membership, oldRoleIds) && (newTenantId != PLATFORM_TENANT_ID || !containsPlatformAdminRole(roleIds))) { @@ -134,6 +137,7 @@ public class PlatformUserManagementService { boolean profileChanged = !Objects.equals(user.getDisplayName(), request.displayName().trim()); boolean scopeChanged = oldTenantId != newTenantId; boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds)); + boolean warehouseChanged = !Objects.equals(membership.getOverseasWarehouseId(), overseasWarehouseId); if (profileChanged) call(() -> authClient.profile(internalToken, user.getAuthUserId(), new AuthAccountClient.ProfileRequest(request.displayName().trim()))); @@ -145,6 +149,7 @@ public class PlatformUserManagementService { user.setEmail(trimToNull(request.email())); user.setUserType(userType(newTenantId)); membership.setTenantId(newTenantId); + membership.setOverseasWarehouseId(overseasWarehouseId); if (scopeChanged && members.existsMemberNo(newTenantId, membership.getMemberNo(), membershipId)) { membership.setMemberNo(memberNo(newTenantId, membershipId)); } @@ -152,7 +157,7 @@ public class PlatformUserManagementService { members.save(membership); if (scopeChanged) members.replaceRoles(oldTenantId, membershipId, List.of()); members.replaceRoles(newTenantId, membershipId, roleIds); - if (rolesChanged && !profileChanged && !scopeChanged) { + if ((rolesChanged || warehouseChanged) && !profileChanged && !scopeChanged) { call(() -> authClient.revokeSessions(internalToken, new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); } @@ -222,7 +227,8 @@ public class PlatformUserManagementService { return new UserResponse(membership.getId(), user.getId(), user.getAuthUserId(), account.username(), user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName, membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), roleIds, - roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds)); + roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds), + membership.getOverseasWarehouseId()); } private List platformRoles() { @@ -245,6 +251,18 @@ public class PlatformUserManagementService { return roleIds; } + private Long validateWarehouseBinding(List roleIds, Long overseasWarehouseId) { + boolean picker = roleIds.stream().map(roleMapper::selectById).filter(Objects::nonNull) + .anyMatch(role -> "WAREHOUSE_PICKER".equals(role.getRoleCode())); + if (picker && (overseasWarehouseId == null || overseasWarehouseId <= 0)) { + throw new BizException(ResultCode.BAD_REQUEST, "海外仓拣货员必须绑定一个有效海外仓"); + } + if (!picker && overseasWarehouseId != null) { + throw new BizException(ResultCode.BAD_REQUEST, "仅海外仓拣货员可绑定海外仓"); + } + return overseasWarehouseId; + } + private boolean isProtectedPlatformAdmin(IamMembership membership, List roleIds) { return membership.getTenantId() == PLATFORM_TENANT_ID && containsPlatformAdminRole(roleIds); } diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java index d9372a2e2..fcc158420 100644 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java @@ -53,9 +53,10 @@ public class SecurityEventOutboxService { 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, EventIdentifiers.newUuid(), null, null, payload, hash).requireValid(); + 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, diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 65b0d2654..c2dfaffb3 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -114,6 +114,7 @@ CREATE TABLE IF NOT EXISTS iam_membership ( user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID', member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等', member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号', + overseas_warehouse_id BIGINT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开', joined_at DATETIME(3) NULL COMMENT '加入客户企业时间', left_at DATETIME(3) NULL COMMENT '离开客户企业时间', diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 5bd6ff291..dc8e69d70 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -19,6 +19,7 @@ VALUES (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), @@ -71,10 +72,122 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat 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.id BETWEEN 5107000 AND 5107299 AND permission.permission_code<>'lastleg:pda:operate' WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_role role ON role.id=role_permission.role_id AND role.tenant_id=role_permission.tenant_id +JOIN iam_permission permission ON permission.id=role_permission.permission_id AND permission.tenant_id=role_permission.tenant_id +WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER'); + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code='lastleg:pda:operate' +WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS assert_overseas_pda_picker_permission; +DELIMITER $$ +CREATE PROCEDURE assert_overseas_pda_picker_permission() +BEGIN + IF (SELECT COUNT(*) FROM iam_role_permission grant_row + JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE permission.permission_code='lastleg:pda:operate' + AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:WAREHOUSE_PICKER未精确授权'; + END IF; + IF EXISTS (SELECT 1 FROM iam_role_permission grant_row + JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非仓库拣货员授权'; + END IF; +END$$ +DELIMITER ; +CALL assert_overseas_pda_picker_permission(); +DROP PROCEDURE assert_overseas_pda_picker_permission; + +-- 四期仓库后台角色严格最小授权:list 同时覆盖列表与详情,只读角色不得获得任何写权限。 +DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission; +CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission ( + role_code VARCHAR(64) NOT NULL, + permission_code VARCHAR(160) NOT NULL, + PRIMARY KEY (role_code, permission_code) +); +INSERT INTO tmp_phase4_warehouse_role_permission (role_code, permission_code) VALUES + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:last-mile-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inbound-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:relabel-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inventory:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:warehouse:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:location:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:pallet:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:purchase:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:truck:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:looseboard:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:last-mile-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:inbound-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:relabel-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:inventory:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:warehouse:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:location:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:pallet:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:purchase:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:truck:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:looseboard:list'); + +DELETE grant_row FROM iam_role_permission grant_row +JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id +JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id +LEFT JOIN tmp_phase4_warehouse_role_permission expected + ON expected.role_code=role.role_code AND expected.permission_code=permission.permission_code +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_PICKER','WAREHOUSE_TALLY') + AND permission.permission_code LIKE 'platform:overseas-%' + AND expected.permission_code IS NULL; + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM tmp_phase4_warehouse_role_permission expected +JOIN iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0 +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=expected.permission_code + AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS assert_phase4_warehouse_role_minimum; +DELIMITER $$ +CREATE PROCEDURE assert_phase4_warehouse_role_minimum() +BEGIN + IF (SELECT COUNT(*) FROM tmp_phase4_warehouse_role_permission) <> 20 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限矩阵数量异常'; + END IF; + IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission + WHERE permission_code REGEXP ':(create|edit|status|export|adjust|delete|review)$') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库只读角色错误获得写权限'; + END IF; + IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission expected + LEFT JOIN iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0 + LEFT JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=expected.permission_code AND permission.status='ACTIVE' AND permission.deleted=0 + LEFT JOIN iam_role_permission grant_row ON grant_row.tenant_id=0 + AND grant_row.role_id=role.id AND grant_row.permission_id=permission.id + WHERE grant_row.role_id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase4_warehouse_role_minimum(); +DROP PROCEDURE assert_phase4_warehouse_role_minimum; +DROP TEMPORARY TABLE tmp_phase4_warehouse_role_permission; + INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role diff --git a/iam/src/main/resources/db/phase3-warehouse-roles.sql b/iam/src/main/resources/db/phase3-warehouse-roles.sql index 75bf068e1..5f39d36cf 100644 --- a/iam/src/main/resources/db/phase3-warehouse-roles.sql +++ b/iam/src/main/resources/db/phase3-warehouse-roles.sql @@ -140,6 +140,7 @@ DELETE role_permission WHERE role.tenant_id = 0 AND role.app_code = 'PLATFORM' AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY') + AND NOT (role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate') AND expected.permission_code IS NULL; INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 5f5b22b77..4fa22a7e6 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -886,7 +886,7 @@ components: TenantCreationSuccess: {description: Enterprise tenant and one-time owner credentials returned / 返回企业客户及一次性管理员凭据, content: {application/json: {schema: {$ref: '#/components/schemas/TenantCreationApiResponse'}}}} BadRequest: {description: Invalid request / 请求参数错误, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Unauthorized: {description: Authentication required / 需要登录, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} - Forbidden: {description: Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + Forbidden: {description: 'Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配', content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} NotFound: {description: Resource not found / 资源不存在, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Conflict: {description: Resource conflict / 资源冲突, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} ServiceUnavailable: {description: Authentication service unavailable / 认证服务不可用, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} @@ -1279,6 +1279,7 @@ components: email: {type: string, format: email, maxLength: 128} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} + overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择WAREHOUSE_PICKER角色时必填且只能绑定一个实际海外仓;其他角色必须为空} password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} status: {type: string, enum: [ACTIVE, DISABLED]} UpdatePlatformManagedUserRequest: @@ -1290,6 +1291,7 @@ components: email: {type: string, format: email, maxLength: 128} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} + overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择WAREHOUSE_PICKER角色时必填且只能绑定一个实际海外仓;其他角色必须为空} CreateInternalUserRequest: type: object required: [displayName] @@ -1362,6 +1364,7 @@ components: roleNames: {type: array, items: {type: string}} lastLoginAt: {type: string, format: date-time, nullable: true} protectedAccount: {type: boolean} + overseasWarehouseId: {type: string, pattern: '^\d+$', nullable: true, description: 海外仓PDA操作员绑定的唯一实际海外仓ID} CreateMemberRequest: type: object required: [username, password, displayName, memberType] diff --git a/iam/src/main/resources/openapi/notification-v1.yaml b/iam/src/main/resources/openapi/notification-v1.yaml index 99f5f992f..e184472d0 100644 --- a/iam/src/main/resources/openapi/notification-v1.yaml +++ b/iam/src/main/resources/openapi/notification-v1.yaml @@ -60,7 +60,7 @@ paths: - {$ref: '#/components/parameters/Size'} - {name: templateCode, in: query, schema: {type: string, maxLength: 64}} - {name: category, in: query, schema: {type: string, maxLength: 64}} - - {name: title, in: query, description: Fuzzy match template name or title template, schema: {type: string, maxLength: 128}} + - {name: title, in: query, description: 'Fuzzy match template name or title template', schema: {type: string, maxLength: 128}} - {name: status, in: query, schema: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}} responses: {'200': {$ref: '#/components/responses/TemplatePage'}} post: @@ -92,7 +92,7 @@ paths: - {$ref: '#/components/parameters/Size'} - {name: taskNo, in: query, schema: {type: string, maxLength: 64}} - {name: category, in: query, schema: {type: string, maxLength: 64}} - - {name: title, in: query, description: Fuzzy match rendered title, schema: {type: string, maxLength: 128}} + - {name: title, in: query, description: 'Fuzzy match rendered title', schema: {type: string, maxLength: 128}} - {name: status, in: query, schema: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}} responses: {'200': {$ref: '#/components/responses/TaskPage'}} /platform/v1/notification-tasks/{id}: @@ -145,17 +145,27 @@ components: tenantId: {type: integer, format: int64, minimum: 0} templateCode: {type: string, maxLength: 64} recipientType: {type: string, enum: [USER, MEMBERSHIP, TENANT_ROLE, TENANT_ALL, PLATFORM_ROLE]} - recipientIds: {type: array, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types.} - roleCode: {type: string, maxLength: 64, description: Required for TENANT_ROLE or PLATFORM_ROLE; forbidden otherwise.} + recipientIds: {type: array, minItems: 1, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types.} + roleCode: {type: string, minLength: 1, maxLength: 64, description: Required for TENANT_ROLE or PLATFORM_ROLE; forbidden otherwise.} variables: {type: object, maxProperties: 32, additionalProperties: {type: string, maxLength: 2000}} scheduledAt: {type: string, format: date-time, nullable: true} idempotencyKey: {type: string, minLength: 8, maxLength: 128} sourceEventId: {type: string, maxLength: 128, nullable: true} - allOf: - - if: {properties: {recipientType: {enum: [USER, MEMBERSHIP]}}} - then: {required: [recipientIds]} - - if: {properties: {recipientType: {enum: [TENANT_ROLE, PLATFORM_ROLE]}}} - then: {required: [roleCode]} + oneOf: + - required: [recipientIds] + properties: + recipientType: {type: string, enum: [USER, MEMBERSHIP]} + not: {required: [roleCode]} + - required: [roleCode] + properties: + recipientType: {type: string, enum: [TENANT_ROLE, PLATFORM_ROLE]} + not: {required: [recipientIds]} + - properties: + recipientType: {type: string, enum: [TENANT_ALL]} + not: + anyOf: + - required: [recipientIds] + - required: [roleCode] CreateTemplateRequest: type: object required: [templateCode, templateName, category, titleTemplate, bodyTemplate, variableWhitelist] diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java index af6a9a172..d514112cd 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java @@ -69,6 +69,33 @@ class NotificationOpenApiContractTest { assertThat(map(map(taskDetail.get("properties")).get("attemptCount"))) .containsEntry("minimum", 0).containsEntry("maximum", 3); assertThat(document).contains("scheduler delivery starts after the retry delay"); + + Map createNotification = map(schemas.get("CreateNotificationRequest")); + Map selectorProperties = map(createNotification.get("properties")); + assertThat(map(selectorProperties.get("recipientIds"))).containsEntry("minItems", 1); + assertThat(map(selectorProperties.get("roleCode"))).containsEntry("minLength", 1); + List selectorBranches = list(createNotification.get("oneOf")); + assertThat(selectorBranches).hasSize(3); + + Map directRecipients = map(selectorBranches.get(0)); + assertThat(list(directRecipients.get("required"))).containsExactly("recipientIds"); + assertThat(list(map(directRecipients.get("not")).get("required"))).containsExactly("roleCode"); + assertThat(list(map(map(directRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("USER", "MEMBERSHIP"); + + Map roleRecipients = map(selectorBranches.get(1)); + assertThat(list(roleRecipients.get("required"))).containsExactly("roleCode"); + assertThat(list(map(roleRecipients.get("not")).get("required"))).containsExactly("recipientIds"); + assertThat(list(map(map(roleRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("TENANT_ROLE", "PLATFORM_ROLE"); + + Map allRecipients = map(selectorBranches.get(2)); + assertThat(list(map(map(allRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("TENANT_ALL"); + assertThat(list(map(allRecipients.get("not")).get("anyOf"))) + .extracting(value -> list(map(value).get("required"))) + .containsExactly(List.of("recipientIds"), List.of("roleCode")); + assertThat(document).doesNotContain("if:").doesNotContain("then:"); } private void assertRequestBody(Map paths, String path, String method) { diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index c333f01e1..2182c43f1 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -39,6 +39,8 @@ class OverseasPhase4PermissionSqlContractTest { .getContentAsString(StandardCharsets.UTF_8); Set catalog = codes(sql); assertThat(sql).contains(REQUIRED.toArray(String[]::new)); + assertThat(sql).contains("'lastleg:pda:operate'"); + assertPdaPickerGrant(sql); assertThat(catalog).hasSize(53).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:"); @@ -53,10 +55,40 @@ class OverseasPhase4PermissionSqlContractTest { 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"); + assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'"); + assertPdaPickerGrant(sql); assertCustomAdminGrant(sql); } + @Test + void warehouseRolesReceiveOnlyTheTenPhase4ListPermissionsAndPickerRemainsPdaOnly() throws Exception { + String moduleSql = new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(path)) path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"); + String unifiedSql = Files.readString(path, StandardCharsets.UTF_8); + + for (String sql : List.of(moduleSql, unifiedSql)) { + String matrix = matrixBlock(sql); + assertThat(matrix).contains("WAREHOUSE_OPERATOR", "WAREHOUSE_TALLY"); + assertThat(codes(matrix)).containsExactlyInAnyOrder( + "platform:overseas-warehouse:last-mile-order:list", + "platform:overseas-warehouse:inbound-order:list", + "platform:overseas-warehouse:relabel-order:list", + "platform:overseas-warehouse:inventory:list", + "platform:overseas-warehouse:warehouse:list", + "platform:overseas-warehouse:location:list", + "platform:overseas-warehouse:pallet:list", + "platform:overseas-business:purchase:list", + "platform:overseas-business:truck:list", + "platform:overseas-business:looseboard:list"); + assertThat(matrix).doesNotContain("WAREHOUSE_PICKER"); + assertThat(matrix).doesNotContain(":create'", ":edit'", ":status'", ":export'", ":adjust'"); + assertThat(sql).contains("assert_phase4_warehouse_role_minimum", "<> 20", + "role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_PICKER','WAREHOUSE_TALLY')"); + } + } + @Test void servicePermissionConstantsExactlyMatchTheIamCatalog() throws Exception { Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") @@ -88,6 +120,32 @@ class OverseasPhase4PermissionSqlContractTest { .containsExactlyInAnyOrderElementsOf(CUSTOM_ADMIN_PERMISSIONS); } + private static void assertPdaPickerGrant(String sql) { + assertThat(sql).contains( + "permission.permission_code<>'lastleg:pda:operate'", + "role.id=4000005", + "assert_overseas_pda_picker_permission", + "存在非仓库拣货员授权", + "role.role_code='WAREHOUSE_PICKER'", + "permission.permission_code='lastleg:pda:operate'", + "role.role_code='PLATFORM_ADMIN'", + "DELETE role_permission FROM"); + int picker = sql.indexOf("role.role_code='WAREHOUSE_PICKER'"); + int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", picker); + int grantEnd = sql.indexOf(';', picker); + assertThat(grantStart).isGreaterThanOrEqualTo(0); + assertThat(grantEnd).isGreaterThan(picker); + assertThat(sql.substring(grantStart, grantEnd)).contains("lastleg:pda:operate"); + } + + private static String matrixBlock(String sql) { + int start = sql.indexOf("INSERT INTO tmp_phase4_warehouse_role_permission"); + int end = sql.indexOf("DELETE grant_row FROM", start); + assertThat(start).isGreaterThanOrEqualTo(0); + assertThat(end).isGreaterThan(start); + return sql.substring(start, end); + } + private static String repositoryFile(String relativePath) throws Exception { Path path = Path.of(relativePath); if (!Files.exists(path)) path = Path.of("..").resolve(relativePath); diff --git a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java index 9cb8406a9..36786af3e 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java @@ -45,6 +45,7 @@ class WarehouseRoleSqlContractTest { .contains("依赖的三期平台权限尚未完整部署") .contains("DELETE role_permission") .contains("expected.permission_code IS NULL") + .contains("role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate'") .contains("最小权限授权未完整写入") .contains("grant_row.tenant_id = 0) <> 19") .contains("(6000004, 4000004, 0, 'TENANT', NULL)") diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index 550066f8d..eeab42b78 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -29,6 +29,7 @@ class AuthorizationSnapshotServiceTest { .thenReturn(List.of("ADMIN", "OPERATOR")); when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")) .thenReturn(List.of("order:read", "order:create")); + when(mapper.findOverseasWarehouseId(7L, 9L)).thenReturn(900L); var result = service .load(new SnapshotRequest(42L, 7L, 9L, "merchant")); @@ -37,6 +38,7 @@ class AuthorizationSnapshotServiceTest { assertThat(result.getClientType()).isEqualTo("MERCHANT"); assertThat(result.getRoles()).containsExactlyInAnyOrder("ADMIN", "OPERATOR"); assertThat(result.getPermissions()).containsExactlyInAnyOrder("order:read", "order:create"); + assertThat(result.getOverseasWarehouseId()).isEqualTo(900L); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 42df71a34..4dd7ddfb6 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -57,6 +57,7 @@ class PlatformUserManagementServiceTest { when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( new AuthAccountClient.AccountSummary(10L, "zhangwei", "张伟", "ACTIVE", LocalDateTime.of(2026, 6, 1, 10, 30), 0L, 30L, "PLATFORM"))); + when(auth.revokeSessions(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); } @Test @@ -117,6 +118,51 @@ class PlatformUserManagementServiceTest { verify(auth, never()).scope(anyString(), anyLong(), any()); } + @Test + void warehousePickerRequiresAndPersistsSingleOverseasWarehouseBinding() { + platformRole.setRoleCode("WAREHOUSE_PICKER"); + UpdateUserRequest missing = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), null); + assertThrows(BizException.class, + () -> service.update(30L, missing, Set.of("platform:user:grant-role"))); + + UpdateUserRequest bound = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), 900L); + UserResponse response = service.update(30L, bound, Set.of("platform:user:grant-role")); + + assertEquals(900L, membership.getOverseasWarehouseId()); + assertEquals(900L, response.overseasWarehouseId()); + verify(auth).revokeSessions(eq("internal-token"), any()); + } + + @Test + void changingWarehouseBindingRevokesAllAuthSessionsSoAccountVersionIsBumped() { + platformRole.setRoleCode("WAREHOUSE_PICKER"); + membership.setOverseasWarehouseId(900L); + UpdateUserRequest changed = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), 901L); + + UserResponse response = service.update(30L, changed, Set.of("platform:user:grant-role")); + + assertEquals(901L, response.overseasWarehouseId()); + verify(auth).revokeSessions(eq("internal-token"), + eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L)))); + } + + @Test + void clearingWarehouseBindingAlsoRevokesAllAuthSessions() { + platformRole.setRoleCode("WAREHOUSE_OPERATOR"); + membership.setOverseasWarehouseId(900L); + UpdateUserRequest cleared = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), null); + + UserResponse response = service.update(30L, cleared, Set.of("platform:user:grant-role")); + + assertNull(response.overseasWarehouseId()); + verify(auth).revokeSessions(eq("internal-token"), + eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L)))); + } + @Test void currentPlatformOperatorCannotDisableSelf() { assertThrows(BizException.class, diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java new file mode 100644 index 000000000..432587f3f --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java @@ -0,0 +1,46 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.event.EventEnvelope; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SecurityEventOutboxServiceTest { + @Mock JdbcTemplate jdbc; + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private SecurityEventOutboxService service; + + @BeforeEach + void setUp() { + service = new SecurityEventOutboxService(); + ReflectionTestUtils.setField(service, "jdbc", jdbc); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + } + + @Test + void userStatusWritesAValidEnvelopeWithCorrelationId() throws Exception { + when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(1L); + + service.userStatus(101L, 0L, "PLATFORM", "DISABLED"); + + ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " + + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); + EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); + assertThat(envelope.requireValid().correlationId()).isNotBlank().isEqualTo(envelope.traceId()); + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 1d14a5980..0c374a698 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -12,7 +12,7 @@ |---|---| | 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | -| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端按冻结字段校验后建单 | +| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | | 订单查询与更新 | `service/LastlegOrderService.java` | | 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | @@ -22,10 +22,12 @@ | 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | -| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java`;入口固定为 `/api/pda/v1/last-leg/**`,不得挂入平台浏览器 API | -| PDA 卸柜照片文件上传与归属校验 | `InboundPdaController.java` 的 `/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` | -| 卸柜照片与海外卸柜完成 Outbox | `inboundplan/pda/controller/InboundPdaController.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,旧 planNo/allocationId Controller 已移除 | +| PDA 卸柜照片文件上传与归属校验 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` | +| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | +| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 | +| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | @@ -73,6 +75,7 @@ - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 - PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 +- 海外 PDA 柜号签收只允许平台 `WAREHOUSE_PICKER` 使用 `lastleg:pda:operate`;PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。 ## 查询与租户约束 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..0acced4c7 --- /dev/null +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -0,0 +1,199 @@ +# Lastleg 内部模块化依赖基线与首切片方案 + +## 1. 范围与生成方式 + +本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务代码、POM、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在 RestClient 超时专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。 + +可重复命令: + +```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 图有 67 个包节点但没有测试类之间的内部边。`all` 把 `src/main/java` 和 `src/test/java` 合并解析,是观察测试对生产类型耦合的主图。Spring 扫描字符串、反射、MyBatis SQL 表引用和跨服务运行时调用不属于 Java 类型图。 + +## 2. 当前规模与 SCC + +| 源集 | Java 文件 | 内部类依赖 | 物理包 | 跨切片类依赖 | 模型泄漏 | Mapper 泄漏 | +|---|---:|---:|---:|---:|---:|---:| +| main | 347 | 672 | 157 | 137 | 60 | 38 | +| test only | 98 | 0 | 67 | 0 | 0 | 0 | +| main + test | 445 | 1,195 | 161 | 256 | 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` | 26 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 | + +另有 6 个 `shared-technical` 类:应用启动类、Event Bridge Client、RestClient 配置、内部令牌保护和海外权限常量。该分类不授权移动这些共享类。 + +## 4. 主源码逻辑切片边 + +| 来源 | 目标 | 类边数 | +|---|---|---:| +| dispatch-delivery | inbound-pda | 6 | +| dispatch-delivery | inventory | 4 | +| dispatch-delivery | shared-technical | 2 | +| dispatch-delivery | tailleg-handoff | 8 | +| dropship | inbound-pda | 1 | +| dropship | shared-technical | 1 | +| dropship | tailleg-handoff | 2 | +| inbound-pda | dropship | 1 | +| inbound-pda | inventory | 25 | +| inbound-pda | shared-technical | 8 | +| inbound-pda | tailleg-handoff | 19 | +| inventory | inbound-pda | 1 | +| inventory | shared-technical | 2 | +| overseas-master-data | shared-technical | 3 | +| procurement-trucking-looseboard | overseas-master-data | 1 | +| procurement-trucking-looseboard | shared-technical | 5 | +| procurement-trucking-looseboard | tailleg-handoff | 5 | +| relabel-transfer-discard | dropship | 8 | +| relabel-transfer-discard | inventory | 6 | +| relabel-transfer-discard | shared-technical | 3 | +| relabel-transfer-discard | tailleg-handoff | 14 | +| tailleg-handoff | dropship | 2 | +| tailleg-handoff | inbound-pda | 6 | +| tailleg-handoff | inventory | 2 | +| tailleg-handoff | shared-technical | 2 | + +当前 25 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `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`:冻结 25 条 main 切片边;历史边可减少,不可增加。 +- `scripts/analyze-lastleg-dependencies.test.mjs`:校验基线、main/test/all 包图输出,以及车辆候选的 5 类、零入向、唯一权限出向、零 Client/RestClient 和不进入 SCC。 + +最小验证: + +```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 个环境验收跳过,构建成功。 + +首切片完成后停止,不进入第二切片。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs new file mode 100644 index 000000000..c639c1bcd --- /dev/null +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -0,0 +1,326 @@ +#!/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 = [ + ['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..bb8a6a945 --- /dev/null +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -0,0 +1,76 @@ +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, []); +}); 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 index e6ab0d6fa..5a09c0bc4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestHeader; /** 将海外业务 Outbox 投递到国内区域事件桥。 */ @FeignClient(name = "event-bridge-service", contextId = "overseasContainerEventBridgeClient", - url = "${baiyue.firstleg.event-bridge-url:}") + url = "${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}") public interface EventBridgeClient { @PostMapping("/internal/event-bridge/events") ApiResponse receive( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java new file mode 100644 index 000000000..298124a59 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.config; + +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.net.http.HttpClient; + +@Component +public class LastlegRestClientFactory { + public RestClient build(RestClient.Builder builder, String baseUrl, + LastlegRestClientProperties.ClientTimeouts timeouts) { + JdkClientHttpRequestFactory requestFactory = requestFactory(timeouts); + return builder.requestFactory(requestFactory).baseUrl(baseUrl).build(); + } + + HttpClient httpClient(LastlegRestClientProperties.ClientTimeouts timeouts) { + return HttpClient.newBuilder() + .connectTimeout(timeouts.getConnectTimeout()) + .build(); + } + + JdkClientHttpRequestFactory requestFactory(LastlegRestClientProperties.ClientTimeouts timeouts) { + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient(timeouts)); + requestFactory.setReadTimeout(timeouts.getReadTimeout()); + return requestFactory; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java new file mode 100644 index 000000000..be6fd6900 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@ConfigurationProperties(prefix = "baiyue.http.clients") +public class LastlegRestClientProperties { + private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); + private ClientTimeouts palletPhysical = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); + + public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; } + public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); } + public ClientTimeouts getRelabelPhotoFile() { return relabelPhotoFile; } + public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); } + public ClientTimeouts getWarehouseResource() { return warehouseResource; } + public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); } + public ClientTimeouts getPalletPhysical() { return palletPhysical; } + public void setPalletPhysical(ClientTimeouts value) { palletPhysical = required(value, "pallet-physical"); } + + private static ClientTimeouts required(ClientTimeouts value, String client) { + if (value == null) throw new IllegalArgumentException(client + " HTTP timeouts are required"); + return value; + } + + public static class ClientTimeouts { + private Duration connectTimeout; + private Duration readTimeout; + + public ClientTimeouts() { } + + public ClientTimeouts(Duration connectTimeout, Duration readTimeout) { + setConnectTimeout(connectTimeout); + setReadTimeout(readTimeout); + } + + public Duration getConnectTimeout() { return connectTimeout; } + public void setConnectTimeout(Duration value) { connectTimeout = positive(value, "connect-timeout"); } + public Duration getReadTimeout() { return readTimeout; } + public void setReadTimeout(Duration value) { readTimeout = positive(value, "read-timeout"); } + + private static Duration positive(Duration value, String name) { + if (value == null || value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } + } +} + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 0769880ca..7fd3375d5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -11,6 +11,8 @@ import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; +import com.baiyue.common.event.EventTypes; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -37,16 +39,26 @@ import java.nio.charset.StandardCharsets; @Tag(name = "Tail-leg orders") public class LastlegOrderController { @Resource private LoadingCompletedConsumer consumer; + @Resource private ContainerShippingNodeConsumer shippingNodeConsumer; @Resource private LastlegOrderService orderService; @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping("/internal/lastleg/events") - @Operation(summary = "Receive a first-leg loading-completed event") + @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); - return ApiResponse.success(consumer.consume(event)); + int consumed; + if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) { + consumed = shippingNodeConsumer.consume(event); + } else if (EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(event.eventType())) { + consumed = consumer.consume(event); + } else { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Lastleg 不消费该事件类型: " + event.eventType()); + } + return ApiResponse.success(consumed); } @GetMapping("/api/platform/v1/last-leg/orders") 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 index bfe09c241..fb5b95346 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -11,6 +11,8 @@ public record LastlegOrderSummary( String orderNo, String orderType, String orderTypeName, + @Schema(description = "跨区冻结目的类型", allowableValues = {"FBA", "OVERSEAS"}) + String destinationType, String sourceInboundId, String inboundCode, String sourceOrderNo, 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 index 5c9927d88..7c7ce0bd6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java @@ -13,7 +13,6 @@ import java.time.LocalDateTime; @TableName("ll_inbound_order") public class LastlegInboundOrder { @TableId private Long id; - private String tenantId; private String customerId; private String sourceRegion; private String sourceInboundId; @@ -25,6 +24,7 @@ public class LastlegInboundOrder { private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; + private String destinationType; private Integer packageCount; private Integer productQuantity; private Integer palletCount; 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 index 903ab3afb..89234d307 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -15,6 +15,7 @@ public class LastlegOrder { private String orderNo; private String customerId; private String orderType; + private String destinationType; private String inboundCode; private String sourceRegion; private String sourceInboundId; @@ -23,6 +24,8 @@ public class LastlegOrder { private String sourceOrderNo; private String rootOrderId; private String customerName; + private String sourceWarehouseId; + private String sourceWarehouseName; private String overseasWarehouseId; private String overseasWarehouseName; private String fbaWarehouseCode; 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 index ec6ccd5ad..79ffac343 100644 --- 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 @@ -17,6 +17,7 @@ public class InboundPlan { private String customerId; private Long sourceOrderId; private String containerNo; + private Long overseasWarehouseId; private String status; private Integer lineCount; private Integer palletCount; 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 index 21026bf8d..6e8cd25d4 100644 --- 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 @@ -16,6 +16,7 @@ public class InboundPlanLine { private Long planId; private Long orderId; private String inboundCode; + private String destinationType; private String fbaWarehouseCode; private String destinationWarehouseId; private String destinationWarehouseCode; 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 index f95d5cbc7..742781c8a 100644 --- 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 @@ -6,6 +6,8 @@ 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(""" @@ -31,4 +33,13 @@ public interface InboundPlanCartonMapper extends BaseMapper { 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/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java index 68bc44b2c..534969f23 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -7,6 +7,7 @@ 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; @@ -47,4 +48,10 @@ public interface InboundPlanMapper extends BaseMapper { @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1") InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId); + + @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/pda/controller/InboundPalletAssignmentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java deleted file mode 100644 index eb33d0f29..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java +++ /dev/null @@ -1,43 +0,0 @@ -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.PdaRequests; -import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService; -import jakarta.validation.Valid; -import jakarta.annotation.Resource; -import org.springframework.http.HttpStatus; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -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/pda/v1/last-leg/inbound-plans/{planNo}/pallets") -public class InboundPalletAssignmentController { - @Resource private InboundPdaWorkflowService service; - - @PostMapping("/{planPalletId}/assign") - public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, @PathVariable long planPalletId, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.Assign request) { - service.assign(scope(user, customerId), planNo, planPalletId, request); - return ApiResponse.success(null); - } - - private String scope(AuthenticatedUser user, String requestedCustomer) { - if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { - if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); - } - return requestedCustomer; - } - return String.valueOf(user.tenantId()); - } -} 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..b87e1a101 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -0,0 +1,136 @@ +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.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.util.List; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/receiving-containers") +@Tag(name = "Overseas PDA container receiving") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class ReceivingContainerController { + public static final String OPERATE_PERMISSION = PdaWarehouseIdentity.OPERATE_PERMISSION; + @Resource private ReceivingContainerService service; + + @GetMapping("/candidates") + @Operation(summary = "List pending receiving containers across customers for the authenticated warehouse picker") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user) { + PdaWarehouseIdentity identity = identity(user); + return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId())); + } + + @GetMapping("/{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)); + } + + @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/skus/resolve") + public ApiResponse resolveSku( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.SkuResolve request) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.resolveSku(identity.operatorKey(), request)); + } + + @PostMapping("/current/pallets/resolve") + public ApiResponse resolvePallet( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.PalletResolve request) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request)); + } + + @PostMapping("/current/receipts/bulk") + public ApiResponse receiveBulk(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.BulkReceive request) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request)); + } + + @PostMapping("/current/receipts/carton") + public ApiResponse receiveCarton(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.CartonReceive request) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request)); + } + + @PostMapping("/current/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.Action request) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.complete(identity.operatorKey(), request)); + } + + private static PdaWarehouseIdentity identity(AuthenticatedUser user) { + return PdaWarehouseIdentity.require(user); + } + + private PdaWarehouseIdentity currentIdentity(AuthenticatedUser user) { + PdaWarehouseIdentity identity = identity(user); + service.requireCurrentWarehouse(identity.operatorKey(), identity.overseasWarehouseId()); + return identity; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java index 268374c33..a1fce95bd 100644 --- 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 @@ -12,7 +12,9 @@ public final class PdaResponses { List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount, int pickedPackageCount, String orderType, boolean wholePalletInbound, - List dropshipPallets, String inboundMode) { + 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, @@ -20,7 +22,8 @@ public final class PdaResponses { int pickedPackageCount) { this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt, unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt, - expectedPackageCount, pickedPackageCount, null, false, List.of(), null); + expectedPackageCount, pickedPackageCount, null, false, List.of(), null, + List.of(), List.of(), List.of()); } public Task(long taskId, String taskNo, String planNo, String status, @@ -36,6 +39,21 @@ public final class PdaResponses { 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, 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..4b3e93b8b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public final class ReceivingContainerRequests { + private ReceivingContainerRequests() { } + + public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { } + public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos, + @NotBlank @Size(max = 255) String idempotencyKey) { } + public record SignedPhoto(@NotNull ReceivingPhotoRole role, @Positive long fileId) { } + public record CartonResolve(@NotBlank String cartonMark) { } + public record SkuResolve(@NotBlank @Size(max = 128) String skuCode) { } + public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { } + public record BulkReceive(@NotBlank String inboundCode, @NotBlank @Size(max = 128) String skuCode, + @NotBlank String palletCode, + @Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { } + public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode, + @NotBlank @Size(max = 255) String idempotencyKey) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java new file mode 100644 index 000000000..656c83a1a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class ReceivingContainerResponses { + private ReceivingContainerResponses() { } + + public record Candidate(String containerNo, String status, int planCount, int inboundCodeCount, + List destinationCodes, int plannedPalletCount, + int totalPackages, int receivedPackages, boolean currentAccount) { } + + public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName, + String status, String operatorName, int version, + List photos, LocalDateTime preparedAt, LocalDateTime signedAt, + LocalDateTime receivingAt, LocalDateTime completedAt, + int totalPackages, int receivedPackages, int pendingPackages, + List inbounds) { } + + public record SignedPhoto(ReceivingPhotoRole role, long fileId) { } + + public record Inbound(String inboundCode, String destinationType, + String overseasWarehouseId, String overseasWarehouseName, + String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + String fbaWarehouseCode, int plannedPalletCount, int totalPackages, + int receivedPackages, int pendingPackages) { } + + public record CartonResolved(String cartonMark, String skuCode, String inboundCode, + String destinationType, String overseasWarehouseId, + String overseasWarehouseName, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + String fbaWarehouseCode, int plannedPalletCount, int totalPackages, + int receivedPackages, int pendingPackages) { } + + public record SkuResolved(String skuCode, String productName, String inboundCode, + String destinationType, String overseasWarehouseId, + String overseasWarehouseName, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + String fbaWarehouseCode, int plannedPalletCount, int totalPackages, + int receivedPackages, int pendingPackages) { } + + public record PalletResolved(String palletCode, long warehouseId, String status, + String fbaWarehouseCode, String inboundCode, + String destinationType, boolean compatible) { } + + public record Receipt(String result, String containerNo, String inboundCode, + String destinationType, String palletCode, int acceptedPackages, + int receivedPackages, int pendingPackages, String sessionStatus) { } + + public record Photo(long 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/ReceivingContainerOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java new file mode 100644 index 000000000..18d2bfcac --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_receiving_container_operation") +public class ReceivingContainerOperation { + @TableId(type = IdType.INPUT) + private Long id; + private Long sessionId; + private String customerId; + private String operatorKey; + private String action; + private String idempotencyKey; + private String requestHash; + private String requestJson; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java new file mode 100644 index 000000000..388bcf420 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_receiving_container_session") +public class ReceivingContainerSession { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String containerNo; + private Long overseasWarehouseId; + private String overseasWarehouseName; + private String operatorKey; + private String operatorName; + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String activeOperatorKey; + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String activeContainerKey; + private String status; + private String signPhotos; + private LocalDateTime preparedAt; + private LocalDateTime signedAt; + private LocalDateTime receivingAt; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @Version + private Integer version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingSkuReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingSkuReceipt.java new file mode 100644 index 000000000..91ab80bf0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingSkuReceipt.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_receiving_sku_receipt") +public class ReceivingSkuReceipt { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long sessionId; + private Long planId; + private Long lineId; + private Long palletId; + private Long orderId; + private String inboundCode; + private String skuCode; + private String productName; + private Integer receivedPackages; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @Version + private Integer version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java index debcb721b..6a8b0c243 100644 --- 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 @@ -1,15 +1,19 @@ 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.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; @@ -17,12 +21,17 @@ import java.io.IOException; 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 = restClientBuilder.baseUrl(baseUrl).build(); } + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getUnloadPhotoFile()); + } @Override public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { @@ -42,10 +51,14 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway { .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 IllegalArgumentException("Unload photo upload failed"); + 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 IllegalArgumentException("File service returned no photo metadata"); + 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()); } @@ -58,22 +71,40 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway { tenantId, BUSINESS_TYPE, taskId, fileId) .header("X-Internal-Service-Token", internalToken) .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { - throw new IllegalArgumentException("Unload photo does not exist or its ownership is invalid"); + 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 IllegalArgumentException("Unload photo metadata does not match the PDA task"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Unload photo metadata does not match the PDA task"); } } private long tenantId(String customerId) { try { return Long.parseLong(customerId); } - catch (NumberFormatException exception) { throw new IllegalArgumentException("Customer ID must be numeric", exception); } + catch (NumberFormatException exception) { throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Customer ID must be numeric", exception); } } - private record MetadataEnvelope(Metadata data) {} + 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/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 1a5ebcf0c..ea792ea46 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -1,9 +1,14 @@ 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; @@ -12,6 +17,8 @@ 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; @@ -23,7 +30,8 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { @PostConstruct void initializeClient() { - this.client = restClientBuilder.baseUrl(baseUrl).build(); + this.client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getWarehouseResource()); } @Override @@ -35,7 +43,9 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { 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().locationId()); + return new WarehousePalletReference(response.data().id(), response.data().palletCode(), + response.data().warehouseId(), response.data().locationId(), response.data().status(), + response.data().fbaWarehouseCode(), response.data().version()); } @Override @@ -69,6 +79,19 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { .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() @@ -82,8 +105,79 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { .toBodilessEntity(); } + @Override + public WarehousePalletReference resolvePallet(String palletCode) { + WarehousePalletResponse response = client.get() + .uri("/internal/v1/warehouse-pallets/resolve/{palletCode}", palletCode) + .header("X-Internal-Service-Token", internalToken) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalArgumentException("Warehouse rejected pallet code: HTTP " + result.getStatusCode()); + }).body(WarehousePalletResponse.class); + if (response == null || response.data() == null) throw new IllegalArgumentException("Warehouse returned an empty pallet response"); + return new WarehousePalletReference(response.data().id(), response.data().palletCode(), + response.data().warehouseId(), response.data().locationId(), response.data().status(), + response.data().fbaWarehouseCode(), response.data().version()); + } + + @Override + public WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) { + WarehouseLocationResponse response = client.get() + .uri(uri -> uri.path("/internal/v1/warehouse-resource-locks/locations/resolve/{locationCode}") + .queryParam("warehouseId", warehouseId).build(locationCode)) + .header("X-Internal-Service-Token", internalToken).retrieve() + .body(WarehouseLocationResponse.class); + return location(response); + } + + @Override + public WarehouseLocationReference resolveLocation(long warehouseId, long locationId) { + WarehouseLocationResponse response = client.get() + .uri(uri -> uri.path("/internal/v1/warehouse-resource-locks/locations/{locationId}") + .queryParam("warehouseId", warehouseId).build(locationId)) + .header("X-Internal-Service-Token", internalToken).retrieve() + .body(WarehouseLocationResponse.class); + return location(response); + } + + private WarehouseLocationReference location(WarehouseLocationResponse response) { + if (response == null || response.data() == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse location was not found"); + } + return response.data(); + } + + @Override + public WarehouseReference requireActiveSharedWarehouse(long warehouseId) { + WarehouseReferenceResponse response = client.get() + .uri("/internal/v1/warehouse-resource-locks/shared-warehouses/{warehouseId}", warehouseId) + .header("X-Internal-Service-Token", internalToken) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().value() == 404 + ? HttpStatus.NOT_FOUND : (result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE), + "Shared overseas warehouse lookup failed"); + }).body(WarehouseReferenceResponse.class); + if (response == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Shared overseas warehouse service returned no response"); + } + if ("A0404".equals(response.code())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Active shared overseas warehouse was not found"); + } + if (!"200".equals(response.code()) || response.data() == null + || response.data().warehouseId() != warehouseId || !"ACTIVE".equals(response.data().status())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Invalid shared overseas warehouse binding"); + } + return response.data(); + } + private record WarehousePalletResponse(WarehousePalletData data) {} - private record WarehousePalletData(long id, String palletCode, long locationId) {} + private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) {} private record ReserveEnvelope(ReserveData data) {} private record ReserveData(long lockId) {} + private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {} + private record WarehouseLocationResponse(String code, String message, WarehouseLocationReference data) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index ada468ece..138c9a784 100644 --- 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 @@ -4,8 +4,29 @@ public interface WarehouseResourceGateway { WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId); long reserve(String customerId, long warehouseId, long palletId, long locationId, String businessType, String businessId, String reservationKey); + void release(String customerId, long resourceLockId); void scrap(String customerId, long resourceLockId); void scrapPallet(String customerId, long palletId); + WarehousePalletReference resolvePallet(String palletCode); + default WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) { + throw new UnsupportedOperationException("Warehouse location resolution is not configured"); + } + default WarehouseLocationReference resolveLocation(long warehouseId, long locationId) { + throw new UnsupportedOperationException("Warehouse location resolution is not configured"); + } + default WarehouseReference requireActiveSharedWarehouse(long warehouseId) { + throw new UnsupportedOperationException("Shared warehouse resolution is not configured"); + } - record WarehousePalletReference(long palletId, String palletNo, long locationId) {} + record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) { + public WarehousePalletReference(long palletId, String palletNo, long locationId) { + this(palletId, palletNo, 0L, locationId, null, null, 0); + } + } + + record WarehouseReference(long warehouseId, String warehouseCode, String warehouseName, + String status) { } + record WarehouseLocationReference(long locationId, String locationCode, String locationType, + java.math.BigDecimal maxLoadKg, String status) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java new file mode 100644 index 000000000..ca291794b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ReceivingContainerOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_receiving_container_operation " + + "WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + ReceivingContainerOperation find(@Param("operatorKey") String operatorKey, + @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java new file mode 100644 index 000000000..b50e7eabf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ReceivingContainerSessionMapper extends BaseMapper { + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE") + ReceivingContainerSession lockActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey); + + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_operator_key=#{activeOperatorKey} LIMIT 1") + ReceivingContainerSession findActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey); + + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE") + ReceivingContainerSession lockActiveByContainer(@Param("activeContainerKey") String activeContainerKey); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java new file mode 100644 index 000000000..fa28ae74e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface ReceivingSkuReceiptMapper extends BaseMapper { + @Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " + + "AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id FOR UPDATE") + List lockLineSku(@Param("customerId") String customerId, + @Param("lineId") Long lineId, + @Param("skuCode") String skuCode); + + @Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " + + "AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id") + List listLineSku(@Param("customerId") String customerId, + @Param("lineId") Long lineId, + @Param("skuCode") String skuCode); + + @Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " + + "AND plan_id=#{planId} ORDER BY line_id,pallet_id,sku_code,id") + List listPlan(@Param("customerId") String customerId, + @Param("planId") Long planId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java index 726cf9054..3968df33d 100644 --- 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 @@ -66,6 +66,7 @@ public class PdaWorkflowRepository { 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()); } 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 index 92d3be40d..ae5d85b9a 100644 --- 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 @@ -17,6 +17,7 @@ import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; 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; @@ -36,6 +37,7 @@ 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; @@ -52,6 +54,7 @@ public class InboundPdaWorkflowService { @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; @@ -142,6 +145,118 @@ public class InboundPdaWorkflowService { 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 (pallet.getWarehouseResourceLockId() != null) { + if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return null; + throw conflict("Destination pallet is already bound to another physical pallet"); + } + String destinationKey = destinationKey(pallet); + String businessId = plan.getContainerNo() + ":" + destinationKey; + String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId(); + long lockId = warehouse.reserve(customerId, resource.warehouseId(), resource.palletId(), resource.locationId(), + "INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey); + try { + pallet.setWarehousePalletId(resource.palletId()); pallet.setWarehouseLocationId(resource.locationId()); + pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED"); + pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet); + return lockId; + } catch (RuntimeException failure) { + compensateReservation(customerId, lockId, failure); + throw failure; + } + } + + public void validateBulkResolved(String customerId, InboundPlan plan, InboundPlanLine line, int packageCount) { + requireActiveTask(customerId, plan); + int received = repository.allocations(plan.getId()).stream() + .filter(value -> Objects.equals(value.getLineId(), line.getId())) + .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + int pending = value(line.getRequiredPackageCount()) - received; + if (packageCount <= 0 || packageCount > pending) { + throw conflict("Received package count exceeds the inbound pending total"); + } + } + + public void validateCartonResolved(String customerId, InboundPlan plan, InboundPlanLine line, + InboundPlanPallet pallet, String cartonMark) { + requireActiveTask(customerId, plan); + InboundPlanCarton carton = repository.lockCarton(customerId, cartonMark); + if (carton == null || !("UNASSIGNED".equals(carton.getStatus()) || "EXPECTED".equals(carton.getStatus()))) { + throw conflict("Carton mark has already been scanned or is unavailable"); + } + InboundPlanLine resolvedLine = carton.getLineId() == null + ? repository.lineForCarton(plan.getId(), carton) : requireLine(plan.getId(), carton.getLineId()); + if (resolvedLine == null || !Objects.equals(resolvedLine.getId(), line.getId())) { + throw conflict("Carton mark belongs to another inbound code or container"); + } + if (!sameWarehouse(carton, pallet)) throw conflict("Carton and pallet destination warehouses must match"); + if (repository.scannedCartons(plan.getId(), line.getId()) >= value(line.getRequiredPackageCount())) { + throw conflict("This inbound plan line has reached its required carton count"); + } + } + + public void compensateReservation(String customerId, Long resourceLockId, RuntimeException originalFailure) { + if (resourceLockId == null) return; + try { + warehouse.release(customerId, resourceLockId); + } catch (RuntimeException releaseFailure) { + originalFailure.addSuppressed(releaseFailure); + } + } + + @Transactional + public int receiveBulkResolved(String customerId, InboundPlan plan, InboundPlanLine line, + InboundPlanPallet pallet, int packageCount, + String operatorId, String operatorName) { + InboundPdaTask task = requireActiveTask(customerId, plan); + int received = repository.allocations(plan.getId()).stream() + .filter(value -> Objects.equals(value.getLineId(), line.getId())) + .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + int pending = value(line.getRequiredPackageCount()) - received; + if (packageCount > pending) throw conflict("Received package count exceeds the inbound pending total"); + InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId()); + if (allocation == null) { + allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); + allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId()); + allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME); + allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); + allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(ZERO_VOLUME); + allocation.setStatus("RECEIVING"); allocation.setVersion(0); repository.insert(allocation); + } + BigDecimal perPackage = perPackageVolume(line); + allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + packageCount); + allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + packageCount); + allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()) + .add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP)); + allocation.setStatus(allocation.getPickedPackageCount() == value(line.getRequiredPackageCount()) + ? "RECEIVED" : "RECEIVING"); bump(allocation); repository.update(allocation); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + packageCount); + pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()) + .add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP)); + repository.update(pallet); startPicking(task, plan, operatorId, operatorName); + return packageCount; + } + @Transactional public PdaResponses.Operation count(String customerId, String planNo, long allocationId, PdaRequests.PackageOperation request) { @@ -280,7 +395,8 @@ public class InboundPdaWorkflowService { 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.setPlanId(plan.getId()); + 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); @@ -430,14 +546,61 @@ public class InboundPdaWorkflowService { @Transactional public PdaResponses.Task complete(String customerId, String planNo) { InboundPlan plan = requirePlan(customerId, planNo); + return completeContainer(customerId, List.of(plan)).get(0); + } + + /** Completes every plan of one physical container and emits exactly one container-level outbox event. */ + @Transactional + public List completeContainer(String customerId, List containerPlans) { + if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans"); + List orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList(); + String containerNo = orderedPlans.get(0).getContainerNo(); + if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan -> + !customerId.equals(plan.getCustomerId()) || !containerNo.equals(plan.getContainerNo()))) { + throw conflict("Inbound plans must belong to one customer and one physical container"); + } + List contexts = orderedPlans.stream().map(plan -> completionContext(customerId, plan)).toList(); + boolean anyCompleted = contexts.stream().anyMatch(context -> "COMPLETED".equals(context.task().getStatus())); + boolean allCompleted = contexts.stream().allMatch(context -> "COMPLETED".equals(context.task().getStatus())); + if (allCompleted) return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList(); + if (anyCompleted) throw conflict("Container plans are only partially completed; manual reconciliation is required"); + + LocalDateTime now = LocalDateTime.now(); + for (CompletionContext context : contexts) completePlan(customerId, context, now); + String sourceContainerBookingId = sourceContainerBookingId(contexts); + LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt()) + .max(LocalDateTime::compareTo).orElseThrow(); + long aggregateVersion = contexts.stream().map(CompletionContext::task) + .map(InboundPdaTask::getVersion).filter(Objects::nonNull).mapToLong(Integer::longValue).max().orElse(1L); + unloadCompletedOutbox.publishContainerCompleted(customerId, sourceContainerBookingId, containerNo, + now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion); + return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList(); + } + + private String sourceContainerBookingId(List contexts) { + List bookingIds = contexts.stream().map(context -> { + LastlegOrder order = orders.selectById(context.plan().getSourceOrderId()); + if (order == null || order.getSourceContainerBookingId() == null + || order.getSourceContainerBookingId().isBlank()) { + throw new IllegalStateException("Inbound plan has no source container booking ID"); + } + return order.getSourceContainerBookingId(); + }).distinct().toList(); + if (bookingIds.size() != 1) throw conflict("Container plans reference different source container bookings"); + return bookingIds.get(0); + } + + private CompletionContext completionContext(String customerId, InboundPlan plan) { InboundPdaTask task = repository.lockTask(customerId, plan.getId()); - if (task == null) throw conflict("Push the inbound plan to PDA before operating"); - if ("COMPLETED".equals(task.getStatus())) return task(task, planNo); + 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 before completing the PDA inbound task"); + throw conflict("Upload unload photos for every inbound plan before completing the container"); } Map pickedByLine = new HashMap<>(); List allocations = repository.allocations(plan.getId()); @@ -451,15 +614,24 @@ public class InboundPdaWorkflowService { 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 (mode(plan) == InboundMode.OWN_OVERSEAS && !cartons.isEmpty() - && cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) { + 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"); } - LocalDateTime now = LocalDateTime.now(); - stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now); - for (InboundPlanCarton carton : cartons) { - carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now); + return new CompletionContext(plan, task, unloadPhotoFileIds, lines, allocations, cartons); + } + + private void completePlan(String customerId, CompletionContext context, LocalDateTime now) { + InboundPlan plan = context.plan(); + InboundPdaTask task = context.task(); + stockReceipts.stockIn(customerId, plan, task, context.lines(), context.allocations(), + repository.pallets(plan.getId()), now); + for (InboundPlanCarton carton : context.cartons()) { + carton.setStatus("ON_PALLET".equals(carton.getStatus()) ? "AVAILABLE" : "BULK_RECEIVED"); + carton.setReceivedAt(now); carton.setUpdatedAt(now); carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton); } task.setStatus("COMPLETED"); @@ -470,8 +642,6 @@ public class InboundPdaWorkflowService { plan.setStatus("COMPLETED"); plan.setUpdatedAt(now); repository.update(plan); - unloadCompletedOutbox.publish(plan, task, unloadPhotoFileIds); - return taskResponse(task, plan, planNo); } /** Explicit operator decision to receive only the cartons actually picked. */ @@ -562,6 +732,7 @@ public class InboundPdaWorkflowService { if (moved == null) { moved = new InboundPlanPalletAllocation(); moved.setId(IdWorker.getId()); + moved.setCustomerId(source.getCustomerId()); moved.setPlanId(planId); moved.setLineId(source.getLineId()); moved.setPalletId(targetPalletId); @@ -776,16 +947,55 @@ public class InboundPdaWorkflowService { 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()); + 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()); + 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, @@ -857,6 +1067,13 @@ public class InboundPdaWorkflowService { 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; @@ -911,6 +1128,11 @@ public class InboundPdaWorkflowService { 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); } private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index 9f826b8f2..61f29be57 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -9,7 +9,9 @@ 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; @@ -39,6 +41,7 @@ public class InboundStockReceiptService { @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderDetailMapper orderDetails; @Resource private InboundStockReceiptMapper receipts; + @Resource private ReceivingSkuReceiptMapper receivingSkuReceipts; @Resource private InventoryCommandService inventory; @Resource private DropshipOrderService dropshipOrders; @Resource private LastlegOrderStatusHistoryMapper orderHistories; @@ -64,6 +67,9 @@ public class InboundStockReceiptService { .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()); @@ -92,6 +98,12 @@ public class InboundStockReceiptService { || (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()) @@ -127,6 +139,48 @@ public class InboundStockReceiptService { 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"; @@ -141,6 +195,7 @@ public class InboundStockReceiptService { 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"); @@ -161,8 +216,11 @@ public class InboundStockReceiptService { if (packages <= 0 || pallet.getWarehousePalletId() == null) { throw conflict("Stock-in requires picked packages and an assigned warehouse pallet"); } - String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()), - "Destination warehouse code is required for stock-in"); + String destinationCode = first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()); + if (destinationCode == null && trim(line.getDestinationWarehouseId()) == null) { + throw conflict("Destination warehouse ID or code is required for stock-in"); + } + if (destinationCode == null) destinationCode = ""; String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode(); String overseasWarehouseId = required(order.getOverseasWarehouseId(), "Actual overseas warehouse is required for stock-in"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java index dcdaa2c9f..e9a5e50c0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java @@ -39,10 +39,7 @@ public class InboundUnloadCompletedOutboxRelay { EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); EventEnvelopeValidator.requireValid(event); var response = client.receive(internalToken, event); - if (response == null || !response.successful() || response.data() == null - || !event.eventId().equals(response.data().eventId())) { - throw new IllegalStateException("国内事件桥未确认海外卸柜完成事件"); - } + requireAcknowledgement(event, response); repository.published(row.getId(), workerId, LocalDateTime.now()); } catch (Exception exception) { repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)), @@ -51,4 +48,18 @@ public class InboundUnloadCompletedOutboxRelay { } } } + + 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 index 5a502dab5..535f62079 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java @@ -19,11 +19,13 @@ 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 @@ -32,31 +34,88 @@ public class InboundUnloadCompletedOutboxService { @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"); } - Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC); - var payload = new OverseasContainerUnloadedV1(sourceOrder.getSourceContainerBookingId(), - plan.getContainerNo(), photoUploadedAt, photoUploadedAt, null); + if (task.getCompletedAt() == null) { + throw new IllegalStateException("Inbound task has no completion time"); + } + if (task.getUnloadPhotoUploadedAt() == null) { + throw new IllegalStateException("Inbound task has no unload photo upload time"); + } + publishContainerCompleted(task.getCustomerId(), sourceOrder.getSourceContainerBookingId(), + plan.getContainerNo(), task.getCompletedAt().toInstant(ZoneOffset.UTC), + task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC), + Math.max(1, task.getVersion() == null ? 1 : task.getVersion())); + } + + /** + * 柜级唯一发布入口。调用方只能在同一物理柜的全部计划完成后调用一次;重复调用保留首次冻结事实。 + */ + public void publishContainerCompleted(String customerId, String sourceContainerBookingId, + String containerNo, Instant unloadingCompletedAt, + Instant unloadingPhotoUploadedAt, long aggregateVersion) { + requireText(customerId, "customerId"); + requireText(sourceContainerBookingId, "sourceContainerBookingId"); + requireText(containerNo, "containerNo"); + Objects.requireNonNull(unloadingCompletedAt, "unloadingCompletedAt"); + Objects.requireNonNull(unloadingPhotoUploadedAt, "unloadingPhotoUploadedAt"); + if (unloadingCompletedAt.isBefore(unloadingPhotoUploadedAt)) { + throw new IllegalArgumentException("unloadingCompletedAt cannot be before unloadingPhotoUploadedAt"); + } + + String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + sourceContainerBookingId; + LastlegEventOutbox existing = mapper.findByEventKey(eventKey); + if (existing != null) { + requireSameContainer(existing, customerId, sourceContainerBookingId, containerNo); + return; + } + var payload = new OverseasContainerUnloadedV1(sourceContainerBookingId, + containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt, null); JsonNode payloadNode = objectMapper.valueToTree(payload); - long aggregateVersion = Math.max(1, task.getVersion() == null ? 1 : task.getVersion()); - String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId(); String traceId = EventIdentifiers.newUuid(); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, - 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, task.getCustomerId(), "CONTAINER_BOOKING", - sourceOrder.getSourceContainerBookingId(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode, + 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, customerId, "CONTAINER_BOOKING", + sourceContainerBookingId, Math.max(1, aggregateVersion), 1L, traceId, eventKey, null, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); LastlegEventOutbox event = new LastlegEventOutbox(); - event.setId(IdWorker.getId()); event.setCustomerId(task.getCustomerId()); + event.setId(IdWorker.getId()); event.setCustomerId(customerId); event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId()); event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion()); event.setEventKey(eventKey); event.setPayloadJson(json(envelope)); event.setStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); - mapper.insert(event); + try { + mapper.insert(event); + } catch (DuplicateKeyException exception) { + LastlegEventOutbox raced = mapper.findByEventKey(eventKey); + if (raced == null) throw exception; + requireSameContainer(raced, customerId, sourceContainerBookingId, containerNo); + } + } + + private void requireSameContainer(LastlegEventOutbox existing, String customerId, + String sourceContainerBookingId, String containerNo) { + try { + EventEnvelope envelope = objectMapper.readValue(existing.getPayloadJson(), EventEnvelope.class); + OverseasContainerUnloadedV1 frozen = objectMapper.treeToValue( + envelope.payload(), OverseasContainerUnloadedV1.class); + if (!Objects.equals(existing.getCustomerId(), customerId) + || !Objects.equals(existing.getAggregateId(), sourceContainerBookingId) + || !Objects.equals(frozen.containerNo(), containerNo)) { + throw new IllegalStateException("Unload-completed event key conflicts with another container"); + } + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read existing unload-completed event", exception); + } + } + + private void requireText(String value, String field) { + if (value == null || value.isBlank()) throw new IllegalArgumentException(field + " is required"); } private String json(Object value) { 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..092e88c99 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -0,0 +1,760 @@ +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.entity.ReceivingContainerOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; +import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class ReceivingContainerService { + @Resource private InboundPlanMapper plans; + @Resource private InboundPlanLineMapper lines; + @Resource private InboundPlanPalletMapper pallets; + @Resource private InboundPlanPalletAllocationMapper allocations; + @Resource private InboundPlanCartonMapper cartons; + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderDetailMapper orderDetails; + @Resource private ReceivingContainerSessionMapper sessions; + @Resource private ReceivingContainerOperationMapper operations; + @Resource private ReceivingSkuReceiptMapper skuReceipts; + @Resource private ContainerShippingGateMapper shippingGates; + @Resource private WarehouseResourceGateway warehouse; + @Resource private UnloadPhotoFileGateway unloadPhotos; + @Resource private InboundPdaWorkflowService workflow; + @Resource private ObjectMapper sourceObjectMapper; + + private ObjectMapper objectMapper; + + @PostConstruct + void initializeObjectMapper() { + objectMapper = sourceObjectMapper.copy() + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + public List candidates(String operatorKey, long overseasWarehouseId) { + ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); + requireSessionWarehouse(current, overseasWarehouseId); + Map> grouped = new LinkedHashMap<>(); + plans.selectList(new LambdaQueryWrapper() + .ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> { + if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) { + grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan); + } + }); + return grouped.entrySet().stream().filter(entry -> eligible(entry.getValue(), overseasWarehouseId)).map(entry -> { + requireSingleCustomer(entry.getValue(), entry.getKey()); + Totals totals = totals(entry.getValue()); + boolean active = current != null && entry.getKey().equals(current.getContainerNo()); + return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN", + entry.getValue().size(), totals.inboundCount(), totals.destinationCodes(), totals.plannedPalletCount(), + totals.total(), totals.received(), active); + }).toList(); + } + + public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { + List containerPlans = requirePlans(containerNo); + String customerId = requireSingleCustomer(containerPlans, containerNo); + if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse"); + requireReceivingAvailable(customerId, containerNo); + ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); + requireSessionWarehouse(current, overseasWarehouseId); + if (current != null && !containerNo.equals(current.getContainerNo())) { + throw conflict("Current account is locked to another selected container"); + } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + warehouse.requireActiveSharedWarehouse(overseasWarehouseId); + return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( + customerId, operatorKey, receivingWarehouse, containerNo), containerPlans); + } + + @Transactional + public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo, + ReceivingContainerRequests.Action request) { + PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey()); + ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command); + if (replay != null) { + ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class); + if (!Objects.equals(result.overseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Prepared container belongs to another overseas warehouse"); + } + return result; + } + List containerPlans = requirePlans(containerNo); + String customerId = requireSingleCustomer(containerPlans, containerNo); + if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse"); + requireReceivingAvailable(customerId, containerNo); + String activeOperatorKey = activeOperatorKey(operatorKey); + ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); + requireSessionWarehouse(active, overseasWarehouseId); + if (active != null) { + if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one"); + ReceivingContainerResponses.Session result = snapshot(active, containerPlans); + save(active, "PREPARE", request.idempotencyKey(), command, result); return result; + } + if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) { + throw conflict("Container is already signed or prepared by another account"); + } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + warehouse.requireActiveSharedWarehouse(overseasWarehouseId); + claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse); + LocalDateTime now = LocalDateTime.now(); + ReceivingContainerSession session = new ReceivingContainerSession(); + session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo); + session.setOverseasWarehouseId(overseasWarehouseId); + session.setOverseasWarehouseName(receivingWarehouse.warehouseName()); + session.setOperatorKey(operatorKey); session.setOperatorName(operatorName); + session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo)); + session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now); + session.setVersion(0); + try { sessions.insert(session); } + catch (DuplicateKeyException duplicate) { throw conflict("Account or container already has an active receiving session"); } + ReceivingContainerResponses.Session result = snapshot(session, containerPlans); + save(session, "PREPARE", request.idempotencyKey(), command, result); return result; + } + + public ReceivingContainerResponses.Photo uploadPhoto(String operatorKey, long operatorId, MultipartFile file) { + ReceivingContainerSession session = requireCurrent(operatorKey, true); + String customerId = session.getCustomerId(); + if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing"); + UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file); + return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()); + } + + @Transactional + public ReceivingContainerResponses.Session sign(String operatorKey, String operatorName, + ReceivingContainerRequests.Sign request) { + ReceivingContainerOperation replay = replay(operatorKey, "SIGN", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + ReceivingContainerSession session = requireCurrent(operatorKey, true); + String customerId = session.getCustomerId(); + if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed"); + List photos = validateSignPhotos(request); + List photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList(); + photoFileIds.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId)); + LocalDateTime now = LocalDateTime.now(); + session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); + session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session); + for (InboundPlan plan : requirePlans(customerId, session.getContainerNo())) { + workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photoFileIds, now); + } + ReceivingContainerResponses.Session result = snapshot(session, requirePlans(customerId, session.getContainerNo())); + save(session, "SIGN", request.idempotencyKey(), request, result); return result; + } + + public ReceivingContainerResponses.Session current(String operatorKey) { + ReceivingContainerSession session = requireCurrent(operatorKey, false); + return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo())); + } + + public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) { + requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId); + } + + public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey, + ReceivingContainerRequests.CartonResolve request) { + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); + List currentRefs = refs(customerId, session.getContainerNo()); + if (currentRefs.stream().noneMatch(value -> + "OVERSEAS".equals(destinationType(value.line(), value.order())))) { + throw conflict("FBA destination must use bulk count"); + } + InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); + if (carton == null) throw notFound("Carton mark was not found"); + InboundRef ref = currentRefs.stream() + .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst() + .orElseThrow(() -> conflict("Carton mark belongs to another container")); + if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { + throw conflict("FBA inbound must use bulk count instead of carton scan"); + } + ReceivingContainerResponses.Inbound inbound = inbound(ref); + return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(), + inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(), + inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), + inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), + inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); + } + + public ReceivingContainerResponses.SkuResolved resolveSku(String operatorKey, + ReceivingContainerRequests.SkuResolve request) { + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); + List matches = refs(customerId, session.getContainerNo()).stream() + .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) + .map(ref -> skuRef(ref, request.skuCode())) + .filter(Objects::nonNull) + .toList(); + if (matches.isEmpty()) throw notFound("SKU was not found in the current FBA container"); + if (matches.size() != 1) throw conflict("SKU belongs to multiple inbound codes in the current container"); + return skuResponse(matches.get(0)); + } + + public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, + ReceivingContainerRequests.PalletResolve request) { + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); + InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); + WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode()); + validatePallet(ref, pallet); + return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(), + pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true); + } + + @Transactional + public ReceivingContainerResponses.Receipt receiveBulk(String operatorKey, String operatorName, + ReceivingContainerRequests.BulkReceive request) { + ReceivingContainerOperation replay = replay(operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); + InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); + if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { + throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); + } + SkuRef sku = requireSku(ref, request.skuCode()); + validateSkuPending(customerId, sku, request.packageCount()); + workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount()); + WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); + validatePallet(ref, resource); + InboundPlanPallet pallet = choosePallet(ref, resource); + Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); + try { + int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, + request.packageCount(), operatorKey, operatorName); + recordSkuReceipt(session, sku, pallet, accepted); + markReceiving(session); + ReceivingContainerResponses.Inbound inbound = inbound(ref); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", + session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted, + inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); + save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); + return result; + } catch (RuntimeException failure) { + workflow.compensateReservation(customerId, resourceLockId, failure); + throw failure; + } + } + + @Transactional + public ReceivingContainerResponses.Receipt receiveCarton(String operatorKey, String operatorName, + ReceivingContainerRequests.CartonReceive request) { + ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); + List currentRefs = refs(customerId, session.getContainerNo()); + if (currentRefs.stream().noneMatch(value -> + "OVERSEAS".equals(destinationType(value.line(), value.order())))) { + throw conflict("FBA destination must use bulk count"); + } + InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); + if (carton == null) throw notFound("Carton mark was not found"); + InboundRef ref = currentRefs.stream() + .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst() + .orElseThrow(() -> conflict("Carton mark belongs to another container")); + if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { + throw conflict("FBA destination must use bulk count"); + } + WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); + validatePallet(ref, resource); + InboundPlanPallet pallet = choosePallet(ref, resource); + workflow.validateCartonResolved(customerId, ref.plan(), ref.line(), pallet, request.cartonMark()); + Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); + try { + workflow.scanCarton(customerId, ref.plan().getPlanNo(), new PdaRequests.ScanCarton( + request.cartonMark(), pallet.getId(), request.idempotencyKey() + ":SCAN", operatorKey, operatorName)); + markReceiving(session); + ReceivingContainerResponses.Inbound inbound = inbound(ref); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", + session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1, + inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); + save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result); + return result; + } catch (RuntimeException failure) { + workflow.compensateReservation(customerId, resourceLockId, failure); + throw failure; + } + } + + @Transactional(isolation = Isolation.READ_COMMITTED) + public ReceivingContainerResponses.Session complete(String operatorKey, + ReceivingContainerRequests.Action request) { + ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); + List containerPlans = requirePlans(customerId, session.getContainerNo()); + Totals totals = totals(containerPlans); + if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container"); + validateFbaSkuBalance(customerId, session.getContainerNo()); + workflow.completeContainer(customerId, containerPlans); + LocalDateTime now = LocalDateTime.now(); + session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now); + session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session); + ReceivingContainerResponses.Session result = snapshot(session, containerPlans); + save(session, "COMPLETE", request.idempotencyKey(), request, result); return result; + } + + private ReceivingContainerSession requireCurrent(String operatorKey, boolean lock) { + String key = activeOperatorKey(operatorKey); + ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key) + : sessions.findActiveByOperator(key); + if (session == null) throw notFound("Current receiving container session was not found"); + return session; + } + + private ReceivingContainerSession requireSigned(String operatorKey) { + ReceivingContainerSession session = requireCurrent(operatorKey, true); + if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) { + throw conflict("Sign the receiving container before receiving cargo"); + } + return session; + } + + private List requirePlans(String containerNo) { + List values = plans.selectList(new LambdaQueryWrapper() + .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); + if (values.isEmpty()) throw notFound("Receiving container was not found"); + requireSingleCustomer(values, containerNo); + if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) { + throw conflict("Receiving container is already completed"); + } + return values; + } + + private List requirePlans(String customerId, String containerNo) { + List values = plansForContainer(customerId, containerNo); + if (values.isEmpty()) throw notFound("Receiving container was not found"); + if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) { + throw conflict("Receiving container is already completed"); + } + return values; + } + + private List plansForContainer(String customerId, String containerNo) { + List values = plans.selectList(new LambdaQueryWrapper() + .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getContainerNo, containerNo) + .orderByAsc(InboundPlan::getId)); + if (values.isEmpty()) throw notFound("Receiving container was not found"); + return values; + } + + private String requireSingleCustomer(List values, String containerNo) { + Set customers = values.stream().map(InboundPlan::getCustomerId).map(ReceivingContainerService::trim) + .filter(Objects::nonNull).collect(Collectors.toCollection(java.util.LinkedHashSet::new)); + if (customers.size() != 1 || values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { + throw conflict("Receiving container must resolve to exactly one customer: " + containerNo); + } + return customers.iterator().next(); + } + + private List refs(String customerId, String containerNo) { + List result = new ArrayList<>(); + for (InboundPlan plan : plansForContainer(customerId, containerNo)) { + for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) { + LastlegOrder order = orders.selectById(line.getOrderId()); + if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing"); + result.add(new InboundRef(plan, line, order)); + } + } + return result; + } + + private InboundRef requireInbound(String customerId, String containerNo, String inboundCode) { + List matches = refs(customerId, containerNo).stream() + .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); + if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container"); + if (matches.size() != 1) throw conflict("Inbound code is duplicated inside the current container"); + return matches.get(0); + } + + private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) { + List candidates = pallets.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream() + .filter(value -> sameDestination(ref.line(), value)) + .filter(value -> value.getWarehousePalletId() == null || Objects.equals(value.getWarehousePalletId(), resource.palletId())) + .sorted(Comparator.comparing((InboundPlanPallet value) -> !Objects.equals(value.getWarehousePalletId(), resource.palletId())) + .thenComparing(value -> value.getActualPackageCount() == null ? 0 : value.getActualPackageCount())) + .toList(); + if (candidates.isEmpty()) throw conflict("Pallet does not match an available destination pallet in this inbound code"); + return candidates.get(0); + } + + private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) { + if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped"); + long actualWarehouse; + try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); } + catch (Exception exception) { throw conflict("Inbound order has no valid receiving warehouse ID"); } + long assignedWarehouse = ref.plan().getOverseasWarehouseId() == null ? actualWarehouse : ref.plan().getOverseasWarehouseId(); + if (pallet.warehouseId() != assignedWarehouse) throw conflict("Pallet belongs to another overseas warehouse"); + String type = destinationType(ref.line(), ref.order()); + if ("FBA".equals(type)) { + if (ref.line().getFbaWarehouseCode() == null || ref.line().getFbaWarehouseCode().isBlank() + || !ref.line().getFbaWarehouseCode().equalsIgnoreCase(String.valueOf(pallet.fbaWarehouseCode()))) { + throw conflict("Pallet FBA destination does not match the inbound code"); + } + } else { + if (pallet.fbaWarehouseCode() != null && !pallet.fbaWarehouseCode().isBlank()) { + throw conflict("FBA-designated pallet cannot receive an OVERSEAS destination carton"); + } + } + } + + private boolean sameDestination(InboundPlanLine line, InboundPlanPallet pallet) { + if (line.getDestinationWarehouseId() != null && !line.getDestinationWarehouseId().isBlank()) { + return line.getDestinationWarehouseId().equals(pallet.getDestinationWarehouseId()); + } + return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode()); + } + + private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { + List inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream() + .map(this::inbound).toList(); + int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); + int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); + return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(), + session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(), + value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(), + session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds); + } + + private ReceivingContainerResponses.Inbound inbound(InboundRef ref) { + int received = allocations.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId()) + .eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream() + .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + int total = value(ref.line().getRequiredPackageCount()); + return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), + ref.plan().getOverseasWarehouseId() == null ? ref.order().getOverseasWarehouseId() + : String.valueOf(ref.plan().getOverseasWarehouseId()), ref.order().getOverseasWarehouseName(), + ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(), + ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(), + value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received)); + } + + private Totals totals(List containerPlans) { + List values = refs(containerPlans.get(0).getCustomerId(), + containerPlans.get(0).getContainerNo()).stream().map(this::inbound).toList(); + List destinationCodes = values.stream().map(this::destinationCode) + .filter(Objects::nonNull).distinct().sorted().toList(); + return new Totals(values.size(), destinationCodes, + values.stream().mapToInt(ReceivingContainerResponses.Inbound::plannedPalletCount).sum(), + values.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(), + values.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum()); + } + + private String destinationCode(ReceivingContainerResponses.Inbound inbound) { + return trim("FBA".equals(inbound.destinationType()) ? inbound.fbaWarehouseCode() + : inbound.destinationWarehouseCode()); + } + + private List validateSignPhotos(ReceivingContainerRequests.Sign request) { + if (request.photos() == null || request.photos().size() != 3) { + throw conflict("Exactly three typed unload photos are required"); + } + if (request.photos().stream().anyMatch(Objects::isNull)) throw conflict("Unload photo cannot be null"); + Set roles = request.photos().stream() + .map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet()); + if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once"); + Set fileIds = request.photos().stream().map(ReceivingContainerRequests.SignedPhoto::fileId) + .collect(Collectors.toSet()); + if (fileIds.size() != 3 || fileIds.stream().anyMatch(value -> value == null || value <= 0)) { + 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())).toList(); + } + + private SkuRef requireSku(InboundRef ref, String skuCode) { + SkuRef result = skuRef(ref, skuCode); + if (result == null) throw notFound("SKU is not part of the selected inbound code"); + return result; + } + + private SkuRef skuRef(InboundRef ref, String skuCode) { + String requested = trim(skuCode); + if (requested == null) return null; + List details = orderDetails.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, ref.order().getCustomerId()) + .eq(LastlegOrderDetail::getOrderId, ref.order().getId()).orderByAsc(LastlegOrderDetail::getId)); + List matched = details.stream() + .filter(detail -> requested.equalsIgnoreCase(trim(detail.getSkuCode()))).toList(); + int expected = matched.stream().mapToInt(detail -> value(detail.getPackageCount())).sum(); + if (expected <= 0) return null; + String canonicalSku = matched.stream().map(LastlegOrderDetail::getSkuCode).map(ReceivingContainerService::trim) + .filter(Objects::nonNull).findFirst().orElse(requested); + String productName = matched.stream().map(detail -> first(detail.getProductNameZh(), detail.getProductNameEn())) + .filter(Objects::nonNull).findFirst().orElse(null); + return new SkuRef(ref, canonicalSku, productName, expected); + } + + private ReceivingContainerResponses.SkuResolved skuResponse(SkuRef sku) { + ReceivingContainerResponses.Inbound inbound = inbound(sku.ref()); + int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode()); + return new ReceivingContainerResponses.SkuResolved(sku.skuCode(), sku.productName(), inbound.inboundCode(), + inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), + inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), + inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(), + sku.expectedPackages(), received, Math.max(0, sku.expectedPackages() - received)); + } + + private void validateSkuPending(String customerId, SkuRef sku, int packageCount) { + int received = receivedSkuPackages(customerId, sku.ref().line().getId(), sku.skuCode()); + if (packageCount <= 0 || packageCount > sku.expectedPackages() - received) { + throw conflict("Received package count exceeds the SKU pending total"); + } + } + + private int receivedSkuPackages(String customerId, Long lineId, String skuCode) { + return skuReceipts.listLineSku(customerId, lineId, skuCode).stream() + .mapToInt(value -> value(value.getReceivedPackages())).sum(); + } + + private void recordSkuReceipt(ReceivingContainerSession session, SkuRef sku, InboundPlanPallet pallet, int packages) { + List existing = skuReceipts.lockLineSku(session.getCustomerId(), + sku.ref().line().getId(), sku.skuCode()); + ReceivingSkuReceipt receipt = existing.stream() + .filter(value -> Objects.equals(value.getPalletId(), pallet.getId())).findFirst().orElse(null); + LocalDateTime now = LocalDateTime.now(); + if (receipt == null) { + receipt = new ReceivingSkuReceipt(); receipt.setId(IdWorker.getId()); + receipt.setCustomerId(session.getCustomerId()); receipt.setSessionId(session.getId()); + receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId()); + receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId()); + receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode()); + receipt.setProductName(sku.productName()); receipt.setReceivedPackages(packages); + receipt.setCreatedAt(now); receipt.setUpdatedAt(now); receipt.setVersion(0); + skuReceipts.insert(receipt); + } else { + receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + packages); + receipt.setUpdatedAt(now); + if (skuReceipts.updateById(receipt) != 1) throw conflict("SKU receiving progress changed concurrently"); + } + } + + private void validateFbaSkuBalance(String customerId, String containerNo) { + for (InboundRef ref : refs(customerId, containerNo)) { + if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; + List details = orderDetails.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, ref.order().getId())); + Set skuCodes = details.stream().map(LastlegOrderDetail::getSkuCode) + .map(ReceivingContainerService::trim).filter(Objects::nonNull) + .map(value -> value.toUpperCase(java.util.Locale.ROOT)) + .collect(Collectors.toCollection(java.util.LinkedHashSet::new)); + int expected = 0; + int received = 0; + for (String skuCode : skuCodes) { + SkuRef sku = requireSku(ref, skuCode); + 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"); + expected += sku.expectedPackages(); received += skuReceived; + } + if (skuCodes.isEmpty() || expected != value(ref.line().getRequiredPackageCount()) || received != expected) { + throw conflict("FBA SKU package snapshots must exactly match the inbound code total"); + } + } + } + + private String destinationType(InboundPlanLine line, LastlegOrder order) { + String value = line.getDestinationType(); + if (value == null || value.isBlank()) value = order.getDestinationType(); + if (value == null || value.isBlank()) value = line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank() + ? "OVERSEAS" : "FBA"; + value = value.trim().toUpperCase(); + if (!List.of("FBA", "OVERSEAS").contains(value)) throw conflict("Unsupported destination type"); + return value; + } + + private void markReceiving(ReceivingContainerSession session) { + if (!"RECEIVING".equals(session.getStatus())) { + session.setStatus("RECEIVING"); session.setReceivingAt(LocalDateTime.now()); + } + session.setUpdatedAt(LocalDateTime.now()); update(session); + } + + private void update(ReceivingContainerSession session) { + if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently"); + } + + private ReceivingContainerSession waitingView(String customerId, String operatorKey, + WarehouseResourceGateway.WarehouseReference warehouse, + String containerNo) { + ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId); + value.setOverseasWarehouseId(warehouse.warehouseId()); + value.setOverseasWarehouseName(warehouse.warehouseName()); + value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0); + return value; + } + + private boolean eligible(List containerPlans, long overseasWarehouseId) { + String customerId = requireSingleCustomer(containerPlans, containerPlans.get(0).getContainerNo()); + return warehouseEligible(containerPlans, overseasWarehouseId) + && shippingGates.receivingAvailable(customerId, containerPlans.get(0).getContainerNo()) == 1; + } + + private boolean warehouseEligible(List containerPlans, long overseasWarehouseId) { + Set assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream() + .anyMatch(plan -> plan.getOverseasWarehouseId() == null))) { + throw conflict("Container plans have inconsistent receiving warehouse assignments"); + } + return assigned.isEmpty() || assigned.contains(overseasWarehouseId); + } + + private void requireReceivingAvailable(String customerId, String containerNo) { + if (shippingGates.receivingAvailable(customerId, containerNo) != 1) { + throw conflict("Container has not reached PICKUP_READY and is not available for overseas receiving"); + } + } + + private void claimReceivingWarehouse(String customerId, List containerPlans, + WarehouseResourceGateway.WarehouseReference warehouse) { + long overseasWarehouseId = warehouse.warehouseId(); + String warehouseId = String.valueOf(overseasWarehouseId); + for (InboundPlan plan : containerPlans) { + int claimed = plans.claimReceivingWarehouse(customerId, plan.getId(), overseasWarehouseId); + InboundPlan actual = plans.selectById(plan.getId()); + if ((claimed == 0 && actual == null) || actual == null + || !Objects.equals(actual.getOverseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Container was claimed by another overseas warehouse"); + } + plan.setOverseasWarehouseId(overseasWarehouseId); + for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) { + orders.claimReceivingWarehouse(customerId, line.getOrderId(), warehouseId, + warehouse.warehouseName()); + LastlegOrder order = orders.selectById(line.getOrderId()); + if (order == null || !warehouseId.equals(order.getOverseasWarehouseId())) { + throw conflict("Inbound order was claimed by another overseas warehouse"); + } + } + } + } + + private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) { + if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Current container belongs to another overseas warehouse"); + } + } + + private ReceivingContainerOperation replay(String operatorKey, String action, String key, Object request) { + key = idempotencyKey(key); + ReceivingContainerOperation value = operations.find(operatorKey, key); + if (value == null) return null; + if (!action.equals(value.getAction()) || !hash(request).equals(value.getRequestHash())) { + throw conflict("Idempotency key was already used with a different request"); + } + return value; + } + + private void save(ReceivingContainerSession session, String action, String key, Object request, Object result) { + key = idempotencyKey(key); + ReceivingContainerOperation value = new ReceivingContainerOperation(); value.setId(IdWorker.getId()); + value.setSessionId(session.getId()); value.setCustomerId(session.getCustomerId()); + value.setOperatorKey(session.getOperatorKey()); value.setAction(action); value.setIdempotencyKey(key); + value.setRequestHash(hash(request)); value.setRequestJson(write(request)); value.setResultJson(write(result)); + value.setCreatedAt(LocalDateTime.now()); operations.insert(value); + } + + private List signPhotos(ReceivingContainerSession session) { + 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 SkuRef(InboundRef ref, String skuCode, String productName, int expectedPackages) { } + private record Totals(int inboundCount, List destinationCodes, int plannedPalletCount, + int total, int received) { } + private record PrepareCommand(String containerNo, String idempotencyKey) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 27b745e6b..7e43558ff 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -228,7 +228,8 @@ public class InboundPlanService { .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.setPlanId(plan.getId()); pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", sequence)); + 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"); @@ -249,6 +250,8 @@ public class InboundPlanService { 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()); @@ -315,7 +318,8 @@ public class InboundPlanService { private void validateOrders(List orders) { for (SourceOrderSnapshot order : orders) { - if (!"PENDING_ASSIGNMENT".equals(order.currentStatus())) { + 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"); } 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 index 48e7b0a3d..2773296fa 100644 --- 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 @@ -17,7 +17,7 @@ public final class InventoryCommandRequests { String overseasWarehouseCode, String overseasWarehouseName, String destinationWarehouseId, - @NotBlank String destinationWarehouseCode, + String destinationWarehouseCode, String destinationWarehouseName, @NotNull Long inboundOrderId, @NotBlank String inboundOrderNo, @@ -44,7 +44,7 @@ public final class InventoryCommandRequests { String overseasWarehouseCode, String overseasWarehouseName, String destinationWarehouseId, - @NotBlank String destinationWarehouseCode, + String destinationWarehouseCode, String destinationWarehouseName, @NotNull Long inboundOrderId, @NotBlank String inboundOrderNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index cc8d917af..f42015986 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -233,8 +233,14 @@ public class InventoryCommandService { String inboundCode, String requestedSkuCode, String productNameZh, String productNameEn) { String skuCode = sku(requestedSkuCode); + String destinationId = trim(destinationWarehouseId); + String destinationCode = trim(destinationWarehouseCode); + if (destinationId == null && destinationCode == null) { + throw badRequest("Destination warehouse ID or code is required"); + } + if (destinationCode == null) destinationCode = ""; InventoryBalance balance = balances.lockDimension(customerId, overseasWarehouseId, - destinationWarehouseCode, inboundOrderId, inboundCode); + destinationCode, inboundOrderId, inboundCode); if (balance != null) { mergeSkuSnapshot(balance, skuCode); return balance; @@ -246,8 +252,8 @@ public class InventoryCommandService { balance.setOverseasWarehouseId(overseasWarehouseId.trim()); balance.setOverseasWarehouseCode(trim(overseasWarehouseCode)); balance.setOverseasWarehouseName(trim(overseasWarehouseName)); - balance.setDestinationWarehouseId(trim(destinationWarehouseId)); - balance.setDestinationWarehouseCode(destinationWarehouseCode.trim()); + balance.setDestinationWarehouseId(destinationId); + balance.setDestinationWarehouseCode(destinationCode); balance.setDestinationWarehouseName(trim(destinationWarehouseName)); balance.setInboundOrderId(inboundOrderId); balance.setInboundOrderNo(inboundOrderNo.trim()); @@ -264,7 +270,7 @@ public class InventoryCommandService { return balance; } catch (DuplicateKeyException exception) { InventoryBalance concurrent = balances.lockDimension(customerId, overseasWarehouseId, - destinationWarehouseCode, inboundOrderId, inboundCode); + destinationCode, inboundOrderId, inboundCode); if (concurrent != null) { mergeSkuSnapshot(concurrent, skuCode); return concurrent; 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 index 0e9dc4d95..4ffeff2cd 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -40,8 +41,11 @@ public class InventoryLedger { 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; 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 index f34f676e2..87226951e 100644 --- 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 @@ -22,6 +22,7 @@ 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; @@ -40,9 +41,9 @@ public class InventoryQueryController { @Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse summary( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - requirePlatform(user); - return ApiResponse.success(service.dashboard(null, query)); + return ApiResponse.success(service.dashboard(platformScope(user, customerId), query)); } @GetMapping(BASE_PATH + "/balances") @@ -50,9 +51,9 @@ public class InventoryQueryController { @Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse> balances( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - requirePlatform(user); - return ApiResponse.success(service.balances(null, query)); + return ApiResponse.success(service.balances(platformScope(user, customerId), query)); } @GetMapping(BASE_PATH + "/balances/{balanceId}/positions") @@ -61,9 +62,9 @@ public class InventoryQueryController { public ApiResponse> positions( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long balanceId, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.PositionQuery query) { - requirePlatform(user); - return ApiResponse.success(service.positions(null, balanceId, query)); + return ApiResponse.success(service.positions(platformScope(user, customerId), balanceId, query)); } @GetMapping(BASE_PATH + "/ledger") @@ -71,9 +72,9 @@ public class InventoryQueryController { @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) { - requirePlatform(user); - return ApiResponse.success(service.ledgers(null, query)); + return ApiResponse.success(service.ledgers(platformScope(user, customerId), query)); } @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") @@ -81,9 +82,9 @@ public class InventoryQueryController { @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) public ResponseEntity exportBalances( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - requirePlatform(user); - return csv(service.exportBalances(null, query), "inventory-balances.csv"); + return csv(service.exportBalances(platformScope(user, customerId), query), "inventory-balances.csv"); } @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") @@ -91,9 +92,9 @@ public class InventoryQueryController { @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) { - requirePlatform(user); - return csv(service.exportLedgers(null, query), "inventory-ledger.csv"); + return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv"); } private static ResponseEntity csv(byte[] body, String filename) { @@ -103,12 +104,14 @@ public class InventoryQueryController { .body(body); } - private void requirePlatform(AuthenticatedUser user) { + 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/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java index 316eb2b8d..16c85a001 100644 --- 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 @@ -89,8 +89,6 @@ public interface InventoryQueryMapper { @Select("") + List page(@Param("warehouseId") long warehouseId, + @Param("status") String status, + @Param("offset") long offset, @Param("limit") long limit); + + @Select("") + long count(@Param("warehouseId") long warehouseId, @Param("status") String status); + + @Update("UPDATE ll_pda_stocktake_task SET counted_count=#{countedCount},difference_count=#{differenceCount}," + + "version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND version=#{expectedVersion} " + + "AND status='IN_PROGRESS'") + int refreshCounts(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion, + @Param("countedCount") int countedCount, @Param("differenceCount") int differenceCount); + + @Update("UPDATE ll_pda_stocktake_task SET status='SUBMITTED',submitted_by=#{operatorKey}," + + "submit_reason=#{reason},submitted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3)," + + "version=version+1 WHERE id=#{taskId} AND version=#{expectedVersion} AND status='IN_PROGRESS'") + int submit(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion, + @Param("operatorKey") String operatorKey, @Param("reason") String reason); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java new file mode 100644 index 000000000..41443f089 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.pda.stocktake.model; + +import lombok.Data; + +@Data +public class StocktakeCandidate { + private String customerId; + private Long balanceId; + private Long positionId; + private String skuCode; + private String productName; + private String inboundCode; + private Long palletId; + private String palletCode; + private Long locationId; + private String locationCode; + private Long bookPackages; + private Long positionVersion; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java new file mode 100644 index 000000000..101e03b74 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java @@ -0,0 +1,323 @@ +package com.baiyue.lastleg.pda.stocktake.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper; +import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +@Service +public class PdaStocktakeService { + @Resource private PdaStocktakeTaskMapper tasks; + @Resource private PdaStocktakeItemMapper items; + @Resource private PdaStocktakeActionMapper actions; + @Resource private PdaStocktakeInventoryMapper inventory; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryLedgerMapper ledgers; + @Resource private WarehouseResourceGateway warehouse; + @Resource private PdaWarehouseService history; + @Resource private ObjectMapper objectMapper; + + public PageResult page(long warehouseId, PdaStocktakeRequests.TaskQuery query) { + String status = status(query.status()); + long offset = (query.page() - 1) * query.size(); + List rows = tasks.page(warehouseId, status, offset, query.size()); + long total = tasks.count(warehouseId, status); + return new PageResult<>(rows, query.page(), query.size(), total, + total == 0 ? 0 : (total + query.size() - 1) / query.size()); + } + + public PdaStocktakeResponses.Detail detail(long warehouseId, String taskNo) { + PdaStocktakeTask task = requireTask(warehouseId, taskNo, false); + return detail(task); + } + + @Transactional + public PdaStocktakeResponses.Detail create(long warehouseId, String operatorKey, + PdaStocktakeRequests.Create request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaStocktakeTask replay = tasks.findCreate(warehouseId, operatorKey, key); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with another stocktake"); + return detail(replay); + } + + String scopeType = required(request.scopeType(), "Scope type is required").toUpperCase(); + String scopeCode = required(request.scopeCode(), "Scope code is required"); + long resourceId; + List candidates; + if ("PALLET".equals(scopeType)) { + var pallet = warehouse.resolvePallet(scopeCode); + if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse"); + resourceId = pallet.palletId(); + scopeCode = pallet.palletNo(); + candidates = items.candidatesByPallet(String.valueOf(warehouseId), resourceId); + } else if ("LOCATION".equals(scopeType)) { + var location = warehouse.resolveLocation(warehouseId, scopeCode); + if (!List.of("ACTIVE", "IDLE", "OCCUPIED").contains(location.status())) { + throw conflict("Stocktake location is not active"); + } + resourceId = location.locationId(); + scopeCode = location.locationCode(); + candidates = items.candidatesByLocation(String.valueOf(warehouseId), resourceId, scopeCode); + } else { + throw badRequest("scopeType must be LOCATION or PALLET"); + } + if (candidates.isEmpty()) throw conflict("No inventory is available in the scanned scope"); + + long taskId = IdWorker.getId(); + LocalDateTime now = LocalDateTime.now(); + PdaStocktakeTask task = new PdaStocktakeTask(); + task.setId(taskId); task.setTaskNo("CC-" + Long.toUnsignedString(taskId)); + task.setOverseasWarehouseId(warehouseId); task.setScopeType(scopeType); task.setScopeResourceId(resourceId); + task.setScopeCode(scopeCode); task.setStatus("IN_PROGRESS"); task.setPositionCount(candidates.size()); + task.setCountedCount(0); task.setDifferenceCount(0); task.setCreatedBy(operatorKey); + task.setIdempotencyKey(key); task.setRequestHash(requestHash); task.setVersion(0L); + task.setCreatedAt(now); task.setUpdatedAt(now); + try { + tasks.insert(task); + } catch (DuplicateKeyException duplicate) { + PdaStocktakeTask winner = tasks.findCreate(warehouseId, operatorKey, key); + if (winner == null || !requestHash.equals(winner.getRequestHash())) throw duplicate; + return detail(winner); + } + for (StocktakeCandidate candidate : candidates) { + PdaStocktakeItem item = new PdaStocktakeItem(); + item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setCustomerId(candidate.getCustomerId()); + item.setBalanceId(candidate.getBalanceId()); item.setPositionId(candidate.getPositionId()); + item.setSkuCode(candidate.getSkuCode()); item.setProductName(candidate.getProductName()); + item.setInboundCode(candidate.getInboundCode()); item.setPalletId(candidate.getPalletId()); + item.setPalletCode(candidate.getPalletCode()); item.setLocationId(candidate.getLocationId()); + item.setLocationCode(candidate.getLocationCode()); item.setBookPackages(value(candidate.getBookPackages())); + item.setStatus("PENDING"); item.setPositionVersion(value(candidate.getPositionVersion())); + item.setVersion(0L); item.setCreatedAt(now); item.setUpdatedAt(now); items.insert(item); + } + history.record(warehouseId, null, operatorKey, "STOCKTAKE_CREATE", task.getTaskNo(), "IN_PROGRESS", + "STOCKTAKE_CREATE:" + key, requestHash, + scopeType + " " + scopeCode + ",共 " + candidates.size() + " 个库存位置"); + return detail(task); + } + + @Transactional + public PdaStocktakeResponses.Item count(long warehouseId, String operatorKey, String taskNo, + PdaStocktakeRequests.Count request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "COUNT", requestHash, taskNo); + if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Item.class); + + PdaStocktakeTask task = requireTask(warehouseId, taskNo, true); + if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Submitted stocktake cannot be changed"); + PdaStocktakeItem item = items.lockItem(task.getId(), request.positionId()); + if (item == null) throw notFound("Stocktake position was not found in this task"); + long difference = request.actualPackages() - value(item.getBookPackages()); + if (items.countItem(item.getId(), request.expectedVersion(), request.actualPackages(), difference, + difference == 0 ? "MATCHED" : "DIFFERENCE", operatorKey) != 1) { + throw conflict("Stocktake item version changed; refresh and retry"); + } + int counted = items.counted(task.getId()); + int differences = items.differences(task.getId()); + if (tasks.refreshCounts(task.getId(), value(task.getVersion()), counted, differences) != 1) { + throw conflict("Stocktake task version changed; refresh and retry"); + } + PdaStocktakeResponses.Item response = items.views(task.getId()).stream() + .filter(row -> row.positionId() == request.positionId()).findFirst().orElseThrow(); + complete(action, response); + return response; + } + + @Transactional + public PdaStocktakeResponses.Detail submit(long warehouseId, String operatorKey, String taskNo, + PdaStocktakeRequests.Submit request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "SUBMIT", requestHash, taskNo); + if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Detail.class); + + PdaStocktakeTask task = requireTask(warehouseId, taskNo, true); + if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Stocktake was already submitted"); + if (value(task.getVersion()) != request.expectedVersion()) throw conflict("Stocktake task version changed; refresh and retry"); + List taskItems = items.lockAll(task.getId()); + if (taskItems.isEmpty() || taskItems.stream().anyMatch(item -> item.getActualPackages() == null)) { + throw conflict("Every stocktake position must be counted before submission"); + } + + Map changedBalances = new HashMap<>(); + LocalDateTime now = LocalDateTime.now(); + for (PdaStocktakeItem item : taskItems) { + InventoryPosition position = inventory.lockPosition(item.getPositionId(), item.getCustomerId(), + String.valueOf(warehouseId)); + if (position == null || value(position.getVersion()) != value(item.getPositionVersion())) { + throw conflict("Inventory changed after counting; recreate the stocktake task"); + } + long before = physical(position); + if (before != value(item.getBookPackages())) { + throw conflict("Inventory package total changed after counting; recreate the stocktake task"); + } + long fixed = value(position.getHoldPackages()) + value(position.getAllocatedPackages()) + + value(position.getPickedPackages()) + value(position.getReceivingPackages()); + if (value(item.getActualPackages()) < fixed) { + throw conflict("Actual packages cannot be lower than reserved or processing packages"); + } + long newAvailable = value(item.getActualPackages()) - fixed; + long delta = newAvailable - value(position.getAvailablePackages()); + InventoryBalance balance = changedBalances.get(item.getBalanceId()); + if (balance == null) { + balance = inventory.lockBalance(item.getBalanceId(), item.getCustomerId(), String.valueOf(warehouseId)); + if (balance == null) throw new IllegalStateException("Stocktake inventory balance is missing"); + changedBalances.put(item.getBalanceId(), balance); + } + long balanceAfter = value(balance.getAvailablePackages()) + delta; + if (balanceAfter < 0) throw conflict("Stocktake adjustment would make inventory negative"); + + position.setAvailablePackages(newAvailable); position.setStatus(physicalAfter(position, newAvailable) == 0 ? "EMPTY" : "OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(now); positions.updateById(position); + balance.setAvailablePackages(balanceAfter); balance.setVersion(value(balance.getVersion()) + 1); balance.setUpdatedAt(now); + + ledger(task, item, position, balance, operatorKey, key, request.reason(), before, delta, + value(item.getActualPackages()), now); + item.setStatus(delta == 0 ? "MATCHED" : "ADJUSTED"); item.setUpdatedAt(now); + item.setVersion(value(item.getVersion()) + 1); items.updateById(item); + } + changedBalances.values().forEach(balances::updateById); + if (tasks.submit(task.getId(), request.expectedVersion(), operatorKey, request.reason()) != 1) { + throw conflict("Stocktake task version changed; refresh and retry"); + } + history.record(warehouseId, null, operatorKey, "STOCKTAKE_SUBMIT", taskNo, "COMPLETED", + "STOCKTAKE_SUBMIT:" + key, requestHash, + "盘点提交,差异位置 " + task.getDifferenceCount() + " 个;原因:" + request.reason().trim()); + PdaStocktakeResponses.Detail response = detail(requireTask(warehouseId, taskNo, false)); + complete(action, response); + return response; + } + + private PdaStocktakeResponses.Detail detail(PdaStocktakeTask task) { + return new PdaStocktakeResponses.Detail(summary(task), items.views(task.getId())); + } + + private PdaStocktakeResponses.Summary summary(PdaStocktakeTask task) { + return new PdaStocktakeResponses.Summary(task.getTaskNo(), task.getScopeType(), task.getScopeCode(), + task.getStatus(), intValue(task.getPositionCount()), intValue(task.getCountedCount()), + intValue(task.getDifferenceCount()), value(task.getVersion()), task.getCreatedAt(), task.getSubmittedAt()); + } + + private PdaStocktakeTask requireTask(long warehouseId, String taskNo, boolean lock) { + String normalized = required(taskNo, "Stocktake task number is required"); + PdaStocktakeTask task = lock ? tasks.lock(warehouseId, normalized) : tasks.find(warehouseId, normalized); + if (task == null) throw notFound("Stocktake task was not found in the bound warehouse"); + return task; + } + + private PdaStocktakeAction beginAction(long warehouseId, String operatorKey, String key, String type, + String requestHash, String taskNo) { + PdaStocktakeAction existing = actions.find(warehouseId, operatorKey, key); + if (existing != null) return replay(existing, type, requestHash); + PdaStocktakeAction action = new PdaStocktakeAction(); + action.setId(IdWorker.getId()); action.setOverseasWarehouseId(warehouseId); action.setOperatorKey(operatorKey); + action.setIdempotencyKey(key); action.setActionType(type); action.setRequestHash(requestHash); + PdaStocktakeTask task = tasks.find(warehouseId, taskNo); action.setTaskId(task == null ? null : task.getId()); + action.setStatus("PROCESSING"); action.setCreatedAt(LocalDateTime.now()); action.setUpdatedAt(action.getCreatedAt()); + try { actions.insert(action); return action; } + catch (DuplicateKeyException duplicate) { + PdaStocktakeAction winner = actions.find(warehouseId, operatorKey, key); + if (winner == null) throw duplicate; + return replay(winner, type, requestHash); + } + } + + private PdaStocktakeAction replay(PdaStocktakeAction action, String type, String requestHash) { + if (!type.equals(action.getActionType()) || !requestHash.equals(action.getRequestHash())) { + throw conflict("Idempotency key was reused with a different stocktake action"); + } + if (!"COMPLETED".equals(action.getStatus())) throw conflict("Stocktake action is still processing"); + return action; + } + + private void complete(PdaStocktakeAction action, Object response) { + action.setStatus("COMPLETED"); action.setResultJson(write(response)); action.setUpdatedAt(LocalDateTime.now()); + actions.updateById(action); + } + + private T read(PdaStocktakeAction action, Class type) { + try { return objectMapper.readValue(action.getResultJson(), type); } + catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read stocktake replay", error); } + } + + private void ledger(PdaStocktakeTask task, PdaStocktakeItem item, InventoryPosition position, + InventoryBalance balance, String operator, String key, String reason, + long before, long delta, long after, LocalDateTime now) { + InventoryLedger row = new InventoryLedger(); + row.setId(IdWorker.getId()); row.setCustomerId(item.getCustomerId()); row.setBalanceId(balance.getId()); + row.setPositionId(position.getId()); row.setOverseasWarehouseId(String.valueOf(task.getOverseasWarehouseId())); + row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId()); + row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo()); + row.setSkuCode(item.getSkuCode()); row.setWarehouseLocationCode(position.getWarehouseLocationCode()); + row.setBusinessType("STOCKTAKE_ADJUST"); row.setBusinessOrderId(task.getId()); row.setBusinessOrderNo(task.getTaskNo()); + row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo()); + row.setIdempotencyKey("STOCKTAKE:" + task.getId() + ":" + item.getId()); + row.setStatusFrom("PHYSICAL"); row.setStatusTo("PHYSICAL"); row.setBeforePackages(before); + row.setDeltaPackages(delta); row.setAfterPackages(after); row.setOperatorId(operator); row.setRemark(reason.trim()); + row.setOccurredAt(now); row.setCreatedAt(now); ledgers.insert(row); + } + + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } + } + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize stocktake operation", error); } + } + private static String status(String value) { + String normalized = trim(value); if (normalized == null) return null; + normalized = normalized.toUpperCase(); + if (!List.of("IN_PROGRESS", "SUBMITTED").contains(normalized)) throw badRequest("Unsupported stocktake status"); + return normalized; + } + private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages()) + + value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); } + private static long physicalAfter(InventoryPosition value, long available) { return available + value(value.getHoldPackages()) + + value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); } + private static long value(Long value) { return value == null ? 0 : value; } + private static int intValue(Integer value) { return value == null ? 0 : value; } + private static String required(String value, String message) { String normalized = trim(value); if (normalized == null) throw badRequest(message); return normalized; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java new file mode 100644 index 000000000..874bb0e17 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.pda.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests; +import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/pda/v1/last-leg") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class PdaWarehouseController { + @Resource private PdaWarehouseService service; + + @PostMapping("/inventory/resolve") + public ApiResponse inventory(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaWarehouseRequests.Scan request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.inventory(identity.overseasWarehouseId(), request)); + } + + @PostMapping("/locations/resolve") + public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaWarehouseRequests.Scan request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.location(identity.overseasWarehouseId(), request)); + } + + @PostMapping("/pallets/resolve") + public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaWarehouseRequests.Scan request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pallet(identity.overseasWarehouseId(), request)); + } + + @GetMapping("/operations") + public ApiResponse> operations( + @AuthenticationPrincipal AuthenticatedUser user, @Valid PdaWarehouseRequests.HistoryQuery query) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.history(identity.overseasWarehouseId(), identity.operatorKey(), query)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java new file mode 100644 index 000000000..aaeef8112 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.pda.warehouse.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public final class PdaWarehouseRequests { + private PdaWarehouseRequests() { } + + public record Scan(@NotBlank @Size(max = 128) String code) { } + public record HistoryQuery(@Min(1) long page, @Min(1) @Max(200) long size, String operationType) { + public HistoryQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java new file mode 100644 index 000000000..cb5b3921e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.pda.warehouse.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaWarehouseResponses { + private PdaWarehouseResponses() { } + + public record InventoryScan(String queryCode, List items) { } + public record InventoryItem(String customerId, String customerName, long balanceId, long positionId, + String skuCode, String productName, String inboundCode, String cartonMark, + long palletId, String palletCode, Long locationId, String locationCode, + long availablePackages, long holdPackages, long receivingPackages, + String status, long version) { } + public record Location(long locationId, String locationCode, String locationType, BigDecimal maxLoadKg, + String status, long occupiedPackages, int inventoryPositionCount) { } + public record Pallet(long palletId, String palletCode, String status, int version, + long locationId, String locationCode, long occupiedPackages, + int inventoryPositionCount, List inventory) { } + public record Operation(long operationId, String operationType, String businessKey, String status, + String summary, String customerId, LocalDateTime occurredAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java new file mode 100644 index 000000000..59e428c49 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.pda.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_pda_operation_history") +public class PdaOperationHistory { + @TableId(type = IdType.INPUT) + private Long id; + private Long overseasWarehouseId; + private String customerId; + private String operatorKey; + private String operationType; + private String businessKey; + private String status; + private String idempotencyKey; + private String requestHash; + private String summary; + private LocalDateTime occurredAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java new file mode 100644 index 000000000..586d7dde6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.pda.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory; +import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface PdaWarehouseMapper extends BaseMapper { + String INVENTORY_SELECT = "SELECT DISTINCT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," + + "COALESCE(NULLIF(r.sku_code,''),b.sku_code) sku_code," + + "COALESCE(NULLIF(r.product_name_zh,''),b.product_name_zh) product_name_zh," + + "COALESCE(NULLIF(r.product_name_en,''),b.product_name_en) product_name_en,b.inbound_code," + + "c.carton_mark,p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," + + "p.warehouse_location_code location_code,p.available_packages,p.hold_packages,p.receiving_packages," + + "p.status,p.version FROM ll_inventory_balance b JOIN ll_inventory_position p " + + "ON p.balance_id=b.id AND p.customer_id=b.customer_id " + + "LEFT JOIN ll_inbound_stock_receipt r ON r.inventory_balance_id=b.id AND r.inventory_position_id=p.id " + + "LEFT JOIN ll_inbound_plan_carton c ON c.customer_id=b.customer_id AND c.order_id=b.inbound_order_id " + + "AND c.warehouse_pallet_id=p.pallet_id AND c.carton_mark=#{code} AND c.status='RECEIVED' "; + + @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " + + "AND (UPPER(b.sku_code)=UPPER(#{code}) OR UPPER(r.sku_code)=UPPER(#{code}) " + + "OR b.inbound_code=#{code} OR c.carton_mark=#{code}) " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id") + List scanInventory(@Param("warehouseId") String warehouseId, + @Param("code") String code); + + @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.id") + List inventoryByPallet(@Param("warehouseId") String warehouseId, + @Param("palletId") long palletId, + @Param("code") String ignored); + + @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " + + "AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id") + List inventoryByLocation(@Param("warehouseId") String warehouseId, + @Param("locationId") long locationId, + @Param("locationCode") String locationCode, + @Param("code") String ignored); + + @Select("") + List history( + @Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey, + @Param("operationType") String operationType, @Param("offset") long offset, @Param("limit") long limit); + + @Select("") + long countHistory(@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey, + @Param("operationType") String operationType); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java new file mode 100644 index 000000000..a34b41d97 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.pda.warehouse.model; + +import lombok.Data; + +@Data +public class PdaInventoryProjection { + private String customerId; + private String customerName; + private Long balanceId; + private Long positionId; + private String skuCode; + private String productNameZh; + private String productNameEn; + private String inboundCode; + private String cartonMark; + private Long palletId; + private String palletCode; + private Long locationId; + private String locationCode; + private Long availablePackages; + private Long holdPackages; + private Long receivingPackages; + private String status; + private Long version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java new file mode 100644 index 000000000..b8500a76e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.pda.warehouse.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests; +import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses; +import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory; +import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper; +import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; + +@Service +public class PdaWarehouseService { + @Resource private PdaWarehouseMapper mapper; + @Resource private WarehouseResourceGateway warehouse; + + public PdaWarehouseResponses.InventoryScan inventory(long warehouseId, PdaWarehouseRequests.Scan request) { + String code = code(request.code()); + List items = items(mapper.scanInventory(String.valueOf(warehouseId), code)); + if (items.isEmpty()) throw notFound("Inventory code was not found in the bound warehouse"); + return new PdaWarehouseResponses.InventoryScan(code, items); + } + + public PdaWarehouseResponses.Location location(long warehouseId, PdaWarehouseRequests.Scan request) { + WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId, + code(request.code())); + List inventory = items(mapper.inventoryByLocation( + String.valueOf(warehouseId), location.locationId(), location.locationCode(), "__NO_CARTON__")); + long packages = inventory.stream().mapToLong(this::packages).sum(); + int positions = (int) inventory.stream().map(PdaWarehouseResponses.InventoryItem::positionId).distinct().count(); + return new PdaWarehouseResponses.Location(location.locationId(), location.locationCode(), + location.locationType(), location.maxLoadKg(), location.status(), packages, positions); + } + + public PdaWarehouseResponses.Pallet pallet(long warehouseId, PdaWarehouseRequests.Scan request) { + WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(code(request.code())); + if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse"); + WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId, + pallet.locationId()); + List inventory = items(mapper.inventoryByPallet( + String.valueOf(warehouseId), pallet.palletId(), "__NO_CARTON__")); + long packages = inventory.stream().mapToLong(this::packages).sum(); + int positions = (int) inventory.stream().map(PdaWarehouseResponses.InventoryItem::positionId).distinct().count(); + return new PdaWarehouseResponses.Pallet(pallet.palletId(), pallet.palletNo(), pallet.status(), pallet.version(), + location.locationId(), location.locationCode(), packages, positions, inventory); + } + + public PageResult history(long warehouseId, String operatorKey, + PdaWarehouseRequests.HistoryQuery query) { + long offset = (query.page() - 1) * query.size(); + List rows = mapper.history(warehouseId, operatorKey, + trim(query.operationType()), offset, query.size()); + long total = mapper.countHistory(warehouseId, operatorKey, trim(query.operationType())); + return new PageResult<>(rows, query.page(), query.size(), total, + total == 0 ? 0 : (total + query.size() - 1) / query.size()); + } + + public void record(long warehouseId, String customerId, String operatorKey, String operationType, + String businessKey, String status, String idempotencyKey, String requestHash, String summary) { + PdaOperationHistory history = new PdaOperationHistory(); + history.setId(IdWorker.getId()); history.setOverseasWarehouseId(warehouseId); + history.setCustomerId(trim(customerId)); history.setOperatorKey(operatorKey); + history.setOperationType(operationType); history.setBusinessKey(businessKey); history.setStatus(status); + history.setIdempotencyKey(trim(idempotencyKey)); history.setRequestHash(trim(requestHash)); + history.setSummary(trim(summary)); history.setOccurredAt(LocalDateTime.now()); history.setCreatedAt(history.getOccurredAt()); + mapper.insert(history); + } + + private List items(List values) { + LinkedHashMap result = new LinkedHashMap<>(); + for (PdaInventoryProjection value : values) { + String key = value.getPositionId() + ":" + text(value.getSkuCode()) + ":" + text(value.getCartonMark()); + result.putIfAbsent(key, new PdaWarehouseResponses.InventoryItem(value.getCustomerId(), value.getCustomerName(), + number(value.getBalanceId()), number(value.getPositionId()), value.getSkuCode(), + first(value.getProductNameZh(), value.getProductNameEn()), value.getInboundCode(), value.getCartonMark(), + number(value.getPalletId()), value.getPalletCode(), value.getLocationId(), value.getLocationCode(), + number(value.getAvailablePackages()), number(value.getHoldPackages()), number(value.getReceivingPackages()), + value.getStatus(), number(value.getVersion()))); + } + return List.copyOf(result.values()); + } + + private long packages(PdaWarehouseResponses.InventoryItem value) { + return value.availablePackages() + value.holdPackages() + value.receivingPackages(); + } + private static String code(String value) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Scan code is required"); return result; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String text(String value) { return value == null ? "" : value; } + private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; } + private static long number(Long value) { return value == null ? 0 : value; } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java index 133885b7d..d31cd0672 100644 --- 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 @@ -36,7 +36,11 @@ public final class OverseasProcurementRequests { String productName, String status, String purchaseWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) {} - public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {} + public record Review( + @NotNull @Min(0) Long version, + @NotBlank String decision, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} public record Update( @NotNull @Min(0) Long version, 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 index 2a2eff426..d0f94de6b 100644 --- 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 @@ -22,7 +22,7 @@ public final class OverseasProcurementResponses { public record History( String operationType, String fromStatus, String toStatus, String operatorId, - String changeDetail, String remark, + String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) {} public record Detail( 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 index 200b99a67..0a0c52dd9 100644 --- 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 @@ -1,6 +1,8 @@ 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; @@ -17,29 +19,31 @@ public class OverseasProcurementOrder { private String orderNo; private String purchaseCategory; private String productName; - private String productSpecification; - private String productUrl; + @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; - private String purchaseWarehouseId; - private String purchaseWarehouseName; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName; private Long inboundQuantity; - private String domesticCarrier; - private String domesticTrackingNo; - private String overseasCarrier; - private String overseasTrackingNo; - private LocalDateTime estimatedArrivalAt; + @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; - private String remark; + @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; 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 index 509b9f1bd..e7d3be60d 100644 --- 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 @@ -17,6 +17,7 @@ public class OverseasProcurementStatusHistory { 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/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 865caf78c..6a08ae5aa 100644 --- 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 @@ -89,7 +89,8 @@ public class OverseasProcurementService { if (concurrent != null) return replay(customerId, concurrent, requestHash); throw exception; } - history(order, "CREATE", null, PENDING_REVIEW, operatorId, null, request.remark(), now); + history(order, "CREATE", null, PENDING_REVIEW, operatorId, + key, null, request.remark(), now); return detail(customerId, order.getOrderNo()); } @@ -144,7 +145,8 @@ public class OverseasProcurementService { 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.getChangeDetail(), value.getRemark(), + value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), + value.getChangeDetail(), value.getRemark(), value.getCreatedAt())).toList()); } @@ -157,16 +159,27 @@ public class OverseasProcurementService { default -> throw badRequest("Review decision must be APPROVE or REJECT"); }; OverseasProcurementOrder order = lock(customerId, orderNo); - if (next.equals(order.getStatus())) return detail(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, null, request.remark(), now); + history(order, "REVIEW", PENDING_REVIEW, next, operatorId, + key, null, request.remark(), now); return detail(customerId, orderNo); } @@ -197,7 +210,7 @@ public class OverseasProcurementService { touch(order, now); orders.updateById(order); history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, - changeDetail(before, after), null, now); + null, changeDetail(before, after), null, now); return detail(customerId, orderNo); } @@ -224,7 +237,8 @@ public class OverseasProcurementService { } private void history(OverseasProcurementOrder order, String operationType, String from, String to, - String operator, String changeDetail, String remark, LocalDateTime now) { + String operator, String idempotencyKey, String changeDetail, + String remark, LocalDateTime now) { OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory(); history.setId(IdWorker.getId()); history.setOrderId(order.getId()); @@ -233,6 +247,7 @@ public class OverseasProcurementService { history.setFromStatus(from); history.setToStatus(to); history.setOperatorId(operator); + history.setIdempotencyKey(trim(idempotencyKey)); history.setChangeDetail(changeDetail); history.setRemark(trim(remark)); history.setCreatedAt(now); 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 index 4807f5419..fa5482e72 100644 --- 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 @@ -25,9 +25,10 @@ public class RelabelApplicationController { @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("0", operator(user), request)); + return ApiResponse.success(service.create(requiredCustomer(customerId), operator(user), request)); } @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") @@ -60,5 +61,12 @@ public class RelabelApplicationController { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); } } + private String requiredCustomer(String customerId) { + String value = customerId == null ? null : customerId.trim(); + if (value == null || value.isEmpty() || "0".equals(value)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required"); + } + return value; + } private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java new file mode 100644 index 000000000..893536c8e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.pda; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.relabel.pda.mapper") +public class PdaRelabelMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java new file mode 100644 index 000000000..6d89ac391 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java @@ -0,0 +1,127 @@ +package com.baiyue.lastleg.relabel.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses; +import com.baiyue.lastleg.relabel.pda.service.PdaRelabelService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/relabel-tasks") +@PreAuthorize("hasAuthority('lastleg:pda:operate')") +public class PdaRelabelController { + public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + private static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER"; + + @Resource private PdaRelabelService service; + + @GetMapping("/candidates") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user) { + Identity identity = identity(user); + return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId())); + } + + @GetMapping("/{relabelNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo) { + Identity identity = identity(user); + return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), relabelNo)); + } + + @PostMapping("/{relabelNo}/claim") + public ApiResponse claim(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Claim request) { + Identity identity = identity(user); + return ApiResponse.success(service.claim(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/units") + public ApiResponse scan(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Scan request) { + Identity identity = identity(user); + return ApiResponse.success(service.scan(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/units/skip") + public ApiResponse skip(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Skip request) { + Identity identity = identity(user); + return ApiResponse.success(service.skip(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/exceptions") + public ApiResponse exception(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.ExceptionFact request) { + Identity identity = identity(user); + return ApiResponse.success(service.exception(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping(value = "/{relabelNo}/photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @RequestPart("file") MultipartFile file) { + Identity identity = identity(user); + return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), identity.overseasWarehouseId(), + identity.userId(), relabelNo, file)); + } + + @PostMapping("/{relabelNo}/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Complete request) { + Identity identity = identity(user); + return ApiResponse.success(service.complete(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/review") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Review request) { + Identity identity = identity(user); + return ApiResponse.success(service.review(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + static Identity identity(AuthenticatedUser user) { + if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0L + || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "Authenticated platform warehouse PDA identity is required"); + } + if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION) + || !user.permissions().contains(PICKER_ROLE)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "WAREHOUSE_PICKER and " + OPERATE_PERMISSION + " are required"); + } + if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "PDA operator must be bound to one overseas warehouse"); + } + String operator = String.valueOf(user.userId()); + return new Identity(user.userId(), operator, + user.username() == null || user.username().isBlank() ? operator : user.username(), + user.overseasWarehouseId()); + } + + record Identity(long userId, String operatorKey, String operatorName, long overseasWarehouseId) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java new file mode 100644 index 000000000..c363ac059 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.relabel.pda.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public final class PdaRelabelRequests { + private PdaRelabelRequests() { } + + public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { } + + public record Scan(@NotNull @Positive Long itemId, + @NotBlank @Size(max = 255) String oldLabel, + @NotBlank @Size(max = 255) String newLabel, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record Skip(@NotNull @Positive Long itemId, + @NotBlank @Size(max = 255) String oldLabel, + @NotBlank @Size(max = 1000) String reason, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record ExceptionFact(@NotNull @Positive Long itemId, + @Size(max = 255) String oldLabel, + @NotBlank @Size(max = 32) String exceptionCode, + @NotBlank @Size(max = 1000) String reason, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record Complete(@NotEmpty @Size(max = 20) List<@Positive Long> photoFileIds, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) { } + + public record Review(@NotBlank @Size(max = 16) String decision, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java new file mode 100644 index 000000000..72724a325 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.relabel.pda.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaRelabelResponses { + private PdaRelabelResponses() { } + + public record Candidate(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, + String applicationStatus, String taskStatus, int itemCount, + int expectedPackages, int relabelledPackages, int skippedPackages, + int exceptionPackages, int version, boolean claimedByCurrentOperator) { } + + public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, + String contactName, String contactPhone, String applicationStatus, + String taskStatus, int expectedPackages, int relabelledPackages, + int skippedPackages, int exceptionPackages, int version, + String operatorName, String reviewerName, List completionPhotoFileIds, + String completionRemark, String reviewDecision, String reviewRemark, + LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt, + List items, List unitFacts, List history) { } + + public record Item(Long itemId, String skuCode, String productNameZh, String shipmentNo, + String trackingCode, String newSkuCode, String labelRequirement, + String relabelFileId, String relabelFileName, String relabelFileUrl, + int expectedPackages, int relabelledPackages, int skippedPackages, + int exceptionPackages) { } + + public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel, + String result, String exceptionCode, String reason, + String operatorName, LocalDateTime occurredAt) { } + + public record History(String action, String fromStatus, String toStatus, + String operatorName, String detail, LocalDateTime createdAt) { } + + public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java new file mode 100644 index 000000000..674a07be8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.relabel.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_relabel_pda_history") +public class PdaRelabelHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private Long applicationId; + private String customerId; + private String action; + private String fromStatus; + private String toStatus; + private String operatorKey; + private String operatorName; + private String detailJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java new file mode 100644 index 000000000..991bbea9d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.relabel.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_relabel_pda_operation") +public class PdaRelabelOperation { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private String customerId; + private String operatorKey; + private String action; + private String idempotencyKey; + private String requestHash; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java new file mode 100644 index 000000000..f2e07ea86 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.relabel.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_relabel_pda_task") +public class PdaRelabelTask { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private String customerId; + private String relabelNo; + private Long overseasWarehouseId; + private String status; + private String operatorKey; + private String operatorName; + private String reviewerKey; + private String reviewerName; + private Integer expectedPackages; + private Integer relabelledPackages; + private Integer skippedPackages; + private Integer exceptionPackages; + private String completionPhotoFileIds; + private String completionRemark; + private String reviewDecision; + private String reviewRemark; + private LocalDateTime claimedAt; + private LocalDateTime submittedAt; + private LocalDateTime reviewedAt; + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java new file mode 100644 index 000000000..a6ece68da --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.relabel.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_relabel_pda_unit_fact") +public class PdaRelabelUnitFact { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private Long applicationId; + private Long itemId; + private String customerId; + private String oldLabel; + private String newLabel; + private String result; + private String exceptionCode; + private String reason; + private String operatorKey; + private String operatorName; + private LocalDateTime occurredAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java new file mode 100644 index 000000000..d693674a3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java @@ -0,0 +1,116 @@ +package com.baiyue.lastleg.relabel.pda.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; + +@Component +public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_RELABEL_PHOTO"; + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getRelabelPhotoFile()); + } + + @Override + public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { + long tenantId = tenantId(customerId); + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { + throw new IllegalArgumentException("Cannot read relabel photo", exception); + } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId, BUSINESS_TYPE, taskId) + .header("X-Internal-Service-Token", internalToken) + .header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Relabel photo upload failed"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Relabel photo upload failed"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "File service returned no relabel photo metadata"); + } + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), + data.sizeBytes() == null ? 0L : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long taskId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Relabel photo does not exist or its ownership is invalid"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Relabel photo does not exist or its ownership is invalid"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null || data.fileId() != fileId + || data.tenantId() == null || data.tenantId() != tenantId + || !BUSINESS_TYPE.equals(data.businessType()) + || !String.valueOf(taskId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Relabel photo metadata does not match the PDA task"); + } + } + + private static long tenantId(String customerId) { + try { return Long.parseLong(customerId); } + catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Customer ID must be numeric", exception); + } + } + + private static void requireSuccess(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if ("200".equals(response.code())) return; + HttpStatus status = switch (String.valueOf(response.code())) { + case "A0400", "A0422" -> HttpStatus.BAD_REQUEST; + case "A0403" -> HttpStatus.FORBIDDEN; + case "A0404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.SERVICE_UNAVAILABLE; + }; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + + private record MetadataEnvelope(String code, String message, Metadata data) { } + private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java new file mode 100644 index 000000000..c309f5dd8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.relabel.pda.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface RelabelPhotoFileGateway { + UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file); + void validate(String customerId, long taskId, long fileId); + + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java new file mode 100644 index 000000000..ccd126e40 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PdaRelabelHistoryMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java new file mode 100644 index 000000000..8e96b2050 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface PdaRelabelOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_pda_operation WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + PdaRelabelOperation find(@Param("operatorKey") String operatorKey, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java new file mode 100644 index 000000000..ee6b56521 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface PdaRelabelTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1") + PdaRelabelTask find(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); + + @Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1 FOR UPDATE") + PdaRelabelTask lock(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); + + @Update(""" + UPDATE ll_relabel_pda_task + SET status=#{task.status}, operator_key=#{task.operatorKey}, operator_name=#{task.operatorName}, + reviewer_key=#{task.reviewerKey}, reviewer_name=#{task.reviewerName}, + relabelled_packages=#{task.relabelledPackages}, skipped_packages=#{task.skippedPackages}, + exception_packages=#{task.exceptionPackages}, completion_photo_file_ids=#{task.completionPhotoFileIds}, + completion_remark=#{task.completionRemark}, review_decision=#{task.reviewDecision}, + review_remark=#{task.reviewRemark}, submitted_at=#{task.submittedAt}, reviewed_at=#{task.reviewedAt}, + version=version+1, updated_at=#{task.updatedAt} + WHERE id=#{task.id} AND customer_id=#{task.customerId} AND version=#{expectedVersion} + """) + int updateWithVersion(@Param("task") PdaRelabelTask task, @Param("expectedVersion") Integer expectedVersion); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java new file mode 100644 index 000000000..af9100c89 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface PdaRelabelUnitFactMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_pda_unit_fact WHERE old_label=#{oldLabel} LIMIT 1") + PdaRelabelUnitFact findOldLabel(@Param("oldLabel") String oldLabel); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java new file mode 100644 index 000000000..a9fc30a0b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -0,0 +1,480 @@ +package com.baiyue.lastleg.relabel.pda.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +@Service +public class PdaRelabelService { + public static final String PROCESSING = "PROCESSING"; + public static final String WAIT_REVIEW = "WAIT_REVIEW"; + public static final String COMPLETED = "COMPLETED"; + private static final long MAX_PHOTO_SIZE = 10L * 1024 * 1024; + private static final Set EXCEPTION_CODES = Set.of( + "DAMAGED", "MISSING_LABEL", "LABEL_MISMATCH", "UNREADABLE", "OTHER"); + + @Resource private RelabelApplicationMapper applications; + @Resource private RelabelItemMapper items; + @Resource private PdaRelabelTaskMapper tasks; + @Resource private PdaRelabelUnitFactMapper unitFacts; + @Resource private PdaRelabelOperationMapper operations; + @Resource private PdaRelabelHistoryMapper histories; + @Resource private RelabelApplicationService applicationService; + @Resource private RelabelPhotoFileGateway photoFiles; + @Resource private ObjectMapper sourceObjectMapper; + private ObjectMapper objectMapper; + + @PostConstruct + void initializeObjectMapper() { + objectMapper = sourceObjectMapper.copy().findAndRegisterModules() + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + @Transactional(readOnly = true) + public List candidates(String operatorKey, long overseasWarehouseId) { + return applications.selectList(new LambdaQueryWrapper() + .eq(RelabelApplication::getOverseasWarehouseId, String.valueOf(overseasWarehouseId)) + .in(RelabelApplication::getStatus, RelabelApplicationService.PENDING_PROCESSING, PROCESSING) + .orderByAsc(RelabelApplication::getLabelDeadline) + .orderByAsc(RelabelApplication::getCreatedAt)) + .stream().map(application -> candidate(application, + tasks.find(application.getCustomerId(), application.getId()), operatorKey)).toList(); + } + + @Transactional(readOnly = true) + public PdaRelabelResponses.Detail detail(String operatorKey, long overseasWarehouseId, String relabelNo) { + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false); + return detail(application, tasks.find(application.getCustomerId(), application.getId())); + } + + @Transactional + public PdaRelabelResponses.Detail claim(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Claim request) { + ClaimCommand command = new ClaimCommand(relabelNo, request.idempotencyKey()); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + "CLAIM", request.idempotencyKey(), command); + if (replay != null) return replay; + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + if (!Set.of(RelabelApplicationService.PENDING_PROCESSING, PROCESSING).contains(application.getStatus())) { + throw conflict("Relabel application is not executable"); + } + PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId()); + if (task != null) { + if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) { + throw conflict("Relabel task is already claimed or awaiting review"); + } + } else { + LocalDateTime now = LocalDateTime.now(); + task = new PdaRelabelTask(); + task.setId(IdWorker.getId()); + task.setApplicationId(application.getId()); task.setCustomerId(application.getCustomerId()); + task.setRelabelNo(application.getRelabelNo()); task.setOverseasWarehouseId(overseasWarehouseId); + task.setStatus(PROCESSING); task.setOperatorKey(operatorKey); task.setOperatorName(operatorName); + task.setExpectedPackages(expectedPackages(application.getId())); + task.setRelabelledPackages(0); task.setSkippedPackages(0); task.setExceptionPackages(0); + task.setClaimedAt(now); task.setVersion(0); task.setCreatedAt(now); task.setUpdatedAt(now); + try { tasks.insert(task); } + catch (DuplicateKeyException duplicate) { throw conflict("Relabel task was claimed concurrently"); } + if (RelabelApplicationService.PENDING_PROCESSING.equals(application.getStatus())) { + applicationService.changeStatus(application.getCustomerId(), application.getRelabelNo(), operatorKey, + new RelabelRequests.ChangeStatus(PROCESSING, "PDA relabel task claimed")); + application.setStatus(PROCESSING); + } + insertHistory(task, "CLAIM", null, PROCESSING, operatorKey, operatorName, + Map.of("relabelNo", relabelNo)); + } + PdaRelabelResponses.Detail result = detail(application, task); + saveOperation(task, operatorKey, "CLAIM", request.idempotencyKey(), command, result); + return result; + } + + @Transactional + public PdaRelabelResponses.Detail scan(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Scan request) { + return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "RELABEL", request.idempotencyKey(), + request, request.itemId(), request.oldLabel(), request.newLabel(), "RELABELLED", null, null, + request.expectedVersion(), item -> { + if (!item.getNewSkuCode().equalsIgnoreCase(request.newLabel().trim())) { + throw conflict("Scanned new label does not match the approved new SKU"); + } + if (request.oldLabel().trim().equalsIgnoreCase(request.newLabel().trim())) { + throw conflict("Old and new labels must be different"); + } + }); + } + + @Transactional + public PdaRelabelResponses.Detail skip(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Skip request) { + return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "SKIP", request.idempotencyKey(), + request, request.itemId(), request.oldLabel(), null, "SKIPPED", null, request.reason(), + request.expectedVersion(), ignored -> { }); + } + + @Transactional + public PdaRelabelResponses.Detail exception(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.ExceptionFact request) { + String code = request.exceptionCode().trim().toUpperCase(Locale.ROOT); + if (!EXCEPTION_CODES.contains(code)) throw badRequest("Unsupported relabel exception code"); + return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "EXCEPTION", + request.idempotencyKey(), request, request.itemId(), trim(request.oldLabel()), null, + "EXCEPTION", code, request.reason(), request.expectedVersion(), ignored -> { }); + } + + public PdaRelabelResponses.Photo uploadPhoto(String operatorKey, long overseasWarehouseId, long operatorId, + String relabelNo, MultipartFile file) { + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, false); + if (!PROCESSING.equals(task.getStatus())) throw conflict("Relabel photos can only be uploaded while processing"); + if (file == null || file.isEmpty()) throw badRequest("Relabel photo is required"); + if (file.getSize() > MAX_PHOTO_SIZE) throw badRequest("Relabel photo exceeds 10 MB"); + if (file.getContentType() == null || !file.getContentType().toLowerCase(Locale.ROOT).startsWith("image/")) { + throw badRequest("Relabel photo must be an image"); + } + RelabelPhotoFileGateway.UploadedFile uploaded = photoFiles.upload(application.getCustomerId(), + task.getId(), operatorId, file); + return new PdaRelabelResponses.Photo(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes()); + } + + @Transactional + public PdaRelabelResponses.Detail complete(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Complete request) { + CompleteCommand command = new CompleteCommand(relabelNo, request); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + "COMPLETE", request.idempotencyKey(), command); + if (replay != null) return replay; + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); + requireVersion(task, request.expectedVersion()); + List detailItems = itemList(application); + List facts = factList(task); + for (RelabelItem item : detailItems) { + long actual = facts.stream().filter(fact -> item.getId().equals(fact.getItemId())).count(); + if (actual != value(item.getNotifiedPackageCount())) { + throw conflict("Every relabel item must have exactly one fact per notified package"); + } + } + List photoIds = request.photoFileIds().stream().distinct().toList(); + if (photoIds.size() != request.photoFileIds().size()) throw conflict("Relabel photo IDs must be unique"); + photoIds.forEach(fileId -> photoFiles.validate(application.getCustomerId(), task.getId(), fileId)); + String before = task.getStatus(); + task.setStatus(WAIT_REVIEW); task.setCompletionPhotoFileIds(write(photoIds)); + task.setCompletionRemark(trim(request.remark())); task.setSubmittedAt(LocalDateTime.now()); + updateTask(task, request.expectedVersion()); + insertHistory(task, "COMPLETE", before, WAIT_REVIEW, operatorKey, operatorName, + Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), ""))); + application.setStatus(PROCESSING); + PdaRelabelResponses.Detail result = detail(application, task); + saveOperation(task, operatorKey, "COMPLETE", request.idempotencyKey(), command, result); + return result; + } + + @Transactional + public PdaRelabelResponses.Detail review(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Review request) { + ReviewCommand command = new ReviewCommand(relabelNo, request); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + "REVIEW", request.idempotencyKey(), command); + if (replay != null) return replay; + String decision = request.decision().trim().toUpperCase(Locale.ROOT); + if (!Set.of("APPROVE", "REWORK").contains(decision)) throw badRequest("Review decision must be APPROVE or REWORK"); + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + if (!PROCESSING.equals(application.getStatus())) { + throw conflict("Relabel application is no longer executable"); + } + PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId()); + if (task == null) throw notFound("PDA relabel task was not found"); + if (!Objects.equals(task.getOverseasWarehouseId(), overseasWarehouseId)) { + throw notFound("PDA relabel task was not found in the bound overseas warehouse"); + } + if (!WAIT_REVIEW.equals(task.getStatus())) throw conflict("Relabel task is not awaiting review"); + requireVersion(task, request.expectedVersion()); + String before = task.getStatus(); + String after = "APPROVE".equals(decision) ? COMPLETED : PROCESSING; + task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName); + task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark())); + task.setReviewedAt(LocalDateTime.now()); + updateTask(task, request.expectedVersion()); + if ("APPROVE".equals(decision)) { + applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey, + new RelabelRequests.ChangeStatus(COMPLETED, request.remark())); + application.setStatus(COMPLETED); + } + insertHistory(task, "REVIEW_" + decision, before, after, operatorKey, operatorName, + Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), ""))); + PdaRelabelResponses.Detail result = detail(application, task); + saveOperation(task, operatorKey, "REVIEW", request.idempotencyKey(), command, result); + return result; + } + + private PdaRelabelResponses.Detail unitAction(String operatorKey, String operatorName, + long overseasWarehouseId, String relabelNo, String action, String idempotencyKey, Object command, + Long itemId, String oldLabel, String newLabel, String resultCode, String exceptionCode, String reason, + Integer expectedVersion, Consumer validation) { + UnitCommand replayCommand = new UnitCommand(relabelNo, action, command); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + action, idempotencyKey, replayCommand); + if (replay != null) return replay; + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); + requireVersion(task, expectedVersion); + RelabelItem item = requireItem(application, itemId); + validation.accept(item); + List existingFacts = factList(task); + long itemFacts = existingFacts.stream().filter(fact -> itemId.equals(fact.getItemId())).count(); + if (itemFacts >= value(item.getNotifiedPackageCount())) { + throw conflict("Relabel item already has all required package facts"); + } + String normalizedOldLabel = trim(oldLabel); + if (normalizedOldLabel != null && unitFacts.findOldLabel(normalizedOldLabel) != null) { + throw conflict("Old label was already processed by another relabel unit"); + } + PdaRelabelUnitFact fact = new PdaRelabelUnitFact(); + fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId()); + fact.setItemId(itemId); fact.setCustomerId(application.getCustomerId()); + fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(trim(newLabel)); fact.setResult(resultCode); + fact.setExceptionCode(exceptionCode); fact.setReason(trim(reason)); + fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now()); + try { unitFacts.insert(fact); } + catch (DuplicateKeyException duplicate) { throw conflict("Old label was processed concurrently"); } + if ("RELABELLED".equals(resultCode)) task.setRelabelledPackages(value(task.getRelabelledPackages()) + 1); + if ("SKIPPED".equals(resultCode)) task.setSkippedPackages(value(task.getSkippedPackages()) + 1); + if ("EXCEPTION".equals(resultCode)) task.setExceptionPackages(value(task.getExceptionPackages()) + 1); + updateTask(task, expectedVersion); + insertHistory(task, action, PROCESSING, PROCESSING, operatorKey, operatorName, + Map.of("factId", fact.getId(), "itemId", itemId, "result", resultCode)); + PdaRelabelResponses.Detail response = detail(application, task); + saveOperation(task, operatorKey, action, idempotencyKey, replayCommand, response); + return response; + } + + private RelabelApplication requireApplication(String relabelNo, long overseasWarehouseId, boolean lock) { + RelabelApplication application = lock ? applications.lockAnyByRelabelNo(relabelNo) + : applications.findAnyByRelabelNo(relabelNo); + if (application == null) throw notFound("Relabel application was not found"); + long applicationWarehouse; + try { applicationWarehouse = Long.parseLong(application.getOverseasWarehouseId()); } + catch (Exception invalid) { throw conflict("Relabel application has no valid overseas warehouse"); } + if (applicationWarehouse != overseasWarehouseId) { + throw notFound("Relabel application was not found in the bound overseas warehouse"); + } + return application; + } + + private PdaRelabelTask requireOwnedTask(RelabelApplication application, String operatorKey, boolean lock) { + if (!PROCESSING.equals(application.getStatus())) { + throw conflict("Relabel application is no longer executable"); + } + PdaRelabelTask task = lock ? tasks.lock(application.getCustomerId(), application.getId()) + : tasks.find(application.getCustomerId(), application.getId()); + if (task == null) throw conflict("Claim the relabel task before operating"); + if (!operatorKey.equals(task.getOperatorKey())) throw conflict("Relabel task is claimed by another operator"); + if (!Objects.equals(task.getOverseasWarehouseId(), Long.parseLong(application.getOverseasWarehouseId()))) { + throw conflict("Relabel task warehouse does not match the approved application"); + } + if (!PROCESSING.equals(task.getStatus())) throw conflict("Relabel task is not in processing status"); + return task; + } + + private RelabelItem requireItem(RelabelApplication application, Long itemId) { + RelabelItem item = items.selectOne(new LambdaQueryWrapper() + .eq(RelabelItem::getId, itemId).eq(RelabelItem::getApplicationId, application.getId()) + .eq(RelabelItem::getCustomerId, application.getCustomerId())); + if (item == null) throw notFound("Relabel item does not belong to the current application"); + return item; + } + + private void requireVersion(PdaRelabelTask task, Integer expectedVersion) { + if (!Objects.equals(task.getVersion(), expectedVersion)) { + throw conflict("Relabel task version changed; reload before retrying"); + } + } + + private void updateTask(PdaRelabelTask task, Integer expectedVersion) { + task.setUpdatedAt(LocalDateTime.now()); + if (tasks.updateWithVersion(task, expectedVersion) != 1) { + throw conflict("Relabel task version changed concurrently"); + } + task.setVersion(expectedVersion + 1); + } + + private int expectedPackages(Long applicationId) { + return itemList(applicationId).stream().mapToInt(item -> value(item.getNotifiedPackageCount())).sum(); + } + + private List itemList(RelabelApplication application) { return itemList(application.getId()); } + private List itemList(Long applicationId) { + return items.selectList(new LambdaQueryWrapper() + .eq(RelabelItem::getApplicationId, applicationId).orderByAsc(RelabelItem::getId)); + } + + private List factList(PdaRelabelTask task) { + return unitFacts.selectList(new LambdaQueryWrapper() + .eq(PdaRelabelUnitFact::getCustomerId, task.getCustomerId()) + .eq(PdaRelabelUnitFact::getTaskId, task.getId()) + .orderByAsc(PdaRelabelUnitFact::getOccurredAt).orderByAsc(PdaRelabelUnitFact::getId)); + } + + private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task, + String operatorKey) { + return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(), + application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(), + value(application.getItemCount()), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), + task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), + task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()), + task != null && operatorKey.equals(task.getOperatorKey())); + } + + private PdaRelabelResponses.Detail detail(RelabelApplication application, PdaRelabelTask task) { + List detailItems = itemList(application); + List facts = task == null ? List.of() : factList(task); + Map> factsByItem = facts.stream() + .collect(Collectors.groupingBy(PdaRelabelUnitFact::getItemId, LinkedHashMap::new, Collectors.toList())); + List itemResponses = detailItems.stream().map(item -> { + List itemFacts = factsByItem.getOrDefault(item.getId(), List.of()); + return new PdaRelabelResponses.Item(item.getId(), item.getSkuCode(), item.getProductNameZh(), + item.getShipmentNo(), item.getTrackingCode(), item.getNewSkuCode(), item.getLabelRequirement(), + item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(), + value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"), + count(itemFacts, "EXCEPTION")); + }).toList(); + List factResponses = facts.stream().map(fact -> + new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(), + fact.getResult(), fact.getExceptionCode(), fact.getReason(), fact.getOperatorName(), fact.getOccurredAt())).toList(); + List historyResponses = task == null ? List.of() : histories.selectList( + new LambdaQueryWrapper().eq(PdaRelabelHistory::getCustomerId, application.getCustomerId()) + .eq(PdaRelabelHistory::getTaskId, task.getId()) + .orderByAsc(PdaRelabelHistory::getCreatedAt).orderByAsc(PdaRelabelHistory::getId)).stream() + .map(history -> new PdaRelabelResponses.History(history.getAction(), history.getFromStatus(), + history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList(); + return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(), + application.getLabelDeadline(), application.getContactName(), application.getContactPhone(), + application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(), + task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), + task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), + task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()), + task == null ? null : task.getOperatorName(), task == null ? null : task.getReviewerName(), + task == null ? List.of() : readIds(task.getCompletionPhotoFileIds()), + task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(), + task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(), + task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(), + itemResponses, factResponses, historyResponses); + } + + private void insertHistory(PdaRelabelTask task, String action, String from, String to, + String operatorKey, String operatorName, Object detail) { + PdaRelabelHistory history = new PdaRelabelHistory(); + history.setId(IdWorker.getId()); history.setTaskId(task.getId()); + history.setApplicationId(task.getApplicationId()); history.setCustomerId(task.getCustomerId()); + history.setAction(action); history.setFromStatus(from); history.setToStatus(to); + history.setOperatorKey(operatorKey); history.setOperatorName(operatorName); + history.setDetailJson(write(detail)); history.setCreatedAt(LocalDateTime.now()); + histories.insert(history); + } + + private PdaRelabelResponses.Detail replay(String operatorKey, long overseasWarehouseId, String relabelNo, + String action, String key, Object request) { + PdaRelabelOperation operation = operations.find(operatorKey, idempotencyKey(key)); + if (operation == null) return null; + PdaRelabelTask task = tasks.selectById(operation.getTaskId()); + if (task == null || !Objects.equals(task.getOverseasWarehouseId(), overseasWarehouseId) + || !Objects.equals(task.getRelabelNo(), relabelNo) + || !Objects.equals(task.getCustomerId(), operation.getCustomerId())) { + throw notFound("PDA relabel operation was not found in the bound overseas warehouse"); + } + if (!action.equals(operation.getAction()) || !hash(request).equals(operation.getRequestHash())) { + throw conflict("Idempotency key was already used with a different relabel request"); + } + try { return objectMapper.readValue(operation.getResultJson(), PdaRelabelResponses.Detail.class); } + catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read PDA relabel replay", exception); } + } + + private void saveOperation(PdaRelabelTask task, String operatorKey, String action, String key, + Object request, PdaRelabelResponses.Detail result) { + PdaRelabelOperation operation = new PdaRelabelOperation(); + operation.setId(IdWorker.getId()); operation.setTaskId(task.getId()); operation.setCustomerId(task.getCustomerId()); + operation.setOperatorKey(operatorKey); operation.setAction(action); operation.setIdempotencyKey(idempotencyKey(key)); + operation.setRequestHash(hash(request)); operation.setResultJson(write(result)); operation.setCreatedAt(LocalDateTime.now()); + try { operations.insert(operation); } + catch (DuplicateKeyException duplicate) { throw conflict("Relabel operation was submitted concurrently"); } + } + + private String hash(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { throw new IllegalStateException(exception); } + } + + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException(exception); } + } + + private List readIds(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { + return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class)); + } catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel photo IDs", exception); } + } + + private static int count(List facts, String result) { + return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count(); + } + private static int value(Integer value) { return value == null ? 0 : value; } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String idempotencyKey(String value) { return value == null ? null : value.trim(); } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + + private record ClaimCommand(String relabelNo, String idempotencyKey) { } + private record UnitCommand(String relabelNo, String action, Object request) { } + private record CompleteCommand(String relabelNo, PdaRelabelRequests.Complete request) { } + private record ReviewCommand(String relabelNo, PdaRelabelRequests.Review request) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java index 72fd13cd9..53e110c8a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -32,6 +32,8 @@ public class UnloadCompletedOutboxRepository { .eq(LastlegEventOutbox::getId, id) .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt) + .or().le(LastlegEventOutbox::getNextRetryAt, now)) .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) .set(LastlegEventOutbox::getStatus, "PROCESSING") 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 index 2d144ffc4..8b88d3694 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -9,6 +9,8 @@ import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; @@ -35,6 +37,7 @@ public class LastlegOrderService { @Resource private LastlegOrderMapper orderMapper; @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; + @Resource private InboundPlanCartonMapper cartonMapper; public PageResult page(String customerId, LastlegOrderRequests.Query query) { Page page = orderMapper.selectPage( @@ -46,9 +49,11 @@ public class LastlegOrderService { public LastlegOrderDetailResponse detail(String customerId, long orderId) { LastlegOrder order = findOwned(customerId, orderId); List cargo = detailMapper.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) .eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId)); List history = historyMapper.selectList( new LambdaQueryWrapper() + .eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId()) .eq(LastlegOrderStatusHistory::getOrderId, orderId) .orderByAsc(LastlegOrderStatusHistory::getCreatedAt)); return new LastlegOrderDetailResponse(summary(order), cargo, history.stream() @@ -86,9 +91,19 @@ public class LastlegOrderService { detailUpdate.setDestinationWarehouseCode(request.warehouseCode()); detailUpdate.setDestinationWarehouseName(request.warehouseName()); detailMapper.update(detailUpdate, new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) .eq(LastlegOrderDetail::getOrderId, order.getId())); + InboundPlanCarton cartonUpdate = new InboundPlanCarton(); + cartonUpdate.setDestinationWarehouseId(request.warehouseId()); + cartonUpdate.setDestinationWarehouseCode(request.warehouseCode()); + cartonUpdate.setDestinationWarehouseName(request.warehouseName()); + cartonMapper.update(cartonUpdate, new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, order.getCustomerId()) + .eq(InboundPlanCarton::getOrderId, order.getId())); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setCustomerId(order.getCustomerId()); history.setOrderId(order.getId()); history.setFromStatus(fromStatus); history.setToStatus(DESTINATION_ASSIGNED); @@ -164,7 +179,7 @@ public class LastlegOrderService { private LastlegOrderSummary summary(LastlegOrder o) { return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), - LastlegOrderType.labelOf(o.getOrderType()), o.getSourceInboundId(), + LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), 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 index febdbe99d..0fcdae84e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -11,12 +11,14 @@ import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.DeserializationFeature; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -40,8 +42,9 @@ public class LoadingCompletedConsumer { @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private DropshipOrderService dropshipOrders; @Resource private InventoryCommandService inventory; + @Resource private ContainerShippingGateMapper shippingGates; - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public int consume(EventEnvelope envelope) { envelope.requireValid(); if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType()) @@ -51,6 +54,8 @@ public class LoadingCompletedConsumer { throw new IllegalArgumentException("尾程交接事件区域无效"); if (!"LOADING_PLAN".equals(envelope.aggregateType())) throw new IllegalArgumentException("尾程交接事件聚合类型无效"); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), + envelope.payloadHash())) throw new IllegalArgumentException("payloadHash 与尾程事件负载不一致"); FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope); if(payload.containerNo()==null||payload.containerNo().isBlank()) throw new IllegalArgumentException("事件没有实际集装箱号 containerNo"); @@ -67,9 +72,20 @@ public class LoadingCompletedConsumer { inbox.setEventVersion(envelope.eventVersion()); inbox.setPayloadHash(envelope.payloadHash()); inbox.setPayloadJson(envelope.payload().toString()); inbox.setStatus("RECEIVED"); inbox.setReceivedAt(LocalDateTime.now()); try { inboxMapper.insert(inbox); } catch(DuplicateKeyException exception) { + LastlegEventInbox raced = inboxMapper.selectById(envelope.eventId()); + if (raced == null || raced.getPayloadHash() == null + || !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { + throw new IllegalArgumentException("事件摘要冲突", exception); + } return countOrders(envelope,payload.containerNo()); } + // The loading-plan and container-shipping events are independent aggregate streams. + // Seeding the shipping version from the loading-plan version makes a valid first node + // (aggregateVersion=0) look stale whenever the loading plan has a higher version. + shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "", + payload.containerNo(), 0, envelope.handoverVersion()); + String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); JsonNode rawOrders = envelope.payload().path("orders"); for (int index = 0; index < payload.orders().size(); index++) { @@ -107,12 +123,27 @@ public class LoadingCompletedConsumer { } continue; } + String destinationType = destinationType(payload, order); for (FirstLegLoadingPlanCompletedV1.Item item : order.items()) { - requireSkuCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks); + 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()) { @@ -139,6 +170,7 @@ public class LoadingCompletedConsumer { } LastlegOrder order=new LastlegOrder(); String orderType = source.orderType(); + String destinationType = destinationType(payload, source); String customerId = source.customerId() == null || source.customerId().isBlank() ? event.tenantId() : source.customerId(); if (!event.tenantId().equals(customerId)) { @@ -150,10 +182,12 @@ public class LoadingCompletedConsumer { throw new IllegalArgumentException("One-click dropship event has no pallet snapshot"); } order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType); + order.setDestinationType(destinationType); order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name()); order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId()); order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId()); - order.setOverseasWarehouseId(payload.warehouseId()); order.setOverseasWarehouseName(payload.warehouseName()); + order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName()); + order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null); order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo()); order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false); order.setPackageCount(source.packageCount()); @@ -188,7 +222,7 @@ public class LoadingCompletedConsumer { 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(), item); + createCartonLabels(customerId, order.getId(), destinationType, item); } LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId()); @@ -219,7 +253,7 @@ public class LoadingCompletedConsumer { FirstLegLoadingPlanCompletedV1.Order source, LastlegOrder order, String orderHash) { LastlegInboundOrder snapshot = new LastlegInboundOrder(); - snapshot.setId(order.getId()); snapshot.setTenantId("BAIYUE"); + 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()); @@ -227,6 +261,7 @@ public class LoadingCompletedConsumer { 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()); @@ -254,9 +289,11 @@ public class LoadingCompletedConsumer { } } - private void createCartonLabels(String customerId, Long orderId, FirstLegLoadingPlanCompletedV1.Item item) { + private void createCartonLabels(String customerId, Long orderId, String destinationType, + FirstLegLoadingPlanCompletedV1.Item item) { + if (!"OVERSEAS".equals(destinationType)) return; List marks = item.cartonMarks() == null ? List.of() : item.cartonMarks(); - if (marks.isEmpty()) return; // 历史 V1 事件保持原有按箱数入库路径。 + if (marks.isEmpty()) return; if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) { throw new IllegalArgumentException("装柜事件的箱唛池不可包含空值或重复箱唛"); } @@ -272,6 +309,40 @@ public class LoadingCompletedConsumer { } } + private String destinationType(FirstLegLoadingPlanCompletedV1 payload, + FirstLegLoadingPlanCompletedV1.Order order) { + String explicit = normalizeDestinationType(order.destinationType()); + if (explicit != null) return explicit; + + boolean fbaEvidence = order.items().stream().anyMatch(item -> hasText(item.fbaTrackingNo()) + || hasText(item.shipmentNo()) || startsWithFba(item.destinationWarehouseName())); + boolean overseasWarehouseIdentity = hasText(payload.warehouseId()) + && Objects.equals(payload.warehouseId(), order.destinationWarehouseId()); + if (fbaEvidence && overseasWarehouseIdentity) { + throw new IllegalArgumentException("历史尾程事件目的类型证据冲突: " + order.sourceInboundId()); + } + if (fbaEvidence) return "FBA"; + if (overseasWarehouseIdentity) return "OVERSEAS"; + throw new IllegalArgumentException("历史尾程事件缺少 destinationType 且无法安全推断: " + order.sourceInboundId()); + } + + private String normalizeDestinationType(String value) { + if (!hasText(value)) return null; + String normalized = value.trim().toUpperCase(java.util.Locale.ROOT); + if (!"FBA".equals(normalized) && !"OVERSEAS".equals(normalized)) { + throw new IllegalArgumentException("尾程事件 destinationType 必须为 FBA 或 OVERSEAS"); + } + return normalized; + } + + private boolean startsWithFba(String value) { + return hasText(value) && value.trim().toUpperCase(java.util.Locale.ROOT).startsWith("FBA"); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + private List pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) { if (source.pallets() != null && !source.pallets().isEmpty()) { return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/ShippingMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/ShippingMapperConfiguration.java new file mode 100644 index 000000000..509032dff --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/ShippingMapperConfiguration.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.shipping; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +/** Registers the shipping gate persistence mapper used during event and receiving workflows. */ +@Configuration +@MapperScan(basePackages = "com.baiyue.lastleg.shipping.mapper") +public class ShippingMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java new file mode 100644 index 000000000..26b248db4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.shipping.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_container_shipping_gate") +public class ContainerShippingGate { + @TableId(type = IdType.INPUT) + private String containerBookingId; + private String customerId; + private String oceanBookingId; + private String containerNo; + private String status; + private String standardNode; + private Integer nodeSequence; + private LocalDateTime observedAt; + private LocalDateTime actualAt; + private String locationCode; + private String locationName; + private String providerCode; + private String providerEventId; + private String providerNodeCode; + private String sourceReference; + private String rawPayloadHash; + private Long aggregateVersion; + private Long handoverVersion; + private String lastEventId; + private String payloadHash; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java new file mode 100644 index 000000000..c505dca92 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.shipping.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ContainerShippingGateMapper extends BaseMapper { + @Select("SELECT * FROM ll_container_shipping_gate WHERE container_booking_id=#{containerBookingId} FOR UPDATE") + ContainerShippingGate lock(@Param("containerBookingId") String containerBookingId); + + @Insert("INSERT IGNORE INTO ll_container_shipping_gate(container_booking_id,customer_id,ocean_booking_id," + + "container_no,status,node_sequence,aggregate_version,handover_version,created_at,updated_at) VALUES(" + + "#{containerBookingId},#{customerId},#{oceanBookingId},#{containerNo},'AWAITING_PICKUP_READY',0," + + "#{aggregateVersion},#{handoverVersion},NOW(3),NOW(3))") + int initialize(@Param("containerBookingId") String containerBookingId, @Param("customerId") String customerId, + @Param("oceanBookingId") String oceanBookingId, @Param("containerNo") String containerNo, + @Param("aggregateVersion") long aggregateVersion, @Param("handoverVersion") long handoverVersion); + + @Select("SELECT CASE WHEN COUNT(DISTINCT o.source_container_booking_id)=1 " + + "AND MIN(CASE WHEN g.status='RECEIVING_AVAILABLE' THEN 1 ELSE 0 END)=1 THEN 1 ELSE 0 END " + + "FROM ll_order o LEFT JOIN ll_container_shipping_gate g " + + "ON g.container_booking_id=o.source_container_booking_id AND g.customer_id=o.customer_id " + + "WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo}") + int receivingAvailable(@Param("customerId") String customerId, @Param("containerNo") String containerNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java new file mode 100644 index 000000000..b641c1e46 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java @@ -0,0 +1,129 @@ +package com.baiyue.lastleg.shipping.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.lastleg.entity.LastlegEventInbox; +import com.baiyue.lastleg.mapper.LastlegEventInboxMapper; +import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +@Service +public class ContainerShippingNodeConsumer { + public static final int PICKUP_READY_SEQUENCE = 80; + @Resource private LastlegEventInboxMapper inboxes; + @Resource private ContainerShippingGateMapper gates; + @Resource private ObjectMapper objectMapper; + + @Transactional(isolation = Isolation.READ_COMMITTED) + public int consume(EventEnvelope envelope) { + validate(envelope); + FirstLegContainerShippingNodeRecordedV1 payload = payload(envelope); + validateEnvelopePayload(envelope, payload); + LastlegEventInbox existing = inboxes.selectById(envelope.eventId()); + if (existing != null) { + if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { + throw new IllegalArgumentException("船节点事件摘要冲突"); + } + return 1; + } + LastlegEventInbox inbox = inbox(envelope); + try { inboxes.insert(inbox); } + catch (DuplicateKeyException raced) { + LastlegEventInbox concurrent = inboxes.selectById(envelope.eventId()); + if (concurrent == null || !concurrent.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { + throw new IllegalArgumentException("船节点事件摘要冲突", raced); + } + return 1; + } + + gates.initialize(payload.containerBookingId(), payload.customerId(), payload.oceanBookingId(), + payload.containerNo(), 0, 1); + ContainerShippingGate gate = gates.lock(payload.containerBookingId()); + if (gate == null || !payload.customerId().equals(gate.getCustomerId()) + || !payload.containerNo().equals(gate.getContainerNo())) { + throw new IllegalArgumentException("船节点柜号快照与装柜交接不一致"); + } + apply(gate, envelope, payload); + gates.updateById(gate); + inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxes.updateById(inbox); + return 1; + } + + private void apply(ContainerShippingGate gate, EventEnvelope envelope, + FirstLegContainerShippingNodeRecordedV1 payload) { + long currentVersion = gate.getAggregateVersion() == null ? 0 : gate.getAggregateVersion(); + int currentSequence = gate.getNodeSequence() == null ? 0 : gate.getNodeSequence(); + LocalDateTime actualAt = LocalDateTime.ofInstant(payload.actualAt(), ZoneOffset.UTC); + boolean newerVersion = envelope.aggregateVersion() >= currentVersion; + boolean newerNode = payload.nodeSequence() > currentSequence; + boolean laterSameNode = payload.nodeSequence() == currentSequence + && (gate.getActualAt() == null || actualAt.isAfter(gate.getActualAt())); + boolean accepted = newerVersion && (newerNode || laterSameNode); + if (accepted) { + gate.setOceanBookingId(payload.oceanBookingId()); + gate.setStandardNode(payload.standardNode()); gate.setNodeSequence(payload.nodeSequence()); + gate.setObservedAt(LocalDateTime.ofInstant(payload.observedAt(), ZoneOffset.UTC)); gate.setActualAt(actualAt); + gate.setLocationCode(payload.locationCode()); gate.setLocationName(payload.locationName()); + gate.setProviderCode(payload.providerCode()); gate.setProviderEventId(payload.providerEventId()); + gate.setProviderNodeCode(payload.providerNodeCode()); gate.setSourceReference(payload.sourceReference()); + gate.setRawPayloadHash(payload.rawPayloadHash()); gate.setLastEventId(envelope.eventId()); + gate.setPayloadHash(envelope.payloadHash()); + } + if (accepted && payload.nodeSequence() >= PICKUP_READY_SEQUENCE) { + gate.setStatus("RECEIVING_AVAILABLE"); + } + gate.setAggregateVersion(Math.max(currentVersion, envelope.aggregateVersion())); + gate.setHandoverVersion(Math.max(gate.getHandoverVersion() == null ? 0 : gate.getHandoverVersion(), + envelope.handoverVersion())); + gate.setUpdatedAt(LocalDateTime.now()); + } + + private void validate(EventEnvelope envelope) { + envelope.requireValid(); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), + envelope.payloadHash())) { + throw new IllegalArgumentException("payloadHash 与船节点事件负载不一致"); + } + if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType()) + || envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN + || envelope.targetRegion() != RegionCode.OVERSEAS + || !"CONTAINER_BOOKING".equals(envelope.aggregateType()) + || envelope.handoverVersion() == null + || envelope.handoverVersion() != envelope.aggregateVersion() + 1) { + throw new IllegalArgumentException("船节点交接信封无效"); + } + } + + private void validateEnvelopePayload(EventEnvelope envelope, FirstLegContainerShippingNodeRecordedV1 payload) { + if (!envelope.tenantId().equals(payload.customerId()) + || !envelope.aggregateId().equals(payload.containerBookingId())) { + throw new IllegalArgumentException("船节点交接客户或聚合标识不一致"); + } + } + + private FirstLegContainerShippingNodeRecordedV1 payload(EventEnvelope envelope) { + try { return objectMapper.treeToValue(envelope.payload(), FirstLegContainerShippingNodeRecordedV1.class); } + catch (Exception exception) { throw new IllegalArgumentException("船节点交接负载无效", exception); } + } + + private LastlegEventInbox inbox(EventEnvelope envelope) { + LastlegEventInbox value = new LastlegEventInbox(); value.setEventId(envelope.eventId()); + value.setCustomerId(envelope.tenantId()); value.setEventType(envelope.eventType()); + value.setEventVersion(envelope.eventVersion()); value.setPayloadHash(envelope.payloadHash()); + value.setPayloadJson(envelope.payload().toString()); value.setStatus("RECEIVED"); + value.setReceivedAt(LocalDateTime.now()); return value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java index a081338e6..09cd8e495 100644 --- 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 @@ -16,6 +16,7 @@ public final class WarehouseTransferResponses { 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, 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 index ddafeed42..8995b02bd 100644 --- 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 @@ -225,6 +225,7 @@ public class WarehouseTransferService { 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(), 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 index b1d1b1803..b1afcaa80 100644 --- 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 @@ -75,6 +75,15 @@ public class PlatformUsTruckOrderController { 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) @@ -85,6 +94,66 @@ public class PlatformUsTruckOrderController { 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) { 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 index 120364ad3..9dc8d9646 100644 --- 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 @@ -4,7 +4,10 @@ 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; @@ -71,7 +74,31 @@ public final class UsTruckOrderRequests { public record ChangeStatus( @NotNull @Min(0) Long version, - @NotBlank @Size(max = 32) String status, + @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( 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 index 6169954b8..2370d6afe 100644 --- 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 @@ -11,13 +11,14 @@ public final class 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, int shippedPalletCount, + 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 fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {} + 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) {} 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 index 264ab0905..6f6d5eb79 100644 --- 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 @@ -25,7 +25,6 @@ public class UsTruckOrder { private Integer packageCount; private Integer receivedPalletCount; private Integer allocatedPalletCount; - private Integer shippedPalletCount; private BigDecimal totalWeightKg; private BigDecimal totalVolumeCbm; private BigDecimal budgetAmount; @@ -37,8 +36,6 @@ public class UsTruckOrder { private String remark; private String idempotencyKey; private String requestHash; - private String receiptIdempotencyKey; - private String receiptRequestHash; private String createdBy; private Long version; private LocalDateTime createdAt; 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/UsTruckOrderStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java index be4fee492..3f4602de5 100644 --- 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 @@ -13,9 +13,11 @@ 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/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/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index 4dd11a600..fe586c4da 100644 --- 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 @@ -7,9 +7,11 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation; import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet; import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper; +import com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper; import com.baiyue.lastleg.security.OverseasPermissions; @@ -30,6 +32,7 @@ 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; @@ -55,20 +58,12 @@ public class UsTruckOrderService { 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 java.util.Map> TRANSITIONS = java.util.Map.ofEntries( - java.util.Map.entry(PENDING_REVIEW, Set.of(WAITING_INBOUND, REJECTED, CANCELLED)), - java.util.Map.entry(WAITING_INBOUND, Set.of(RECEIVING, CANCELLED)), - java.util.Map.entry(RECEIVING, Set.of(WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, CANCELLED)), - java.util.Map.entry(PALLET_OVERAGE_REVIEW, Set.of(WAITING_CONSOLIDATION, CANCELLED)), - java.util.Map.entry(WAITING_CONSOLIDATION, Set.of(PARTIALLY_ALLOCATED, ALLOCATED, CANCELLED)), - java.util.Map.entry(PARTIALLY_ALLOCATED, Set.of(ALLOCATED, CANCELLED)), - java.util.Map.entry(ALLOCATED, Set.of(PENDING_DISPATCH, CANCELLED)), - java.util.Map.entry(PENDING_DISPATCH, Set.of(DISPATCHED, CANCELLED)), - java.util.Map.entry(DISPATCHED, Set.of(IN_TRANSIT)), - java.util.Map.entry(IN_TRANSIT, Set.of(COMPLETED))); + 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; @@ -90,7 +85,7 @@ public class UsTruckOrderService { order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null); order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount())); - order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0); + 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()); @@ -103,7 +98,7 @@ public class UsTruckOrderService { if (concurrent != null) return replay(customerId, concurrent, hash); throw e; } - history(order, null, PENDING_REVIEW, operatorId, request.remark(), now); + history(order, "CREATE", null, PENDING_REVIEW, operatorId, key, request.remark(), now); return detail(customerId, order.getOrderNo()); } @@ -150,7 +145,7 @@ public class UsTruckOrderService { 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, order.getStatus(), order.getStatus(), operatorId, "UPDATE", LocalDateTime.now()); + history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, "UPDATE", LocalDateTime.now()); return detail(customerId, orderNo); } @@ -158,34 +153,37 @@ public class UsTruckOrderService { public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.ChangeStatus request) { UsTruckOrder order = lock(customerId, orderNo); - 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 truck order status"); - String from = order.getStatus(); - if (!next.equals(from)) { - if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(next)) { - throw conflict("Unsupported truck order transition: " + from + " -> " + next); - } - order.setStatus(next); - if (PENDING_REVIEW.equals(from) && WAITING_INBOUND.equals(next)) createPlannedPallets(order); - touch(order); orders.updateById(order); - history(order, from, next, operatorId, request.remark(), LocalDateTime.now()); + 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 detail(customerId, orderNo); + 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); - String key = request.idempotencyKey().trim(); - String requestHash = hash(request); - if (key.equals(order.getReceiptIdempotencyKey())) { - if (!requestHash.equals(order.getReceiptRequestHash())) { - throw conflict("Receipt idempotency key was reused with different pallet data"); - } - return detail(customerId, orderNo); - } - if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); + 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"); } @@ -220,18 +218,180 @@ public class UsTruckOrderService { } } order.setReceivedPalletCount(actual); - order.setReceiptIdempotencyKey(key); - order.setReceiptRequestHash(requestHash); String from = order.getStatus(); String next = actual > planned ? PALLET_OVERAGE_REVIEW : actual == planned ? WAITING_CONSOLIDATION : RECEIVING; order.setStatus(next); touch(order); orders.updateById(order); - history(order, from, next, operatorId, request.remark(), now); + 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) { @@ -240,15 +400,23 @@ public class UsTruckOrderService { .toList(); return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(), o.getRemark(), o.getCreatedBy(), palletDetails, - hs.stream().map(h -> new UsTruckOrderResponses.History(h.getFromStatus(), h.getToStatus(), - h.getOperatorId(), h.getRemark(), h.getCreatedAt())).toList()); + 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()), value(o.getShippedPalletCount()), 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 from, String to, String operator, String remark, LocalDateTime at) { UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId()); h.setFromStatus(from); h.setToStatus(to); h.setOperatorId(operator); h.setRemark(trim(remark)); h.setCreatedAt(at); histories.insert(h); } + 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(); diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index d9e11b65c..3699c27ba 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -41,6 +41,20 @@ lastleg: enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true} baiyue: + http: + clients: + unload-photo-file: + connect-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_READ_TIMEOUT:30s} + relabel-photo-file: + connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s} + warehouse-resource: + connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s} + pallet-physical: + connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s} clients: domestic-warehouse: url: ${DOMESTIC_WAREHOUSE_URL:} @@ -50,7 +64,7 @@ baiyue: event-bridge: url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} firstleg: - event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL:} + 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} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 5da318484..893cea457 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -18,11 +18,29 @@ CREATE TABLE IF NOT EXISTS ll_loading_event_inbox ( UNIQUE KEY uk_ll_loading_inbox_tenant_key (customer_id,event_type,event_id) ) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱'; +CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( + container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', + status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', + standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号', + observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间', actual_at DATETIME(3) NULL COMMENT '节点实际发生时间', + location_code VARCHAR(128) NULL COMMENT '节点地点编码', location_name VARCHAR(255) NULL COMMENT '节点地点名称', + provider_code VARCHAR(64) NULL COMMENT '船节点供应商编码', provider_event_id VARCHAR(128) NULL COMMENT '供应商事件ID', + provider_node_code VARCHAR(128) NULL COMMENT '供应商原始节点编码', source_reference VARCHAR(255) NULL COMMENT '供应商来源引用', + raw_payload_hash VARCHAR(64) NULL COMMENT '供应商原始负载SHA-256', aggregate_version BIGINT NOT NULL DEFAULT 0 COMMENT '来源查询聚合版本', + handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本', last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID', + payload_hash VARCHAR(71) NULL COMMENT '最近采纳事件规范化负载摘要', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(container_booking_id), KEY idx_ll_shipping_gate_candidate(customer_id,container_no,status), + KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence) +) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照'; + CREATE TABLE IF NOT EXISTS ll_order ( id BIGINT NOT NULL COMMENT '雪花 ID', order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', @@ -31,7 +49,9 @@ CREATE TABLE IF NOT EXISTS ll_order ( source_order_no VARCHAR(128) COMMENT '来源订单号', root_order_id VARCHAR(128) COMMENT '根订单 ID', customer_name VARCHAR(255) COMMENT '货主名称', - overseas_warehouse_id VARCHAR(128) COMMENT '海外仓 ID', + source_warehouse_id VARCHAR(128) COMMENT '来源国内装柜仓 ID 快照', + source_warehouse_name VARCHAR(255) COMMENT '来源国内装柜仓名称快照', + overseas_warehouse_id VARCHAR(128) COMMENT '海外实际收货仓 ID,由绑定仓库的PDA操作员认领柜号时冻结', overseas_warehouse_name VARCHAR(255) COMMENT '海外仓名称', transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID', transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码', @@ -79,6 +99,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( 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 编码', @@ -99,7 +120,8 @@ CREATE TABLE IF NOT EXISTS ll_order_detail ( 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_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 ( @@ -134,11 +156,12 @@ CREATE TABLE IF NOT EXISTS ll_event_inbox ( ) ENGINE=InnoDB COMMENT='通用事件接收幂等箱'; CREATE TABLE IF NOT EXISTS ll_inbound_order ( - id BIGINT NOT NULL COMMENT '入仓单主键', customer_id BIGINT NOT NULL COMMENT '租户 ID', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + id BIGINT NOT NULL COMMENT '入仓单主键', customer_id BIGINT NOT NULL COMMENT '客户账号标识', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', source_order_no VARCHAR(128) NULL COMMENT '来源订单号', root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', inbound_code VARCHAR(128) NULL COMMENT '入仓码', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID', @@ -169,6 +192,10 @@ ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等 ALTER TABLE ll_order COMMENT='尾程订单主表'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); +PREPARE lastleg_order_destination_type_stmt FROM @ddl; EXECUTE lastleg_order_destination_type_stmt; DEALLOCATE PREPARE lastleg_order_destination_type_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); +PREPARE lastleg_inbound_destination_type_stmt FROM @ddl; EXECUTE lastleg_inbound_destination_type_stmt; DEALLOCATE PREPARE lastleg_inbound_destination_type_stmt; ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细'; ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史'; ALTER TABLE ll_event_conflict COMMENT='事件冲突记录'; @@ -344,6 +371,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan ( customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + overseas_warehouse_id BIGINT NULL COMMENT '海外实际收货共享物理仓ID,由PDA认领柜号时冻结', status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', @@ -358,9 +386,11 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan ( 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 '目的仓名称快照', @@ -368,7 +398,8 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( 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_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 ( @@ -379,7 +410,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( 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 '乐观锁版本', + 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), @@ -389,6 +420,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', plan_id BIGINT NOT NULL COMMENT '入库计划ID', pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', @@ -400,11 +432,13 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no), UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id, sequence_no), - KEY idx_ll_inbound_pallet_plan (plan_id) + KEY idx_ll_inbound_pallet_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', @@ -412,6 +446,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( 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='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; @@ -435,9 +470,11 @@ ALTER TABLE ll_inbound_plan ALTER TABLE ll_inbound_plan_line COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; ALTER TABLE ll_inbound_plan_line MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单ID', MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', @@ -621,9 +658,157 @@ CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id IS NULL OR l.customer_id=''; +ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND index_name='idx_ll_inbound_line_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD KEY idx_ll_inbound_line_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id IS NULL OR v.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND index_name='idx_ll_inbound_pallet_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD KEY idx_ll_inbound_pallet_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id IS NULL OR a.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +DROP PROCEDURE IF EXISTS assert_inbound_plan_customer_scope_ready; +DELIMITER $$ +CREATE PROCEDURE assert_inbound_plan_customer_scope_ready() +BEGIN + IF EXISTS (SELECT 1 FROM ll_inbound_plan_line l LEFT JOIN ll_inbound_plan p ON p.id=l.plan_id WHERE l.customer_id IS NULL OR l.customer_id='' OR p.id IS NULL OR l.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet v LEFT JOIN ll_inbound_plan p ON p.id=v.plan_id WHERE v.customer_id IS NULL OR v.customer_id='' OR p.id IS NULL OR v.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a LEFT JOIN ll_inbound_plan p ON p.id=a.plan_id WHERE a.customer_id IS NULL OR a.customer_id='' OR p.id IS NULL OR a.customer_id<>p.customer_id) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Inbound plan child customer scope migration is incomplete'; + END IF; +END$$ +DELIMITER ; +CALL assert_inbound_plan_customer_scope_ready(); +DROP PROCEDURE assert_inbound_plan_customer_scope_ready; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_type'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code')); +PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id -SET l.fba_warehouse_code=o.fba_warehouse_code -WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL; +SET l.fba_warehouse_code=o.fba_warehouse_code, + l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(o.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END) +WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL) + OR l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( + id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', + operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', + signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间', + completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key), + KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; + +CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt ( + id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name VARCHAR(255) NULL COMMENT '产品名称快照', + received_packages INT NOT NULL COMMENT 'SKU实收箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code), + KEY idx_ll_receiving_sku_session (customer_id,session_id,line_id), + KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id) +) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( + id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_operation_history ( + id BIGINT NOT NULL COMMENT 'PDA作业历史ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + customer_id VARCHAR(128) NULL COMMENT '作业涉及的客户标识', operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', + operation_type VARCHAR(64) NOT NULL COMMENT '作业类型', business_key VARCHAR(255) NOT NULL COMMENT '作业业务键', + status VARCHAR(32) NOT NULL COMMENT '作业结果状态', idempotency_key VARCHAR(255) NULL COMMENT '写操作幂等键', + request_hash VARCHAR(128) NULL COMMENT '写请求内容哈希', summary VARCHAR(1000) NULL COMMENT '作业结果摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '作业发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_history_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_history_operator(overseas_warehouse_id,operator_key,occurred_at), + KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key) +) ENGINE=InnoDB COMMENT='海外PDA真实作业历史'; + +CREATE TABLE IF NOT EXISTS ll_pda_pallet_operation ( + id BIGINT NOT NULL COMMENT 'PDA托盘作业ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '设备幂等键', + operation_type VARCHAR(64) NOT NULL COMMENT '托盘作业类型', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容摘要', + status VARCHAR(32) NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED', + result_json LONGTEXT NULL COMMENT '成功响应快照', remote_transitions_json LONGTEXT NULL COMMENT '实物托盘状态变更回执', + last_error VARCHAR(2000) NULL COMMENT '最后失败原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_pallet_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_pallet_warehouse(overseas_warehouse_id,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA托盘作业幂等、补偿与结果记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_task ( + id BIGINT NOT NULL COMMENT '盘点任务ID', task_no VARCHAR(64) NOT NULL COMMENT '盘点任务号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', scope_type VARCHAR(16) NOT NULL COMMENT '盘点范围类型LOCATION或PALLET', + scope_resource_id BIGINT NOT NULL COMMENT '盘点库位或托盘资源ID', scope_code VARCHAR(128) NOT NULL COMMENT '盘点库位码或托盘码快照', + status VARCHAR(32) NOT NULL COMMENT '盘点任务状态', position_count INT NOT NULL COMMENT '库存位置总数', + counted_count INT NOT NULL DEFAULT 0 COMMENT '已盘库存位置数', difference_count INT NOT NULL DEFAULT 0 COMMENT '存在差异的库存位置数', + created_by VARCHAR(128) NOT NULL COMMENT '创建操作账号标识', submitted_by VARCHAR(128) NULL COMMENT '提交操作账号标识', + submit_reason VARCHAR(500) NULL COMMENT '盘点差异提交原因', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '创建请求哈希', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + submitted_at DATETIME(3) NULL COMMENT '提交完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_no(task_no), + UNIQUE KEY uk_ll_pda_stocktake_create(overseas_warehouse_id,created_by,idempotency_key), + KEY idx_ll_pda_stocktake_list(overseas_warehouse_id,status,created_at) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点任务'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_item ( + id BIGINT NOT NULL COMMENT '盘点明细ID', task_id BIGINT NOT NULL COMMENT '盘点任务ID', customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户标识', + balance_id BIGINT NOT NULL COMMENT '库存余额ID', position_id BIGINT NOT NULL COMMENT '库存位置ID', sku_code VARCHAR(128) NULL COMMENT 'SKU快照', + product_name VARCHAR(255) NULL COMMENT '品名快照', inbound_code VARCHAR(128) NULL COMMENT '入仓码快照', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', location_id BIGINT NOT NULL COMMENT '库位ID快照', location_code VARCHAR(128) NOT NULL COMMENT '库位码快照', + book_packages BIGINT NOT NULL COMMENT '任务创建时账面总箱数', actual_packages BIGINT NULL COMMENT '现场实盘总箱数', + difference_packages BIGINT NULL COMMENT '实盘与账面箱数差异', status VARCHAR(32) NOT NULL COMMENT '盘点明细状态', + position_version BIGINT NOT NULL COMMENT '任务创建时库存位置版本', counted_by VARCHAR(128) NULL COMMENT '盘点操作账号标识', + version BIGINT NOT NULL DEFAULT 0 COMMENT '明细乐观锁版本', counted_at DATETIME(3) NULL COMMENT '盘点时间', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_item(task_id,position_id), KEY idx_ll_pda_stocktake_item_task(task_id,status,id) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点位置明细'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_action ( + id BIGINT NOT NULL COMMENT '盘点动作ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + action_type VARCHAR(32) NOT NULL COMMENT '盘点动作类型', task_id BIGINT NULL COMMENT '盘点任务ID', request_hash VARCHAR(128) NOT NULL COMMENT '动作请求哈希', + status VARCHAR(32) NOT NULL COMMENT '动作处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_stocktake_action(overseas_warehouse_id,operator_key,idempotency_key), KEY idx_ll_pda_stocktake_action_task(task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA盘点动作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_inventory_move ( + id BIGINT NOT NULL COMMENT '移库操作ID', move_no VARCHAR(64) NOT NULL COMMENT '移库操作单号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', pallet_id BIGINT NOT NULL COMMENT '整托移库的实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', source_location_id BIGINT NOT NULL COMMENT '来源库位ID', + source_location_code VARCHAR(128) NOT NULL COMMENT '来源库位码快照', target_location_id BIGINT NOT NULL COMMENT '目标库位ID', + target_location_code VARCHAR(128) NOT NULL COMMENT '目标库位码快照', customer_count INT NOT NULL COMMENT '托盘涉及客户数', + position_count INT NOT NULL COMMENT '迁移库存位置数', package_count BIGINT NOT NULL COMMENT '迁移总箱数', + pallet_version_before INT NOT NULL COMMENT '迁移前托盘版本', pallet_version_after INT NULL COMMENT '迁移后托盘版本', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', reason VARCHAR(500) NOT NULL COMMENT '移库原因', + idempotency_key VARCHAR(128) NOT NULL COMMENT '移库幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '移库请求哈希', + status VARCHAR(32) NOT NULL COMMENT '移库处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_inventory_move_no(move_no), + UNIQUE KEY uk_ll_pda_inventory_move_key(overseas_warehouse_id,operator_key,idempotency_key), + KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录'; CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( id BIGINT NOT NULL COMMENT '主键ID', @@ -1117,6 +1302,90 @@ CREATE TABLE IF NOT EXISTS ll_relabel_status_history ( 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 '托盘号', @@ -1163,6 +1432,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( 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), @@ -1213,6 +1483,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( 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 '更新时间', @@ -1226,7 +1497,7 @@ 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', - change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注', + 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='海外采购操作流水'; @@ -1245,7 +1516,6 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( 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 '预算金额', @@ -1255,8 +1525,6 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( 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', @@ -1266,7 +1534,6 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( PRIMARY KEY(id), UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no), UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key), - UNIQUE KEY uk_ll_us_truck_receipt_key(customer_id,receipt_idempotency_key), KEY idx_ll_us_truck_order_list(customer_id,status,created_at), KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at) ) ENGINE=InnoDB COMMENT='美国卡车运输订单'; @@ -1290,13 +1557,25 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( 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), @@ -1331,7 +1610,17 @@ 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', - change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注', + 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='本土散板订单幂等操作账'; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 6cf76b0a9..1c5d4c33f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -3,6 +3,120 @@ info: title: Lastleg Overseas Business API version: 1.0.0 paths: + /api/pda/v1/last-leg/receiving-containers/candidates: + get: + summary: 查询当前客户待签收柜号 + description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用;仅返回已单调消费 PICKUP_READY(80) 或更高实际船节点、状态为 RECEIVING_AVAILABLE 的柜,不接收 customerId。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } } + /api/pda/v1/last-leg/receiving-containers/{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/sign: + post: + summary: 签收当前柜 + description: 权限 lastleg:pda:operate;精确校验车牌、封签、开柜三类照片各一次及文件归属后由 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;仅 OVERSEAS 目的类型可逐箱收货,返回该箱所属入仓码完整信息。 + 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' } } } }, '409': { description: 跨柜、重复箱或目的类型不符 } } + /api/pda/v1/last-leg/receiving-containers/current/skus/resolve: + post: + summary: 解析当前柜 FBA SKU + description: 权限 lastleg:pda:operate;仅解析当前已签柜内唯一归属一个入仓码的 FBA SKU,返回冻结箱数与实收进度。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { type: object, required: [skuCode], properties: { skuCode: { type: string, minLength: 1, maxLength: 128 } } } } } } + responses: { '200': { description: SKU与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSkuResolvedApiResponse' } } } }, '404': { description: A0404:SKU不在当前FBA柜内 }, '409': { description: A0409:SKU跨多个入仓码导致归属不唯一 } } + /api/pda/v1/last-leg/receiving-containers/current/pallets/resolve: + post: + summary: 解析当前柜实物托盘码 + description: 权限 lastleg:pda:operate;托盘固定从 Warehouse Overseas 公共资源作用域解析并校验实际仓与目的仓。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { type: object, required: [palletCode, inboundCode], properties: { palletCode: { type: string }, inboundCode: { type: string } } } } } } + responses: { '200': { description: 可用托盘与目的匹配结果, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingPalletResolvedApiResponse' } } } }, '409': { description: 仓库、目的仓或托盘状态不符 } } + /api/pda/v1/last-leg/receiving-containers/current/receipts/bulk: + post: + summary: FBA 入仓码按箱数收货 + description: 权限 lastleg:pda:operate;只允许 destinationType=FBA,OVERSEAS 在服务端硬拒绝;不接收 customerId、planNo、allocationId、warehouseId、locationId。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } } + responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 海外仓批量、超收或托盘不匹配 } } + /api/pda/v1/last-leg/receiving-containers/current/receipts/carton: + post: + summary: 海外仓入仓码逐箱收货 + description: 权限 lastleg:pda:operate;只允许 destinationType=OVERSEAS,拒绝跨柜、重复箱和不匹配托盘。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCarton' } } } } + responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: FBA逐箱、跨柜、重复箱或托盘不匹配 } } + /api/pda/v1/last-leg/receiving-containers/current/complete: + post: + summary: 完成当前柜收货并入库存 + description: 权限 lastleg:pda:operate;仅柜内每个入仓码实收总数精确相等后完成,库存、计划、尾程状态与卸柜 Outbox 同事务落地。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } + responses: { '200': { description: COMPLETED 柜号会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 数量未核平或状态冲突 } } + /api/pda/v1/last-leg/inventory/resolve: + post: + summary: PDA按SKU、箱唛或入仓码扫码查询库存 + description: 仅 WAREHOUSE_PICKER + lastleg:pda:operate;海外实际仓只取Token overseasWarehouseId,不接收客户或仓库参数。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } } + responses: { '200': { description: 同仓跨客户库存与物理仓位事实, content: { application/json: { schema: { $ref: '#/components/schemas/PdaInventoryScanApiResponse' } } } }, '404': { description: A0404:当前绑定仓无匹配库存 } } + /api/pda/v1/last-leg/locations/resolve: + post: + summary: PDA查询库位容量、状态与库存占用 + description: 库位从Warehouse Overseas公共资源解析,并强制匹配Token绑定仓。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } } + responses: { '200': { description: 库位物理事实及Lastleg库存占用汇总, content: { application/json: { schema: { $ref: '#/components/schemas/PdaLocationApiResponse' } } } }, '404': { description: A0404:库位不存在或跨仓 } } + /api/pda/v1/last-leg/pallets/resolve: + post: + summary: PDA查询托盘、库位与绑定库存 + description: 托盘从Warehouse Overseas公共资源解析;跨Token绑定仓返回A0409。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } } + responses: { '200': { description: 托盘物理事实及Lastleg绑定库存, content: { application/json: { schema: { $ref: '#/components/schemas/PdaPalletApiResponse' } } } }, '409': { description: A0409:托盘属于其他仓 } } + /api/pda/v1/last-leg/operations: + get: + summary: 查询当前PDA账号真实作业历史 + description: 返回Token绑定仓内当前操作账号的柜号作业及盘点、移库、托盘、换标历史;不含出库或派送。 + security: [{ bearerAuth: [] }] + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: operationType, in: query, schema: { type: string } } + responses: { '200': { description: 真实作业历史分页, content: { application/json: { schema: { $ref: '#/components/schemas/PdaOperationPageApiResponse' } } } } } /api/platform/v1/last-leg/orders: get: summary: 分页查询尾程订单 @@ -23,7 +137,7 @@ paths: - { 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 包装的尾程订单分页 } } + responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } } /api/platform/v1/last-leg/dispatches: get: summary: 分页查询排车单 @@ -186,14 +300,14 @@ paths: /api/platform/v1/last-leg/overseas-procurements/{orderNo}/review: post: summary: 审核海外采购订单 - description: 权限 platform:overseas-business:purchase:review。 + 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: { type: object, required: [decision], properties: { decision: { type: string, enum: [APPROVE, REJECT] }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } + content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementReview' } } } responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } /api/merchant/v1/last-leg/overseas-procurements: post: @@ -242,14 +356,24 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } /api/platform/v1/last-leg/us-truck-orders/{orderNo}/status: patch: - summary: 推进本土卡车订单状态 - description: 权限 platform:overseas-business:truck:status;普通编辑接口不能修改状态。 + 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: { type: object, required: [version, status], properties: { version: { type: integer, format: int64, minimum: 0 }, status: { $ref: '#/components/schemas/TruckStatus' }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } } + 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: 记录本土卡车订单实收托盘 @@ -258,7 +382,56 @@ paths: 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: 分页查询本土散板订单 @@ -290,12 +463,12 @@ paths: /api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/status: patch: summary: 推进或取消本土散板订单 - description: 权限 platform:overseas-business:looseboard:status;只允许 PENDING_ACCEPTANCE→DISPATCHED→IN_TRANSIT→COMPLETED,未完成节点按规则取消。 + 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: { type: object, required: [version, status], properties: { version: { type: integer, format: int64, minimum: 0 }, status: { $ref: '#/components/schemas/LooseboardStatus' }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } } + 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: @@ -317,6 +490,8 @@ paths: 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}: @@ -338,6 +513,280 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } components: schemas: + PdaWarehouseScan: + type: object + required: [code] + properties: { code: { type: string, minLength: 1, maxLength: 128 } } + PdaInventoryItem: + type: object + required: [customerId, balanceId, positionId, inboundCode, palletId, palletCode, availablePackages, holdPackages, receivingPackages, status, version] + properties: + customerId: { type: string } + customerName: { type: string, nullable: true } + balanceId: { type: integer, format: int64 } + positionId: { type: integer, format: int64 } + skuCode: { type: string, nullable: true } + productName: { type: string, nullable: true } + inboundCode: { type: string } + cartonMark: { type: string, nullable: true } + palletId: { type: integer, format: int64 } + palletCode: { type: string } + locationId: { type: integer, format: int64, nullable: true } + locationCode: { type: string, nullable: true } + availablePackages: { type: integer, format: int64, minimum: 0 } + holdPackages: { type: integer, format: int64, minimum: 0 } + receivingPackages: { type: integer, format: int64, minimum: 0 } + status: { type: string } + version: { type: integer, format: int64, minimum: 0 } + PdaInventoryScan: + type: object + required: [queryCode, items] + properties: + queryCode: { type: string } + items: { type: array, items: { $ref: '#/components/schemas/PdaInventoryItem' } } + PdaLocation: + type: object + required: [locationId, locationCode, locationType, status, occupiedPackages, inventoryPositionCount] + properties: + locationId: { type: integer, format: int64 } + locationCode: { type: string } + locationType: { type: string } + maxLoadKg: { type: number, nullable: true } + status: { type: string } + occupiedPackages: { type: integer, format: int64, minimum: 0 } + inventoryPositionCount: { type: integer, minimum: 0 } + PdaPallet: + type: object + required: [palletId, palletCode, status, version, locationId, locationCode, occupiedPackages, inventoryPositionCount, inventory] + properties: + palletId: { type: integer, format: int64 } + palletCode: { type: string } + status: { type: string } + version: { type: integer, minimum: 0 } + locationId: { type: integer, format: int64 } + locationCode: { type: string } + occupiedPackages: { type: integer, format: int64, minimum: 0 } + inventoryPositionCount: { type: integer, minimum: 0 } + inventory: { type: array, items: { $ref: '#/components/schemas/PdaInventoryItem' } } + PdaOperation: + type: object + required: [operationId, operationType, businessKey, status, occurredAt] + properties: + operationId: { type: integer, format: int64 } + operationType: { type: string } + businessKey: { type: string } + status: { type: string } + summary: { type: string, nullable: true } + customerId: { type: string, nullable: true } + occurredAt: { type: string, format: date-time } + PdaInventoryScanApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: { data: { $ref: '#/components/schemas/PdaInventoryScan' } } + PdaLocationApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: { data: { $ref: '#/components/schemas/PdaLocation' } } + PdaPalletApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: { data: { $ref: '#/components/schemas/PdaPallet' } } + PdaOperationPageApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: + data: + type: object + required: [records, page, size, total, pages] + properties: + records: { type: array, items: { $ref: '#/components/schemas/PdaOperation' } } + page: { type: integer } + size: { type: integer } + total: { type: integer, format: int64 } + pages: { type: integer, format: int64 } + ReceivingAction: + type: object + required: [idempotencyKey] + properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } + ReceivingSign: + type: object + required: [photos, idempotencyKey] + properties: + photos: { type: array, minItems: 3, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedPhoto' } } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + ReceivingSignedPhoto: + type: object + required: [role, fileId] + properties: + role: { type: string, enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN] } + fileId: { type: integer, format: int64, minimum: 1 } + ReceivingBulk: + type: object + required: [inboundCode, skuCode, palletCode, packageCount, idempotencyKey] + properties: + inboundCode: { type: string, minLength: 1 } + skuCode: { type: string, minLength: 1, maxLength: 128 } + palletCode: { type: string, minLength: 1 } + packageCount: { type: integer, minimum: 1 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + ReceivingCarton: + type: object + required: [cartonMark, palletCode, idempotencyKey] + properties: + cartonMark: { type: string, minLength: 1 } + palletCode: { type: string, minLength: 1 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + ReceivingApiResponseBase: + type: object + required: [code, message, timestamp] + properties: + code: { type: string, example: '200' } + message: { type: string } + requestId: { type: string, nullable: true } + traceId: { type: string, nullable: true } + timestamp: { type: string, format: date-time } + ReceivingInbound: + type: object + required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages] + properties: + inboundCode: { type: string } + destinationType: { type: string, enum: [FBA, OVERSEAS] } + overseasWarehouseId: { type: string, nullable: true, description: 实际海外收货仓ID;与FBA或其他业务目的仓ID严格区分 } + overseasWarehouseName: { type: string, nullable: true, description: 实际海外收货仓名称快照 } + destinationWarehouseId: { type: string, nullable: true } + destinationWarehouseCode: { type: string, nullable: true } + destinationWarehouseName: { type: string, nullable: true } + fbaWarehouseCode: { type: string, nullable: true } + plannedPalletCount: { type: integer } + totalPackages: { type: integer } + receivedPackages: { type: integer } + pendingPackages: { type: integer } + ReceivingCandidate: + type: object + required: [containerNo, status, planCount, inboundCodeCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, currentAccount] + properties: + containerNo: { type: string } + status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] } + planCount: { type: integer } + inboundCodeCount: { type: integer } + destinationCodes: { type: array, uniqueItems: true, items: { type: string } } + plannedPalletCount: { type: integer } + totalPackages: { type: integer } + receivedPackages: { type: integer } + currentAccount: { type: boolean } + ReceivingSession: + type: object + required: [containerNo, overseasWarehouseId, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds] + properties: + containerNo: { type: string } + overseasWarehouseId: { type: string, pattern: '^\d+$', description: PDA Token绑定并在认领柜号时冻结的共享实际海外收货仓ID } + overseasWarehouseName: { type: string, description: Warehouse Overseas服务校验后的共享实际海外收货仓名称快照 } + status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] } + operatorName: { type: string, nullable: true } + version: { type: integer } + photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedPhoto' } } + preparedAt: { type: string, format: date-time, nullable: true } + signedAt: { type: string, format: date-time, nullable: true } + receivingAt: { type: string, format: date-time, nullable: true } + completedAt: { type: string, format: date-time, nullable: true } + totalPackages: { type: integer } + receivedPackages: { type: integer } + pendingPackages: { type: integer } + inbounds: { type: array, items: { $ref: '#/components/schemas/ReceivingInbound' } } + ReceivingPhoto: + type: object + required: [fileId, fileName, contentType, sizeBytes] + properties: + fileId: { type: integer, format: int64 } + fileName: { type: string } + contentType: { type: string } + sizeBytes: { type: integer, format: int64 } + ReceivingCartonResolved: + allOf: + - { $ref: '#/components/schemas/ReceivingInbound' } + - type: object + required: [cartonMark] + properties: + cartonMark: { type: string } + skuCode: { type: string, nullable: true } + ReceivingSkuResolved: + allOf: + - { $ref: '#/components/schemas/ReceivingInbound' } + - type: object + required: [skuCode] + properties: + skuCode: { type: string } + productName: { type: string, nullable: true } + ReceivingPalletResolved: + type: object + required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible] + properties: + palletCode: { type: string } + warehouseId: { type: integer, format: int64 } + status: { type: string } + fbaWarehouseCode: { type: string, nullable: true } + inboundCode: { type: string } + destinationType: { type: string, enum: [FBA, OVERSEAS] } + compatible: { type: boolean } + ReceivingReceipt: + type: object + required: [result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, receivedPackages, pendingPackages, sessionStatus] + properties: + result: { type: string } + containerNo: { type: string } + inboundCode: { type: string } + destinationType: { type: string, enum: [FBA, OVERSEAS] } + palletCode: { type: string } + acceptedPackages: { type: integer } + receivedPackages: { type: integer } + pendingPackages: { type: integer } + sessionStatus: { type: string, enum: [SIGNED, RECEIVING] } + ReceivingCandidateListApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { type: array, items: { $ref: '#/components/schemas/ReceivingCandidate' } } } + 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' } } + ReceivingSkuResolvedApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingSkuResolved' } } + 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: @@ -501,6 +950,14 @@ components: status: { $ref: '#/components/schemas/RelabelStatus' } remark: { type: string, nullable: true, maxLength: 1000 } ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] } + ProcurementReview: + type: object + required: [version, decision, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + decision: { type: string, enum: [APPROVE, REJECT] } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } ProcurementCreate: type: object required: [purchaseCategory, productName, purchaseQuantity, contactName, contactPhone, idempotencyKey] @@ -548,6 +1005,49 @@ components: 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] @@ -591,6 +1091,14 @@ components: 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] 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 index f67455ca1..06a85196f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml @@ -25,8 +25,25 @@ paths: /api/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options: get: summary: 查询缓存的有效海外合作仓选项 - description: '权限:platform:overseas-master-data:quote:list;浏览器不直接调用国内内部接口' - responses: { '200': { description: 业务响应 } } + 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: 导出海外仓报价 @@ -161,6 +178,14 @@ components: 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] diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml new file mode 100644 index 000000000..e455f6f48 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -0,0 +1,256 @@ +openapi: 3.0.3 +info: + title: Lastleg Overseas PDA Warehouse Operations API + version: 1.0.0 + description: 海外仓PDA盘点、同仓整托移库、托盘作业与换标现场作业;全部范围从PDA Token解析。 +security: + - bearerAuth: [] +paths: + /api/pda/v1/last-leg/stocktakes: + get: + summary: 查询Token绑定仓盘点任务 + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } + - { name: status, in: query, schema: { type: string, enum: [IN_PROGRESS, SUBMITTED] } } + responses: { '200': { description: 盘点任务分页 } } + post: + summary: 按库位或托盘创建盘点任务 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCreate' } } } } + responses: { '200': { description: 创建后的完整盘点任务 } } + /api/pda/v1/last-leg/stocktakes/{taskNo}: + get: + summary: 查询盘点任务详情 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 盘点任务及冻结库存位置 } } + /api/pda/v1/last-leg/stocktakes/{taskNo}/counts: + post: + summary: 提交一个库存位置的实盘箱数 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCount' } } } } + responses: { '200': { description: 已计数明细 }, '409': { description: 版本或幂等冲突 } } + /api/pda/v1/last-leg/stocktakes/{taskNo}/submit: + post: + summary: 提交盘点并按差异调整库存 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeSubmit' } } } } + responses: { '200': { description: 已提交盘点任务 }, '409': { description: 未全盘、库存版本变化或负库存风险 } } + /api/pda/v1/last-leg/inventory-moves: + post: + summary: 同仓整托移库 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMove' } } } } + responses: { '200': { description: 移库结果 }, '409': { description: 跨仓、资源或库存版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/pallets/resolve: + post: + summary: 扫码查询托盘及库存内容 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } } + responses: { '200': { description: Token绑定仓托盘内容 } } + /api/pda/v1/last-leg/pallet-operations/assemble: + post: + summary: 将多个来源托盘的指定库存行组到目标托盘 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletAssemble' } } } } + responses: { '200': { description: 组托及实物托盘状态结果 }, '409': { description: 目的仓、库存或资源版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/disassemble: + post: + summary: 将来源托盘库存拆到一个或多个目标托盘 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletDisassemble' } } } } + responses: { '200': { description: 拆托结果 }, '409': { description: 库存或资源版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/swap: + post: + summary: 将整托库存更换到空目标托盘 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletSwap' } } } } + responses: { '200': { description: 换托结果 }, '409': { description: 目标托盘非空或版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/unbind: + post: + summary: 解除整托库存与实物托盘绑定 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletUnbind' } } } } + responses: { '200': { description: 解绑结果 }, '409': { description: 库存或资源版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/candidates: + get: + summary: 查询Token绑定仓可执行换标任务 + responses: { '200': { description: 待认领、处理中或待复核换标任务 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}: + get: + summary: 查询换标现场任务详情 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 申请明细、逐件事实、照片与历史 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/claim: + post: + summary: 认领换标任务并推进申请至处理中 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } + responses: { '200': { description: 已认领任务 }, '409': { description: 仓库、状态或幂等冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units: + post: + summary: 登记一件正常换标事实 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } } + responses: { '200': { description: 更新后的任务 }, '409': { description: 旧标重复、新标不符或版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip: + post: + summary: 登记一件有原因的跳过事实 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelSkip' } } } } + responses: { '200': { description: 更新后的任务 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions: + post: + summary: 登记一件换标异常事实 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelException' } } } } + responses: { '200': { description: 更新后的任务 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files: + post: + summary: 上传换标完成现场照片 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } + responses: { '200': { description: OVERSEAS_RELABEL_PHOTO文件元数据 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete: + post: + summary: 提交现场任务等待复核,不提前完成申请 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } } + responses: { '200': { description: WAIT_REVIEW任务 }, '409': { description: 箱数未核平、照片或版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review: + post: + summary: 现场复核;APPROVE才完成既有换标申请,REWORK退回处理 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelReview' } } } } + responses: { '200': { description: COMPLETED或PROCESSING任务 }, '409': { description: 状态或版本冲突 } } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + schemas: + ScanCode: + type: object + required: [code] + properties: { code: { type: string, minLength: 1, maxLength: 128 } } + IdempotentAction: + type: object + required: [idempotencyKey] + properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } + StocktakeCreate: + type: object + required: [scopeType, scopeCode, idempotencyKey] + properties: + scopeType: { type: string, enum: [LOCATION, PALLET] } + scopeCode: { type: string, maxLength: 128 } + idempotencyKey: { type: string, maxLength: 128 } + StocktakeCount: + type: object + required: [positionId, actualPackages, expectedVersion, idempotencyKey] + properties: + positionId: { type: string, pattern: '^[0-9]+$' } + actualPackages: { type: integer, format: int64, minimum: 0 } + expectedVersion: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, maxLength: 128 } + StocktakeSubmit: + type: object + required: [expectedVersion, reason, idempotencyKey] + properties: + expectedVersion: { type: integer, format: int64, minimum: 0 } + reason: { type: string, minLength: 1, maxLength: 500 } + idempotencyKey: { type: string, maxLength: 128 } + InventoryMove: + type: object + required: [sourcePalletCode, targetLocationCode, expectedPalletVersion, idempotencyKey, reason] + properties: + sourcePalletCode: { type: string, maxLength: 128 } + targetLocationCode: { type: string, maxLength: 128 } + expectedPalletVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 128 } + reason: { type: string, minLength: 1, maxLength: 500 } + PalletExpectation: + type: object + required: [palletCode, expectedVersion] + properties: + palletCode: { type: string, maxLength: 128 } + expectedVersion: { type: integer, minimum: 0 } + PalletMoveLine: + type: object + required: [positionId, packages, expectedPositionVersion] + properties: + positionId: { type: string, pattern: '^[0-9]+$' } + packages: { type: integer, format: int64, minimum: 1 } + expectedPositionVersion: { type: integer, format: int64, minimum: 0 } + PalletAssemble: + type: object + required: [idempotencyKey, target, sources, lines] + properties: + idempotencyKey: { type: string, maxLength: 128 } + target: { $ref: '#/components/schemas/PalletExpectation' } + sources: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/PalletExpectation' } } + lines: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/PalletMoveLine' } } + reason: { type: string, maxLength: 500 } + PalletTargetMove: + type: object + required: [target, lines] + properties: + target: { $ref: '#/components/schemas/PalletExpectation' } + lines: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/PalletMoveLine' } } + PalletDisassemble: + type: object + required: [idempotencyKey, source, targets] + properties: + idempotencyKey: { type: string, maxLength: 128 } + source: { $ref: '#/components/schemas/PalletExpectation' } + targets: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/PalletTargetMove' } } + reason: { type: string, maxLength: 500 } + PalletSwap: + type: object + required: [idempotencyKey, source, target] + properties: + idempotencyKey: { type: string, maxLength: 128 } + source: { $ref: '#/components/schemas/PalletExpectation' } + target: { $ref: '#/components/schemas/PalletExpectation' } + reason: { type: string, maxLength: 500 } + PalletUnbind: + type: object + required: [idempotencyKey, source] + properties: + idempotencyKey: { type: string, maxLength: 128 } + source: { $ref: '#/components/schemas/PalletExpectation' } + reason: { type: string, maxLength: 500 } + RelabelUnit: + type: object + required: [itemId, oldLabel, newLabel, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + oldLabel: { type: string, maxLength: 255 } + newLabel: { type: string, maxLength: 255 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + RelabelSkip: + type: object + required: [itemId, oldLabel, reason, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + oldLabel: { type: string, maxLength: 255 } + reason: { type: string, maxLength: 1000 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + RelabelException: + type: object + required: [itemId, exceptionCode, reason, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + oldLabel: { type: string, maxLength: 255, nullable: true } + exceptionCode: { type: string, enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER] } + reason: { type: string, maxLength: 1000 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + RelabelComplete: + type: object + required: [photoFileIds, expectedVersion, idempotencyKey] + properties: + photoFileIds: { type: array, minItems: 1, maxItems: 20, items: { type: string, pattern: '^[0-9]+$' } } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + remark: { type: string, maxLength: 1000, nullable: true } + RelabelReview: + type: object + required: [decision, expectedVersion, idempotencyKey] + properties: + decision: { type: string, enum: [APPROVE, REWORK] } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + remark: { type: string, maxLength: 1000, nullable: true } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java new file mode 100644 index 000000000..5891459d7 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeClientContractTest { + @Test + void canonicalDomesticBridgeEnvironmentOverridesLegacyNacosProperty() { + FeignClient contract = EventBridgeClient.class.getAnnotation(FeignClient.class); + + assertThat(contract.url()) + .isEqualTo("${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java new file mode 100644 index 000000000..94c1758ab --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.config; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; + +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LastlegRestClientFactoryTest { + private final LastlegRestClientFactory factory = new LastlegRestClientFactory(); + + @Test + void defaultsArePositiveAndSeparateFileUploadsFromWarehouseCalls() { + LastlegRestClientProperties properties = new LastlegRestClientProperties(); + + assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofSeconds(3)); + assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30)); + assertThat(properties.getRelabelPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30)); + assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void rejectsUnboundedOrNonPositiveTimeouts() { + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(null, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ZERO, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ofSeconds(1), Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void bindsPerClientTimeoutOverridesFromConfiguration() { + var source = new MapConfigurationPropertySource(Map.of( + "baiyue.http.clients.unload-photo-file.connect-timeout", "125ms", + "baiyue.http.clients.unload-photo-file.read-timeout", "9s", + "baiyue.http.clients.pallet-physical.connect-timeout", "250ms", + "baiyue.http.clients.pallet-physical.read-timeout", "2s")); + + LastlegRestClientProperties properties = new Binder(source) + .bind("baiyue.http.clients", Bindable.of(LastlegRestClientProperties.class)) + .orElseThrow(() -> new AssertionError("Lastleg HTTP client properties were not bound")); + + assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofMillis(125)); + assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(9)); + assertThat(properties.getPalletPhysical().getConnectTimeout()).isEqualTo(Duration.ofMillis(250)); + assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(2)); + assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void appliesConfiguredConnectAndReadTimeoutsWithoutRetryOrFallback() throws Exception { + var timeouts = new LastlegRestClientProperties.ClientTimeouts( + Duration.ofMillis(250), Duration.ofMillis(75)); + assertThat(factory.httpClient(timeouts).connectTimeout()).contains(Duration.ofMillis(250)); + + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/slow", exchange -> { + try { Thread.sleep(500); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + exchange.sendResponseHeaders(200, 0); + exchange.close(); + }); + server.start(); + try { + RestClient client = factory.build(RestClient.builder(), + "http://127.0.0.1:" + server.getAddress().getPort(), timeouts); + assertThatThrownBy(() -> client.get().uri("/slow").retrieve().toBodilessEntity()) + .isInstanceOf(ResourceAccessException.class); + } finally { + server.stop(0); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index e2975697a..abb7991c9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -3,11 +3,14 @@ package com.baiyue.lastleg.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; @@ -20,6 +23,8 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; class LastlegOrderControllerTest { private final LastlegOrderService service = mock(LastlegOrderService.class); @@ -45,6 +50,17 @@ class LastlegOrderControllerTest { verify(service).page(eq("200"), eq(query)); } + @Test + void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() { + EventEnvelope event = mock(EventEnvelope.class); + when(event.eventType()).thenReturn(EventTypes.IAM_USER_STATUS_CHANGED); + LoadingCompletedConsumer loading = (LoadingCompletedConsumer) ReflectionTestUtils.getField(controller, "consumer"); + + assertThrows(ResponseStatusException.class, () -> controller.receive("internal-token", event)); + + verify(loading, never()).consume(event); + } + private static AuthenticatedUser user(long customerId, String appCode) { return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of()); } @@ -52,6 +68,7 @@ class LastlegOrderControllerTest { private LastlegOrderController controller() { LastlegOrderController controller = new LastlegOrderController(); ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class)); + ReflectionTestUtils.setField(controller, "shippingNodeConsumer", mock(ContainerShippingNodeConsumer.class)); ReflectionTestUtils.setField(controller, "orderService", service); ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class)); return controller; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java index 8270d8f01..850071823 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java @@ -5,8 +5,7 @@ import com.baiyue.lastleg.dispatch.controller.DispatchController; import com.baiyue.lastleg.dispatch.controller.DispatchQueryController; import com.baiyue.lastleg.dispatch.controller.SmartDispatchController; import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController; -import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController; -import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController; +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; @@ -26,10 +25,17 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class Phase4BrowserApiBoundaryTest { @Test - void inboundPdaAndUnloadPhotoApisUseThePdaAccessDomain() { - assertTrue(basePath(InboundPdaController.class).startsWith("/api/pda/v1/")); - assertTrue(basePath(InboundPalletAssignmentController.class).startsWith("/api/pda/v1/")); - assertFalse(basePath(InboundPdaController.class).contains("/api/platform/")); + 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 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..d3d4447dc --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -0,0 +1,160 @@ +package com.baiyue.lastleg.inboundplan.pda; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; +import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import jakarta.validation.constraints.Size; + +import java.io.IOException; +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReceivingContainerContractTest { + private static final Set FORBIDDEN_CALLER_SCOPE = Set.of( + "customerId", "planNo", "allocationId", "warehouseId", "locationId"); + + @Test + void pdaRequestsNeverAcceptServerOwnedScopeFields() { + for (Class type : ReceivingContainerRequests.class.getDeclaredClasses()) { + if (!type.isRecord()) continue; + Set fields = Arrays.stream(type.getRecordComponents()) + .map(RecordComponent::getName).collect(Collectors.toSet()); + assertTrue(java.util.Collections.disjoint(fields, FORBIDDEN_CALLER_SCOPE), type.getSimpleName()); + } + for (Class type : Set.of(ReceivingContainerRequests.Action.class, ReceivingContainerRequests.Sign.class, + ReceivingContainerRequests.BulkReceive.class, ReceivingContainerRequests.CartonReceive.class)) { + RecordComponent key = Arrays.stream(type.getRecordComponents()) + .filter(component -> "idempotencyKey".equals(component.getName())).findFirst().orElseThrow(); + Size size = key.getAccessor().getAnnotation(Size.class); + assertTrue(size != null && size.max() == 255, type.getSimpleName()); + } + } + + @Test + void everyBrowserReachableReceivingMethodUsesOnlyPdaOperatePermission() { + PreAuthorize permission = ReceivingContainerController.class.getAnnotation(PreAuthorize.class); + assertTrue(permission != null); + assertTrue("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')" + .equals(permission.value())); + } + + @Test + void completedSessionAlwaysPersistsClearedGlobalLockKeys() throws NoSuchFieldException { + for (String fieldName : List.of("activeOperatorKey", "activeContainerKey")) { + TableField field = ReceivingContainerSession.class.getDeclaredField(fieldName) + .getAnnotation(TableField.class); + assertTrue(field != null && field.updateStrategy() == FieldStrategy.ALWAYS, fieldName); + } + } + + @Test + void inventoryCreatingEntryTransactionsAvoidMissingDimensionGapLockDeadlocks() throws NoSuchMethodException { + Transactional pda = ReceivingContainerService.class + .getMethod("complete", String.class, ReceivingContainerRequests.Action.class) + .getAnnotation(Transactional.class); + Transactional bridge = LoadingCompletedConsumer.class.getMethod("consume", EventEnvelope.class) + .getAnnotation(Transactional.class); + assertTrue(pda != null && pda.isolation() == Isolation.READ_COMMITTED); + assertTrue(bridge != null && bridge.isolation() == Isolation.READ_COMMITTED); + } + + @Test + void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException { + String schema = resource("db/lastleg.sql").toLowerCase(); + String unified = java.nio.file.Files.readString( + java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase(); + for (String table : Set.of("ll_inbound_plan_line", "ll_inbound_plan_pallet", + "ll_inbound_plan_pallet_allocation")) { + assertTrue(tableBlock(schema, table).contains("customer_id varchar(128) not null"), table); + assertTrue(unified.contains("table_name='" + table + "' and column_name='customer_id'"), table); + assertTrue(unified.contains("update " + table), table); + } + assertFalse(schema.contains("tenant_id varchar(64)")); + assertFalse(unified.contains("column_name='tenant_id'")); + assertTrue(schema.contains("create table if not exists ll_receiving_container_session")); + assertTrue(schema.contains("unique key uk_ll_receiving_active_operator(active_operator_key)")); + assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)")); + assertTrue(schema.contains("平台操作账号全局唯一锁键")); + assertTrue(schema.contains("柜号全局唯一锁键")); + assertTrue(schema.contains("create table if not exists ll_receiving_container_operation")); + assertTrue(schema.contains("unique key uk_ll_receiving_operation(operator_key,idempotency_key)")); + assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt")); + assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)")); + String receivingSession = tableBlock(schema, "ll_receiving_container_session"); + assertTrue(receivingSession.contains("sign_photos json")); + assertTrue(receivingSession.contains("overseas_warehouse_id bigint not null")); + assertTrue(receivingSession.contains("overseas_warehouse_name varchar(255) not null")); + assertFalse(receivingSession.contains("photo_file_ids json")); + assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt")); + assertTrue(unified.contains("column_name='sign_photos'")); + assertTrue(schema.contains("wait_sign、signed、receiving、completed")); + assertTrue(unified.contains("customer_id is null or l.customer_id=''")); + assertTrue(unified.contains("customer_id is null or v.customer_id=''")); + assertTrue(unified.contains("customer_id is null or a.customer_id=''")); + assertTrue(unified.contains("signal sqlstate '45000'")); + assertTrue(unified.contains("assert_receiving_global_locks_migratable")); + assertTrue(unified.contains("where status='completed' and (active_operator_key is not null or active_container_key is not null)")); + assertTrue(unified.contains("concat('operator:',operator_key)")); + assertTrue(unified.contains("concat('container:',upper(trim(container_no)))")); + assertTrue(unified.contains("assert_receiving_operation_global_key_migratable")); + assertTrue(unified.contains("group_concat(column_name order by seq_in_index)= 'operator_key,idempotency_key'")); + assertTrue(unified.contains("assert_pending_orders_do_not_use_source_warehouse")); + assertTrue(unified.contains("assert_active_receiving_session_has_warehouse")); + } + + @Test + void openApiPublishesOnlyStableContainerPaths() throws IOException { + String yaml = resource("openapi/overseas-business-v1.yaml"); + String prefix = "/api/pda/v1/last-leg/receiving-containers"; + assertTrue(yaml.contains(prefix + "/candidates:")); + assertTrue(yaml.contains(prefix + "/current/receipts/bulk:")); + assertTrue(yaml.contains(prefix + "/current/skus/resolve:")); + assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); + assertTrue(yaml.contains(prefix + "/current/complete:")); + assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}")); + String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); + List successResponses = receivingPaths.lines().map(String::trim) + .filter(line -> line.startsWith("responses: { '200':")).toList(); + assertEquals(16, successResponses.size()); + assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); + assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count()); + assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); + assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用")); + assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取")); + assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]")); + assertTrue(yaml.contains("overseasWarehouseId: { type: string")); + assertTrue(yaml.contains("overseasWarehouseName: { type: string")); + assertFalse(receivingPaths.contains("photoFileIds")); + } + + private String resource(String name) throws IOException { + try (var input = getClass().getClassLoader().getResourceAsStream(name)) { + assertTrue(input != null, name); + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static String tableBlock(String schema, String table) { + int start = schema.indexOf("create table if not exists " + table + " ("); + int end = schema.indexOf(") engine=innodb", start); + return start < 0 || end < 0 ? "" : schema.substring(start, end); + } + +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java new file mode 100644 index 000000000..3ff993075 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.inboundplan.pda.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; +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.test.util.ReflectionTestUtils; +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.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReceivingContainerControllerTest { + @Mock ReceivingContainerService service; + private ReceivingContainerController controller; + + @BeforeEach + void setUp() { + controller = new ReceivingContainerController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() { + when(service.candidates("42", 900L)).thenReturn(List.of(new ReceivingContainerResponses.Candidate( + "CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false))); + + controller.candidates(picker()); + + verify(service).candidates("42", 900L); + } + + @Test + void 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 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"); + } + + private static AuthenticatedUser picker() { + return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA", + Set.of(PdaWarehouseIdentity.PICKER_ROLE, ReceivingContainerController.OPERATE_PERMISSION), + false, null, null, false, 900L); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java new file mode 100644 index 000000000..734406198 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class HttpUnloadPhotoFileGatewayTest { + private HttpUnloadPhotoFileGateway gateway; + private MockRestServiceServer server; + + @BeforeEach + void setUp() { + RestClient.Builder builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).build(); + gateway = new HttpUnloadPhotoFileGateway(); + ReflectionTestUtils.setField(gateway, "client", builder.baseUrl("http://file-service").build()); + ReflectionTestUtils.setField(gateway, "internalToken", "internal-token"); + } + + @Test + void forgedImageBusinessFailureMapsToBadRequestInsteadOfInternalError() { + server.expect(requestTo("http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8")) + .andRespond(withSuccess("{\"code\":\"A0400\",\"message\":\"上传内容不是有效图片\",\"data\":null}", + MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> gateway.upload("100", 8L, 7L, + new MockMultipartFile("file", "forged.png", "image/png", "')), + }, token) + context.addStep(MODULE, '伪造图片内容安全扫描返回A0400', malicious, + malicious?.http === 200 && code(malicious) === 'A0400' && traced(malicious), trace(malicious), OPTIONS) + const photos = [] + for (const role of PHOTO_ROLES) { + const response = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { + name: `${context.marker}-${role.toLowerCase()}.png`, type: 'image/png', content: PNG, + }, token) + const fileId = id(response?.body?.data?.fileId) + context.addStep(MODULE, `上传${role}真实签收照片`, response, success(response) && longId(fileId), + `role=${role}, fileId=${fileId || 'missing'}; ${trace(response)}`, OPTIONS) + if (!longId(fileId)) return null + photos.push({ role, fileId }) + } + return photos +} + +async function awaitProducerCandidates(context, token, producers) { + const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] + if (!expected.length) return false + let response = null + let seen = new Set() + for (let attempt = 1; attempt <= 30; attempt += 1) { + response = await context.requestOverseas('GET', + '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token) + const records = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] + seen = new Set(records.map((item) => id(item?.containerNo)).filter(nonBlank)) + if (expected.every((containerNo) => seen.has(containerNo))) break + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + const ready = expected.every((containerNo) => seen.has(containerNo)) + context.addStep(MODULE, '三期生产矩阵全部进入海外待收候选后再开始PDA', response, ready, + `expected=${expected.join('|')}, seen=${[...seen].join('|')}; ${trace(response)}`, OPTIONS) + return ready +} + +function skuFixtures(context, producer) { + const frozenLines = Array.isArray(producer?.skuLines) + ? producer.skuLines + .map((line) => ({ skuCode: id(line?.skuCode), packageCount: Number(line?.packageCount) })) + .filter((line) => nonBlank(line.skuCode) && Number.isInteger(line.packageCount) && line.packageCount > 0) + : [] + if (frozenLines.length) return frozenLines + const frozen = Array.isArray(producer?.skuCodes) + ? producer.skuCodes.map(id).filter(nonBlank) + : [] + if (frozen.length) return [...new Set(frozen)].map((skuCode) => ({ skuCode, packageCount: 1 })) + const boxes = context.createdResources?.orders?.boxTypes + const orchestration = context.createdResources?.orchestrationFixtures || {} + const markers = [context.marker, orchestration.phase3Loading?.sourceMarker, + orchestration.phase3LoadingIncremental?.sourceMarker].filter(nonBlank) + return Array.isArray(boxes) + ? [...new Set(markers.flatMap((marker) => boxes.map((boxType) => `SKU-${boxType}-${marker}`.slice(0, 64))))] + .map((skuCode) => ({ skuCode, packageCount: 1 })) + : [] +} + +async function receiveFba(context, token, inbound, pallet, producer) { + const skus = skuFixtures(context, producer) + if (!skus.length) { + blockerGap(context, `FBA入仓码${inbound.inboundCode} SKU`, '本轮订单 fixture 未交接冻结 SKU,无法安全猜测实收 SKU') + return false + } + let accepted = 0 + for (const [index, sku] of skus.entries()) { + const { skuCode, packageCount } = sku + const resolvedResponse = await context.requestOverseas('POST', + '/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token) + const resolvable = success(resolvedResponse) || notFound(resolvedResponse) + context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS) + const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null } + if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue + const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode, + packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) } + const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + if (!success(first.response)) return false + accepted += Number(first.data?.acceptedPackages || 0) + const replay = await pda(context, token, 'POST', `FBA SKU ${skuCode}同键同载荷重放`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + context.assert(MODULE, `FBA SKU ${skuCode}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `FBA SKU ${skuCode}同键异载荷冲突`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', + { ...body, packageCount: packageCount + 1 }, conflict) + await expected(context, token, 'POST', `FBA SKU ${skuCode}超收被拒绝`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', + { ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, `fba-over-${index}`) }, conflict) + } + if (accepted <= 0) { + blockerGap(context, `FBA入仓码${inbound.inboundCode}实收`, `候选 SKU 均未唯一解析到当前入仓码;skuCount=${skus.length}`) + return false + } + return true +} + +async function receiveOverseas(context, token, inbounds, pallets, producerCartonMarks = []) { + const cartonMarks = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) + .map(id).filter(nonBlank))] + if (!cartonMarks.length) { + blockerGap(context, 'OVERSEAS逐箱链', '缺少本轮PDA任务冻结箱唛,逐箱实收不能使用伪造箱号') + return false + } + for (const [index, cartonMark] of cartonMarks.entries()) { + const resolved = await pda(context, token, 'POST', `解析OVERSEAS冻结箱唛${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }) + const inbound = inbounds.find((item) => item.inboundCode === resolved.data?.inboundCode) + const pallet = inbound ? pallets.get(inbound.inboundCode) : null + if (!success(resolved.response) || !inbound || !pallet) { + blockerGap(context, `OVERSEAS箱唛${cartonMark}归属`, + `冻结箱唛未解析到带真实托盘的本柜入仓码;inboundCode=${resolved.data?.inboundCode || 'missing'}`, + 'fixture-mismatch', resolved.response) + return false + } + const body = { cartonMark, palletCode: pallet.palletCode, + idempotencyKey: key(context, `overseas-carton-${index}`) } + const first = await pda(context, token, 'POST', `OVERSEAS逐箱实收${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) + if (!success(first.response)) return false + const replay = await pda(context, token, 'POST', `OVERSEAS逐箱${index + 1}同键同载荷重放`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) + context.assert(MODULE, `OVERSEAS逐箱${index + 1}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `OVERSEAS逐箱${index + 1}同键异载荷冲突`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', + { ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict) + await expected(context, token, 'POST', `OVERSEAS重复箱新键被拒绝${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', + { ...body, idempotencyKey: key(context, `overseas-duplicate-${index}`) }, conflict) + } + return true +} + +async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) { + if (runAuthorizationProbes && context.overseasToken) { + const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken) + context.addStep(MODULE, '平台管理员海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) + } + if (runAuthorizationProbes && merchantToken) { + const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, merchantToken) + context.addStep(MODULE, '客户海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) + } + const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + const noCurrent = notFound(before) + context.addStep(MODULE, '无当前柜返回404属于正常空态', before, noCurrent || success(before), + `${noCurrent ? 'empty=true' : `resumeStatus=${before?.body?.data?.status || 'missing'}`}; ${trace(before)}`, OPTIONS) + if (success(before)) { + blockerGap(context, '可重复的完整柜号验收', `账号已有未完成柜 ${before.body?.data?.containerNo || 'unknown'},本轮不覆盖或篡改遗留现场会话`, 'dirty-fixture', before) + return + } + const candidates = await pda(context, token, 'GET', '查询当前客户待收柜号', '/api/pda/v1/last-leg/receiving-containers/candidates') + if (!Array.isArray(candidates.data) || candidates.data.length === 0) { + blockerGap(context, 'PDA柜号候选', `当前客户没有由三期装柜完成事件生成的待收入仓计划;${trace(candidates.response)}`, 'producer-chain', candidates.response) + return + } + const preferredContainer = id(preferredProducer?.containerNo) + || context.createdResources?.phase3Loading?.containerNo + const candidate = preferredContainer + ? candidates.data.find((item) => item.containerNo === preferredContainer) + : candidates.data[0] + if (!candidate) { + blockerGap(context, `本轮柜号${preferredContainer || 'missing'}候选`, + '三期装柜完成生产者已落地,但海外候选尚未出现该柜;禁止回退处理历史或其他柜', 'producer-chain', candidates.response) + return + } + const containerNo = id(candidate?.containerNo) + context.assert(MODULE, '柜号候选来自本轮生产链且包含进度', candidates.response, + nonBlank(containerNo) && Number(candidate?.totalPackages) > 0 && Number(candidate?.receivedPackages) >= 0, + `containerNo=${containerNo || 'missing'}, total=${candidate?.totalPackages ?? 'missing'}; ${trace(candidates.response)}`, OPTIONS) + if (!containerNo) return + context.createdResources.phase4OverseasPda.containerNo = containerNo + const detail = await pda(context, token, 'GET', '查询柜号完整入仓码详情', `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}`) + if (!Array.isArray(detail.data?.inbounds) || detail.data.inbounds.length === 0) return + const prepareBody = { idempotencyKey: key(context, 'prepare') } + const prepared = await pda(context, token, 'POST', '选择柜号并建立WAIT_SIGN会话', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody) + if (!success(prepared.response)) return + const prepareReplay = await pda(context, token, 'POST', '选择柜号同键重放', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody) + context.assert(MODULE, '选择柜号幂等结果一致', prepareReplay.response, + success(prepareReplay.response) && prepareReplay.data?.containerNo === containerNo && prepareReplay.data?.status === 'WAIT_SIGN', trace(prepareReplay.response), OPTIONS) + if (secondaryToken) { + await expected(context, secondaryToken, 'POST', '第二仓库操作员抢占同柜返回409', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, + { idempotencyKey: key(context, 'secondary-prepare') }, conflict) + } + const other = candidates.data.find((item) => item.containerNo !== containerNo) + if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}`, undefined, conflict) + else blockerGap(context, '跨柜锁409', '当前客户只有一个待收柜,无法在不伪造柜数据的前提下验证跨柜锁') + const photos = await uploadPhotos(context, token) + if (!photos) return + const signBody = { photos, idempotencyKey: key(context, 'sign') } + const signed = await pda(context, token, 'POST', '绑定车牌封签开柜三角色照片并签收', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody) + if (!success(signed.response)) return + const signReplay = await pda(context, token, 'POST', '签收同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody) + context.assert(MODULE, '三角色照片各一次且签收幂等', signReplay.response, + success(signReplay.response) && signReplay.data?.status === 'SIGNED' + && PHOTO_ROLES.every((role) => signReplay.data?.photos?.filter((item) => item.role === role).length === 1), trace(signReplay.response), OPTIONS) + await expected(context, token, 'POST', '签收同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/sign', + { ...signBody, photos: [...photos].reverse() }, conflict) + const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current') + if (!success(current.response)) return + const pallets = new Map() + for (const [index, inbound] of current.data.inbounds.entries()) { + const pallet = await createPallet(context, inbound, index) + if (!pallet) { + blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`, + `无法在实际海外收货仓 ${inbound.overseasWarehouseId || 'missing'} 创建可解析真实托盘;目的仓 ${inbound.destinationWarehouseId || 'missing'} 不得代替物理仓`, 'warehouse-resource') + continue + } + pallets.set(inbound.inboundCode, pallet) + context.createdResources.phase4OverseasPda.palletIds.push(pallet.palletId) + const resolved = await pda(context, token, 'POST', `解析${inbound.destinationType}实物托盘`, + '/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve', { palletCode: pallet.palletCode, inboundCode: inbound.inboundCode }) + context.assert(MODULE, `${inbound.destinationType}托盘目的模式匹配`, resolved.response, + success(resolved.response) && resolved.data?.compatible === true && resolved.data?.destinationType === inbound.destinationType, trace(resolved.response), OPTIONS) + } + const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA') + const overseasInbounds = current.data.inbounds.filter((item) => item.destinationType === 'OVERSEAS') + if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) { + const firstFba = fbaInbounds[0] + await expected(context, token, 'POST', 'FBA模式硬拒绝逐箱收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', { + cartonMark: context.createdResources?.inboundMeasurement?.boxCode || 'MODE-REJECT-FBA', + palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'), + }, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i)) + for (const inbound of fbaInbounds) { + await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), preferredProducer) + } + } else if (preferredProducer?.destinationType !== 'OVERSEAS') { + blockerGap(context, 'FBA SKU按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') + } + if (overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) { + const firstOverseas = overseasInbounds[0] + const pallet = pallets.get(firstOverseas.inboundCode) + await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { + inboundCode: firstOverseas.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode, + packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), + }, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i)) + await receiveOverseas(context, token, overseasInbounds, pallets, + preferredProducer?.cartonMarks?.length ? preferredProducer.cartonMarks : [preferredProducer?.cartonMark]) + } else if (preferredProducer?.destinationType !== 'FBA') { + blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') + } + const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current') + if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) { + blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response) + return + } + const completeBody = { idempotencyKey: key(context, 'complete') } + const completed = await pda(context, token, 'POST', '完成当前柜并写入库存与卸柜事件', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody) + if (!success(completed.response)) return + const completeReplay = await pda(context, token, 'POST', '完成当前柜同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody) + context.assert(MODULE, '完成幂等重放仍返回COMPLETED', completeReplay.response, + success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED', trace(completeReplay.response), OPTIONS) + const after = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) + context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null + if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { + const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId) + for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) { + const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode, + pallets.get(inbound.inboundCode)) + if (warehouseFacts && context.context?.phase4OverseasPdaOperations) { + context.context.phase4OverseasPdaOperations.completedFixtures.push({ + containerNo, order, ...warehouseFacts, + }) + } + } + } +} + +export async function run(context) { + context.createdResources.phase4OverseasPda = { + marker: context.marker, containerNo: null, customerId: null, orderId: null, + palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [], + overseasWarehouseId: null, overseasWarehouseName: null, + cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence', + } + const merchantToken = await loginMerchantForNegativeProbe(context) + const pickerAccess = await resolvePickerAccess(context) + if (!pickerAccess) return + const sharedWarehouse = await createSharedWarehouse(context) + if (!sharedWarehouse) { + blockerGap(context, '海外PDA共享实际收货仓', '无法通过平台正式API创建customerId=0共享物理仓') + return + } + context.createdResources.phase4OverseasPda.overseasWarehouseId = sharedWarehouse.id + context.createdResources.phase4OverseasPda.overseasWarehouseName = sharedWarehouse.name + const primary = await createPicker(context, 1, sharedWarehouse.id, pickerAccess) + const secondary = primary?.token ? await createPicker(context, 2, sharedWarehouse.id, pickerAccess) : null + for (const picker of [primary, secondary]) if (picker?.membershipId) { + context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId) + } + let handoffReady = false + const cleanup = async () => { + await disablePicker(context, secondary, 2) + await disablePicker(context, primary, 1) + } + context.context.phase4OverseasPdaOperations = { + marker: context.marker, + primary: primary ? { membershipId: primary.membershipId, username: primary.username, token: primary.token } : null, + secondary: secondary ? { membershipId: secondary.membershipId, username: secondary.username, token: secondary.token } : null, + overseasWarehouseId: sharedWarehouse.id, + completedFixtures: [], + cleanup, + } + try { + if (!primary?.token || !secondary?.token) { + blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个WAREHOUSE_PICKER平台账号,无法验证全局一柜锁') + return + } + const matrix = context.createdResources?.phase4InboundProducerFixtures + const dropship = matrix?.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') + const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] + .filter((item) => nonBlank(item?.containerNo)) + const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers + if (allProducers.length && !(await awaitProducerCandidates(context, primary.token, allProducers))) { + blockerGap(context, '海外待收柜生产矩阵', + '三期已发布的FBA、OVERSEAS和第二柜尚未全部被海外事件消费者提交,禁止与消费事务并发入库') + return + } + for (const [index, producer] of (producers.length ? producers : [null]).entries()) { + context.pdaContainerSuffix = producer?.containerNo || `CONTAINER-${index + 1}` + context.createdResources.phase4OverseasPda.finalStatus = null + await runReceiving(context, primary.token, secondary.token, + index === 0 ? merchantToken : null, producer, index === 0) + if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { + context.createdResources.phase4OverseasPda.completedContainers.push(producer?.containerNo || null) + } + } + handoffReady = context.context.phase4OverseasPdaOperations.completedFixtures.length > 0 + } finally { + delete context.pdaContainerSuffix + if (!handoffReady) { + try { await cleanup() } + finally { delete context.context.phase4OverseasPdaOperations } + } + } +} + +async function createSharedWarehouse(context) { + const suffix = markerSuffix(context) + const payload = { + customerId: 0, warehouseCode: `PDA-${suffix}`.slice(0, 50), + warehouseName: `PDA共享收货仓-${suffix}`.slice(0, 120), warehouseType: 'OVERSEAS', + countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'PDA Acceptance', + contactPhone: '+1-213-555-0198', contactEmail: `pda-${suffix.toLowerCase()}@example.com`, + status: 'ACTIVE', remark: `${context.marker}-shared-receiving`, + address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'CA', city: 'Los Angeles', + district: 'Commerce', postalCode: '90040', addressLine1: '1 Acceptance Logistics Ave' }, + capabilities: [{ capabilityCode: 'RECEIVING', capabilityName: '收货', enabled: true }], + } + const created = await context.requestOverseas('POST', '/api/platform/v1/warehouse-admin', payload) + const warehouseId = id(created?.body?.data) + context.addStep(MODULE, '创建本轮PDA共享实际收货仓', created, + success(created) && longId(warehouseId), `warehouseId=${warehouseId || 'missing'}; ${trace(created)}`, OPTIONS) + return longId(warehouseId) ? { id: warehouseId, name: payload.warehouseName } : null +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index df3577739..261b0e84b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -9,29 +9,51 @@ 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) { return { http: 200, body: { code: '200', message: 'success', data }, requestId: 'request-test', traceId: 'trace-test' } } +function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } } +function voidResponse() { return { http: 200, body: { code: '200', message: 'success' }, requestId: 'request-test', traceId: 'trace-test' } } function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) { - const calls = []; const steps = []; const gaps = []; let createdPallet = null + const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null const context = { context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps, async request(method, requestPath) { calls.push({ channel: 'domestic', method, path: requestPath }); return response(page(tenants)) }, async requestOverseas(method, requestPath, body) { calls.push({ channel: 'overseas', method, path: requestPath, body }) - if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', ...body }; return response('71001') } - if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { createdPallet = { ...createdPallet, ...body }; return response(null) } + if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') + if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) + if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') } + if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() } + if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() } + if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') } + if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { + if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409') + createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return voidResponse() + } + if (method === 'POST' && requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) { + if (createdRelabel && body.remark !== createdRelabel.remark) return response(null, 'A0409') + if (!createdRelabel) createdRelabel = { ...body, relabelNo: 'FBR-TEST', status: 'PENDING_PROCESSING' } + return response({ application: createdRelabel, items: body.items, history: [] }) + } + if (method === 'PUT' && requestPath.endsWith('/relabel-applications/FBR-TEST/status')) { + createdRelabel = { ...createdRelabel, status: body.status } + return response({ application: createdRelabel, items: createdRelabel.items, history: [] }) + } if (requestPath.startsWith('/api/platform/v1/last-leg/orders?')) return response(page([{ id: '30001', orderNo: 'LL-1', currentStatus: 'PENDING_ASSIGNMENT' }])) if (requestPath.includes('/last-leg/orders/30001?')) return response({ order: { id: '30001' }, cargo: [], statusHistory: [] }) if (requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) return response(page([{ id: '40001', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }])) if (requestPath.includes('/relabel-applications/RL-1?')) return response({ application: { relabelNo: 'RL-1' }, items: [], history: [] }) - if (requestPath.startsWith('/api/platform/v1/last-leg/inbound-plans?')) return response(page([{ id: '50001', sourceOrderId: '30001', planNo: 'IP-1', customerId: '10001', status: 'PENDING_ASSIGNMENT' }])) + if (requestPath.startsWith('/api/platform/v1/last-leg/inbound-plans?')) return response(page([{ lineId: '50002', planId: '50001', orderId: '30001', planNo: 'IP-1', customerId: '10001', status: 'PENDING_ASSIGNMENT' }])) if (requestPath.includes('/inbound-plans/IP-1?')) return response({ planNo: 'IP-1', sourceOrderId: '30001', lines: [], pallets: [], allocations: [] }) if (requestPath.includes('/inventory/summary?')) return response({ inboundOrderCount: 1, palletCount: 1, totalPackages: 10, availablePackages: 10, holdPackages: 0, inTransitPackages: 0, receivingPackages: 0, procurementPackages: 0 }) if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }])) if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }]) if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }])) if (requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) - if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001' } }) + if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } }) + if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }])) + if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' }) + if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null + ? response(undefined, 'A0404') : response(createdLocation) if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }])) if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' }) if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet) @@ -54,17 +76,26 @@ test('模块只依赖注入请求,不读取环境或自行联网', () => { assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/) }) -test('仅托盘生命周期允许写请求,其他海外业务保持只读', () => { - assert.doesNotMatch(source, /requestOverseas\(['"]PATCH['"]/) +test('既有仓库场景只写本轮库位托盘与换标生命周期', () => { const context = contextFixture() return run(context).then(() => { assert.ok(context.calls.length >= 12) - assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT'].includes(call.method))) + assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'DELETE'].includes(call.method))) assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 1) assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/'))) const writes = context.calls.filter((call) => call.method !== 'GET') - assert.deepEqual(writes.map((call) => call.method), ['POST', 'PUT', 'PUT']) - assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/pallets'))) + assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002'))) + assert.ok(writes.some((call) => call.path.includes('/warehouse-admin/20001/pallets'))) + assert.ok(writes.some((call) => call.path.includes('/relabel-applications'))) + assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/') || call.path.includes('/relabel-applications'))) + for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) { + const step = context.steps.find((item) => item.name === name) + assert.ok(step?.passed, name) + assert.equal(Object.hasOwn(step.result?.body || {}, 'data'), false, name) + } + const deletedReadback = context.steps.find((step) => step.name === '本轮公共库位已删除且回查不存在') + assert.equal(deletedReadback?.result?.body?.code, 'A0404') + assert.ok(deletedReadback?.passed) assert.ok(context.steps.every((step) => step.passed), context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n')) }) }) @@ -72,40 +103,55 @@ test('仅托盘生命周期允许写请求,其他海外业务保持只读', () test('覆盖冻结的海外只读列表与有记录才查详情', async () => { const context = contextFixture() await run(context) - for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/warehouse-admin?', '/pallets?']) { + for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/warehouse-admin?', '/locations?', '/pallets?']) { assert.ok(context.calls.some((call) => call.channel === 'overseas' && call.path.includes(pathToken)), pathToken) } for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/warehouse-admin/20001?', '/pallets/70001']) { assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken) } - 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' }) + for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?') || item.path.includes('/inventory/balances/export?') || item.path.includes('/inventory/ledger/export?'))) { + const params = new URL(`http://acceptance${call.path}`).searchParams + assert.equal(params.has('page'), false, call.path) + assert.equal(params.has('size'), false, call.path) + } + assert.deepEqual(context.context.phase4OverseasWarehouse, { customerId: '10001', warehouseId: '20001', palletId: '71001', relabelNo: 'FBR-TEST' }) + assert.deepEqual(context.createdResources.phase4OverseasWarehouse, { + marker: 'ACC-PHASE4-TEST', customerId: '10001', warehouseId: '20001', palletId: '71001', terminalStatus: 'SCRAPPED', + locationId: '72002', locationTerminalStatus: 'DELETED', relabelNo: 'FBR-TEST', relabelTerminalStatus: 'COMPLETED', + }) }) -test('缺少真实客户或仓库时记录 coverageGap,不使用固定业务ID补洞', async () => { +test('缺少客户时明确失败,缺少仓库时通过正式接口创建并停用fixture', async () => { const noCustomer = contextFixture({ tenants: [] }) await run(noCustomer) - assert.equal(noCustomer.gaps.length, 1) - assert.match(noCustomer.gaps[0][3], /requestId=request-test.*traceId=trace-test/) + 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] === '公共托盘真实仓库前置'), true) - assert.equal(noWarehouse.calls.some((call) => call.path.includes('/pallets')), false) + 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, page: 1, size: 200/) - assert.match(source, /inventory\/balances\/export[^\n]+customerId, page: 1, size: 200/) - assert.match(source, /inventory\/ledger\/export[^\n]+customerId, page: 1, size: 200/) + 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=/) - assert.match(source, /longId\(plan\.sourceOrderId\)/) + for (const field of ['lineId', 'planId', 'orderId']) { + assert.match(source, new RegExp(`longId\\(plan\\.${field}\\)`)) + } assert.match(source, /id\(pallet\.customerId\) === '0'/) - assert.match(source, /换标创建与状态生命周期/) - assert.match(source, /旧版本并发冲突/) + assert.match(source, /创建本轮换标单/) + assert.match(source, /换标单同幂等键不同请求返回A0409/) + assert.match(source, /创建本轮公共库位/) + assert.match(source, /公共托盘旧版本编辑返回冲突/) + assert.doesNotMatch(source, /deploymentBlocker|addCoverageGap|coverageGap/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 292fb489b..7dc90a1b3 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -1,6 +1,6 @@ const MODULE = 'phase4-overseas-warehouse' const SCENARIO = 'phase4-overseas-warehouse' -const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const OPTIONS = { scenario: SCENARIO } function id(value) { return value == null ? '' : String(value) } function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 } @@ -37,8 +37,9 @@ async function overseasGet(context, name, path) { } async function overseasMutation(context, method, name, path, body) { const response = await context.requestOverseas(method, path, body) - assertEnvelope(context, name, response) - return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) } + 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) @@ -51,8 +52,15 @@ 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 coverageGap(context, name, reason, category = 'prerequisite') { - context.addCoverageGap(MODULE, SCENARIO, name, reason, category) +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) { @@ -62,7 +70,8 @@ export async function run(context) { const customer = records(tenantsResult.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE') const customerId = id(customer?.id) if (!customerId) { - coverageGap(context, '海外只读接口显式客户范围', `IAM ACTIVE 客户列表未返回可用 customerId;${trace(tenantsResult.response)}`) + context.assert(MODULE, '海外仓验收必须取得ACTIVE客户', tenantsResult.response, false, + `IAM ACTIVE 客户列表未返回可用 customerId;${trace(tenantsResult.response)}`, OPTIONS) return } @@ -98,8 +107,9 @@ export async function run(context) { const plan = records(plans.data)[0] if (plan) { context.assert(MODULE, '入仓计划关键字段正确', plans.response, - longId(plan.id) && longId(plan.sourceOrderId) && nonBlank(plan.planNo) && id(plan.customerId) === customerId && nonBlank(plan.status), - `id=${id(plan.id) || 'missing'}, sourceOrderId=${id(plan.sourceOrderId) || 'missing'}, planNo=${plan.planNo || 'missing'}, status=${plan.status || 'missing'}; ${trace(plans.response)}`, OPTIONS) + 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) @@ -107,7 +117,7 @@ export async function run(context) { `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, page: 1, size: 200 })) + 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])), @@ -135,17 +145,32 @@ export async function run(context) { && 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, page: 1, size: 200 })) - await overseasCsv(context, '下载进销存流水CSV', query('/api/platform/v1/last-leg/inventory/ledger/export', { customerId, page: 1, size: 200 })) + 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 })) - coverageGap(context, '换标创建与状态生命周期', - '当前 Admin 换标新增/审核仍使用不可提交的契约提示弹框,前端未真实接入写 API;验收模块不越过页面范围直接创建换标单', 'frontend-scope') - - const warehouse = await findWarehouse(context, records(tenantsResult.data), 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] @@ -160,8 +185,158 @@ export async function run(context) { `listId=${id(pallet.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) } - await verifyPalletLifecycle(context, warehouseId) - context.context.phase4OverseasWarehouse = { customerId, warehouseId, palletId: context.createdResources.phase4OverseasWarehouse.palletId } + try { + await verifyLocationLifecycle(context, warehouseId) + await verifyPalletLifecycle(context, warehouseId) + await verifyRelabelLifecycle(context, customerId, warehouseId) + context.context.phase4OverseasWarehouse = { + customerId, warehouseId, + palletId: context.createdResources.phase4OverseasWarehouse.palletId, + relabelNo: context.createdResources.phase4OverseasWarehouse.relabelNo, + } + } finally { + if (ownedWarehouse) await retireWarehouseFixture(context, customerId, warehouseId) + } +} + +async function verifyLocationLifecycle(context, warehouseId) { + const marker = String(context.marker || '').replace(/[^A-Za-z0-9_-]/g, '').slice(-40).toUpperCase() || 'PHASE4' + const locationCode = `ACC-${marker}`.slice(0, 64) + let locationId = '' + try { + const created = await overseasMutation(context, 'POST', '创建本轮公共库位', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { + zoneCode: 'ACC', locationCode, locationType: 'STORAGE', maxLoadKg: 1000, + status: 'ACTIVE', remark: context.marker, + }) + locationId = id(created.data) + context.createdResources.phase4OverseasWarehouse.locationId = locationId || null + context.assert(MODULE, '公共库位创建返回长ID', created.response, longId(locationId), + `locationId=${locationId || 'missing'}, marker=${context.marker}; ${trace(created.response)}`, OPTIONS) + if (!locationId) return + const detailPath = `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}` + const detail = await overseasGet(context, '查询本轮公共库位详情', detailPath) + context.assert(MODULE, '本轮公共库位创建事实正确', detail.response, + id(detail.data?.id) === locationId && id(detail.data?.customerId) === '0' + && detail.data?.locationCode === locationCode && detail.data?.remark === context.marker, + `locationId=${id(detail.data?.id) || 'missing'}, code=${detail.data?.locationCode || 'missing'}; ${trace(detail.response)}`, OPTIONS) + const updated = await overseasMutation(context, 'PUT', '编辑本轮公共库位', detailPath, { + zoneCode: 'ACC', locationCode, locationType: 'STORAGE', maxLoadKg: 1200, + status: 'ACTIVE', remark: `${context.marker}-edited`, + }) + context.assert(MODULE, '公共库位编辑成功', updated.response, voidMutationSucceeded(updated), + `locationId=${locationId}; ${trace(updated.response)}`, OPTIONS) + const after = await overseasGet(context, '回查公共库位编辑结果', detailPath) + context.assert(MODULE, '公共库位编辑字段已生效', after.response, + Number(after.data?.maxLoadKg) === 1200 && after.data?.remark === `${context.marker}-edited`, + `maxLoadKg=${after.data?.maxLoadKg ?? 'missing'}, remark=${after.data?.remark || 'missing'}; ${trace(after.response)}`, OPTIONS) + } finally { + if (locationId) { + const removed = await overseasMutation(context, 'DELETE', '删除本轮公共库位', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) + const deletedDetail = await context.requestOverseas('GET', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) + const deleted = voidMutationSucceeded(removed) && businessNotFound(deletedDetail) + context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = deleted ? 'DELETED' : null + context.assert(MODULE, '本轮公共库位已删除且回查不存在', deletedDetail, deleted, + `locationId=${locationId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.locationTerminalStatus || 'missing'}; delete=${trace(removed.response)}, readback=${trace(deletedDetail)}`, OPTIONS) + } + } +} + +async function verifyRelabelLifecycle(context, customerId, warehouseId) { + const marker = String(context.marker || 'PHASE4') + const idempotencyKey = `${marker}:RELABEL`.slice(0, 255) + const skuSuffix = marker.replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() || 'PHASE4' + const payload = { + customerOrderNo: `${marker}-RELABEL`, overseasWarehouseId: warehouseId, + labelDeadline: new Date(Date.now() + 86400000).toISOString().slice(0, 19), + contactName: 'Phase4 QA', contactPhone: '+1-213-555-0199', remark: marker, idempotencyKey, + items: [{ skuCode: `OLD-${skuSuffix}`.slice(0, 128), productNameZh: `${marker} 换标商品`, + totalPackageCount: 2, notifiedPackageCount: 2, shipmentNo: `SHIP-${skuSuffix}`.slice(0, 128), + newSkuCode: `NEW-${skuSuffix}`.slice(0, 128), labelRequirement: marker }], + } + let relabelNo = '' + let terminalStatus = '' + try { + const created = await overseasMutation(context, 'POST', '创建本轮换标单', + query('/api/platform/v1/last-leg/relabel-applications', { customerId }), payload) + relabelNo = id(created.data?.application?.relabelNo) + context.createdResources.phase4OverseasWarehouse.relabelNo = relabelNo || null + context.assert(MODULE, '换标单创建事实正确', created.response, + nonBlank(relabelNo) && created.data?.application?.customerOrderNo === payload.customerOrderNo + && created.data?.application?.status === 'PENDING_PROCESSING' + && Array.isArray(created.data?.items) && created.data.items.length === 1, + `relabelNo=${relabelNo || 'missing'}, status=${created.data?.application?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!relabelNo) return + + const replay = await overseasMutation(context, 'POST', '换标单幂等精确重放', + query('/api/platform/v1/last-leg/relabel-applications', { customerId }), payload) + context.assert(MODULE, '换标单精确重放返回同一单号', replay.response, + replay.data?.application?.relabelNo === relabelNo, + `expected=${relabelNo}, actual=${replay.data?.application?.relabelNo || 'missing'}; ${trace(replay.response)}`, OPTIONS) + + const conflict = await context.requestOverseas('POST', + query('/api/platform/v1/last-leg/relabel-applications', { customerId }), { ...payload, remark: `${marker}-DIFFERENT` }) + context.addStep(MODULE, '换标单同幂等键不同请求返回A0409', conflict, businessConflict(conflict), trace(conflict), OPTIONS) + + const processing = await overseasMutation(context, 'PUT', '换标单进入处理中', + `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`, + { status: 'PROCESSING', remark: marker }) + context.assert(MODULE, '换标单状态推进为PROCESSING', processing.response, + processing.data?.application?.status === 'PROCESSING', + `status=${processing.data?.application?.status || 'missing'}; ${trace(processing.response)}`, OPTIONS) + const completed = await overseasMutation(context, 'PUT', '完成本轮换标单', + `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`, + { status: 'COMPLETED', remark: `${marker}-completed` }) + terminalStatus = completed.data?.application?.status || '' + context.createdResources.phase4OverseasWarehouse.relabelTerminalStatus = terminalStatus || null + context.assert(MODULE, '本轮换标单已完成收口', completed.response, terminalStatus === 'COMPLETED', + `relabelNo=${relabelNo}, status=${terminalStatus || 'missing'}; ${trace(completed.response)}`, OPTIONS) + } finally { + if (relabelNo && terminalStatus !== 'COMPLETED') { + const cancelled = await context.requestOverseas('PUT', + `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`, + { status: 'CANCELLED', remark: `${marker}-acceptance-cleanup` }) + const status = cancelled?.body?.data?.application?.status + context.createdResources.phase4OverseasWarehouse.relabelTerminalStatus = status || null + context.addStep(MODULE, '异常路径换标单取消收口', cancelled, + cancelled?.http === 200 && String(cancelled?.body?.code) === '200' && status === 'CANCELLED', trace(cancelled), OPTIONS) + } + } +} + +async function createWarehouseFixture(context, customerId) { + const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() + const payload = { + customerId, warehouseCode: `P4W-${suffix}`.slice(0, 50), warehouseName: `${context.marker} 海外仓`, + warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', + contactName: 'Phase4 QA', contactPhone: '+1-213-555-0188', contactEmail: null, + status: 'ACTIVE', remark: `${context.marker}-warehouse-fixture`, + address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles', + district: 'Commerce', postalCode: '90040', addressLine1: '501 Phase Four Warehouse Avenue', + addressLine2: null, latitude: null, longitude: null }, + capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, + cutoffTime: null, description: `${context.marker} acceptance` }], + } + const created = await overseasMutation(context, 'POST', '创建本轮客户海外仓', '/api/platform/v1/warehouse-admin', payload) + const warehouseId = id(created.data) + context.assert(MODULE, '客户海外仓创建返回长ID', created.response, longId(warehouseId), + `warehouseId=${warehouseId || 'missing'}, customerId=${customerId}; ${trace(created.response)}`, OPTIONS) + if (!warehouseId) return null + const detail = await overseasGet(context, '查询本轮客户海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}`, { customerId })) + context.assert(MODULE, '本轮客户海外仓归属和状态正确', detail.response, + id(detail.data?.warehouse?.id || detail.data?.id) === warehouseId + && id(detail.data?.warehouse?.customerId || detail.data?.customerId) === customerId + && (detail.data?.warehouse?.status || detail.data?.status) === 'ACTIVE', + `warehouseId=${warehouseId}, customerId=${id(detail.data?.warehouse?.customerId || detail.data?.customerId) || 'missing'}; ${trace(detail.response)}`, OPTIONS) + return { id: warehouseId, customerId, warehouseCode: payload.warehouseCode, warehouseName: payload.warehouseName, status: 'ACTIVE' } +} + +async function retireWarehouseFixture(context, customerId, warehouseId) { + const retired = await context.requestOverseas('PATCH', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/status`, { customerId, status: 'INACTIVE' })) + context.addStep(MODULE, '本轮客户海外仓已停用收口', retired, + retired?.http === 200 && String(retired?.body?.code) === '200', trace(retired), OPTIONS) } async function verifyPalletLifecycle(context, warehouseId) { @@ -188,26 +363,32 @@ async function verifyPalletLifecycle(context, warehouseId) { 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`, + locationId: id(detail.data?.locationId) || undefined, remark: `${context.marker}-edited`, version: Number(detail.data?.version), }) - context.assert(MODULE, '公共托盘编辑成功', updated.response, updated.data === null, + 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) - coverageGap(context, '公共托盘旧版本并发冲突', - '当前 Pallet SaveRequest/PUT 未提供 version 或 expectedVersion,无法构造稳定旧版本冲突且禁止伪造冲突请求', 'missing-version-contract') + 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`, + 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 = retired.data === null ? 'SCRAPPED' : null - context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, retired.data === null, + 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)}`) @@ -237,6 +418,5 @@ async function findWarehouse(context, tenants, preferredCustomerId) { return warehouse } } - coverageGap(context, '公共托盘真实仓库前置', `ACTIVE 客户列表未找到可用海外仓;${trace(lastResponse)}`, 'missing-readonly-warehouse') return null } diff --git a/scripts/admin-test-env-modules/pricing-contract.test.mjs b/scripts/admin-test-env-modules/pricing-contract.test.mjs new file mode 100644 index 000000000..55cc64b54 --- /dev/null +++ b/scripts/admin-test-env-modules/pricing-contract.test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'pricing.mjs'), 'utf8') + +test('price-book acceptance is portable and reuses a deployed active source by default', () => { + assert.match(source, /process\.env\.ADMIN_TEST_PRICE_BOOK_FILE/) + assert.match(source, /activeBefore\[0\]/) + assert.match(source, /requestBinary\('GET', `\$\{PRICE_BOOK_PATH\}\/\$\{encodeURIComponent\(activeFixture\.id\)\}\/source`\)/) + assert.match(source, /复用当前生效价格源作为本轮隔离上传夹具/) + assert.doesNotMatch(source, /WXWork|Users\\BY-OY|DEFAULT_PRICE_BOOK_FILE/) +}) diff --git a/scripts/admin-test-env-modules/pricing.mjs b/scripts/admin-test-env-modules/pricing.mjs index 1853dbf82..91952b572 100644 --- a/scripts/admin-test-env-modules/pricing.mjs +++ b/scripts/admin-test-env-modules/pricing.mjs @@ -5,7 +5,6 @@ import { basename } from 'node:path' const PRICE_BOOK_PATH = '/api/platform/v1/fba-pricing/carrier-rates/price-books' const DOMESTIC_WAREHOUSE_PATH = '/api/platform/v1/warehouse-admin?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC' const OVERSEAS_WAREHOUSE_PATH = '/api/platform/v1/warehouse-admin?page=1&size=200&status=ACTIVE&warehouseType=OVERSEAS' -const DEFAULT_PRICE_BOOK_FILE = 'C:\\Users\\BY-OY\\Documents\\WXWork\\1688854456873012\\Cache\\File\\2026-08\\2026.08.03柏悦价格表 船期2026.08.12.xlsx' const EXPECTED_CHANNEL_CODES = [ 'MATSON_CLX_TRUCK', 'MATSON_MAX_TRUCK', 'EXX_TRUCK', 'HEDE_TRUCK', 'EXX_RAIL_NY', 'OA_LA_TRUCK', 'OA_LA_FULL_TRUCK', 'OA_CHICAGO_RAIL', 'OA_HOUSTON_TRUCK', @@ -134,18 +133,37 @@ export async function run(context) { Array.isArray(versionsBefore) && activeBefore.length <= 1, `versions=${Array.isArray(versionsBefore) ? versionsBefore.length : 'n/a'}, active=${activeBefore.length}`, blocker) - const sourcePath = process.env.ADMIN_TEST_PRICE_BOOK_FILE || DEFAULT_PRICE_BOOK_FILE + const sourcePath = process.env.ADMIN_TEST_PRICE_BOOK_FILE let sourceBytes - try { - sourceBytes = await readFile(sourcePath) - } catch (error) { - context.addStep(module, '读取船期价格测试文件', null, false, - `path=${sourcePath}, error=${error instanceof Error ? error.message : String(error)}`, blocker) - finishScenario(context, module, scenario, start) - return + let sourceName + if (sourcePath) { + try { + sourceBytes = await readFile(sourcePath) + sourceName = basename(sourcePath) + } catch (error) { + context.addStep(module, '读取显式船期价格测试文件', null, false, + `path=${sourcePath}, error=${error instanceof Error ? error.message : String(error)}`, blocker) + finishScenario(context, module, scenario, start) + return + } + } else { + const activeFixture = activeBefore[0] + const sourceResponse = activeFixture?.id + ? await context.requestBinary('GET', `${PRICE_BOOK_PATH}/${encodeURIComponent(activeFixture.id)}/source`) + : null + const zipMagic = sourceResponse?.bytes?.[0] === 0x50 && sourceResponse?.bytes?.[1] === 0x4b + const fixtureReady = sourceResponse?.http === 200 && sourceResponse.bytes?.length > 0 && zipMagic + context.addStep(module, '复用当前生效价格源作为本轮隔离上传夹具', sourceResponse, fixtureReady, + `activeVersionId=${activeFixture?.id || 'missing'}, bytes=${sourceResponse?.bytes?.length || 0}, zipMagic=${zipMagic}`, blocker) + if (!fixtureReady) { + finishScenario(context, module, scenario, start) + return + } + sourceBytes = sourceResponse.bytes + sourceName = activeFixture.originalFileName || `price-book-${activeFixture.id}.xlsx` } const sourceHash = sha256(sourceBytes) - const uploadName = `${context.marker}-${basename(sourcePath)}` + const uploadName = `${context.marker}-${sourceName}` context.addStep(module, '船期价格测试文件已隔离', null, sourceBytes.length > 0 && uploadName.includes(context.marker) && uploadName.toLowerCase().endsWith('.xlsx'), `fileName=${uploadName}, bytes=${sourceBytes.length}, sha256=${sourceHash}`, blocker) diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 7d0fb741d..6564558ea 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -30,10 +30,27 @@ async function actionRequest(context, path, endpoint, version) { } } +async function uploadBillOfLading(context, appointmentId) { + const path = `${TRUCK}/${appointmentId}/attachments/upload?attachmentType=BILL_OF_LADING` + const form = new FormData() + const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) + form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-bill.png`) + const response = await fetch(`${context.options.gateway}${path}`, { + method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + body: form, signal: AbortSignal.timeout(60_000), + }) + const body = await response.json() + return { method: 'POST', path, http: response.status, body, + requestId: body?.requestId || response.headers.get('x-request-id'), + traceId: body?.traceId || response.headers.get('x-trace-id') } +} + export async function run(context) { - const ocean = context.createdResources?.oceanBooking const containerFlow = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading + const ocean = containerFlow?.oceanBookingId + ? { id: id(containerFlow.oceanBookingId), finalStatus: 'RELEASED' } + : context.createdResources?.oceanBooking const containerFixture = [ context.createdResources?.orders, context.createdResources?.orchestrationFixtures?.inboundMeasurement, @@ -70,6 +87,13 @@ export async function run(context) { `containerBookingId=${id(containerFlow.containerBookingId)}, containers=${oceanDetail?.containers?.length ?? 'n/a'}`, OPTIONS) if (!allocationMatches) return } + const carrierResponse = await context.request('GET', + '/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=20') + const carrierPage = context.apiSuccess(MODULE, '读取 ACTIVE TRUCKING 承运商事实源', carrierResponse, '', OPTIONS) + const carrier = carrierPage?.records?.find?.((item) => item?.id && item?.name) + context.assert(MODULE, '跨服务拖车使用正式 ACTIVE TRUCKING 承运商', carrierResponse, + Boolean(carrier?.id && carrier?.name), `carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS) + if (!carrier) return const payload = { outboundOrderId: linkContainer ? id(containerFixture?.orderId) || null : null, oceanBookingId: ocean.id, @@ -78,7 +102,7 @@ export async function run(context) { warehouseId: linkContainer ? id(containerFlow.warehouseId) : id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'), - customerId, customerName, carrierId: null, carrierName: null, + customerId, customerName: null, carrierId: id(carrier.id), carrierName: carrier.name, fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008', driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人', storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000', @@ -105,6 +129,9 @@ export async function run(context) { `appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS) if (!appointmentId) return const path = `${TRUCK}/${appointmentId}` + const billUpload = await uploadBillOfLading(context, appointmentId) + const billAttachment = context.apiSuccess(MODULE, '跨服务拖车上传有效提单附件', billUpload, '', OPTIONS) + if (!billAttachment?.id) return let version = Number(appointment.version) for (const [name, endpoint, status] of [ ['提交跨链路预约', 'submit', 'PENDING_DISPATCH'], diff --git a/scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs b/scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs new file mode 100644 index 000000000..d80e26117 --- /dev/null +++ b/scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./supplemental-boundaries.mjs', import.meta.url), 'utf8') + +test('occupied location deletion protection reuses only the marker-owned loading fixture', () => { + assert.match(source, /phase3Loading/) + assert.match(source, /inventoryMutationCleanup\?\.targetLocationId/) + assert.match(source, /loading\?\.marker === context\.marker/) + assert.match(source, /真实占用库位删除返回A0409且保留资源/) + assert.match(source, /占用删除冲突未造成资源丢失/) + assert.doesNotMatch(source, /no-public-fixture-api|当前公共验收 API 未提供/) +}) diff --git a/scripts/admin-test-env-modules/supplemental-boundaries.mjs b/scripts/admin-test-env-modules/supplemental-boundaries.mjs index 7ca47c5ae..a6057b65d 100644 --- a/scripts/admin-test-env-modules/supplemental-boundaries.mjs +++ b/scripts/admin-test-env-modules/supplemental-boundaries.mjs @@ -86,11 +86,30 @@ export async function run(context) { locationVersion = Number(afterData?.location?.version) } - // Public APIs do not expose a safe way to create a real occupied pallet/ - // location association without an inbound allocation fixture. Keep this as - // an explicit automation gap instead of claiming a deletion-protection pass. - context.addCoverageGap(MODULE, 'resource-occupancy', '托盘/库位真实占用删除保护', - '当前公共验收 API 未提供可安全构造并释放占用关联的独立 fixture;未操作历史资源', 'no-public-fixture-api') + // phase3-loading has already created and rebound a marker-owned live + // occupancy. Reuse that exact location to prove deletion protection without + // touching historical resources or inventing a setup-only API. + const loading = context.createdResources?.phase3Loading + const occupiedLocationId = id(loading?.inventoryMutationCleanup?.targetLocationId) + const occupiedWarehouseId = id(loading?.warehouseId) + context.assert(MODULE, '取得本轮装柜链真实占用库位', null, + Boolean(occupiedLocationId && occupiedWarehouseId && loading?.marker === context.marker), + `locationId=${occupiedLocationId || 'missing'}, warehouseId=${occupiedWarehouseId || 'missing'}`, OPTIONS) + if (occupiedLocationId && occupiedWarehouseId && loading?.marker === context.marker) { + const occupiedPath = `/api/platform/v1/locations/${occupiedLocationId}?tenantId=0&warehouseId=${occupiedWarehouseId}` + const occupiedDetail = await context.request('GET', occupiedPath) + const occupied = context.apiSuccess(MODULE, '读取本轮真实占用库位版本', occupiedDetail, '', OPTIONS) + const occupiedVersion = Number(occupied?.location?.version) + const rejectedDelete = await context.request('DELETE', `${occupiedPath}&version=${occupiedVersion}`) + context.addStep(MODULE, '真实占用库位删除返回A0409且保留资源', rejectedDelete, + rejectedDelete.http === 200 && ['A0409', '409'].includes(codeOf(rejectedDelete)), + `HTTP=${rejectedDelete.http}, code=${codeOf(rejectedDelete)}, version=${occupiedVersion}`, OPTIONS) + const occupiedAfter = await context.request('GET', occupiedPath) + const after = context.apiSuccess(MODULE, '删除冲突后回查占用库位', occupiedAfter, '', OPTIONS) + context.assert(MODULE, '占用删除冲突未造成资源丢失', occupiedAfter, + id(after?.location?.id) === occupiedLocationId, + `expected=${occupiedLocationId}, actual=${id(after?.location?.id) || 'missing'}`, OPTIONS) + } // Import boundary probes use only in-memory bytes and dry-run where supported. const bogusXlsx = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00]) diff --git a/scripts/admin-test-env-modules/system.mjs b/scripts/admin-test-env-modules/system.mjs index 7a1c877b6..a76a33c2a 100644 --- a/scripts/admin-test-env-modules/system.mjs +++ b/scripts/admin-test-env-modules/system.mjs @@ -146,6 +146,21 @@ export async function run(context) { const resetLoginResponse = await context.request('POST', '/api/auth/login', { username, password: resetPassword }, '') const resetLoginPassed = resetLoginResponse.http === 200 && String(resetLoginResponse.body?.code) === '200' && Boolean(resetLoginResponse.body?.data?.accessToken) context.addStep(module, '内部用户使用重置密码登录', resetLoginResponse, resetLoginPassed, `username=${username}`) + if (context.options.modules.includes('phase4-cross-region-events')) { + const restrictedOverseasToken = resetLoginResponse.body?.data?.overseasAccessToken || '' + context.assert(module, '受限角色登录同时签发海外Token', resetLoginResponse, + Boolean(restrictedOverseasToken), `tokenPresent=${Boolean(restrictedOverseasToken)}`, + { scenario: 'canonical-overseas-permission-denial', deploymentBlocker: true }) + if (restrictedOverseasToken) { + const denied = await context.requestOverseas('GET', '/api/platform/v1/last-leg/orders?page=1&size=1', + undefined, {}, restrictedOverseasToken) + context.assert(module, '缺少canonical海外订单权限必须403', denied, + (denied.http === 403 || ['403', 'A0403'].includes(String(denied.body?.code))) + && Boolean(denied.requestId) && Boolean(denied.traceId), + `HTTP=${denied.http}, code=${denied.body?.code ?? 'missing'}, requestId=${denied.requestId || 'missing'}, traceId=${denied.traceId || 'missing'}`, + { scenario: 'canonical-overseas-permission-denial', deploymentBlocker: true }) + } + } const disableUserResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/status`, { status: 'DISABLED' }) const disabledUser = context.apiSuccess(module, '停用平台内部用户', disableUserResponse) diff --git a/scripts/admin-test-env-report-summary-contract.test.mjs b/scripts/admin-test-env-report-summary-contract.test.mjs new file mode 100644 index 000000000..1171aecce --- /dev/null +++ b/scripts/admin-test-env-report-summary-contract.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' + +test('report summary separates assertion, module-run and unique-request metrics', () => { + const sharedResponse = { + method: 'GET', path: '/api/platform/v1/orders?page=1', requestId: 'request-1', + } + const steps = [ + { module: 'orders', name: '订单列表成功', passed: true, + request: { method: sharedResponse.method, path: sharedResponse.path }, requestId: sharedResponse.requestId }, + { module: 'orders', name: '订单列表数据结构正确', passed: true, + request: { method: sharedResponse.method, path: sharedResponse.path }, requestId: sharedResponse.requestId }, + { module: 'orders', name: '订单列表成功', passed: false, + request: { method: 'GET', path: '/api/platform/v1/orders?page=2' }, requestId: 'request-2' }, + { module: 'report-security', name: '报告敏感信息扫描', passed: true, request: null, requestId: null }, + ] + const modules = [ + { name: 'orders' }, + { name: 'orders' }, + { name: 'finance', skipped: 'owned by finance full chain' }, + ] + + const summary = buildAcceptanceSummary(steps, modules, []) + + assert.deepEqual(summary, { + total: 4, + passed: 3, + failed: 1, + moduleRuns: 2, + uniqueModules: 1, + uniqueRequests: 2, + uniqueStepNames: 3, + skippedModules: 1, + coverageGaps: { total: 0, blocking: 0, nonBlocking: 0, byCategory: {} }, + }) +}) + +test('request evidence without requestId is deduplicated by method and path', () => { + const summary = buildAcceptanceSummary([ + { name: 'CSV 状态', passed: true, request: { method: 'GET', path: '/api/export' }, requestId: null }, + { name: 'CSV 表头', passed: true, request: { method: 'GET', path: '/api/export' }, requestId: null }, + { name: 'CSV POST', passed: true, request: { method: 'POST', path: '/api/export' }, requestId: null }, + ]) + + assert.equal(summary.uniqueRequests, 2) +}) + +test('coverage gaps are counted by category and blocking status without copying details', () => { + const summary = buildAcceptanceSummary([], [], [ + { category: 'device', blocking: false, reason: 'camera', secretEvidence: 'must-not-copy' }, + { category: 'device', blocking: true, reason: 'scanner' }, + { category: 'external-third-party', blocking: false, reason: 'captcha' }, + { blocking: true, reason: 'fixture' }, + ]) + + assert.deepEqual(summary.coverageGaps, { + total: 4, + blocking: 2, + nonBlocking: 2, + byCategory: { + device: { total: 2, blocking: 1, nonBlocking: 1 }, + 'external-third-party': { total: 1, blocking: 0, nonBlocking: 1 }, + uncategorized: { total: 1, blocking: 1, nonBlocking: 0 }, + }, + }) + assert.equal(JSON.stringify(summary).includes('must-not-copy'), false) +}) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 4052af722..ad8e06621 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -115,6 +115,19 @@ foreach ($pdaApplication in $pdaApplications) { } } +# 海外 PDA 当前只开放真实拆柜收货;其余后端虽已存在,前端接入前必须保持禁用。 +$overseasWorkbenchPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/data/overseas-workbench.js' +$overseasWorkbench = Read-RequiredText $overseasWorkbenchPath +Assert-Matches $overseasWorkbench "(?s)name:\s*'拆柜收货'.{0,300}?available:\s*true" 'Overseas PDA 拆柜收货开关' +foreach ($disabledOperation in @( + '库存盘点', '商品移库', '托盘管理', + '换标收货', '换标执行', '换标复核', + '库存查询', '库位查询', '托盘查询' +)) { + $disabledPattern = "(?s)name:\s*'$([regex]::Escape($disabledOperation))'.{0,300}?available:\s*false" + Assert-Matches $overseasWorkbench $disabledPattern "Overseas PDA 禁用入口 $disabledOperation" +} + # 文档声明为正式开放的 Client 海外仓页面必须仍是 active 路由。 $clientOverseasRoutesPath = Join-Path $workspaceRoot 'apps/client-web/src/modules/overseas-warehouse/routes.ts' $clientOverseasRoutes = Read-RequiredText $clientOverseasRoutesPath @@ -134,6 +147,7 @@ $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/**' )) { @@ -174,6 +188,9 @@ $criticalPaths = @( 'apps/pda-overseas-uniapp/src/pages/operation/operation.vue', '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', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', @@ -187,6 +204,22 @@ $criticalPaths = @( '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/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java', + 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.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/inboundplan/pda/controller/ReceivingContainerController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/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/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', @@ -197,6 +230,10 @@ $criticalPaths = @( 'baiyue-firstleg/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java', 'baiyue-firstleg/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java', + 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java', 'baiyue-firstleg/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegInboundOrderMapper.java', @@ -256,5 +293,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面、Maven 模块、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index 5bf92b4ed..9ee3dd23c 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -11,6 +11,18 @@ test('coordinator releases its builder while waiting for downstream jobs', () => assert.match(pipeline, /agent \{ label 'java-builder' \}/) assert.doesNotMatch(pipeline, /agent any/) assert.doesNotMatch(pipeline, /cleanWs\(/) + assert.doesNotMatch(pipeline, /^\s*(?:&&|\|\|)/m) +}) + +test('coordinator runs selected tests once before parallel service packaging', () => { + const gateIndex = pipeline.indexOf("stage('统一 Maven 测试门禁')") + const dispatchIndex = pipeline.indexOf("stage('并行调度独立服务 Job')") + + assert.ok(gateIndex >= 0, 'unified test gate must exist') + assert.ok(dispatchIndex > gateIndex, 'unified tests must finish before downstream dispatch') + assert.match(pipeline, /-pl \$\{selectedModules\} -am test/) + assert.match(pipeline, /booleanParam\(name: 'SKIP_TESTS', value: true\)/) + assert.doesNotMatch(pipeline, /TESTED_COMMIT/) }) test('AUTO detects database migrations without expanding them to all services', () => { @@ -23,3 +35,20 @@ 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/db/incremental.sql b/scripts/db/incremental.sql index 3a9bf24a9..81332bff9 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3614,6 +3614,41 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_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 @@ -3765,3 +3800,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN change_detail JSON NULL COMMENT ''字段变更前后值JSON''', 'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN change_detail JSON NULL COMMENT ''字段变更前后值JSON'' AFTER operator_id')); PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +-- 四期货物废弃审核幂等快照;存量表按字段存在性幂等补齐。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_cargo_discard_application' AND column_name='review_idempotency_key'), + 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash')); +PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'), + 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key')); +PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; diff --git a/scripts/deploy-built-service-rollback.test.mjs b/scripts/deploy-built-service-rollback.test.mjs index e8103388e..e794a23d3 100644 --- a/scripts/deploy-built-service-rollback.test.mjs +++ b/scripts/deploy-built-service-rollback.test.mjs @@ -41,8 +41,12 @@ case "$command" in 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 @@ -51,9 +55,13 @@ case "$command" in 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 @@ -61,7 +69,7 @@ case "$command" in esac ` -async function runDeployment({ imageTag, failingTag, failingStartTag }) { +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') @@ -69,6 +77,7 @@ async function runDeployment({ imageTag, failingTag, failingStartTag }) { 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') @@ -77,6 +86,7 @@ async function runDeployment({ imageTag, failingTag, failingStartTag }) { 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('\\', '/'), @@ -100,7 +110,9 @@ async function runDeployment({ imageTag, failingTag, failingStartTag }) { MOCK_CURRENT_IMAGE: currentImageFile, MOCK_HEALTH_STATE: healthStateFile, MOCK_FAIL_TAG: failingTag ?? 'never', - MOCK_FAIL_UP_TAG: failingStartTag ?? 'never' + MOCK_FAIL_UP_TAG: failingStartTag ?? 'never', + MOCK_RESTART_TAG: restartingTag ?? 'never', + MOCK_RUNTIME_STATE: runtimeStateFile } }) @@ -129,6 +141,9 @@ test('unhealthy deployment restores the previous healthy image and still fails t 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/) @@ -146,3 +161,15 @@ test('failed compose start also restores the previous healthy image', async (con 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/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index f694486e0..82eb5278e 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -110,7 +110,7 @@ test('default loading chain carries the three loading-list remarks contract', () assert.match(loading, /preservedRemarkItem\?\.warehouseRemark === warehouseRemark/) }) -test('default Admin HTTP entry carries evidence chain through settlement-aware PDA completion', () => { +test('default Admin HTTP entry completes settlement before customs consumes loaded slices', () => { const focused = normalizeModuleOrder([ 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-container-overview', @@ -120,8 +120,8 @@ test('default Admin HTTP entry carries evidence chain through settlement-aware P assert.deepEqual(focused, [ 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-container-overview', - 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-customs', - 'phase3-loading-finalize', + 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize', + 'phase3-customs', ]) const loading = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8') @@ -146,10 +146,19 @@ test('default Admin HTTP entry carries evidence chain through settlement-aware P assert.ok(finalize.indexOf('const taskPath =') < finalize.indexOf('结算递增 inboundVersion 后 final complete 必须使用最新版本')) assert.match(finalize, /结算或版本冲突失败后 task\/plan\/inbound\/operation 全部零写入/) - assert.match(finalize, /结算递增版本后实际完成分片唯一且柜次身份稳定/) + assert.match(finalize, /结算后每个实装来源分片唯一且柜次身份稳定/) assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/) }) +test('focused order splitting completes the initial loading settlement first', () => { + const focused = normalizeModuleOrder(['phase3-order-splitting']) + const loading = focused.indexOf('phase3-loading') + const finalize = focused.indexOf('phase3-loading-finalize') + const splitting = focused.indexOf('phase3-order-splitting') + + assert.ok(loading >= 0 && finalize > loading && splitting > finalize) +}) + test('Finance-only HTTP entry expands producer prerequisites and runs downloads last', () => { const focused = normalizeModuleOrder(['finance-downloads', 'finance-settlement-full']) assert.deepEqual(focused.slice(0, 4), [ diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs new file mode 100644 index 000000000..6c094c6e3 --- /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 23 modules and service-map freezes 16 executable services', async () => { + const modules = parseRootModules(await read('pom.xml')) + const services = parseServiceMap(await read('deploy/service-map.yml')) + + assert.equal(modules.length, 23) + assert.equal(new Set(modules).size, 23) + assert.equal(services.size, 16) + + 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/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 index 575df9e41..b1f89decb 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -8,18 +8,23 @@ const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.u 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']) { + for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-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(controller, /'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY'/) 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.match(service, /overseas-security-service\/target\/overseas-security-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(controller, /EVENT_BRIDGE_REGION/) - assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{params\.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 () => { @@ -27,10 +32,12 @@ test('service map freezes unique internal ports and moves event bridge away from 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, /^ overseas-security-service:[\s\S]*?port: 9307$/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.match(map, /^ overseas-security-service:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ event-bridge-service:[\s\S]*?regions: \[CN, OVERSEAS\]$/m) }) @@ -43,8 +50,20 @@ test('overseas compose is isolated from CN defaults and disables implicit schema 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, /^ overseas-security-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, /OVERSEAS_SECURITY_DB_URL: \$\{OVERSEAS_SECURITY_DB_URL\}/) + assert.match(compose, /OVERSEAS_SECURITY_EVENT_CONSUMER_URL:/) + assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/) + assert.match(compose, /aliases: \[event-bridge-overseas\]/) }) test('CN and overseas event bridges have explicit opposite-region consumers', async () => { @@ -58,10 +77,11 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as 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(firstleg, /LASTLEG_OUTBOX_RELAY_LEASE_SECONDS/) - assert.match(lastleg, /FIRSTLEG_EVENT_BRIDGE_URL/) + 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(scripts, /FIRSTLEG_EVENT_BRIDGE_URL/) assert.match(scripts, /LASTLEG_EVENT_BRIDGE_URL/) @@ -72,6 +92,7 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as 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 [ @@ -87,3 +108,15 @@ test('domestic Auth receives the overseas JWT signing configuration', async () = '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/) +}) diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index e78eab28c..05baae375 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -2,13 +2,19 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { parseArguments } from './admin-test-env-acceptance.mjs' +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', @@ -17,6 +23,18 @@ test('phase-four HTTP acceptance modules are registered in the Admin acceptance } }) +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/', @@ -28,5 +46,42 @@ test('entry accepts a separate overseas Gateway without exposing token state', ( assert.match(source, /this\.overseasToken = login\.overseasAccessToken \|\| ''/) assert.match(source, /async requestOverseas\(/) assert.match(source, /async requestOverseasBinary\(/) + assert.match(source, /async requestOverseasMultipart\(/) assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.overseasToken\]/) }) + +test('entry has no built-in environment address or password and requires explicit execution options', () => { + assert.doesNotMatch(source, /8\.163\.|ChangeMe123|DEFAULT_PASSWORD|DEFAULT_GATEWAY/) + assert.throws(() => validateExecutionOptions(parseArguments(['--modules', 'phase4-overseas-pda'])), + /缺少必填执行参数/) + const options = parseArguments([ + '--gateway', 'http://localhost:9000', '--overseas-gateway', 'http://localhost:9300', + '--username', 'contract-user', '--password', 'contract-password', '--modules', 'phase4-overseas-pda', + ]) + assert.equal(validateExecutionOptions(options), options) +}) + +test('core coverage gaps fail closed while device and external-provider gaps remain report-only', () => { + const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300', + username: 'contract-user', password: 'contract-password', modules: [], output: '' }) + run.addCoverageGap('phase4', 'core', 'fixture', 'missing fixture', 'fixture') + assert.equal(run.steps.at(-1)?.passed, false) + assert.equal(run.steps.at(-1)?.deploymentBlocker, true) + const stepCount = run.steps.length + run.addCoverageGap('phase3', 'device', 'Android', 'manual device validation', 'device') + run.addCoverageGap('phase3', 'provider', 'captcha', 'external captcha', 'external-third-party') + assert.equal(run.steps.length, stepCount) +}) + +test('PDA entry expands the real phase-three producer chain and builds an FBA/OVERSEAS/second-container matrix', () => { + const modules = parseArguments(['--modules', 'phase4-overseas-pda']).modules + for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', 'phase3-pda-convergence', + 'phase3-evidence-gate', 'phase3-loading-finalize']) { + assert.ok(modules.indexOf(prerequisite) >= 0 && modules.indexOf(prerequisite) < modules.indexOf('phase4-overseas-pda')) + } + assert.match(source, /async ensurePhase4PdaProducerMatrix\(\)/) + assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS'\)/) + assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA'\)/) + 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 index e4c7ced35..baa57b467 100644 --- a/scripts/provision-development-service-jobs.groovy +++ b/scripts/provision-development-service-jobs.groovy @@ -6,7 +6,7 @@ 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' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' ] Jenkins jenkins = Jenkins.get() diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs new file mode 100644 index 000000000..0e829bb6f --- /dev/null +++ b/scripts/synchronous-dependency-baseline.mjs @@ -0,0 +1,230 @@ +export const CONTEXTS = Object.freeze(['REQUEST', 'BACKGROUND', 'INFRASTRUCTURE', 'EXTERNAL']) +export const ACCESS_MODES = Object.freeze(['READ', 'WRITE', 'READ_WRITE']) +export const IDEMPOTENCY = Object.freeze([ + 'SAFE_READ', 'IDEMPOTENCY_KEY', 'BUSINESS_KEY', 'EVENT_ID', 'STATE_CONVERGENT', 'NONE', 'MIXED' +]) + +const feign = (path, source, target, contexts, access, idempotency, note) => ({ + id: `FEIGN:${path}`, + transport: 'FEIGN', path, source, target, contexts, access, idempotency, note +}) + +const http = (path, source, target, contexts, access, idempotency, note) => ({ + id: `HTTP:${path}`, + transport: 'HTTP', path, source, target, contexts, access, idempotency, note +}) + +export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java', + 'auth-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 读取授权快照,不写 IAM'), + feign('auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java', + 'auth-service', 'iam-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '注册/补偿以 authUserId 收敛,不自动重试'), + + feign('file/src/main/java/com/baiyue/file/client/FinanceExportProviderClient.java', + 'file-service', 'finance-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/FirstlegExportProviderClient.java', + 'file-service', 'firstleg-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/IamExportProviderClient.java', + 'file-service', 'iam-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/IamQualificationClient.java', + 'file-service', 'iam-service', ['REQUEST'], 'WRITE', 'STATE_CONVERGENT', '资质登记/替换按客户与资质类型收敛,无通用幂等头'), + feign('file/src/main/java/com/baiyue/file/client/OrderExportProviderClient.java', + 'file-service', 'order-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/WarehouseExportProviderClient.java', + 'file-service', 'warehouse-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/WeightOcrClient.java', + 'file-service', 'integration-service', ['REQUEST'], 'READ', 'NONE', '上传线程内调用 OCR;第三方调用不可假定免费幂等'), + + feign('finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java', + 'finance-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '元数据 GET 可重复,凭证上传无幂等键'), + feign('finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java', + 'finance-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取整柜成本分摊快照'), + feign('finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java', + 'finance-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ_WRITE', 'MIXED', + '事件消费回读快照;平台作废命令携带 Idempotency-Key 与请求哈希'), + feign('finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java', + 'finance-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户主档快照'), + feign('finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java', + 'finance-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取并校验供应商快照'), + + feign('firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java', + 'firstleg-service', 'integration-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '船节点定时任务查询,不属于用户请求'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java', + 'firstleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '装柜/船节点 Outbox 按 eventId 投递'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '校验付款凭证元数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/FinanceFeeSourceEventClient.java', + 'firstleg-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '费用 Outbox 按事件幂等键投递'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java', + 'firstleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户名称与状态快照'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据/下载可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取装柜证据元数据/内容'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '图片上传/作废非安全重放,查询可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java', + 'firstleg-service', 'order-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取订单货物/来源快照'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java', + 'firstleg-service', 'order-service', ['REQUEST'], 'WRITE', 'IDEMPOTENCY_KEY', 'PDA 结算拆单携带 idempotencyKey/operationId'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '价目文件上传无幂等键,读取可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookWarehouseClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库/渠道主数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java', + 'firstleg-service', 'print-service', ['REQUEST'], 'READ', 'SAFE_READ', '无状态 PDF 渲染,POST 为计算型读取'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/TruckAppointmentFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '附件上传无幂等键,元数据读取可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库主数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '查询可重复;预占/选择释放/转移/结算使用业务幂等键,普通确认/释放按状态收敛'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取供应商快照'), + feign('firstleg/src/main/java/com/baiyue/firstleg/customs/client/CustomsFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '报关文件上传无幂等键,读取可重复'), + + feign('iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java', + 'iam-service', 'auth-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '账号查询可重复,写操作按账号/命令业务键收敛'), + feign('iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', + 'iam-service', 'firstleg-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), + feign('iam/src/main/java/com/baiyue/iam/client/OverseasStorageFeeClient.java', + 'iam-service', 'warehouse-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), + feign('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java', + 'iam-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全 Outbox 按 eventId 投递'), + + feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java', + 'lastleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '卸柜完成 Outbox 按 eventId 投递'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '报价创建时读取国内合作仓快照'), + + feign('order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java', + 'order-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ_WRITE', 'BUSINESS_KEY', + '列表查询/缺失修复在线执行;审核建单由 Outbox 投递,按 orderId/inboundCode 收敛'), + feign('order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java', + 'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 校验计价渠道,不写履约状态'), + feign('order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java', + 'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 查询统一订单投影'), + feign('order/src/main/java/com/baiyue/order/client/ProductImageClient.java', + 'order-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取商品图片元数据'), + feign('order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java', + 'order-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户下单资格'), + feign('order/src/main/java/com/baiyue/order/client/WarehouseClient.java', + 'order-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库主数据'), + + http('common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java', + 'common-audit', 'audit-service', ['INFRASTRUCTURE'], 'WRITE', 'NONE', '同步审计投递失败当前仅记录告警'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java', + 'event-bridge-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', + 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java', + 'event-bridge-service', 'overseas-security-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'), + http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java', + 'gateway-overseas', 'overseas-security-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'), + http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', + 'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', + 'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '查询可重复;写操作按 business/reservation key 或状态收敛'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', + 'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'IDEMPOTENCY_KEY', '托盘变更/补偿使用 operationKey 与请求哈希'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'), + http('integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java', + 'integration-service', 'external-evergreen', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java', + 'integration-service', 'external-hede', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java', + 'integration-service', 'external-matson', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java', + 'integration-service', 'external-sml', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java', + 'integration-service', 'external-zim', ['EXTERNAL'], 'READ', 'SAFE_READ', '令牌获取与轨迹查询不改变平台业务事实'), + http('integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java', + 'integration-service', 'external-baidu-ocr', ['EXTERNAL'], 'READ', 'NONE', 'OCR 可能计费,不由 HTTP 层自动重试') +]) + +export const USER_ENTRIES = Object.freeze([ + { name: '认证 API', service: 'auth-service' }, + { name: 'IAM API', service: 'iam-service' }, + { name: '订单 API', service: 'order-service' }, + { name: '头程 API/PDA', service: 'firstleg-service' }, + { name: '财务 API', service: 'finance-service' }, + { name: '文件 API', service: 'file-service' }, + { name: '海外尾程 API/PDA', service: 'lastleg-service' }, + { name: '国内仓储 API', service: 'warehouse-service' }, + { name: '海外仓储 API', service: 'warehouse-overseas-service' } +]) + +export function graphEdges(context) { + return [...new Set(SYNCHRONOUS_CLIENTS + .filter(client => context === 'ALL' || client.contexts.includes(context)) + .map(client => `${client.source}->${client.target}`))].sort() +} + +export function stronglyConnectedComponents(edgeNames) { + const adjacency = new Map() + for (const edge of edgeNames) { + const [from, to] = edge.split('->') + if (!adjacency.has(from)) adjacency.set(from, new Set()) + if (!adjacency.has(to)) adjacency.set(to, new Set()) + adjacency.get(from).add(to) + } + let index = 0 + const indexes = new Map() + const lowLinks = new Map() + const stack = [] + const onStack = new Set() + const components = [] + function connect(node) { + indexes.set(node, index) + lowLinks.set(node, index++) + stack.push(node) + onStack.add(node) + for (const target of adjacency.get(node)) { + if (!indexes.has(target)) { + connect(target) + lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(target))) + } else if (onStack.has(target)) lowLinks.set(node, Math.min(lowLinks.get(node), indexes.get(target))) + } + if (lowLinks.get(node) !== indexes.get(node)) return + const component = [] + let current + do { + current = stack.pop() + onStack.delete(current) + component.push(current) + } while (current !== node) + components.push(component.sort()) + } + for (const node of adjacency.keys()) if (!indexes.has(node)) connect(node) + return components.filter(component => component.length > 1) + .sort((left, right) => left.join().localeCompare(right.join())) +} + +export function longestSimplePaths(entryService, edgeNames = graphEdges('REQUEST')) { + const adjacency = new Map() + for (const edge of edgeNames) { + const [from, to] = edge.split('->') + if (!adjacency.has(from)) adjacency.set(from, new Set()) + adjacency.get(from).add(to) + } + let longest = [] + function visit(node, path) { + const targets = [...(adjacency.get(node) ?? [])].sort() + let extended = false + for (const target of targets) { + if (path.includes(target)) continue + extended = true + visit(target, [...path, target]) + } + if (extended) return + if (longest.length === 0 || path.length > longest[0].length) longest = [path] + else if (path.length === longest[0].length) longest.push(path) + } + visit(entryService, [entryService]) + return longest.sort((left, right) => left.join('->').localeCompare(right.join('->'))) +} diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs new file mode 100644 index 000000000..a36533f8c --- /dev/null +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -0,0 +1,245 @@ +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', 'overseas-security-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->firstleg-service', + 'iam-service->warehouse-service', + 'lastleg-service->file-service', + 'lastleg-service->warehouse-overseas-service', + 'lastleg-service->warehouse-service', + 'order-service->file-service', + 'order-service->firstleg-service', + 'order-service->iam-service', + 'order-service->warehouse-service' +] + +const expectedBackgroundEdges = [ + 'event-bridge-service->firstleg-service', + 'event-bridge-service->lastleg-service', + 'event-bridge-service->overseas-security-service', + 'file-service->finance-service', + 'file-service->firstleg-service', + 'file-service->iam-service', + 'file-service->order-service', + 'file-service->warehouse-service', + 'finance-service->firstleg-service', + 'firstleg-service->event-bridge-service', + 'firstleg-service->finance-service', + 'firstleg-service->integration-service', + 'iam-service->event-bridge-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 44 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, 44) + assert.deepEqual(await discoverFeignEdges(), reviewed) +}) + +test('all 16 raw HTTP transports have reviewed context, access and idempotency metadata', async () => { + const discovered = [] + for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { + for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) { + const source = await readFile(file, 'utf8') + if (!rawHttpPattern.test(source)) continue + if (!/(?:private|final)\s+(?:RestClient|WebClient|HttpClient)\b|HttpClient\s+client\s*=/.test(source)) continue + discovered.push(path.relative(root, file).replaceAll('\\', '/')) + } + } + const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') + .map(client => client.path).sort() + assert.equal(reviewed.length, 16) + assert.deepEqual(discovered.sort(), reviewed) +}) + +test('every reviewed synchronous client has a complete and exclusive executable classification', () => { + assert.equal(new Set(SYNCHRONOUS_CLIENTS.map(client => client.id)).size, SYNCHRONOUS_CLIENTS.length) + for (const client of SYNCHRONOUS_CLIENTS) { + assert.ok(client.path && client.source && client.target && client.note, `${client.id} metadata is incomplete`) + assert.ok(client.contexts.length > 0, `${client.id} has no execution context`) + assert.equal(new Set(client.contexts).size, client.contexts.length, `${client.id} repeats a context`) + for (const context of client.contexts) assert.ok(CONTEXTS.includes(context), `${client.id} has invalid context ${context}`) + assert.ok(ACCESS_MODES.includes(client.access), `${client.id} has invalid access ${client.access}`) + assert.ok(IDEMPOTENCY.includes(client.idempotency), `${client.id} has invalid idempotency ${client.idempotency}`) + } +}) + +test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { + const clients = new Map([ + ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', 'getUnloadPhotoFile'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', 'getRelabelPhotoFile'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', 'getWarehouseResource'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', 'getPalletPhysical'] + ]) + for (const [file, propertyAccessor] of clients) { + const source = await readFile(path.join(root, file), 'utf8') + assert.match(source, /restClientFactory\.build\(restClientBuilder, baseUrl,/) + assert.match(source, new RegExp(`restClientProperties\\.${propertyAccessor}\\(\\)`)) + assert.doesNotMatch(source, /RetryTemplate|Retryer|@Retryable|\.retry\(|fallbackFactory|fallback\s*=/i) + } + + const factory = await readFile(path.join(root, + 'lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java'), 'utf8') + assert.match(factory, /connectTimeout\(timeouts\.getConnectTimeout\(\)\)/) + assert.match(factory, /setReadTimeout\(timeouts\.getReadTimeout\(\)\)/) + assert.doesNotMatch(factory, /RetryTemplate|Retryer|@Retryable|\.retry\(|fallbackFactory|fallback\s*=/i) + + for (const configFile of [ + 'lastleg-service/src/main/resources/application.yml', + 'config/nacos/overseas/lastleg-service.yml' + ]) { + const source = await readFile(path.join(root, configFile), 'utf8') + for (const key of ['unload-photo-file', 'relabel-photo-file', 'warehouse-resource', 'pallet-physical']) { + assert.match(source, new RegExp( + `${key}:\\s*\\n\\s+connect-timeout:\\s*\\$\\{[^}]+:[1-9][0-9]*(?:ms|s)\\}` + + `\\s*\\n\\s+read-timeout:\\s*\\$\\{[^}]+:[1-9][0-9]*(?:ms|s)\\}`)) + } + } +}) + +test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', () => { + assert.deepEqual(graphEdges('REQUEST'), expectedOnlineEdges) + assert.deepEqual(graphEdges('BACKGROUND'), expectedBackgroundEdges) + assert.deepEqual(graphEdges('INFRASTRUCTURE'), [ + 'common-audit->audit-service', + 'gateway-overseas->auth-service', + 'gateway-overseas->overseas-security-service' + ]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [[ + 'auth-service', 'file-service', 'firstleg-service', 'iam-service', 'order-service' + ]]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[ + 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-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 edges never become online business dependencies', () => { + assert.ok(!graphEdges('REQUEST').some(edge => edge.includes('overseas-security-service'))) + assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->overseas-security-service')) + assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->overseas-security-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: 5, paths: [ + 'auth-service->iam-service->firstleg-service->order-service->file-service->integration-service' + ] }, + 'iam-service': { hops: 4, paths: [ + 'iam-service->firstleg-service->order-service->file-service->integration-service' + ] }, + 'order-service': { hops: 4, paths: [ + 'order-service->file-service->iam-service->firstleg-service->print-service', + 'order-service->file-service->iam-service->firstleg-service->warehouse-service', + 'order-service->firstleg-service->file-service->iam-service->auth-service', + 'order-service->firstleg-service->file-service->iam-service->warehouse-service', + 'order-service->iam-service->firstleg-service->file-service->integration-service' + ] }, + 'firstleg-service': { hops: 4, paths: [ + 'firstleg-service->order-service->file-service->iam-service->auth-service', + 'firstleg-service->order-service->file-service->iam-service->warehouse-service' + ] }, + 'finance-service': { hops: 5, paths: [ + 'finance-service->file-service->iam-service->firstleg-service->order-service->warehouse-service', + 'finance-service->firstleg-service->order-service->file-service->iam-service->auth-service', + 'finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service', + 'finance-service->iam-service->firstleg-service->order-service->file-service->integration-service' + ] }, + 'file-service': { hops: 4, paths: [ + 'file-service->iam-service->firstleg-service->order-service->warehouse-service' + ] }, + 'lastleg-service': { hops: 5, paths: [ + 'lastleg-service->file-service->iam-service->firstleg-service->order-service->warehouse-service' + ] }, + 'warehouse-service': { hops: 0, paths: ['warehouse-service'] }, + 'warehouse-overseas-service': { hops: 0, paths: ['warehouse-overseas-service'] } + }) + for (const entry of USER_ENTRIES) { + const result = summary[entry.service] + if (result.hops > 2) assert.ok(result.paths.every(path => path.split('->').length > 3)) + } +}) + +test('shared Feign timeout defaults and long-running export overrides do not drift silently', async () => { + for (const profile of ['local', 'cn', 'overseas']) { + const source = await readFile(path.join(root, 'config', 'nacos', profile, 'baiyue-common.yml'), 'utf8') + assert.match(source, /connect-timeout:\s*3000/) + assert.match(source, /read-timeout:\s*5000/) + } + const fileConfig = await readFile(path.join(root, 'file', 'src', 'main', 'resources', 'application.yml'), 'utf8') + assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 5) +}) diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 85f55937c..22eaf133c 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -12,6 +12,14 @@ const ciMavenSettings = (await readFile(new URL('../deploy/maven/settings-ci.xml .replaceAll('\r\n', '\n') const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile', import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') +const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') +const overseasCompose = (await readFile(new URL('../deploy/docker-compose.overseas.yml', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') + +test('service pipeline keeps boolean operators on parseable Groovy continuation lines', () => { + assert.doesNotMatch(pipeline, /^\s*(?:&&|\|\|)/m) +}) test('test deployments compile packages without rerunning the full unit test suite', () => { assert.match(pipeline, /params\.ENVIRONMENT == 'test' \|\| params\.SKIP_TESTS/) @@ -30,8 +38,9 @@ test('coordinator jobs present their own deployment environment as the default c }) test('production deployments cannot skip tests', () => { - assert.match(pipeline, /params\.ENVIRONMENT == 'production' && params\.SKIP_TESTS/) - assert.match(pipeline, /error\('生产环境禁止跳过测试'\)/) + assert.match(coordinatorPipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS/) + assert.match(coordinatorPipeline, /error\('生产环境禁止跳过测试'\)/) + assert.match(pipeline, /生产环境手工构建禁止跳过测试/) }) test('Java builds use the shared builder pool and serialize incremental target deployments', () => { @@ -51,21 +60,40 @@ test('Java builds use the shared builder pool and serialize incremental target d assert.match(directDeployScript, /--pull never/) }) +test('production only accepts skipped downstream tests for a coordinator-pinned commit', () => { + assert.match(pipeline, /boolean coordinatorPinnedCommit = params\.SOURCE_COMMIT\?\.trim\(\) as boolean/) + assert.match(pipeline, /params\.ENVIRONMENT == 'production' && params\.SKIP_TESTS && !coordinatorPinnedCommit/) + assert.match(pipeline, /生产环境手工构建禁止跳过测试/) +}) + test('development and test deployment locks are acquired before target executors', () => { const directDeployStage = pipeline.slice( pipeline.indexOf("stage('部署开发或测试环境')"), pipeline.indexOf("stage('部署单个服务')") ) + const serviceLockIndex = directDeployStage.indexOf('lock(resource: serviceDeployResource') const lockIndex = directDeployStage.indexOf('lock(label: deploySlotLabel') const nodeIndex = directDeployStage.indexOf('node(targetLabel)') assert.match(directDeployStage, /agent none/) assert.match(directDeployStage, /String targetLabel = params\.ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) + assert.match(directDeployStage, /String serviceDeployResource = "firstleg-\$\{params\.ENVIRONMENT\}-\$\{env\.RESOLVED_SERVICE\}-deploy"/) + assert.ok(serviceLockIndex >= 0, 'per-service deployment lock must exist') assert.ok(lockIndex >= 0, 'deployment slot lock must exist') assert.ok(nodeIndex >= 0, 'target node allocation must exist') + assert.ok(serviceLockIndex < lockIndex, 'per-service lock must be acquired before the shared deployment slot') assert.ok(lockIndex < nodeIndex, 'deployment lock must be acquired before target node allocation') }) +test('development exposes gateways for remote acceptance and keeps internal services on loopback', () => { + 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, /gateway\|gateway-overseas\) PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) + assert.match(directDeployScript, /\*\) PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) + assert.match(directDeployScript, /gateway\|gateway-overseas\) export PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) + assert.match(directDeployScript, /\*\) export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) +}) + test('parallel image publications isolate registry credentials per Jenkins job', () => { const imageStage = pipeline.slice( pipeline.indexOf("stage('构建并发布单服务镜像')"), @@ -148,3 +176,15 @@ test('direct deployments wait for health and automatically restore the previous assert.match(directDeployScript, /自动回滚失败/) assert.match(directDeployScript, /return 1/) }) + +test('one event bridge build deploys both regional instances in development and test', () => { + assert.match(pipeline, /choice\(name: 'EVENT_BRIDGE_REGION', choices: \['BOTH', 'CN', 'OVERSEAS', 'SELECT'\]/) + assert.match(pipeline, /\['BOTH', 'SELECT'\]\.contains\(params\.EVENT_BRIDGE_REGION\)/) + assert.match(pipeline, /\? \['CN', 'OVERSEAS'\]/) + assert.match(pipeline, /bridgeRegions\.each \{ bridgeRegion ->/) + assert.match(pipeline, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) + assert.match(pipeline, /部署事件桥实例:/) + assert.match(pipeline, /imageTag\?\.endsWith\('-both'\)/) + assert.match(pipeline, /imageTag\.substring\(0, imageTag\.length\(\) - '-both'\.length\(\)\)/) + assert.match(pipeline, /env\.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges \? 'true' : 'false'/) +}) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java new file mode 100644 index 000000000..37ffad011 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java @@ -0,0 +1,39 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.warehouse.dto.PalletTransitionRequests; +import com.baiyue.warehouse.dto.PalletTransitionResponse; +import com.baiyue.warehouse.security.InternalServiceTokenGuard; +import com.baiyue.warehouse.service.WarehousePalletTransitionService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/internal/v1/pda-pallet-transitions") +public class InternalPdaPalletTransitionController { + @Resource private InternalServiceTokenGuard tokenGuard; + @Resource private WarehousePalletTransitionService service; + + @PostMapping("/{palletId}") + public ApiResponse apply( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long palletId, @Valid @RequestBody PalletTransitionRequests.Apply request) { + tokenGuard.verify(token); + return ApiResponse.success(service.apply(palletId, request)); + } + + @PostMapping("/{palletId}/{operationKey}/compensate") + public ApiResponse compensate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long palletId, @PathVariable String operationKey, + @Valid @RequestBody PalletTransitionRequests.Compensate request) { + tokenGuard.verify(token); + return ApiResponse.success(service.compensate(palletId, operationKey, request)); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java index 5cceb91f5..1d651fc2d 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java @@ -3,6 +3,7 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.warehouse.dto.DropshipPalletRequests; import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.dto.WarehousePalletResolveResponse; import com.baiyue.warehouse.security.InternalServiceTokenGuard; import com.baiyue.warehouse.service.WarehouseResourceService; import jakarta.annotation.Resource; @@ -22,4 +23,13 @@ public class InternalWarehousePalletController { tokenGuard.verify(token); return ApiResponse.success(service.ensureDropshipPallet(request.customerId(), request.warehouseId(), request.businessId())); } + + @GetMapping("/resolve/{palletCode}") + public ApiResponse resolve( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable String palletCode) { + tokenGuard.verify(token); + return ApiResponse.success(service.resolvePublicPallet(palletCode)); + } + } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java index 1e9c74f77..94b636b8e 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java @@ -4,16 +4,21 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; import com.baiyue.warehouse.dto.WarehousePalletScrapResponse; +import com.baiyue.warehouse.dto.SharedWarehouseReference; +import com.baiyue.warehouse.dto.WarehouseLocationResolveResponse; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.security.InternalServiceTokenGuard; import com.baiyue.warehouse.service.WarehouseResourceLockService; import com.baiyue.warehouse.service.WarehouseResourceService; +import com.baiyue.warehouse.service.WarehouseApplicationService; import jakarta.validation.Valid; import jakarta.annotation.Resource; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -22,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController; public class InternalWarehouseResourceLockController { @Resource private WarehouseResourceLockService service; @Resource private WarehouseResourceService resourceService; + @Resource private WarehouseApplicationService warehouses; @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping @@ -67,4 +73,28 @@ public class InternalWarehouseResourceLockController { return ApiResponse.success(resourceService.ensureDefaultVirtualLocation( request.customerId(), request.warehouseId())); } + + @GetMapping("/shared-warehouses/{warehouseId}") + public ApiResponse sharedWarehouse( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long warehouseId) { + tokenGuard.verify(token); + return ApiResponse.success(warehouses.activeSharedReference(warehouseId)); + } + + @GetMapping("/locations/resolve/{locationCode}") + public ApiResponse resolveLocation( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam long warehouseId, @PathVariable String locationCode) { + tokenGuard.verify(token); + return ApiResponse.success(resourceService.resolvePublicLocation(warehouseId, locationCode)); + } + + @GetMapping("/locations/{locationId}") + public ApiResponse location( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam long warehouseId, @PathVariable long locationId) { + tokenGuard.verify(token); + return ApiResponse.success(resourceService.resolvePublicLocation(warehouseId, locationId)); + } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java index ab4d06ec6..29d409b18 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java @@ -34,7 +34,7 @@ public class MerchantWarehouseController { @Operation( summary = "List available warehouses / 查询可用仓库", - description = "Tenant comes only from the authenticated JWT; clients cannot override it. / 租户仅从认证 JWT 获取,客户端不可覆盖。" + description = "Customer scope comes only from the authenticated JWT; any customerId query value is ignored. / 客户范围仅从认证 JWT 获取,查询中的 customerId 不生效。" ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index 9467901d7..e4b27dfbc 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -91,7 +91,8 @@ public class PlatformWarehouseController { @Operation( summary = "List warehouses / 查询仓库", - description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns all warehouses with optional filters." + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + + ". Returns warehouses across customers, or only the optional customerId scope when supplied." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @@ -163,8 +164,8 @@ public class PlatformWarehouseController { } private static void requireTenant(Long customerId) { - if (customerId == null || customerId <= 0) { - throw new BizException(ResultCode.BAD_REQUEST, "Target tenant is required / 必须指定有效目标租户"); + if (customerId == null || customerId < 0) { + throw new BizException(ResultCode.BAD_REQUEST, "Target customer scope is required / 必须指定有效客户或平台共享范围"); } } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java index 7ede1b836..be951ca52 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -87,7 +87,8 @@ public class PlatformWarehouseResourceController { } @PostMapping("/pallets") - @Operation(summary = "Create a warehouse pallet", description = "Platform access required") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_CREATE + "')") + @Operation(summary = "Create a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_CREATE) public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, @Valid @RequestBody PalletRequests.SaveRequest request) { diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java index b5c6d8491..d209bfb06 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -23,11 +23,19 @@ public final class PalletRequests { @NotBlank String status, Long locationId, @Size(max = 500) String remark, - @Size(max = 128) String fbaWarehouseCode + @Size(max = 128) String fbaWarehouseCode, + @Min(0) Integer version ) { public SaveRequest(String palletCode, String palletType, BigDecimal capacityCbm, BigDecimal unitPrice, String status, Long locationId, String remark) { - this(palletCode, palletType, capacityCbm, unitPrice, status, locationId, remark, null); + this(palletCode, palletType, capacityCbm, unitPrice, status, locationId, remark, null, null); + } + + public SaveRequest(String palletCode, String palletType, BigDecimal capacityCbm, + BigDecimal unitPrice, String status, Long locationId, String remark, + String fbaWarehouseCode) { + this(palletCode, palletType, capacityCbm, unitPrice, status, locationId, remark, + fbaWarehouseCode, null); } public SaveRequest { diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java new file mode 100644 index 000000000..9d8bdc683 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java @@ -0,0 +1,20 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public final class PalletTransitionRequests { + private PalletTransitionRequests() { } + + public record Apply(@Positive long warehouseId, + @PositiveOrZero int expectedVersion, + @NotBlank @Size(max = 20) String targetStatus, + @NotNull @Positive Long targetLocationId, + @NotBlank @Size(max = 128) String operationKey, + @Size(max = 500) String reason) { } + + public record Compensate(@Positive long warehouseId) { } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java new file mode 100644 index 000000000..c3e159661 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java @@ -0,0 +1,16 @@ +package com.baiyue.warehouse.dto; + +public record PalletTransitionResponse( + String operationKey, + long palletId, + String palletCode, + long warehouseId, + String beforeStatus, + String afterStatus, + long beforeLocationId, + long afterLocationId, + int beforeVersion, + int afterVersion, + String operationStatus, + boolean replayed +) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java new file mode 100644 index 000000000..aa83148fd --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java @@ -0,0 +1,4 @@ +package com.baiyue.warehouse.dto; + +public record SharedWarehouseReference(long warehouseId, String warehouseCode, + String warehouseName, String status) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseLocationResolveResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseLocationResolveResponse.java new file mode 100644 index 000000000..571c15925 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseLocationResolveResponse.java @@ -0,0 +1,6 @@ +package com.baiyue.warehouse.dto; + +import java.math.BigDecimal; + +public record WarehouseLocationResolveResponse(long locationId, String locationCode, String locationType, + BigDecimal maxLoadKg, String status) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java new file mode 100644 index 000000000..be3e2610c --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java @@ -0,0 +1,12 @@ +package com.baiyue.warehouse.dto; + +public record WarehousePalletResolveResponse( + long palletId, + String palletCode, + long warehouseId, + long locationId, + String status, + String fbaWarehouseCode, + int version +) { +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java index 034dde3c6..d762b3a30 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -94,6 +95,8 @@ public final class WarehouseRequests { @Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件") public record QueryRequest( + @Schema(description = "Optional target customer ID for platform queries; merchant queries always use the authenticated customer / 平台查询可选目标客户 ID,商户查询始终使用认证客户", example = "10001", nullable = true) + @PositiveOrZero Long customerId, @Schema(description = "Page number, starting at 1 / 页码,从 1 开始", example = "1", defaultValue = "1", minimum = "1") @Min(1) long page, @Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200") diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java new file mode 100644 index 000000000..f9bfbc36e --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java @@ -0,0 +1,29 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("wh_pallet_operation") +public class WarehousePalletOperationEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String operationKey; + private String requestHash; + private Long warehouseId; + private Long palletId; + private String palletCode; + private String beforeStatus; + private String afterStatus; + private Long beforeLocationId; + private Long afterLocationId; + private Integer beforeVersion; + private Integer afterVersion; + private String operationStatus; + private String reason; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java index 5e2f5ca0d..fb225f1f0 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java @@ -3,5 +3,4 @@ package com.baiyue.warehouse.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.warehouse.entity.WarehousePalletEntity; -public interface WarehousePalletMapper extends BaseMapper { -} +public interface WarehousePalletMapper extends BaseMapper { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java new file mode 100644 index 000000000..751a848c0 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java @@ -0,0 +1,32 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehousePalletOperationEntity; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +public interface WarehousePalletTransitionMapper extends BaseMapper { + @Select("SELECT * FROM wh_pallet_operation WHERE operation_key=#{operationKey} LIMIT 1") + WarehousePalletOperationEntity findOperation(@Param("operationKey") String operationKey); + + @Select("SELECT * FROM wh_pallet_operation WHERE operation_key=#{operationKey} LIMIT 1 FOR UPDATE") + WarehousePalletOperationEntity lockOperation(@Param("operationKey") String operationKey); + + @Select("SELECT * FROM wh_pallet WHERE id=#{palletId} AND customer_id=0 " + + "AND warehouse_id=#{warehouseId} AND deleted=0 LIMIT 1 FOR UPDATE") + WarehousePalletEntity lockPublicPallet(@Param("warehouseId") long warehouseId, + @Param("palletId") long palletId); + + @Select("SELECT COUNT(*) FROM wh_location WHERE id=#{locationId} AND customer_id=0 " + + "AND warehouse_id=#{warehouseId} AND status<>'INACTIVE' AND deleted=0") + long countActiveLocation(@Param("warehouseId") long warehouseId, @Param("locationId") long locationId); + + @Update("UPDATE wh_pallet SET status=#{targetStatus},location_id=#{targetLocationId},version=version+1," + + "updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{palletId} AND customer_id=0 " + + "AND warehouse_id=#{warehouseId} AND version=#{expectedVersion} AND status<>'SCRAPPED' AND deleted=0") + int transition(@Param("warehouseId") long warehouseId, @Param("palletId") long palletId, + @Param("targetStatus") String targetStatus, @Param("targetLocationId") long targetLocationId, + @Param("expectedVersion") int expectedVersion); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java index 5e0bb2ca0..cfb36643f 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -87,6 +87,7 @@ public class WarehouseRepository { public PageResult pageAll(WarehouseRequests.QueryRequest query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(query.customerId() != null, WarehouseEntity::getCustomerId, query.customerId()) .eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status()) .eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode()) .orderByDesc(WarehouseEntity::getCreatedAt); @@ -94,6 +95,7 @@ public class WarehouseRepository { if (StringUtils.hasText(query.capabilityCode())) { List ids = capabilities.selectList(new LambdaQueryWrapper() .select(ServiceCapabilityEntity::getWarehouseId) + .eq(query.customerId() != null, ServiceCapabilityEntity::getCustomerId, query.customerId()) .eq(ServiceCapabilityEntity::getCapabilityCode, query.capabilityCode()) .eq(ServiceCapabilityEntity::getEnabled, true)) .stream() diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java index cdd5dc0f0..7603cd9e6 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -17,6 +17,8 @@ import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; import jakarta.annotation.Resource; +import java.util.List; + @Repository public class WarehouseResourceRepository { @Resource private WarehouseMapper warehouses; @@ -124,8 +126,8 @@ public class WarehouseResourceRepository { pallets.insert(entity); } - public void updatePallet(WarehousePalletEntity entity) { - pallets.updateById(entity); + public int updatePallet(WarehousePalletEntity entity) { + return pallets.updateById(entity); } public WarehousePalletEntity findPallet(long customerId, long warehouseId, long id) { @@ -135,6 +137,13 @@ public class WarehouseResourceRepository { .eq(WarehousePalletEntity::getId, id)); } + public List findPublicPalletsByCode(String palletCode) { + return pallets.selectList(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, 0L) + .eq(WarehousePalletEntity::getPalletCode, palletCode) + .orderByAsc(WarehousePalletEntity::getId)); + } + public boolean palletCodeExists(long customerId, long warehouseId, String code, Long excludedId) { return pallets.selectCount(new LambdaQueryWrapper() .eq(WarehousePalletEntity::getCustomerId, customerId) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java index 3abc3cb97..89b7ded73 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.dto.SharedWarehouseReference; import com.baiyue.warehouse.entity.ServiceCapabilityEntity; import com.baiyue.warehouse.entity.WarehouseAddressEntity; import com.baiyue.warehouse.entity.WarehouseEntity; @@ -36,6 +37,7 @@ public class WarehouseApplicationService { captureBefore = false ) public Long create(WarehouseRequests.SaveRequest request) { + validateSharedWarehouse(request); ensureCodeUnique(request.customerId(), request.warehouseCode(), null); WarehouseEntity warehouse = toEntity(request); repository.insertWarehouse(warehouse); @@ -72,6 +74,7 @@ public class WarehouseApplicationService { if (request.customerId() == null || customerId != request.customerId()) { throw new BizException(ResultCode.FORBIDDEN, "租户不匹配"); } + validateSharedWarehouse(request); WarehouseEntity old = owned(customerId, id); ensureCodeUnique(customerId, request.warehouseCode(), id); @@ -98,6 +101,29 @@ public class WarehouseApplicationService { return new WarehouseDetailResponse(warehouse, addresses, capabilities); } + public SharedWarehouseReference activeSharedReference(long warehouseId) { + WarehouseEntity warehouse = repository.findOwned(0L, warehouseId); + boolean receiving = warehouse != null && repository.findCapabilities(0L, warehouseId).stream() + .anyMatch(value -> "RECEIVING".equals(value.getCapabilityCode()) && Boolean.TRUE.equals(value.getEnabled())); + if (warehouse == null || !"ACTIVE".equals(warehouse.getStatus()) + || !"OVERSEAS".equals(warehouse.getWarehouseType()) || !receiving) { + throw new BizException(ResultCode.NOT_FOUND, "Active shared overseas warehouse not found"); + } + return new SharedWarehouseReference(warehouse.getId(), warehouse.getWarehouseCode(), + warehouse.getWarehouseName(), warehouse.getStatus()); + } + + private void validateSharedWarehouse(WarehouseRequests.SaveRequest request) { + if (request.customerId() == null || request.customerId() != 0L) return; + boolean receiving = request.capabilities() != null && request.capabilities().stream() + .anyMatch(value -> "RECEIVING".equals(value.capabilityCode()) + && !Boolean.FALSE.equals(value.enabled())); + if (!"OVERSEAS".equals(request.warehouseType()) || !receiving) { + throw new BizException(ResultCode.BAD_REQUEST, + "Shared physical warehouse must be OVERSEAS with RECEIVING enabled"); + } + } + @Transactional @BusinessAudit( event = "WAREHOUSE_STATUS_CHANGE", diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java new file mode 100644 index 000000000..bf2782ec6 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java @@ -0,0 +1,106 @@ +package com.baiyue.warehouse.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.PalletTransitionRequests; +import com.baiyue.warehouse.dto.PalletTransitionResponse; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehousePalletOperationEntity; +import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.Set; + +@Service +public class WarehousePalletTransitionService { + private static final Set MUTABLE_STATUSES = Set.of("IDLE", "OCCUPIED"); + @Resource private WarehousePalletTransitionMapper mapper; + + @Transactional + public PalletTransitionResponse apply(long palletId, PalletTransitionRequests.Apply request) { + String key = request.operationKey().trim(); + String status = request.targetStatus().trim().toUpperCase(); + if (!MUTABLE_STATUSES.contains(status)) { + throw new BizException(ResultCode.BAD_REQUEST, "托盘作业状态只允许 IDLE 或 OCCUPIED"); + } + String requestHash = hash(request.warehouseId() + "|" + palletId + "|" + request.expectedVersion() + + "|" + status + "|" + request.targetLocationId()); + WarehousePalletOperationEntity existing = mapper.findOperation(key); + if (existing != null) return replay(existing, requestHash); + + WarehousePalletEntity pallet = mapper.lockPublicPallet(request.warehouseId(), palletId); + if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "当前冻结仓内托盘不存在"); + int version = pallet.getVersion() == null ? 0 : pallet.getVersion(); + if (version != request.expectedVersion()) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码"); + } + if (mapper.countActiveLocation(request.warehouseId(), request.targetLocationId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "目标库位无效或已停用"); + } + if (pallet.getLocationId() == null) { + throw new BizException(ResultCode.CONFLICT, "托盘没有有效库位,不能执行库内作业"); + } + if (mapper.transition(request.warehouseId(), palletId, status, request.targetLocationId(), version) != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码"); + } + WarehousePalletOperationEntity operation = new WarehousePalletOperationEntity(); + operation.setId(IdWorker.getId()); operation.setOperationKey(key); operation.setRequestHash(requestHash); + operation.setWarehouseId(request.warehouseId()); operation.setPalletId(palletId); + operation.setPalletCode(pallet.getPalletCode()); operation.setBeforeStatus(pallet.getStatus()); + operation.setAfterStatus(status); operation.setBeforeLocationId(pallet.getLocationId()); + operation.setAfterLocationId(request.targetLocationId()); operation.setBeforeVersion(version); + operation.setAfterVersion(version + 1); operation.setOperationStatus("APPLIED"); + operation.setReason(trim(request.reason())); operation.setCreatedAt(LocalDateTime.now()); + operation.setUpdatedAt(operation.getCreatedAt()); mapper.insert(operation); + return response(operation, false); + } + + @Transactional + public PalletTransitionResponse compensate(long palletId, String operationKey, + PalletTransitionRequests.Compensate request) { + WarehousePalletOperationEntity operation = mapper.lockOperation(operationKey); + if (operation == null || !Long.valueOf(palletId).equals(operation.getPalletId()) + || !Long.valueOf(request.warehouseId()).equals(operation.getWarehouseId())) { + throw new BizException(ResultCode.NOT_FOUND, "托盘状态变更记录不存在"); + } + if ("COMPENSATED".equals(operation.getOperationStatus())) return response(operation, true); + if (!"APPLIED".equals(operation.getOperationStatus())) { + throw new BizException(ResultCode.CONFLICT, "托盘状态变更不可补偿"); + } + if (mapper.transition(request.warehouseId(), palletId, operation.getBeforeStatus(), + operation.getBeforeLocationId(), operation.getAfterVersion()) != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘已被后续作业修改,自动补偿失败"); + } + operation.setOperationStatus("COMPENSATED"); operation.setUpdatedAt(LocalDateTime.now()); + mapper.updateById(operation); + return response(operation, false); + } + + private PalletTransitionResponse replay(WarehousePalletOperationEntity operation, String hash) { + if (!hash.equals(operation.getRequestHash())) { + throw new BizException(ResultCode.CONFLICT, "操作幂等键已被不同托盘请求使用"); + } + return response(operation, true); + } + private static PalletTransitionResponse response(WarehousePalletOperationEntity value, boolean replayed) { + return new PalletTransitionResponse(value.getOperationKey(), value.getPalletId(), value.getPalletCode(), + value.getWarehouseId(), value.getBeforeStatus(), value.getAfterStatus(), value.getBeforeLocationId(), + value.getAfterLocationId(), value.getBeforeVersion(), value.getAfterVersion(), + value.getOperationStatus(), replayed); + } + private static String hash(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index 68c90694c..796d9890e 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -5,6 +5,8 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.LocationRequests; import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.dto.WarehousePalletResolveResponse; +import com.baiyue.warehouse.dto.WarehouseLocationResolveResponse; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.entity.WarehouseEntity; @@ -152,6 +154,9 @@ public class WarehouseResourceService { @Transactional public void updatePallet(long customerId, long warehouseId, long id, PalletRequests.SaveRequest request) { WarehousePalletEntity existing = requirePallet(customerId, warehouseId, id); + if (request.version() == null || !request.version().equals(existing.getVersion())) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请刷新后重试"); + } if ("SCRAPPED".equals(existing.getStatus()) && !"SCRAPPED".equals(request.status())) { throw new BizException(ResultCode.CONFLICT, "SCRAPPED pallet cannot be reused"); } @@ -161,7 +166,10 @@ public class WarehouseResourceService { validatePallet(request, palletCode, locationId, customerId, warehouseId, id); WarehousePalletEntity entity = palletEntity(customerId, warehouseId, request, palletCode, locationId); entity.setId(existing.getId()); - repository.updatePallet(entity); + entity.setVersion(request.version()); + if (repository.updatePallet(entity) != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请刷新后重试"); + } } public WarehousePalletEntity pallet(long customerId, long warehouseId, long id) { @@ -169,6 +177,40 @@ public class WarehouseResourceService { return requirePallet(customerId, warehouseId, id); } + public WarehousePalletResolveResponse resolvePublicPallet(String palletCode) { + if (!StringUtils.hasText(palletCode)) { + throw new BizException(ResultCode.BAD_REQUEST, "托盘码不能为空"); + } + var matches = repository.findPublicPalletsByCode(palletCode.trim()); + if (matches.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "托盘码不存在"); + if (matches.size() != 1) throw new BizException(ResultCode.CONFLICT, "托盘码在多个仓库重复,无法解析"); + WarehousePalletEntity pallet = matches.get(0); + if (pallet.getLocationId() == null) throw new BizException(ResultCode.CONFLICT, "托盘未绑定库位"); + if ("SCRAPPED".equals(pallet.getStatus())) throw new BizException(ResultCode.CONFLICT, "托盘已报废"); + return new WarehousePalletResolveResponse(pallet.getId(), pallet.getPalletCode(), pallet.getWarehouseId(), + pallet.getLocationId(), pallet.getStatus(), pallet.getFbaWarehouseCode(), + pallet.getVersion() == null ? 0 : pallet.getVersion()); + } + + public WarehouseLocationResolveResponse resolvePublicLocation(long warehouseId, String locationCode) { + if (!StringUtils.hasText(locationCode)) throw new BizException(ResultCode.BAD_REQUEST, "库位码不能为空"); + WarehouseLocationEntity location = repository.findLocationByCode(PUBLIC_RESOURCE_SCOPE, warehouseId, + locationCode.trim()); + if (location == null) throw new BizException(ResultCode.NOT_FOUND, "库位码不存在或不属于当前仓库"); + return locationReference(location); + } + + public WarehouseLocationResolveResponse resolvePublicLocation(long warehouseId, long locationId) { + WarehouseLocationEntity location = repository.findLocation(PUBLIC_RESOURCE_SCOPE, warehouseId, locationId); + if (location == null) throw new BizException(ResultCode.NOT_FOUND, "库位不存在或不属于当前仓库"); + return locationReference(location); + } + + private static WarehouseLocationResolveResponse locationReference(WarehouseLocationEntity location) { + return new WarehouseLocationResolveResponse(location.getId(), location.getLocationCode(), + location.getLocationType(), location.getMaxLoadKg(), location.getStatus()); + } + public PageResult pallets(long customerId, long warehouseId, PalletRequests.QueryRequest query) { requireWarehouse(customerId, warehouseId); diff --git a/warehouse-overseas-service/src/main/resources/db/warehouse.sql b/warehouse-overseas-service/src/main/resources/db/warehouse.sql index 9eb4d8dcd..7665093d0 100644 --- a/warehouse-overseas-service/src/main/resources/db/warehouse.sql +++ b/warehouse-overseas-service/src/main/resources/db/warehouse.sql @@ -178,6 +178,20 @@ CREATE TABLE IF NOT EXISTS wh_pallet_business_binding ( UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id) ) ENGINE=InnoDB COMMENT='托盘业务绑定关系'; +CREATE TABLE IF NOT EXISTS wh_pallet_operation ( + id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键', + request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', + before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态', + before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID', + before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本', + operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿', + reason VARCHAR(500) NULL COMMENT '操作原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_operation_key (operation_key), + KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录'; + -- Every warehouse has one non-occupiable virtual location used for inventory history. INSERT INTO wh_location (id, customer_id, warehouse_id, zone_code, location_code, location_type, max_load_kg, status, remark) diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java new file mode 100644 index 000000000..2a5b57d42 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java @@ -0,0 +1,36 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.service.WarehouseApplicationService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Set; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MerchantWarehouseControllerTest { + @Test + void merchantPageIgnoresRequestedCustomerAndUsesAuthenticatedCustomer() { + WarehouseApplicationService service = mock(WarehouseApplicationService.class); + MerchantWarehouseController controller = new MerchantWarehouseController(); + ReflectionTestUtils.setField(controller, "service", service); + WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest( + 99999L, 1, 20, "US", null, "ACTIVE"); + when(service.page(10001L, query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + controller.page(merchant(10001L), query); + + verify(service).page(10001L, query); + } + + private static AuthenticatedUser merchant(long customerId) { + return new AuthenticatedUser(1L, customerId, customerId, 1L, + "merchant", "MERCHANT", "WEB", Set.of()); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java index 1206986b8..ac330716f 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java @@ -5,6 +5,8 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.security.OverseasWarehousePermissions; import com.baiyue.warehouse.service.WarehouseApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.test.util.ReflectionTestUtils; @@ -16,6 +18,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; class PlatformWarehouseControllerTest { @Test @@ -30,11 +33,11 @@ class PlatformWarehouseControllerTest { } @Test - void platformPageQueriesAllWarehousesWithoutCustomerId() { + void platformPagePassesOptionalCustomerFilterToApplicationService() { WarehouseApplicationService service = mock(WarehouseApplicationService.class); PlatformWarehouseController controller = new PlatformWarehouseController(); ReflectionTestUtils.setField(controller, "service", service); - WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "ACTIVE", null, null); + WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(10001L, 1, 20, null, null, "ACTIVE"); when(service.pageAll(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); controller.page(platform(), query); @@ -42,6 +45,38 @@ class PlatformWarehouseControllerTest { verify(service).pageAll(query); } + @Test + void platformPageOpenApiExposesOptionalCustomerScope() throws Exception { + var customerId = WarehouseRequests.QueryRequest.class.getRecordComponents()[0]; + Schema schema = WarehouseRequests.QueryRequest.class.getDeclaredMethod("customerId") + .getAnnotation(Schema.class); + Operation operation = PlatformWarehouseController.class + .getMethod("page", AuthenticatedUser.class, WarehouseRequests.QueryRequest.class) + .getAnnotation(Operation.class); + + assertEquals("customerId", customerId.getName()); + assertEquals(Long.class, customerId.getType()); + assertTrue(schema.nullable()); + assertTrue(schema.description().contains("platform")); + assertTrue(operation.description().contains("customerId")); + } + + @Test + void platformCanCreateSharedWarehouseInCustomerZeroScope() { + WarehouseApplicationService service = mock(WarehouseApplicationService.class); + PlatformWarehouseController controller = new PlatformWarehouseController(); + ReflectionTestUtils.setField(controller, "service", service); + WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( + "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", "1 Shared Ave", null, null, null); + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(0L, "SHARED-LAX", "Shared LAX", + "OVERSEAS", "US", "America/Los_Angeles", null, null, null, "ACTIVE", null, address, + List.of(new WarehouseRequests.CapabilityRequest("RECEIVING", "收货", true, null, null))); + when(service.create(request)).thenReturn(900L); + + assertEquals(900L, controller.create(platform(), request).data()); + verify(service).create(request); + } + private static AuthenticatedUser platform() { return new AuthenticatedUser(1L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of()); } diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java index 6af72807f..65588f077 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java @@ -10,7 +10,6 @@ import java.util.Map; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; class WarehouseResourcePermissionContractTest { private static final String PREFIX = "platform:overseas-warehouse:"; @@ -28,15 +27,9 @@ class WarehouseResourcePermissionContractTest { assertPermission(methods.get("deleteLocation"), PREFIX + "location:delete"); assertPermission(methods.get("pallets"), PREFIX + "pallet:list"); assertPermission(methods.get("pallet"), PREFIX + "pallet:list"); + assertPermission(methods.get("createPallet"), PREFIX + "pallet:create"); assertPermission(methods.get("updatePallet"), PREFIX + "pallet:edit"); assertPermission(methods.get("deletePallet"), PREFIX + "pallet:delete"); - - Method createPallet = Arrays.stream(PlatformWarehouseResourceController.class.getDeclaredMethods()) - .filter(method -> "createPallet".equals(method.getName())) - .findFirst() - .orElseThrow(); - assertNull(createPallet.getAnnotation(PreAuthorize.class)); - assertEquals("Platform access required", createPallet.getAnnotation(Operation.class).description()); } private static void assertPermission(Method method, String authority) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java new file mode 100644 index 000000000..821981179 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java @@ -0,0 +1,42 @@ +package com.baiyue.warehouse.repository; + +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.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.mapper.ServiceCapabilityMapper; +import com.baiyue.warehouse.mapper.WarehouseMapper; +import org.junit.jupiter.api.Test; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.springframework.test.util.ReflectionTestUtils; + +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 WarehouseRepositoryTest { + @SuppressWarnings({"unchecked", "rawtypes"}) + @Test + void platformPageAllFiltersByOptionalCustomerId() { + WarehouseMapper warehouses = mock(WarehouseMapper.class); + WarehouseRepository repository = new WarehouseRepository(); + ReflectionTestUtils.setField(repository, "warehouses", warehouses); + ReflectionTestUtils.setField(repository, "capabilities", mock(ServiceCapabilityMapper.class)); + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "warehouse-overseas-query-test"), + WarehouseEntity.class); + when(warehouses.selectPage(any(Page.class), any(LambdaQueryWrapper.class))) + .thenReturn(new Page(1, 20)); + + repository.pageAll(new WarehouseRequests.QueryRequest(10001L, 1, 20, "US", null, "ACTIVE")); + + var queryCaptor = org.mockito.ArgumentCaptor.forClass(LambdaQueryWrapper.class); + org.mockito.Mockito.verify(warehouses).selectPage(any(Page.class), queryCaptor.capture()); + LambdaQueryWrapper query = queryCaptor.getValue(); + assertThat(query.getSqlSegment()).contains("customer_id"); + assertThat(query.getParamNameValuePairs()).containsValue(10001L); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java index f59b0735b..678cdbb21 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java @@ -29,7 +29,7 @@ class SchemaChineseCommentContractTest { Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); List tables = parseAndAssert(baseline, "仓储基线 SQL"); - assertEquals(7, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移"); + assertEquals(8, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移"); parseAndAssert(incremental, "统一增量 SQL"); int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments"); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseUnifiedIncrementalContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseUnifiedIncrementalContractTest.java new file mode 100644 index 000000000..0492c6281 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseUnifiedIncrementalContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.warehouse.schema; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WarehouseUnifiedIncrementalContractTest { + + @Test + void overseasUnifiedEntryUpgradesAndValidatesPalletFbaWarehouseCode() throws Exception { + String sql = Files.readString( + Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); + + assertTrue(sql.contains("USE baiyue_lastlag_warehouse")); + assertTrue(sql.contains("ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL")); + assertTrue(sql.contains("@warehouse_pallet_fba_ready")); + assertTrue(sql.contains("wh_pallet.fba_warehouse_code migration is incomplete")); + assertTrue(sql.contains("USE baiyue_lastleg")); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index 4852efa8f..37699e071 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -3,6 +3,8 @@ package com.baiyue.warehouse.service; import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.repository.WarehouseRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,6 +17,7 @@ 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.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.verify; @@ -50,8 +53,8 @@ class WarehouseApplicationServiceTest { } @Test - void platformWarehousePageUsesGlobalRepositoryQuery() { - WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "ACTIVE", "US", null); + void platformWarehousePagePassesCustomerScopedQueryToRepository() { + WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(10001L, 1, 20, "US", null, "ACTIVE"); when(repository.pageAll(query)).thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0)); service.pageAll(query); @@ -59,6 +62,31 @@ class WarehouseApplicationServiceTest { verify(repository).pageAll(query); } + @Test + void sharedReceivingWarehouseMustBeOverseasAndResolvesCanonicalReference() { + WarehouseRequests.SaveRequest shared = sharedRequest("OVERSEAS", true); + doAnswer(call -> { ((WarehouseEntity) call.getArgument(0)).setId(900L); return null; }) + .when(repository).insertWarehouse(any()); + + assertThat(service.create(shared)).isEqualTo(900L); + + WarehouseEntity warehouse = new WarehouseEntity(); warehouse.setId(900L); warehouse.setCustomerId(0L); + warehouse.setWarehouseCode("SHARED-LAX"); warehouse.setWarehouseName("Shared LAX"); + warehouse.setWarehouseType("OVERSEAS"); warehouse.setStatus("ACTIVE"); + ServiceCapabilityEntity receiving = new ServiceCapabilityEntity(); + receiving.setCapabilityCode("RECEIVING"); receiving.setEnabled(true); + when(repository.findOwned(0L, 900L)).thenReturn(warehouse); + when(repository.findCapabilities(0L, 900L)).thenReturn(List.of(receiving)); + + assertThat(service.activeSharedReference(900L).warehouseName()).isEqualTo("Shared LAX"); + } + + @Test + void sharedWarehouseRejectsNonOverseasOrMissingReceivingCapability() { + assertThatThrownBy(() -> service.create(sharedRequest("FBA", true))).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.create(sharedRequest("OVERSEAS", false))).isInstanceOf(BizException.class); + } + private static WarehouseRequests.SaveRequest request() { WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", @@ -67,4 +95,15 @@ class WarehouseApplicationServiceTest { "OVERSEAS", "US", "America/Los_Angeles", null, null, null, "ACTIVE", null, address, List.of()); } + + private static WarehouseRequests.SaveRequest sharedRequest(String type, boolean receiving) { + WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( + "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", + "1 Logistics Ave", null, null, null); + List capabilities = receiving + ? List.of(new WarehouseRequests.CapabilityRequest("RECEIVING", "收货", true, null, null)) + : List.of(); + return new WarehouseRequests.SaveRequest(0L, "SHARED-LAX", "Shared LAX", type, + "US", "America/Los_Angeles", null, null, null, "ACTIVE", null, address, capabilities); + } } diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java new file mode 100644 index 000000000..5f998e23c --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java @@ -0,0 +1,91 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.PalletTransitionRequests; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehousePalletOperationEntity; +import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class WarehousePalletTransitionServiceTest { + @Mock private WarehousePalletTransitionMapper mapper; + private WarehousePalletTransitionService service; + + @BeforeEach void setUp() { + service = new WarehousePalletTransitionService(); + ReflectionTestUtils.setField(service, "mapper", mapper); + } + + @Test void appliesVersionedPublicPalletTransitionAndPersistsCompensationReceipt() { + WarehousePalletEntity pallet = pallet("P-1", "IDLE", 4); + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet); + when(mapper.countActiveLocation(88L, 20L)).thenReturn(1L); + when(mapper.transition(88L, 10L, "OCCUPIED", 20L, 4)).thenReturn(1); + + var response = service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "OCCUPIED", 20L, "operation-1", "assemble")); + + assertEquals("APPLIED", response.operationStatus()); + assertEquals(5, response.afterVersion()); + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletOperationEntity.class); + verify(mapper).insert(captor.capture()); + assertEquals("IDLE", captor.getValue().getBeforeStatus()); + assertEquals("OCCUPIED", captor.getValue().getAfterStatus()); + assertEquals(4, captor.getValue().getBeforeVersion()); + } + + @Test void exactReplayDoesNotMutatePhysicalPalletAgain() { + WarehousePalletOperationEntity existing = operation(); + when(mapper.findOperation("operation-1")).thenReturn(existing); + + var response = service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "OCCUPIED", 20L, "operation-1", "any changed reason")); + + assertTrue(response.replayed()); + verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt()); + } + + @Test void compensationRequiresExactAppliedVersionAndRestoresBeforeState() { + WarehousePalletOperationEntity existing = operation(); + when(mapper.lockOperation("operation-1")).thenReturn(existing); + when(mapper.transition(88L, 10L, "IDLE", 11L, 5)).thenReturn(1); + + var response = service.compensate(10L, "operation-1", new PalletTransitionRequests.Compensate(88L)); + + assertEquals("COMPENSATED", response.operationStatus()); + verify(mapper).updateById(existing); + } + + @Test void staleVersionIsRejectedWithoutPhysicalMutation() { + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "IDLE", 5)); + assertThrows(BizException.class, () -> service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "OCCUPIED", 20L, "operation-1", null))); + verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt()); + } + + private static WarehousePalletEntity pallet(String code, String status, int version) { + WarehousePalletEntity value = new WarehousePalletEntity(); value.setId(10L); value.setCustomerId(0L); + value.setWarehouseId(88L); value.setLocationId(11L); value.setPalletCode(code); + value.setStatus(status); value.setVersion(version); return value; + } + private static WarehousePalletOperationEntity operation() { + WarehousePalletOperationEntity value = new WarehousePalletOperationEntity(); value.setId(1L); + value.setOperationKey("operation-1"); + value.setRequestHash("f17b4386e6fdecbaab1d066ae4fefa47dba0687ff27361b8f92ef097d4e8072e"); + value.setWarehouseId(88L); value.setPalletId(10L); value.setPalletCode("P-1"); + value.setBeforeStatus("IDLE"); value.setAfterStatus("OCCUPIED"); value.setBeforeLocationId(11L); + value.setAfterLocationId(20L); value.setBeforeVersion(4); value.setAfterVersion(5); + value.setOperationStatus("APPLIED"); return value; + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index 005815ef1..5907572f9 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -50,6 +50,19 @@ class WarehouseResourceServiceTest { .isEqualTo(IdType.ASSIGN_ID); } + @Test + void resolvesOnlyPublicLocationInsideBoundWarehouse() { + WarehouseLocationEntity location = new WarehouseLocationEntity(); location.setId(30001L); + location.setWarehouseId(WAREHOUSE); location.setCustomerId(0L); location.setLocationCode("A-01"); + location.setLocationType("STORAGE"); location.setMaxLoadKg(new BigDecimal("1000")); location.setStatus("IDLE"); + when(repository.findLocationByCode(0L, WAREHOUSE, "A-01")).thenReturn(location); + + var result = service.resolvePublicLocation(WAREHOUSE, " A-01 "); + + assertThat(result.locationId()).isEqualTo(30001L); + assertThat(result.locationCode()).isEqualTo("A-01"); + } + @Test void createsLocationWithSnowflakeAssignedByMapper() { when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); @@ -259,10 +272,11 @@ class WarehouseResourceServiceTest { existing.setId(40002L); existing.setPalletCode("PLT-OLD"); existing.setStatus("SCRAPPED"); + existing.setVersion(0); when(repository.findPallet(TENANT, WAREHOUSE, 40002L)).thenReturn(existing); assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40002L, - new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null))) + new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null, null, 0))) .isInstanceOf(BizException.class) .hasMessageContaining("cannot be reused"); } @@ -274,17 +288,20 @@ class WarehouseResourceServiceTest { existing.setId(40003L); existing.setPalletCode("PLT-OLD"); existing.setStatus("IDLE"); + existing.setVersion(2); when(repository.findPallet(TENANT, WAREHOUSE, 40003L)).thenReturn(existing); + when(repository.updatePallet(org.mockito.ArgumentMatchers.any())).thenReturn(1); service.updatePallet(TENANT, WAREHOUSE, 40003L, new PalletRequests.SaveRequest("PLT-NEW", "WOOD", new BigDecimal("2.5000"), - new BigDecimal("25.00"), "IDLE", null, "updated", "ONT8")); + new BigDecimal("25.00"), "IDLE", null, "updated", "ONT8", 2)); ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); verify(repository).palletCodeExists(TENANT, WAREHOUSE, "PLT-NEW", 40003L); verify(repository).updatePallet(captor.capture()); assertThat(captor.getValue().getPalletCode()).isEqualTo("PLT-NEW"); assertThat(captor.getValue().getFbaWarehouseCode()).isEqualTo("ONT8"); + assertThat(captor.getValue().getVersion()).isEqualTo(2); } @Test @@ -294,17 +311,39 @@ class WarehouseResourceServiceTest { existing.setId(40004L); existing.setPalletCode("PLT-OLD"); existing.setStatus("IDLE"); + existing.setVersion(0); when(repository.findPallet(TENANT, WAREHOUSE, 40004L)).thenReturn(existing); when(repository.palletCodeExists(TENANT, WAREHOUSE, "PLT-DUP", 40004L)).thenReturn(true); assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40004L, new PalletRequests.SaveRequest("PLT-DUP", "WOOD", BigDecimal.ONE, - BigDecimal.ONE, "IDLE", null, null, "ONT8"))) + BigDecimal.ONE, "IDLE", null, null, "ONT8", 0))) .isInstanceOf(BizException.class) .hasMessageContaining("托盘编码已存在"); verify(repository, never()).updatePallet(org.mockito.ArgumentMatchers.any()); } + @Test + void updatePalletRejectsStaleVersionBeforeMutation() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + WarehousePalletEntity existing = new WarehousePalletEntity(); + existing.setId(40005L); + existing.setPalletCode("PLT-OLD"); + existing.setStatus("IDLE"); + existing.setVersion(3); + when(repository.findPallet(TENANT, WAREHOUSE, 40005L)).thenReturn(existing); + + assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40005L, + new PalletRequests.SaveRequest("PLT-STALE", "WOOD", BigDecimal.ONE, + BigDecimal.ONE, "IDLE", null, null, "ONT8", 2))) + .isInstanceOf(BizException.class) + .hasMessageContaining("版本已变化"); + verify(repository, never()).palletCodeExists(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any()); + verify(repository, never()).updatePallet(org.mockito.ArgumentMatchers.any()); + } + private static WarehouseEntity warehouse() { WarehouseEntity warehouse = new WarehouseEntity(); warehouse.setId(WAREHOUSE); diff --git a/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs b/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs index cfcd5a871..72214465c 100644 --- a/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs +++ b/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs @@ -111,6 +111,9 @@ assert.doesNotMatch(sql, /contact_name\s*=\s*VALUES\(contact_name\)/) assert.doesNotMatch(sql, /contact_phone\s*=\s*VALUES\(contact_phone\)/) assert.doesNotMatch(sql, /contact_email\s*=\s*VALUES\(contact_email\)/) assert.doesNotMatch(sql, /remark\s*=\s*VALUES\(remark\)/) -assert.doesNotMatch(sql, /INSERT INTO wh_service_capability/i) +assert.match(sql, /国内固定作业仓必须启用收货能力/) +assert.match(sql, /INSERT INTO wh_service_capability[\s\S]+?'RECEIVING'/i) +const fbaBlock = sql.slice(sql.indexOf('CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data')) +assert.doesNotMatch(fbaBlock, /INSERT INTO wh_service_capability/i) assert.match(sql, /FBA公共主数据地址种子ID与既有数据冲突/) assert.match(sql, /FBA公共地址不得创建平台作业能力/)