diff --git a/AGENTS.md b/AGENTS.md index 896e86841..57fb7d324 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,14 @@ ## 服务路由(按关键词定位) -本表只保留定位入口;命中业务后必须用 `rg -n -F '关键词' docs/backend-business-index.md` 读取对应行/段的详细约束,再沿代码引用追踪;不默认全文加载详细索引。已知目标文件时直接定位,并定向核对该业务约束。 +本表只保留定位入口;命中业务后用 `rg -n -F '关键词' docs/backend-business-index.md` 读取对应行/段的详细约束,再沿代码引用追踪;不默认全文加载详细索引。已知目标文件时直接定位,并定向核对该业务约束。 + +### 后端任务最小读取集 + +- 单服务 Controller/Service/Mapper 任务:本文件、目标服务 `AGENTS.md`、目标类及其直接调用点。 +- 跨服务 API/事件任务:在上述基础上增加直接调用方、被调用方和对应 Gateway/OpenAPI;不因业务关键词自动加载整个业务专题。 +- 只有状态、权限、数据库或验收边界无法从上述入口确定时,才读取 `docs/业务快速索引.md`、专题 README 或完整契约。 +- 后端指引累计超过约 250 行时优先用 `rg` 读取命中段;聚合构建或架构任务才全文读取 `README.md`、`pom.xml` 和目录结构文档。 | 任务关键词 | 首选服务 | |---|---| @@ -99,15 +106,15 @@ - 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。 - 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。 -- 所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`;每个持久化服务只允许在启动类使用一次服务根包 `@MapperScan(..., annotationClass = Mapper.class)`,不得新增子域包白名单或分散的 `*MapperConfiguration`。新增或移动 Mapper 后先运行 `node scripts/mapper-scan-contract.mjs`,全量后端回归也会在 Maven 前执行该门禁。 -- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。 +- 所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`;每个持久化服务只允许在启动类使用一次服务根包 `@MapperScan(..., annotationClass = Mapper.class)`,不得新增子域包白名单或分散的 `*MapperConfiguration`。新增、移动 Mapper 或修改扫描配置后运行 `node scripts/mapper-scan-contract.mjs`;全量后端回归已在 Maven 前执行该门禁时复用结果,输入未变不重复执行。 +- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前正式权限契约不保留旧独立 `*:view`;Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。若外部已有不可变权限契约,先按当前对话建立单独兼容规则。 - 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。 - 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。 -- 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。 +- 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。 - 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 -- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外仓业务范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 +- 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。 - Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。 @@ -120,7 +127,7 @@ - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - 订舱与拖车跨服务完整状态链由 Admin 注册模块 `service-center-flow` 验证,必须复用本轮已放舱订舱并由总入口展开依赖,不得直接执行模块文件或借历史数据。 - 尾程公网控制由 `phase4-tail-order-controls` 在海外 PDA 前验证整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、暂扣/解除、八个人工节点和逐票人工计划;目的港码头拖车由 `phase5-terminal-drayage` 在卸柜后验证选车队、预约提柜、实际提柜和实际还柜,`phase4-carrier-nodes` 只读核验最终船节点终态。 -- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 +- 这些脚本会创建、更新或最终停用测试数据;指定测试环境和服务后,本轮非破坏性写入复用一次授权,删除、重置、SQL、部署或发布仍逐项授权,并保存 JSON 报告;未授权不得请求 Gateway。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 - Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 - HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。 @@ -129,10 +136,10 @@ ## 业务域文档入口 - 订单与客户:`../docs/订单与客户/README.md`、`../docs/订单与客户/订单与客户头程订单闭环设计.md` -- 入库测量与计费:`../docs/入库测量与计费/README.md`、`../docs/入库测量与计费/入库测量与计费需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告 -- 国内货柜履约:`../docs/国内货柜履约/README.md`、`../docs/国内货柜履约/国内货柜履约开工门禁.md`、`../docs/国内货柜履约/国内货柜履约开发规划.md`、`../docs/国内货柜履约/国内货柜履约需求确认表.md`、页面与原型清单 +- 入库测量与计费:`../docs/入库测量与计费/README.md`;流程细节按 `../docs/头程到尾程/README.md` 指向的当前阶段文档读取,需求确认和联调报告仅在历史追溯或验收证据任务中读取 +- 国内货柜履约:`../docs/国内货柜履约/README.md`、`../docs/国内货柜履约/报关资料与船期价格接入梳理.md`;开发规划、开工门禁、需求确认和验收材料已归档,只有历史追溯或验收证据任务才读取 - 海外仓业务:`../docs/海外仓业务/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` - 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/` - 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束 -全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。 +全量验收需要证据时再读取对应阶段报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。 diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java index 1061534be..a7cae146c 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java @@ -22,11 +22,19 @@ public class AuthorizationSnapshot { private Set permissions; private Long permissionVersion; private Long overseasWarehouseId; + private Long domesticWarehouseId; 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); + permissionVersion, null, null); + } + + public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode, + String clientType, Set roles, Set permissions, + Long permissionVersion, Long overseasWarehouseId) { + this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, + permissionVersion, overseasWarehouseId, null); } } 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 6ff1a3fa6..15144bf9f 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -135,6 +135,9 @@ public class AuthService { if (authorization.getOverseasWarehouseId() != null) { claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); } + if (authorization.getDomesticWarehouseId() != null) { + claimsBuilder.claim("domestic_warehouse_id", authorization.getDomesticWarehouseId()); + } JwtClaimsSet claims = claimsBuilder.build(); String accessToken = jwtEncoder.encode(JwtEncoderParameters.from( signingHeader(), claims)).getTokenValue(); diff --git a/common-core/AGENTS.md b/common-core/AGENTS.md index 277a9fa7d..0bd37d101 100644 --- a/common-core/AGENTS.md +++ b/common-core/AGENTS.md @@ -18,7 +18,7 @@ ## 修改与验证 -- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。 +- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约。当前正式模型不保留旧序列化或数据库兼容分支;若已有不可变外部契约,先单独定义升级规则。 - `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。 - `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。 - 新能力只有被多个模块稳定复用时才放入本模块。 diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index 5f107b109..c1ce19dbe 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -29,7 +29,7 @@ ## 修改与验证 -- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。 +- 所有字段与事件类型都视为跨仓公共契约;当前正式模型不保留旧事件字段、旧版本或兼容解析,契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。若存在不可变外部事件契约,先单独定义版本升级规则。 - 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。 - MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。 - `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。 diff --git a/common-openapi/AGENTS.md b/common-openapi/AGENTS.md index f91a255d8..41c12e6e7 100644 --- a/common-openapi/AGENTS.md +++ b/common-openapi/AGENTS.md @@ -20,6 +20,6 @@ - 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。 - PDA Controller 必须进入独立 `pda` 分组;新增或改名 `/api/pda/**` 路径时同步检查分组合同和目标服务 OpenAPI,不得混入平台或商户分组。 - 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。 -- 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。 +- 配置项变化直接同步代码、全部消费者和 Nacos 模板;当前正式配置不保留旧配置键兼容。若已有不可变外部配置契约,先单独定义升级规则。 - 从父目录运行 `mvn -pl common-openapi -am test`。 - 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。 diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index e9e566096..f53733015 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -10,7 +10,7 @@ | 功能 | 入口 | |---|---| | 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` | -| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 | +| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作清理前的历史实现定位,不得作为跨区域共享密钥方案 | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` | | 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` | | 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` | diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index cd8b7d7ec..42b868723 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -35,6 +35,23 @@ baiyue: lease-seconds: ${LASTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} retry-seconds: ${LASTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} firstleg: + lingxing: + # 安全默认关闭;开启后自动任务才允许调用领星新品维护接口。 + auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false} + base-url: ${LINGXING_DIRECT_BASE_URL:https://wms.xlwms.com} + login-origin: ${LINGXING_LOGIN_ORIGIN:https://wms.xlwms.com} + provider-key: ${LINGXING_PROVIDER_KEY:LINGXING_01} + # Firstleg and the isolated Lingxing browser run on the same Docker network. + # Keep the environment override for deployments that use another endpoint. + browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:http://lingxing-browser:9222} + username: ${LINGXING_USERNAME:} + password: ${LINGXING_PASSWORD:} + browser-session-required: ${LINGXING_BROWSER_SESSION_REQUIRED:true} + token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800} + token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300} + track-key-v2-secret-base64: ${LINGXING_TRACK_KEY_V2_SECRET_BASE64:} + request-timeout: ${LINGXING_REQUEST_TIMEOUT:30s} + warehouses: [US01, US02] inbound: allow-rejected-measurement: false default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000} diff --git a/config/nacos/local/baiyue-common.yml b/config/nacos/local/baiyue-common.yml index 9af5a8d4a..5acc5cbe2 100644 --- a/config/nacos/local/baiyue-common.yml +++ b/config/nacos/local/baiyue-common.yml @@ -40,6 +40,8 @@ spring: timeout: ${REDIS_TIMEOUT:3s} cloud: nacos.discovery: + # Local services must stay reachable through the gateway when VPN adapters change priority. + ip: ${NACOS_DISCOVERY_IP:127.0.0.1} group: ${NACOS_GROUP:BAIYUE} namespace: ${NACOS_NAMESPACE:public} openfeign: diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index 454b7343f..de2ce6708 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -22,6 +22,22 @@ baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} baiyue.carrier-schedule.scheduler.max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} baiyue.carrier-schedule.scheduler.lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} firstleg: + lingxing: + auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false} + base-url: ${LINGXING_DIRECT_BASE_URL:https://wms.xlwms.com} + login-origin: ${LINGXING_LOGIN_ORIGIN:https://wms.xlwms.com} + provider-key: ${LINGXING_PROVIDER_KEY:LINGXING_01} + # Firstleg and the isolated Lingxing browser run on the same Docker network. + # Keep the environment override for deployments that use another endpoint. + browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:http://lingxing-browser:9222} + username: ${LINGXING_USERNAME:} + password: ${LINGXING_PASSWORD:} + browser-session-required: ${LINGXING_BROWSER_SESSION_REQUIRED:true} + token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800} + token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300} + track-key-v2-secret-base64: ${LINGXING_TRACK_KEY_V2_SECRET_BASE64:} + request-timeout: ${LINGXING_REQUEST_TIMEOUT:30s} + warehouses: [US01, US02] inbound: allow-rejected-measurement: false default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000} diff --git a/deploy/docker-compose.lingxing-browser.yml b/deploy/docker-compose.lingxing-browser.yml new file mode 100644 index 000000000..fe39942fb --- /dev/null +++ b/deploy/docker-compose.lingxing-browser.yml @@ -0,0 +1,31 @@ +name: firstleg-lingxing-${DEPLOY_ENV:-development} + +services: + lingxing-browser: + build: + context: ./docker/lingxing-browser + image: ${LINGXING_BROWSER_IMAGE:-firstleg-lingxing-browser:dev} + container_name: firstleg-${DEPLOY_ENV:-development}-lingxing-browser + restart: unless-stopped + init: true + ports: + - "127.0.0.1:${LINGXING_NOVNC_PORT:-6080}:6080" + volumes: + - lingxing-browser-profile:/profile + networks: + - firstleg-network + healthcheck: + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:9222/json/version >/dev/null || exit 1"] + interval: 15s + timeout: 5s + start_period: 30s + retries: 5 + +networks: + firstleg-network: + external: true + name: firstleg-${DEPLOY_ENV:-development}-network + +volumes: + lingxing-browser-profile: + name: firstleg-${DEPLOY_ENV:-development}-lingxing-browser-profile diff --git a/deploy/docker/lingxing-browser/Dockerfile b/deploy/docker/lingxing-browser/Dockerfile new file mode 100644 index 000000000..cecbce563 --- /dev/null +++ b/deploy/docker/lingxing-browser/Dockerfile @@ -0,0 +1,20 @@ +FROM alpine:3.21 + +ARG ALPINE_MIRROR=https://mirrors.aliyun.com/alpine + +RUN sed -i "s#https://dl-cdn.alpinelinux.org/alpine#${ALPINE_MIRROR}#g" /etc/apk/repositories \ + && apk add --no-cache chromium font-noto-cjk novnc openbox socat su-exec tigervnc websockify xvfb \ + && adduser -D -u 1000 browser \ + && ln -sf /usr/share/novnc/vnc_lite.html /usr/share/novnc/index.html + +COPY entrypoint.sh /usr/local/bin/lingxing-browser-entrypoint +RUN chmod 0755 /usr/local/bin/lingxing-browser-entrypoint + +ENV DISPLAY=:99 \ + LINGXING_LOGIN_URL=https://wms.xlwms.com/login \ + PROFILE_DIR=/profile + +VOLUME ["/profile"] +EXPOSE 6080 9222 + +ENTRYPOINT ["/usr/local/bin/lingxing-browser-entrypoint"] diff --git a/deploy/docker/lingxing-browser/entrypoint.sh b/deploy/docker/lingxing-browser/entrypoint.sh new file mode 100644 index 000000000..74afcba3c --- /dev/null +++ b/deploy/docker/lingxing-browser/entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -eu + +mkdir -p "$PROFILE_DIR" /tmp/lingxing-runtime /tmp/.X11-unix +rm -f /tmp/.X99-lock /tmp/.X11-unix/X99 +rm -f "$PROFILE_DIR/SingletonCookie" "$PROFILE_DIR/SingletonLock" "$PROFILE_DIR/SingletonSocket" +chmod 1777 /tmp/.X11-unix +chown -R browser:browser "$PROFILE_DIR" /tmp/lingxing-runtime +chmod 0700 "$PROFILE_DIR" /tmp/lingxing-runtime + +cleanup() { + for pid in "${CHROMIUM_PID:-}" "${CDP_PROXY_PID:-}" "${NOVNC_PID:-}" "${VNC_PID:-}" "${OPENBOX_PID:-}" "${XVFB_PID:-}"; do + [ -n "$pid" ] && kill "$pid" 2>/dev/null || true + done +} +trap cleanup EXIT INT TERM + +su-exec browser Xvfb "$DISPLAY" -screen 0 1440x900x24 -nolisten tcp & XVFB_PID=$! +sleep 1 +su-exec browser env DISPLAY="$DISPLAY" XDG_RUNTIME_DIR=/tmp/lingxing-runtime openbox & OPENBOX_PID=$! +su-exec browser x0vncserver -display "$DISPLAY" -localhost -rfbport 5900 -SecurityTypes None -AlwaysShared & VNC_PID=$! +su-exec browser websockify --web=/usr/share/novnc 0.0.0.0:6080 127.0.0.1:5900 & NOVNC_PID=$! +su-exec browser socat TCP-LISTEN:9222,bind=0.0.0.0,reuseaddr,fork TCP:127.0.0.1:9223 & CDP_PROXY_PID=$! + +su-exec browser env DISPLAY="$DISPLAY" XDG_RUNTIME_DIR=/tmp/lingxing-runtime chromium-browser \ + --user-data-dir="$PROFILE_DIR" \ + --remote-debugging-address=0.0.0.0 \ + --remote-debugging-port=9223 \ + --window-size=1440,900 \ + --disable-gpu \ + --use-gl=swiftshader \ + --disable-dev-shm-usage \ + --no-first-run \ + --no-default-browser-check \ + --no-sandbox \ + "$LINGXING_LOGIN_URL" & CHROMIUM_PID=$! + +wait "$CHROMIUM_PID" diff --git a/deploy/lingxing-browser-dev.md b/deploy/lingxing-browser-dev.md new file mode 100644 index 000000000..601108023 --- /dev/null +++ b/deploy/lingxing-browser-dev.md @@ -0,0 +1,84 @@ +# 开发环境独立领星浏览器 + +该浏览器只供开发环境 Java Firstleg 使用,使用独立 Docker volume,不复用其他 Node.js 账号的 profile。 + +## 启动 + +项目开发部署脚本使用 `DEPLOY_ENV=development`,因此先确保 Java 服务所在 Docker 网络已经存在: + +```bash +docker network inspect firstleg-development-network >/dev/null +``` + +在仓库 `baiyue-firstleg` 根目录执行: + +```bash +DEPLOY_ENV=development docker compose \ + -f deploy/docker-compose.lingxing-browser.yml \ + up -d --build +``` + +Java 容器加入同一个 `firstleg-development-network` 后,Nacos 使用: + +```yaml +firstleg: + lingxing: + login-origin: https://wms.xlwms.com + browser-cdp-endpoint: http://lingxing-browser:9222 + auto-sync-enabled: false +``` + +已有开发数据库需要单独执行一次 SKU 同步权限脚本(目标库为 `baiyue_iam`),否则页面菜单会因没有权限而隐藏、接口会返回 403: + +```bash +mysql -h <开发数据库地址> -u <账号> -p baiyue_iam \ + < iam/src/main/resources/db/mojia-sku-sync-permissions.sql +``` + +执行后让后台账号重新登录,使新的授权快照生效。 + +账号密码通过 Nacos 的加密配置保存: + +```yaml +firstleg: + lingxing: + username: ${LINGXING_USERNAME} + password: ${LINGXING_PASSWORD} +``` + +不要把真实账号、密码、Cookie、Token 或签名密钥写入仓库文件。 + +## 首次登录 + +浏览器容器启动后,noVNC 只绑定宿主机回环地址: + +```text +http://127.0.0.1:6080/?autoconnect=1&resize=scale +``` + +从个人电脑查看开发服务器上的 noVNC 时,先建立 SSH 隧道: + +```bash +ssh -N -L 6080:127.0.0.1:6080 <开发服务器用户>@<开发服务器地址> +``` + +然后在个人电脑浏览器打开上面的地址。CDP 不需要暴露到宿主机,Java 通过 Docker 网络访问 `lingxing-browser:9222`。 + +正常情况下不需要先人工登录。点击后台测试连接或人工推送时,Java 会自动填写账号密码。遇到腾讯安全滑块时,通过 noVNC 人工完成滑块;遇到短信、邮箱或 TOTP 二次验证时,在后台输入验证码,Java 会自动继续原操作。 + +登录页已按领星实际页面校验以下控件:账号 `input[name="username"]`、密码 `input[name="password"]`、普通登录按钮 `.login-btn`;检测到二次验证页后,短信/邮箱立即自动点击 `.verify-code-login .code-btn` 获取验证码,后台输入验证码后只填写 `input[placeholder="输入验证码"]` 并点击 `.verify-code-login .login-btn`,不会重复发送。TOTP 由页面自动初始化,验证码输入框为 `input.verification-code-native`,后台提交方式相同。 + +## 检查 + +```bash +chmod +x deploy/scripts/check-lingxing-browser.sh +DEPLOY_ENV=development deploy/scripts/check-lingxing-browser.sh + +DEPLOY_ENV=development docker compose \ + -f deploy/docker-compose.lingxing-browser.yml ps + +docker exec firstleg-dev-lingxing-browser \ + wget -q -O - http://127.0.0.1:9222/json/version +``` + +不要把 6080 当成 CDP 地址,也不要使用容器内原生 9223;Java 使用同网络的 `http://lingxing-browser:9222`。 diff --git a/deploy/scripts/check-lingxing-browser.sh b/deploy/scripts/check-lingxing-browser.sh new file mode 100644 index 000000000..6cba8e246 --- /dev/null +++ b/deploy/scripts/check-lingxing-browser.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +deployment_env="${DEPLOY_ENV:-development}" +compose_file="deploy/docker-compose.lingxing-browser.yml" +container="firstleg-${deployment_env}-lingxing-browser" + +docker compose -f "$compose_file" ps --status running +docker exec "$container" wget -q -O - http://127.0.0.1:9222/json/version \ + | sed -E 's/("webSocketDebuggerUrl"[[:space:]]*:[[:space:]]*")[^"]+(".*)/\1[redacted]\2/' + +echo "CDP is reachable; credentials and browser storage were not printed." diff --git a/deploy/scripts/deploy-blue-green-service.sh b/deploy/scripts/deploy-blue-green-service.sh index 24c60f2a3..ff2fb20ee 100644 --- a/deploy/scripts/deploy-blue-green-service.sh +++ b/deploy/scripts/deploy-blue-green-service.sh @@ -64,10 +64,11 @@ ROUTING_NETWORK="${BLUE_GREEN_ROUTING_NETWORK:-firstleg-${DEPLOY_ENV}-routing}" HEALTH_TIMEOUT="${DEPLOY_HEALTH_TIMEOUT_SECONDS:-240}" HEALTH_POLL="${DEPLOY_HEALTH_POLL_SECONDS:-5}" STABILIZATION_SECONDS="${BLUE_GREEN_STABILIZATION_SECONDS:-10}" +DRAIN_SECONDS="${BLUE_GREEN_DRAIN_SECONDS:-30}" STOP_TIMEOUT="${BLUE_GREEN_STOP_TIMEOUT_SECONDS:-45}" IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG" -for value in "$HEALTH_TIMEOUT" "$HEALTH_POLL" "$STABILIZATION_SECONDS" "$STOP_TIMEOUT"; do +for value in "$HEALTH_TIMEOUT" "$HEALTH_POLL" "$STABILIZATION_SECONDS" "$DRAIN_SECONDS" "$STOP_TIMEOUT"; do case "$value" in ''|*[!0-9]*) echo "蓝绿时间参数必须为非负整数: $value" >&2; exit 2 ;; esac done @@ -365,6 +366,10 @@ fi old_container_stopped=false if [ -n "$active_container" ] && docker inspect "$active_container" >/dev/null 2>&1; then + if [ "$DRAIN_SECONDS" -gt 0 ]; then + echo "蓝绿切换后连接排空 ${DRAIN_SECONDS}s,旧 Web 实例保持运行: $active_container" + sleep "$DRAIN_SECONDS" + fi echo "优雅停止旧 Web 实例: $active_container" docker stop --time "$STOP_TIMEOUT" "$active_container" >/dev/null old_container_stopped=true diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index b4062334b..a658d1e77 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -348,8 +348,7 @@ deploy_local() { if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then echo "镜像已在目标服务器本机,直接部署: $IMAGE" - if [ "$DEPLOY_ENV" = "production" ] \ - || { [ "$DEPLOY_ENV" = "test" ] && [ "$SERVICE" != "gateway" ] && [ "$SERVICE" != "gateway-overseas" ]; }; then + if [ "$DEPLOY_ENV" = "production" ]; then printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" trap local_registry_logout EXIT HUP INT TERM docker pull "$IMAGE" @@ -433,8 +432,7 @@ printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --pa trap remote_registry_logout EXIT HUP INT TERM run_ssh docker pull "$IMAGE" -if [ "$DEPLOY_ENV" = "production" ] \ - || { [ "$DEPLOY_ENV" = "test" ] && [ "$SERVICE" != "gateway" ] && [ "$SERVICE" != "gateway-overseas" ]; }; then +if [ "$DEPLOY_ENV" = "production" ]; then echo "$DEPLOY_ENV 服务使用蓝绿执行器,旧实例在候选槽健康前保持运行" run_ssh sh "$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME" \ "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" \ diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index 5880075cf..8c720a1fe 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -195,7 +195,7 @@ if [ "$DEPLOY_ENV" = "test" ] || [ "$DEPLOY_ENV" = "production" ]; then -f "$COMPOSE_FILE" \ pull $SERVICES for service in $SERVICES; do - if [ "$DEPLOY_ENV" = test ] && { [ "$service" = gateway ] || [ "$service" = gateway-overseas ]; }; then + if [ "$DEPLOY_ENV" = test ]; then docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" \ up -d --no-deps --pull never "$service" continue diff --git a/deploy/sql/README.md b/deploy/sql/README.md index aadb21f39..05f4ae572 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -6,7 +6,7 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| -| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 208 | +| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 223 | | 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 138 | - 国内和海外是两个部署区域,不是把微服务合并为两个 schema。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 @@ -24,7 +24,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/138`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=223/138`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 @@ -40,7 +40,7 @@ | 所属库 | 保留内容 | 不包含 | |---|---|---| -| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、364 个权限、539 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 | +| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、370 个权限、552 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 | | Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | @@ -66,17 +66,20 @@ | 区域 / schema | 迁移 | 目的 | |---|---|---| -| CN / `baiyue_iam` | `migrations/cn/baiyue_iam/V1__align_schema_baseline.sql` | 收敛客户编号、昵称和资质原文件名定义;收紧前检查存量数据 | -| CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql` | 收敛一件代发托盘码非空定义;收紧前检查存量数据 | +| CN / `baiyue_iam` | `migrations/cn/baiyue_iam/V1__align_schema_baseline.sql`、`V2__add_pda_release_permissions.sql`、`V3__align_domestic_pda_mojia_sailing_permissions.sql` | 收敛客户编号、昵称和资质原文件名定义;补齐 PDA 发布、国内作业仓绑定、墨家 SKU 同步、仓管 PDA 和船期日历权限 | +| CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql`、`V2__align_measurement_and_fee_schema.sql`、`V3__add_customs_receipt_declaration_schema.sql`、`V4__add_sailing_calendar_schema.sql` | 收敛一件代发托盘码非空定义;补齐墨家测量和人工报价字段、报关行回执/申报事实、船期日历事实表;V2 对存量收件记录仅做阻断检查,不自动回填 | | CN / `baiyue_file` | `migrations/cn/baiyue_file/V1__align_schema_baseline.sql`、`V2__widen_business_type.sql` | 补齐 PDA 发布表和固定渠道,并支持正式长业务类型 | -| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql` | 补齐尾程字段并收敛字段定义;放宽退货出库和预入仓批量 SKU 换标合法空值 | +| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql`、`V4__cargo_discard_return_inventory.sql`、`V5__allow_multiple_relabel_applications.sql` | 补齐尾程字段并收敛字段定义;放宽退货出库和预入仓批量 SKU 换标合法空值;货物废弃支持退货库存箱;允许同一尾程订单多个换标申请 | +| OVERSEAS / `baiyue_lastlag_warehouse` | `migrations/overseas/baiyue_lastlag_warehouse/V1__add_shared_overflow_location.sql`、`V2__retire_location_zone_and_rebuild_query_index.sql` | 补齐共享溢出库位并移除退役 `zone_code`;V2 含 `DROP COLUMN`,生产执行必须单独授权并确认无外部依赖 | -上述新增候选当前只完成编写和离线检查,尚未在测试或生产执行。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。 +上述迁移候选只完成编写和离线检查,尚未在测试或生产执行。`db-migrations.mjs plan --services iam,firstleg,lastleg-service,warehouse-overseas-service --regions CN,OVERSEAS` 生成的 manifest 目标版本应为 `baiyue_iam=3`、`baiyue_firstleg=4`、`baiyue_lastleg=5`、`baiyue_lastlag_warehouse=2`。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。 迁移范围映射位于 `migration-scopes.json`,按区域、schema、所属服务和顺序登记。迁移文件位于 `migrations/{cn|overseas}//`,一个文件只允许修改一个 schema。目录中的 `.gitkeep` 仅用于保留空目录,不是迁移文件。 完整操作请先读 [数据库迁移操作手册](数据库迁移操作手册.md)。离线入口为 `scripts/db-migrations.mjs`: +编号统一采用每个 schema 独立递增的 `V1、V2、V3……`,有 SQL 变更才由工具新增,成功执行后不改内容和编号。功能分支从最新 `main` 创建,进入测试前同步 main 并解决草稿编号冲突。生产发布另记 `release-1、release-2……`;该发布编号及分支门禁尚待流水线实现,完整流程见操作手册“编号与功能分支规则”。 + ```powershell # 自动取得该 schema 的下一个整数序号并创建草稿;不连接数据库 node deploy/sql/scripts/db-migrations.mjs new --region CN --schema baiyue_auth --description add_login_audit diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V2__align_measurement_and_fee_schema.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V2__align_measurement_and_fee_schema.sql new file mode 100644 index 000000000..b207b75e1 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V2__align_measurement_and_fee_schema.sql @@ -0,0 +1,52 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: 对齐墨家测量收件箱字段并允许人工报价费用快照缺少船司价格。 +-- Preconditions: 连接必须指向 baiyue_firstleg;若墨家收件箱已有记录,必须先完成经批准的重量回填方案;本文件不执行 UPDATE。 +-- Postconditions: 墨家收件箱字段、入库费用船司价格字段与当前 Firstleg 基线一致。 + +SET NAMES utf8mb4; + +SET @guard = IF( + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt') + AND EXISTS (SELECT 1 FROM fl_mojia_measurement_receipt LIMIT 1), + 'SELECT 1 FROM migration_blocked_firstleg_mojia_receipt_requires_weight_review', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'inbound_code'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN inbound_code VARCHAR(64) NULL COMMENT ''箱码命中后的权威入仓码;SKU记录为空'' AFTER box_code', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'measurement_kind'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN measurement_kind VARCHAR(16) NOT NULL DEFAULT ''BOX_LABEL'' COMMENT ''测量分流:BOX_LABEL箱唛;SKU一件代发SKU'' AFTER inbound_code', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'weight_kg'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN weight_kg DECIMAL(18,3) NOT NULL COMMENT ''设备实测重量kg'' AFTER height_cm', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'sync_status'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_status VARCHAR(24) NULL COMMENT ''SKU同步状态:PENDING/SYNCING/SUCCESS/FAILED'' AFTER status', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'sync_failure_code'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_failure_code VARCHAR(64) NULL COMMENT ''SKU同步失败业务码'' AFTER sync_status', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'sync_failure_reason'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_failure_reason VARCHAR(500) NULL COMMENT ''脱敏后的SKU同步失败原因'' AFTER sync_failure_code', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'lingxing_sku_id'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN lingxing_sku_id VARCHAR(128) NULL COMMENT ''领星SKU内部标识'' AFTER sync_failure_reason', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'revision'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN revision INT NOT NULL DEFAULT 1 COMMENT ''当前测量版本号'' AFTER lingxing_sku_id', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'match_failure_code' AND is_nullable = 'NO'), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN match_failure_code VARCHAR(16) NULL COMMENT ''正式箱唛业务匹配失败码''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'match_failure_reason' AND is_nullable = 'NO'), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN match_failure_reason VARCHAR(500) NULL COMMENT ''脱敏后的箱唛匹配失败原因''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE fl_inbound_fee + MODIFY COLUMN carrier_rate_id BIGINT UNSIGNED NULL COMMENT '船司FBA价格ID;人工报价为空', + MODIFY COLUMN carrier_rate_version INT UNSIGNED NULL COMMENT '船司价格版本快照;人工报价为空', + MODIFY COLUMN rate_effective_from DATETIME(3) NULL COMMENT '价格生效开始快照;人工报价为空'; diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V3__add_customs_receipt_declaration_schema.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V3__add_customs_receipt_declaration_schema.sql new file mode 100644 index 000000000..a4c74eb38 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V3__add_customs_receipt_declaration_schema.sql @@ -0,0 +1,146 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: 补齐报关行发送、客户回执动作、完税文件、正式申报批次和海关结果事实。 +-- Preconditions: 连接必须指向 baiyue_firstleg;现有报关主表和客户资料表已存在;不执行数据回填或清理。 +-- Postconditions: 报关增量字段与事实表和当前 Firstleg 报关基线一致。 + +SET NAMES utf8mb4; + +SET @guard = IF( + NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'fl_customs_container'), + 'SELECT 1 FROM migration_blocked_firstleg_customs_container_table_missing', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_container' AND column_name = 'broker_submitted_by'), + 'ALTER TABLE fl_customs_container ADD COLUMN broker_submitted_by BIGINT UNSIGNED NULL COMMENT ''已将客户确认结果发送给报关行的操作人ID'' AFTER completed_at', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_container' AND column_name = 'broker_submitted_by_name'), + 'ALTER TABLE fl_customs_container ADD COLUMN broker_submitted_by_name VARCHAR(64) NULL COMMENT ''发送报关行操作人名称快照'' AFTER broker_submitted_by', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_container' AND column_name = 'broker_submitted_at'), + 'ALTER TABLE fl_customs_container ADD COLUMN broker_submitted_at DATETIME(3) NULL COMMENT ''发送报关行执行时间'' AFTER broker_submitted_by_name', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_completion_document' AND column_name = 'customer_confirm_status'), + 'ALTER TABLE fl_customs_completion_document ADD COLUMN customer_confirm_status VARCHAR(20) NOT NULL DEFAULT ''PENDING'' COMMENT ''客户回执确认状态:PENDING/CONFIRMED/REJECTED'' AFTER version', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_completion_document' AND column_name = 'customer_confirmed_by'), + 'ALTER TABLE fl_customs_completion_document ADD COLUMN customer_confirmed_by BIGINT UNSIGNED NULL COMMENT ''客户确认人ID'' AFTER customer_confirm_status', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_completion_document' AND column_name = 'customer_confirmed_by_name'), + 'ALTER TABLE fl_customs_completion_document ADD COLUMN customer_confirmed_by_name VARCHAR(64) NULL COMMENT ''客户确认人名称快照'' AFTER customer_confirmed_by', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_completion_document' AND column_name = 'customer_confirmed_at'), + 'ALTER TABLE fl_customs_completion_document ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认时间'' AFTER customer_confirmed_by_name', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_completion_document' AND column_name = 'customer_reject_reason'), + 'ALTER TABLE fl_customs_completion_document ADD COLUMN customer_reject_reason VARCHAR(500) NULL COMMENT ''客户打回原因'' AFTER customer_confirmed_at', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +CREATE TABLE IF NOT EXISTS fl_customs_broker_submission ( + id BIGINT UNSIGNED NOT NULL COMMENT '首次发送报关行批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', + batch_no VARCHAR(64) NOT NULL COMMENT '首次发送报关行批次号', + status VARCHAR(16) NOT NULL COMMENT '发送状态:SENT已发送', + source_version INT UNSIGNED NOT NULL COMMENT '发送时整柜报关聚合版本', + source_snapshot_json JSON NOT NULL COMMENT '发送时冻结资料版本与覆盖范围快照', + sent_by BIGINT UNSIGNED NOT NULL COMMENT '首次发送操作人ID', + sent_by_name VARCHAR(64) NULL COMMENT '首次发送操作人名称快照', + sent_at DATETIME(3) NOT NULL COMMENT '首次发送时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_broker_submission_container(customs_container_id), + UNIQUE KEY uk_customs_broker_submission_batch(batch_no), + KEY idx_customs_broker_submission_plan(loading_plan_id,sent_at), + CONSTRAINT fk_customs_broker_submission_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), + CONSTRAINT chk_customs_broker_submission_status CHECK(status IN('SENT')) +) ENGINE=InnoDB COMMENT='冻结退税报关资料首次发送报关行批次事实'; + +CREATE TABLE IF NOT EXISTS fl_customs_completion_action_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '客户回执动作历史ID', + completion_document_id BIGINT UNSIGNED NOT NULL COMMENT '报关行回执当前版本逻辑根ID', + receipt_version INT UNSIGNED NOT NULL COMMENT '动作对应的回执文件版本', + action VARCHAR(16) NOT NULL COMMENT '客户动作:CONFIRM确认、REJECT打回', + reason VARCHAR(500) NULL COMMENT '客户打回原因;确认动作为空', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '客户操作人ID', + operator_name VARCHAR(64) NULL COMMENT '客户操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户动作时间', + PRIMARY KEY(id), + KEY idx_customs_completion_action_history(completion_document_id,receipt_version,created_at,id), + CONSTRAINT fk_customs_completion_action_history_document FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id), + CONSTRAINT chk_customs_completion_action_history_action CHECK(action IN('CONFIRM','REJECT')) +) ENGINE=InnoDB COMMENT='客户报关行回执确认与打回不可变历史'; + +CREATE TABLE IF NOT EXISTS fl_customs_final_document ( + id BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件ID', + source_document_id BIGINT UNSIGNED NOT NULL COMMENT '对应冻结客户资料ID', + file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '真实报关完税文件名称快照', + file_hash CHAR(64) NOT NULL COMMENT '真实报关完税文件SHA-256摘要', + content_type VARCHAR(128) NULL COMMENT '真实报关完税文件媒体类型', + size_bytes BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件大小(字节)', + uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '完税文件版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_final_source(source_document_id), + CONSTRAINT fk_customs_final_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) +) ENGINE=InnoDB COMMENT='真实报关完税文件当前版本'; + +CREATE TABLE IF NOT EXISTS fl_customs_declaration_batch ( + id BIGINT UNSIGNED NOT NULL COMMENT '正式申报批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', + batch_no VARCHAR(64) NULL COMMENT '平台正式申报批次号', + status VARCHAR(32) NOT NULL COMMENT '批次状态:READY/SUBMITTED/ACKNOWLEDGED/RESULT_PENDING/CLOSED/CANCELLED', + source_version INT UNSIGNED NOT NULL COMMENT '创建时整柜报关聚合版本', + source_snapshot_json JSON NOT NULL COMMENT '提交时资料、回执和确认版本快照', + customs_entry_no VARCHAR(64) NULL COMMENT '报关单号', + submitted_by BIGINT UNSIGNED NULL COMMENT '正式申报提交人', + submitted_by_name VARCHAR(64) NULL COMMENT '正式申报提交人名称快照', + submitted_at DATETIME(3) NULL COMMENT '正式申报提交时间', + acknowledged_by BIGINT UNSIGNED NULL COMMENT '报关行确认受理人', + acknowledged_by_name VARCHAR(64) NULL COMMENT '报关行确认受理人名称快照', + acknowledged_at DATETIME(3) NULL COMMENT '报关行确认受理时间', + remark VARCHAR(500) NULL COMMENT '批次说明', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_declaration_batch_container(customs_container_id, id), + KEY idx_customs_declaration_batch_plan(loading_plan_id,status,created_at), + CONSTRAINT fk_customs_declaration_batch_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), + CONSTRAINT chk_customs_declaration_batch_status CHECK(status IN ('READY','SUBMITTED','ACKNOWLEDGED','RESULT_PENDING','CLOSED','CANCELLED')) +) ENGINE=InnoDB COMMENT='整柜正式申报批次事实'; + +CREATE TABLE IF NOT EXISTS fl_customs_clearance_result ( + id BIGINT UNSIGNED NOT NULL COMMENT '海关结果事实ID', + declaration_batch_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式申报批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + result_type VARCHAR(32) NOT NULL COMMENT '结果类型:INSPECTION/SUPPLEMENT_REQUIRED/RELEASED/DETAINED/OTHER', + result_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '结果状态:OPEN/RESOLVED/CANCELLED', + customs_entry_no VARCHAR(64) NULL COMMENT '报关单号快照', + occurred_at DATETIME(3) NOT NULL COMMENT '海关事实发生时间', + resolution VARCHAR(1000) NULL COMMENT '处理结果或异常说明', + evidence_file_id BIGINT UNSIGNED NULL COMMENT '结果凭证File文件ID', + recorded_by BIGINT UNSIGNED NOT NULL COMMENT '平台录入人', + recorded_by_name VARCHAR(64) NULL COMMENT '平台录入人名称快照', + recorded_at DATETIME(3) NOT NULL COMMENT '平台录入时间', + previous_result_id BIGINT UNSIGNED NULL COMMENT '前序结果事实ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '结果创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '结果更新时间', + PRIMARY KEY(id), + KEY idx_customs_clearance_result_batch(declaration_batch_id,occurred_at,id), + KEY idx_customs_clearance_result_container(customs_container_id, result_type, result_status), + CONSTRAINT fk_customs_clearance_result_batch FOREIGN KEY(declaration_batch_id) REFERENCES fl_customs_declaration_batch(id), + CONSTRAINT chk_customs_clearance_result_type CHECK(result_type IN ('INSPECTION','SUPPLEMENT_REQUIRED','RELEASED','DETAINED','OTHER')), + CONSTRAINT chk_customs_clearance_result_status CHECK(result_status IN ('OPEN','RESOLVED','CANCELLED')) +) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实'; + diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql new file mode 100644 index 000000000..63cd59683 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql @@ -0,0 +1,243 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: 补齐船期日历、周模板及收货/预报/归班/订舱关联事实表。 +-- Preconditions: 连接必须指向 baiyue_firstleg;不写入业务数据,不包含跨 schema 权限种子。 +-- Postconditions: 船期日历 8 张表与 Firstleg 当前 phase4 SQL 定义一致。 + +SET NAMES utf8mb4; +-- 船期计划日历首版基线。与 fl_port_schedule(港务船期观测)分离。 +CREATE TABLE IF NOT EXISTS fl_sailing_channel ( + id BIGINT NOT NULL, + channel_code VARCHAR(64) NOT NULL, + channel_name VARCHAR(128) NOT NULL, + source_type VARCHAR(24) NOT NULL, + mapped_channel_code VARCHAR(64) NULL, + default_departure_port_code VARCHAR(32) NULL, + default_destination_port_code VARCHAR(32) NULL, + default_cutoff_days TINYINT NOT NULL DEFAULT 3, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_channel_code (channel_code), + KEY idx_fl_sailing_channel_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS fl_sailing_template ( + id BIGINT NOT NULL, + template_no VARCHAR(64) NOT NULL, + template_name VARCHAR(128) NOT NULL, + channel_id BIGINT NOT NULL, + channel_code_snapshot VARCHAR(64) NOT NULL, + channel_name_snapshot VARCHAR(128) NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_name_snapshot VARCHAR(128) NULL, + departure_port_code VARCHAR(32) NOT NULL, + destination_port_code VARCHAR(32) NOT NULL, + weekday TINYINT NOT NULL, + weekday_mask TINYINT NOT NULL DEFAULT 1, + etd_local_time TIME NOT NULL, + cutoff_days TINYINT NOT NULL DEFAULT 3, + cutoff_local_time TIME NOT NULL DEFAULT '18:00:00', + valid_from DATE NOT NULL, + valid_to DATE NULL, + voyage_rule VARCHAR(64) NULL, + vessel_name VARCHAR(128) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_template_no (template_no), + KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday), + CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7), + CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS fl_sailing ( + id BIGINT NOT NULL, + sailing_no VARCHAR(64) NOT NULL, + template_id BIGINT NULL, + channel_id BIGINT NOT NULL, + channel_code_snapshot VARCHAR(64) NOT NULL, + channel_name_snapshot VARCHAR(128) NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_name_snapshot VARCHAR(128) NULL, + departure_port_code VARCHAR(32) NOT NULL, + destination_port_code VARCHAR(32) NOT NULL, + vessel_name VARCHAR(128) NULL, + voyage_no VARCHAR(64) NULL, + etd_at DATETIME(3) NOT NULL, + warehouse_cutoff_at DATETIME(3) NOT NULL, + eta_at DATETIME(3) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'OPEN', + source_type VARCHAR(24) NOT NULL DEFAULT 'MANUAL', + generated_key VARCHAR(192) NULL, + planned_capacity_cbm DECIMAL(14,3) NULL, + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_no (sailing_no), + UNIQUE KEY uk_fl_sailing_generated_key (generated_key), + UNIQUE KEY uk_fl_sailing_business (warehouse_id,channel_code_snapshot,departure_port_code,destination_port_code,etd_at,voyage_no), + KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status), + KEY idx_fl_sailing_route (departure_port_code,destination_port_code), + CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS fl_sailing_history ( + id BIGINT NOT NULL, + sailing_id BIGINT NOT NULL, + action VARCHAR(32) NOT NULL, + from_status VARCHAR(16) NULL, + to_status VARCHAR(16) NULL, + reason VARCHAR(1000) NULL, + operator_id BIGINT NULL, + operator_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +/* + * 船期日历的事实契约。 + * + * 这些表不修改入库单或海运订舱聚合:入库单仍是履约主数据,订舱仍是 + * RELEASED/DRAFT 聚合。船期日历只消费这里的稳定事实和审计版本。 + */ + +CREATE TABLE IF NOT EXISTS fl_sailing_receipt_batch_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '稳定收货批次事实ID(receiptBatchId)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实', + receipt_batch_key VARCHAR(64) NOT NULL COMMENT '同一物理收货批次的不可变业务键', + fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一批次复测/冲正版本', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + measurement_id BIGINT UNSIGNED NULL COMMENT '最终审核测量批次ID', + receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际收货仓ID', + channel_code VARCHAR(64) NULL COMMENT '收货渠道编码快照', + channel_name VARCHAR(128) NULL COMMENT '收货渠道名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', + destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', + received_at DATETIME(3) NOT NULL COMMENT '实际收货完成时间,不使用入库单inbounded_at替代', + actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '最终审核实测体积m3', + fact_status VARCHAR(24) NOT NULL DEFAULT 'APPROVED' COMMENT '事实状态:APPROVED/REVERSED/VOIDED/SUPERSEDED', + reversal_of_fact_id BIGINT UNSIGNED NULL COMMENT '冲正所替代的事实ID', + source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_RECEIPT' COMMENT '事实来源', + created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_receipt_batch_version (tenant_id, receipt_batch_key, fact_version), + KEY idx_sailing_receipt_batch_current (tenant_id, receipt_batch_key, fact_status, fact_version), + KEY idx_sailing_receipt_match (tenant_id, receiving_warehouse_id, channel_code, received_at, id), + KEY idx_sailing_receipt_inbound (tenant_id, inbound_id, received_at, id), + CONSTRAINT chk_sailing_receipt_volume CHECK (actual_volume_cbm > 0), + CONSTRAINT chk_sailing_receipt_status CHECK (fact_status IN ('APPROVED','REVERSED','VOIDED','SUPERSEDED')) +) ENGINE=InnoDB COMMENT='船期日历稳定收货批次实测事实及版本历史'; + +CREATE TABLE IF NOT EXISTS fl_sailing_inbound_volume_forecast ( + id BIGINT UNSIGNED NOT NULL COMMENT '货量预报事实ID(forecastId)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实', + forecast_key VARCHAR(64) NOT NULL COMMENT '同一未收货对象的不可变业务键', + fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '预报修订版本', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '预计收货仓ID', + channel_code VARCHAR(64) NULL COMMENT '预计渠道编码快照', + channel_name VARCHAR(128) NULL COMMENT '预计渠道名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '预计起运港编码快照', + destination_port_code VARCHAR(32) NULL COMMENT '预计目的港编码快照', + expected_arrival_at DATETIME(3) NOT NULL COMMENT '预计到仓时间', + forecast_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '未收货预计体积m3,不得使用申报体积代替', + source_type VARCHAR(32) NOT NULL COMMENT '预报来源,例如OPERATIONS/UPSTREAM_MEASUREMENT;不得使用客户申报体积冒充预报', + fact_status VARCHAR(24) NOT NULL DEFAULT 'VALID' COMMENT '事实状态:VALID/SUPERSEDED/VOIDED/CONSUMED', + effective_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '本版本生效时间', + created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_forecast_version (tenant_id, forecast_key, fact_version), + KEY idx_sailing_forecast_current (tenant_id, forecast_key, fact_status, fact_version), + KEY idx_sailing_forecast_match (tenant_id, receiving_warehouse_id, channel_code, expected_arrival_at, id), + KEY idx_sailing_forecast_inbound (tenant_id, inbound_id, expected_arrival_at, id), + CONSTRAINT chk_sailing_forecast_volume CHECK (forecast_volume_cbm > 0), + CONSTRAINT chk_sailing_forecast_status CHECK (fact_status IN ('VALID','SUPERSEDED','VOIDED','CONSUMED')) +) ENGINE=InnoDB COMMENT='船期日历未收货量预报事实及版本历史'; + +CREATE TABLE IF NOT EXISTS fl_sailing_assignment ( + id BIGINT UNSIGNED NOT NULL COMMENT '归班事实ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', + sailing_id BIGINT UNSIGNED NULL COMMENT '船期实例ID,UNMATCHED为空', + origin_sailing_id BIGINT UNSIGNED NULL COMMENT '异常重排/取消前的原船期实例ID', + inbound_id BIGINT UNSIGNED NULL COMMENT '归班对象入库单ID', + inbound_code VARCHAR(64) NULL COMMENT '入仓码快照,仅用于展示', + receipt_batch_id BIGINT UNSIGNED NULL COMMENT '收货批次事实ID', + forecast_id BIGINT UNSIGNED NULL COMMENT '货量预报事实ID', + assignment_source VARCHAR(24) NOT NULL DEFAULT 'AUTO' COMMENT '归班来源:AUTO/MANUAL/ROLLOVER', + assignment_version INT UNSIGNED NOT NULL COMMENT '同一对象的归班版本', + match_status VARCHAR(24) NOT NULL COMMENT 'FORECAST_MATCHED/RECEIVED/LATE/UNMATCHED/SUPERSEDED', + volume_cbm DECIMAL(18,4) NULL COMMENT '已收实测体积', + forecast_volume_cbm DECIMAL(18,4) NULL COMMENT '未收有效预计体积', + route_source VARCHAR(32) NULL COMMENT '航线来源:INBOUND_FACT/FORECAST_FACT/CHANNEL_DEFAULT', + volume_source VARCHAR(32) NULL COMMENT 'MEASURED_ACTUAL或预报来源', + rule_version VARCHAR(64) NOT NULL COMMENT '自动归班规则版本', + matched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + superseded_at DATETIME(3) NULL, + locked_at DATETIME(3) NULL COMMENT '截止/订舱/关闭后冻结时间', + lock_reason VARCHAR(32) NULL COMMENT 'CUTOFF/BOOKING_CONFIRMED/CLOSED', + operator_id BIGINT UNSIGNED NULL, + reason VARCHAR(500) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sailing_assignment_current (tenant_id, sailing_id, match_status, matched_at, id), + KEY idx_sailing_assignment_origin (tenant_id, origin_sailing_id, match_status, id), + KEY idx_sailing_assignment_receipt (tenant_id, receipt_batch_id, assignment_version, id), + KEY idx_sailing_assignment_forecast (tenant_id, forecast_id, assignment_version, id), + KEY idx_sailing_assignment_inbound (tenant_id, inbound_id, match_status, id), + CONSTRAINT chk_sailing_assignment_status CHECK (match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED','SUPERSEDED')), + CONSTRAINT chk_sailing_assignment_source CHECK (assignment_source IN ('AUTO','MANUAL','ROLLOVER')) +) ENGINE=InnoDB COMMENT='船期日历自动归班事实及不可变版本历史'; + +CREATE TABLE IF NOT EXISTS fl_sailing_booking_link ( + id BIGINT UNSIGNED NOT NULL COMMENT '船期-订舱关联ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台关联隔离键', + sailing_id BIGINT UNSIGNED NOT NULL COMMENT '计划船期实例ID', + ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', + link_status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '关联状态:ACTIVE/REVOKED', + active_booking_guard TINYINT NULL COMMENT 'ACTIVE为1,REVOKED为空,用于唯一约束', + booking_status_snapshot VARCHAR(24) NOT NULL COMMENT '关联时订舱状态快照;只有RELEASED可建立', + warehouse_id BIGINT UNSIGNED NULL COMMENT '关联仓库快照', + channel_code VARCHAR(64) NULL COMMENT '关联渠道快照', + departure_port_code VARCHAR(32) NULL COMMENT '关联起运港快照', + destination_port_code VARCHAR(32) NULL COMMENT '关联目的港快照', + linked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + operator_id BIGINT UNSIGNED NULL, + operator_name VARCHAR(64) NULL, + reason VARCHAR(500) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_booking_active (ocean_booking_id, active_booking_guard), + KEY idx_sailing_booking_link (sailing_id, link_status, ocean_booking_id), + KEY idx_sailing_booking_booking (ocean_booking_id, link_status, id), + CONSTRAINT chk_sailing_booking_link_status CHECK (link_status IN ('ACTIVE','REVOKED')), + CONSTRAINT chk_sailing_booking_link_guard CHECK ((link_status='ACTIVE' AND active_booking_guard=1) OR (link_status='REVOKED' AND active_booking_guard IS NULL)), + CONSTRAINT chk_sailing_booking_released CHECK (booking_status_snapshot='RELEASED') +) ENGINE=InnoDB COMMENT='船期计划与已放舱订舱的最小关联及历史'; + + diff --git a/deploy/sql/migrations/cn/baiyue_iam/V3__align_domestic_pda_mojia_sailing_permissions.sql b/deploy/sql/migrations/cn/baiyue_iam/V3__align_domestic_pda_mojia_sailing_permissions.sql new file mode 100644 index 000000000..c39386b43 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_iam/V3__align_domestic_pda_mojia_sailing_permissions.sql @@ -0,0 +1,108 @@ +-- Region: CN +-- Schema: baiyue_iam +-- Purpose: 补齐国内 PDA、墨家 SKU 同步和船期日历所需权限,并增加国内作业仓绑定索引。 +-- Preconditions: 连接必须指向 baiyue_iam;角色和权限基线已存在;不执行数据回填。 +-- Postconditions: 国内成员可绑定作业仓,新增权限和角色授权与当前 IAM 基线一致。 + +SET NAMES utf8mb4; + +SET @guard = IF( + NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'iam_membership'), + 'SELECT 1 FROM migration_blocked_iam_membership_table_missing', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @guard = IF( + EXISTS ( + SELECT 1 FROM iam_permission + WHERE (id IN (5102131, 5102132, 5102133, 5102206, 5102207, 5102208, 5102209, 5102210, 5102211, 5102212) + AND permission_code NOT IN ( + 'platform:mojia-sku-sync:list', 'platform:mojia-sku-sync:edit', 'platform:mojia-sku-sync:retry', + 'platform:firstleg:sailing-calendar:list', 'platform:firstleg:sailing-calendar:create', + 'platform:firstleg:sailing-calendar:edit', 'platform:firstleg:sailing-template:manage', + 'platform:firstleg:sailing-booking:link', 'platform:firstleg:sailing-volume:forecast-manage', + 'platform:firstleg:sailing-volume:exception-reassign')) + OR (permission_code IN ( + 'platform:mojia-sku-sync:list', 'platform:mojia-sku-sync:edit', 'platform:mojia-sku-sync:retry', + 'platform:firstleg:sailing-calendar:list', 'platform:firstleg:sailing-calendar:create', + 'platform:firstleg:sailing-calendar:edit', 'platform:firstleg:sailing-template:manage', + 'platform:firstleg:sailing-booking:link', 'platform:firstleg:sailing-volume:forecast-manage', + 'platform:firstleg:sailing-volume:exception-reassign') + AND id NOT IN (5102131, 5102132, 5102133, 5102206, 5102207, 5102208, 5102209, 5102210, 5102211, 5102212)) + ), + 'SELECT 1 FROM migration_blocked_iam_permission_id_code_conflict', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_membership' AND column_name = 'domestic_warehouse_id'), + 'ALTER TABLE iam_membership ADD COLUMN domestic_warehouse_id BIGINT NULL COMMENT ''国内头程PDA操作员绑定的唯一国内作业仓ID'' AFTER overseas_warehouse_id', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'iam_membership' AND index_name = 'idx_membership_domestic_warehouse'), + 'ALTER TABLE iam_membership ADD KEY idx_membership_domestic_warehouse (tenant_id, domestic_warehouse_id, status)', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +INSERT INTO iam_permission ( + id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted +) +VALUES + (5102131, 0, 'PLATFORM', 'MENU', 5103000, 'platform:mojia-sku-sync:list', '称重机器', '/general/mojia-sku-sync', 'GET', 130, 'ACTIVE', 1, 0), + (5102132, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:edit', '编辑SKU测量数据', NULL, 'PUT', 10, 'ACTIVE', 1, 0), + (5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送SKU测量', NULL, 'POST', 20, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + http_method = VALUES(http_method), status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT r.id, p.id, 0 + FROM iam_role r + JOIN iam_permission p ON p.tenant_id = 0 AND p.app_code = 'PLATFORM' + WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' + AND r.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') + AND p.permission_code IN ('platform:mojia-sku-sync:list', 'platform:mojia-sku-sync:edit', 'platform:mojia-sku-sync:retry'); + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT r.id, p.id, 0 + FROM iam_role r + JOIN iam_permission p ON p.tenant_id = 0 AND p.app_code = 'PLATFORM' + WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' + AND r.role_code = 'WAREHOUSE_OPERATOR' + AND p.permission_code IN ('platform:firstleg:preload:list', 'platform:firstleg:preload:edit', 'platform:firstleg:pda:assign'); + +INSERT INTO iam_permission ( + id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted +) +VALUES + (5102206, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:sailing-calendar:list', '船期日历', '/sailing-calendar', 'GET', 125, 'ACTIVE', 1, 0), + (5102207, 0, 'PLATFORM', 'BUTTON', 5102206, 'platform:firstleg:sailing-calendar:create', '新增船期', '/sailing-calendar', 'POST', 10, 'ACTIVE', 1, 0), + (5102208, 0, 'PLATFORM', 'BUTTON', 5102206, 'platform:firstleg:sailing-calendar:edit', '编辑船期', '/sailing-calendar', 'PUT', 20, 'ACTIVE', 1, 0), + (5102209, 0, 'PLATFORM', 'BUTTON', 5102206, 'platform:firstleg:sailing-template:manage', '管理渠道与周模板', '/sailing-calendar', 'POST', 30, 'ACTIVE', 1, 0), + (5102210, 0, 'PLATFORM', 'BUTTON', 5102206, 'platform:firstleg:sailing-booking:link', '关联订舱', '/sailing-calendar', 'POST', 40, 'ACTIVE', 1, 0), + (5102211, 0, 'PLATFORM', 'BUTTON', 5102206, 'platform:firstleg:sailing-volume:forecast-manage', '维护货量预报', '/sailing-calendar', 'POST', 50, 'ACTIVE', 1, 0), + (5102212, 0, 'PLATFORM', 'BUTTON', 5102206, 'platform:firstleg:sailing-volume:exception-reassign', '异常重新安排', '/sailing-calendar', 'POST', 60, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + parent_id = VALUES(parent_id), permission_code = VALUES(permission_code), permission_name = VALUES(permission_name), + route_path = VALUES(route_path), http_method = VALUES(http_method), sort_no = VALUES(sort_no), + status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT r.id, p.id, 0 + FROM iam_role r + JOIN iam_permission p ON p.tenant_id = 0 AND p.app_code = 'PLATFORM' + WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' + AND r.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') + AND p.permission_code IN ( + 'platform:firstleg:sailing-calendar:list', 'platform:firstleg:sailing-calendar:create', + 'platform:firstleg:sailing-calendar:edit', 'platform:firstleg:sailing-template:manage', + 'platform:firstleg:sailing-booking:link', 'platform:firstleg:sailing-volume:forecast-manage', + 'platform:firstleg:sailing-volume:exception-reassign'); diff --git a/deploy/sql/migrations/overseas/baiyue_lastlag_warehouse/V1__add_shared_overflow_location.sql b/deploy/sql/migrations/overseas/baiyue_lastlag_warehouse/V1__add_shared_overflow_location.sql new file mode 100644 index 000000000..433e44e15 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastlag_warehouse/V1__add_shared_overflow_location.sql @@ -0,0 +1,5 @@ +-- 海外仓库位 A0 共享溢出位:普通库位独占,A0 允许多托盘共享。 +ALTER TABLE wh_location + ADD COLUMN shared_overflow TINYINT(1) NOT NULL DEFAULT 0 + COMMENT '是否系统共享溢出库位(A0)' + AFTER location_type; diff --git a/deploy/sql/migrations/overseas/baiyue_lastlag_warehouse/V2__retire_location_zone_and_rebuild_query_index.sql b/deploy/sql/migrations/overseas/baiyue_lastlag_warehouse/V2__retire_location_zone_and_rebuild_query_index.sql new file mode 100644 index 000000000..edc137896 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastlag_warehouse/V2__retire_location_zone_and_rebuild_query_index.sql @@ -0,0 +1,18 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastlag_warehouse +-- Purpose: 移除已退役的库位 zone_code,并重建不含 zone_code 的查询索引。 +-- Preconditions: 连接必须指向 baiyue_lastlag_warehouse;生产执行前必须单独授权 DROP COLUMN,并确认 zone_code 无外部读写依赖。 +-- Postconditions: wh_location 不再有 zone_code,idx_location_query 为 (customer_id, warehouse_id, status, deleted)。 +-- Safety: DROP COLUMN zone_code 是有意的破坏性结构变更;本文件只声明迁移,不执行生产授权或数据清理。 + +SET NAMES utf8mb4; + +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'wh_location' AND index_name = 'idx_location_query'), + 'ALTER TABLE wh_location DROP INDEX idx_location_query', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'wh_location' AND column_name = 'zone_code'), + 'ALTER TABLE wh_location DROP COLUMN zone_code', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'wh_location' AND index_name = 'idx_location_query'), + 'ALTER TABLE wh_location ADD KEY idx_location_query (customer_id, warehouse_id, status, deleted)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V4__cargo_discard_return_inventory.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V4__cargo_discard_return_inventory.sql new file mode 100644 index 000000000..ff1784f50 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V4__cargo_discard_return_inventory.sql @@ -0,0 +1,24 @@ +ALTER TABLE ll_cargo_discard_item + MODIFY COLUMN order_id BIGINT NULL COMMENT '一件代发订单ID', + MODIFY COLUMN profile_id BIGINT NULL COMMENT '托盘档案ID', + MODIFY COLUMN sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)); + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')); + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='source_type'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON'' AFTER order_id', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='return_carton_id'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'), + 'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V5__allow_multiple_relabel_applications.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V5__allow_multiple_relabel_applications.sql new file mode 100644 index 000000000..415312c6e --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V5__allow_multiple_relabel_applications.sql @@ -0,0 +1,14 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: 允许同一尾程订单建立多个换标申请,并保留订单查询索引。 +-- Preconditions: 连接必须指向 baiyue_lastleg;执行前确认旧唯一索引不再是业务约束。 +-- Postconditions: ll_relabel_application.lastleg_order_id 不再受唯一索引限制,普通查询索引存在。 + +SET NAMES utf8mb4; + +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' AND index_name = 'uk_ll_relabel_application_order'), + 'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' AND index_name = 'idx_ll_relabel_application_order'), + 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_order (lastleg_order_id)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 0376e6313..4dcf76941 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -124,6 +124,7 @@ CREATE TABLE iam_membership ( member_type varchar(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等', member_no varchar(64) DEFAULT NULL COMMENT '客户企业内部成员编号', overseas_warehouse_id bigint DEFAULT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID', + domestic_warehouse_id bigint DEFAULT NULL COMMENT '国内头程PDA操作员绑定的唯一国内作业仓ID', status varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开', joined_at datetime(3) DEFAULT NULL COMMENT '加入客户企业时间', left_at datetime(3) DEFAULT NULL COMMENT '离开客户企业时间', @@ -137,7 +138,8 @@ CREATE TABLE iam_membership ( UNIQUE KEY uk_membership_tenant_user (tenant_id,user_id), UNIQUE KEY uk_membership_tenant_no (tenant_id,member_no), KEY idx_membership_user (user_id,status), - KEY idx_membership_tenant (tenant_id,status) + KEY idx_membership_tenant (tenant_id,status), + KEY idx_membership_domestic_warehouse (tenant_id,domestic_warehouse_id,status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体'; CREATE TABLE iam_membership_role ( @@ -1754,6 +1756,9 @@ CREATE TABLE fl_customs_container ( completed_by bigint unsigned DEFAULT NULL COMMENT '完成报关操作人ID', completed_by_name varchar(64) DEFAULT NULL COMMENT '完成报关操作人名称快照', completed_at datetime(3) DEFAULT NULL COMMENT '报关完成时间', + broker_submitted_by bigint unsigned DEFAULT NULL COMMENT '发送报关行执行操作人ID', + broker_submitted_by_name varchar(64) DEFAULT NULL COMMENT '发送报关行执行操作人名称快照', + broker_submitted_at datetime(3) DEFAULT NULL COMMENT '发送报关行执行时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '聚合创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '聚合更新时间', version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', @@ -1764,6 +1769,28 @@ CREATE TABLE fl_customs_container ( CONSTRAINT chk_customs_container_status CHECK ((status in (_utf8mb4'WAITING_CUSTOMER_DOCUMENTS',_utf8mb4'READY_TO_START',_utf8mb4'DECLARING',_utf8mb4'COMPLETED'))) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='装柜完成后的整柜出口报关聚合'; +CREATE TABLE fl_customs_broker_submission ( + id bigint unsigned NOT NULL COMMENT '首次发送报关行批次ID', + customs_container_id bigint unsigned NOT NULL COMMENT '所属整柜报关聚合ID', + loading_plan_id bigint unsigned NOT NULL COMMENT '装柜计划ID快照', + batch_no varchar(64) NOT NULL COMMENT '首次发送报关行批次号', + status varchar(16) NOT NULL COMMENT '发送状态:SENT已发送', + source_version int unsigned NOT NULL COMMENT '发送时整柜报关聚合版本', + source_snapshot_json json NOT NULL COMMENT '发送时冻结资料版本与覆盖范围快照', + sent_by bigint unsigned NOT NULL COMMENT '首次发送操作人ID', + sent_by_name varchar(64) DEFAULT NULL COMMENT '首次发送操作人名称快照', + sent_at datetime(3) NOT NULL COMMENT '首次发送时间', + version int unsigned NOT NULL DEFAULT '1' COMMENT '乐观锁版本号', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_customs_broker_submission_container (customs_container_id), + UNIQUE KEY uk_customs_broker_submission_batch (batch_no), + KEY idx_customs_broker_submission_plan (loading_plan_id,sent_at), + CONSTRAINT fk_customs_broker_submission_container FOREIGN KEY (customs_container_id) REFERENCES fl_customs_container (id), + CONSTRAINT chk_customs_broker_submission_status CHECK ((status in (_utf8mb4'SENT'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='冻结退税报关资料首次发送报关行批次事实'; + CREATE TABLE fl_customs_container_history ( id bigint unsigned NOT NULL COMMENT '报关历史记录ID', customs_container_id bigint unsigned NOT NULL COMMENT '所属整柜报关聚合ID', @@ -1806,10 +1833,10 @@ CREATE TABLE fl_customs_customer_document ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户整份退税报送资料逻辑根'; CREATE TABLE fl_customs_completion_document ( - id bigint unsigned NOT NULL COMMENT '报关完成文件ID', + id bigint unsigned NOT NULL COMMENT '报关行回执文件ID', source_document_id bigint unsigned NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根', file_id bigint unsigned NOT NULL COMMENT 'File服务文件ID', - file_name varchar(255) NOT NULL COMMENT '完成文件名称快照', + file_name varchar(255) NOT NULL COMMENT '回执文件名称快照', file_hash char(64) NOT NULL COMMENT '完成文件SHA-256摘要', content_type varchar(128) DEFAULT NULL COMMENT '完成文件媒体类型', size_bytes bigint unsigned NOT NULL COMMENT '完成文件大小(字节)', @@ -1817,12 +1844,36 @@ CREATE TABLE fl_customs_completion_document ( uploaded_by_name varchar(64) DEFAULT NULL COMMENT '上传操作人名称快照', uploaded_at datetime(3) NOT NULL COMMENT '上传时间', version int unsigned NOT NULL DEFAULT '1' COMMENT '完成文件版本号', + customer_confirm_status varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT '客户回执确认状态:PENDING/CONFIRMED/REJECTED', + customer_confirmed_by bigint unsigned DEFAULT NULL COMMENT '客户确认人ID', + customer_confirmed_by_name varchar(64) DEFAULT NULL COMMENT '客户确认人名称快照', + customer_confirmed_at datetime(3) DEFAULT NULL COMMENT '客户确认时间', + customer_reject_reason varchar(500) DEFAULT NULL COMMENT '客户打回原因', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成文件创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '完成文件更新时间', PRIMARY KEY (id), UNIQUE KEY uk_customs_completion_source (source_document_id), CONSTRAINT fk_customs_completion_source FOREIGN KEY (source_document_id) REFERENCES fl_customs_customer_document (id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关完成文件当前版本'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关行回执当前版本'; + +CREATE TABLE fl_customs_final_document ( + id bigint unsigned NOT NULL COMMENT '真实报关完税文件ID', + source_document_id bigint unsigned NOT NULL COMMENT '一份冻结客户资料严格对应一份完税文件逻辑根', + file_id bigint unsigned NOT NULL COMMENT 'File服务文件ID', + file_name varchar(255) NOT NULL COMMENT '完税文件名称快照', + file_hash char(64) NOT NULL COMMENT '完税文件SHA-256摘要', + content_type varchar(128) DEFAULT NULL COMMENT '完税文件媒体类型', + size_bytes bigint unsigned NOT NULL COMMENT '完税文件大小(字节)', + uploaded_by bigint unsigned NOT NULL COMMENT '上传操作人ID', + uploaded_by_name varchar(64) DEFAULT NULL COMMENT '上传操作人名称快照', + uploaded_at datetime(3) NOT NULL COMMENT '上传时间', + version int unsigned NOT NULL DEFAULT '1' COMMENT '完税文件版本号', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完税文件创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '完税文件更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_customs_final_source (source_document_id), + CONSTRAINT fk_customs_final_source FOREIGN KEY (source_document_id) REFERENCES fl_customs_customer_document (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='真实报关完税文件当前版本'; CREATE TABLE fl_customs_completion_document_version ( id bigint unsigned NOT NULL COMMENT '完成文件版本记录ID', @@ -1841,6 +1892,21 @@ CREATE TABLE fl_customs_completion_document_version ( CONSTRAINT fk_customs_completion_version FOREIGN KEY (completion_document_id) REFERENCES fl_customs_completion_document (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关完成文件不可变覆盖版本'; +CREATE TABLE fl_customs_completion_action_history ( + id bigint unsigned NOT NULL COMMENT '客户回执动作历史ID', + completion_document_id bigint unsigned NOT NULL COMMENT '报关行回执当前版本逻辑根ID', + receipt_version int unsigned NOT NULL COMMENT '动作对应的回执文件版本', + action varchar(16) NOT NULL COMMENT '客户动作:CONFIRM确认、REJECT打回', + reason varchar(500) DEFAULT NULL COMMENT '客户打回原因;确认动作为空', + operator_id bigint unsigned NOT NULL COMMENT '客户操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '客户操作人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户动作时间', + PRIMARY KEY (id), + KEY idx_customs_completion_action_history (completion_document_id,receipt_version,created_at,id), + CONSTRAINT fk_customs_completion_action_history_document FOREIGN KEY (completion_document_id) REFERENCES fl_customs_completion_document (id), + CONSTRAINT chk_customs_completion_action_history_action CHECK ((action in (_utf8mb4'CONFIRM',_utf8mb4'REJECT'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户报关行回执确认与打回不可变历史'; + CREATE TABLE fl_customs_customer_document_inbound ( document_id bigint unsigned NOT NULL COMMENT '客户报送资料ID', inbound_id bigint unsigned NOT NULL COMMENT '覆盖入仓单ID', @@ -1883,6 +1949,56 @@ CREATE TABLE fl_customs_frozen_document_inbound ( CONSTRAINT fk_customs_frozen_document FOREIGN KEY (document_id) REFERENCES fl_customs_customer_document (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='开始报关时冻结的客户资料覆盖范围'; +CREATE TABLE fl_customs_declaration_batch ( + id BIGINT UNSIGNED NOT NULL COMMENT '正式申报批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', + batch_no VARCHAR(64) NULL COMMENT '平台正式申报批次号', + status VARCHAR(32) NOT NULL COMMENT '批次状态:READY/SUBMITTED/ACKNOWLEDGED/RESULT_PENDING/CLOSED/CANCELLED', + source_version INT UNSIGNED NOT NULL COMMENT '创建时整柜报关聚合版本', + source_snapshot_json JSON NOT NULL COMMENT '提交时资料、回执和确认版本快照', + customs_entry_no VARCHAR(64) NULL COMMENT '报关单号', + submitted_by BIGINT UNSIGNED NULL COMMENT '正式申报提交人', + submitted_by_name VARCHAR(64) NULL COMMENT '正式申报提交人名称快照', + submitted_at DATETIME(3) NULL COMMENT '正式申报提交时间', + acknowledged_by BIGINT UNSIGNED NULL COMMENT '报关行确认受理人', + acknowledged_by_name VARCHAR(64) NULL COMMENT '报关行确认受理人名称快照', + acknowledged_at DATETIME(3) NULL COMMENT '报关行确认受理时间', + remark VARCHAR(500) NULL COMMENT '批次说明', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_declaration_batch_container(customs_container_id, id), + KEY idx_customs_declaration_batch_plan(loading_plan_id,status,created_at), + CONSTRAINT fk_customs_declaration_batch_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), + CONSTRAINT chk_customs_declaration_batch_status CHECK(status IN ('READY','SUBMITTED','ACKNOWLEDGED','RESULT_PENDING','CLOSED','CANCELLED')) +) ENGINE=InnoDB COMMENT='整柜正式申报批次事实'; + +CREATE TABLE fl_customs_clearance_result ( + id BIGINT UNSIGNED NOT NULL COMMENT '海关结果事实ID', + declaration_batch_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式申报批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + result_type VARCHAR(32) NOT NULL COMMENT '结果类型:INSPECTION/SUPPLEMENT_REQUIRED/RELEASED/DETAINED/OTHER', + result_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '结果状态:OPEN/RESOLVED/CANCELLED', + customs_entry_no VARCHAR(64) NULL COMMENT '报关单号快照', + occurred_at DATETIME(3) NOT NULL COMMENT '海关事实发生时间', + resolution VARCHAR(1000) NULL COMMENT '处理结果或异常说明', + evidence_file_id BIGINT UNSIGNED NULL COMMENT '结果凭证File文件ID', + recorded_by BIGINT UNSIGNED NOT NULL COMMENT '平台录入人', + recorded_by_name VARCHAR(64) NULL COMMENT '平台录入人名称快照', + recorded_at DATETIME(3) NOT NULL COMMENT '平台录入时间', + previous_result_id BIGINT UNSIGNED NULL COMMENT '前序结果事实ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '结果创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '结果更新时间', + PRIMARY KEY(id), + KEY idx_customs_clearance_result_batch(declaration_batch_id,occurred_at,id), + KEY idx_customs_clearance_result_container(customs_container_id, result_type, result_status), + CONSTRAINT fk_customs_clearance_result_batch FOREIGN KEY(declaration_batch_id) REFERENCES fl_customs_declaration_batch(id), + CONSTRAINT chk_customs_clearance_result_type CHECK(result_type IN ('INSPECTION','SUPPLEMENT_REQUIRED','RELEASED','DETAINED','OTHER')), + CONSTRAINT chk_customs_clearance_result_status CHECK(result_status IN ('OPEN','RESOLVED','CANCELLED')) +) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实'; + CREATE TABLE fl_document_sequence ( sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET', current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号', @@ -2286,8 +2402,8 @@ CREATE TABLE fl_inbound_fee ( fee_version int unsigned NOT NULL DEFAULT '1' COMMENT '费用快照版本', charge_rule_id bigint unsigned DEFAULT NULL COMMENT '历史客户计费规则ID;渠道价格可为空', charge_rule_version int unsigned DEFAULT NULL COMMENT '历史客户规则版本快照', - carrier_rate_id bigint unsigned NOT NULL COMMENT '船司FBA价格ID', - carrier_rate_version int unsigned NOT NULL COMMENT '船司价格版本快照', + carrier_rate_id bigint unsigned DEFAULT NULL COMMENT '船司FBA价格ID;人工报价为空', + carrier_rate_version int unsigned DEFAULT NULL COMMENT '船司价格版本快照;人工报价为空', price_book_version_id bigint unsigned DEFAULT NULL COMMENT '整表价格版本ID快照', rule_code varchar(50) DEFAULT NULL COMMENT '历史客户规则编码快照', customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID快照', @@ -2309,7 +2425,7 @@ CREATE TABLE fl_inbound_fee ( destination_warehouse_id bigint unsigned DEFAULT NULL COMMENT '海外仓或FBA目的仓ID快照', destination_name varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', destination_code varchar(32) DEFAULT NULL COMMENT 'FBA仓编码快照;海外仓可为空', - rate_effective_from datetime(3) NOT NULL COMMENT '价格生效开始快照', + rate_effective_from datetime(3) DEFAULT NULL COMMENT '价格生效开始快照;人工报价为空', rate_effective_to datetime(3) DEFAULT NULL COMMENT '价格生效结束快照', charge_mode varchar(16) DEFAULT NULL COMMENT '计费口径:KG或CBM', conversion_factor decimal(18,6) DEFAULT NULL COMMENT '历史客户规则使用的换算系数快照', @@ -3313,18 +3429,25 @@ CREATE TABLE fl_mojia_measurement_receipt ( id bigint unsigned NOT NULL COMMENT '墨家联调收件记录ID', tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', subject_id bigint unsigned NOT NULL COMMENT '设备应用操作主体ID', - box_code varchar(128) NOT NULL COMMENT '设备扫描的原始箱码', + box_code varchar(128) NOT NULL COMMENT '设备扫描的原始箱码或不带#的SKU', + inbound_code varchar(64) DEFAULT NULL COMMENT '箱码命中后的权威入仓码;SKU记录为空', + measurement_kind varchar(16) NOT NULL DEFAULT 'BOX_LABEL' COMMENT '测量分流:BOX_LABEL箱唛;SKU一件代发SKU', scanned_shipment_id varchar(64) DEFAULT NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', length_cm decimal(12,3) NOT NULL COMMENT '设备实测长度cm', width_cm decimal(12,3) NOT NULL COMMENT '设备实测宽度cm', height_cm decimal(12,3) NOT NULL COMMENT '设备实测高度cm', - weight_kg decimal(18,3) DEFAULT NULL COMMENT '设备实测重量kg', + weight_kg decimal(18,3) NOT NULL COMMENT '设备实测重量kg', machine_no varchar(64) NOT NULL COMMENT '设备编号', measured_at datetime(3) NOT NULL COMMENT '设备测量时间或服务接收时间', image_file_id bigint unsigned NOT NULL COMMENT 'File服务中的墨家联调图片ID', - status varchar(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '测量回执匹配状态:UNMATCHED未匹配;当前首版仅记录未匹配回执', - match_failure_code varchar(16) NOT NULL COMMENT '正式业务匹配失败码', - match_failure_reason varchar(500) NOT NULL COMMENT '脱敏后的匹配失败原因', + status varchar(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '原始收件状态;SKU记录使用SKU_PENDING_SYNC', + sync_status varchar(24) DEFAULT NULL COMMENT 'SKU同步状态:PENDING/SYNCING/SUCCESS/FAILED', + sync_failure_code varchar(64) DEFAULT NULL COMMENT 'SKU同步失败业务码', + sync_failure_reason varchar(500) DEFAULT NULL COMMENT '脱敏后的SKU同步失败原因', + lingxing_sku_id varchar(128) DEFAULT NULL COMMENT '领星SKU内部标识', + revision int NOT NULL DEFAULT 1 COMMENT '当前测量版本号', + match_failure_code varchar(16) DEFAULT NULL COMMENT '正式箱唛业务匹配失败码', + match_failure_reason varchar(500) DEFAULT NULL COMMENT '脱敏后的箱唛匹配失败原因', request_id varchar(128) DEFAULT NULL COMMENT '请求链路ID', trace_id varchar(128) DEFAULT NULL COMMENT '调用链ID', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -5999,7 +6122,7 @@ INSERT INTO iam_membership_role (membership_id, role_id, tenant_id, created_at) -- 系统基线 iam_number_sequence:1 行。 INSERT INTO iam_number_sequence (sequence_key, next_value, updated_at) VALUES ('CUSTOMER_CODE',1,CURRENT_TIMESTAMP(3)); --- 系统基线 iam_permission:364 行。 +-- 系统基线 iam_permission:367 行。 INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100000,0,'PLATFORM','MENU',0,'platform:customer','客户管理','/customers',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100010,0,'PLATFORM','MENU',5100000,'platform:customer:list','企业客户','/customers/list',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100011,0,'PLATFORM','BUTTON',5100010,'platform:customer:create','新增企业客户',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6113,6 +6236,9 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102127,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:delivery:complete','完成派送',NULL,NULL,200,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102128,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:picking:edit','执行拣货',NULL,NULL,210,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102129,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:picking:view','查看拣货任务',NULL,NULL,220,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102131,0,'PLATFORM','MENU',5103000,'platform:mojia-sku-sync:list','称重机器','/general/mojia-sku-sync','GET',130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102132,0,'PLATFORM','BUTTON',5102131,'platform:mojia-sku-sync:edit','编辑SKU测量数据',NULL,'PUT',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102133,0,'PLATFORM','BUTTON',5102131,'platform:mojia-sku-sync:retry','重新推送SKU测量',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6378,7 +6504,7 @@ INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM','ACTIVE','负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000100,0,'MERCHANT','MERCHANT_OWNER','企业管理员','SYSTEM','ACTIVE','一期企业全部菜单和操作按钮模板',1000001,CURRENT_TIMESTAMP(3),1000001,CURRENT_TIMESTAMP(3),0,0); --- 系统基线 iam_role_permission:539 行。 +-- 系统基线 iam_role_permission:545 行。 INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100000,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100010,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100011,0,CURRENT_TIMESTAMP(3)); @@ -6489,6 +6615,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102127,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102128,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102129,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102131,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102132,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102133,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102140,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102142,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102146,0,CURRENT_TIMESTAMP(3)); @@ -6753,6 +6882,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102127,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102128,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102129,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102131,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102132,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102133,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102140,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102142,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102146,0,CURRENT_TIMESTAMP(3)); @@ -6803,6 +6935,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102031,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102124,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102125,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102100,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102103,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5102191,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107000,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107010,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107016,0,CURRENT_TIMESTAMP(3)); @@ -7735,4 +7870,276 @@ USE baiyue_finance; -- 系统基线 fin_subject:1 行。 INSERT INTO fin_subject (id, subject_code, subject_name, status, version, created_by, updated_by, created_at, updated_at) VALUES (1,'PLATFORM_DEFAULT','平台','ACTIVE',0,0,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); -SELECT 'PASS' AS initialization_status, 'CN' AS region, 210 AS initialized_tables; +-- phase4-sailing-calendar-schema.sql + +SET NAMES utf8mb4; +USE baiyue_firstleg; + +-- 船期计划日历首版基线。与 fl_port_schedule(港务船期观测)分离。 +CREATE TABLE fl_sailing_channel ( + id BIGINT NOT NULL, + channel_code VARCHAR(64) NOT NULL, + channel_name VARCHAR(128) NOT NULL, + source_type VARCHAR(24) NOT NULL, + mapped_channel_code VARCHAR(64) NULL, + default_departure_port_code VARCHAR(32) NULL, + default_destination_port_code VARCHAR(32) NULL, + default_cutoff_days TINYINT NOT NULL DEFAULT 3, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_channel_code (channel_code), + KEY idx_fl_sailing_channel_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE fl_sailing_template ( + id BIGINT NOT NULL, + template_no VARCHAR(64) NOT NULL, + template_name VARCHAR(128) NOT NULL, + channel_id BIGINT NOT NULL, + channel_code_snapshot VARCHAR(64) NOT NULL, + channel_name_snapshot VARCHAR(128) NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_name_snapshot VARCHAR(128) NULL, + departure_port_code VARCHAR(32) NOT NULL, + destination_port_code VARCHAR(32) NOT NULL, + weekday TINYINT NOT NULL, + weekday_mask TINYINT NOT NULL DEFAULT 1, + etd_local_time TIME NOT NULL, + cutoff_days TINYINT NOT NULL DEFAULT 3, + cutoff_local_time TIME NOT NULL DEFAULT '18:00:00', + valid_from DATE NOT NULL, + valid_to DATE NULL, + voyage_rule VARCHAR(64) NULL, + vessel_name VARCHAR(128) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_template_no (template_no), + KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday), + CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7), + CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE fl_sailing ( + id BIGINT NOT NULL, + sailing_no VARCHAR(64) NOT NULL, + template_id BIGINT NULL, + channel_id BIGINT NOT NULL, + channel_code_snapshot VARCHAR(64) NOT NULL, + channel_name_snapshot VARCHAR(128) NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_name_snapshot VARCHAR(128) NULL, + departure_port_code VARCHAR(32) NOT NULL, + destination_port_code VARCHAR(32) NOT NULL, + vessel_name VARCHAR(128) NULL, + voyage_no VARCHAR(64) NULL, + etd_at DATETIME(3) NOT NULL, + warehouse_cutoff_at DATETIME(3) NOT NULL, + eta_at DATETIME(3) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'OPEN', + source_type VARCHAR(24) NOT NULL DEFAULT 'MANUAL', + generated_key VARCHAR(192) NULL, + planned_capacity_cbm DECIMAL(14,3) NULL, + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_no (sailing_no), + UNIQUE KEY uk_fl_sailing_generated_key (generated_key), + UNIQUE KEY uk_fl_sailing_business (warehouse_id,channel_code_snapshot,departure_port_code,destination_port_code,etd_at,voyage_no), + KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status), + KEY idx_fl_sailing_route (departure_port_code,destination_port_code), + CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE fl_sailing_history ( + id BIGINT NOT NULL, + sailing_id BIGINT NOT NULL, + action VARCHAR(32) NOT NULL, + from_status VARCHAR(16) NULL, + to_status VARCHAR(16) NULL, + reason VARCHAR(1000) NULL, + operator_id BIGINT NULL, + operator_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +USE baiyue_iam; + +INSERT INTO iam_permission + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5102206,0,'PLATFORM','MENU',5102000,'platform:firstleg:sailing-calendar:list','船期日历','/sailing-calendar','GET',125,'ACTIVE',1,0), + (5102207,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-calendar:create','新增船期','/sailing-calendar','POST',10,'ACTIVE',1,0), + (5102208,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-calendar:edit','编辑船期','/sailing-calendar','PUT',20,'ACTIVE',1,0), + (5102209,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-template:manage','管理渠道与周模板','/sailing-calendar','POST',30,'ACTIVE',1,0), + (5102210,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-booking:link','关联订舱','/sailing-calendar','POST',40,'ACTIVE',1,0), + (5102211,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-volume:forecast-manage','维护货量预报','/sailing-calendar','POST',50,'ACTIVE',1,0), + (5102212,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-volume:exception-reassign','异常重新安排','/sailing-calendar','POST',60,'ACTIVE',1,0); + +INSERT INTO iam_role_permission(role_id,permission_id,tenant_id) +SELECT r.id,p.id,0 FROM iam_role r JOIN iam_permission p + ON p.tenant_id=0 AND p.app_code='PLATFORM' + AND p.permission_code IN ( + 'platform:firstleg:sailing-calendar:list', + 'platform:firstleg:sailing-calendar:create', + 'platform:firstleg:sailing-calendar:edit', + 'platform:firstleg:sailing-template:manage', + 'platform:firstleg:sailing-booking:link', + 'platform:firstleg:sailing-volume:forecast-manage', + 'platform:firstleg:sailing-volume:exception-reassign' + ) +WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); + +-- phase4-sailing-facts-schema.sql + +SET NAMES utf8mb4; +USE baiyue_firstleg; + +/* + * 船期日历的事实契约。 + * + * 这些表不修改入库单或海运订舱聚合:入库单仍是履约主数据,订舱仍是 + * RELEASED/DRAFT 聚合。船期日历只消费这里的稳定事实和审计版本。 + */ + +CREATE TABLE fl_sailing_receipt_batch_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '稳定收货批次事实ID(receiptBatchId)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实', + receipt_batch_key VARCHAR(64) NOT NULL COMMENT '同一物理收货批次的不可变业务键', + fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一批次复测/冲正版本', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + measurement_id BIGINT UNSIGNED NULL COMMENT '最终审核测量批次ID', + receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际收货仓ID', + channel_code VARCHAR(64) NULL COMMENT '收货渠道编码快照', + channel_name VARCHAR(128) NULL COMMENT '收货渠道名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', + destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', + received_at DATETIME(3) NOT NULL COMMENT '实际收货完成时间,不使用入库单inbounded_at替代', + actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '最终审核实测体积m3', + fact_status VARCHAR(24) NOT NULL DEFAULT 'APPROVED' COMMENT '事实状态:APPROVED/REVERSED/VOIDED/SUPERSEDED', + reversal_of_fact_id BIGINT UNSIGNED NULL COMMENT '冲正所替代的事实ID', + source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_RECEIPT' COMMENT '事实来源', + created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_receipt_batch_version (tenant_id, receipt_batch_key, fact_version), + KEY idx_sailing_receipt_batch_current (tenant_id, receipt_batch_key, fact_status, fact_version), + KEY idx_sailing_receipt_match (tenant_id, receiving_warehouse_id, channel_code, received_at, id), + KEY idx_sailing_receipt_inbound (tenant_id, inbound_id, received_at, id), + CONSTRAINT chk_sailing_receipt_volume CHECK (actual_volume_cbm > 0), + CONSTRAINT chk_sailing_receipt_status CHECK (fact_status IN ('APPROVED','REVERSED','VOIDED','SUPERSEDED')) +) ENGINE=InnoDB COMMENT='船期日历稳定收货批次实测事实及版本历史'; + +CREATE TABLE fl_sailing_inbound_volume_forecast ( + id BIGINT UNSIGNED NOT NULL COMMENT '货量预报事实ID(forecastId)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实', + forecast_key VARCHAR(64) NOT NULL COMMENT '同一未收货对象的不可变业务键', + fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '预报修订版本', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '预计收货仓ID', + channel_code VARCHAR(64) NULL COMMENT '预计渠道编码快照', + channel_name VARCHAR(128) NULL COMMENT '预计渠道名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '预计起运港编码快照', + destination_port_code VARCHAR(32) NULL COMMENT '预计目的港编码快照', + expected_arrival_at DATETIME(3) NOT NULL COMMENT '预计到仓时间', + forecast_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '未收货预计体积m3,不得使用申报体积代替', + source_type VARCHAR(32) NOT NULL COMMENT '预报来源,例如OPERATIONS/UPSTREAM_MEASUREMENT;不得使用客户申报体积冒充预报', + fact_status VARCHAR(24) NOT NULL DEFAULT 'VALID' COMMENT '事实状态:VALID/SUPERSEDED/VOIDED/CONSUMED', + effective_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '本版本生效时间', + created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_forecast_version (tenant_id, forecast_key, fact_version), + KEY idx_sailing_forecast_current (tenant_id, forecast_key, fact_status, fact_version), + KEY idx_sailing_forecast_match (tenant_id, receiving_warehouse_id, channel_code, expected_arrival_at, id), + KEY idx_sailing_forecast_inbound (tenant_id, inbound_id, expected_arrival_at, id), + CONSTRAINT chk_sailing_forecast_volume CHECK (forecast_volume_cbm > 0), + CONSTRAINT chk_sailing_forecast_status CHECK (fact_status IN ('VALID','SUPERSEDED','VOIDED','CONSUMED')) +) ENGINE=InnoDB COMMENT='船期日历未收货量预报事实及版本历史'; + +CREATE TABLE fl_sailing_assignment ( + id BIGINT UNSIGNED NOT NULL COMMENT '归班事实ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', + sailing_id BIGINT UNSIGNED NULL COMMENT '船期实例ID,UNMATCHED为空', + origin_sailing_id BIGINT UNSIGNED NULL COMMENT '异常重排/取消前的原船期实例ID', + inbound_id BIGINT UNSIGNED NULL COMMENT '归班对象入库单ID', + inbound_code VARCHAR(64) NULL COMMENT '入仓码快照,仅用于展示', + receipt_batch_id BIGINT UNSIGNED NULL COMMENT '收货批次事实ID', + forecast_id BIGINT UNSIGNED NULL COMMENT '货量预报事实ID', + assignment_source VARCHAR(24) NOT NULL DEFAULT 'AUTO' COMMENT '归班来源:AUTO/MANUAL/ROLLOVER', + assignment_version INT UNSIGNED NOT NULL COMMENT '同一对象的归班版本', + match_status VARCHAR(24) NOT NULL COMMENT 'FORECAST_MATCHED/RECEIVED/LATE/UNMATCHED/SUPERSEDED', + volume_cbm DECIMAL(18,4) NULL COMMENT '已收实测体积', + forecast_volume_cbm DECIMAL(18,4) NULL COMMENT '未收有效预计体积', + route_source VARCHAR(32) NULL COMMENT '航线来源:INBOUND_FACT/FORECAST_FACT/CHANNEL_DEFAULT', + volume_source VARCHAR(32) NULL COMMENT 'MEASURED_ACTUAL或预报来源', + rule_version VARCHAR(64) NOT NULL COMMENT '自动归班规则版本', + matched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + superseded_at DATETIME(3) NULL, + locked_at DATETIME(3) NULL COMMENT '截止/订舱/关闭后冻结时间', + lock_reason VARCHAR(32) NULL COMMENT 'CUTOFF/BOOKING_CONFIRMED/CLOSED', + operator_id BIGINT UNSIGNED NULL, + reason VARCHAR(500) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sailing_assignment_current (tenant_id, sailing_id, match_status, matched_at, id), + KEY idx_sailing_assignment_origin (tenant_id, origin_sailing_id, match_status, id), + KEY idx_sailing_assignment_receipt (tenant_id, receipt_batch_id, assignment_version, id), + KEY idx_sailing_assignment_forecast (tenant_id, forecast_id, assignment_version, id), + KEY idx_sailing_assignment_inbound (tenant_id, inbound_id, match_status, id), + CONSTRAINT chk_sailing_assignment_status CHECK (match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED','SUPERSEDED')), + CONSTRAINT chk_sailing_assignment_source CHECK (assignment_source IN ('AUTO','MANUAL','ROLLOVER')) +) ENGINE=InnoDB COMMENT='船期日历自动归班事实及不可变版本历史'; + +CREATE TABLE fl_sailing_booking_link ( + id BIGINT UNSIGNED NOT NULL COMMENT '船期-订舱关联ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台关联隔离键', + sailing_id BIGINT UNSIGNED NOT NULL COMMENT '计划船期实例ID', + ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', + link_status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '关联状态:ACTIVE/REVOKED', + active_booking_guard TINYINT NULL COMMENT 'ACTIVE为1,REVOKED为空,用于唯一约束', + booking_status_snapshot VARCHAR(24) NOT NULL COMMENT '关联时订舱状态快照;只有RELEASED可建立', + warehouse_id BIGINT UNSIGNED NULL COMMENT '关联仓库快照', + channel_code VARCHAR(64) NULL COMMENT '关联渠道快照', + departure_port_code VARCHAR(32) NULL COMMENT '关联起运港快照', + destination_port_code VARCHAR(32) NULL COMMENT '关联目的港快照', + linked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + operator_id BIGINT UNSIGNED NULL, + operator_name VARCHAR(64) NULL, + reason VARCHAR(500) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_booking_active (ocean_booking_id, active_booking_guard), + KEY idx_sailing_booking_link (sailing_id, link_status, ocean_booking_id), + KEY idx_sailing_booking_booking (ocean_booking_id, link_status, id), + CONSTRAINT chk_sailing_booking_link_status CHECK (link_status IN ('ACTIVE','REVOKED')), + CONSTRAINT chk_sailing_booking_link_guard CHECK ((link_status='ACTIVE' AND active_booking_guard=1) OR (link_status='REVOKED' AND active_booking_guard IS NULL)), + CONSTRAINT chk_sailing_booking_released CHECK (booking_status_snapshot='RELEASED') +) ENGINE=InnoDB COMMENT='船期计划与已放舱订舱的最小关联及历史'; + +SELECT 'PASS' AS initialization_status, 'CN' AS region, 223 AS initialized_tables; diff --git a/deploy/sql/数据库迁移操作手册.md b/deploy/sql/数据库迁移操作手册.md index 99b306ee9..bb31bca76 100644 --- a/deploy/sql/数据库迁移操作手册.md +++ b/deploy/sql/数据库迁移操作手册.md @@ -11,6 +11,22 @@ 当前测试分支只完成了版本迁移的离线工具和目录骨架。没有冻结基线、没有具体业务变更时,不要复制初始化 SQL 生成 `V1`,也不要连接测试或生产数据库试跑。 +## 编号与功能分支规则(2026-09-22 确认) + +SQL 编号统一保持 **`V1、V2、V3、V4、V5……`**,不使用时间戳。每个数据库独立计数,由工具生成下一个整数;数字变长不需要重置或重新编号。 + +- 有数据库逻辑变更才新增迁移;普通提交、重复测试和重复部署不增加 SQL 编号。 +- 同一未冻结、未在保留历史环境执行的变更可以反复编辑;成功执行后不改内容、不改编号,修复新增迁移。失败候选的受控修订仍按方案第 13.2.1 节处理。 +- 功能分支从最新 `main` 创建;进入共享测试前先拉取并合并最新 `main`,解决代码和同库编号冲突。只允许调整尚未冻结、未在保留历史环境执行的草稿。 +- 两人并行开发,共享测试、合并和发布依次进行。前一功能完成发布或处理失败后,下一功能同步 main 再进入测试;若前一功能留下未合并 SQL 或未决失败,先处理测试起点,不能直接继续验收。 +- 测试后 main 又变化,必须重新同步和验证。合并 main 后构建正式候选并再次自动验证,生产使用这份正式候选,不重新构建或修改 SQL。 + +**生产发布版本另外记为 `release-1、release-2……`,不与 SQL 编号绑定。** 例如 `release-12` 中 Auth 是 `V3`、Order 是 `V7`;只有代码变化也可以发布 `release-13`,SQL 编号保持不变。 + +目标流程由 Jenkins 为新的生产候选自动分配发布编号;成功后更新当前生产版本并记录 Git 标签、完整提交、镜像 digest 和 SQL 清单。失败编号保留且不复用,同一制品重试沿用原编号;修改制品后分配新编号并重新验证。main 是已合并代码,生产实际版本以成功发布记录为准;发布失败时还需核对各库、各服务的实际状态,不能假定全部未变。 + +当前工具只依据当前分支的迁移目录生成整数编号;跨分支冲突预检、main 同步门禁、`release-N` 分配和标签归档尚待接入,不因写入本手册就视为已自动执行。 + ## 日常开发:新建和修改迁移 以下命令均从 `baiyue-firstleg` 根目录执行,PowerShell 和 Bash 都可以使用 Node 命令。 @@ -136,6 +152,7 @@ node deploy/sql/scripts/db-migrations.mjs run ` - 生产或测试数据库基线登记; - Flyway 实际执行和历史表校验; - Jenkins 的数据库阶段、环境锁和构建/部署解耦; +- 功能分支同步检查、合并后候选验证、生产 `release-N` 自动编号及发布标签归档; - 真实测试环境部署与生产晋级。 因此现在可以开始编写和评审迁移文件,但不能把离线检查通过当作测试或生产发布通过。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 4249d4ce3..176fe0178 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -140,10 +140,12 @@ CREATE TABLE ll_cargo_discard_item ( id bigint NOT NULL COMMENT '主键ID', application_id bigint NOT NULL COMMENT '申请单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - order_id bigint NOT NULL COMMENT '订单ID', + order_id bigint DEFAULT NULL COMMENT '一件代发订单ID', + source_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON', + return_carton_id bigint DEFAULT NULL COMMENT '退货库存箱ID', inbound_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号快照', - profile_id bigint NOT NULL COMMENT '托盘档案ID', - sku_inventory_id bigint NOT NULL COMMENT 'SKU库存记录ID', + profile_id bigint DEFAULT NULL COMMENT '托盘档案ID', + sku_inventory_id bigint DEFAULT NULL COMMENT 'SKU库存记录ID', sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称', available_packages_snapshot int NOT NULL COMMENT '可用箱数快照', @@ -157,6 +159,7 @@ CREATE TABLE ll_cargo_discard_item ( UNIQUE KEY uk_ll_cargo_discard_item (application_id,profile_id,sku_code), KEY idx_ll_cargo_discard_item_list (customer_id,application_id), KEY idx_ll_cargo_discard_item_inbound (customer_id,inbound_code,sku_code) + ,KEY idx_ll_cargo_discard_return_carton (customer_id,return_carton_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='货物废弃SKU明细'; CREATE TABLE ll_cargo_discard_status_history ( @@ -2189,7 +2192,7 @@ CREATE TABLE ll_relabel_application ( PRIMARY KEY (id), UNIQUE KEY uk_ll_relabel_application_no (customer_id,relabel_no), UNIQUE KEY uk_ll_relabel_application_idempotency (customer_id,idempotency_key), - UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id), + KEY idx_ll_relabel_application_order (lastleg_order_id), KEY idx_ll_relabel_application_list (customer_id,status,created_at), KEY idx_ll_relabel_application_customer (customer_id,customer_order_no), KEY idx_ll_relabel_application_warehouse (customer_id,overseas_warehouse_id) @@ -2384,8 +2387,8 @@ CREATE TABLE ll_return_inventory_carton ( UNIQUE KEY uk_ll_return_inventory_carton_mark (carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt (source_receipt_id), KEY idx_ll_return_inventory_carton_customer (customer_id,overseas_warehouse_id,status,created_at), - CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUNDED')) and (sku_types > 0) and (total_quantity > 0)) or ((status = _utf8mb4'MERGED') and (sku_types = 0) and (total_quantity = 0)))), - CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED'))) + CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING')) and (sku_types > 0) and (total_quantity > 0)) or ((status in (_utf8mb4'MERGED',_utf8mb4'DISCARDED')) and (sku_types = 0) and (total_quantity = 0)))), + CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING',_utf8mb4'DISCARDED'))) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物库存箱及箱唛'; CREATE TABLE ll_return_inventory_carton_item ( @@ -2917,9 +2920,9 @@ CREATE TABLE wh_location ( id bigint unsigned NOT NULL COMMENT '库位ID(雪花ID)', customer_id bigint unsigned NOT NULL COMMENT '数据所属租户ID', warehouse_id bigint unsigned NOT NULL COMMENT '所属仓库ID', - zone_code varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '仓库分区编码', location_code varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '仓库内唯一库位编码', location_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库位类型', + shared_overflow tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否系统共享溢出库位(A0)', max_load_kg decimal(18,3) NOT NULL COMMENT '最大承重(千克)', status varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', @@ -2931,7 +2934,7 @@ CREATE TABLE wh_location ( version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_location_code (customer_id,warehouse_id,location_code,deleted), - KEY idx_location_query (customer_id,warehouse_id,zone_code,status,deleted) + KEY idx_location_query (customer_id,warehouse_id,status,deleted) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; CREATE TABLE wh_pallet ( diff --git a/docs/lingxing-browser-dev-test.md b/docs/lingxing-browser-dev-test.md new file mode 100644 index 000000000..c28f19f0c --- /dev/null +++ b/docs/lingxing-browser-dev-test.md @@ -0,0 +1,50 @@ +# 领星浏览器登录态开发测试 + +开发服务器为 Ubuntu 22.04.5 LTS。开发阶段先验证持久化 Chromium/Chrome + CDP,不调用领星新品测量接口。 + +## 1. 启动持久化 Chromium(Ubuntu 22.04) + +服务器上使用专用浏览器 profile,不复用系统默认用户目录: + +```bash +sudo apt-get update +sudo apt-get install -y chromium-browser xvfb +sudo mkdir -p /opt/lingxing/browser-profile +sudo chown -R "$USER":"$USER" /opt/lingxing + +nohup xvfb-run --server-num=99 --server-args='-screen 0 1920x1080x24' \ + chromium-browser \ + --no-sandbox \ + --disable-dev-shm-usage \ + --remote-debugging-address=127.0.0.1 \ + --remote-debugging-port=9222 \ + --user-data-dir=/opt/lingxing/browser-profile \ + --window-size=1920,1080 \ + https://oms.xlwms.com \ + >/var/log/lingxing-chromium.log 2>&1 & +``` + +若系统命令名是 `chromium` 而非 `chromium-browser`,替换命令名即可。通过临时 VNC/远程桌面完成人工登录和二次验证码;后续复用该 profile。不要把真实账号、密码、验证码或 profile 目录提交到仓库。 + +建议通过 SSH 隧道访问 CDP,不要把 9222 暴露到公网: + +```bash +ssh -N -L 9222:127.0.0.1:9222 @ +``` + +## 2. 验证 CDP 页面 + +```bash +curl http://127.0.0.1:9222/json/version +curl http://127.0.0.1:9222/json/list +``` + +必须能看到 `https://oms.xlwms.com` 页面和 `webSocketDebuggerUrl`。页面仍在 `/login` 时先完成人工二次验证,读取器不会绕过验证码。Java 读取入口是: + +```java +new LingxingBrowserSessionReader(objectMapper).read( + new LingxingProviderConfig("LINGXING_01", "http://127.0.0.1:9222", true, 172800, 300), + "https://oms.xlwms.com"); +``` + +成功时只在内存返回 Token、Cookie 和过期时间;日志不得输出完整凭证。页面回到 `/login`、没有 `oms-token` 或没有 OMS Cookie 时,应判定为需要人工登录。 diff --git a/docs/领星新品测量接口说明.md b/docs/领星新品测量接口说明.md new file mode 100644 index 000000000..a62a71889 --- /dev/null +++ b/docs/领星新品测量接口说明.md @@ -0,0 +1,210 @@ +# 领星新品测量接口说明 + +> 依据开发环境实际成功请求整理。接口属于领星 WMS 内部网关接口,需要携带已经登录领星 WMS 后取得的 Token 和 Cookie。账号、密码、Token、Cookie 不写入本文档。 + +## 1. 业务流程 + +墨家上传不带 `#` 的 SKU 后,按以下流程处理: + +```text +墨家 SKU 测量数据 + ↓ +查询领星新品 SKU(US01、US02 分别查询) + ↓ +精确匹配 productSku 或 barcode + ↓ +maintained = 1:禁止再次维护,记录失败原因 +maintained = 0:调用新品测量维护接口 + ↓ +记录同步成功或失败 +``` + +箱唛(`boxCode` 包含 `#`)继续走现有箱唛测量逻辑,不调用本文档接口。 + +## 2. 查询 SKU + +### 请求 + +```http +POST https://wms.xlwms.com/gateway/wms/newProduct/page +Content-Type: application/json +Authorization: Bearer {token} +Cookie: {领星登录Cookie} +``` + +请求体: + +```json +{ + "barcode": "A120133-A-TK", + "current": 1, + "customerCode": "", + "keyword": "A120133-A-TK", + "multiKeyword": "", + "searchType": "barcode", + "size": 20, + "whCode": "US01" +} +``` + +`US02` 查询时只替换: + +```json +"whCode": "US02" +``` + +### 成功响应示例 + +```json +{ + "code": 200, + "data": { + "records": [ + { + "tenantCode": "1189", + "whCode": "US01", + "customerCode": "1189060", + "customerName": "DF067", + "skuId": "", + "productSku": "A120133-A-TK", + "productName": "(压缩)2026新款30CM耶稣玩偶", + "productLname": "", + "barcode": "A120133-A-TK", + "length": 23.000, + "lengthBs": 9.055, + "width": 15.000, + "widthBs": 5.906, + "height": 3.500, + "heightBs": 1.378, + "sizeUnit": "cm", + "weight": 0.150, + "weightBs": 0.331, + "weightUnit": "kg", + "wmsLength": 0.000, + "wmsLengthBs": 0.000, + "wmsWidth": 0.000, + "wmsWidthBs": 0.000, + "wmsHeight": 0.000, + "wmsHeightBs": 0.000, + "wmsSizeUnit": "cm", + "wmsWeight": 0.000, + "wmsWeightBs": 0.000, + "wmsWeightUnit": "kg", + "wmsUnitMark": 0, + "maintainer": "", + "wmsMaintenanceTime": "", + "maintained": 0 + } + ], + "total": 1, + "size": 20, + "current": 1, + "pages": 1 + }, + "msg": "操作成功" +} +``` + +### 查询规则 + +1. `code = 200` 且 `data.records` 有记录,才算查询成功。 +2. 查询结果必须精确匹配墨家 SKU:`productSku` 或 `barcode` 等于墨家传入的 SKU。 +3. `US01`、`US02` 分别查询,不能把一个仓库的匹配结果当作另一个仓库的结果。 +4. 没有匹配记录时,记录失败原因“未匹配到领星 SKU”,不调用维护接口。 +5. 保存 `tenantCode`、`whCode`、`customerCode`、`customerName`、`productSku`、`barcode`、`maintained` 和当前领星尺寸重量,供页面追踪。 + +## 3. 新品测量维护 + +### 请求 + +```http +POST https://wms.xlwms.com/gateway/wms/newProduct/measure +Content-Type: application/json +Authorization: Bearer {token} +Cookie: {领星登录Cookie} +``` + +维护请求以查询返回的 SKU 记录为基础,只把 `wmsLength`、`wmsWidth`、`wmsHeight`、`wmsWeight` 替换为墨家测量值,并保留领星返回的业务标识字段。 + +成功样例: + +```json +{ + "barcode": "A120133-A-TK", + "customerCode": "1189060", + "customerName": "DF067", + "height": 3.5, + "heightBs": 1.378, + "length": 23, + "lengthBs": 9.055, + "maintained": 0, + "maintainer": "", + "productLname": "", + "productName": "(压缩)2026新款30CM耶稣玩偶", + "productSku": "A120133-A-TK", + "sizeUnit": "in", + "skuId": "", + "tenantCode": "1189", + "weight": 0.15, + "weightBs": 0.331, + "weightUnit": "lb", + "whCode": "US01", + "width": 15, + "widthBs": 5.906, + "wmsHeight": "5", + "wmsHeightBs": 1.969, + "wmsLength": "24.5", + "wmsLengthBs": 9.646, + "wmsMaintenanceTime": "", + "wmsSizeUnit": "cm", + "wmsUnitMark": 0, + "wmsWeight": "0.15", + "wmsWeightBs": 0.331, + "wmsWeightUnit": "kg", + "wmsWidth": "18.5", + "wmsWidthBs": 7.283 +} +``` + +### 成功响应 + +```json +{ + "code": 200, + "data": {}, + "msg": "操作成功" +} +``` + +`data` 为空对象时,只要 `code = 200`,即视为维护成功。 + +## 4. 单次维护限制 + +领星新品测量只能维护一次,不能重复覆盖: + +- 查询结果 `maintained = 0`:允许调用 `/measure`。 +- 查询结果 `maintained = 1`:禁止调用 `/measure`,记录失败原因“领星 SKU 已维护,不能重复维护”。 +- 已经提交过维护但本地没有收到明确成功响应时,不应盲目重试;应重新查询确认 `maintained` 和 `wmsMaintenanceTime`。 +- 本地页面保留墨家测量原始数据和同步失败原因,但不允许通过编辑或重推绕过领星的单次维护限制。 + +## 5. 单位处理 + +当前成功样例中,查询返回的基础字段和维护请求中的单位字段存在差异: + +- 查询结果:`sizeUnit = cm`、`weightUnit = kg`; +- 成功维护请求样例:`sizeUnit = in`、`weightUnit = lb`; +- WMS 维护字段:`wmsSizeUnit = cm`、`wmsWeightUnit = kg`; +- `wmsLength`、`wmsWidth`、`wmsHeight`、`wmsWeight` 在样例中以字符串传递。 + +因此 Java 实现不能根据字段名自行换算或把所有字段统一成同一单位。应以查询返回的原始记录作为模板,仅按领星接口约定替换 WMS 维护字段;具体换算规则以领星成功请求和后续验证结果为准。 + +## 6. 本地同步状态建议 + +| 状态 | 含义 | +| --- | --- | +| 待同步 | 墨家已接收,尚未完成查询和维护 | +| 同步成功 | `/measure` 返回 `code = 200` | +| 同步失败 | 未匹配 SKU、已维护、查询失败、维护失败或登录态失效 | + +失败记录必须保存脱敏后的失败原因;不保存完整 Token、Cookie、账号、密码或验证码。 + diff --git a/file/AGENTS.md b/file/AGENTS.md index 7d16583d5..c4a01d206 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -19,7 +19,7 @@ | 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | | 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 | | 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 | -| 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | +| 报关源文件、报关行回执、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RECEIPT`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | | 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=0`(平台专属,客户可选快照不改变文件归属,拒绝客户作用域)、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 | | 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 10c944048..42add2447 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -31,6 +31,7 @@ enum BusinessFileCategory { OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), + LOADING_LIST_CUSTOMS_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO), ORDER_BATCH_IMPORT_ERROR_REPORT(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO); diff --git a/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java b/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java index 5519973f0..ff4903161 100644 --- a/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java +++ b/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.time.LocalDate; +import org.springframework.util.StringUtils; @Service public class QualificationPackageService { @@ -45,9 +46,10 @@ public class QualificationPackageService { String legalPersonIdDocumentUrl = storedLegalPersonId == null ? null : storedLegalPersonId.documentUrl(); var request = new IamQualificationClient.RequiredPackageRequest( - tenantId, normalizedCreditCode, storedBusinessLicense.documentUrl(), businessLicense.getOriginalFilename(), + tenantId, normalizedCreditCode, storedBusinessLicense.documentUrl(), + originalFileName(businessLicense, storedBusinessLicense, "business-license"), businessLicenseValidTo, replaceBusinessLicense, legalPersonIdDocumentUrl, - legalPersonId == null ? null : legalPersonId.getOriginalFilename(), + legalPersonId == null ? null : originalFileName(legalPersonId, storedLegalPersonId, "legal-person-id"), legalPersonIdValidTo, replaceLegalPersonId); try { var response = platform @@ -87,6 +89,13 @@ public class QualificationPackageService { if (storedFile != null) storage.delete(storedFile); } + private String originalFileName(MultipartFile file, LocalQualificationStorage.StoredFile stored, String fallback) { + String original = file.getOriginalFilename(); + if (StringUtils.hasText(original)) return original; + String storedName = stored == null ? null : stored.fileName(); + return StringUtils.hasText(storedName) ? storedName : fallback; + } + public record PackageUploadResult(Long businessLicenseQualificationId, Long legalPersonIdQualificationId, String tenantStatus, diff --git a/file/src/main/resources/openapi/customs-file-and-export-v1.yaml b/file/src/main/resources/openapi/customs-file-and-export-v1.yaml index f7fa002a8..88e895f73 100644 --- a/file/src/main/resources/openapi/customs-file-and-export-v1.yaml +++ b/file/src/main/resources/openapi/customs-file-and-export-v1.yaml @@ -5,7 +5,7 @@ info: description: | File 只保存不可变文件对象并生成异步 XLSX,不保存报关状态或业务版本。 Firstleg 持有 loadingPlanId、文件版本、完成状态和可见性,并在调用本内部接口前完成 Platform/Client 鉴权。 - LOADING_LIST_CUSTOMS_SOURCE 与 LOADING_LIST_CUSTOMS_RESULT 均为 CUSTOMER owner scope,tenantId 必须大于 0; + LOADING_LIST_CUSTOMS_SOURCE、LOADING_LIST_CUSTOMS_RECEIPT 与 LOADING_LIST_CUSTOMS_RESULT 均为 CUSTOMER owner scope,tenantId 必须大于 0; 客户源文件上传/下载由 Firstleg 校验当前客户,结果文件下载还须由 Firstleg 校验整柜完成和结果可见性。 用户侧权限冻结为:Platform 上传结果 platform:firstleg:customs:manage,Platform 下载 platform:firstleg:customs:list; Client 上传源文件 merchant:order:edit,Client 下载自身源文件或获准结果 merchant:order:view。 @@ -15,7 +15,7 @@ paths: post: summary: 保存报关业务文件对象 description: | - 仅服务令牌可调用。fileCategory 取 LOADING_LIST_CUSTOMS_SOURCE 或 LOADING_LIST_CUSTOMS_RESULT。 + 仅服务令牌可调用。fileCategory 取 LOADING_LIST_CUSTOMS_SOURCE、LOADING_LIST_CUSTOMS_RECEIPT 或 LOADING_LIST_CUSTOMS_RESULT。 支持任意文件格式,单文件最多 50MB。operatorId 为已通过 Firstleg 鉴权的实际操作人。 parameters: - $ref: '#/components/parameters/InternalToken' @@ -94,7 +94,7 @@ components: InternalToken: {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}} OperatorId: {name: X-Operator-Id, in: header, required: true, schema: {type: integer, format: int64, minimum: 1}} TenantId: {name: tenantId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}, description: 客户ID;不得使用平台0作用域} - FileCategory: {name: fileCategory, in: path, required: true, schema: {type: string, enum: [LOADING_LIST_CUSTOMS_SOURCE, LOADING_LIST_CUSTOMS_RESULT]}} + FileCategory: {name: fileCategory, in: path, required: true, schema: {type: string, enum: [LOADING_LIST_CUSTOMS_SOURCE, LOADING_LIST_CUSTOMS_RECEIPT, LOADING_LIST_CUSTOMS_RESULT]}} LoadingPlanId: {name: loadingPlanId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} FileId: {name: fileId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} schemas: diff --git a/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java b/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java index 1252a954f..29fef5e9e 100644 --- a/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java @@ -29,7 +29,7 @@ class CustomsFileExportContractTest { String source = Files.readString(repositoryRoot() .resolve("file/src/main/resources/openapi/customs-file-and-export-v1.yaml")); - assertThat(source).contains("LOADING_LIST_CUSTOMS_SOURCE", "LOADING_LIST_CUSTOMS_RESULT") + assertThat(source).contains("LOADING_LIST_CUSTOMS_SOURCE", "LOADING_LIST_CUSTOMS_RECEIPT", "LOADING_LIST_CUSTOMS_RESULT") .contains("maximum: 52428800", "no-store", "CUSTOMER owner scope", "loadingPlanId") .contains("platform:firstleg:customs:manage", "platform:firstleg:customs:list", "merchant:order:edit", "merchant:order:view") 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 3f1c1fe34..03ef101c5 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -159,7 +159,7 @@ class InternalBusinessFileServiceTest { .containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81"); } - @Test void loadingCustomsSourceAndResultAreCustomerOwnedArbitraryFormatObjects() { + @Test void loadingCustomsSourceReceiptAndResultAreCustomerOwnedArbitraryFormatObjects() { MockMultipartFile file = new MockMultipartFile("file", "资料包.zip", "application/zip", "zip".getBytes()); when(storage.store(eq(10001L), eq("51"), same(file), any(BusinessFileCategory.class))) .thenReturn(new BusinessFileStorage.Stored("business-files/10001/51/key", "application/zip", @@ -169,14 +169,17 @@ class InternalBusinessFileServiceTest { }); var source = service.upload(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file); + var receipt = service.upload(10001L, "LOADING_LIST_CUSTOMS_RECEIPT", "51", 7L, file); var result = service.upload(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 7L, file); assertThat(source).extracting(InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::businessType, InternalBusinessFileService.Metadata::businessId) .containsExactly(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51"); + assertThat(receipt.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RECEIPT"); assertThat(result.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RESULT"); verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_SOURCE); + verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_RECEIPT); verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_RESULT); } @@ -185,6 +188,8 @@ class InternalBusinessFileServiceTest { assertThatThrownBy(() -> service.upload(0L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file)) .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); + assertThatThrownBy(() -> service.upload(0L, "LOADING_LIST_CUSTOMS_RECEIPT", "51", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); assertThatThrownBy(() -> service.metadata(0L, "LOADING_LIST_CUSTOMS_RESULT", "51", 91L)) .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); verifyNoInteractions(files, storage); diff --git a/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java b/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java index 43d1b1944..cf7d0d345 100644 --- a/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java @@ -83,6 +83,25 @@ class QualificationPackageServiceTest { verify(fixture.storage, never()).store(TENANT_ID, fixture.legalPersonId); } + @Test + void missingMultipartOriginalNamesUseStoredNamesForIamRegistration() { + var fixture = fixture(); + var businessLicense = new MockMultipartFile("businessLicense", "", "image/png", new byte[] {1}); + var legalPersonId = new MockMultipartFile("legalPersonId", "", "image/png", new byte[] {2}); + when(fixture.storage.store(TENANT_ID, businessLicense)).thenReturn(fixture.businessStored); + when(fixture.storage.store(TENANT_ID, legalPersonId)).thenReturn(fixture.legalPersonStored); + when(fixture.iamClient.submitRequiredPackage(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new IamQualificationClient.RequiredPackageResponse(31L, 32L, "PROFILE_REQUIRED", null, null))); + + fixture.service.upload(TENANT_ID, businessLicense, legalPersonId, + "91310101abcde12345", null, null, false, false, false); + + var captor = ArgumentCaptor.forClass(IamQualificationClient.RequiredPackageRequest.class); + verify(fixture.iamClient).submitRequiredPackage(eq("internal-token"), captor.capture()); + assertEquals("business.pdf", captor.getValue().businessLicenseOriginalFileName()); + assertEquals("legal-person.png", captor.getValue().legalPersonIdOriginalFileName()); + } + @Test void missingBusinessLicenseAndOrphanedLegalPersonParametersStillFailClosed() { var fixture = fixture(); diff --git a/finance/AGENTS.md b/finance/AGENTS.md index 87820bedc..b4b5d848d 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -3,7 +3,8 @@ ## 模块定位 - 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。 -- 产品范围先读 `../../docs/财务管理/财务管理产品确认表.md` 与 `财务核心五页产品与接口验收契约.md`,跨服务契约再读 `Firstleg-Finance跨服务契约.md`;专题 README、证据矩阵和测试环境报告只用于状态或验收问题,不作为普通开发首读。 +- 产品范围先读 `../../docs/财务管理/README.md`、`../../docs/财务管理/财务领域模型与跨服务契约.md` 和 `Firstleg-Finance跨服务契约.md`;产品确认表、五页验收契约和证据矩阵只在状态核对或验收任务中读取。只有海外仓费用阶段 0A—0E 的设计或实现任务再读取阶段技术契约,并明确区分其目标 V2 协议与当前 V1 代码事实。 +- 海外仓费用阶段 0 按需契约:`../../docs/财务管理/海外仓费用统一接入阶段0技术契约.md`;仅在该阶段的实现、接口或验收任务中读取,不作为普通 Finance 任务首读。 - Firstleg 拥有入库/柜次业务费用和履约事实,File 拥有凭证文件;Finance 通过内部接口读取或消费快照,不跨库查询,也不复制上游业务状态机。 ## 快速查询 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 8d8cf8596..691ae867a 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -51,7 +51,7 @@ | 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 | -| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | +| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | | 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | | 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` | @@ -77,7 +77,7 @@ - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。 - 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。 -- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。 +- 国内出口报关在装柜完成后启用,不阻塞物理 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才能推进“待发区”门禁;报关完成后以系统身份、确定性幂等键立即尝试确认待发区,其他物理/运输事实未齐时由受控定时补偿重试。待发区之后才推进离仓、进港和船期;甩柜/改配只在原柜船期上人工修改船信息并保留历史,不回写报关资料、回执或客户确认,禁止部分入仓成功或重复创建离仓/船司查询事实。 - 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 - 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜完成时间加 72 小时计算,实际报空时间可为空。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/DomesticPdaOperatorClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/DomesticPdaOperatorClient.java new file mode 100644 index 000000000..f154064ea --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/DomesticPdaOperatorClient.java @@ -0,0 +1,19 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "iam-service", contextId = "firstlegDomesticPdaOperatorClient", + url = "${baiyue.clients.iam.url:}", path = "/internal/iam") +public interface DomesticPdaOperatorClient { + @GetMapping("/domestic-pda-operators/{authUserId}") + ApiResponse required(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long authUserId, + @RequestParam long warehouseId); + + record Operator(Long userId, String displayName) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/config/LingxingSyncProperties.java b/firstleg/src/main/java/com/baiyue/firstleg/config/LingxingSyncProperties.java new file mode 100644 index 000000000..a9c1dd4c4 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/config/LingxingSyncProperties.java @@ -0,0 +1,34 @@ +package com.baiyue.firstleg.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** 领星新品自动同步策略;默认关闭,开关由 Nacos 注入。 */ +@Getter +@Setter +@Component +@RefreshScope +@ConfigurationProperties(prefix = "firstleg.lingxing") +public class LingxingSyncProperties { + /** 安全默认值:墨家数据只入库,必须人工点击推送。 */ + private boolean autoSyncEnabled = false; + private String baseUrl = "https://wms.xlwms.com"; + private String loginOrigin = "https://wms.xlwms.com"; + private String username; + private String password; + private String providerKey = "LINGXING_01"; + private String browserCdpEndpoint; + private boolean browserSessionRequired = true; + private long tokenTtlSeconds = 172800; + private long tokenExpirySafetySeconds = 300; + private String trackKeyV2SecretBase64; + private Duration requestTimeout = Duration.ofSeconds(30); + private List warehouses = new ArrayList<>(List.of("US01", "US02")); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java index b24b0f549..9c4dfde6c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java @@ -43,7 +43,7 @@ public class OpenApiInboundMeasurementController { private MojiaMeasurementOpenApiService mojiaService; @Operation(summary = "墨家设备按箱码上传单箱测量结果", - description = "首轮联调使用固定 X-App-Key;JSON 中 picture 为 JPEG Data URL,boxCode 原样取自柏悦箱唛二维码") + description = "首轮联调使用固定 X-App-Key;JSON 中 picture 为 JPEG Data URL,boxCode 原样取自设备条码;带 # 的箱唛继续走箱唛测量,不带 # 的 SKU 进入SKU同步收件;weightKg 为 kg") @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) public ApiResponse receiveMojia( @RequestHeader("X-App-Key") String appKey, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java index b4bcec533..c3c315a72 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java @@ -8,6 +8,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest; +import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; import com.baiyue.firstleg.service.FbaPricingService; import com.baiyue.firstleg.service.FirstlegScopeGuard; import io.swagger.v3.oas.annotations.Operation; @@ -50,6 +51,18 @@ public class PlatformFbaPricingController { request == null ? null : request.volumeSource(), idempotencyKey)); } + @PostMapping("/manual-quote") + @PreAuthorize("hasAuthority('firstleg:inbound:confirm-send-fee')") + @BusinessAudit(event = "FBA_FEE_MANUAL_QUOTE", resourceType = "INBOUND", resourceId = "#inboundId", + description = "价格未命中时人工确认头程运费单价") + public ApiResponse manualQuote( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, + @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody ManualQuoteRequest request) { + long scope = FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.manualQuote(scope, user.userId(), inboundId, request, idempotencyKey)); + } + @PostMapping("/confirm") @PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee'," + " 'firstleg:fba-pricing:confirm')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index 31408a188..e4f5b0dd8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -92,9 +92,9 @@ public class PlatformLoadingListController { planId, warehouseId, user.userId(), user.username(), idempotencyKey, request)); } - @Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部已分配入库预装票据;要求关联订舱已放舱,真实集装箱号可在PDA确认装柜完成前补录。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。") + @Operation(summary = "确认预装并推送指定 PDA 作业员", description = "按 loadingPlanId 原子确认预装、创建任务并分配给启用的国内头程 PDA 作业员;仅被指定人员可在 PDA 查看和领取。") @PostMapping("/loading-plans/{planId}/confirm") - @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") + @PreAuthorize("hasAuthority('platform:firstleg:preload:edit') and hasAuthority('platform:firstleg:pda:assign')") public ApiResponse confirmPreload( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index 0344746e7..96b687425 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -64,7 +64,9 @@ public class PlatformLoadingPdaController { @RequestParam(required = false) Long loadingPlanId, @RequestParam @Positive long warehouseId) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.task(cabinetNo, loadingPlanId, warehouseId)); + Task result = service.task(cabinetNo, loadingPlanId, warehouseId); + service.requireVisibleTo(result.id(), user.userId(), canManage(user)); + return ApiResponse.success(result); } @GetMapping("/tasks/pending") @@ -90,6 +92,7 @@ public class PlatformLoadingPdaController { @PathVariable @Positive long taskId, @RequestParam @Positive long warehouseId) { FirstlegScopeGuard.platform(user); + service.requireVisibleTo(taskId, user.userId(), canManage(user)); return ApiResponse.success(service.task(taskId, warehouseId)); } @@ -143,6 +146,7 @@ public class PlatformLoadingPdaController { @PathVariable @Positive long taskId, @RequestParam @Positive long warehouseId) { FirstlegScopeGuard.platform(user); + service.requireVisibleTo(taskId, user.userId(), canManage(user)); return ApiResponse.success(service.inventorySnapshot(taskId, warehouseId)); } @@ -238,7 +242,13 @@ public class PlatformLoadingPdaController { public ApiResponse> operations(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long taskId) { FirstlegScopeGuard.platform(user); + service.requireVisibleTo(taskId, user.userId(), canManage(user)); return ApiResponse.success(service.operations(taskId)); } + private static boolean canManage(AuthenticatedUser user) { + return user.permissions().contains("platform:firstleg:pda:assign") + || user.permissions().contains("platform:firstleg:pda:review"); + } + } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java new file mode 100644 index 000000000..3eae3921d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java @@ -0,0 +1,115 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.firstleg.dto.MojiaSkuSyncDtos; +import com.baiyue.firstleg.dto.LingxingTestDtos; +import com.baiyue.firstleg.lingxing.LingxingDirectClient; +import com.baiyue.firstleg.lingxing.LingxingAuthManager; +import com.baiyue.firstleg.config.LingxingSyncProperties; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.firstleg.service.MojiaSkuSyncService; +import com.baiyue.firstleg.lingxing.LingxingSecondaryVerificationException; +import com.baiyue.firstleg.lingxing.LingxingBrowserChallengeException; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import com.baiyue.common.security.AuthenticatedUser; + +@Tag(name = "墨家一件代发SKU同步") +@RestController +@RequestMapping("/api/platform/v1/firstleg/mojia-sku-sync") +public class PlatformMojiaSkuSyncController { + @Resource private MojiaSkuSyncService service; + @Resource private LingxingDirectClient lingxing; + @Resource private LingxingAuthManager authManager; + @Resource private LingxingSyncProperties lingxingProperties; + + @GetMapping + @PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')") + @Operation(summary = "查询墨家机器全部上传记录") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String code, + @RequestParam(required = false) String measurementKind, + @RequestParam(required = false) String syncStatus, + @RequestParam(defaultValue = "1") @Min(1) long page, + @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.page(new MojiaSkuSyncDtos.Query(code, measurementKind, syncStatus, page, size))); + } + @GetMapping("/config") + @PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')") + @Operation(summary = "查询领星新品自动同步配置") + public ApiResponse config( + @AuthenticationPrincipal AuthenticatedUser user) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.config()); + } + + @PostMapping("/test-connection") + @PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')") + @Operation(summary = "测试领星连接并查询 SKU") + public ApiResponse testConnection( + @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody LingxingTestDtos.Request request) { + FirstlegScopeGuard.platform(user); + try { + return ApiResponse.success(lingxing.test(request.skuCode())); + } catch (LingxingSecondaryVerificationException ex) { + throw new BizException(ResultCode.CONFLICT, "领星需要二次验证码,请输入验证码后重试"); + } catch (LingxingBrowserChallengeException ex) { + throw new BizException(ResultCode.CONFLICT, "领星需要在浏览器中完成安全验证"); + } + } + + @PostMapping("/verification-code") + @PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')") + @Operation(summary = "提交领星二次验证码") + public ApiResponse verificationCode( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody LingxingTestDtos.VerificationRequest request) { + FirstlegScopeGuard.platform(user); + try { + authManager.submitVerification(lingxingProperties.getProviderKey(), lingxingProperties.getLoginOrigin(), request.code()); + return ApiResponse.success("领星登录成功,请重新执行原操作"); + } catch (LingxingSecondaryVerificationException ex) { + throw new BizException(ResultCode.CONFLICT, "领星二次验证码仍未完成,请重新输入验证码"); + } catch (LingxingBrowserChallengeException ex) { + throw new BizException(ResultCode.CONFLICT, "领星需要在浏览器中完成安全验证"); + } + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('platform:mojia-sku-sync:edit')") + @Operation(summary = "编辑墨家一件代发SKU测量数据") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id, + @Valid @RequestBody MojiaSkuSyncDtos.UpdateRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.update(id, request)); + } + + @PostMapping("/{id}/retry") + @PreAuthorize("hasAuthority('platform:mojia-sku-sync:retry')") + @Operation(summary = "人工推送墨家一件代发SKU") + public ApiResponse retry( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id, + @Valid @RequestBody MojiaSkuSyncDtos.RetryRequest request) { + FirstlegScopeGuard.platform(user); + try { + return ApiResponse.success(service.retry(id, request)); + } catch (LingxingSecondaryVerificationException ex) { + throw new BizException(ResultCode.CONFLICT, "领星需要二次验证码,请输入验证码后重试"); + } catch (LingxingBrowserChallengeException ex) { + throw new BizException(ResultCode.CONFLICT, "领星需要在浏览器中完成安全验证"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java index 9fed8f7f9..faa6b9f41 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java @@ -33,6 +33,13 @@ public class MerchantContainerCustomsController { return ApiResponse.success(service.merchantOrderView(FirstlegScopeGuard.merchant(user), orderId)); } + @GetMapping("/orders/{orderId}/clearance-results") + @PreAuthorize("hasAuthority('merchant:order:view')") + public ApiResponse> clearanceResults(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long orderId) { + return ApiResponse.success(service.merchantClearanceResults(FirstlegScopeGuard.merchant(user), orderId)); + } + @PostMapping(value="/loading-plans/{planId}/documents/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('merchant:order:edit')") public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user, @@ -77,6 +84,13 @@ public class MerchantContainerCustomsController { return ApiResponse.success(service.deleteSource(tenantId,documentId,user.userId(),key,expectedVersion)); } + @GetMapping("/documents/{documentId}/receipt/actions") + @PreAuthorize("hasAuthority('merchant:order:view')") + public ApiResponse> receiptActions(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId) { + return ApiResponse.success(service.receiptActions(FirstlegScopeGuard.merchant(user),documentId,false)); + } + @GetMapping("/documents/{documentId}/download") @PreAuthorize("hasAuthority('merchant:order:view')") public ResponseEntity downloadSource(@AuthenticationPrincipal AuthenticatedUser user, @@ -84,13 +98,40 @@ public class MerchantContainerCustomsController { return response(service.downloadSource(FirstlegScopeGuard.merchant(user),documentId,false)); } - @GetMapping("/documents/{documentId}/completion/download") + @GetMapping("/documents/{documentId}/receipt/download") @PreAuthorize("hasAuthority('merchant:order:view')") - public ResponseEntity downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user, + public ResponseEntity downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long documentId) { return response(service.downloadCompletion(FirstlegScopeGuard.merchant(user),documentId,false)); } + @GetMapping("/documents/{documentId}/final/download") + @PreAuthorize("hasAuthority('merchant:order:view')") + public ResponseEntity downloadFinal(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId) { + return response(service.downloadFinal(FirstlegScopeGuard.merchant(user),documentId,false)); + } + + @PostMapping("/documents/{documentId}/receipt/confirm") + @PreAuthorize("hasAuthority('merchant:order:edit')") + public ApiResponse confirmReceipt(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ActionRequest request) { + long tenantId=FirstlegScopeGuard.merchant(user); + return ApiResponse.success(service.confirmReceipt(tenantId,documentId,user.userId(),user.username(),key,request.expectedVersion(),true,request.remark())); + } + + @PostMapping("/documents/{documentId}/receipt/reject") + @PreAuthorize("hasAuthority('merchant:order:edit')") + public ApiResponse rejectReceipt(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ActionRequest request) { + long tenantId=FirstlegScopeGuard.merchant(user); + return ApiResponse.success(service.confirmReceipt(tenantId,documentId,user.userId(),user.username(),key,request.expectedVersion(),false,request.remark())); + } + private static ResponseEntity response(Download value) { return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .contentType(MediaType.parseMediaType(value.contentType())) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java index 48c0df180..afe1b81e6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.nio.charset.StandardCharsets; +import java.util.List; @Validated @RestController @@ -33,6 +34,66 @@ public class PlatformContainerCustomsController { return ApiResponse.success(service.platformView(planId)); } + @GetMapping("/{planId}/declaration-batches") + @PreAuthorize("hasAuthority('platform:firstleg:customs:list')") + public ApiResponse> declarationBatches(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.platformDeclarationBatches(planId)); + } + + @PostMapping("/{planId}/declaration-batches") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_DECLARATION_BATCH_CREATE",resourceType="CUSTOMS_CONTAINER") + public ApiResponse createDeclarationBatch(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody DeclarationBatchRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.createDeclarationBatch(planId,user.userId(),user.username(),key,request)); + } + + @PostMapping("/declaration-batches/{batchId}/submit") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_DECLARATION_BATCH_SUBMIT",resourceType="CUSTOMS_DECLARATION_BATCH") + public ApiResponse submitDeclarationBatch(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long batchId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody DeclarationBatchRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.submitDeclarationBatch(batchId,user.userId(),user.username(),key,request)); + } + + @GetMapping("/declaration-batches/{batchId}/results") + @PreAuthorize("hasAuthority('platform:firstleg:customs:list')") + public ApiResponse> clearanceResults(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long batchId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.platformClearanceResults(batchId)); + } + + @PostMapping("/declaration-batches/{batchId}/results") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_CLEARANCE_RESULT_RECORD",resourceType="CUSTOMS_DECLARATION_BATCH") + public ApiResponse addClearanceResult(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long batchId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ClearanceResultRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.addClearanceResult(batchId,user.userId(),user.username(),key,request)); + } + + @PostMapping("/clearance-results/{resultId}/resolve") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_CLEARANCE_RESULT_RESOLVE",resourceType="CUSTOMS_CLEARANCE_RESULT") + public ApiResponse resolveClearanceResult(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long resultId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ClearanceResolveRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.resolveClearanceResult(resultId,user.userId(),user.username(),key,request)); + } + @PostMapping("/{planId}/start") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @BusinessAudit(event="CONTAINER_CUSTOMS_START",resourceType="CUSTOMS_CONTAINER") @@ -44,18 +105,51 @@ public class PlatformContainerCustomsController { return ApiResponse.success(service.start(planId,user.userId(),user.username(),key,request)); } - @PostMapping(value="/documents/{documentId}/completion/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping("/{planId}/send-to-broker") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_INITIAL_SEND_TO_BROKER",resourceType="CUSTOMS_CONTAINER") + public ApiResponse sendInitialToBroker(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ActionRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.sendInitialToBroker(planId,user.userId(),user.username(),key,request)); + } + + @PostMapping("/{planId}/submit-to-broker") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_SUBMIT_TO_BROKER",resourceType="CUSTOMS_CONTAINER") + public ApiResponse submitToBroker(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ActionRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.submitToBroker(planId,user.userId(),user.username(),key,request)); + } + + @PostMapping(value="/documents/{documentId}/receipt/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @BusinessAudit(event="CUSTOMS_COMPLETION_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT") public ApiResponse uploadCompletion(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long documentId, @RequestHeader("Idempotency-Key") String key, @RequestParam @Min(0) int expectedVersion, - @RequestParam(defaultValue="false") boolean finalConfirmation, @RequestPart("file") MultipartFile file) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.uploadCompletion(documentId,user.userId(),user.username(),key, - expectedVersion,finalConfirmation,file)); + expectedVersion,file)); + } + + @PostMapping(value="/documents/{documentId}/final/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_FINAL_DOCUMENT_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT") + public ApiResponse uploadFinal(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId, + @RequestHeader("Idempotency-Key") String key, + @RequestParam @Min(0) int expectedVersion, + @RequestPart("file") MultipartFile file) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.uploadFinalDocument(documentId,user.userId(),user.username(),key,expectedVersion,file)); } @PostMapping("/{planId}/complete-all-buy") @@ -69,6 +163,14 @@ public class PlatformContainerCustomsController { return ApiResponse.success(service.completeAllBuy(planId,user.userId(),user.username(),key,request)); } + @GetMapping("/documents/{documentId}/receipt/actions") + @PreAuthorize("hasAuthority('platform:firstleg:customs:list')") + public ApiResponse> receiptActions(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.receiptActions(0,documentId,true)); + } + @GetMapping("/documents/{documentId}/download") @PreAuthorize("hasAuthority('platform:firstleg:customs:list')") public ResponseEntity downloadSource(@AuthenticationPrincipal AuthenticatedUser user, @@ -85,6 +187,14 @@ public class PlatformContainerCustomsController { return response(service.downloadCompletion(0,documentId,true)); } + @GetMapping("/documents/{documentId}/final/download") + @PreAuthorize("hasAuthority('platform:firstleg:customs:list')") + public ResponseEntity downloadFinal(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId) { + FirstlegScopeGuard.platform(user); + return response(service.downloadFinal(0,documentId,true)); + } + private static ResponseEntity response(Download value) { return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .contentType(MediaType.parseMediaType(value.contentType())) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java index ffa5959db..776b65125 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java @@ -17,16 +17,30 @@ public final class ContainerCustomsDtos { public record FileView(long fileId, String fileName, String fileHash, String contentType, long sizeBytes, int version, LocalDateTime uploadedAt) {} + public record ReceiptVersionView(long version, FileView file, String operatorName, + LocalDateTime createdAt) {} + + public record ReceiptActionView(long id, int receiptVersion, String action, String reason, + String operatorName, LocalDateTime createdAt) {} + + public record BrokerSubmissionView(long id, long customsContainerId, long loadingPlanId, + String batchNo, String status, int sourceVersion, + String sentByName, LocalDateTime sentAt, long version) {} + public record DocumentView(long documentId, long tenantId, FileView source, List inboundIds, List inboundCodes, - boolean frozen, FileView completion) {} + boolean frozen, FileView receipt, String receiptStatus, + String receiptRejectReason, FileView finalDocument, + List receiptHistory, + List confirmationHistory) {} public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId, String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo, String merchantStatus, - int version, int taxRefundCount, int coveredTaxRefundCount, + int version, int taxRefundCount, int coveredTaxRefundCount, long actualBoxCount, List inbounds, List documents, - LocalDateTime startedAt, LocalDateTime completedAt) {} + LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime brokerSubmittedAt, + BrokerSubmissionView brokerSubmission) {} public record ScopeRequest(@NotEmpty @Size(max = 200) List<@Positive Long> inboundIds, @Min(0) int expectedVersion) {} @@ -34,5 +48,30 @@ public final class ContainerCustomsDtos { public record ActionRequest(@Min(0) int expectedVersion, boolean confirm, @Size(max = 500) String remark) {} + public record DeclarationBatchView(long id, long customsContainerId, long loadingPlanId, + String batchNo, String status, int sourceVersion, + String customsEntryNo, long version, + LocalDateTime submittedAt, String submittedByName, + LocalDateTime acknowledgedAt, String acknowledgedByName, + String remark, List results) {} + + public record DeclarationBatchRequest(@Min(0) int expectedVersion, + @Size(max = 64) String batchNo, + @Size(max = 64) String customsEntryNo, + @Size(max = 500) String remark) {} + + public record ClearanceResultView(long id, long declarationBatchId, long customsContainerId, + String resultType, String resultStatus, String customsEntryNo, + LocalDateTime occurredAt, String resolution, Long evidenceFileId, + long recordedBy, String recordedByName, LocalDateTime recordedAt, + Long previousResultId) {} + + public record ClearanceResultRequest(@Min(0) int expectedVersion, @Size(max = 32) String resultType, + LocalDateTime occurredAt, @Size(max = 1000) String resolution, + Long evidenceFileId, boolean resolved) {} + + public record ClearanceResolveRequest(@Min(0) int expectedVersion, + @Size(max = 1000) String resolution) {} + public record Download(String fileName, String contentType, byte[] content) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index f15db80a4..e4946da34 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -17,7 +17,13 @@ public interface ContainerCustomsMapper { String COMPLETION_COLUMNS = """ r.id id,r.source_document_id sourceDocumentId,r.file_id fileId,r.file_name fileName, r.file_hash fileHash,r.content_type contentType,r.size_bytes sizeBytes,r.uploaded_by uploadedBy, - r.uploaded_by_name uploadedByName,r.uploaded_at uploadedAt,r.version version + r.uploaded_by_name uploadedByName,r.uploaded_at uploadedAt,r.version version, + r.customer_confirm_status customerConfirmStatus,r.customer_reject_reason customerRejectReason + """; + String FINAL_COLUMNS = """ + f.id id,f.source_document_id sourceDocumentId,f.file_id fileId,f.file_name fileName, + f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy, + f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version """; @Select(""" @@ -34,6 +40,18 @@ public interface ContainerCustomsMapper { """) List planInbounds(@Param("planId") long planId); + @Select(""" + SELECT COALESCE(SUM(item.package_count),0) + FROM fl_loading_plan_item item + JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED' + JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 + WHERE item.loading_plan_id=#{planId} AND item.deleted=0 + AND (#{tenantId} IS NULL OR i.tenant_id=#{tenantId}) + AND (#{taxMode} IS NULL OR i.tax_mode=#{taxMode}) + """) + long actualBoxCount(@Param("planId") long planId, @Param("tenantId") Long tenantId, + @Param("taxMode") String taxMode); + @Select(""" SELECT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId, p.plan_status loadingPlanStatus,b.cabinet_no cabinetNo,b.container_no containerNo @@ -89,6 +107,132 @@ public interface ContainerCustomsMapper { int start(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + @Update(""" + UPDATE fl_customs_container SET broker_submitted_at=CURRENT_TIMESTAMP(3), + broker_submitted_by=#{operatorId},broker_submitted_by_name=#{operatorName}, + updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{id} AND version=#{version} AND status='DECLARING' AND broker_submitted_at IS NULL + """) + int submitToBroker(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Select("SELECT broker_submitted_at FROM fl_customs_container WHERE id=#{id}") + LocalDateTime brokerSubmittedAt(@Param("id") long id); + + @Select("SELECT " + BROKER_SUBMISSION_COLUMNS + " FROM fl_customs_broker_submission s " + + "WHERE s.customs_container_id=#{containerId}") + BrokerSubmissionRow brokerSubmission(@Param("containerId") long containerId); + + @Select("SELECT " + BROKER_SUBMISSION_COLUMNS + " FROM fl_customs_broker_submission s " + + "WHERE s.customs_container_id=#{containerId} FOR UPDATE") + BrokerSubmissionRow lockBrokerSubmission(@Param("containerId") long containerId); + + @Insert(""" + INSERT INTO fl_customs_broker_submission + (id,customs_container_id,loading_plan_id,batch_no,status,source_version,source_snapshot_json, + sent_by,sent_by_name,sent_at,version,created_at,updated_at) + VALUES(#{id},#{containerId},#{planId},#{batchNo},'SENT',#{sourceVersion},CAST(#{snapshotJson} AS JSON), + #{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertBrokerSubmission(@Param("id") long id,@Param("containerId") long containerId, + @Param("planId") long planId,@Param("batchNo") String batchNo, + @Param("sourceVersion") int sourceVersion,@Param("snapshotJson") String snapshotJson, + @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_customs_container SET version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND version=#{version} AND status='DECLARING' + """) + int bumpVersionAfterBrokerSubmission(@Param("id") long id,@Param("version") int version); + + @Select("SELECT " + DECLARATION_BATCH_COLUMNS + " FROM fl_customs_declaration_batch b " + + "WHERE customs_container_id=#{containerId} ORDER BY created_at DESC,id DESC") + List declarationBatches(@Param("containerId") long containerId); + + @Select("SELECT " + DECLARATION_BATCH_COLUMNS + " FROM fl_customs_declaration_batch b WHERE b.id=#{id}") + DeclarationBatchRow declarationBatch(@Param("id") long id); + + @Select("SELECT " + DECLARATION_BATCH_COLUMNS + " FROM fl_customs_declaration_batch b WHERE b.id=#{id} FOR UPDATE") + DeclarationBatchRow lockDeclarationBatch(@Param("id") long id); + + @Insert(""" + INSERT INTO fl_customs_declaration_batch + (id,customs_container_id,loading_plan_id,batch_no,status,source_version,source_snapshot_json,remark, + version,created_at,updated_at) + VALUES(#{id},#{containerId},#{planId},#{batchNo},'READY',#{sourceVersion},CAST(#{snapshotJson} AS JSON),#{remark}, + 0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertDeclarationBatch(@Param("id") long id,@Param("containerId") long containerId,@Param("planId") long planId, + @Param("batchNo") String batchNo,@Param("sourceVersion") int sourceVersion, + @Param("snapshotJson") String snapshotJson,@Param("remark") String remark); + + @Update(""" + UPDATE fl_customs_declaration_batch SET batch_no=#{batchNo},status='SUBMITTED',customs_entry_no=#{entryNo}, + submitted_by=#{operatorId},submitted_by_name=#{operatorName},submitted_at=CURRENT_TIMESTAMP(3),remark=#{remark}, + version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND version=#{version} AND status='READY' + """) + int submitDeclarationBatch(@Param("id") long id,@Param("version") int version,@Param("batchNo") String batchNo, + @Param("entryNo") String entryNo,@Param("operatorId") long operatorId, + @Param("operatorName") String operatorName,@Param("remark") String remark); + + @Update(""" + UPDATE fl_customs_declaration_batch SET status=#{status},acknowledged_by=#{operatorId}, + acknowledged_by_name=#{operatorName},acknowledged_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND version=#{version} AND status IN ('SUBMITTED','RESULT_PENDING','ACKNOWLEDGED') + """) + int acknowledgeDeclarationBatch(@Param("id") long id,@Param("version") int version,@Param("status") String status, + @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + + @Select("SELECT " + CLEARANCE_RESULT_COLUMNS + " FROM fl_customs_clearance_result r " + + "WHERE declaration_batch_id=#{batchId} ORDER BY occurred_at,id") + List clearanceResults(@Param("batchId") long batchId); + + @Select("SELECT " + CLEARANCE_RESULT_COLUMNS + " FROM fl_customs_clearance_result r WHERE r.id=#{id}") + ClearanceResultRow clearanceResult(@Param("id") long id); + + @Select("SELECT " + CLEARANCE_RESULT_COLUMNS + " FROM fl_customs_clearance_result r WHERE r.id=#{id} FOR UPDATE") + ClearanceResultRow lockClearanceResult(@Param("id") long id); + + @Insert(""" + INSERT INTO fl_customs_clearance_result + (id,declaration_batch_id,customs_container_id,result_type,result_status,customs_entry_no,occurred_at, + resolution,evidence_file_id,recorded_by,recorded_by_name,recorded_at,previous_result_id,created_at,updated_at) + VALUES(#{id},#{batchId},#{containerId},#{resultType},#{resultStatus},#{entryNo},#{occurredAt},#{resolution}, + #{evidenceFileId},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),#{previousResultId},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertClearanceResult(@Param("id") long id,@Param("batchId") long batchId,@Param("containerId") long containerId, + @Param("resultType") String resultType,@Param("resultStatus") String resultStatus, + @Param("entryNo") String entryNo,@Param("occurredAt") LocalDateTime occurredAt, + @Param("resolution") String resolution,@Param("evidenceFileId") Long evidenceFileId, + @Param("operatorId") long operatorId,@Param("operatorName") String operatorName, + @Param("previousResultId") Long previousResultId); + + @Update(""" + UPDATE fl_customs_clearance_result SET result_status='RESOLVED',resolution=#{resolution},updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND result_status='OPEN' + """) + int resolveClearanceResult(@Param("id") long id,@Param("resolution") String resolution); + + @Select("SELECT COUNT(*) FROM fl_customs_clearance_result WHERE declaration_batch_id=#{batchId} AND result_status='OPEN'") + int openClearanceResultCount(@Param("batchId") long batchId); + + @Update("UPDATE fl_customs_declaration_batch SET status='CLOSED',version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status IN ('SUBMITTED','ACKNOWLEDGED','RESULT_PENDING')") + int closeDeclarationBatch(@Param("id") long id); + + @Update("UPDATE fl_customs_declaration_batch SET status='RESULT_PENDING',version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status IN ('SUBMITTED','ACKNOWLEDGED')") + int markDeclarationResultPending(@Param("id") long id); + + @Select(""" + SELECT DISTINCT b.id + FROM fl_customs_declaration_batch b + JOIN fl_customs_clearance_result r ON r.declaration_batch_id=b.id + JOIN fl_customs_customer_document d ON d.customs_container_id=b.customs_container_id AND d.tenant_id=#{tenantId} AND d.status='ACTIVE' + JOIN fl_customs_frozen_document_inbound s ON s.document_id=d.id AND s.order_id=#{orderId} + ORDER BY b.id DESC + """) + List merchantDeclarationBatchIds(@Param("tenantId") long tenantId,@Param("orderId") long orderId); + @Update(""" UPDATE fl_customs_container SET status='COMPLETED',completed_by=#{operatorId},completed_by_name=#{operatorName}, completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 @@ -187,6 +331,14 @@ public interface ContainerCustomsMapper { """) int missingRefundCount(@Param("planId") long planId,@Param("containerId") long containerId); + @Select(""" + SELECT COUNT(DISTINCT i.id) + FROM fl_loading_plan_item item + JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 + WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND' + """) + int taxRefundCount(@Param("planId") long planId); + @Update("UPDATE fl_customs_customer_document SET frozen_version=version,frozen_at=CURRENT_TIMESTAMP(3) " + "WHERE customs_container_id=#{containerId} AND status='ACTIVE' AND frozen_at IS NULL") int freezeDocuments(@Param("containerId") long containerId); @@ -214,12 +366,18 @@ public interface ContainerCustomsMapper { "WHERE r.source_document_id=#{documentId} FOR UPDATE") CompletionRow lockCompletion(@Param("documentId") long documentId); + @Select("SELECT v.document_version documentVersion,v.file_id fileId,v.file_name fileName,v.file_hash fileHash," + + "v.content_type contentType,v.size_bytes sizeBytes,v.operator_id operatorId,v.operator_name operatorName,v.created_at createdAt " + + "FROM fl_customs_completion_document_version v WHERE v.completion_document_id=#{completionId} " + + "ORDER BY v.document_version") + List completionVersions(@Param("completionId") long completionId); + @Insert(""" INSERT INTO fl_customs_completion_document (id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name, - uploaded_at,version,created_at,updated_at) + uploaded_at,version,customer_confirm_status,created_at,updated_at) VALUES(#{id},#{documentId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},#{operatorId},#{operatorName}, - CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + CURRENT_TIMESTAMP(3),1,'PENDING',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) """) int insertCompletion(@Param("id") long id,@Param("documentId") long documentId,@Param("fileId") long fileId, @Param("fileName") String fileName,@Param("fileHash") String fileHash, @@ -229,7 +387,9 @@ public interface ContainerCustomsMapper { @Update(""" UPDATE fl_customs_completion_document SET file_id=#{fileId},file_name=#{fileName},file_hash=#{fileHash}, content_type=#{contentType},size_bytes=#{sizeBytes},uploaded_by=#{operatorId},uploaded_by_name=#{operatorName}, - uploaded_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 + uploaded_at=CURRENT_TIMESTAMP(3),customer_confirm_status='PENDING',customer_confirmed_by=NULL, + customer_confirmed_by_name=NULL,customer_confirmed_at=NULL,customer_reject_reason=NULL, + updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND version=#{version} AND EXISTS(SELECT 1 FROM fl_customs_customer_document d JOIN fl_customs_container c ON c.id=d.customs_container_id WHERE d.id=source_document_id AND c.status='DECLARING') @@ -239,6 +399,68 @@ public interface ContainerCustomsMapper { @Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes, @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + @Update(""" + UPDATE fl_customs_completion_document r + JOIN fl_customs_customer_document d ON d.id=r.source_document_id + JOIN fl_customs_container c ON c.id=d.customs_container_id + SET r.customer_confirm_status=#{status},r.customer_confirmed_by=#{operatorId}, + r.customer_confirmed_by_name=#{operatorName},r.customer_confirmed_at=CURRENT_TIMESTAMP(3), + r.customer_reject_reason=#{reason},r.updated_at=CURRENT_TIMESTAMP(3) + WHERE r.source_document_id=#{documentId} AND d.tenant_id=#{tenantId} AND c.status='DECLARING' + AND r.customer_confirm_status IN ('PENDING','REJECTED') + """) + int confirmReceipt(@Param("documentId") long documentId,@Param("tenantId") long tenantId, + @Param("status") String status,@Param("reason") String reason, + @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + + @Insert(""" + INSERT INTO fl_customs_completion_action_history + (id,completion_document_id,receipt_version,action,reason,operator_id,operator_name) + VALUES(#{id},#{completionId},#{receiptVersion},#{action},#{reason},#{operatorId},#{operatorName}) + """) + int insertReceiptAction(@Param("id") long id,@Param("completionId") long completionId, + @Param("receiptVersion") int receiptVersion,@Param("action") String action, + @Param("reason") String reason,@Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Select(""" + + """) + List receiptActionHistory(@Param("documentId") long documentId,@Param("tenantId") Long tenantId); + + @Select(""" + SELECT COUNT(*) FROM fl_customs_customer_document d + JOIN fl_customs_completion_document r ON r.source_document_id=d.id + WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' + AND r.customer_confirm_status='CONFIRMED' + """) + int confirmedReceiptCount(@Param("containerId") long containerId); + + @Select(""" + SELECT COUNT(*) FROM fl_customs_customer_document d + LEFT JOIN fl_customs_completion_document r ON r.source_document_id=d.id + WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' + AND (r.id IS NULL OR r.customer_confirm_status<>'CONFIRMED') + """) + int unconfirmedReceiptCount(@Param("containerId") long containerId); + + @Select(""" + SELECT COUNT(*) FROM fl_customs_customer_document d + JOIN fl_customs_completion_document r ON r.source_document_id=d.id + WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' AND d.frozen_at IS NOT NULL + AND r.customer_confirm_status <> 'CONFIRMED' + """) + int pendingReceiptConfirmationCount(@Param("containerId") long containerId); + @Insert(""" INSERT INTO fl_customs_completion_document_version (id,completion_document_id,document_version,file_id,file_name,file_hash,content_type,size_bytes, @@ -251,12 +473,39 @@ public interface ContainerCustomsMapper { @Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes, @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + @Select("SELECT " + FINAL_COLUMNS + " FROM fl_customs_final_document f WHERE f.source_document_id=#{documentId}") + FinalRow finalDocument(@Param("documentId") long documentId); + + @Select("SELECT " + FINAL_COLUMNS + " FROM fl_customs_final_document f WHERE f.source_document_id=#{documentId} FOR UPDATE") + FinalRow lockFinalDocument(@Param("documentId") long documentId); + + @Insert(""" + INSERT INTO fl_customs_final_document + (id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name, + uploaded_at,version,created_at,updated_at) + VALUES(#{id},#{documentId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},#{operatorId},#{operatorName}, + CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertFinal(@Param("id") long id,@Param("documentId") long documentId,@Param("fileId") long fileId, + @Param("fileName") String fileName,@Param("fileHash") String fileHash,@Param("contentType") String contentType, + @Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_customs_final_document SET file_id=#{fileId},file_name=#{fileName},file_hash=#{fileHash}, + content_type=#{contentType},size_bytes=#{sizeBytes},uploaded_by=#{operatorId},uploaded_by_name=#{operatorName}, + uploaded_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND version=#{version} + """) + int replaceFinal(@Param("id") long id,@Param("version") int version,@Param("fileId") long fileId, + @Param("fileName") String fileName,@Param("fileHash") String fileHash,@Param("contentType") String contentType, + @Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + @Select(""" SELECT COUNT(*) FROM fl_customs_customer_document d - LEFT JOIN fl_customs_completion_document r ON r.source_document_id=d.id - WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' AND d.frozen_at IS NOT NULL AND r.id IS NULL + LEFT JOIN fl_customs_final_document f ON f.source_document_id=d.id + WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' AND f.id IS NULL """) - int missingCompletionCount(@Param("containerId") long containerId); + int missingFinalCount(@Param("containerId") long containerId); @Insert(""" INSERT INTO fl_customs_container_history @@ -344,15 +593,48 @@ public interface ContainerCustomsMapper { String cabinetNo,String containerNo) {} record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy, String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName, - LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {} + LocalDateTime completedAt,Long brokerSubmittedBy,String brokerSubmittedByName, + LocalDateTime brokerSubmittedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) { + public ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy, + String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName, + LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) { + this(id, loadingPlanId, containerBookingId, status, startedBy, startedByName, startedAt, + completedBy, completedByName, completedAt, null, null, null, createdAt, updatedAt, version); + } + } record DocumentRow(Long id,Long customsContainerId,Long tenantId,Long fileId,String fileName,String fileHash, String contentType,Long sizeBytes,String status,Long uploadedBy,LocalDateTime uploadedAt, Integer frozenVersion,LocalDateTime frozenAt,Integer version) {} record ScopeRow(Long documentId,Long inboundId,Long orderId,String inboundCode) {} record CompletionRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType, - Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version) {} + Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version, + String customerConfirmStatus,String customerRejectReason) {} + record CompletionVersionRow(Integer documentVersion,Long fileId,String fileName,String fileHash,String contentType, + Long sizeBytes,Long operatorId,String operatorName,LocalDateTime createdAt) {} + record ReceiptActionRow(Long id,Integer receiptVersion,String action,String reason, + String operatorName,LocalDateTime createdAt) {} + record FinalRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType, + Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version) {} record CommandRow(Long id,Long actorId,String action,String idempotencyKey,String requestHash,String status, Long resultId,LocalDateTime createdAt,LocalDateTime completedAt) {} + String DECLARATION_BATCH_COLUMNS = "b.id id,b.customs_container_id customsContainerId,b.loading_plan_id loadingPlanId,"+ + "b.batch_no batchNo,b.status status,b.source_version sourceVersion,b.customs_entry_no customsEntryNo,b.submitted_by_name submittedByName,"+ + "b.submitted_at submittedAt,b.acknowledged_by_name acknowledgedByName,b.acknowledged_at acknowledgedAt,b.remark remark,b.version version,b.created_at createdAt,b.updated_at updatedAt"; + String BROKER_SUBMISSION_COLUMNS = "s.id id,s.customs_container_id customsContainerId,s.loading_plan_id loadingPlanId,"+ + "s.batch_no batchNo,s.status status,s.source_version sourceVersion,s.sent_by_name sentByName,s.sent_at sentAt,s.version version"; + String CLEARANCE_RESULT_COLUMNS = "r.id id,r.declaration_batch_id declarationBatchId,r.customs_container_id customsContainerId,"+ + "r.result_type resultType,r.result_status resultStatus,r.customs_entry_no customsEntryNo,r.occurred_at occurredAt,"+ + "r.resolution resolution,r.evidence_file_id evidenceFileId,r.recorded_by recordedBy,r.recorded_by_name recordedByName,"+ + "r.recorded_at recordedAt,r.previous_result_id previousResultId"; + record DeclarationBatchRow(Long id,Long customsContainerId,Long loadingPlanId,String batchNo,String status, + Integer sourceVersion,String customsEntryNo,String submittedByName,LocalDateTime submittedAt, + String acknowledgedByName,LocalDateTime acknowledgedAt,String remark,Integer version, + LocalDateTime createdAt,LocalDateTime updatedAt) {} + record BrokerSubmissionRow(Long id,Long customsContainerId,Long loadingPlanId,String batchNo,String status, + Integer sourceVersion,String sentByName,LocalDateTime sentAt,Integer version) {} + record ClearanceResultRow(Long id,Long declarationBatchId,Long customsContainerId,String resultType,String resultStatus, + String customsEntryNo,LocalDateTime occurredAt,String resolution,Long evidenceFileId, + Long recordedBy,String recordedByName,LocalDateTime recordedAt,Long previousResultId) {} record BuyExportRow(Long itemId,String planNo,String cabinetNo,String containerNo,String customerName,String orderNo, String inboundCode,String skuCode,String productNameZh,String productNameEn,Integer packageCount, Integer productQuantity,BigDecimal grossWeightKg,BigDecimal netWeightKg,BigDecimal volumeCbm, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index a55395fd6..b4ab07155 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -21,6 +21,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.*; import java.util.function.Function; @@ -33,6 +34,7 @@ public class ContainerCustomsService { public static final String DECLARING = "DECLARING"; public static final String COMPLETED = "COMPLETED"; private static final String SOURCE_FILE_TYPE = "LOADING_LIST_CUSTOMS_SOURCE"; + private static final String RECEIPT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RECEIPT"; private static final String RESULT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RESULT"; private static final long MAX_FILE_BYTES = 50L * 1024 * 1024; @@ -172,13 +174,62 @@ public class ContainerCustomsService { return platformView(planId); } + @Transactional + public ContainerView sendInitialToBroker(long planId, long operatorId, String operatorName, String key, + ActionRequest request) { + String action = "SEND_CUSTOMS_INITIAL_TO_BROKER_" + planId; + if (!request.confirm()) throw bad("请确认已将冻结后的报关资料发送给报关行"); + String requestHash = hash(action, request.expectedVersion(), request.confirm(), request.remark()); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return platformView(planId); + ContainerRow container = mapper.lockContainer(planId); + if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中"); + if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); + if (mapper.taxRefundCount(planId) == 0) throw conflict("全买单柜无需发送报关资料给报关行"); + if (mapper.brokerSubmission(container.id()) != null) throw conflict("当前柜已完成首次发送报关行批次"); + List documents = mapper.documents(container.id(), null); + if (documents.isEmpty() || documents.stream().anyMatch(document -> document.frozenAt() == null)) { + throw conflict("报关资料尚未全部冻结,不能发送给报关行"); + } + long submissionId = IdWorker.getId(); + String batchNo = brokerBatchNo(planId); + String snapshot = brokerSubmissionSnapshot(container.id()); + if (mapper.insertBrokerSubmission(submissionId, container.id(), planId, batchNo, container.version(), snapshot, + operatorId, operatorName) != 1) throw conflict("首次发送报关行批次创建失败"); + if (mapper.bumpVersionAfterBrokerSubmission(container.id(), container.version()) != 1) throw concurrent(); + history(container, "SEND_CUSTOMS_INITIAL_TO_BROKER", container.status(), container.status(), + request.remark(), operatorId, operatorName); + completeCommand(operatorId, action, key, submissionId); + return platformView(planId); + } + + @Transactional + public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) { + String action = "SUBMIT_CUSTOMS_TO_BROKER_" + planId; + if (!request.confirm()) throw bad("请确认已将客户确认结果发送给报关行"); + String requestHash = hash(action, request.expectedVersion(), request.confirm(), request.remark()); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return platformView(planId); + ContainerRow container = mapper.lockContainer(planId); + if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中"); + if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); + if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送客户回执给报关行"); + if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有报关行回执未完成客户确认"); + if (mapper.brokerSubmission(container.id()) == null) throw conflict("当前柜尚未完成首次发送报关行资料"); + if (mapper.brokerSubmittedAt(container.id()) != null) throw conflict("当前柜已发送给报关行执行"); + if (mapper.submitToBroker(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent(); + history(container, "SUBMIT_CUSTOMS_TO_BROKER", container.status(), container.status(), request.remark(), operatorId, operatorName); + completeCommand(operatorId, action, key, container.id()); + return platformView(planId); + } + @Transactional public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key, - int expectedVersion, boolean finalConfirmation, MultipartFile file) { + int expectedVersion, MultipartFile file) { validateFile(file); DocumentRow readable=requiredDocument(documentId); String action = "UPLOAD_COMPLETION_" + documentId; - String requestHash = hash(action, expectedVersion, finalConfirmation, fileHash(file)); + String requestHash = hash(action, expectedVersion, fileHash(file)); CommandRow replay = claim(operatorId, action, key, requestHash); if (replay != null) return documentView(requiredDocument(documentId), false); ContainerRow container = mapper.lockContainerById(readable.customsContainerId()); @@ -186,12 +237,14 @@ public class ContainerCustomsService { if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) { throw conflict("当前客户资料不在报关中冻结版本"); } + if (mapper.brokerSubmittedAt(container.id()) != null) { + throw conflict("客户确认回执已发送给报关行,不可再修改回执"); + } + if (mapper.brokerSubmission(container.id()) == null) { + throw conflict("当前柜尚未完成首次发送报关行资料"); + } CompletionRow completion = mapper.lockCompletion(documentId); - int missingBefore=mapper.missingCompletionCount(container.id()); - boolean lastNewCompletion=completion==null&&missingBefore==1; - if(lastNewCompletion&&!finalConfirmation)throw conflict("这是最后一份完成文件,请二次确认后上传"); - if(finalConfirmation&&!lastNewCompletion)throw conflict("只有最后一份尚未上传的完成文件才能确认整柜完成"); - CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); + CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); long completionId; int version; if (completion == null) { @@ -206,13 +259,74 @@ public class ContainerCustomsService { } mapper.insertCompletionVersion(IdWorker.getId(), completionId, version, metadata.fileId(), metadata.fileName(), metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName); - if (lastNewCompletion) { - completeContainer(container, operatorId, operatorName, "全部冻结客户资料已回传完成文件"); - } completeCommand(operatorId, action, key, completionId); return documentView(requiredDocument(documentId), false); } + @Transactional + public ContainerView confirmReceipt(long tenantId, long documentId, long operatorId, String operatorName, + String key, int expectedVersion, boolean confirm, String reason) { + String action = (confirm ? "CONFIRM_CUSTOMS_RECEIPT_" : "REJECT_CUSTOMS_RECEIPT_") + documentId; + String requestHash = hash(action, expectedVersion, confirm, reason); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) { + DocumentRow source = requiredDocument(documentId); + return merchantViewForDocument(tenantId, source); + } + DocumentRow source = requiredDocumentLocked(documentId, tenantId); + ContainerRow container = mapper.lockContainerById(source.customsContainerId()); + CompletionRow receipt = mapper.lockCompletion(documentId); + if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null || receipt == null) { + throw conflict("当前回执不在待确认状态"); + } + if (!Objects.equals(receipt.version(), expectedVersion)) throw concurrent(); + if (!confirm && (reason == null || reason.isBlank())) throw bad("打回回执必须填写原因"); + if (mapper.confirmReceipt(documentId, tenantId, confirm ? "CONFIRMED" : "REJECTED", + confirm ? null : trim(reason), operatorId, operatorName) != 1) throw conflict("回执状态已变化,请刷新后重试"); + if (mapper.insertReceiptAction(IdWorker.getId(), receipt.id(), receipt.version(), confirm ? "CONFIRM" : "REJECT", + trim(reason), operatorId, operatorName) != 1) throw concurrent(); + if (confirm && mapper.pendingReceiptConfirmationCount(container.id()) == 0) { + history(container, "ALL_CUSTOMS_RECEIPTS_CONFIRMED", container.status(), container.status(), "客户已确认全部报关行回执,待发送给报关行执行", operatorId, operatorName); + } + completeCommand(operatorId, action, key, documentId); + return merchantViewForDocument(tenantId, source); + } + + @Transactional + public DocumentView uploadFinalDocument(long documentId, long operatorId, String operatorName, String key, + int expectedVersion, MultipartFile file) { + validateFile(file); + DocumentRow readable = requiredDocument(documentId); + String action = "UPLOAD_FINAL_CUSTOMS_DOCUMENT_" + documentId; + String requestHash = hash(action, expectedVersion, fileHash(file)); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return documentView(requiredDocument(documentId), false); + ContainerRow container = mapper.lockContainerById(readable.customsContainerId()); + DocumentRow source = requiredDocumentLocked(documentId, null); + if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) { + throw conflict("当前客户资料不在报关中冻结版本"); + } + if (mapper.brokerSubmittedAt(container.id()) == null) throw conflict("客户确认结果尚未发送给报关行"); + CompletionRow receipt = mapper.lockCompletion(documentId); + if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户回执尚未确认"); + FinalRow existing = mapper.lockFinalDocument(documentId); + CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); + long finalId; + int version; + if (existing == null) { + if (expectedVersion != 0) throw concurrent(); + finalId = IdWorker.getId(); version = 1; + mapper.insertFinal(finalId, documentId, metadata.fileId(), metadata.fileName(), metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName); + } else { + finalId = existing.id(); version = existing.version() + 1; + if (mapper.replaceFinal(existing.id(), expectedVersion, metadata.fileId(), metadata.fileName(), metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName) != 1) throw concurrent(); + } + history(container, "UPLOAD_FINAL_CUSTOMS_DOCUMENT", container.status(), container.status(), "真实报关完税文件已上传", operatorId, operatorName); + if (mapper.missingFinalCount(container.id()) == 0) completeContainer(container, operatorId, operatorName, "全部真实报关完税文件已上传"); + completeCommand(operatorId, action, key, finalId); + return documentView(requiredDocument(documentId), false); + } + @Transactional public ContainerView completeAllBuy(long planId, long operatorId, String operatorName, String key, ActionRequest request) { String action = "COMPLETE_ALL_BUY_CUSTOMS_" + planId; @@ -231,6 +345,174 @@ public class ContainerCustomsService { return platformView(planId); } + public List platformDeclarationBatches(long planId) { + ContainerRow container = mapper.container(planId); + if (container == null) throw notFound("整柜报关尚未生成"); + return mapper.declarationBatches(container.id()).stream().map(this::declarationBatchView).toList(); + } + + @Transactional + public DeclarationBatchView createDeclarationBatch(long planId, long operatorId, String operatorName, + String key, DeclarationBatchRequest request) { + String action = "CREATE_CUSTOMS_DECLARATION_BATCH_" + planId; + String requestHash = hash(action, request.expectedVersion(), request.batchNo(), request.remark()); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return declarationBatchView(mapper.declarationBatch(replay.resultId())); + ContainerRow container = mapper.lockContainer(planId); + if (container == null || !(DECLARING.equals(container.status()) || COMPLETED.equals(container.status()))) throw conflict("当前柜尚未进入报关行执行阶段,不能创建正式申报批次"); + if (mapper.taxRefundCount(planId) == 0) throw conflict("全买单柜不创建退税正式申报批次"); + if (mapper.unconfirmedReceiptCount(container.id()) != 0 || mapper.brokerSubmittedAt(container.id()) == null) { + throw conflict("当前柜尚未完成退税资料、回执确认和报关行执行"); + } + if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); + long batchId = IdWorker.getId(); + String batchNo = trim(request.batchNo()); + if (batchNo == null) batchNo = "DECL-" + batchId; + String snapshot = declarationSnapshot(container.id()); + if (mapper.insertDeclarationBatch(batchId, container.id(), planId, batchNo, container.version(), snapshot, + trim(request.remark())) != 1) throw conflict("正式申报批次创建失败"); + history(container, "CREATE_CUSTOMS_DECLARATION_BATCH", container.status(), container.status(), batchNo, operatorId, operatorName); + completeCommand(operatorId, action, key, batchId); + return declarationBatchView(mapper.declarationBatch(batchId)); + } + + @Transactional + public DeclarationBatchView submitDeclarationBatch(long batchId, long operatorId, String operatorName, + String key, DeclarationBatchRequest request) { + String action = "SUBMIT_CUSTOMS_DECLARATION_BATCH_" + batchId; + String entryNo = trim(request.customsEntryNo()); + if (entryNo == null) throw bad("正式申报必须回填报关单号"); + String requestHash = hash(action, request.expectedVersion(), request.batchNo(), entryNo, request.remark()); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return declarationBatchView(mapper.declarationBatch(batchId)); + DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId); + if (batch == null || !"READY".equals(batch.status())) throw conflict("正式申报批次不在可提交状态"); + if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent(); + String batchNo = trim(request.batchNo()); + if (batchNo == null) batchNo = batch.batchNo(); + if (mapper.submitDeclarationBatch(batchId, batch.version(), batchNo, entryNo, operatorId, operatorName, + trim(request.remark())) != 1) throw concurrent(); + ContainerRow container = mapper.lockContainerById(batch.customsContainerId()); + if (container != null) history(container, "SUBMIT_CUSTOMS_DECLARATION_BATCH", container.status(), container.status(), entryNo, operatorId, operatorName); + completeCommand(operatorId, action, key, batchId); + return declarationBatchView(mapper.declarationBatch(batchId)); + } + + public List platformClearanceResults(long batchId) { + if (mapper.declarationBatch(batchId) == null) throw notFound("正式申报批次不存在"); + return mapper.clearanceResults(batchId).stream().map(this::clearanceResultView).toList(); + } + + @Transactional + public ClearanceResultView addClearanceResult(long batchId, long operatorId, String operatorName, + String key, ClearanceResultRequest request) { + String type = trim(request.resultType()); + if (!Set.of("INSPECTION", "SUPPLEMENT_REQUIRED", "RELEASED", "DETAINED", "OTHER").contains(type)) { + throw bad("海关结果类型无效"); + } + if (request.occurredAt() == null) throw bad("海关结果发生时间不能为空"); + if (Set.of("DETAINED", "OTHER", "SUPPLEMENT_REQUIRED").contains(type) + && (request.resolution() == null || request.resolution().isBlank())) throw bad("该海关结果必须填写处理说明"); + String action = "ADD_CUSTOMS_CLEARANCE_RESULT_" + batchId; + String requestHash = hash(action, request.expectedVersion(), type, request.occurredAt(), request.resolution(), + request.evidenceFileId(), request.resolved()); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return clearanceResultView(mapper.clearanceResult(replay.resultId())); + DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId); + if (batch == null || !Set.of("SUBMITTED", "ACKNOWLEDGED", "RESULT_PENDING").contains(batch.status())) { + throw conflict("正式申报批次不在可录入海关结果状态"); + } + if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent(); + String resultStatus = "RELEASED".equals(type) || request.resolved() ? "RESOLVED" : "OPEN"; + long resultId = IdWorker.getId(); + if (mapper.insertClearanceResult(resultId, batch.id(), batch.customsContainerId(), type, resultStatus, + batch.customsEntryNo(), request.occurredAt(), trim(request.resolution()), request.evidenceFileId(), + operatorId, operatorName, null) != 1) throw conflict("海关结果保存失败"); + if ("OPEN".equals(resultStatus)) mapper.markDeclarationResultPending(batch.id()); + else mapper.closeDeclarationBatch(batch.id()); + ContainerRow container = mapper.lockContainerById(batch.customsContainerId()); + if (container != null) history(container, "RECORD_CUSTOMS_CLEARANCE_RESULT", container.status(), container.status(), type, operatorId, operatorName); + completeCommand(operatorId, action, key, resultId); + return clearanceResultView(mapper.clearanceResult(resultId)); + } + + @Transactional + public ClearanceResultView resolveClearanceResult(long resultId, long operatorId, String operatorName, + String key, ClearanceResolveRequest request) { + String action = "RESOLVE_CUSTOMS_CLEARANCE_RESULT_" + resultId; + String requestHash = hash(action, request.expectedVersion(), request.resolution()); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return clearanceResultView(mapper.clearanceResult(resultId)); + ClearanceResultRow result = mapper.lockClearanceResult(resultId); + if (result == null) throw notFound("海关结果不存在"); + DeclarationBatchRow batch = mapper.lockDeclarationBatch(result.declarationBatchId()); + if (batch == null || !Objects.equals(batch.version(), request.expectedVersion())) throw concurrent(); + if (mapper.resolveClearanceResult(resultId, trim(request.resolution())) != 1) throw conflict("海关结果已被其他人处理"); + if (mapper.openClearanceResultCount(batch.id()) == 0) mapper.closeDeclarationBatch(batch.id()); + ContainerRow container = mapper.lockContainerById(batch.customsContainerId()); + if (container != null) history(container, "RESOLVE_CUSTOMS_CLEARANCE_RESULT", container.status(), container.status(), trim(request.resolution()), operatorId, operatorName); + completeCommand(operatorId, action, key, resultId); + return clearanceResultView(mapper.clearanceResult(resultId)); + } + + public List merchantClearanceResults(long tenantId, long orderId) { + return mapper.merchantDeclarationBatchIds(tenantId, orderId).stream() + .flatMap(id -> mapper.clearanceResults(id).stream()).map(this::clearanceResultView).toList(); + } + + private DeclarationBatchView declarationBatchView(DeclarationBatchRow row) { + if (row == null) throw notFound("正式申报批次不存在"); + return new DeclarationBatchView(row.id(), row.customsContainerId(), row.loadingPlanId(), row.batchNo(), row.status(), + row.sourceVersion() == null ? 0 : row.sourceVersion(), row.customsEntryNo(), row.version() == null ? 0 : row.version(), + row.submittedAt(), row.submittedByName(), row.acknowledgedAt(), row.acknowledgedByName(), row.remark(), + mapper.clearanceResults(row.id()).stream().map(this::clearanceResultView).toList()); + } + + private ClearanceResultView clearanceResultView(ClearanceResultRow row) { + if (row == null) throw notFound("海关结果不存在"); + return new ClearanceResultView(row.id(), row.declarationBatchId(), row.customsContainerId(), row.resultType(), + row.resultStatus(), row.customsEntryNo(), row.occurredAt(), row.resolution(), row.evidenceFileId(), + row.recordedBy() == null ? 0 : row.recordedBy(), row.recordedByName(), row.recordedAt(), row.previousResultId()); + } + + private BrokerSubmissionView brokerSubmissionView(BrokerSubmissionRow row) { + return new BrokerSubmissionView(row.id(), row.customsContainerId(), row.loadingPlanId(), row.batchNo(), + row.status(), row.sourceVersion() == null ? 0 : row.sourceVersion(), row.sentByName(), row.sentAt(), + row.version() == null ? 0 : row.version()); + } + + private String declarationSnapshot(long containerId) { + List> snapshot = mapper.documents(containerId, null).stream().map(doc -> { + CompletionRow receipt = mapper.completion(doc.id()); + FinalRow finalDocument = mapper.finalDocument(doc.id()); + return Map.of("documentId", doc.id(), "tenantId", doc.tenantId(), "sourceVersion", doc.version(), + "receiptVersion", receipt == null ? 0 : receipt.version(), "receiptStatus", receipt == null ? "MISSING" : receipt.customerConfirmStatus(), + "finalVersion", finalDocument == null ? 0 : finalDocument.version()); + }).toList(); + try { return objectMapper.writeValueAsString(snapshot); } + catch (JsonProcessingException ex) { throw new IllegalStateException(ex); } + } + + private String brokerBatchNo(long planId) { + PlanRow plan = requiredPlan(planId); + String planNo = trim(plan.planNo()); + if (planNo != null && planNo.matches("[A-Za-z0-9_-]{1,52}")) return "BR-" + planNo + "-01"; + String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(Locale.ROOT); + return "BR-" + LocalDate.now() + "-" + suffix; + } + + private String brokerSubmissionSnapshot(long containerId) { + List> snapshot = mapper.documents(containerId, null).stream().map(document -> { + List scopes = mapper.frozenScopes(document.id()); + return Map.of("documentId", document.id(), "tenantId", document.tenantId(), + "sourceFileId", document.fileId(), "sourceFileName", document.fileName(), "sourceFileHash", document.fileHash(), + "frozenVersion", document.frozenVersion(), "inboundIds", scopes.stream().map(ScopeRow::inboundId).toList(), + "inboundCodes", scopes.stream().map(ScopeRow::inboundCode).toList()); + }).toList(); + try { return objectMapper.writeValueAsString(snapshot); } + catch (JsonProcessingException ex) { throw new IllegalStateException(ex); } + } + public Download downloadSource(long tenantId, long documentId, boolean platform) { DocumentRow source = requiredDocument(documentId); if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在"); @@ -238,15 +520,38 @@ public class ContainerCustomsService { return download(source.tenantId(), SOURCE_FILE_TYPE, Long.toString(container.loadingPlanId()), source.fileId(), source.fileName()); } + public List receiptActions(long tenantId, long documentId, boolean platform) { + DocumentRow source = requiredDocument(documentId); + if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在"); + return receiptActionViews(documentId, platform ? null : tenantId); + } + + private List receiptActionViews(long documentId, Long tenantId) { + return Optional.ofNullable(mapper.receiptActionHistory(documentId, tenantId)).orElseGet(List::of).stream() + .map(row -> new ReceiptActionView(row.id(), row.receiptVersion(), row.action(), row.reason(), + row.operatorName(), row.createdAt())).toList(); + } + public Download downloadCompletion(long tenantId, long documentId, boolean platform) { DocumentRow source = requiredDocument(documentId); ContainerRow container = mapper.containerById(source.customsContainerId()); - if (!platform && (source.tenantId() != tenantId || container == null || !COMPLETED.equals(container.status()))) { - throw notFound("报关完成文件不存在"); + if (!platform && (source.tenantId() != tenantId || container == null || !Set.of(DECLARING, COMPLETED).contains(container.status()))) { + throw notFound("报关行回执不存在"); } CompletionRow completion = mapper.completion(documentId); - if (completion == null) throw notFound("报关完成文件不存在"); - return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName()); + if (completion == null) throw notFound("报关行回执不存在"); + return download(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName()); + } + + public Download downloadFinal(long tenantId, long documentId, boolean platform) { + DocumentRow source = requiredDocument(documentId); + ContainerRow container = mapper.containerById(source.customsContainerId()); + if (!platform && (source.tenantId() != tenantId || container == null || !COMPLETED.equals(container.status()))) { + throw notFound("报关完税文件不存在"); + } + FinalRow finalDocument = mapper.finalDocument(documentId); + if (finalDocument == null) throw notFound("报关完税文件不存在"); + return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), finalDocument.fileId(), finalDocument.fileName()); } private ContainerRow ensure(long planId) { @@ -336,19 +641,30 @@ public class ContainerCustomsService { List documentViews = documents.stream().map(doc -> documentView(doc, merchant)).toList(); int refund = (int) visibleRows.stream().filter(row -> "TAX_REFUND".equals(row.taxMode())).count(); int coveredRefund = (int) visibleRows.stream().filter(row -> covered.contains(row.inboundId())).count(); + long actualBoxCount = mapper.actualBoxCount(container.loadingPlanId(), tenantId, + tenantId == null ? null : "TAX_REFUND"); String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund); + BrokerSubmissionRow submission = mapper.brokerSubmission(container.id()); return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), plan.planNo(), plan.cabinetNo(), plan.containerNo(), container.status(), !rows.isEmpty(), merchantStatus, container.version(), refund, coveredRefund, - inbounds, documentViews, container.startedAt(), container.completedAt()); + actualBoxCount, inbounds, documentViews, container.startedAt(), container.completedAt(), container.brokerSubmittedAt(), + submission == null ? null : brokerSubmissionView(submission)); } private DocumentView documentView(DocumentRow source, boolean merchant) { List scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id()); CompletionRow completion = mapper.completion(source.id()); + FinalRow finalDocument = mapper.finalDocument(source.id()); ContainerRow container = mapper.containerById(source.customsContainerId()); - FileView result = completion == null || (merchant && !COMPLETED.equals(container.status())) ? null : file(completion); + FileView result = completion == null ? null : file(completion); + List receiptHistory = completion == null ? List.of() : Optional.ofNullable(mapper.completionVersions(completion.id())).orElseGet(List::of).stream() + .map(version -> new ReceiptVersionView(version.documentVersion(), file(version), version.operatorName(), version.createdAt())).toList(); return new DocumentView(source.id(), source.tenantId(), file(source), scopes.stream().map(ScopeRow::inboundId).toList(), - scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result); + scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result, + completion == null ? null : completion.customerConfirmStatus(), + completion == null ? null : completion.customerRejectReason(), + finalDocument == null ? null : file(finalDocument), receiptHistory, + receiptActionViews(source.id(), merchant ? source.tenantId() : null)); } private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) { @@ -406,6 +722,12 @@ public class ContainerCustomsService { return value; } + private ContainerView merchantViewForDocument(long tenantId, DocumentRow source) { + List scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id()); + if (scopes.isEmpty()) throw notFound("客户报送资料未关联入仓单"); + return merchantOrderView(tenantId, scopes.get(0).orderId()); + } + private String scopeJson(List scope) { try { return objectMapper.writeValueAsString(scope.stream().map(row -> Map.of("inboundId", row.inboundId(), "orderId", row.orderId(), "inboundCode", row.inboundCode())).toList()); } @@ -414,6 +736,11 @@ public class ContainerCustomsService { private static FileView file(DocumentRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); } private static FileView file(CompletionRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); } + private static FileView file(ContainerCustomsMapper.CompletionVersionRow row) { + return new FileView(row.fileId(), row.fileName(), row.fileHash(), row.contentType(), row.sizeBytes(), + row.documentVersion(), row.createdAt()); + } + private static FileView file(FinalRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); } private static long size(CustomsFileClient.Metadata value) { return value.sizeBytes() == null ? 0 : value.sizeBytes(); } private static void validateFile(MultipartFile file) { if (file == null || file.isEmpty()) throw bad("请选择文件"); if (file.getSize() > MAX_FILE_BYTES) throw bad("单文件不能超过50MB"); } private static String fileHash(MultipartFile file) { try (InputStream input=file.getInputStream()) { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.readAllBytes())); } catch(Exception ex){ throw bad("无法读取上传文件"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java index 84aa64bde..e6401d4bc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java @@ -76,6 +76,13 @@ public final class FbaPricingDtos { } } + public record ManualQuoteRequest( + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal unitPrice, + @NotBlank @Size(min = 2, max = 500) String reason + ) {} + public record AdjustFeeRequest( @NotNull @Positive Integer expectedFeeVersion, @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LingxingTestDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LingxingTestDtos.java new file mode 100644 index 000000000..b2754712b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LingxingTestDtos.java @@ -0,0 +1,18 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.math.BigDecimal; +import java.util.List; + +public final class LingxingTestDtos { + private LingxingTestDtos() {} + + public record Request(@NotBlank @Size(max = 128) String skuCode) {} + public record VerificationRequest(@NotBlank @Size(max = 16) String code) {} + public record WarehouseResult(String whCode, boolean reachable, boolean matched, + boolean maintained, String productSku, String barcode, + BigDecimal length, BigDecimal width, BigDecimal height, + BigDecimal weight, String message) {} + public record Response(String skuCode, List warehouses) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 3b270601c..383ffb6bb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -102,7 +102,8 @@ public final class LoadingListDtos { } } - public record ConfirmPreloadRequest(@NotNull @Min(0) Integer expectedVersion) { + public record ConfirmPreloadRequest(@NotNull @Min(0) Integer expectedVersion, + @NotNull @Min(1) Long assigneeId) { } public record PreloadConfirmationResponse( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java index a64dc7948..1e9682d09 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java @@ -23,6 +23,8 @@ public final class MojiaMeasurementDtos { @NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal length, @NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal width, @NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal height, + /** 设备实测单件重量,单位 kg。墨家协议已提供但旧 Java 接收模型遗漏。 */ + @NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal weightKg, @NotBlank @Size(max = 64) String machineNo, LocalDateTime measuredAt, @NotBlank @Size(max = 7_100_000) String picture diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaSkuSyncDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaSkuSyncDtos.java new file mode 100644 index 000000000..88b6a8763 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaSkuSyncDtos.java @@ -0,0 +1,33 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public final class MojiaSkuSyncDtos { + private MojiaSkuSyncDtos() {} + + public record Query(String code, String measurementKind, String syncStatus, long page, long size) {} + + public record Config(boolean autoSyncEnabled) {} + + public record UpdateRequest( + @NotBlank @Size(max = 128) String skuCode, + @NotNull @DecimalMin("0.001") BigDecimal lengthCm, + @NotNull @DecimalMin("0.001") BigDecimal widthCm, + @NotNull @DecimalMin("0.001") BigDecimal heightCm, + @NotNull @DecimalMin("0.001") BigDecimal weightKg, + @NotNull Integer revision) {} + + public record RetryRequest(@NotNull Integer revision) {} + + public record Record( + Long id, String measurementKind, String boxCode, String inboundCode, String skuCode, + BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, + BigDecimal weightKg, Long imageFileId, LocalDateTime measuredAt, LocalDateTime receivedAt, + String syncStatus, String syncFailureCode, String syncFailureReason, String lingxingSkuId, + Integer revision, LocalDateTime updatedAt) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java index ff341cc93..78e45cf5b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java @@ -19,14 +19,23 @@ public class MojiaMeasurementReceiptEntity { private Long tenantId; private Long subjectId; private String boxCode; + private String inboundCode; + /** BOX_LABEL 表示现有箱唛链路;SKU 表示一件代发新品测量同步收件。 */ + private String measurementKind; private String scannedShipmentId; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; + private BigDecimal weightKg; private String machineNo; private LocalDateTime measuredAt; private Long imageFileId; private String status; + private String syncStatus; + private String syncFailureCode; + private String syncFailureReason; + private String lingxingSkuId; + private Integer revision; private String matchFailureCode; private String matchFailureReason; private String requestId; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthManager.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthManager.java new file mode 100644 index 000000000..96de6b70d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthManager.java @@ -0,0 +1,99 @@ +package com.baiyue.firstleg.lingxing; + +import java.time.Clock; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.function.Function; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** 领星登录态缓存:按 providerKey 隔离、提前过期、single-flight 和二次验证冷却。 */ +@Component +public class LingxingAuthManager { + private final LingxingBrowserSessionReader browser; + private final Clock clock; + private final Executor executor; + private final Map cache = new ConcurrentHashMap<>(); + private final Map> refreshing = new ConcurrentHashMap<>(); + private final Map cooldownUntil = new ConcurrentHashMap<>(); + private final Map pendingVerification = new ConcurrentHashMap<>(); + private volatile Function passwordLogin; + + @Autowired + public LingxingAuthManager(LingxingBrowserSessionReader browser) { + this(browser, Clock.systemUTC(), Executors.newCachedThreadPool(r -> { + Thread thread = new Thread(r, "lingxing-auth-refresh"); thread.setDaemon(true); return thread; + })); + } + + LingxingAuthManager(LingxingBrowserSessionReader browser, Clock clock, Executor executor) { + this.browser = browser; this.clock = clock; this.executor = executor; + } + + public CompletableFuture getAuthState(LingxingProviderConfig config, String loginOrigin) { + LingxingAuthState cached = cache.get(config.providerKey()); + if (cached != null && cached.usable(clock.millis())) return CompletableFuture.completedFuture(cached); + CompletableFuture current = refreshing.get(config.providerKey()); + if (current != null) return current; + CompletableFuture created = CompletableFuture.supplyAsync( + () -> refresh(config, loginOrigin), executor); + CompletableFuture previous = refreshing.putIfAbsent(config.providerKey(), created); + if (previous != null) return previous; + created.whenComplete((value, error) -> refreshing.remove(config.providerKey(), created)); + return created; + } + + public void invalidate(String providerKey, String reason) { cache.remove(providerKey); } + + public void setPasswordLogin(Function passwordLogin) { + this.passwordLogin = passwordLogin; + } + + public void markSecondaryVerificationRequired(String providerKey, long cooldownSeconds) { + cooldownUntil.put(providerKey, clock.millis() + Math.max(1, cooldownSeconds) * 1000L); + } + + public void clear(String providerKey) { cache.remove(providerKey); cooldownUntil.remove(providerKey); } + + private LingxingAuthState refresh(LingxingProviderConfig config, String loginOrigin) { + Long retryAt = cooldownUntil.get(config.providerKey()); + if (retryAt != null && retryAt > clock.millis()) { + throw new LingxingSecondaryVerificationException("领星账号处于二次验证冷却期"); + } + if (config.browserCdpEndpoint() != null && !config.browserCdpEndpoint().isBlank()) { + try { + LingxingAuthState state = browser.read(config, loginOrigin); + cache.put(config.providerKey(), state); cooldownUntil.remove(config.providerKey()); return state; + } catch (RuntimeException ex) { + if (config.browserSessionRequired() && (config.username() == null || config.password() == null)) throw ex; + } + } + Function login = passwordLogin; + LingxingAuthState state; + if (login != null) state = login.apply(config); + else if (config.username() != null && !config.username().isBlank() + && config.password() != null && !config.password().isBlank()) { + try { + state = browser.login(config, loginOrigin); + } catch (LingxingSecondaryVerificationException | LingxingBrowserChallengeException ex) { + pendingVerification.put(config.providerKey(), config); + throw ex; + } + } + else throw new IllegalStateException("领星浏览器登录态不可用,且未配置账号密码"); + cache.put(config.providerKey(), state); cooldownUntil.remove(config.providerKey()); return state; + } + + public LingxingAuthState submitVerification(String providerKey, String loginOrigin, String code) { + LingxingProviderConfig config = pendingVerification.get(providerKey); + if (config == null) throw new LingxingSecondaryVerificationException("当前没有等待验证码的领星登录任务"); + LingxingAuthState state = browser.submitVerification(config, loginOrigin, code); + cache.put(providerKey, state); + pendingVerification.remove(providerKey); + cooldownUntil.remove(providerKey); + return state; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthState.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthState.java new file mode 100644 index 000000000..065de5e3b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthState.java @@ -0,0 +1,9 @@ +package com.baiyue.firstleg.lingxing; + +/** 不包含完整凭证日志输出的领星登录态快照。 */ +public record LingxingAuthState(String token, String cookies, long expiresAt, + String userAgent, String loginVia) { + public boolean usable(long now) { + return token != null && !token.isBlank() && cookies != null && expiresAt > now; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserChallengeException.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserChallengeException.java new file mode 100644 index 000000000..20c16c288 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserChallengeException.java @@ -0,0 +1,6 @@ +package com.baiyue.firstleg.lingxing; + +/** 需要人工完成浏览器安全验证(例如腾讯滑块)时的可识别错误。 */ +public class LingxingBrowserChallengeException extends RuntimeException { + public LingxingBrowserChallengeException(String message) { super(message); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReader.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReader.java new file mode 100644 index 000000000..b4410f910 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReader.java @@ -0,0 +1,274 @@ +package com.baiyue.firstleg.lingxing; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** 通过 Chrome DevTools Protocol 读取人工登录的持久化 OMS 页面。 */ +@Component +public class LingxingBrowserSessionReader { + private final ObjectMapper mapper; + private final HttpClient http; + private final Duration timeout; + + @Autowired + public LingxingBrowserSessionReader(ObjectMapper mapper) { + this(mapper, HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(), Duration.ofSeconds(5)); + } + + LingxingBrowserSessionReader(ObjectMapper mapper, HttpClient http, Duration timeout) { + this.mapper = mapper; this.http = http; this.timeout = timeout; + } + + public LingxingAuthState read(LingxingProviderConfig config, String loginOrigin) { + CdpSocket socket = null; + try { + socket = open(config, loginOrigin, false); + return readFromSocket(socket, config, loginOrigin); + } catch (Exception ex) { + throw new IllegalStateException("读取领星浏览器登录态失败", ex); + } finally { + if (socket != null) socket.close(); + } + } + + /** 方案 A:业务调用触发的浏览器账号密码登录。不会绕过腾讯滑块。 */ + public LingxingAuthState login(LingxingProviderConfig config, String loginOrigin) { + CdpSocket socket = null; + try { + socket = open(config, loginOrigin, true); + JsonNode state = evaluate(socket, pageStateExpression()); + if (state.path("token").asText("").isBlank() || state.path("href").asText("").contains("/login")) { + if (state.path("captcha").asBoolean(false)) throw new LingxingBrowserChallengeException("领星需要人工完成安全验证"); + fill(socket, "input[name='username']", config.username()); + fill(socket, "input[name='password']", config.password()); + click(socket, ".login-btn"); + long deadline = System.nanoTime() + timeout.toNanos() * 12; + while (System.nanoTime() < deadline) { + Thread.sleep(250); + state = evaluate(socket, pageStateExpression()); + if (!state.path("token").asText("").isBlank() && !state.path("href").asText("").contains("/login")) break; + if (state.path("captcha").asBoolean(false)) throw new LingxingBrowserChallengeException("领星需要人工完成安全验证"); + if (state.path("mfa").asBoolean(false)) { + requestVerificationCode(socket); + throw new LingxingSecondaryVerificationException("领星需要二次验证码"); + } + } + } + return readFromSocket(socket, config, loginOrigin); + } catch (LingxingSecondaryVerificationException | LingxingBrowserChallengeException ex) { + throw ex; + } catch (Exception ex) { + throw new IllegalStateException("领星自动登录失败", ex); + } finally { + if (socket != null) socket.close(); + } + } + + /** 提交当前登录页面的短信、邮箱或 TOTP 验证码,并读取新的会话。 */ + public LingxingAuthState submitVerification(LingxingProviderConfig config, String loginOrigin, String code) { + if (code == null || code.isBlank()) throw new IllegalArgumentException("验证码不能为空"); + CdpSocket socket = null; + try { + socket = open(config, loginOrigin, false); + // SMS/email uses Element UI's placeholder input; TOTP uses the + // custom six-digit input component with a native input element. + fill(socket, "input[placeholder='输入验证码'], input.verification-code-native", code.trim()); + click(socket, ".verify-code-login .login-btn"); + long deadline = System.nanoTime() + timeout.toNanos() * 12; + while (System.nanoTime() < deadline) { + Thread.sleep(250); + JsonNode state = evaluate(socket, pageStateExpression()); + if (!state.path("token").asText("").isBlank() && !state.path("href").asText("").contains("/login")) { + return readFromSocket(socket, config, loginOrigin); + } + if (state.path("captcha").asBoolean(false)) { + throw new LingxingBrowserChallengeException("领星需要人工完成安全验证"); + } + if (!state.path("mfa").asBoolean(false)) throw new IllegalStateException("领星验证码验证失败"); + } + throw new IllegalStateException("领星验证码验证超时"); + } catch (Exception ex) { + if (ex instanceof IllegalStateException) throw (IllegalStateException) ex; + throw new IllegalStateException("提交领星验证码失败", ex); + } finally { + if (socket != null) socket.close(); + } + } + + private CdpSocket open(LingxingProviderConfig config, String loginOrigin, boolean allowLoginPage) throws Exception { + ArrayNode pages = (ArrayNode) mapper.readTree(http.send( + HttpRequest.newBuilder(URI.create(stripSlash(config.browserCdpEndpoint()) + "/json/list")) + .timeout(timeout).GET().build(), HttpResponse.BodyHandlers.ofString()).body()); + JsonNode selected = selectPage(pages, loginOrigin); + if (selected == null) throw new IllegalStateException("未找到领星 OMS 页面"); + String ws = selected.path("webSocketDebuggerUrl").asText(""); + if (ws.isBlank()) throw new IllegalStateException("领星页面未提供 CDP WebSocket 地址"); + CdpSocket socket = new CdpSocket(mapper, http, timeout, rewriteWebSocketEndpoint(URI.create(ws), URI.create(config.browserCdpEndpoint()))); + socket.command("Runtime.enable", mapper.createObjectNode()).join(); + socket.command("Network.enable", mapper.createObjectNode()).join(); + if (allowLoginPage) socket.command("Page.enable", mapper.createObjectNode()).join(); + return socket; + } + + private LingxingAuthState readFromSocket(CdpSocket socket, LingxingProviderConfig config, String loginOrigin) { + JsonNode value = evaluate(socket, "({token:localStorage.getItem('oms-token'),version:localStorage.getItem('version'),href:location.href,ua:navigator.userAgent})"); + String token = value.path("token").asText(""); + String href = value.path("href").asText(""); + if (token.isBlank() || href.contains("/login")) throw new IllegalStateException("领星浏览器尚未完成登录"); + ObjectNode cookiesResult = socket.command("Network.getAllCookies", mapper.createObjectNode()).join(); + String host = URI.create(loginOrigin).getHost(); + List cookies = new ArrayList<>(); + for (JsonNode cookie : cookiesResult.path("result").path("cookies")) { + if (cookie.path("domain").asText("").contains(host)) cookies.add(cookie.path("name").asText() + "=" + cookie.path("value").asText()); + } + if (cookies.isEmpty()) throw new IllegalStateException("领星浏览器未返回有效 Cookie"); + return new LingxingAuthState(token, String.join("; ", cookies), expiresAt(token, config), value.path("ua").asText(null), "browser_session"); + } + + private JsonNode evaluate(CdpSocket socket, String expression) { + return socket.command("Runtime.evaluate", mapper.createObjectNode().put("expression", expression).put("returnByValue", true)) + .join().path("result").path("result").path("value"); + } + + private static String pageStateExpression() { + return "(() => {" + + "const visible = (e) => { if (!e) return false; const s=getComputedStyle(e), r=e.getBoundingClientRect();" + + "return s.display !== 'none' && s.visibility !== 'hidden' && Number(s.opacity) !== 0 && r.width > 0 && r.height > 0; };" + + "const captcha = Array.from(document.querySelectorAll('#captchaId iframe,#captchaId > *,.tencent-captcha-dy')).some(visible);" + + "return {href:location.href,token:localStorage.getItem('oms-token'),mfa:!!document.querySelector('.verify-code-login'),captcha};" + + "})()"; + } + + private void fill(CdpSocket socket, String selector, String value) { + String expression = "(() => { const e=document.querySelector(" + mapper.createObjectNode().put("s", selector).path("s").toString() + "); if(!e) return false; e.focus(); e.select(); return true; })()"; + if (!evaluate(socket, expression).asBoolean(false)) throw new IllegalStateException("登录控件不存在: " + selector); + socket.command("Input.insertText", mapper.createObjectNode().put("text", value)).join(); + } + + private void click(CdpSocket socket, String selector) { + String expression = "(() => { const e=document.querySelector(" + mapper.createObjectNode().put("s", selector).path("s").toString() + "); if(!e) return false; e.click(); return true; })()"; + if (!evaluate(socket, expression).asBoolean(false)) throw new IllegalStateException("登录按钮不存在: " + selector); + } + + private void requestVerificationCode(CdpSocket socket) throws InterruptedException { + // SMS/EMAIL pages require an explicit send action. TOTP initializes itself + // when the channel is mounted and has no .code-btn. + long deadline = System.nanoTime() + Duration.ofSeconds(2).toNanos(); + while (System.nanoTime() < deadline) { + if (evaluate(socket, "(() => { const e=document.querySelector('.verify-code-login .code-btn'); return !!e && !e.disabled; })()") + .asBoolean(false)) { + click(socket, ".verify-code-login .code-btn"); + Thread.sleep(300); + return; + } + Thread.sleep(100); + } + } + + static URI rewriteWebSocketEndpoint(URI discovered, URI endpoint) { + if (discovered == null || endpoint == null || discovered.getPath() == null || discovered.getPath().isBlank()) { + throw new IllegalArgumentException("CDP WebSocket 地址无效"); + } + try { + String scheme = "https".equalsIgnoreCase(endpoint.getScheme()) ? "wss" : "ws"; + return new URI(scheme, null, endpoint.getHost(), endpoint.getPort(), discovered.getPath(), discovered.getQuery(), discovered.getFragment()); + } catch (URISyntaxException ex) { + throw new IllegalArgumentException("CDP WebSocket 地址无效", ex); + } + } + + static JsonNode selectPage(ArrayNode pages, String origin) { + URI expected = URI.create(origin); + JsonNode fallback = null; + for (JsonNode page : pages) { + if (!"page".equals(page.path("type").asText(""))) continue; + String url = page.path("url").asText(""); + if (page.path("webSocketDebuggerUrl").asText("").isBlank()) continue; + URI actual; + try { actual = URI.create(url); } catch (IllegalArgumentException ex) { continue; } + if (!sameOrigin(expected, actual)) continue; + String path = actual.getPath() == null ? "" : actual.getPath(); + if (!path.equals("/login") && !path.startsWith("/login/")) return page; + fallback = page; + } + return fallback; + } + + private static boolean sameOrigin(URI expected, URI actual) { + return expected.getScheme() != null && expected.getScheme().equalsIgnoreCase(actual.getScheme()) + && expected.getHost() != null && expected.getHost().equalsIgnoreCase(actual.getHost()) + && effectivePort(expected) == effectivePort(actual); + } + + private static int effectivePort(URI uri) { + if (uri.getPort() >= 0) return uri.getPort(); + return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; + } + + static long expiresAt(String token, LingxingProviderConfig config) { + long now = System.currentTimeMillis(); + try { + String[] parts = token.split("\\."); + if (parts.length >= 2) { + JsonNode payload = new ObjectMapper().readTree(java.util.Base64.getUrlDecoder().decode(parts[1])); + if (payload.has("exp")) return Math.max(now, payload.path("exp").asLong() * 1000L - config.expirySafetySeconds() * 1000L); + } + } catch (Exception ignored) { } + return now + Math.max(1, config.tokenTtlSeconds() - config.expirySafetySeconds()) * 1000L; + } + + private static String stripSlash(String value) { return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; } + + static final class CdpSocket implements WebSocket.Listener { + private final ObjectMapper mapper; private final Duration timeout; private final AtomicLong ids = new AtomicLong(); + private final Map> pending = new ConcurrentHashMap<>(); + private final StringBuilder textBuffer = new StringBuilder(); + private final CompletableFuture connected; + private volatile WebSocket socket; + CdpSocket(ObjectMapper mapper, HttpClient http, Duration timeout, URI uri) { + this.mapper = mapper; this.timeout = timeout; + connected = http.newWebSocketBuilder().connectTimeout(timeout).buildAsync(uri, this); + } + CompletableFuture command(String method, ObjectNode params) { + return connected.thenCompose(ws -> { + long id = ids.incrementAndGet(); CompletableFuture result = new CompletableFuture<>(); pending.put(id, result); + ObjectNode request = mapper.createObjectNode().put("id", id).put("method", method).set("params", params); + ws.sendText(request.toString(), true); return result.orTimeout(timeout.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS); + }); + } + void close() { if (socket != null) socket.sendClose(WebSocket.NORMAL_CLOSURE, "done"); } + @Override public void onOpen(WebSocket webSocket) { socket = webSocket; webSocket.request(1); } + @Override public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + try { + textBuffer.append(data); + if (last) { + ObjectNode response = (ObjectNode) mapper.readTree(textBuffer.toString()); + textBuffer.setLength(0); + long id = response.path("id").asLong(-1); + CompletableFuture f = pending.remove(id); + if (f != null) f.complete(response); + } + } catch (Exception ex) { textBuffer.setLength(0); } + webSocket.request(1); return CompletableFuture.completedFuture(null); + } + @Override public void onError(WebSocket webSocket, Throwable error) { pending.values().forEach(f -> f.completeExceptionally(error)); } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingDirectClient.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingDirectClient.java new file mode 100644 index 000000000..0b246d123 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingDirectClient.java @@ -0,0 +1,261 @@ +package com.baiyue.firstleg.lingxing; + +import com.baiyue.firstleg.config.LingxingSyncProperties; +import com.baiyue.firstleg.dto.LingxingTestDtos; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Base64; +import java.util.concurrent.CompletionException; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** 领星新品查询与人工尺寸维护客户端;自动任务不会调用本客户端的写入入口。 */ +@Component +public class LingxingDirectClient { + private final ObjectMapper mapper; + private final LingxingAuthManager authManager; + private final LingxingSyncProperties properties; + private final HttpClient http; + + @Autowired + public LingxingDirectClient(ObjectMapper mapper, LingxingAuthManager authManager, + LingxingSyncProperties properties) { + this.mapper = mapper; this.authManager = authManager; this.properties = properties; + this.http = HttpClient.newBuilder().connectTimeout(properties.getRequestTimeout()).build(); + } + + public LingxingTestDtos.Response test(String skuCode) { + String sku = skuCode == null ? "" : skuCode.trim(); + if (sku.isBlank()) throw new IllegalArgumentException("SKU不能为空"); + LingxingAuthState auth = awaitAuth(authManager.getAuthState(new LingxingProviderConfig( + properties.getProviderKey(), properties.getBrowserCdpEndpoint(), properties.isBrowserSessionRequired(), + properties.getTokenTtlSeconds(), properties.getTokenExpirySafetySeconds(), + properties.getUsername(), properties.getPassword()), properties.getLoginOrigin())); + List results = new ArrayList<>(); + for (String whCode : properties.getWarehouses()) results.add(queryWithReLogin(auth, sku, whCode)); + return new LingxingTestDtos.Response(sku, results); + } + + /** 手工维护领星新品尺寸;只对精确命中的、尚未维护的仓库发起一次 /measure。 */ + public PushResult push(String skuCode, BigDecimal lengthCm, BigDecimal widthCm, + BigDecimal heightCm, BigDecimal weightKg) { + String sku = skuCode == null ? "" : skuCode.trim(); + if (sku.isBlank()) throw new IllegalArgumentException("SKU不能为空"); + LingxingAuthState auth = awaitAuth(authManager.getAuthState(new LingxingProviderConfig( + properties.getProviderKey(), properties.getBrowserCdpEndpoint(), properties.isBrowserSessionRequired(), + properties.getTokenTtlSeconds(), properties.getTokenExpirySafetySeconds(), + properties.getUsername(), properties.getPassword()), properties.getLoginOrigin())); + List results = new ArrayList<>(); + for (String whCode : properties.getWarehouses()) { + try { + JsonNode record; + try { + record = findExact(auth, sku, whCode); + } catch (IllegalStateException unauthorized) { + if (!"领星登录态已失效".equals(unauthorized.getMessage())) throw unauthorized; + authManager.invalidate(properties.getProviderKey(), "lingxing_push_unauthorized"); + auth = awaitAuth(authManager.getAuthState(providerConfig(), properties.getLoginOrigin())); + record = findExact(auth, sku, whCode); + } + if (record == null) { + results.add(new PushWarehouseResult(whCode, false, "未匹配到领星 SKU")); + } else if (record.path("maintained").asInt(0) == 1) { + results.add(new PushWarehouseResult(whCode, false, "领星 SKU 已维护,不能重复维护")); + } else { + try { + measure(auth, record, lengthCm, widthCm, heightCm, weightKg); + } catch (IllegalStateException unauthorized) { + if (!"领星登录态已失效".equals(unauthorized.getMessage())) throw unauthorized; + authManager.invalidate(properties.getProviderKey(), "lingxing_measure_unauthorized"); + auth = awaitAuth(authManager.getAuthState(providerConfig(), properties.getLoginOrigin())); + JsonNode refreshedRecord = findExact(auth, sku, whCode); + if (refreshedRecord == null || refreshedRecord.path("maintained").asInt(0) == 1) { + results.add(new PushWarehouseResult(whCode, false, "领星 SKU 已维护,不能重复维护")); + continue; + } + measure(auth, refreshedRecord, lengthCm, widthCm, heightCm, weightKg); + } + results.add(new PushWarehouseResult(whCode, true, "提交成功")); + } + } catch (Exception ex) { + if (ex instanceof LingxingSecondaryVerificationException + || ex instanceof LingxingBrowserChallengeException) throw (RuntimeException) ex; + results.add(new PushWarehouseResult(whCode, false, "提交领星失败")); + } + } + boolean success = results.stream().anyMatch(PushWarehouseResult::success) + && results.stream().filter(r -> r.matched()).allMatch(PushWarehouseResult::success); + String lingxingSkuId = sku; + return new PushResult(success, lingxingSkuId, results); + } + + private LingxingTestDtos.WarehouseResult query(LingxingAuthState auth, String sku, String whCode) { + try { + ObjectNode body = mapper.createObjectNode().put("barcode", sku).put("current", 1) + .put("customerCode", "").put("keyword", sku).put("multiKeyword", "") + .put("searchType", "barcode").put("size", 20).put("whCode", whCode); + String json = canonical(body); + HttpRequest request = HttpRequest.newBuilder(URI.create(strip(properties.getBaseUrl()) + "/gateway/wms/newProduct/page")) + .timeout(properties.getRequestTimeout()).header("Content-Type", "application/json") + .header("Authorization", "Bearer " + auth.token()).header("Cookie", auth.cookies()) + .header("Origin", properties.getLoginOrigin()).header("Referer", strip(properties.getLoginOrigin()) + "/") + .header("lang", "zh").header("version", "prod") + .header("Track-Key", trackKey("POST", json)).POST(HttpRequest.BodyPublishers.ofString(json)).build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode root = mapper.readTree(response.body()); + if (response.statusCode() == 401 || isUnauthorized(root)) { + authManager.invalidate(properties.getProviderKey(), "lingxing_test_unauthorized"); + return new LingxingTestDtos.WarehouseResult(whCode, false, false, false, null, null, null, null, null, null, "领星登录态已失效,请重新登录"); + } + if (root.path("code").asInt(-1) != 200) return empty(whCode, false, "领星返回业务失败:" + root.path("msg").asText("未知错误")); + JsonNode records = root.path("data").path("records"); + for (JsonNode record : records) { + if (sku.equals(record.path("productSku").asText()) || sku.equals(record.path("barcode").asText())) { + return new LingxingTestDtos.WarehouseResult(whCode, true, true, record.path("maintained").asInt(0) == 1, + text(record,"productSku"), text(record,"barcode"), decimal(record,"wmsLength"), decimal(record,"wmsWidth"), + decimal(record,"wmsHeight"), decimal(record,"wmsWeight"), "查询成功"); + } + } + return empty(whCode, true, "未匹配到领星 SKU"); + } catch (Exception ex) { + return empty(whCode, false, "查询领星失败:" + ex.getMessage()); + } + } + + private LingxingTestDtos.WarehouseResult queryWithReLogin(LingxingAuthState initial, String sku, String whCode) { + LingxingTestDtos.WarehouseResult result = query(initial, sku, whCode); + if (result.reachable() || result.message() == null || !result.message().contains("登录态已失效")) return result; + authManager.invalidate(properties.getProviderKey(), "lingxing_test_unauthorized"); + LingxingAuthState refreshed = awaitAuth(authManager.getAuthState(providerConfig(), properties.getLoginOrigin())); + return query(refreshed, sku, whCode); + } + + /** Keep domain login/challenge exceptions visible to the REST controller. */ + private static LingxingAuthState awaitAuth(java.util.concurrent.CompletableFuture future) { + try { + return future.join(); + } catch (CompletionException ex) { + Throwable cause = ex; + while (cause instanceof CompletionException && cause.getCause() != null) cause = cause.getCause(); + if (cause instanceof RuntimeException runtime) throw runtime; + throw ex; + } + } + + private JsonNode findExact(LingxingAuthState auth, String sku, String whCode) throws Exception { + ObjectNode body = mapper.createObjectNode().put("barcode", sku).put("current", 1) + .put("customerCode", "").put("keyword", sku).put("multiKeyword", "") + .put("searchType", "barcode").put("size", 20).put("whCode", whCode); + String json = canonical(body); + HttpRequest request = HttpRequest.newBuilder(URI.create(strip(properties.getBaseUrl()) + "/gateway/wms/newProduct/page")) + .timeout(properties.getRequestTimeout()).header("Content-Type", "application/json") + .header("Authorization", "Bearer " + auth.token()).header("Cookie", auth.cookies()) + .header("Origin", properties.getLoginOrigin()).header("Referer", strip(properties.getLoginOrigin()) + "/") + .header("lang", "zh").header("version", "prod").header("Track-Key", trackKey("POST", json)) + .POST(HttpRequest.BodyPublishers.ofString(json)).build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode root = mapper.readTree(response.body()); + if (response.statusCode() == 401 || isUnauthorized(root)) { + authManager.invalidate(properties.getProviderKey(), "lingxing_push_unauthorized"); + throw new IllegalStateException("领星登录态已失效"); + } + if (root.path("code").asInt(-1) != 200) throw new IllegalStateException("领星查询失败"); + for (JsonNode record : root.path("data").path("records")) { + if (sku.equals(record.path("productSku").asText()) || sku.equals(record.path("barcode").asText())) return record; + } + return null; + } + + private LingxingProviderConfig providerConfig() { + return new LingxingProviderConfig(properties.getProviderKey(), properties.getBrowserCdpEndpoint(), + properties.isBrowserSessionRequired(), properties.getTokenTtlSeconds(), + properties.getTokenExpirySafetySeconds(), properties.getUsername(), properties.getPassword()); + } + + private void measure(LingxingAuthState auth, JsonNode record, BigDecimal lengthCm, + BigDecimal widthCm, BigDecimal heightCm, BigDecimal weightKg) throws Exception { + ObjectNode body = (ObjectNode) record.deepCopy(); + body.put("wmsLength", number(lengthCm)); + body.put("wmsLengthBs", number(lengthCm.divide(BigDecimal.valueOf(2.54), 3, RoundingMode.HALF_UP))); + body.put("wmsWidth", number(widthCm)); + body.put("wmsWidthBs", number(widthCm.divide(BigDecimal.valueOf(2.54), 3, RoundingMode.HALF_UP))); + body.put("wmsHeight", number(heightCm)); + body.put("wmsHeightBs", number(heightCm.divide(BigDecimal.valueOf(2.54), 3, RoundingMode.HALF_UP))); + // The successful browser request uses display units in/lb for the original + // product fields, while wms* fields remain the submitted cm/kg values. + body.put("sizeUnit", "in"); + body.put("wmsSizeUnit", "cm"); + body.put("wmsWeight", number(weightKg)); + body.put("wmsWeightBs", number(weightKg.multiply(BigDecimal.valueOf(2.2046226218)).setScale(3, RoundingMode.HALF_UP))); + body.put("weightUnit", "lb"); + body.put("wmsWeightUnit", "kg"); + body.put("wmsUnitMark", record.path("wmsUnitMark").asInt(0)); + String json = canonical(body); + HttpRequest request = HttpRequest.newBuilder(URI.create(strip(properties.getBaseUrl()) + "/gateway/wms/newProduct/measure")) + .timeout(properties.getRequestTimeout()).header("Content-Type", "application/json") + .header("Authorization", "Bearer " + auth.token()).header("Cookie", auth.cookies()) + .header("Origin", properties.getLoginOrigin()).header("Referer", strip(properties.getLoginOrigin()) + "/") + .header("lang", "zh").header("version", "prod").header("Track-Key", trackKey("POST", json)) + .POST(HttpRequest.BodyPublishers.ofString(json)).build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode root = mapper.readTree(response.body()); + if (response.statusCode() == 401 || isUnauthorized(root)) { + authManager.invalidate(properties.getProviderKey(), "lingxing_measure_unauthorized"); + throw new IllegalStateException("领星登录态已失效"); + } + if (root.path("code").asInt(-1) != 200) throw new IllegalStateException("领星维护失败"); + } + + private static String number(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); } + + public record PushWarehouseResult(String whCode, boolean success, String message) { + public boolean matched() { return !"未匹配到领星 SKU".equals(message); } + } + public record PushResult(boolean success, String lingxingSkuId, List warehouses) {} + + private LingxingTestDtos.WarehouseResult empty(String whCode, boolean reachable, String message) { + return new LingxingTestDtos.WarehouseResult(whCode, reachable, false, false, null, null, null, null, null, null, message); + } + private static String text(JsonNode node, String key) { return node.path(key).isMissingNode() ? null : node.path(key).asText(null); } + private static java.math.BigDecimal decimal(JsonNode node, String key) { return node.path(key).isNumber() ? node.path(key).decimalValue() : null; } + private static boolean isUnauthorized(JsonNode root) { + int code = root.path("code").asInt(-1); + return code == 401 || code == 40101 || code == 40102 || code == 40301; + } + static String buildTrackKey(String method, String json, String secretBase64) { + if (secretBase64 == null || secretBase64.isBlank()) throw new IllegalStateException("未配置领星 Track-Key v2 密钥"); + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(json.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); for (byte b : digest) hex.append(String.format(Locale.ROOT, "%02x", b)); + byte[] key = Base64.getDecoder().decode(secretBase64); + if (key.length == 0) throw new IllegalStateException("领星 Track-Key v2 密钥解码后不能为空"); + Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key, "HmacSHA256")); + return "v2:" + Base64.getEncoder().encodeToString(mac.doFinal((method.toUpperCase(Locale.ROOT) + hex).getBytes(StandardCharsets.UTF_8))); + } catch (Exception ex) { throw new IllegalStateException("领星 Track-Key 生成失败", ex); } + } + private String trackKey(String method, String json) { return buildTrackKey(method, json, properties.getTrackKeyV2SecretBase64()); } + private String canonical(JsonNode node) throws Exception { return mapper.writeValueAsString(sort(node)); } + private JsonNode sort(JsonNode node) { + if (!node.isObject()) return node; + ObjectNode out = mapper.createObjectNode(); Map ordered = new TreeMap<>(); node.fields().forEachRemaining(e -> ordered.put(e.getKey(), e.getValue())); + ordered.forEach((key, value) -> out.set(key, sort(value))); return out; + } + private static String strip(String value) { return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingProviderConfig.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingProviderConfig.java new file mode 100644 index 000000000..5c5fff384 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingProviderConfig.java @@ -0,0 +1,29 @@ +package com.baiyue.firstleg.lingxing; + +/** 领星账号运行配置;密码和签名密钥不进入此对象日志。 */ +public record LingxingProviderConfig(String providerKey, String browserCdpEndpoint, + boolean browserSessionRequired, long tokenTtlSeconds, + long expirySafetySeconds, String username, String password) { + public LingxingProviderConfig(String providerKey, String browserCdpEndpoint, + boolean browserSessionRequired, long tokenTtlSeconds, + long expirySafetySeconds) { + this(providerKey, browserCdpEndpoint, browserSessionRequired, tokenTtlSeconds, + expirySafetySeconds, null, null); + } + public LingxingProviderConfig { + if (providerKey == null || providerKey.isBlank()) throw new IllegalArgumentException("providerKey不能为空"); + if (tokenTtlSeconds <= 0) tokenTtlSeconds = 172800; + if (expirySafetySeconds < 0) expirySafetySeconds = 300; + } + + @Override + public String toString() { + return "LingxingProviderConfig[providerKey=" + providerKey + + ", browserCdpEndpoint=" + browserCdpEndpoint + + ", browserSessionRequired=" + browserSessionRequired + + ", tokenTtlSeconds=" + tokenTtlSeconds + + ", expirySafetySeconds=" + expirySafetySeconds + + ", usernamePresent=" + (username != null && !username.isBlank()) + + ", passwordPresent=" + (password != null && !password.isBlank()) + "]"; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingSecondaryVerificationException.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingSecondaryVerificationException.java new file mode 100644 index 000000000..1d63e5065 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingSecondaryVerificationException.java @@ -0,0 +1,5 @@ +package com.baiyue.firstleg.lingxing; + +public class LingxingSecondaryVerificationException extends RuntimeException { + public LingxingSecondaryVerificationException(String message) { super(message); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java index c07eb3662..60efeb76b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; @@ -94,6 +95,7 @@ public interface LoadingModelBridgeMapper extends BaseMapper byLoadingPlan(@Param("loadingPlanId") long loadingPlanId); String LINK_SELECT = """ 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 99f9d342a..1ebb4d447 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -265,7 +265,7 @@ public interface LoadingListQueryMapper { AND version = #{expectedVersion} AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id - WHERE task.loading_plan_id=fl_loading_plan.id) + WHERE task.loading_plan_id=fl_loading_plan.id AND task.task_status<>'LOADING') AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task WHERE task.loading_plan_id=fl_loading_plan.id AND task.task_status<>'LOADING') AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task @@ -286,7 +286,7 @@ public interface LoadingListQueryMapper { WHERE p.id = #{planId} AND p.plan_status='EDITING' AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id - WHERE task.loading_plan_id=p.id) + WHERE task.loading_plan_id=p.id AND task.task_status<>'LOADING') AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task WHERE task.loading_plan_id=p.id AND task.task_status<>'LOADING') AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task @@ -332,9 +332,6 @@ public interface LoadingListQueryMapper { @Select(""" SELECT CASE WHEN EXISTS (SELECT 1 FROM fl_loading_pick_task task - JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id - WHERE task.loading_plan_id=#{planId}) - OR EXISTS (SELECT 1 FROM fl_loading_pick_task task WHERE task.loading_plan_id=#{planId} AND task.task_status<>'LOADING') OR EXISTS (SELECT 1 FROM fl_loading_pick_task task JOIN fl_loading_pick_item item ON item.pick_task_id=task.id diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java index e3f64662c..809628c83 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java @@ -108,7 +108,7 @@ public interface LoadingPickTaskMapper extends BaseMapper LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id = task.id WHERE task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED') AND booking.record_status = 'ACTIVE' AND booking.warehouse_id = #{warehouseId} - AND (assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}) + AND assignment.assignee_id = #{operatorId} AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%') OR booking.warehouse_name LIKE CONCAT('%', #{keyword}, '%')) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java index b27dc0ed5..c90eb6763 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java @@ -13,12 +13,47 @@ public interface UnifiedInboundTransportMapper { @Select(""" + """) + List selectChannels(@Param("query") ChannelQuery query); + + @Update(""" + UPDATE fl_sailing_channel SET channel_code=#{value.channelCode},channel_name=#{value.channelName}, + source_type=#{value.sourceType},mapped_channel_code=#{value.mappedChannelCode}, + default_departure_port_code=#{value.defaultDeparturePortCode},default_destination_port_code=#{value.defaultDestinationPortCode}, + default_cutoff_days=#{value.defaultCutoffDays}, + remark=#{value.remark},status=#{value.status},updated_by=#{operatorId},updated_by_name=#{operatorName}, + updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{value.id} AND version=#{expectedVersion} + """) + int updateChannel(@Param("value") SailingChannelEntity value, @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingHistoryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingHistoryMapper.java new file mode 100644 index 000000000..60f074533 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingHistoryMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.firstleg.sailingcalendar.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SailingHistoryMapper extends BaseMapper {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingMapper.java new file mode 100644 index 000000000..766b44c24 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingMapper.java @@ -0,0 +1,111 @@ +package com.baiyue.firstleg.sailingcalendar.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery; +import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface SailingMapper extends BaseMapper { + @Select(""" + + """) + List selectCalendar(@Param("query") SailingQuery query, + @Param("startAt") java.time.LocalDateTime startAt, + @Param("endAt") java.time.LocalDateTime endAt); + + @Select(""" + SELECT * FROM fl_sailing + WHERE warehouse_id=#{warehouseId} AND channel_code_snapshot=#{channelCode} + AND (#{departurePortCode} IS NULL OR departure_port_code=#{departurePortCode}) + AND (#{destinationPortCode} IS NULL OR destination_port_code=#{destinationPortCode}) + AND status='OPEN' AND etd_at >= #{from} AND warehouse_cutoff_at >= #{from} + ORDER BY etd_at ASC,sailing_no ASC,id ASC + """) + List selectFutureCandidates(@Param("warehouseId") long warehouseId, + @Param("channelCode") String channelCode, + @Param("departurePortCode") String departurePortCode, + @Param("destinationPortCode") String destinationPortCode, + @Param("from") java.time.LocalDateTime from); + + @Select(""" + SELECT * FROM fl_sailing + WHERE warehouse_id=#{warehouseId} AND channel_code_snapshot=#{channelCode} + AND (#{departurePortCode} IS NULL OR departure_port_code=#{departurePortCode}) + AND (#{destinationPortCode} IS NULL OR destination_port_code=#{destinationPortCode}) + AND status='OPEN' AND etd_at >= #{from} + ORDER BY etd_at ASC,sailing_no ASC,id ASC + """) + List selectOpenSailingsFrom(@Param("warehouseId") long warehouseId, + @Param("channelCode") String channelCode, + @Param("departurePortCode") String departurePortCode, + @Param("destinationPortCode") String destinationPortCode, + @Param("from") java.time.LocalDateTime from); + + @Select(""" + SELECT warehouse_cutoff_at + FROM fl_sailing + WHERE warehouse_id=#{warehouseId} AND channel_code_snapshot=#{channelCode} + AND departure_port_code=#{departurePortCode} AND destination_port_code=#{destinationPortCode} + AND status <> 'CANCELLED' AND warehouse_cutoff_at < #{cutoffAt} + ORDER BY warehouse_cutoff_at DESC, id DESC + LIMIT 1 + """) + java.time.LocalDateTime selectPreviousCutoff(@Param("warehouseId") long warehouseId, + @Param("channelCode") String channelCode, + @Param("departurePortCode") String departurePortCode, + @Param("destinationPortCode") String destinationPortCode, + @Param("cutoffAt") java.time.LocalDateTime cutoffAt); + + @Select(""" + SELECT warehouse_cutoff_at + FROM fl_sailing + WHERE warehouse_id=#{warehouseId} AND channel_code_snapshot=#{channelCode} + AND departure_port_code=#{departurePortCode} AND destination_port_code=#{destinationPortCode} + AND status <> 'CANCELLED' AND warehouse_cutoff_at > #{cutoffAt} + ORDER BY warehouse_cutoff_at ASC, id ASC + LIMIT 1 + """) + java.time.LocalDateTime selectNextCutoff(@Param("warehouseId") long warehouseId, + @Param("channelCode") String channelCode, + @Param("departurePortCode") String departurePortCode, + @Param("destinationPortCode") String destinationPortCode, + @Param("cutoffAt") java.time.LocalDateTime cutoffAt); + + @Update(""" + UPDATE fl_sailing SET channel_id=#{value.channelId},channel_code_snapshot=#{value.channelCodeSnapshot}, + channel_name_snapshot=#{value.channelNameSnapshot},warehouse_id=#{value.warehouseId},warehouse_name_snapshot=#{value.warehouseNameSnapshot}, + departure_port_code=#{value.departurePortCode},destination_port_code=#{value.destinationPortCode},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo}, + etd_at=#{value.etdAt},eta_at=#{value.etaAt},warehouse_cutoff_at=#{value.warehouseCutoffAt},source_type=#{value.sourceType}, + remark=#{value.remark},planned_capacity_cbm=#{value.plannedCapacityCbm},status=#{value.status},updated_by=#{operatorId},updated_by_name=#{operatorName}, + updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{value.id} AND version=#{expectedVersion} + """) + int updateSailing(@Param("value") SailingEntity value, @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Update("UPDATE fl_sailing SET vessel_name=#{vesselName},voyage_no=#{voyageNo},etd_at=COALESCE(#{etdAt},etd_at),eta_at=#{etaAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND version=#{expectedVersion}") + int updateBookingDetails(@Param("id") long id, @Param("expectedVersion") int expectedVersion, + @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, + @Param("etdAt") java.time.LocalDateTime etdAt, @Param("etaAt") java.time.LocalDateTime etaAt, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Update("UPDATE fl_sailing SET status=#{status},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND version=#{expectedVersion}") + int updateStatus(@Param("id") long id, @Param("status") String status, @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingTemplateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingTemplateMapper.java new file mode 100644 index 000000000..c4cfa6aad --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingTemplateMapper.java @@ -0,0 +1,36 @@ +package com.baiyue.firstleg.sailingcalendar.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.TemplateQuery; +import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity; +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 SailingTemplateMapper extends BaseMapper { + @Select(""" + + """) + Page selectPage(Page page, @Param("query") TemplateQuery query); + + @Update(""" + UPDATE fl_sailing_template SET template_name=#{value.templateName},channel_id=#{value.channelId}, + channel_code_snapshot=#{value.channelCodeSnapshot},channel_name_snapshot=#{value.channelNameSnapshot}, + warehouse_id=#{value.warehouseId},warehouse_name_snapshot=#{value.warehouseNameSnapshot}, + departure_port_code=#{value.departurePortCode},destination_port_code=#{value.destinationPortCode},weekday=#{value.weekday},weekday_mask=#{value.weekdayMask}, + etd_local_time=#{value.etdLocalTime},cutoff_days=#{value.cutoffDays},cutoff_local_time=#{value.cutoffLocalTime}, + valid_from=#{value.validFrom},valid_to=#{value.validTo},voyage_rule=#{value.voyageRule},vessel_name=#{value.vesselName}, + status=#{value.status},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName}, + updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{value.id} AND version=#{expectedVersion} + """) + int updateTemplate(@Param("value") SailingTemplateEntity value, @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/repository/SailingCalendarRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/repository/SailingCalendarRepository.java new file mode 100644 index 000000000..7be9277a6 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/repository/SailingCalendarRepository.java @@ -0,0 +1,144 @@ +package com.baiyue.firstleg.sailingcalendar.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ChannelQuery; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.TemplateQuery; +import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity; +import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity; +import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity; +import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity; +import com.baiyue.firstleg.sailingcalendar.mapper.SailingChannelMapper; +import com.baiyue.firstleg.sailingcalendar.mapper.SailingHistoryMapper; +import com.baiyue.firstleg.sailingcalendar.mapper.SailingMapper; +import com.baiyue.firstleg.sailingcalendar.mapper.SailingTemplateMapper; +import com.baiyue.firstleg.sailingcalendar.mapper.SailingCalendarDisplayMapper; +import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity; +import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity; +import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity; +import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity; +import com.baiyue.firstleg.sailingcalendar.fact.repository.SailingFactsRepository; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; +import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class SailingCalendarRepository { + @Resource private SailingChannelMapper channels; + @Resource private SailingTemplateMapper templates; + @Resource private SailingHistoryMapper histories; + @Resource private SailingMapper sailings; + @Resource private SailingFactsRepository facts; + @Resource private OceanBookingRepository oceanBookings; + @Resource private SailingCalendarDisplayMapper display; + + public List channels(ChannelQuery query) { return channels.selectChannels(query); } + public SailingChannelEntity channel(long id) { return channels.selectById(id); } + public SailingChannelEntity channelByCode(String code) { + return channels.selectOne(new LambdaQueryWrapper() + .eq(SailingChannelEntity::getChannelCode, code)); + } + public void insert(SailingChannelEntity value) { channels.insert(value); } + public int update(SailingChannelEntity value, int version, long operatorId, String operatorName) { + return channels.updateChannel(value, version, operatorId, operatorName); + } + + public PageResult templates(TemplateQuery query) { + Page page = templates.selectPage(new Page<>(query.page(), query.size()), query); + return PageResult.from(page); + } + public SailingTemplateEntity template(long id) { return templates.selectById(id); } + public void insert(SailingTemplateEntity value) { templates.insert(value); } + public int update(SailingTemplateEntity value, int version, long operatorId, String operatorName) { + return templates.updateTemplate(value, version, operatorId, operatorName); + } + + public List calendar(SailingQuery query, LocalDateTime start, LocalDateTime end) { + return sailings.selectCalendar(query, start, end); + } + public List futureCandidates(long warehouseId, String channelCode, String departurePortCode, String destinationPortCode, LocalDateTime from) { + return sailings.selectFutureCandidates(warehouseId, channelCode, departurePortCode, destinationPortCode, from); + } + public List openSailingsFrom(long warehouseId, String channelCode, String departurePortCode, String destinationPortCode, LocalDateTime from) { + return sailings.selectOpenSailingsFrom(warehouseId, channelCode, departurePortCode, destinationPortCode, from); + } + public LocalDateTime previousCutoff(SailingEntity sailing) { + return sailings.selectPreviousCutoff(sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(), + sailing.getDeparturePortCode(), sailing.getDestinationPortCode(), sailing.getWarehouseCutoffAt()); + } + public LocalDateTime nextCutoff(SailingEntity sailing) { + return sailings.selectNextCutoff(sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(), + sailing.getDeparturePortCode(), sailing.getDestinationPortCode(), sailing.getWarehouseCutoffAt()); + } + public SailingEntity sailing(long id) { return sailings.selectById(id); } + public SailingEntity sailingByBusinessKey(long warehouseId, String channelCode, String departurePortCode, + String destinationPortCode, LocalDateTime etdAt, String voyageNo) { + return sailings.selectOne(new LambdaQueryWrapper() + .eq(SailingEntity::getWarehouseId, warehouseId) + .eq(SailingEntity::getChannelCodeSnapshot, channelCode) + .eq(SailingEntity::getDeparturePortCode, departurePortCode) + .eq(SailingEntity::getDestinationPortCode, destinationPortCode) + .eq(SailingEntity::getEtdAt, etdAt) + .eq(SailingEntity::getVoyageNo, voyageNo)); + } + public SailingEntity sailingByGeneratedKey(String key) { + return sailings.selectOne(new LambdaQueryWrapper().eq(SailingEntity::getGeneratedKey, key)); + } + public void insert(SailingEntity value) { sailings.insert(value); } + public int update(SailingEntity value, int version, long operatorId, String operatorName) { + return sailings.updateSailing(value, version, operatorId, operatorName); + } + public int updateBookingDetails(long id, int version, String vesselName, String voyageNo, LocalDateTime etdAt, LocalDateTime etaAt, long operatorId, String operatorName) { + return sailings.updateBookingDetails(id, version, vesselName, voyageNo, etdAt, etaAt, operatorId, operatorName); + } + public int updateStatus(long id, String status, int version, long operatorId, String operatorName) { + return sailings.updateStatus(id, status, version, operatorId, operatorName); + } + public void insertHistory(SailingHistoryEntity value) { histories.insert(value); } + public List histories(long sailingId) { + return histories.selectList(new LambdaQueryWrapper() + .eq(SailingHistoryEntity::getSailingId, sailingId) + .orderByDesc(SailingHistoryEntity::getCreatedAt) + .orderByDesc(SailingHistoryEntity::getId)); + } + public List assignments(long sailingId) { return facts.assignments(sailingId); } + public void insertAssignment(SailingAssignmentEntity value) { facts.insertAssignment(value); } + public List exceptionAssignments() { return facts.exceptionAssignments(); } + public SailingAssignmentEntity assignmentForUpdate(long id) { return facts.assignmentForUpdate(id); } + public int supersedeAssignment(long id, int expectedVersion, long operatorId, String reason, LocalDateTime at) { + return facts.supersedeAssignment(id, expectedVersion, operatorId, reason, at); + } + public SailingReceiptBatchFactEntity receiptFact(long id) { return facts.receiptFact(id); } + public SailingInboundVolumeForecastEntity forecast(long id) { return facts.forecast(id); } + public void insertForecast(SailingInboundVolumeForecastEntity value) { facts.insertForecast(value); } + public int consumeForecast(long tenantId, long id, LocalDateTime at) { return facts.consumeForecast(tenantId, id, at); } + public int supersedeForecast(long tenantId, long id, LocalDateTime at) { return facts.supersedeForecast(tenantId, id, at); } + public int voidForecast(long tenantId, long id, LocalDateTime at) { return facts.voidForecast(tenantId, id, at); } + public int supersedeForecastAssignments(long tenantId, long id, LocalDateTime at) { return facts.supersedeForecastAssignments(tenantId, id, at); } + public SailingAssignmentEntity currentAssignmentForForecast(long tenantId, long id) { return facts.currentAssignmentForForecast(tenantId, id); } + public SailingAssignmentEntity currentAssignmentForReceipt(long tenantId, long id) { return facts.currentAssignmentForReceipt(tenantId, id); } + public List bookingLinks(long sailingId) { return facts.activeBookingLinks(sailingId); } + public SailingBookingLinkEntity activeBookingLink(long oceanBookingId) { return facts.activeBookingLink(oceanBookingId); } + public void insertBookingLink(SailingBookingLinkEntity value) { facts.insertBookingLink(value); } + public int revokeBookingLink(long id, long operatorId, String reason, LocalDateTime at) { + return facts.revokeBookingLink(id, operatorId, reason, at); + } + public OceanBookingEntity booking(long id) { return oceanBookings.find(id); } + public List requirements(long id) { return oceanBookings.requirements(id); } + public SailingCalendarDisplayMapper.ReceiptSummary receiptSummary(SailingEntity sailing) { + return display.receiptSummary(sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(), + sailing.getWarehouseCutoffAt(), previousCutoff(sailing), nextCutoff(sailing)); + } + public SailingCalendarDisplayMapper.BookingSummary bookingSummary(SailingEntity sailing) { + return display.bookingSummary(sailing.getId(), sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(), + sailing.getDeparturePortCode(), sailing.getDestinationPortCode(), sailing.getVesselName(), + sailing.getVoyageNo(), sailing.getEtdAt()); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java new file mode 100644 index 000000000..5b3450f46 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java @@ -0,0 +1,508 @@ +package com.baiyue.firstleg.sailingcalendar.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos; +import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity; +import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity; +import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity; +import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity; +import com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Objects; +import java.util.Map; +import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity; +import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity; +import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity; +import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity; +import com.baiyue.firstleg.sailingcalendar.mapper.SailingCalendarDisplayMapper; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; +import com.baiyue.common.core.container.ContainerSpecificationCatalog; + +import static com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*; + +@Service +public class SailingCalendarService { + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + private static final String OPEN = "OPEN"; + @Resource private SailingCalendarRepository repository; + + public List channels(ChannelQuery query) { + return repository.channels(query).stream().map(v -> new ChannelView(v, "ACTIVE".equals(v.getStatus()))).toList(); + } + @Transactional + public SailingChannelEntity createChannel(long operatorId, String operatorName, ChannelCreateRequest request) { + if (version(request.expectedVersion()) != 0) throw bad("新建渠道expectedVersion必须为0"); + validateChannel(request.data()); + if (repository.channelByCode(request.data().channelCode().trim().toUpperCase()) != null) throw conflict("渠道代码已存在"); + SailingChannelEntity value = new SailingChannelEntity(); value.setId(IdWorker.getId()); + copy(value, request.data()); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); + value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value); + try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("渠道代码已存在"); } + return value; + } + @Transactional + public SailingChannelEntity updateChannel(long id, long operatorId, String operatorName, ChannelUpdateRequest request) { + SailingChannelEntity before = requiredChannel(id); validateChannel(request.data()); + if (!before.getChannelCode().equalsIgnoreCase(request.data().channelCode().trim()) + && repository.channelByCode(request.data().channelCode().trim().toUpperCase()) != null) throw conflict("渠道代码已存在"); + SailingChannelEntity value = new SailingChannelEntity(); value.setId(id); copy(value, request.data()); + value.setStatus(normalizeStatus(request.status(), "渠道状态")); + if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("渠道已被其他人修改"); + return requiredChannel(id); + } + + public PageResult templates(TemplateQuery query) { return repository.templates(query); } + public List templateViews(TemplateQuery query) { return repository.templates(query).records().stream().map(v -> templateViewOf(v)).toList(); } + public TemplateView templateView(long id) { return templateViewOf(requiredTemplate(id)); } + @Transactional + public SailingTemplateEntity createTemplate(long operatorId, String operatorName, TemplateCreateRequest request) { + if (version(request.expectedVersion()) != 0) throw bad("新建模板expectedVersion必须为0"); + SailingChannelEntity channel = requiredChannel(request.data().channelId()); validateTemplate(request.data(), channel); + if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建模板"); + SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo("ST-" + value.getId()); + copy(value, request.data(), channel); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); + value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value); + repository.insert(value); return value; + } + @Transactional + public SailingTemplateEntity updateTemplate(long id, long operatorId, String operatorName, TemplateUpdateRequest request) { + requiredTemplate(id); SailingChannelEntity channel = requiredChannel(request.data().channelId()); validateTemplate(request.data(), channel); + SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(id); copy(value, request.data(), channel); + value.setStatus(StringUtils.hasText(request.status()) ? normalizeStatus(request.status(), "模板状态") : requiredTemplate(id).getStatus()); + if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("模板已被其他人修改"); + return requiredTemplate(id); + } + + public CalendarResult calendar(SailingQuery query) { + LocalDate first; + try { first = LocalDate.parse(query.month() + "-01"); } catch (RuntimeException ex) { throw bad("month必须为yyyy-MM"); } + LocalDate next = first.plusMonths(1); + List values = repository.calendar(query, first.atStartOfDay(), next.atStartOfDay()); + List items = values.stream().map(this::item).toList(); + BigDecimal capacity = items.stream().map(v -> v.volumeSummary().planningCapacityCbm() == null ? BigDecimal.ZERO : v.volumeSummary().planningCapacityCbm()).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal actual = items.stream().map(v -> v.volumeSummary().actualReceivedCbm()).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal forecast = items.stream().map(v -> v.volumeSummary().catchableForecastCbm()).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal risk = items.stream().map(v -> v.volumeSummary().riskUnmatchedCbm()).reduce(BigDecimal.ZERO, BigDecimal::add); + int booked = items.stream().mapToInt(v -> v.volumeSummary().bookedContainerCount()).sum(); + int recommended = items.stream().mapToInt(v -> v.volumeSummary().recommendedContainerCount()).sum(); + int pending = items.stream().mapToInt(v -> v.volumeSummary().pendingContainerCount()).sum(); + int late = items.stream().mapToInt(v -> v.volumeSummary().lateBatchCount()).sum(); + BigDecimal catchableReceived = items.stream().map(v -> nvl(v.volumeSummary().catchableReceivedCbm())).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal lateReceived = items.stream().map(v -> nvl(v.volumeSummary().lateReceivedCbm())).reduce(BigDecimal.ZERO, BigDecimal::add); + var summary = new CalendarResult.Summary(actual, forecast, risk, booked, recommended, pending, late, items.stream().anyMatch(v -> v.volumeSummary().overCapacity()), capacity, items.size(), catchableReceived, lateReceived); + return new CalendarResult(query.month(), ZONE.getId(), items, summary, + new CalendarResult.FilterOptions( + values.stream().map(v -> new CalendarResult.Option(String.valueOf(v.getWarehouseId()), v.getWarehouseNameSnapshot())).distinct().toList(), + values.stream().map(v -> new CalendarResult.Option(v.getChannelCodeSnapshot(), v.getChannelNameSnapshot())).distinct().toList(), + values.stream().map(v -> new CalendarResult.Option(v.getDeparturePortCode(), v.getDeparturePortCode())).distinct().toList(), + values.stream().map(v -> new CalendarResult.Option(v.getDestinationPortCode(), v.getDestinationPortCode())).distinct().toList())); + } + public SailingDetail detail(long id) { + SailingEntity value = requiredSailing(id); + return new SailingDetail(item(value), history(id)); + } + public List history(long id) { + requiredSailing(id); + return repository.histories(id).stream() + .map(v -> new SailingHistoryView(v.getId(), v.getAction(), v.getFromStatus(), v.getToStatus(), v.getReason(), v.getOperatorName(), v.getCreatedAt())) + .toList(); + } + public ExceptionPage exceptions(ExceptionQuery query) { + List all = repository.exceptionAssignments().stream().map(a -> { + Long sourceId = a.getSailingId() != null ? a.getSailingId() : a.getOriginSailingId(); + SailingEntity source = sourceId == null ? null : repository.sailing(sourceId); + LocalDateTime receivedAt = null, expectedAt = null; + if (a.getReceiptBatchId() != null && repository.receiptFact(a.getReceiptBatchId()) != null) receivedAt = repository.receiptFact(a.getReceiptBatchId()).getReceivedAt(); + if (a.getForecastId() != null && repository.forecast(a.getForecastId()) != null) expectedAt = repository.forecast(a.getForecastId()).getExpectedArrivalAt(); + LocalDateTime eventAt = receivedAt != null ? receivedAt : expectedAt; + Integer waiting = eventAt == null ? null : (int) Math.max(0, ChronoUnit.DAYS.between(eventAt.toLocalDate(), LocalDateTime.now(ZONE).toLocalDate())); + return new ExceptionRecord(a.getId(), a.getInboundCode(), source == null ? null : source.getWarehouseId(), source == null ? null : source.getWarehouseNameSnapshot(), source == null ? null : source.getChannelCodeSnapshot(), source == null ? null : source.getChannelNameSnapshot(), source == null ? null : source.getSailingNo(), source == null ? null : source.getVoyageNo(), a.getMatchStatus(), nvl(a.getVolumeCbm()).add(nvl(a.getForecastVolumeCbm())), receivedAt, expectedAt, waiting, a.getReason(), a.getAssignmentVersion()); + }).filter(r -> query.status() == null || query.status().equals(r.status())) + .filter(r -> query.warehouseId() == null || Objects.equals(query.warehouseId(), r.warehouseId())) + .filter(r -> query.channelCode() == null || Objects.equals(query.channelCode(), r.channelCode())).toList(); + long page = query.page(), size = query.size(), total = all.size(), from = Math.min(total, (page - 1) * size), to = Math.min(total, from + size); + return new ExceptionPage(all.subList((int) from, (int) to), page, size, total, (total + size - 1) / size); + } + @Transactional + public SailingDetail cancel(long id, long operatorId, String operatorName, CancelRequest request) { + SailingEntity before = requiredSailing(id); + if (!Objects.equals(before.getVersion(), version(request.expectedVersion()))) throw conflict("船期已被其他人修改"); + if (List.of("DEPARTED", "COMPLETED", "CANCELLED").contains(before.getStatus())) throw conflict("当前船期不可取消"); + if (repository.updateStatus(id, "CANCELLED", version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("船期已被其他人修改"); + LocalDateTime at = LocalDateTime.now(ZONE); String reason = StringUtils.hasText(request.reason()) ? request.reason().trim() : "CANCELLED"; + for (SailingAssignmentEntity current : repository.assignments(id)) { + int assignmentVersion = current.getAssignmentVersion() == null ? 1 : current.getAssignmentVersion(); + if (repository.supersedeAssignment(current.getId(), assignmentVersion, operatorId, reason, at) != 1) { + throw conflict("取消船期时归班事实已被其他人修改,操作已回滚,请刷新后重试"); + } + SailingAssignmentEntity unmatched = copyAssignment(current); unmatched.setId(IdWorker.getId()); unmatched.setSailingId(null); unmatched.setOriginSailingId(id); + unmatched.setAssignmentSource("ROLLOVER"); unmatched.setAssignmentVersion(assignmentVersion + 1); unmatched.setMatchStatus("UNMATCHED"); unmatched.setMatchedAt(at); unmatched.setReason(reason); unmatched.setOperatorId(operatorId); unmatched.setCreatedAt(at); unmatched.setLockedAt(null); unmatched.setLockReason(null); repository.insertAssignment(unmatched); + } + for (SailingBookingLinkEntity link : repository.bookingLinks(id)) repository.revokeBookingLink(link.getId(), operatorId, reason, at); + recordHistory(id, "CANCEL", before.getStatus(), "CANCELLED", reason, operatorId, operatorName, at); + return detail(id); + } + + @Transactional + public ExceptionReassignResult exceptionReassign(long sourceSailingId, long operatorId, String operatorName, ExceptionReassignRequest request) { + SailingEntity source = requiredSailing(sourceSailingId); SailingEntity target = requiredSailing(request.targetSailingId()); + if (source.getId().equals(target.getId())) throw bad("目标船期不能与原船期相同"); + if (!"OPEN".equals(target.getStatus()) || target.getWarehouseCutoffAt() == null || !target.getWarehouseCutoffAt().isAfter(LocalDateTime.now(ZONE))) throw conflict("目标船期必须为未截止的OPEN状态"); + if (!Objects.equals(source.getWarehouseId(), target.getWarehouseId()) || !Objects.equals(source.getChannelCodeSnapshot(), target.getChannelCodeSnapshot()) + || !Objects.equals(source.getDeparturePortCode(), target.getDeparturePortCode()) || !Objects.equals(source.getDestinationPortCode(), target.getDestinationPortCode())) throw conflict("目标船期的仓库、渠道或港口不一致"); + if (!Objects.equals(target.getVersion(), version(request.expectedTargetVersion()))) throw conflict("目标船期版本已变化,请刷新后重试"); + SailingAssignmentEntity current = repository.assignmentForUpdate(request.assignmentId()); + if (current == null) throw notFound("归班事实不存在"); + if (!Objects.equals(current.getAssignmentVersion(), version(request.expectedAssignmentVersion()))) throw conflict("归班事实版本已变化,请刷新后重试"); + if (!List.of("LATE", "UNMATCHED").contains(current.getMatchStatus())) throw conflict("只有晚到或未安排事实允许异常重新安排"); + if (current.getSailingId() != null && !Objects.equals(current.getSailingId(), sourceSailingId) + || current.getSailingId() == null && !Objects.equals(current.getOriginSailingId(), sourceSailingId)) throw conflict("归班事实不属于原船期"); + String newStatus; LocalDateTime eventAt = null; + if (current.getReceiptBatchId() != null) { + var fact = repository.receiptFact(current.getReceiptBatchId()); if (fact == null || !"APPROVED".equals(fact.getFactStatus())) throw conflict("实收事实不可重新安排"); + eventAt = fact.getReceivedAt(); if (eventAt == null || eventAt.isAfter(target.getWarehouseCutoffAt())) throw conflict("实收时间晚于目标船期截止时间"); newStatus = "RECEIVED"; + } else if (current.getForecastId() != null) { + var fact = repository.forecast(current.getForecastId()); if (fact == null || !"VALID".equals(fact.getFactStatus())) throw conflict("预报事实已失效"); + eventAt = fact.getExpectedArrivalAt(); if (eventAt == null || eventAt.isAfter(target.getWarehouseCutoffAt())) throw conflict("预计到仓时间晚于目标船期截止时间"); newStatus = "FORECAST_MATCHED"; + } else throw conflict("缺少可核验的实收或预报事实"); + LocalDateTime at = LocalDateTime.now(ZONE); if (repository.supersedeAssignment(current.getId(), current.getAssignmentVersion(), operatorId, request.reason().trim(), at) != 1) throw conflict("归班事实已被其他人修改"); + SailingAssignmentEntity next = copyAssignment(current); next.setId(IdWorker.getId()); next.setSailingId(target.getId()); next.setOriginSailingId(sourceSailingId); next.setAssignmentSource("MANUAL"); next.setAssignmentVersion(current.getAssignmentVersion() + 1); next.setMatchStatus(newStatus); next.setMatchedAt(at); next.setLockedAt(null); next.setLockReason(null); next.setOperatorId(operatorId); next.setReason(request.reason().trim()); next.setCreatedAt(at); repository.insertAssignment(next); + return new ExceptionReassignResult(current.getId(), next.getId(), sourceSailingId, target.getId(), next.getAssignmentVersion(), newStatus, next.getVolumeCbm(), next.getForecastVolumeCbm(), request.reason().trim()); + } + + /** 运营维护未收货量预报,并立即生成一条可追溯的自动归班事实。 */ + @Transactional + public SailingInboundVolumeForecastEntity createForecast(long operatorId, ForecastCreateRequest request) { + if (request.expectedArrivalAt() == null || request.expectedArrivalAt().isBefore(LocalDateTime.now(ZONE))) { + throw bad("预计到仓时间不能早于当前时间"); + } + if (request.forecastVolumeCbm() == null || request.forecastVolumeCbm().signum() <= 0) { + throw bad("预计体积必须大于0"); + } + String channelCode = trimUpper(request.channelCode()); + SailingChannelEntity channel = StringUtils.hasText(channelCode) ? repository.channelByCode(channelCode) : null; + if (channel == null || !"ACTIVE".equals(channel.getStatus())) throw bad("预报必须填写已启用的渠道编码"); + String departurePort = trimUpper(request.departurePortCode()); + String destinationPort = trimUpper(request.destinationPortCode()); + if (!StringUtils.hasText(departurePort)) departurePort = trimUpper(channel.getDefaultDeparturePortCode()); + if (!StringUtils.hasText(destinationPort)) destinationPort = trimUpper(channel.getDefaultDestinationPortCode()); + SailingInboundVolumeForecastEntity value = new SailingInboundVolumeForecastEntity(); + value.setId(IdWorker.getId()); value.setTenantId(request.tenantId()); value.setForecastKey(request.forecastKey().trim()); + value.setFactVersion(1); value.setInboundId(request.inboundId()); value.setReceivingWarehouseId(request.receivingWarehouseId()); + value.setChannelCode(channelCode); value.setChannelName(trim(request.channelName())); + value.setDeparturePortCode(departurePort); value.setDestinationPortCode(destinationPort); + value.setExpectedArrivalAt(request.expectedArrivalAt()); value.setForecastVolumeCbm(request.forecastVolumeCbm()); + value.setSourceType(request.sourceType().trim().toUpperCase()); value.setFactStatus("VALID"); + value.setEffectiveAt(LocalDateTime.now(ZONE)); value.setCreatedBy(operatorId); value.setCreatedAt(LocalDateTime.now(ZONE)); value.setUpdatedAt(value.getCreatedAt()); + repository.insertForecast(value); + insertForecastAssignment(value, operatorId); + return value; + } + + @Transactional + public SailingInboundVolumeForecastEntity reviseForecast(long operatorId, long forecastId, ForecastRevisionRequest request) { + SailingInboundVolumeForecastEntity current = repository.forecast(forecastId); + if (current == null || !Objects.equals(current.getTenantId(), request.tenantId())) throw notFound("货量预报不存在"); + if (!"VALID".equals(current.getFactStatus())) throw conflict("只有有效预报可以修订"); + if (!Objects.equals(current.getFactVersion(), request.expectedFactVersion())) throw conflict("预报版本已变化,请刷新后重试"); + if (request.expectedArrivalAt().isBefore(LocalDateTime.now(ZONE))) throw bad("预计到仓时间不能早于当前时间"); + if (request.forecastVolumeCbm().signum() <= 0) throw bad("预计体积必须大于0"); + String channelCode = trimUpper(request.channelCode()); + if (!StringUtils.hasText(channelCode)) channelCode = current.getChannelCode(); + SailingChannelEntity channel = repository.channelByCode(channelCode); + if (channel == null || !"ACTIVE".equals(channel.getStatus())) throw bad("预报必须填写已启用的渠道编码"); + String departurePort = trimUpper(request.departurePortCode()); + String destinationPort = trimUpper(request.destinationPortCode()); + if (!StringUtils.hasText(departurePort)) departurePort = trimUpper(channel.getDefaultDeparturePortCode()); + if (!StringUtils.hasText(destinationPort)) destinationPort = trimUpper(channel.getDefaultDestinationPortCode()); + LocalDateTime now = LocalDateTime.now(ZONE); + if (repository.supersedeForecast(request.tenantId(), forecastId, now) != 1) throw conflict("预报已被其他人修改,请刷新后重试"); + repository.supersedeForecastAssignments(request.tenantId(), forecastId, now); + SailingInboundVolumeForecastEntity next = new SailingInboundVolumeForecastEntity(); + next.setId(IdWorker.getId()); next.setTenantId(current.getTenantId()); next.setForecastKey(current.getForecastKey()); + next.setFactVersion(current.getFactVersion() + 1); next.setInboundId(current.getInboundId()); next.setReceivingWarehouseId(current.getReceivingWarehouseId()); + next.setChannelCode(channelCode); next.setChannelName(trim(request.channelName())); next.setDeparturePortCode(departurePort); next.setDestinationPortCode(destinationPort); + next.setExpectedArrivalAt(request.expectedArrivalAt()); next.setForecastVolumeCbm(request.forecastVolumeCbm()); next.setSourceType(request.sourceType().trim().toUpperCase()); + next.setFactStatus("VALID"); next.setEffectiveAt(now); next.setCreatedBy(operatorId); next.setCreatedAt(now); next.setUpdatedAt(now); + repository.insertForecast(next); insertForecastAssignment(next, operatorId); + return next; + } + + @Transactional + public void voidForecast(long operatorId, long forecastId, ForecastVoidRequest request) { + SailingInboundVolumeForecastEntity current = repository.forecast(forecastId); + if (current == null || !Objects.equals(current.getTenantId(), request.tenantId())) throw notFound("货量预报不存在"); + LocalDateTime now = LocalDateTime.now(ZONE); + if (repository.voidForecast(request.tenantId(), forecastId, now) != 1) throw conflict("预报已被其他人修改,请刷新后重试"); + repository.supersedeForecastAssignments(request.tenantId(), forecastId, now); + } + + @Transactional + public void bindReceiptToForecast(long operatorId, long forecastId, ForecastReceiptBindRequest request) { + SailingInboundVolumeForecastEntity forecast = repository.forecast(forecastId); + SailingReceiptBatchFactEntity receipt = repository.receiptFact(request.receiptBatchId()); + if (forecast == null || receipt == null || !Objects.equals(forecast.getTenantId(), request.tenantId()) + || !Objects.equals(receipt.getTenantId(), request.tenantId())) throw notFound("预报或实收批次不存在"); + if (!"VALID".equals(forecast.getFactStatus()) || !"APPROVED".equals(receipt.getFactStatus())) throw conflict("预报或实收批次已失效"); + if (!Objects.equals(forecast.getInboundId(), receipt.getInboundId())) throw conflict("预报与实收批次不属于同一入仓单"); + SailingAssignmentEntity currentReceipt = repository.currentAssignmentForReceipt(request.tenantId(), request.receiptBatchId()); + if (currentReceipt == null) throw conflict("实收批次当前没有可绑定的归班版本"); + if (!Objects.equals(currentReceipt.getAssignmentVersion(), request.expectedAssignmentVersion())) throw conflict("实收归班版本已变化,请刷新后重试"); + SailingAssignmentEntity previousForecast = repository.currentAssignmentForForecast(request.tenantId(), forecastId); + LocalDateTime now = LocalDateTime.now(ZONE); + if (repository.supersedeAssignment(currentReceipt.getId(), currentReceipt.getAssignmentVersion(), operatorId, request.reason().trim(), now) != 1) throw conflict("实收归班已被其他人修改,请刷新后重试"); + repository.supersedeForecastAssignments(request.tenantId(), forecastId, now); + if (repository.consumeForecast(request.tenantId(), forecastId, now) != 1) throw conflict("预报已被其他人消费,请刷新后重试"); + SailingEntity sailing = previousForecast == null || previousForecast.getSailingId() == null ? null : repository.sailing(previousForecast.getSailingId()); + if (sailing != null && "CANCELLED".equals(sailing.getStatus())) sailing = null; + if (sailing == null && StringUtils.hasText(forecast.getDeparturePortCode()) && StringUtils.hasText(forecast.getDestinationPortCode())) { + List candidates = repository.openSailingsFrom(forecast.getReceivingWarehouseId(), forecast.getChannelCode(), forecast.getDeparturePortCode(), forecast.getDestinationPortCode(), receipt.getReceivedAt()); + sailing = candidates.isEmpty() ? null : candidates.get(0); + } + boolean late = sailing != null && receipt.getReceivedAt().isAfter(sailing.getWarehouseCutoffAt()); + SailingAssignmentEntity next = new SailingAssignmentEntity(); next.setId(IdWorker.getId()); next.setTenantId(request.tenantId()); next.setSailingId(sailing == null ? null : sailing.getId()); + next.setInboundId(receipt.getInboundId()); next.setReceiptBatchId(receipt.getId()); next.setForecastId(forecast.getId()); next.setAssignmentSource("MANUAL"); next.setAssignmentVersion(currentReceipt.getAssignmentVersion() + 1); + next.setMatchStatus(sailing == null ? "UNMATCHED" : late ? "LATE" : "RECEIVED"); next.setVolumeCbm(receipt.getActualVolumeCbm()); next.setRouteSource("FORECAST_FACT"); next.setVolumeSource("MEASURED_ACTUAL"); next.setRuleVersion("V1_CUTOFF_3D_18"); + next.setMatchedAt(now); next.setLockedAt(late ? now : null); next.setLockReason(late ? "CUTOFF" : null); next.setReason(request.reason().trim()); next.setOperatorId(operatorId); next.setCreatedAt(now); repository.insertAssignment(next); + BigDecimal remaining = forecast.getForecastVolumeCbm().subtract(receipt.getActualVolumeCbm()); + if (remaining.signum() > 0) insertResidualForecast(forecast, remaining, operatorId, now); + } + + private void insertForecastAssignment(SailingInboundVolumeForecastEntity value, long operatorId) { + LocalDateTime now = LocalDateTime.now(ZONE); + List candidates = StringUtils.hasText(value.getDeparturePortCode()) && StringUtils.hasText(value.getDestinationPortCode()) + ? repository.futureCandidates(value.getReceivingWarehouseId(), value.getChannelCode(), value.getDeparturePortCode(), value.getDestinationPortCode(), value.getExpectedArrivalAt()) : List.of(); + SailingAssignmentEntity assignment = new SailingAssignmentEntity(); assignment.setId(IdWorker.getId()); assignment.setTenantId(value.getTenantId()); assignment.setSailingId(candidates.isEmpty() ? null : candidates.get(0).getId()); + assignment.setInboundId(value.getInboundId()); assignment.setForecastId(value.getId()); assignment.setAssignmentSource("AUTO"); assignment.setAssignmentVersion(1); assignment.setMatchStatus(candidates.isEmpty() ? "UNMATCHED" : "FORECAST_MATCHED"); assignment.setForecastVolumeCbm(value.getForecastVolumeCbm()); + assignment.setRouteSource("FORECAST_FACT"); assignment.setVolumeSource(value.getSourceType()); assignment.setRuleVersion("V1_CUTOFF_3D_18"); assignment.setMatchedAt(now); assignment.setReason(candidates.isEmpty() ? "没有找到截止前的OPEN船期" : null); assignment.setOperatorId(operatorId); assignment.setCreatedAt(now); repository.insertAssignment(assignment); + } + + private void insertResidualForecast(SailingInboundVolumeForecastEntity forecast, BigDecimal remaining, long operatorId, LocalDateTime now) { + SailingInboundVolumeForecastEntity residual = new SailingInboundVolumeForecastEntity(); residual.setId(IdWorker.getId()); residual.setTenantId(forecast.getTenantId()); residual.setForecastKey(forecast.getForecastKey()); residual.setFactVersion(forecast.getFactVersion() + 1); residual.setInboundId(forecast.getInboundId()); + residual.setReceivingWarehouseId(forecast.getReceivingWarehouseId()); residual.setChannelCode(forecast.getChannelCode()); residual.setChannelName(forecast.getChannelName()); residual.setDeparturePortCode(forecast.getDeparturePortCode()); residual.setDestinationPortCode(forecast.getDestinationPortCode()); residual.setExpectedArrivalAt(forecast.getExpectedArrivalAt()); residual.setForecastVolumeCbm(remaining); residual.setSourceType(forecast.getSourceType()); residual.setFactStatus("VALID"); residual.setEffectiveAt(now); residual.setCreatedBy(operatorId); residual.setCreatedAt(now); residual.setUpdatedAt(now); repository.insertForecast(residual); insertForecastAssignment(residual, operatorId); + } + @Transactional + public SailingDetail copy(long id, long operatorId, String operatorName, CopyRequest request) { + SailingEntity source = requiredSailing(id); + if (request.etdAt().isBefore(LocalDateTime.now(ZONE))) throw bad("复制船期不能早于当前时间"); + if (!request.cutoffAt().isBefore(request.etdAt())) throw bad("收货截止必须早于开船时间"); + String copyReason = "复制自" + source.getSailingNo(); + SailingEntity existing = repository.sailingByBusinessKey(source.getWarehouseId(), source.getChannelCodeSnapshot(), + source.getDeparturePortCode(), source.getDestinationPortCode(), request.etdAt(), source.getVoyageNo()); + if (existing != null) return isCopyOf(existing, copyReason) ? detail(existing.getId()) + : throwCopyConflict(request.etdAt(), source.getVoyageNo()); + + SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(copyEta(source, request.etdAt())); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); + try { repository.insert(value); } catch (DuplicateKeyException ex) { + SailingEntity duplicate = repository.sailingByBusinessKey(source.getWarehouseId(), source.getChannelCodeSnapshot(), + source.getDeparturePortCode(), source.getDestinationPortCode(), request.etdAt(), source.getVoyageNo()); + if (duplicate != null && isCopyOf(duplicate, copyReason)) return detail(duplicate.getId()); + throwCopyConflict(request.etdAt(), source.getVoyageNo()); + } + recordHistory(value.getId(), "COPY", null, OPEN, copyReason, operatorId, operatorName, value.getCreatedAt()); return detail(value.getId()); + } + public VolumeDetail volumeSummary(long id) { + SailingEntity value = requiredSailing(id); + List assignments = repository.assignments(id).stream().map(a -> { + LocalDateTime receivedAt = a.getReceiptBatchId() == null || repository.receiptFact(a.getReceiptBatchId()) == null + ? null : repository.receiptFact(a.getReceiptBatchId()).getReceivedAt(); + return new AssignmentView(String.valueOf(a.getId()), a.getReceiptBatchId() == null ? null : String.valueOf(a.getReceiptBatchId()), a.getForecastId() == null ? null : String.valueOf(a.getForecastId()), a.getInboundCode(), receivedAt, nvl(a.getVolumeCbm()).add(nvl(a.getForecastVolumeCbm())), a.getMatchStatus(), a.getAssignmentSource(), a.getReason(), a.getAssignmentVersion()); + }).toList(); + return new VolumeDetail(summary(value), assignments); + } + public BookingAdvice bookingAdvice(long id) { + SailingEntity value = requiredSailing(id); VolumeSummary s = summary(value); + BigDecimal catchable = catchableVolume(value.getId()); + int recommended = recommendedContainers(catchable); + BigDecimal bookedCapacity = bookedPlanningCapacity(value.getId()); + int pending = ceil(nvl(catchable.subtract(bookedCapacity)), defaultContainerCapacity()); + List scenarios = ContainerSpecificationCatalog.values().stream().map(spec -> { + int count = ceil(catchable, spec.capacityCbm()); + int pendingForType = ceil(nvl(catchable.subtract(bookedCapacity)), spec.capacityCbm()); + return new BookingScenario(spec.containerType(), spec.containerType(), spec.capacityCbm(), count, pendingForType); + }).toList(); + return new BookingAdvice(s.actualReceivedCbm(), catchable, s.planningCapacityCbm(), s.bookedContainerCount(), recommended, pending, scenarios, LocalDateTime.now(ZONE), s.catchableReceivedCbm(), s.lateReceivedCbm()); + } + @Transactional + public Map linkBooking(long id, long operatorId, String operatorName, BookingLinkRequest request) { + SailingEntity sailing = requiredSailing(id); + var booking = repository.booking(request.oceanBookingId()); + if (booking == null) throw notFound("订舱不存在"); + if (!"RELEASED".equals(booking.getStatus())) throw conflict("只有RELEASED订舱可以关联船期"); + if (repository.activeBookingLink(request.oceanBookingId()) != null) throw conflict("订舱已经关联其他船期"); + if (!Objects.equals(sailing.getWarehouseId(), booking.getWarehouseId()) || !Objects.equals(sailing.getChannelCodeSnapshot(), booking.getChannelCode()) + || !Objects.equals(sailing.getDeparturePortCode(), booking.getDeparturePortCode()) || !Objects.equals(sailing.getDestinationPortCode(), booking.getDestinationPortCode())) throw conflict("订舱的仓库、渠道或港口与船期不一致"); + SailingBookingLinkEntity value = new SailingBookingLinkEntity(); value.setId(IdWorker.getId()); value.setTenantId(booking.getCustomerId() == null ? 0L : booking.getCustomerId()); value.setSailingId(id); value.setOceanBookingId(request.oceanBookingId()); value.setLinkStatus("ACTIVE"); value.setActiveBookingGuard(1); value.setBookingStatusSnapshot(booking.getStatus()); value.setWarehouseId(booking.getWarehouseId()); value.setChannelCode(booking.getChannelCode()); value.setDeparturePortCode(booking.getDeparturePortCode()); value.setDestinationPortCode(booking.getDestinationPortCode()); value.setLinkedAt(LocalDateTime.now(ZONE)); value.setOperatorId(operatorId); value.setOperatorName(operatorName); repository.insertBookingLink(value); + if (StringUtils.hasText(booking.getVesselName()) || StringUtils.hasText(booking.getVoyageNo())) { + if (repository.updateBookingDetails(id, sailing.getVersion(), booking.getVesselName(), booking.getVoyageNo(), booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt(), operatorId, operatorName) != 1) throw conflict("船期已被其他人修改"); + } + int booked = repository.requirements(booking.getId()).stream().mapToInt(v -> v.getQuantity() == null ? 0 : v.getQuantity()).sum(); + return Map.of("id", value.getId(), "sailingId", id, "oceanBookingId", request.oceanBookingId(), "status", value.getLinkStatus(), "bookedContainerCount", booked); + } + @Transactional + public SailingEntity createSailing(long operatorId, String operatorName, SailingCreateRequest request) { + if (version(request.expectedVersion()) != 0) throw bad("新建船期expectedVersion必须为0"); + validateSailing(request.data()); SailingChannelEntity channel = resolveChannel(request.data()); + if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建船期"); + SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); + copy(value, request.data(), channel); value.setStatus(OPEN); value.setSourceType(normalizeSource(request.data().sourceType())); + value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value); + try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("船期业务唯一键冲突"); } + recordHistory(value.getId(), "CREATE", null, OPEN, "新增船期", operatorId, operatorName, value.getCreatedAt()); + return value; + } + @Transactional + public SailingEntity updateSailing(long id, long operatorId, String operatorName, SailingUpdateRequest request) { + SailingEntity before = requiredSailing(id); validateSailing(request.data()); SailingChannelEntity channel = resolveChannel(request.data()); + if (!Objects.equals(before.getStatus(), "OPEN") && !Objects.equals(before.getStatus(), "DRAFT")) throw conflict("当前船期状态不可编辑"); + SailingEntity value = new SailingEntity(); value.setId(id); copy(value, request.data(), channel); value.setStatus(StringUtils.hasText(request.status()) ? normalizeSailingStatus(request.status()) : before.getStatus()); + value.setSourceType(normalizeSource(request.data().sourceType())); + if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("船期已被其他人修改"); + recordHistory(id, "EDIT", before.getStatus(), value.getStatus(), "编辑船期", operatorId, operatorName, LocalDateTime.now(ZONE)); + return requiredSailing(id); + } + public List generationPreview(long templateId, LocalDate from, LocalDate to) { + SailingTemplateEntity template = requiredTemplate(templateId); if (!from.isBefore(to.plusDays(1))) throw bad("日期范围无效"); + java.util.ArrayList result = new java.util.ArrayList<>(); + for (LocalDate date = from; !date.isAfter(to); date = date.plusDays(1)) if (matchesWeekday(template, date) + && !date.isBefore(template.getValidFrom()) && (template.getValidTo() == null || !date.isAfter(template.getValidTo()))) { + LocalDateTime etd = LocalDateTime.of(date, template.getEtdLocalTime()); LocalDateTime cutoff = cutoff(template, etd); + String voyage = voyage(template, date); String key = key(template, etd, voyage); + result.add(new GenerationPreview(date, etd, cutoff, voyage, repository.sailingByGeneratedKey(key) != null, "")); + } + return result; + } + @Transactional + public List generate(long operatorId, String operatorName, long templateId, LocalDate from, LocalDate to) { + SailingTemplateEntity template = requiredTemplate(templateId); if (!"ACTIVE".equals(template.getStatus())) throw conflict("停用模板不能生成船期"); + SailingChannelEntity channel = requiredChannel(template.getChannelId()); + if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能生成船期"); + validateTemplateSnapshot(template); + List result = new java.util.ArrayList<>(); + for (GenerationPreview preview : generationPreview(templateId, from, to)) if (!preview.conflict()) { + SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(templateId); + value.setChannelId(channel.getId()); value.setChannelCodeSnapshot(channel.getChannelCode()); value.setChannelNameSnapshot(channel.getChannelName()); + value.setWarehouseId(template.getWarehouseId()); value.setWarehouseNameSnapshot(template.getWarehouseNameSnapshot()); value.setDeparturePortCode(template.getDeparturePortCode()); value.setDestinationPortCode(template.getDestinationPortCode()); value.setVesselName(template.getVesselName()); value.setVoyageNo(preview.voyageNo()); value.setEtdAt(preview.etdAt()); value.setWarehouseCutoffAt(preview.cutoffAt()); value.setStatus(OPEN); value.setSourceType("TEMPLATE"); value.setGeneratedKey(key(template, preview.etdAt(), preview.voyageNo())); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); repository.insert(value); recordHistory(value.getId(), "GENERATE", null, OPEN, "按固定周模板生成", operatorId, operatorName, value.getCreatedAt()); result.add(value); + } + return result; + } + + private void recordHistory(long sailingId, String action, String fromStatus, String toStatus, String reason, long operatorId, String operatorName, LocalDateTime at) { + SailingHistoryEntity history = new SailingHistoryEntity(); history.setId(IdWorker.getId()); history.setSailingId(sailingId); history.setAction(action); history.setFromStatus(fromStatus); history.setToStatus(toStatus); history.setReason(reason); history.setOperatorId(operatorId); history.setOperatorName(operatorName); history.setCreatedAt(at); repository.insertHistory(history); + } + private CalendarItem item(SailingEntity v) { return new CalendarItem(v.getId(), v.getSailingNo(), v.getWarehouseId(), v.getWarehouseNameSnapshot(), v.getChannelCodeSnapshot(), v.getChannelNameSnapshot(), v.getVesselName(), v.getVoyageNo(), v.getDeparturePortCode(), v.getDestinationPortCode(), v.getEtdAt(), v.getWarehouseCutoffAt(), v.getStatus(), v.getVersion(), summary(v), v.getTemplateId(), v.getChannelId(), v.getEtaAt(), v.getSourceType(), v.getPlannedCapacityCbm()); } + private VolumeSummary summary(SailingEntity v) { + SailingCalendarDisplayMapper.ReceiptSummary received = repository.receiptSummary(v); + BigDecimal eligibleActual = received == null ? BigDecimal.ZERO : nvl(received.catchableCbm()); + BigDecimal lateActual = received == null ? BigDecimal.ZERO : nvl(received.lateCbm()); + BigDecimal actual = eligibleActual.add(lateActual); + BigDecimal forecast = BigDecimal.ZERO; + // 当前展示版不再产生“未归班”事实;没有可靠的预计体积源时不把它冒充成待确认货量。 + BigDecimal risk = BigDecimal.ZERO; + int late = received == null || received.lateCount() == null ? 0 : received.lateCount(); + BookingTotals bookedTotals = bookingTotals(v); + BigDecimal capacity = v.getPlannedCapacityCbm(); BigDecimal catchable = catchableVolume(v.getId()); + boolean over = capacity != null && catchable.compareTo(capacity) > 0; + int recommended = recommendedContainers(catchable); + int pending = ceil(nvl(catchable.subtract(bookedTotals.capacityCbm())), defaultContainerCapacity()); + return new VolumeSummary(actual, forecast, risk, bookedTotals.containerCount(), recommended, pending, late, over, capacity, bookedTotals.capacityCbm(), LocalDateTime.now(ZONE), eligibleActual, lateActual); + } + private static int recommendedContainers(BigDecimal volume) { return ceil(volume, defaultContainerCapacity()); } + private static BigDecimal defaultContainerCapacity() { + return ContainerSpecificationCatalog.values().stream() + .filter(spec -> ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE.equals(spec.containerType())) + .map(ContainerSpecificationCatalog.Specification::capacityCbm) + .findFirst() + .orElseThrow(() -> new IllegalStateException("默认柜型未配置")); + } + private BigDecimal catchableVolume(long sailingId) { + SailingEntity sailing = requiredSailing(sailingId); + SailingCalendarDisplayMapper.ReceiptSummary received = repository.receiptSummary(sailing); + return received == null ? BigDecimal.ZERO : nvl(received.catchableCbm()); + } + private BigDecimal bookedPlanningCapacity(long sailingId) { return bookingTotals(requiredSailing(sailingId)).capacityCbm(); } + private BookingTotals bookingTotals(SailingEntity sailing) { + SailingCalendarDisplayMapper.BookingSummary value = repository.bookingSummary(sailing); + return new BookingTotals(value == null || value.containerCount() == null ? 0 : value.containerCount(), + value == null ? BigDecimal.ZERO : nvl(value.capacityCbm())); + } + private record BookingTotals(int containerCount, BigDecimal capacityCbm) {} + private static int ceil(BigDecimal volume, BigDecimal unit) { return volume == null || volume.signum() <= 0 ? 0 : volume.divide(unit, 0, java.math.RoundingMode.CEILING).intValue(); } + private static BigDecimal nvl(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } + private static void copy(SailingChannelEntity v, ChannelData d) { v.setChannelCode(d.channelCode().trim().toUpperCase()); v.setChannelName(d.channelName().trim()); v.setSourceType(d.sourceType().trim().toUpperCase()); v.setMappedChannelCode(trimUpper(d.mappedChannelCode())); v.setDefaultDeparturePortCode(trimUpper(d.defaultDeparturePortCode())); v.setDefaultDestinationPortCode(trimUpper(d.defaultDestinationPortCode())); v.setDefaultCutoffDays(d.defaultCutoffDays() == null ? 3 : d.defaultCutoffDays()); v.setRemark(trim(d.remark())); } + private static void copy(SailingTemplateEntity v, TemplateData d, SailingChannelEntity c) { + v.setTemplateName(StringUtils.hasText(d.templateName()) ? d.templateName().trim() : c.getChannelName()); v.setChannelId(c.getId()); v.setChannelCodeSnapshot(c.getChannelCode()); v.setChannelNameSnapshot(c.getChannelName()); v.setWarehouseId(d.warehouseId()); v.setWarehouseNameSnapshot(trim(d.warehouseName())); + v.setDeparturePortCode(trimUpper(StringUtils.hasText(d.departurePortCode()) ? d.departurePortCode() : c.getDefaultDeparturePortCode())); v.setDestinationPortCode(trimUpper(StringUtils.hasText(d.destinationPortCode()) ? d.destinationPortCode() : c.getDefaultDestinationPortCode())); + int mask = d.weekdays() == null || d.weekdays().isEmpty() ? 1 : d.weekdays().stream().mapToInt(day -> 1 << (day - 1)).reduce(0, (a, b) -> a | b); + v.setWeekday(Integer.numberOfTrailingZeros(mask) + 1); v.setWeekdayMask(mask); v.setEtdLocalTime(d.departureTime()); v.setCutoffDays(d.cutoffDays()); v.setCutoffLocalTime(d.cutoffLocalTime()); v.setValidFrom(d.effectiveFrom()); v.setValidTo(d.effectiveTo()); v.setVoyageRule(trim(d.voyageRule())); v.setVesselName(trim(d.vesselName())); v.setRemark(trim(d.remark())); + } + private static void copy(SailingEntity v, SailingData d, SailingChannelEntity c) { v.setTemplateId(d.templateId()); v.setChannelId(c.getId()); v.setChannelCodeSnapshot(c.getChannelCode()); v.setChannelNameSnapshot(c.getChannelName()); v.setWarehouseId(d.warehouseId()); v.setWarehouseNameSnapshot(trim(d.warehouseName())); v.setDeparturePortCode(d.departurePortCode().trim().toUpperCase()); v.setDestinationPortCode(d.destinationPortCode().trim().toUpperCase()); v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo())); v.setEtdAt(d.etdAt()); v.setEtaAt(d.etaAt()); v.setWarehouseCutoffAt(d.cutoffAt()); v.setPlannedCapacityCbm(d.plannedCapacityCbm()); v.setRemark(trim(d.remark())); } + private static SailingAssignmentEntity copyAssignment(SailingAssignmentEntity source) { SailingAssignmentEntity target = new SailingAssignmentEntity(); target.setTenantId(source.getTenantId()); target.setOriginSailingId(source.getOriginSailingId()); target.setInboundId(source.getInboundId()); target.setInboundCode(source.getInboundCode()); target.setReceiptBatchId(source.getReceiptBatchId()); target.setForecastId(source.getForecastId()); target.setVolumeCbm(source.getVolumeCbm()); target.setForecastVolumeCbm(source.getForecastVolumeCbm()); target.setRouteSource(source.getRouteSource()); target.setVolumeSource(source.getVolumeSource()); target.setRuleVersion(source.getRuleVersion()); return target; } + private static void validateChannel(ChannelData d) { if (!List.of("CATALOG", "SCHEDULE_ONLY").contains(d.sourceType().trim().toUpperCase())) throw bad("sourceType仅支持CATALOG或SCHEDULE_ONLY"); } + private static void validateTemplate(TemplateData d, SailingChannelEntity channel) { + if (d.effectiveFrom() == null || d.departureTime() == null || d.cutoffDays() == null || d.cutoffLocalTime() == null) throw bad("模板必须填写生效日期、开船时间和截止规则"); + if (d.effectiveTo() != null && d.effectiveTo().isBefore(d.effectiveFrom())) throw bad("模板结束日期不能早于开始日期"); + if (d.weekdays() == null || d.weekdays().isEmpty() || d.weekdays().stream().anyMatch(day -> day == null || day < 1 || day > 7)) throw bad("模板至少选择一个有效星期几"); + if (!StringUtils.hasText(d.vesselName())) throw bad("模板必须填写船名"); + if (!StringUtils.hasText(d.departurePortCode()) && !StringUtils.hasText(channel.getDefaultDeparturePortCode())) throw bad("模板必须填写起运港"); + if (!StringUtils.hasText(d.destinationPortCode()) && !StringUtils.hasText(channel.getDefaultDestinationPortCode())) throw bad("模板必须填写目的港"); + if (d.cutoffDays() == 0 && !d.cutoffLocalTime().isBefore(d.departureTime())) throw bad("同日收货截止时刻必须早于开船时间"); + } + private static void validateTemplateSnapshot(SailingTemplateEntity t) { + if (!StringUtils.hasText(t.getVesselName()) || !StringUtils.hasText(t.getDeparturePortCode()) || !StringUtils.hasText(t.getDestinationPortCode()) + || t.getEtdLocalTime() == null || t.getCutoffDays() == null || t.getCutoffLocalTime() == null || t.getValidFrom() == null) throw bad("模板快照字段不完整,不能生成船期"); + if (t.getCutoffDays() == 0 && !t.getCutoffLocalTime().isBefore(t.getEtdLocalTime())) throw bad("同日收货截止时刻必须早于开船时间"); + } + private static void validateSailing(SailingData d) { + if (d.etaAt() != null && d.etaAt().isBefore(d.etdAt())) throw bad("ETA不能早于ETD"); + if (d.cutoffAt() == null || !d.cutoffAt().isBefore(d.etdAt())) throw bad("收货截止必须早于开船时间"); + } + private SailingChannelEntity resolveChannel(SailingData d) { if (d.channelId() != null) return requiredChannel(d.channelId()); if (!StringUtils.hasText(d.channelCode())) throw bad("必须选择价格表渠道"); SailingChannelEntity v = repository.channelByCode(d.channelCode().trim().toUpperCase()); if (v == null) throw notFound("渠道不存在"); return v; } + private SailingChannelEntity requiredChannel(long id) { SailingChannelEntity v = repository.channel(id); if (v == null) throw notFound("渠道不存在"); return v; } + private SailingTemplateEntity requiredTemplate(long id) { SailingTemplateEntity v = repository.template(id); if (v == null) throw notFound("船期模板不存在"); return v; } + private SailingEntity requiredSailing(long id) { SailingEntity v = repository.sailing(id); if (v == null) throw notFound("船期不存在"); return v; } + private boolean isCopyOf(SailingEntity value, String reason) { + return "COPY".equals(value.getSourceType()) && repository.histories(value.getId()).stream() + .anyMatch(history -> "COPY".equals(history.getAction()) && Objects.equals(reason, history.getReason())); + } + private static LocalDateTime copyEta(SailingEntity source, LocalDateTime etdAt) { + if (source.getEtdAt() == null || source.getEtaAt() == null) return null; + return etdAt.plus(ChronoUnit.MILLIS.between(source.getEtdAt(), source.getEtaAt()), ChronoUnit.MILLIS); + } + private static SailingDetail throwCopyConflict(LocalDateTime etdAt, String voyageNo) { + throw conflict("目标开船时间 " + etdAt + " 已存在航次 " + voyageNo + ",请勿重复复制"); + } + private static LocalDateTime cutoff(SailingTemplateEntity t, LocalDateTime etd) { return etd.toLocalDate().minusDays(t.getCutoffDays()).atTime(t.getCutoffLocalTime()); } + private static String voyage(SailingTemplateEntity t, LocalDate date) { return StringUtils.hasText(t.getVoyageRule()) ? t.getVoyageRule().replace("{date}", date.toString().replace("-", "")) : "V" + date.toString().replace("-", ""); } + private static String key(SailingTemplateEntity t, LocalDateTime etd, String voyage) { return t.getId() + "|" + etd + "|" + voyage; } + private static boolean matchesWeekday(SailingTemplateEntity t, LocalDate date) { int mask = t.getWeekdayMask() == null ? 1 << ((t.getWeekday() == null ? 1 : t.getWeekday()) - 1) : t.getWeekdayMask(); return (mask & (1 << (date.getDayOfWeek().getValue() - 1))) != 0; } + private static TemplateView templateViewOf(SailingTemplateEntity v) { int mask = v.getWeekdayMask() == null ? 1 << ((v.getWeekday() == null ? 1 : v.getWeekday()) - 1) : v.getWeekdayMask(); List days = java.util.stream.IntStream.rangeClosed(1, 7).filter(day -> (mask & (1 << (day - 1))) != 0).boxed().toList(); return new TemplateView(v.getId(), v.getTemplateNo(), v.getTemplateName(), v.getChannelId(), v.getChannelCodeSnapshot(), v.getChannelNameSnapshot(), v.getWarehouseId(), v.getWarehouseNameSnapshot(), days, v.getValidFrom(), v.getValidTo(), v.getEtdLocalTime(), v.getCutoffDays(), v.getCutoffLocalTime(), v.getVoyageRule(), v.getVesselName(), v.getDeparturePortCode(), v.getDestinationPortCode(), v.getRemark(), v.getStatus(), v.getVersion()); } + private static void now(SailingChannelEntity v) { LocalDateTime n = LocalDateTime.now(ZONE); v.setCreatedAt(n); v.setUpdatedAt(n); } + private static void now(SailingTemplateEntity v) { LocalDateTime n = LocalDateTime.now(ZONE); v.setCreatedAt(n); v.setUpdatedAt(n); } + private static void now(SailingEntity v) { LocalDateTime n = LocalDateTime.now(ZONE); v.setCreatedAt(n); v.setUpdatedAt(n); } + private static String normalizeSource(String v) { return StringUtils.hasText(v) ? v.trim().toUpperCase() : "MANUAL"; } + private static int version(Integer v) { return v == null ? 0 : v; } + private static String normalizeStatus(String value, String label) { String v = value.trim().toUpperCase(); if (!List.of("ACTIVE", "INACTIVE", "DRAFT").contains(v)) throw bad(label + "无效"); return v; } + private static String normalizeSailingStatus(String value) { String v = value.trim().toUpperCase(); if (!List.of("DRAFT", "OPEN", "CLOSED", "DEPARTED", "CANCELLED", "COMPLETED").contains(v)) throw bad("船期状态无效"); return v; } + private static String trim(String v) { if (v == null) return null; String x = v.trim(); return x.isEmpty() ? null : x; } + private static String trimUpper(String v) { String x = trim(v); return x == null ? null : x.toUpperCase(); } + private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } + private static BizException notFound(String message) { return new BizException(ResultCode.NOT_FOUND, message); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 23b784239..831441e84 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest; +import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; import com.baiyue.firstleg.domain.PricingDestinationType; import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; @@ -46,6 +47,7 @@ public class FbaPricingService { private static final BigDecimal ONE_HUNDRED = new BigDecimal("100"); private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L; private static final String CONTAINER_RECALCULATE_OPERATION = "CONTAINER_FEE_RECALCULATE"; + private static final String MANUAL_RATE_CODE = "MANUAL_QUOTE"; @Resource private FbaPricingRepository repository; @Resource @@ -177,6 +179,94 @@ public class FbaPricingService { return response(inbound, fee, rate.getUnitPrice(), nextStatus); } + @Transactional + public FeeSnapshotResponse manualQuote(long tenantId, long operatorId, long inboundId, + ManualQuoteRequest request, String idempotencyKey) { + String reason = request.reason().trim(); + if (reason.length() < 2 || request.unitPrice().signum() <= 0 + || request.selectedWeightKg().signum() <= 0 || request.selectedVolumeCbm().signum() <= 0) { + throw new BizException(ResultCode.VALIDATION_FAILED, "人工定价的单价、计费重量、体积和原因必须有效"); + } + String discriminator = request.selectedWeightKg() + ":" + request.selectedVolumeCbm() + + ":" + request.unitPrice() + ":" + reason; + InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId, + "FEE_MANUAL_QUOTE", idempotencyKey, hash("MANUAL_QUOTE", inboundId, discriminator)); + if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null); + + InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId); + if (!"FBA".equals(inbound.getDestinationType())) throw conflict("仅FBA头程运费支持未命中人工定价"); + var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId); + if (measurement == null || measurementRepository.listItems(tenantId, measurement.getId()).isEmpty()) { + throw conflict("实际测量明细不存在,不能生成人工头程费用"); + } + InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, inboundId); + if (feeRevisionLocked(current)) throw conflict("客户已确认,不允许再修改费用"); + if (current != null && !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) { + throw conflict("当前费用状态不允许重新定价"); + } + LocalDateTime now = LocalDateTime.now(clock); + PricingPolicy policy = pricingPolicy(inbound, now); + try { + priceBookEstimator.estimate(tenantId, new EstimateRequest( + inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), + destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, + pricingTaxMode(inbound), policy.chargeMode(), request.selectedWeightKg(), request.selectedVolumeCbm())); + throw conflict("当前条件已有匹配价格,请使用正常试算并调整单价"); + } catch (BizException exception) { + if (exception.getResultCode() != ResultCode.NOT_FOUND) throw exception; + } + BigDecimal weight = request.selectedWeightKg().setScale(3, RoundingMode.HALF_UP); + BigDecimal volume = request.selectedVolumeCbm().setScale(4, RoundingMode.HALF_UP); + String unit = policy.chargeMode(); + BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363"); + BigDecimal quantity = ("KG".equals(unit) ? weight.max(volume.multiply(factor)) + : volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP))) + .setScale(6, RoundingMode.HALF_UP); + BigDecimal subtotal = quantity.multiply(request.unitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP); + int version = repository.nextFeeVersion(tenantId, inboundId); + InboundFeeEntity fee = new InboundFeeEntity(); + fee.setTenantId(tenantId); fee.setInboundId(inboundId); + fee.setFeeNo("FBAF-" + inboundId + "-" + version); fee.setFeeVersion(version); + fee.setMeasurementId(measurement.getId()); fee.setMeasurementSource("ACTUAL"); + fee.setCarrierRateId(null); fee.setCarrierRateVersion(null); fee.setPriceBookVersionId(null); + fee.setRateCode(MANUAL_RATE_CODE); fee.setRateEffectiveFrom(null); fee.setRateEffectiveTo(null); + fee.setCustomerId(tenantId); fee.setCustomerName(inbound.getCustomerName()); + fee.setBusinessType(inbound.getBusinessType()); fee.setTaxMode(inbound.getTaxMode()); + fee.setPricingTaxMode(inbound.getPricingTaxMode()); fee.setOriginWarehouseId(inbound.getWarehouseId()); + fee.setOriginWarehouseName(inbound.getWarehouseName()); fee.setChannelCode(inbound.getChannelCode()); + fee.setDestinationType(inbound.getDestinationType()); fee.setDestinationWarehouseId(inbound.getDestinationWarehouseId()); + fee.setDestinationName(inbound.getDestinationName()); fee.setDestinationCode(inbound.getDestinationCode()); + fee.setChargeMode(unit); fee.setChargeUnit(unit); fee.setConversionFactor(factor); + fee.setRoundingScale(6); fee.setRoundingMode(RoundingMode.HALF_UP.name()); + fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(quantity); + fee.setBaselineChargeableQuantity(quantity); fee.setTolerancePercent(ADJUST_TOLERANCE_PERCENT); + fee.setChargeableQuantity(quantity); fee.setCurrency("CNY"); + fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(BigDecimal.ZERO.setScale(MONEY_SCALE)); + fee.setTotalAmount(subtotal); fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); + fee.setExceptionRelease(false); fee.setCalculatedBy(operatorId); fee.setCalculatedAt(now); + fee.setCreatedAt(now); fee.setUpdatedAt(now); + if (policy.rule() != null) { + fee.setChargeRuleId(policy.rule().getId()); fee.setChargeRuleVersion(policy.rule().getRuleVersion()); + fee.setRuleCode(policy.rule().getRuleCode()); fee.setRuleEffectiveFrom(policy.rule().getEffectiveFrom()); + fee.setRuleEffectiveTo(policy.rule().getEffectiveTo()); + } + if (current != null && repository.supersedeCurrent(tenantId, current.getId(), current.getFeeStatus()) != 1) { + throw conflict("费用已被其他操作员修改,请刷新后重试"); + } + repository.insertFee(fee); + InboundFeeItemEntity item = item(tenantId, fee, 1, "FBA_BASE", "FBA基础运费", subtotal, now); + item.setQuantity(quantity); item.setUnit(unit); item.setUnitPrice(request.unitPrice()); + item.setAutomaticFlag(false); item.setRemark(reason); + repository.insertItem(item); + if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(), unit, + weight, volume, quantity) != 1) throw conflict("入库单状态已变化,费用未保存"); + feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now); + complete(token, fee.getId(), now); + String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus()) + ? inbound.getProcessStatus() : "FEE_CONFIRMING"; + return response(inbound, fee, request.unitPrice(), nextStatus); + } + private ActivePriceBookEstimator.Estimate applyContainerCustomerTier( InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual, BigDecimal currentWeight, BigDecimal currentVolume) { @@ -280,6 +370,7 @@ public class FbaPricingService { return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.REPLAYED, 0); } List regionalMembers = members.stream() + .filter(member -> member.current().getCarrierRateId() != null) .filter(member -> priceModel(member.current()) == PriceModel.REGION_WEIGHT_TIER) .toList(); List unlockedRegionalMembers = regionalMembers.stream() 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 d82ca93ec..3f14c3322 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -26,6 +26,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.beans.factory.annotation.Value; import feign.FeignException; +import org.springframework.dao.DataAccessException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,7 +53,7 @@ public class InboundMeasurementService { /** OCR 结果无效或不完整,必须人工录入或修正。 */ private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL"; private static final Set MEASUREMENT_DRAFT_EDITABLE_STATUSES = Set.of( - "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); + "PREPARING", "WAIT_MEASURE", "MEASURING", "PROCESSING", "MEASURE_REJECTED"); private static final Set WEIGHT_EDITABLE_STATUSES = Set.of( "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); /** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */ @@ -99,7 +100,7 @@ public class InboundMeasurementService { ? List.of() : repository.listItems(tenantId, measurement.getId()); List weights = repository.listWeights(tenantId, inbound.getId()); List allocationSlots = - palletAllocations.list(tenantId, inbound.getId()); + loadAllocationSlotsForDetail(tenantId, inbound.getId()); Map allocationsByWeight = allocationSlots.stream() .filter(item -> item.getWeightResultId() != null) @@ -605,8 +606,28 @@ public class InboundMeasurementService { } private boolean customerFeeConfirmed(long tenantId, long inboundId) { - var latestFee = pricingRepository.findLatestFee(tenantId, inboundId); - return latestFee != null && latestFee.getCustomerConfirmedAt() != null; + try { + var latestFee = pricingRepository.findLatestFee(tenantId, inboundId); + return latestFee != null && latestFee.getCustomerConfirmedAt() != null; + } catch (DataAccessException exception) { + // Fee confirmation is an edit-lock hint only; it must not prevent opening the review page. + LOGGER.warn("读取入库费用确认状态失败,继续返回测量详情: tenantId={}, inboundId={}", + tenantId, inboundId, exception); + return false; + } + } + + private List loadAllocationSlotsForDetail( + long tenantId, long inboundId) { + try { + return palletAllocations.list(tenantId, inboundId); + } catch (DataAccessException exception) { + // The location guide is supplementary evidence. A missing/old allocation table must not + // turn a measurement review read into an opaque 500 response. + LOGGER.warn("读取入库库位分配快照失败,继续返回测量详情: tenantId={}, inboundId={}", + tenantId, inboundId, exception); + return List.of(); + } } private static InboundMeasurementResponses.PalletLocationGuide toPalletLocationGuide( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index f2a26f9ce..98c15ed52 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -18,6 +18,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; import com.baiyue.firstleg.dto.LoadingListDtos.LifecycleResolution; import com.baiyue.firstleg.client.LoadingDocumentFileClient; +import com.baiyue.firstleg.client.DomesticPdaOperatorClient; import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; @@ -78,6 +79,8 @@ public class LoadingListService { private ObjectMapper objectMapper; @Resource private LoadingDocumentFileClient documentFiles; + @Resource + private DomesticPdaOperatorClient domesticPdaOperators; @Value("${baiyue.security.internal-service-token:}") private String internalToken; @@ -509,12 +512,21 @@ public class LoadingListService { String idempotencyKey, ConfirmPreloadRequest request) { inboundHoldGuard.requirePlanNotHeld(planId); - String requestHash = sha256("CONFIRM_PRELOAD|" + planId + '|' + request.expectedVersion()); + String requestHash = sha256("CONFIRM_PRELOAD|" + planId + '|' + request.expectedVersion() + + '|' + request.assigneeId()); String operationId = operationId("CONFIRM_PRELOAD", planId, idempotencyKey); HistoryRow replay = repository.history(operationId); if (replay != null) return confirmationReplay(replay, requestHash); AggregateRow before = requirePlan(planId); + var operatorResponse = domesticPdaOperators.required(internalToken, request.assigneeId(), before.getWarehouseId()); + var assignee = operatorResponse == null ? null : operatorResponse.data(); + if (operatorResponse == null || !operatorResponse.successful() || assignee == null + || !request.assigneeId().equals(assignee.userId()) + || !StringUtils.hasText(assignee.displayName())) { + throw badRequest("所选国内头程 PDA 作业员无效"); + } + List links = loadingPickService.requirePreloadConfirmationReady(planId); requireContainerFeesReadyForPreloadConfirmation(links, operationId); if (repository.touchForPreloadConfirmation(planId, request.expectedVersion(), @@ -533,6 +545,12 @@ public class LoadingListService { .forEach(containerBookingId -> loadingPickService.beginIfReady( containerBookingId, operatorId, operatorName)); + var pickTask = pickRepository.taskByPlan(planId); + if (pickTask == null || pickRepository.createAssignment(pickTask.getId(), assignee.userId(), + assignee.displayName().trim(), operatorId, trim(operatorName), "ASSIGNED") != 1) { + throw conflict("PDA任务创建或分配失败,请刷新后重试"); + } + AggregateRow after = requirePlan(planId); PreloadDetail detail = repository.preloadDetail(after); PreloadConfirmationResponse response = new PreloadConfirmationResponse(planId, 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 53a8af32a..9af36a61d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -24,6 +24,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot; import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryUnit; import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask; import com.baiyue.firstleg.domain.Iso6346ContainerNumber; +import com.baiyue.firstleg.client.DomesticPdaOperatorClient; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity; @@ -54,6 +55,7 @@ import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Value; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -110,6 +112,10 @@ public class LoadingPickService { private InboundHoldGuard inboundHoldGuard; @Resource private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPalletizationMapper; + @Resource + private DomesticPdaOperatorClient domesticPdaOperators; + @Value("${baiyue.security.internal-service-token:}") + private String internalToken; /** Opens the field task after planning and ocean release; the real container number may be filled later. */ @Transactional @@ -238,6 +244,15 @@ public class LoadingPickService { return view(requiredTask(taskId), warehouseId); } + /** PDA reads require the actual assigned operator; backstage dispatch/review retains oversight. */ + public void requireVisibleTo(long taskId, long operatorId, boolean dispatcherOrReviewer) { + if (dispatcherOrReviewer) return; + var assignment = repository.assignment(taskId); + if (assignment == null || !Long.valueOf(operatorId).equals(assignment.getAssigneeId())) { + throw new BizException(ResultCode.NOT_FOUND, "PDA任务不存在或未分配给当前作业员"); + } + } + public List pendingTasks(long operatorId, long warehouseId, String keyword) { String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim() : null; return repository.pendingTasks(operatorId, warehouseId, normalizedKeyword).stream() @@ -267,11 +282,7 @@ public class LoadingPickService { if (replay != null) return replay; LoadingPickAssignmentEntity existing = repository.assignment(taskId); if (existing == null) { - if (request.expectedAssignmentVersion() != 0 - || repository.createAssignment(taskId, operatorId, operatorName, operatorId, - operatorName, "CLAIMED") != 1) { - throw conflict("PDA任务已被其他作业员领取"); - } + throw conflict("PDA任务尚未指定作业员,请联系客服或仓管分配"); } else { if (!Long.valueOf(operatorId).equals(existing.getAssigneeId())) { throw conflict("PDA任务已分配给其他作业员"); @@ -289,12 +300,20 @@ public class LoadingPickService { public Task assign(long taskId, AssignRequest request, long operatorId, String operatorName) { inboundHoldGuard.requireTaskNotHeld(taskId); String operationId = operation(request.operationId()); - String assigneeName = request.assigneeName().trim(); + LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); + var booking = repository.activeBookingByTask(taskId); + var operatorResponse = domesticPdaOperators.required(internalToken, request.assigneeId(), booking == null ? 0L : booking.getWarehouseId()); + var assignee = operatorResponse == null ? null : operatorResponse.data(); + if (operatorResponse == null || !operatorResponse.successful() || assignee == null + || !request.assigneeId().equals(assignee.userId()) + || !StringUtils.hasText(assignee.displayName())) { + throw new BizException(ResultCode.BAD_REQUEST, "所选国内头程 PDA 作业员无效"); + } + String assigneeName = assignee.displayName().trim(); String requestHash = hash("ASSIGN|" + taskId + '|' + request.assigneeId() + '|' + assigneeName + '|' + request.expectedAssignmentVersion()); Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); if (replay != null) return replay; - LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId); if (replay != null) return replay; LoadingPickAssignmentEntity existing = repository.assignment(taskId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index db1f98688..711862eab 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -43,7 +43,7 @@ public class MeasurementBatchService { /** 已接收第三方测量数据,待平台提交审核。 */ static final String STATUS_RECEIVED = "RECEIVED"; private static final Set EDITABLE_INBOUND_STATUSES = Set.of( - "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); + "PREPARING", "WAIT_MEASURE", "MEASURING", "PROCESSING", "MEASURE_REJECTED"); static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999"); private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java index a4e6947e2..9956d34c2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java @@ -23,6 +23,7 @@ import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.stereotype.Service; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; @@ -49,17 +50,20 @@ public class MojiaMeasurementOpenApiService { private final MeasurementImageClient imageClient; private final MojiaMeasurementReceiptRepository receipts; private final InboundCartonLabelService cartonLabels; + private final MojiaSkuSyncService skuSync; @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Autowired public MojiaMeasurementOpenApiService(MeasurementRequestAuthenticator authenticator, CartonMeasurementService cartonMeasurements, ObjectMapper objectMapper, Validator validator, MeasurementImageClient imageClient, MojiaMeasurementReceiptRepository receipts, - InboundCartonLabelService cartonLabels) { + InboundCartonLabelService cartonLabels, + MojiaSkuSyncService skuSync) { this.authenticator = authenticator; this.cartonMeasurements = cartonMeasurements; this.objectMapper = objectMapper; @@ -67,6 +71,18 @@ public class MojiaMeasurementOpenApiService { this.imageClient = imageClient; this.receipts = receipts; this.cartonLabels = cartonLabels; + this.skuSync = skuSync; + } + + /** Test/embedded construction compatibility; production wiring uses the constructor above. */ + public MojiaMeasurementOpenApiService(MeasurementRequestAuthenticator authenticator, + CartonMeasurementService cartonMeasurements, + ObjectMapper objectMapper, + Validator validator, + MeasurementImageClient imageClient, + MojiaMeasurementReceiptRepository receipts, + InboundCartonLabelService cartonLabels) { + this(authenticator, cartonMeasurements, objectMapper, validator, imageClient, receipts, cartonLabels, null); } public UploadResponse receive(MeasurementRequestHeaders headers, String rawJson) { @@ -80,6 +96,11 @@ public class MojiaMeasurementOpenApiService { throw new BizException(ResultCode.UNAUTHORIZED, "墨家设备必须使用平台级应用凭据"); } long machineWarehouseId = requireMachineWarehouse(app, request.machineNo()); + // 墨家同一个 boxCode 字段同时承载箱唛和 SKU:带 # 的继续走既有箱唛链路, + // 不带 # 的只进入SKU同步收件,不得误走箱码匹配。 + if (!request.boxCode().contains("#")) { + return saveSkuPending(app, request, image); + } final InboundCartonLabelService.PlatformBinding platformBinding; try { platformBinding = cartonLabels.resolvePlatformByBoxCode(request.boxCode()); @@ -90,7 +111,8 @@ public class MojiaMeasurementOpenApiService { throw exception; } if (platformBinding.warehouseId() != machineWarehouseId) { - throw new BizException(ResultCode.FORBIDDEN, "箱号不属于当前设备绑定仓库"); + return saveUnmatched(app, request, image, + new BizException(ResultCode.FORBIDDEN, "箱号不属于当前设备绑定仓库")); } CartonMeasurementResult result; try { @@ -111,6 +133,7 @@ public class MojiaMeasurementOpenApiService { correlation("requestId"), correlation("traceId"), safe(request.boxCode()), safe(request.machineNo()), request.length(), request.width(), request.height(), image.getSize(), result.measurementId(), result.itemId(), result.created()); + saveMatchedReceipt(app, request, result, platformBinding.binding().inboundCode()); return new UploadResponse(true, true, null, result.measurementId(), result.itemId(), result.boxCode(), result.imageFileId(), result.created()); } catch (RuntimeException exception) { @@ -125,6 +148,71 @@ public class MojiaMeasurementOpenApiService { } } + /** 每次箱码上传都保留一条设备收件流水,正式测量成功后补上权威入仓码。 */ + private void saveMatchedReceipt(AuthenticatedMeasurementApp app, UploadRequest request, + CartonMeasurementResult result, String inboundCode) { + MojiaMeasurementReceiptEntity receipt = new MojiaMeasurementReceiptEntity(); + receipt.setId(IdWorker.getId()); + receipt.setTenantId(app.tenantId()); + receipt.setSubjectId(app.subjectId()); + receipt.setBoxCode(request.boxCode().trim()); + receipt.setInboundCode(inboundCode); + receipt.setMeasurementKind("BOX_LABEL"); + receipt.setScannedShipmentId(optionalTrim(request.shipmentId())); + receipt.setLengthCm(request.length()); + receipt.setWidthCm(request.width()); + receipt.setHeightCm(request.height()); + receipt.setWeightKg(request.weightKg()); + receipt.setMachineNo(request.machineNo().trim()); + receipt.setMeasuredAt(request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt()); + receipt.setImageFileId(result.imageFileId()); + receipt.setStatus("MATCHED"); + receipt.setRevision(1); + receipt.setRequestId(correlation("requestId")); + receipt.setTraceId(correlation("traceId")); + receipt.setCreatedAt(LocalDateTime.now()); + receipt.setUpdatedAt(LocalDateTime.now()); + receipts.insert(receipt); + } + + private UploadResponse saveSkuPending(AuthenticatedMeasurementApp app, UploadRequest request, + MultipartFile image) { + long receiptId = IdWorker.getId(); + MeasurementImageClient.UploadResult uploaded = uploadReceiptImage( + app.tenantId(), app.subjectId(), receiptId, image); + try { + LocalDateTime measuredAt = request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt(); + MojiaMeasurementReceiptEntity receipt = new MojiaMeasurementReceiptEntity(); + receipt.setId(receiptId); + receipt.setTenantId(app.tenantId()); + receipt.setSubjectId(app.subjectId()); + receipt.setBoxCode(request.boxCode().trim()); + receipt.setMeasurementKind("SKU"); + receipt.setScannedShipmentId(optionalTrim(request.shipmentId())); + receipt.setLengthCm(request.length()); + receipt.setWidthCm(request.width()); + receipt.setHeightCm(request.height()); + receipt.setWeightKg(request.weightKg()); + receipt.setMachineNo(request.machineNo().trim()); + receipt.setMeasuredAt(measuredAt); + receipt.setImageFileId(uploaded.fileId()); + receipt.setStatus("SKU_PENDING_SYNC"); + receipt.setSyncStatus("PENDING"); + receipt.setRevision(1); + receipt.setRequestId(correlation("requestId")); + receipt.setTraceId(correlation("traceId")); + receipt.setCreatedAt(LocalDateTime.now()); + receipt.setUpdatedAt(LocalDateTime.now()); + receipts.insert(receipt); + if (skuSync != null) skuSync.autoSync(receiptId); + return new UploadResponse(true, false, receiptId, null, null, + request.boxCode(), uploaded.fileId(), true); + } catch (RuntimeException exception) { + cleanupReceiptImage(app.tenantId(), app.subjectId(), receiptId, uploaded.fileId()); + throw exception; + } + } + private long requireMachineWarehouse(AuthenticatedMeasurementApp app, String machineNo) { Long warehouseId = app.warehouseBindings().get(machineNo.trim()); if (warehouseId == null || warehouseId <= 0) { @@ -144,10 +232,12 @@ public class MojiaMeasurementOpenApiService { receipt.setTenantId(app.tenantId()); receipt.setSubjectId(app.subjectId()); receipt.setBoxCode(request.boxCode().trim()); + receipt.setMeasurementKind("BOX_LABEL"); receipt.setScannedShipmentId(optionalTrim(request.shipmentId())); receipt.setLengthCm(request.length()); receipt.setWidthCm(request.width()); receipt.setHeightCm(request.height()); + receipt.setWeightKg(request.weightKg()); receipt.setMachineNo(request.machineNo().trim()); receipt.setMeasuredAt(request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt()); receipt.setImageFileId(uploaded.fileId()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaSkuSyncService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaSkuSyncService.java new file mode 100644 index 000000000..47b2c9d97 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaSkuSyncService.java @@ -0,0 +1,118 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.config.LingxingSyncProperties; +import com.baiyue.firstleg.dto.MojiaSkuSyncDtos; +import com.baiyue.firstleg.entity.MojiaMeasurementReceiptEntity; +import com.baiyue.firstleg.repository.MojiaSkuSyncRepository; +import com.baiyue.firstleg.lingxing.LingxingDirectClient; +import com.baiyue.firstleg.lingxing.LingxingBrowserChallengeException; +import com.baiyue.firstleg.lingxing.LingxingSecondaryVerificationException; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Service +public class MojiaSkuSyncService { + private static final Logger log = LoggerFactory.getLogger(MojiaSkuSyncService.class); + @Resource private MojiaSkuSyncRepository repository; + @Resource private LingxingSyncProperties lingxingProperties; + @Resource private LingxingDirectClient lingxing; + + public MojiaSkuSyncDtos.Config config() { + return new MojiaSkuSyncDtos.Config(lingxingProperties.isAutoSyncEnabled()); + } + + /** Called after a SKU receipt is durably saved; the Nacos switch is deliberately the only gate. */ + public void autoSync(long id) { + if (!lingxingProperties.isAutoSyncEnabled()) return; + try { + MojiaMeasurementReceiptEntity value = required(id); + retry(id, new MojiaSkuSyncDtos.RetryRequest(value.getRevision())); + } catch (LingxingSecondaryVerificationException | LingxingBrowserChallengeException ex) { + log.warn("领星自动同步等待人工验证 receiptId={} reason={}", id, ex.getMessage()); + } catch (RuntimeException ex) { + log.warn("领星自动同步失败 receiptId={}", id, ex); + } + } + + public PageResult page(MojiaSkuSyncDtos.Query query) { + PageResult source = repository.page( + query.code(), query.measurementKind(), query.syncStatus(), query.page(), query.size()); + return new PageResult<>(source.records().stream().map(MojiaSkuSyncService::view).toList(), + source.page(), source.size(), source.total(), source.pages()); + } + + @Transactional + public MojiaSkuSyncDtos.Record update(long id, MojiaSkuSyncDtos.UpdateRequest request) { + MojiaMeasurementReceiptEntity value = required(id); + if (repository.updateMeasurement(copy(value, request), request.revision()) != 1) { + throw conflict("SKU测量记录已被其他人修改,请刷新后重试"); + } + return view(required(id)); + } + + public MojiaSkuSyncDtos.Record retry(long id, MojiaSkuSyncDtos.RetryRequest request) { + required(id); + if (repository.retry(id, request.revision()) != 1) { + throw conflict("仅待同步或同步失败记录可推送,或记录已被其他人修改"); + } + MojiaMeasurementReceiptEntity claimed = required(id); + LingxingDirectClient.PushResult result; + try { + result = lingxing.push(claimed.getBoxCode(), claimed.getLengthCm(), claimed.getWidthCm(), + claimed.getHeightCm(), claimed.getWeightKg()); + } catch (RuntimeException ex) { + if (ex instanceof LingxingSecondaryVerificationException + || ex instanceof LingxingBrowserChallengeException) { + repository.restorePendingClaim(id, claimed.getRevision()); + throw ex; + } + repository.markFailed(id, claimed.getRevision(), "PUSH_ERROR", "提交领星失败"); + return view(required(id)); + } + if (result.success()) { + if (repository.markSuccess(id, claimed.getRevision(), result.lingxingSkuId()) != 1) { + throw conflict("推送完成但记录已被其他人修改,请刷新查看结果"); + } + } else { + String reason = result.warehouses().stream().map(LingxingDirectClient.PushWarehouseResult::message) + .filter(message -> message != null && !message.isBlank()).findFirst().orElse("提交领星失败"); + if (repository.markFailed(id, claimed.getRevision(), "PUSH_FAILED", reason) != 1) { + throw conflict("推送失败但记录已被其他人修改,请刷新查看结果"); + } + } + return view(required(id)); + } + + private MojiaMeasurementReceiptEntity required(long id) { + MojiaMeasurementReceiptEntity value = repository.find(id); + if (value == null) throw new BizException(ResultCode.NOT_FOUND, "一件代发SKU测量记录不存在"); + return value; + } + + private static MojiaMeasurementReceiptEntity copy(MojiaMeasurementReceiptEntity value, + MojiaSkuSyncDtos.UpdateRequest request) { + value.setBoxCode(request.skuCode().trim()); + value.setLengthCm(request.lengthCm()); value.setWidthCm(request.widthCm()); + value.setHeightCm(request.heightCm()); value.setWeightKg(request.weightKg()); + return value; + } + + private static MojiaSkuSyncDtos.Record view(MojiaMeasurementReceiptEntity value) { + return new MojiaSkuSyncDtos.Record(value.getId(), value.getMeasurementKind(), value.getBoxCode(), + value.getInboundCode(), "SKU".equals(value.getMeasurementKind()) ? value.getBoxCode() : null, + value.getLengthCm(), + value.getWidthCm(), value.getHeightCm(), value.getWeightKg(), value.getImageFileId(), + value.getMeasuredAt(), value.getCreatedAt(), value.getSyncStatus(), value.getSyncFailureCode(), + value.getSyncFailureReason(), value.getLingxingSkuId(), value.getRevision(), value.getUpdatedAt()); + } + + private static BizException conflict(String message) { + return new BizException(ResultCode.CONFLICT, message); + } +} diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 9ae7b8d18..2afc054aa 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -329,16 +329,24 @@ CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', subject_id BIGINT UNSIGNED NOT NULL COMMENT '设备应用操作主体ID', box_code VARCHAR(128) NOT NULL COMMENT '设备扫描的原始箱码', + inbound_code VARCHAR(64) NULL COMMENT '箱码命中后的权威入仓码;SKU记录为空', + measurement_kind VARCHAR(16) NOT NULL DEFAULT 'BOX_LABEL' COMMENT '测量分流:BOX_LABEL箱唛;SKU一件代发SKU', scanned_shipment_id VARCHAR(64) NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', length_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测长度cm', width_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测宽度cm', height_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测高度cm', + weight_kg DECIMAL(18,3) NOT NULL COMMENT '设备实测重量kg', machine_no VARCHAR(64) NOT NULL COMMENT '设备编号', measured_at DATETIME(3) NOT NULL COMMENT '设备测量时间或服务接收时间', image_file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务中的墨家联调图片ID', - status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '测量回执匹配状态:UNMATCHED未匹配;当前首版仅记录未匹配回执', - match_failure_code VARCHAR(16) NOT NULL COMMENT '正式业务匹配失败码', - match_failure_reason VARCHAR(500) NOT NULL COMMENT '脱敏后的匹配失败原因', + status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '原始收件状态;SKU记录使用SKU_PENDING_SYNC', + sync_status VARCHAR(24) NULL COMMENT 'SKU同步状态:PENDING/SYNCING/SUCCESS/FAILED', + sync_failure_code VARCHAR(64) NULL COMMENT 'SKU同步失败业务码', + sync_failure_reason VARCHAR(500) NULL COMMENT '脱敏后的SKU同步失败原因', + lingxing_sku_id VARCHAR(128) NULL COMMENT '领星SKU内部标识', + revision INT NOT NULL DEFAULT 1 COMMENT '当前测量版本号', + match_failure_code VARCHAR(16) NULL COMMENT '正式箱唛业务匹配失败码', + match_failure_reason VARCHAR(500) NULL COMMENT '脱敏后的箱唛匹配失败原因', request_id VARCHAR(128) NULL COMMENT '请求链路ID', trace_id VARCHAR(128) NULL COMMENT '调用链ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -665,8 +673,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本', charge_rule_id BIGINT UNSIGNED NULL COMMENT '历史客户计费规则ID;渠道价格可为空', charge_rule_version INT UNSIGNED NULL COMMENT '历史客户规则版本快照', - carrier_rate_id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', - carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照', + carrier_rate_id BIGINT UNSIGNED NULL COMMENT '船司FBA价格ID;人工报价为空', + carrier_rate_version INT UNSIGNED NULL COMMENT '船司价格版本快照;人工报价为空', price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照', rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照', customer_id BIGINT UNSIGNED NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段', @@ -688,7 +696,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '海外仓或FBA目的仓ID快照', destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照', destination_code VARCHAR(32) NULL COMMENT 'FBA仓编码快照;海外仓可为空', - rate_effective_from DATETIME(3) NOT NULL COMMENT '价格生效开始快照', + rate_effective_from DATETIME(3) NULL COMMENT '价格生效开始快照;人工报价为空', rate_effective_to DATETIME(3) NULL COMMENT '价格生效结束快照', charge_mode VARCHAR(16) NULL COMMENT '计费口径:KG或CBM', conversion_factor DECIMAL(18,6) NULL COMMENT '历史客户规则使用的换算系数快照', @@ -726,6 +734,11 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id) ) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照'; +ALTER TABLE fl_inbound_fee + MODIFY COLUMN carrier_rate_id BIGINT UNSIGNED NULL COMMENT '船司FBA价格ID;人工报价为空', + MODIFY COLUMN carrier_rate_version INT UNSIGNED NULL COMMENT '船司价格版本快照;人工报价为空', + MODIFY COLUMN rate_effective_from DATETIME(3) NULL COMMENT '价格生效开始快照;人工报价为空'; + SET @fee_baseline_quantity_sql = IF( EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_inbound_fee' AND column_name='baseline_chargeable_quantity'), diff --git a/firstleg/src/main/resources/db/phase3-container-customs-schema.sql b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql index f151d6a41..a0c425f30 100644 --- a/firstleg/src/main/resources/db/phase3-container-customs-schema.sql +++ b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql @@ -12,6 +12,9 @@ CREATE TABLE IF NOT EXISTS fl_customs_container ( completed_by BIGINT UNSIGNED NULL COMMENT '完成报关操作人ID', completed_by_name VARCHAR(64) NULL COMMENT '完成报关操作人名称快照', completed_at DATETIME(3) NULL COMMENT '报关完成时间', + broker_submitted_by BIGINT UNSIGNED NULL COMMENT '已将客户确认结果发送给报关行的操作人ID', + broker_submitted_by_name VARCHAR(64) NULL COMMENT '发送报关行操作人名称快照', + broker_submitted_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 UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', @@ -23,6 +26,28 @@ CREATE TABLE IF NOT EXISTS fl_customs_container ( ('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START','DECLARING','COMPLETED')) ) ENGINE=InnoDB COMMENT='装柜完成后的整柜出口报关聚合'; +CREATE TABLE IF NOT EXISTS fl_customs_broker_submission ( + id BIGINT UNSIGNED NOT NULL COMMENT '首次发送报关行批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', + batch_no VARCHAR(64) NOT NULL COMMENT '首次发送报关行批次号', + status VARCHAR(16) NOT NULL COMMENT '发送状态:SENT已发送', + source_version INT UNSIGNED NOT NULL COMMENT '发送时整柜报关聚合版本', + source_snapshot_json JSON NOT NULL COMMENT '发送时冻结资料版本与覆盖范围快照', + sent_by BIGINT UNSIGNED NOT NULL COMMENT '首次发送操作人ID', + sent_by_name VARCHAR(64) NULL COMMENT '首次发送操作人名称快照', + sent_at DATETIME(3) NOT NULL COMMENT '首次发送时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_broker_submission_container(customs_container_id), + UNIQUE KEY uk_customs_broker_submission_batch(batch_no), + KEY idx_customs_broker_submission_plan(loading_plan_id,sent_at), + CONSTRAINT fk_customs_broker_submission_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), + CONSTRAINT chk_customs_broker_submission_status CHECK(status IN('SENT')) +) ENGINE=InnoDB COMMENT='冻结退税报关资料首次发送报关行批次事实'; + CREATE TABLE IF NOT EXISTS fl_customs_customer_document ( id BIGINT UNSIGNED NOT NULL COMMENT '客户报送资料ID', customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', @@ -93,10 +118,10 @@ CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound ( ) ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围'; CREATE TABLE IF NOT EXISTS fl_customs_completion_document ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关完成文件ID', + id BIGINT UNSIGNED NOT NULL COMMENT '报关行回执文件ID', source_document_id BIGINT UNSIGNED NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID', - file_name VARCHAR(255) NOT NULL COMMENT '完成文件名称快照', + file_name VARCHAR(255) NOT NULL COMMENT '回执文件名称快照', file_hash CHAR(64) NOT NULL COMMENT '完成文件SHA-256摘要', content_type VARCHAR(128) NULL COMMENT '完成文件媒体类型', size_bytes BIGINT UNSIGNED NOT NULL COMMENT '完成文件大小(字节)', @@ -104,12 +129,17 @@ CREATE TABLE IF NOT EXISTS fl_customs_completion_document ( uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '完成文件版本号', + customer_confirm_status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '客户回执确认状态:PENDING/CONFIRMED/REJECTED', + customer_confirmed_by BIGINT UNSIGNED NULL COMMENT '客户确认人ID', + customer_confirmed_by_name VARCHAR(64) NULL COMMENT '客户确认人名称快照', + customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认时间', + customer_reject_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_customs_completion_source(source_document_id), CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) -) ENGINE=InnoDB COMMENT='报关完成文件当前版本'; +) ENGINE=InnoDB COMMENT='报关行回执当前版本'; CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version ( id BIGINT UNSIGNED NOT NULL COMMENT '完成文件版本记录ID', @@ -128,6 +158,40 @@ CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version ( CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id) ) ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本'; +CREATE TABLE IF NOT EXISTS fl_customs_completion_action_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '客户回执动作历史ID', + completion_document_id BIGINT UNSIGNED NOT NULL COMMENT '报关行回执当前版本逻辑根ID', + receipt_version INT UNSIGNED NOT NULL COMMENT '动作对应的回执文件版本', + action VARCHAR(16) NOT NULL COMMENT '客户动作:CONFIRM确认、REJECT打回', + reason VARCHAR(500) NULL COMMENT '客户打回原因;确认动作为空', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '客户操作人ID', + operator_name VARCHAR(64) NULL COMMENT '客户操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户动作时间', + PRIMARY KEY(id), + KEY idx_customs_completion_action_history(completion_document_id,receipt_version,created_at,id), + CONSTRAINT fk_customs_completion_action_history_document FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id), + CONSTRAINT chk_customs_completion_action_history_action CHECK(action IN('CONFIRM','REJECT')) +) ENGINE=InnoDB COMMENT='客户报关行回执确认与打回不可变历史'; + +CREATE TABLE IF NOT EXISTS fl_customs_final_document ( + id BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件ID', + source_document_id BIGINT UNSIGNED NOT NULL COMMENT '对应冻结客户资料ID', + file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '真实报关完税文件名称快照', + file_hash CHAR(64) NOT NULL COMMENT '真实报关完税文件SHA-256摘要', + content_type VARCHAR(128) NULL COMMENT '真实报关完税文件媒体类型', + size_bytes BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件大小(字节)', + uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '完税文件版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_final_source(source_document_id), + CONSTRAINT fk_customs_final_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) +) ENGINE=InnoDB COMMENT='真实报关完税文件当前版本'; + CREATE TABLE IF NOT EXISTS fl_customs_container_history ( id BIGINT UNSIGNED NOT NULL COMMENT '报关历史记录ID', customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', @@ -158,6 +222,56 @@ CREATE TABLE IF NOT EXISTS fl_customs_command ( CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS')) ) ENGINE=InnoDB COMMENT='整柜报关写命令幂等事实'; +CREATE TABLE IF NOT EXISTS fl_customs_declaration_batch ( + id BIGINT UNSIGNED NOT NULL COMMENT '正式申报批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', + batch_no VARCHAR(64) NULL COMMENT '平台正式申报批次号', + status VARCHAR(32) NOT NULL COMMENT '批次状态:READY/SUBMITTED/ACKNOWLEDGED/RESULT_PENDING/CLOSED/CANCELLED', + source_version INT UNSIGNED NOT NULL COMMENT '创建时整柜报关聚合版本', + source_snapshot_json JSON NOT NULL COMMENT '提交时资料、回执和确认版本快照', + customs_entry_no VARCHAR(64) NULL COMMENT '报关单号', + submitted_by BIGINT UNSIGNED NULL COMMENT '正式申报提交人', + submitted_by_name VARCHAR(64) NULL COMMENT '正式申报提交人名称快照', + submitted_at DATETIME(3) NULL COMMENT '正式申报提交时间', + acknowledged_by BIGINT UNSIGNED NULL COMMENT '报关行确认受理人', + acknowledged_by_name VARCHAR(64) NULL COMMENT '报关行确认受理人名称快照', + acknowledged_at DATETIME(3) NULL COMMENT '报关行确认受理时间', + remark VARCHAR(500) NULL COMMENT '批次说明', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_declaration_batch_container(customs_container_id, id), + KEY idx_customs_declaration_batch_plan(loading_plan_id,status,created_at), + CONSTRAINT fk_customs_declaration_batch_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), + CONSTRAINT chk_customs_declaration_batch_status CHECK(status IN ('READY','SUBMITTED','ACKNOWLEDGED','RESULT_PENDING','CLOSED','CANCELLED')) +) ENGINE=InnoDB COMMENT='整柜正式申报批次事实'; + +CREATE TABLE IF NOT EXISTS fl_customs_clearance_result ( + id BIGINT UNSIGNED NOT NULL COMMENT '海关结果事实ID', + declaration_batch_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式申报批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + result_type VARCHAR(32) NOT NULL COMMENT '结果类型:INSPECTION/SUPPLEMENT_REQUIRED/RELEASED/DETAINED/OTHER', + result_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '结果状态:OPEN/RESOLVED/CANCELLED', + customs_entry_no VARCHAR(64) NULL COMMENT '报关单号快照', + occurred_at DATETIME(3) NOT NULL COMMENT '海关事实发生时间', + resolution VARCHAR(1000) NULL COMMENT '处理结果或异常说明', + evidence_file_id BIGINT UNSIGNED NULL COMMENT '结果凭证File文件ID', + recorded_by BIGINT UNSIGNED NOT NULL COMMENT '平台录入人', + recorded_by_name VARCHAR(64) NULL COMMENT '平台录入人名称快照', + recorded_at DATETIME(3) NOT NULL COMMENT '平台录入时间', + previous_result_id BIGINT UNSIGNED NULL COMMENT '前序结果事实ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '结果创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '结果更新时间', + PRIMARY KEY(id), + KEY idx_customs_clearance_result_batch(declaration_batch_id,occurred_at,id), + KEY idx_customs_clearance_result_container(customs_container_id, result_type, result_status), + CONSTRAINT fk_customs_clearance_result_batch FOREIGN KEY(declaration_batch_id) REFERENCES fl_customs_declaration_batch(id), + CONSTRAINT chk_customs_clearance_result_type CHECK(result_type IN ('INSPECTION','SUPPLEMENT_REQUIRED','RELEASED','DETAINED','OTHER')), + CONSTRAINT chk_customs_clearance_result_status CHECK(result_status IN ('OPEN','RESOLVED','CANCELLED')) +) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实'; + USE baiyue_iam; DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN(5102171,5102173,5102174,5102175); DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175) diff --git a/firstleg/src/main/resources/db/phase4-sailing-calendar-schema.sql b/firstleg/src/main/resources/db/phase4-sailing-calendar-schema.sql new file mode 100644 index 000000000..828bd262f --- /dev/null +++ b/firstleg/src/main/resources/db/phase4-sailing-calendar-schema.sql @@ -0,0 +1,143 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +-- 船期计划日历首版基线。与 fl_port_schedule(港务船期观测)分离。 +CREATE TABLE fl_sailing_channel ( + id BIGINT NOT NULL, + channel_code VARCHAR(64) NOT NULL, + channel_name VARCHAR(128) NOT NULL, + source_type VARCHAR(24) NOT NULL, + mapped_channel_code VARCHAR(64) NULL, + default_departure_port_code VARCHAR(32) NULL, + default_destination_port_code VARCHAR(32) NULL, + default_cutoff_days TINYINT NOT NULL DEFAULT 3, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_channel_code (channel_code), + KEY idx_fl_sailing_channel_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE fl_sailing_template ( + id BIGINT NOT NULL, + template_no VARCHAR(64) NOT NULL, + template_name VARCHAR(128) NOT NULL, + channel_id BIGINT NOT NULL, + channel_code_snapshot VARCHAR(64) NOT NULL, + channel_name_snapshot VARCHAR(128) NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_name_snapshot VARCHAR(128) NULL, + departure_port_code VARCHAR(32) NOT NULL, + destination_port_code VARCHAR(32) NOT NULL, + weekday TINYINT NOT NULL, + weekday_mask TINYINT NOT NULL DEFAULT 1, + etd_local_time TIME NOT NULL, + cutoff_days TINYINT NOT NULL DEFAULT 3, + cutoff_local_time TIME NOT NULL DEFAULT '18:00:00', + valid_from DATE NOT NULL, + valid_to DATE NULL, + voyage_rule VARCHAR(64) NULL, + vessel_name VARCHAR(128) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_template_no (template_no), + KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday), + CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7), + CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE fl_sailing ( + id BIGINT NOT NULL, + sailing_no VARCHAR(64) NOT NULL, + template_id BIGINT NULL, + channel_id BIGINT NOT NULL, + channel_code_snapshot VARCHAR(64) NOT NULL, + channel_name_snapshot VARCHAR(128) NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_name_snapshot VARCHAR(128) NULL, + departure_port_code VARCHAR(32) NOT NULL, + destination_port_code VARCHAR(32) NOT NULL, + vessel_name VARCHAR(128) NULL, + voyage_no VARCHAR(64) NULL, + etd_at DATETIME(3) NOT NULL, + warehouse_cutoff_at DATETIME(3) NOT NULL, + eta_at DATETIME(3) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'OPEN', + source_type VARCHAR(24) NOT NULL DEFAULT 'MANUAL', + generated_key VARCHAR(192) NULL, + planned_capacity_cbm DECIMAL(14,3) NULL, + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_no (sailing_no), + UNIQUE KEY uk_fl_sailing_generated_key (generated_key), + UNIQUE KEY uk_fl_sailing_business (warehouse_id,channel_code_snapshot,departure_port_code,destination_port_code,etd_at,voyage_no), + KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status), + KEY idx_fl_sailing_route (departure_port_code,destination_port_code), + CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE fl_sailing_history ( + id BIGINT NOT NULL, + sailing_id BIGINT NOT NULL, + action VARCHAR(32) NOT NULL, + from_status VARCHAR(16) NULL, + to_status VARCHAR(16) NULL, + reason VARCHAR(1000) NULL, + operator_id BIGINT NULL, + operator_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +USE baiyue_iam; + +INSERT INTO iam_permission + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5102206,0,'PLATFORM','MENU',5102000,'platform:firstleg:sailing-calendar:list','船期日历','/sailing-calendar','GET',125,'ACTIVE',1,0), + (5102207,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-calendar:create','新增船期','/sailing-calendar','POST',10,'ACTIVE',1,0), + (5102208,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-calendar:edit','编辑船期','/sailing-calendar','PUT',20,'ACTIVE',1,0), + (5102209,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-template:manage','管理渠道与周模板','/sailing-calendar','POST',30,'ACTIVE',1,0), + (5102210,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-booking:link','关联订舱','/sailing-calendar','POST',40,'ACTIVE',1,0), + (5102211,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-volume:forecast-manage','维护货量预报','/sailing-calendar','POST',50,'ACTIVE',1,0), + (5102212,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-volume:exception-reassign','异常重新安排','/sailing-calendar','POST',60,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE + parent_id=VALUES(parent_id), permission_code=VALUES(permission_code), permission_name=VALUES(permission_name), + route_path=VALUES(route_path), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; + +INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id) +SELECT r.id,p.id,0 FROM iam_role r JOIN iam_permission p + ON p.tenant_id=0 AND p.app_code='PLATFORM' + AND p.permission_code IN ( + 'platform:firstleg:sailing-calendar:list', + 'platform:firstleg:sailing-calendar:create', + 'platform:firstleg:sailing-calendar:edit', + 'platform:firstleg:sailing-template:manage', + 'platform:firstleg:sailing-booking:link', + 'platform:firstleg:sailing-volume:forecast-manage', + 'platform:firstleg:sailing-volume:exception-reassign' + ) +WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); diff --git a/firstleg/src/main/resources/db/phase4-sailing-facts-schema.sql b/firstleg/src/main/resources/db/phase4-sailing-facts-schema.sql new file mode 100644 index 000000000..2146a01e0 --- /dev/null +++ b/firstleg/src/main/resources/db/phase4-sailing-facts-schema.sql @@ -0,0 +1,126 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +/* + * 船期日历的事实契约。 + * + * 这些表不修改入库单或海运订舱聚合:入库单仍是履约主数据,订舱仍是 + * RELEASED/DRAFT 聚合。船期日历只消费这里的稳定事实和审计版本。 + */ + +CREATE TABLE fl_sailing_receipt_batch_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '稳定收货批次事实ID(receiptBatchId)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实', + receipt_batch_key VARCHAR(64) NOT NULL COMMENT '同一物理收货批次的不可变业务键', + fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一批次复测/冲正版本', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + measurement_id BIGINT UNSIGNED NULL COMMENT '最终审核测量批次ID', + receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际收货仓ID', + channel_code VARCHAR(64) NULL COMMENT '收货渠道编码快照', + channel_name VARCHAR(128) NULL COMMENT '收货渠道名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', + destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', + received_at DATETIME(3) NOT NULL COMMENT '实际收货完成时间,不使用入库单inbounded_at替代', + actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '最终审核实测体积m3', + fact_status VARCHAR(24) NOT NULL DEFAULT 'APPROVED' COMMENT '事实状态:APPROVED/REVERSED/VOIDED/SUPERSEDED', + reversal_of_fact_id BIGINT UNSIGNED NULL COMMENT '冲正所替代的事实ID', + source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_RECEIPT' COMMENT '事实来源', + created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_receipt_batch_version (tenant_id, receipt_batch_key, fact_version), + KEY idx_sailing_receipt_batch_current (tenant_id, receipt_batch_key, fact_status, fact_version), + KEY idx_sailing_receipt_match (tenant_id, receiving_warehouse_id, channel_code, received_at, id), + KEY idx_sailing_receipt_inbound (tenant_id, inbound_id, received_at, id), + CONSTRAINT chk_sailing_receipt_volume CHECK (actual_volume_cbm > 0), + CONSTRAINT chk_sailing_receipt_status CHECK (fact_status IN ('APPROVED','REVERSED','VOIDED','SUPERSEDED')) +) ENGINE=InnoDB COMMENT='船期日历稳定收货批次实测事实及版本历史'; + +CREATE TABLE fl_sailing_inbound_volume_forecast ( + id BIGINT UNSIGNED NOT NULL COMMENT '货量预报事实ID(forecastId)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实', + forecast_key VARCHAR(64) NOT NULL COMMENT '同一未收货对象的不可变业务键', + fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '预报修订版本', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '预计收货仓ID', + channel_code VARCHAR(64) NULL COMMENT '预计渠道编码快照', + channel_name VARCHAR(128) NULL COMMENT '预计渠道名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '预计起运港编码快照', + destination_port_code VARCHAR(32) NULL COMMENT '预计目的港编码快照', + expected_arrival_at DATETIME(3) NOT NULL COMMENT '预计到仓时间', + forecast_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '未收货预计体积m3,不得使用申报体积代替', + source_type VARCHAR(32) NOT NULL COMMENT '预报来源,例如OPERATIONS/UPSTREAM_MEASUREMENT;不得使用客户申报体积冒充预报', + fact_status VARCHAR(24) NOT NULL DEFAULT 'VALID' COMMENT '事实状态:VALID/SUPERSEDED/VOIDED/CONSUMED', + effective_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '本版本生效时间', + created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_forecast_version (tenant_id, forecast_key, fact_version), + KEY idx_sailing_forecast_current (tenant_id, forecast_key, fact_status, fact_version), + KEY idx_sailing_forecast_match (tenant_id, receiving_warehouse_id, channel_code, expected_arrival_at, id), + KEY idx_sailing_forecast_inbound (tenant_id, inbound_id, expected_arrival_at, id), + CONSTRAINT chk_sailing_forecast_volume CHECK (forecast_volume_cbm > 0), + CONSTRAINT chk_sailing_forecast_status CHECK (fact_status IN ('VALID','SUPERSEDED','VOIDED','CONSUMED')) +) ENGINE=InnoDB COMMENT='船期日历未收货量预报事实及版本历史'; + +CREATE TABLE fl_sailing_assignment ( + id BIGINT UNSIGNED NOT NULL COMMENT '归班事实ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', + sailing_id BIGINT UNSIGNED NULL COMMENT '船期实例ID,UNMATCHED为空', + origin_sailing_id BIGINT UNSIGNED NULL COMMENT '异常重排/取消前的原船期实例ID', + inbound_id BIGINT UNSIGNED NULL COMMENT '归班对象入库单ID', + inbound_code VARCHAR(64) NULL COMMENT '入仓码快照,仅用于展示', + receipt_batch_id BIGINT UNSIGNED NULL COMMENT '收货批次事实ID', + forecast_id BIGINT UNSIGNED NULL COMMENT '货量预报事实ID', + assignment_source VARCHAR(24) NOT NULL DEFAULT 'AUTO' COMMENT '归班来源:AUTO/MANUAL/ROLLOVER', + assignment_version INT UNSIGNED NOT NULL COMMENT '同一对象的归班版本', + match_status VARCHAR(24) NOT NULL COMMENT 'FORECAST_MATCHED/RECEIVED/LATE/UNMATCHED/SUPERSEDED', + volume_cbm DECIMAL(18,4) NULL COMMENT '已收实测体积', + forecast_volume_cbm DECIMAL(18,4) NULL COMMENT '未收有效预计体积', + route_source VARCHAR(32) NULL COMMENT '航线来源:INBOUND_FACT/FORECAST_FACT/CHANNEL_DEFAULT', + volume_source VARCHAR(32) NULL COMMENT 'MEASURED_ACTUAL或预报来源', + rule_version VARCHAR(64) NOT NULL COMMENT '自动归班规则版本', + matched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + superseded_at DATETIME(3) NULL, + locked_at DATETIME(3) NULL COMMENT '截止/订舱/关闭后冻结时间', + lock_reason VARCHAR(32) NULL COMMENT 'CUTOFF/BOOKING_CONFIRMED/CLOSED', + operator_id BIGINT UNSIGNED NULL, + reason VARCHAR(500) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sailing_assignment_current (tenant_id, sailing_id, match_status, matched_at, id), + KEY idx_sailing_assignment_origin (tenant_id, origin_sailing_id, match_status, id), + KEY idx_sailing_assignment_receipt (tenant_id, receipt_batch_id, assignment_version, id), + KEY idx_sailing_assignment_forecast (tenant_id, forecast_id, assignment_version, id), + KEY idx_sailing_assignment_inbound (tenant_id, inbound_id, match_status, id), + CONSTRAINT chk_sailing_assignment_status CHECK (match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED','SUPERSEDED')), + CONSTRAINT chk_sailing_assignment_source CHECK (assignment_source IN ('AUTO','MANUAL','ROLLOVER')) +) ENGINE=InnoDB COMMENT='船期日历自动归班事实及不可变版本历史'; + +CREATE TABLE fl_sailing_booking_link ( + id BIGINT UNSIGNED NOT NULL COMMENT '船期-订舱关联ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台关联隔离键', + sailing_id BIGINT UNSIGNED NOT NULL COMMENT '计划船期实例ID', + ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', + link_status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '关联状态:ACTIVE/REVOKED', + active_booking_guard TINYINT NULL COMMENT 'ACTIVE为1,REVOKED为空,用于唯一约束', + booking_status_snapshot VARCHAR(24) NOT NULL COMMENT '关联时订舱状态快照;只有RELEASED可建立', + warehouse_id BIGINT UNSIGNED NULL COMMENT '关联仓库快照', + channel_code VARCHAR(64) NULL COMMENT '关联渠道快照', + departure_port_code VARCHAR(32) NULL COMMENT '关联起运港快照', + destination_port_code VARCHAR(32) NULL COMMENT '关联目的港快照', + linked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + operator_id BIGINT UNSIGNED NULL, + operator_name VARCHAR(64) NULL, + reason VARCHAR(500) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_booking_active (ocean_booking_id, active_booking_guard), + KEY idx_sailing_booking_link (sailing_id, link_status, ocean_booking_id), + KEY idx_sailing_booking_booking (ocean_booking_id, link_status, id), + CONSTRAINT chk_sailing_booking_link_status CHECK (link_status IN ('ACTIVE','REVOKED')), + CONSTRAINT chk_sailing_booking_link_guard CHECK ((link_status='ACTIVE' AND active_booking_guard=1) OR (link_status='REVOKED' AND active_booking_guard IS NULL)), + CONSTRAINT chk_sailing_booking_released CHECK (booking_status_snapshot='RELEASED') +) ENGINE=InnoDB COMMENT='船期计划与已放舱订舱的最小关联及历史'; diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index 50b825526..c10bb9c70 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -21,27 +21,97 @@ paths: parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {$ref: '#/components/requestBodies/ActionBody'} responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} - /api/platform/v1/customs/loading-plans/documents/{documentId}/completion/upload: + /api/platform/v1/customs/loading-plans/{planId}/send-to-broker: post: - summary: 上传或覆盖冻结客户资料的一对一完成文件 - description: 完成文件自动继承冻结入仓码且不可删除;最后一份须finalConfirmation=true,File调用前服务端预检。 + summary: 将冻结后的退税报关资料首次发送给报关行并生成发送批次 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {$ref: '#/components/requestBodies/ActionBody'} + responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} + /api/platform/v1/customs/loading-plans/{planId}/declaration-batches: + get: + summary: 查询整柜正式申报批次和海关结果 + x-permission: platform:firstleg:customs:list + parameters: [{$ref: '#/components/parameters/PlanId'}] + responses: {'200': {$ref: '#/components/responses/DeclarationBatchListResponse'}} + post: + summary: 创建整柜正式申报批次并冻结提交快照 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchRequest'}}}} + responses: {'200': {$ref: '#/components/responses/DeclarationBatchResponse'}} + /api/platform/v1/customs/loading-plans/declaration-batches/{batchId}/submit: + post: + summary: 平台人工提交正式申报并回填报关单号 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/BatchId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchRequest'}}}} + responses: {'200': {$ref: '#/components/responses/DeclarationBatchResponse'}} + /api/platform/v1/customs/loading-plans/declaration-batches/{batchId}/results: + get: + summary: 查询正式申报批次的海关结果历史 + x-permission: platform:firstleg:customs:list + parameters: [{$ref: '#/components/parameters/BatchId'}] + responses: {'200': {$ref: '#/components/responses/ClearanceResultListResponse'}} + post: + summary: 平台人工录入查验、补资料、放行、扣货或其他海关结果 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/BatchId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ClearanceResultRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ClearanceResultResponse'}} + /api/platform/v1/customs/loading-plans/clearance-results/{resultId}/resolve: + post: + summary: 解决一条待处理海关结果 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/ResultId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ClearanceResolveRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ClearanceResultResponse'}} + /api/platform/v1/customs/loading-plans/{planId}/submit-to-broker: + post: + summary: 将客户确认回执发送给报关行执行 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {$ref: '#/components/requestBodies/ActionBody'} + responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} + /api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/upload: + post: + summary: 上传或覆盖冻结客户资料的一对一报关行回执 + description: 报关行回执按冻结客户资料一对一保存;上传后等待对应客户确认或打回。 x-permission: platform:firstleg:customs:manage parameters: - {$ref: '#/components/parameters/DocumentId'} - {$ref: '#/components/parameters/IdempotencyKey'} - {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 0}} - - {name: finalConfirmation, in: query, required: true, schema: {type: boolean}} requestBody: {$ref: '#/components/requestBodies/FileBody'} responses: {'200': {$ref: '#/components/responses/DocumentResponse'}} + /api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/actions: + get: + summary: 平台查询客户回执确认与打回历史 + x-permission: platform:firstleg:customs:list + parameters: [{$ref: '#/components/parameters/DocumentId'}] + responses: {'200': {$ref: '#/components/responses/ReceiptActionListResponse'}} /api/platform/v1/customs/loading-plans/documents/{documentId}/download: get: summary: 平台下载客户报送资料 x-permission: platform:firstleg:customs:list parameters: [{$ref: '#/components/parameters/DocumentId'}] responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} - /api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download: + /api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/download: get: - summary: 平台下载报关完成文件 + summary: 平台下载报关行回执 + x-permission: platform:firstleg:customs:list + parameters: [{$ref: '#/components/parameters/DocumentId'}] + responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} + /api/platform/v1/customs/loading-plans/documents/{documentId}/final/upload: + post: + summary: 上传真实报关完税文件 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/DocumentId'}, {$ref: '#/components/parameters/IdempotencyKey'}, {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 0}}] + requestBody: {$ref: '#/components/requestBodies/FileBody'} + responses: {'200': {$ref: '#/components/responses/DocumentResponse'}} + /api/platform/v1/customs/loading-plans/documents/{documentId}/final/download: + get: + summary: 平台下载真实报关完税文件 x-permission: platform:firstleg:customs:list parameters: [{$ref: '#/components/parameters/DocumentId'}] responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} @@ -51,6 +121,12 @@ paths: x-permission: merchant:order:view parameters: [{$ref: '#/components/parameters/OrderId'}] responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} + /api/merchant/v1/customs/orders/{orderId}/clearance-results: + get: + summary: 客户只读查看自身订单可见的海关结果 + x-permission: merchant:order:view + parameters: [{$ref: '#/components/parameters/OrderId'}] + responses: {'200': {$ref: '#/components/responses/ClearanceResultListResponse'}} /api/merchant/v1/customs/loading-plans/{planId}/documents/upload: post: summary: 上传资料并关联当前柜内本客户一个或多个退税入仓码 @@ -88,18 +164,44 @@ paths: - {$ref: '#/components/parameters/IdempotencyKey'} - {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 1}} responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} + /api/merchant/v1/customs/documents/{documentId}/receipt/actions: + get: + summary: 客户查询自身资料的回执确认与打回历史 + x-permission: merchant:order:view + parameters: [{$ref: '#/components/parameters/DocumentId'}] + responses: {'200': {$ref: '#/components/responses/ReceiptActionListResponse'}} /api/merchant/v1/customs/documents/{documentId}/download: get: summary: 客户下载自己当前报送资料 x-permission: merchant:order:view parameters: [{$ref: '#/components/parameters/DocumentId'}] responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} - /api/merchant/v1/customs/documents/{documentId}/completion/download: + /api/merchant/v1/customs/documents/{documentId}/receipt/download: get: - summary: 整柜完成后客户下载对应完成文件 + summary: 客户下载报关行回执 x-permission: merchant:order:view parameters: [{$ref: '#/components/parameters/DocumentId'}] responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} + /api/merchant/v1/customs/documents/{documentId}/final/download: + get: + summary: 整柜完成后客户下载真实报关完税文件 + x-permission: merchant:order:view + parameters: [{$ref: '#/components/parameters/DocumentId'}] + responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} + /api/merchant/v1/customs/documents/{documentId}/receipt/confirm: + post: + summary: 客户确认退税报关行整份回执 + x-permission: merchant:order:edit + parameters: [{$ref: '#/components/parameters/DocumentId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {$ref: '#/components/requestBodies/ActionBody'} + responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} + /api/merchant/v1/customs/documents/{documentId}/receipt/reject: + post: + summary: 客户打回退税报关行回执 + x-permission: merchant:order:edit + parameters: [{$ref: '#/components/parameters/DocumentId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {$ref: '#/components/requestBodies/ActionBody'} + responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches: post: summary: File导出中心按loadingPlanId分页获取已完成装柜买单实际行 @@ -111,6 +213,8 @@ components: PlanId: {name: planId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}} OrderId: {name: orderId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}} DocumentId: {name: documentId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}} + BatchId: {name: batchId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}} + ResultId: {name: resultId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}} InboundIds: {name: inboundIds, in: query, required: true, schema: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}} requestBodies: @@ -119,17 +223,35 @@ components: responses: ContainerResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerEnvelope'}}}} DocumentResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}} + ReceiptActionListResponse: {description: 回执确认与打回历史, content: {application/json: {schema: {$ref: '#/components/schemas/ReceiptActionListEnvelope'}}}} BinaryResponse: {description: 文件二进制, headers: {Content-Disposition: {schema: {type: string}}, Cache-Control: {schema: {type: string, example: no-store}}}, content: {application/octet-stream: {schema: {type: string, format: binary}}}} + DeclarationBatchResponse: {description: 正式申报批次, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchEnvelope'}}}} + DeclarationBatchListResponse: {description: 正式申报批次列表, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchListEnvelope'}}}} + ClearanceResultResponse: {description: 海关结果, content: {application/json: {schema: {$ref: '#/components/schemas/ClearanceResultEnvelope'}}}} + ClearanceResultListResponse: {description: 海关结果列表, content: {application/json: {schema: {$ref: '#/components/schemas/ClearanceResultListEnvelope'}}}} schemas: LongId: {type: string, pattern: '^[1-9][0-9]*$', description: Long ID序列化为字符串} ApiEnvelope: {type: object, required: [code, message, timestamp], properties: {code: {type: string}, message: {type: string}, requestId: {type: string, nullable: true}, traceId: {type: string, nullable: true}, timestamp: {type: string, format: date-time}}} ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]} DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]} ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}} + DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, batchNo: {type: string, maxLength: 64, nullable: true}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}} + ClearanceResultRequest: {type: object, required: [expectedVersion, resultType, occurredAt], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, occurredAt: {type: string, format: date-time}, resolution: {type: string, maxLength: 1000, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, resolved: {type: boolean}}} + ClearanceResolveRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resolution: {type: string, maxLength: 1000, nullable: true}}} ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}} FileView: {type: object, required: [fileId, fileName, fileHash, sizeBytes, version, uploadedAt], properties: {fileId: {$ref: '#/components/schemas/LongId'}, fileName: {type: string}, fileHash: {type: string}, contentType: {type: string, nullable: true}, sizeBytes: {type: integer, format: int64}, version: {type: integer}, uploadedAt: {type: string, format: date-time}}} InboundView: {type: object, required: [inboundId, orderId, orderNo, inboundCode, tenantId, customerName, taxMode, covered], properties: {inboundId: {$ref: '#/components/schemas/LongId'}, orderId: {$ref: '#/components/schemas/LongId'}, orderNo: {type: string}, inboundCode: {type: string}, tenantId: {$ref: '#/components/schemas/LongId'}, customerName: {type: string}, taxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION]}, covered: {type: boolean}}} - DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, completion: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true}}} - ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, hasActualCargo, version, taxRefundCount, coveredTaxRefundCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, hasActualCargo: {type: boolean, description: 当前完成装柜计划是否仍有实际装柜货物;false时查询成功返回空明细且无需发起报关}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, completedAt: {type: string, format: date-time, nullable: true}}} + ReceiptVersionView: {type: object, required: [version, file, createdAt], properties: {version: {type: integer}, file: {$ref: '#/components/schemas/FileView'}, operatorName: {type: string, nullable: true}, createdAt: {type: string, format: date-time}}} + ReceiptActionView: {type: object, required: [id, receiptVersion, action, createdAt], properties: {id: {$ref: '#/components/schemas/LongId'}, receiptVersion: {type: integer, minimum: 1}, action: {type: string, enum: [CONFIRM, REJECT]}, reason: {type: string, nullable: true}, operatorName: {type: string, nullable: true}, createdAt: {type: string, format: date-time}}} + BrokerSubmissionView: {type: object, required: [id, customsContainerId, loadingPlanId, batchNo, status, sourceVersion, sentAt, version], properties: {id: {$ref: '#/components/schemas/LongId'}, customsContainerId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, batchNo: {type: string}, status: {type: string, enum: [SENT]}, sourceVersion: {type: integer}, sentByName: {type: string, nullable: true}, sentAt: {type: string, format: date-time}, version: {type: integer}}} + DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen, receiptHistory, confirmationHistory], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, receipt: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 当前报关行回执文件}, receiptStatus: {type: string, enum: [PENDING, CONFIRMED, REJECTED], nullable: true}, receiptRejectReason: {type: string, nullable: true}, finalDocument: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 真实报关完税文件}, receiptHistory: {type: array, items: {$ref: '#/components/schemas/ReceiptVersionView'}, description: 报关行回执历次上传记录,按版本升序}, confirmationHistory: {type: array, items: {$ref: '#/components/schemas/ReceiptActionView'}, description: 客户对各回执版本的确认与打回动作,按时间升序}}} + ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, hasActualCargo, version, taxRefundCount, coveredTaxRefundCount, actualBoxCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, hasActualCargo: {type: boolean, description: 当前完成装柜计划是否仍有实际装柜货物;false时查询成功返回空明细且无需发起报关}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, actualBoxCount: {type: integer, format: int64, minimum: 0, description: 当前视角可见的实际装箱数;部分装柜只展示该数量,线下处理后续}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, brokerSubmission: {allOf: [{$ref: '#/components/schemas/BrokerSubmissionView'}], nullable: true, description: 资料冻结后首次发送报关行的独立事实}, brokerSubmittedAt: {type: string, format: date-time, nullable: true, description: 已将客户确认后的回执发送给报关行执行的时间;不表示报关完成}, completedAt: {type: string, format: date-time, nullable: true}}} + DeclarationBatch: {type: object, required: [id, customsContainerId, loadingPlanId, status, sourceVersion, version, results], properties: {id: {$ref: '#/components/schemas/LongId'}, customsContainerId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, batchNo: {type: string}, status: {type: string, enum: [READY, SUBMITTED, ACKNOWLEDGED, RESULT_PENDING, CLOSED, CANCELLED]}, sourceVersion: {type: integer}, customsEntryNo: {type: string, nullable: true}, version: {type: integer}, submittedAt: {type: string, format: date-time, nullable: true}, submittedByName: {type: string, nullable: true}, acknowledgedAt: {type: string, format: date-time, nullable: true}, acknowledgedByName: {type: string, nullable: true}, remark: {type: string, nullable: true}, results: {type: array, items: {$ref: '#/components/schemas/ClearanceResult'}}}} + ClearanceResult: {type: object, required: [id, declarationBatchId, customsContainerId, resultType, resultStatus, occurredAt, recordedAt], properties: {id: {$ref: '#/components/schemas/LongId'}, declarationBatchId: {$ref: '#/components/schemas/LongId'}, customsContainerId: {$ref: '#/components/schemas/LongId'}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, resultStatus: {type: string, enum: [OPEN, RESOLVED, CANCELLED]}, customsEntryNo: {type: string, nullable: true}, occurredAt: {type: string, format: date-time}, resolution: {type: string, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, recordedBy: {$ref: '#/components/schemas/LongId'}, recordedByName: {type: string, nullable: true}, recordedAt: {type: string, format: date-time}, previousResultId: {$ref: '#/components/schemas/LongId', nullable: true}}} + DeclarationBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DeclarationBatch'}}}]} + DeclarationBatchListEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: array, items: {$ref: '#/components/schemas/DeclarationBatch'}}}}]} + ClearanceResultEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/ClearanceResult'}}}]} + ClearanceResultListEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: array, items: {$ref: '#/components/schemas/ClearanceResult'}}}}]} + ReceiptActionListEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: array, items: {$ref: '#/components/schemas/ReceiptActionView'}}}}]} ExportBatchRequest: {type: object, required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit], properties: {taskId: {type: string}, tenantId: {type: string, enum: ['0']}, appCode: {type: string, enum: [PLATFORM]}, requestedBy: {type: string}, dispatchVersion: {type: integer}, parameters: {type: object, required: [loadingPlanId], additionalProperties: false, properties: {loadingPlanId: {type: string}}}, cursor: {type: string, nullable: true}, snapshotToken: {type: string, nullable: true}, limit: {type: integer, minimum: 1, maximum: 500}}} ExportBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: object, properties: {schemaVersion: {type: integer}, schemaHash: {type: string}, snapshotToken: {type: string}, columns: {type: array, items: {type: object}}, rows: {type: array, items: {type: object}}, nextCursor: {type: string}, hasMore: {type: boolean}, totalRows: {type: integer, format: int64, nullable: true}}}}}]} diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 8ee870441..68c7e1349 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -154,6 +154,13 @@ paths: parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}} responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fees/manual-quote: + post: + summary: FBA价格未命中时人工确定头程单价 + description: 仅FBA且价格查询返回未命中时可用;按客户计费方式与选定测量重量、体积计算计费量,附加费为零。生成正式头程费用快照,后续复用确认与发送;需要 firstleg:inbound:confirm-send-fee 权限。 + parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ManualQuoteRequest'}}}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/confirm: post: summary: 确认最新费用快照 @@ -488,6 +495,14 @@ components: additionalAmount: {type: number, minimum: 0, multipleOf: 0.01} reason: {type: string, minLength: 1, maxLength: 500} toleranceConfirmed: {type: boolean, enum: [true], description: 前端展示上下界并由操作员二次确认后固定传true;服务端仍会重新校验范围与版本} + ManualQuoteRequest: + type: object + required: [selectedWeightKg, selectedVolumeCbm, unitPrice, reason] + properties: + selectedWeightKg: {type: number, exclusiveMinimum: true, minimum: 0} + selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0} + unitPrice: {type: number, exclusiveMinimum: true, minimum: 0} + reason: {type: string, minLength: 2, maxLength: 500} CalculateRequest: type: object properties: diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 5756c25c2..e4cbe949a 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -65,9 +65,9 @@ paths: '200': {$ref: '#/components/responses/PreloadDetailResponse'} /api/platform/v1/first-leg/loading-plans/{planId}/confirm: post: - summary: 原子确认整份预装计划 - description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、稳定 bridge 完整、真实订舱已关联并为 RELEASED,且真实集装箱号已经补录。任一条件不满足均不写入,也不创建 PDA task。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。 - x-permission: platform:firstleg:preload:edit + summary: 原子确认预装并分配国内 PDA 作业员 + description: 以 loadingPlanId 为唯一范围,校验指定人员为启用的国内头程 PDA 作业员;确认预装前必须完成前置门禁且真实集装箱号已经补录,原子确认预装、创建任务并分配。未分配任务不向 PDA 展示;同一 Idempotency-Key 与相同请求精确重放原响应。 + x-permission: platform:firstleg:preload:edit, platform:firstleg:pda:assign parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -329,17 +329,18 @@ components: pdaTaskStatus: {type: string, nullable: true, description: PDA task 当前状态} confirmationStatus: {type: string, enum: [PENDING, PARTIALLY_CONFIRMED, CONFIRMED], description: 整份计划的预装确认状态} canConfirm: {type: boolean, description: 当前是否可调用计划级确认接口} - readyForPda: {type: boolean, description: PDA 创建前置条件是否全部满足,包含真实集装箱号已录入} + readyForPda: {type: boolean, description: PDA 创建前置条件是否全部满足,包含真实集装箱号已录入;只有计划可确认且真实集装箱号已经补录后才可创建 PDA 任务} blockingReasons: type: array description: 阻止计划确认的稳定原因码 items: {type: string, enum: [LOADING_PLAN_NOT_EDITING, PRELOAD_LINK_MISSING, CONTAINER_NO_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]} ConfirmPreloadRequest: type: object - required: [expectedVersion, operationReason] + required: [expectedVersion, assigneeId] additionalProperties: false properties: expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前装柜计划 version} + assigneeId: {$ref: '#/components/schemas/LongId'} PreloadConfirmation: type: object required: [planId, containerBookingId, confirmationStatus, version, readiness] diff --git a/firstleg/src/main/resources/openapi/sailing-calendar-v1.yaml b/firstleg/src/main/resources/openapi/sailing-calendar-v1.yaml new file mode 100644 index 000000000..5f5cd274d --- /dev/null +++ b/firstleg/src/main/resources/openapi/sailing-calendar-v1.yaml @@ -0,0 +1,138 @@ +openapi: 3.0.3 +info: + title: Firstleg Sailing Calendar API + version: v1 + description: 船期实例、固定周模板、渠道管理、收货事实汇总和已放舱订舱关联。 +paths: + /api/platform/v1/sailings/calendar: + get: + summary: 查询船期月历 + x-permission: platform:firstleg:sailing-calendar:list + parameters: + - {name: month, in: query, required: true, schema: {type: string, pattern: '^\\d{4}-\\d{2}$'}} + - {name: warehouseId, in: query, schema: {type: integer, format: int64}} + - {name: channelId, in: query, schema: {type: integer, format: int64}} + - {name: channelCode, in: query, schema: {type: string, maxLength: 64}} + - {name: departurePortCode, in: query, schema: {type: string, maxLength: 32}} + - {name: destinationPortCode, in: query, schema: {type: string, maxLength: 32}} + - {name: keyword, in: query, schema: {type: string, maxLength: 128}} + - {name: status, in: query, schema: {type: string}} + responses: {'200': {description: 月历和按船期汇总}} + /api/platform/v1/sailings: + post: + summary: 新增单次船期 + x-permission: platform:firstleg:sailing-calendar:create + responses: {'200': {description: OK}} + /api/platform/v1/sailings/{id}: + get: + summary: 查询船期详情 + x-permission: platform:firstleg:sailing-calendar:list + parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}] + responses: {'200': {description: OK}} + put: + summary: 编辑船期 + x-permission: platform:firstleg:sailing-calendar:edit + parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}] + responses: {'200': {description: OK}} + /api/platform/v1/sailings/{id}/history: + get: + summary: 查询船期变更历史 + x-permission: platform:firstleg:sailing-calendar:list + parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}] + responses: {'200': {description: OK}} + /api/platform/v1/sailings/{id}/cancel: + post: + summary: 取消船期 + x-permission: platform:firstleg:sailing-calendar:edit + responses: {'200': {description: OK}} + /api/platform/v1/sailings/{id}/copy: + post: + summary: 复制下一班船期 + x-permission: platform:firstleg:sailing-calendar:create + responses: {'200': {description: OK}} + /api/platform/v1/sailings/{id}/volume-summary: + get: + summary: 查询实收、预报和归班明细 + x-permission: platform:firstleg:sailing-calendar:list + responses: {'200': {description: OK}} + /api/platform/v1/sailings/{id}/booking-advice: + get: + summary: 查询只读订柜建议 + x-permission: platform:firstleg:sailing-calendar:list + responses: {'200': {description: OK}} + /api/platform/v1/sailings/{id}/booking-links: + post: + summary: 关联一条 RELEASED 海运订舱 + x-permission: platform:firstleg:sailing-booking:link + responses: {'200': {description: ACTIVE 关联结果}} + /api/platform/v1/sailing-channels: + get: + summary: 查询船期渠道 + x-permission: platform:firstleg:sailing-calendar:list + responses: {'200': {description: OK}} + post: + summary: 新增船期渠道 + x-permission: platform:firstleg:sailing-template:manage + responses: {'200': {description: OK}} + /api/platform/v1/sailing-channels/{id}: + put: + summary: 编辑或启停船期渠道 + x-permission: platform:firstleg:sailing-template:manage + responses: {'200': {description: OK}} + /api/platform/v1/sailing-templates: + get: + summary: 查询固定周模板 + x-permission: platform:firstleg:sailing-template:manage + responses: {'200': {description: OK}} + post: + summary: 新增固定周模板 + x-permission: platform:firstleg:sailing-template:manage + responses: {'200': {description: OK}} + /api/platform/v1/sailing-templates/{id}: + put: + summary: 编辑固定周模板 + x-permission: platform:firstleg:sailing-template:manage + responses: {'200': {description: OK}} + /api/platform/v1/sailing-templates/{id}/generation-preview: + post: + summary: 预览模板生成实例 + x-permission: platform:firstleg:sailing-template:manage + responses: {'200': {description: OK}} + /api/platform/v1/sailing-templates/{id}/generate: + post: + summary: 按模板生成实例 + x-permission: platform:firstleg:sailing-template:manage + responses: {'200': {description: OK}} + /api/platform/v1/sailings/{id}/exception-reassign: + post: + summary: 受限异常重新安排 + x-permission: platform:firstleg:sailing-volume:exception-reassign + responses: {'200': {description: OK}} + /api/platform/v1/sailing-assignments/exceptions: + get: + summary: 查询未安排和晚到待办 + x-permission: platform:firstleg:sailing-calendar:list + responses: {'200': {description: OK}} + /api/platform/v1/sailing-forecasts: + post: + summary: 新增未收货量预报 + x-permission: platform:firstleg:sailing-volume:forecast-manage + responses: {'200': {description: OK}} + /api/platform/v1/sailing-forecasts/{id}: + put: + summary: 修订未收货量预报 + x-permission: platform:firstleg:sailing-volume:forecast-manage + parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}] + responses: {'200': {description: 新版本预报}} + /api/platform/v1/sailing-forecasts/{id}/void: + post: + summary: 作废未收货量预报 + x-permission: platform:firstleg:sailing-volume:forecast-manage + parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}] + responses: {'200': {description: OK}} + /api/platform/v1/sailing-forecasts/{id}/bind-receipt: + post: + summary: 将实收批次绑定到预报批次 + x-permission: platform:firstleg:sailing-volume:forecast-manage + parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}] + responses: {'200': {description: OK}} 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 717cd6f6d..b989c459d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -25,6 +25,7 @@ class FbaPricingContractTest { + " 'firstleg:inbound:send-fee')"); assertPricingAuthority("adjustPublished", "hasAnyAuthority('firstleg:fba-pricing:adjust'," + " 'firstleg:inbound:confirm-send-fee')"); + assertPricingAuthority("manualQuote", "hasAuthority('firstleg:inbound:confirm-send-fee')"); } @Test @@ -60,6 +61,7 @@ class FbaPricingContractTest { .contains("/api/platform/v1/fba-pricing/customer-rules/{id}/default:") .contains("/api/platform/v1/fba-pricing/carrier-rates/versions:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:") + .contains("/api/platform/v1/inbounds/{inboundId}/fees/manual-quote:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/confirm:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/send:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/published:") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java index a6e9f0fe6..8d12aa16c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java @@ -26,6 +26,7 @@ class InboundFeeOpenApiContractTest { Map paths = map(document.get("paths")); assertThat(paths).containsKeys( "/api/platform/v1/inbounds/{inboundId}/fees/calculate", + "/api/platform/v1/inbounds/{inboundId}/fees/manual-quote", "/api/platform/v1/inbounds/{inboundId}/fees/confirm", "/api/platform/v1/inbounds/{inboundId}/fees/send", "/api/platform/v1/inbounds/{inboundId}/fees/latest", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index f1fb2e42f..a596d16a0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -33,8 +33,10 @@ class LoadingListControllerContractTest { authority("lifecycle", "platform:firstleg:preload:list", AuthenticatedUser.class, long.class, long.class); authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class, long.class, long.class, String.class, TransportUpdateRequest.class); - authority("confirmPreload", "platform:firstleg:preload:edit", AuthenticatedUser.class, - long.class, String.class, ConfirmPreloadRequest.class); + Method confirm = PlatformLoadingListController.class.getDeclaredMethod("confirmPreload", + AuthenticatedUser.class, long.class, String.class, ConfirmPreloadRequest.class); + assertThat(confirm.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:firstleg:preload:edit') and hasAuthority('platform:firstleg:pda:assign')"); authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class, ConfirmedQuery.class); authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java index 5cd227593..3a2d1f28c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java @@ -17,8 +17,15 @@ class ContainerCustomsControllerContractTest { .containsExactly("/api/platform/v1/customs/loading-plans"); assertPermission(PlatformContainerCustomsController.class,"detail","platform:firstleg:customs:list"); assertPermission(PlatformContainerCustomsController.class,"start","platform:firstleg:customs:manage"); + assertPermission(PlatformContainerCustomsController.class,"sendInitialToBroker","platform:firstleg:customs:manage"); assertPermission(PlatformContainerCustomsController.class,"uploadCompletion","platform:firstleg:customs:manage"); + assertPermission(PlatformContainerCustomsController.class,"receiptActions","platform:firstleg:customs:list"); assertPermission(PlatformContainerCustomsController.class,"completeAllBuy","platform:firstleg:customs:manage"); + assertPermission(PlatformContainerCustomsController.class,"declarationBatches","platform:firstleg:customs:list"); + assertPermission(PlatformContainerCustomsController.class,"createDeclarationBatch","platform:firstleg:customs:manage"); + assertPermission(PlatformContainerCustomsController.class,"submitDeclarationBatch","platform:firstleg:customs:manage"); + assertPermission(PlatformContainerCustomsController.class,"addClearanceResult","platform:firstleg:customs:manage"); + assertPermission(PlatformContainerCustomsController.class,"resolveClearanceResult","platform:firstleg:customs:manage"); } @Test void merchantUsesOrderViewAndEditWithoutTrustingTenantInput() { assertThat(MerchantContainerCustomsController.class.getAnnotation(RequestMapping.class).value()) @@ -28,11 +35,14 @@ class ContainerCustomsControllerContractTest { assertPermission(MerchantContainerCustomsController.class,"replace","merchant:order:edit"); assertPermission(MerchantContainerCustomsController.class,"scope","merchant:order:edit"); assertPermission(MerchantContainerCustomsController.class,"delete","merchant:order:edit"); + assertPermission(MerchantContainerCustomsController.class,"clearanceResults","merchant:order:view"); + assertPermission(MerchantContainerCustomsController.class,"receiptActions","merchant:order:view"); } @Test void openApiPublishesEveryPathParameterAndGeneratableDtos()throws Exception{ String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8); new Yaml().load(yaml); - assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:") + assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:", + "DeclarationBatch:","BrokerSubmissionView:","ClearanceResult:","ReceiptActionView:","confirmationHistory","brokerSubmission","send-to-broker","declaration-batches","clearance-results") .contains("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:","hasActualCargo:","nullable: true") .contains("LOADING_LIST_BUY_DECLARATION_ITEMS") .doesNotContain("/cases/","CUSTOMS_DECLARATION"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java index cc115e3fd..1e1a59fab 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java @@ -45,9 +45,33 @@ class ContainerCustomsMapperContractTest { @Test void completionQueriesExplicitlyMapEveryRecordComponentIncludingVersion() throws Exception { assertRecordProjection("completion", ContainerCustomsMapper.CompletionRow.class, long.class); assertRecordProjection("lockCompletion", ContainerCustomsMapper.CompletionRow.class, long.class); + assertRecordProjectionWithoutVersion("completionVersions", ContainerCustomsMapper.CompletionVersionRow.class, long.class); + assertRecordProjectionWithoutVersion("receiptActionHistory", ContainerCustomsMapper.ReceiptActionRow.class, long.class, Long.class); + } + + @Test void declarationAndClearanceQueriesMapExplicitHistoricalFacts() throws Exception { + assertRecordProjection("declarationBatches", ContainerCustomsMapper.DeclarationBatchRow.class, long.class); + assertRecordProjection("declarationBatch", ContainerCustomsMapper.DeclarationBatchRow.class, long.class); + assertRecordProjection("lockDeclarationBatch", ContainerCustomsMapper.DeclarationBatchRow.class, long.class); + assertRecordProjectionWithoutVersion("clearanceResults", ContainerCustomsMapper.ClearanceResultRow.class, long.class); + assertRecordProjectionWithoutVersion("clearanceResult", ContainerCustomsMapper.ClearanceResultRow.class, long.class); + assertRecordProjectionWithoutVersion("lockClearanceResult", ContainerCustomsMapper.ClearanceResultRow.class, long.class); + } + + @Test void brokerSubmissionQueriesMapIndependentFirstSendFact() throws Exception { + assertRecordProjection("brokerSubmission", ContainerCustomsMapper.BrokerSubmissionRow.class, long.class); + assertRecordProjection("lockBrokerSubmission", ContainerCustomsMapper.BrokerSubmissionRow.class, long.class); } private static void assertRecordProjection(String method,Class recordType,Class... parameterTypes) throws Exception { + assertProjection(method, recordType, true, parameterTypes); + } + + private static void assertRecordProjectionWithoutVersion(String method,Class recordType,Class... parameterTypes) throws Exception { + assertProjection(method, recordType, false, parameterTypes); + } + + private static void assertProjection(String method,Class recordType,boolean requireVersion,Class... parameterTypes) throws Exception { String value=sql(method,parameterTypes).replaceAll("\\s+"," ").trim(); assertThat(value).doesNotContainIgnoringCase("SELECT *"); String projection=value.substring(value.indexOf("SELECT ")+7,value.indexOf(" FROM ")); @@ -56,7 +80,8 @@ class ContainerCustomsMapperContractTest { .map(column->column.substring(column.lastIndexOf(' ')+1)) .toList(); List components=Arrays.stream(recordType.getRecordComponents()).map(RecordComponent::getName).toList(); - assertThat(aliases).containsExactlyElementsOf(components).contains("version"); + assertThat(aliases).containsExactlyElementsOf(components); + if (requireVersion) assertThat(aliases).contains("version"); } private static String sql(String name,Class... types) throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java index 181f8ea68..279227547 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -88,11 +88,13 @@ class ContainerCustomsServiceTest { when(mapper.completedPlanForOrder(7L,21L)).thenReturn(9L); when(mapper.planInbounds(9L)).thenReturn(List.of(row)); when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,2)); + when(mapper.actualBoxCount(9L,7L,"TAX_REFUND")).thenReturn(3L); var view=service.merchantOrderView(7L,21L); assertThat(view.loadingPlanId()).isEqualTo(9L); assertThat(view.taxRefundCount()).isEqualTo(1); + assertThat(view.actualBoxCount()).isEqualTo(3L); assertThat(view.inbounds()).extracting(value->value.orderId()).containsExactly(21L); } @@ -148,6 +150,61 @@ class ContainerCustomsServiceTest { verify(mapper,never()).freezeScopes(anyLong()); } + @Test void initialBrokerSendCreatesIndependentFrozenSnapshotBatch() throws Exception { + var inbound=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); + var declaring=container(ContainerCustomsService.DECLARING,3); + var source=new ContainerCustomsMapper.DocumentRow(31L,2L,7L,10L,"source.bin","a".repeat(64), + "application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1); + var submission=new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin", + java.time.LocalDateTime.now(),1); + when(mapper.claimCommand(anyLong(),eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyString())).thenReturn(1); + when(mapper.lockContainer(9L)).thenReturn(declaring); + when(mapper.taxRefundCount(9L)).thenReturn(1); + when(mapper.brokerSubmission(2L)).thenReturn(null,submission); + when(mapper.documents(2L,null)).thenReturn(List.of(source)); + when(mapper.frozenScopes(31L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(31L,11L,21L,"I"))); + when(objectMapper.writeValueAsString(any())).thenReturn("[]"); + when(mapper.insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR-LP9-01"),eq(3),eq("[]"),eq(5L),eq("admin"))).thenReturn(1); + when(mapper.bumpVersionAfterBrokerSubmission(2L,3)).thenReturn(1); + when(mapper.insertHistory(anyLong(),eq(2L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER"),eq(ContainerCustomsService.DECLARING),eq(ContainerCustomsService.DECLARING),isNull(),eq(5L),eq("admin"))).thenReturn(1); + when(mapper.completeCommand(eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyLong())).thenReturn(1); + when(mapper.planInbounds(9L)).thenReturn(List.of(inbound)); + when(mapper.plan(9L)).thenReturn(new ContainerCustomsMapper.PlanRow(9L,"LP9",8L,"COMPLETED","CAB","C")); + when(mapper.container(9L)).thenReturn(declaring); + when(mapper.actualBoxCount(9L,null,null)).thenReturn(0L); + when(mapper.finalDocument(31L)).thenReturn(null); + when(mapper.completion(31L)).thenReturn(null); + + var result=service.sendInitialToBroker(9L,5L,"admin","send-key",new ActionRequest(3,true,null)); + + assertThat(result.brokerSubmission()).isNotNull(); + assertThat(result.brokerSubmission().batchNo()).isEqualTo("BR-LP9-01"); + assertThat(result.brokerSubmission().sourceVersion()).isEqualTo(3); + verify(mapper).insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR-LP9-01"),eq(3),eq("[]"),eq(5L),eq("admin")); + } + + @Test void initialBrokerSendRejectsStaleAggregateVersion(){ + var declaring=container(ContainerCustomsService.DECLARING,4); + when(mapper.claimCommand(anyLong(),eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("stale-key"),anyString())).thenReturn(1); + when(mapper.lockContainer(9L)).thenReturn(declaring); + + assertThatThrownBy(()->service.sendInitialToBroker(9L,5L,"admin","stale-key",new ActionRequest(3,true,null))) + .isInstanceOf(BizException.class).hasMessageContaining("其他人修改"); + verify(mapper,never()).insertBrokerSubmission(anyLong(),anyLong(),anyLong(),anyString(),anyInt(),anyString(),anyLong(),anyString()); + } + + @Test void receiptUploadRejectsWhenInitialBrokerSubmissionIsMissing(){ + var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3); + when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("before-send"),anyString())).thenReturn(1); + when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(container); + when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.brokerSubmission(2L)).thenReturn(null); + + assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","before-send",0, + new MockMultipartFile("file","receipt.pdf","application/pdf",new byte[]{1}))) + .isInstanceOf(BizException.class).hasMessageContaining("首次发送报关行资料"); + verifyNoInteractions(files); + } + @Test void declaringContainerFreezesUploadReplacementScopeAndDeletionWithoutDrift(){ var inbound=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); var declaring=container(ContainerCustomsService.DECLARING,3); @@ -241,16 +298,25 @@ class ContainerCustomsServiceTest { verify(mapper,times(1)).completeCommand(5L,"START_CONTAINER_CUSTOMS_9","start-key",2L); } - @Test void lastCompletionRequiresConfirmationBeforeCallingFile(){ + @Test void receiptUploadDoesNotRequireFinalConfirmation(){ var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3); when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("k"),anyString())).thenReturn(1); when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(container); + when(mapper.brokerSubmission(2L)).thenReturn(brokerSubmission()); when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.lockCompletion(1L)).thenReturn(null); - when(mapper.missingCompletionCount(2L)).thenReturn(1); + when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),any())) + .thenReturn(com.baiyue.common.core.api.ApiResponse.success(new CustomsFileClient.Metadata(99L,7L, + "LOADING_LIST_CUSTOMS_RECEIPT","9","result.bin","b".repeat(64),"application/octet-stream",1L))); + when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(),eq(1L),eq(5L),eq("admin"))).thenReturn(1); + when(mapper.insertCompletionVersion(anyLong(),anyLong(),eq(1),eq(99L),anyString(),anyString(),anyString(),eq(1L),eq(5L),eq("admin"))).thenReturn(1); + when(mapper.frozenScopes(1L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(1L,11L,21L,"I"))); + when(mapper.completion(1L)).thenReturn(null);when(mapper.containerById(2L)).thenReturn(container); + when(mapper.completeCommand(anyLong(),eq("UPLOAD_COMPLETION_1"),eq("k"),anyLong())).thenReturn(1); var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1}); - assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","k",0,false,file)) - .isInstanceOf(BizException.class).hasMessageContaining("最后一份"); - verifyNoInteractions(files); + var result=service.uploadCompletion(1L,5L,"admin","k",0,file); + assertThat(result).isNotNull(); + verify(files).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file)); + verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString()); } @Test void completedContainerLocksCompletionReplacement(){ @@ -259,21 +325,32 @@ class ContainerCustomsServiceTest { when(mapper.lockContainerById(2L)).thenReturn(container(ContainerCustomsService.COMPLETED,4)); when(mapper.lockDocument(1L)).thenReturn(source); var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1}); - assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","k",0,false,file)) + assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","k",0,file)) .isInstanceOf(BizException.class).hasMessageContaining("不在报关中"); verifyNoInteractions(files); } - @Test void finalCompletionReplaySucceedsAfterContainerCompletedAndRejectsChangedPayload(){ + @Test void receiptCannotBeReplacedAfterBrokerSubmission(){ + var source=document(1L);when(mapper.document(1L)).thenReturn(source); + when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("broker-sent-key"),anyString())).thenReturn(1); + when(mapper.lockContainerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,4)); + when(mapper.lockDocument(1L)).thenReturn(source); + when(mapper.brokerSubmittedAt(2L)).thenReturn(java.time.LocalDateTime.now()); + var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1}); + assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","broker-sent-key",0,file)) + .isInstanceOf(BizException.class).hasMessageContaining("不可再修改回执"); + verifyNoInteractions(files); + } + + @Test void receiptReplaySucceedsAndDoesNotCompleteContainer(){ var source=document(1L); var declaring=container(ContainerCustomsService.DECLARING,3); - var completed=container(ContainerCustomsService.COMPLETED,4); var containerState=new AtomicReference<>(declaring); var completionState=new AtomicReference(); var commandState=new AtomicReference(); var file=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{1}); var changedFile=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{2}); - var metadata=new CustomsFileClient.Metadata(99L,7L,"LOADING_LIST_CUSTOMS_RESULT","9","result.pdf", + var metadata=new CustomsFileClient.Metadata(99L,7L,"LOADING_LIST_CUSTOMS_RECEIPT","9","result.pdf", "b".repeat(64),"application/pdf",1L); when(mapper.document(1L)).thenReturn(source); when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("completion-key"),anyString())) @@ -286,17 +363,17 @@ class ContainerCustomsServiceTest { when(mapper.command(5L,"UPLOAD_COMPLETION_1","completion-key")).thenAnswer(invocation->commandState.get()); when(mapper.lockContainerById(2L)).thenAnswer(invocation->containerState.get()); when(mapper.lockDocument(1L)).thenReturn(source); + when(mapper.brokerSubmission(2L)).thenReturn(brokerSubmission()); when(mapper.lockCompletion(1L)).thenAnswer(invocation->completionState.get()); - when(mapper.missingCompletionCount(2L)).thenReturn(1); - when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file))) + when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file))) .thenReturn(com.baiyue.common.core.api.ApiResponse.success(metadata)); when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),eq("result.pdf"),eq("b".repeat(64)), eq("application/pdf"),eq(1L),eq(5L),eq("admin"))).thenAnswer(invocation->{ completionState.set(new ContainerCustomsMapper.CompletionRow(invocation.getArgument(0),1L,99L, - "result.pdf","b".repeat(64),"application/pdf",1L,5L,"admin",java.time.LocalDateTime.now(),1)); + "result.pdf","b".repeat(64),"application/pdf",1L,5L,"admin",java.time.LocalDateTime.now(),1, + "PENDING",null)); return 1; }); - when(mapper.complete(2L,3,5L,"admin")).thenAnswer(invocation->{containerState.set(completed);return 1;}); when(mapper.completeCommand(eq(5L),eq("UPLOAD_COMPLETION_1"),eq("completion-key"),anyLong())) .thenAnswer(invocation->{ var current=commandState.get(); @@ -308,26 +385,26 @@ class ContainerCustomsServiceTest { when(mapper.completion(1L)).thenAnswer(invocation->completionState.get()); when(mapper.containerById(2L)).thenAnswer(invocation->containerState.get()); - var first=service.uploadCompletion(1L,5L,"admin","completion-key",0,true,file); - var replay=service.uploadCompletion(1L,5L,"admin","completion-key",0,true,file); + var first=service.uploadCompletion(1L,5L,"admin","completion-key",0,file); + var replay=service.uploadCompletion(1L,5L,"admin","completion-key",0,file); - assertThat(first.completion().fileId()).isEqualTo(99L); - assertThat(replay.completion().fileId()).isEqualTo(99L); - assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","completion-key",0,true,changedFile)) + assertThat(first.receipt().fileId()).isEqualTo(99L); + assertThat(replay.receipt().fileId()).isEqualTo(99L); + assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","completion-key",0,changedFile)) .isInstanceOfSatisfying(BizException.class, error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)); - verify(files,times(1)).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file)); + verify(files,times(1)).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file)); verify(mapper,times(1)).lockContainerById(2L); verify(mapper,times(1)).insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(), eq(1L),eq(5L),eq("admin")); - verify(mapper,times(1)).complete(2L,3,5L,"admin"); + verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString()); } @Test void merchantCannotDownloadCompletionBeforeWholeContainerCompletes(){ var source=document(1L);when(mapper.document(1L)).thenReturn(source); when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3)); assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class) - .hasMessageContaining("完成文件不存在"); + .hasMessageContaining("报关行回执不存在"); verifyNoInteractions(files); } @@ -362,6 +439,7 @@ class ContainerCustomsServiceTest { } private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);} + private static ContainerCustomsMapper.BrokerSubmissionRow brokerSubmission(){return new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin",java.time.LocalDateTime.now(),1);} private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);} private void assertMerchantOrderNotFound(long tenantId,long orderId){ assertThatThrownBy(()->service.merchantOrderView(tenantId,orderId)) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java index 50edf3a3d..465582f4e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java @@ -21,6 +21,9 @@ class CustomsSqlContractTest { "fl_customs_frozen_document_inbound", "fl_customs_completion_document", "fl_customs_completion_document_version", + "fl_customs_final_document", + "fl_customs_declaration_batch", + "fl_customs_clearance_result", "fl_customs_container_history", "fl_customs_command"); private static final Pattern CREATE_TABLE = Pattern.compile( @@ -38,6 +41,8 @@ class CustomsSqlContractTest { .contains("fl_customs_customer_document_version", "scope_snapshot_json JSON NOT NULL") .contains("fl_customs_frozen_document_inbound", "fl_customs_completion_document_version") .contains("UNIQUE KEY uk_customs_completion_source(source_document_id)") + .contains("fl_customs_declaration_batch", "fl_customs_clearance_result", "customs_entry_no") + .contains("INSPECTION", "SUPPLEMENT_REQUIRED", "RELEASED", "DETAINED") .contains("fl_customs_command", "uk_customs_command(actor_id,action,idempotency_key)") .doesNotContain("platform:firstleg:loading-list:export"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingAuthManagerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingAuthManagerTest.java new file mode 100644 index 000000000..6c3831eaa --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingAuthManagerTest.java @@ -0,0 +1,54 @@ +package com.baiyue.firstleg.lingxing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class LingxingAuthManagerTest { + private final LingxingProviderConfig config = new LingxingProviderConfig( + "LINGXING_01", "http://127.0.0.1:9222", true, 172800, 300); + + @Test + void concurrentRequestsUseOneBrowserRefresh() { + LingxingBrowserSessionReader browser = mock(LingxingBrowserSessionReader.class); + LingxingAuthState state = new LingxingAuthState("token", "sid=1", Long.MAX_VALUE, "ua", "browser_session"); + when(browser.read(config, "https://oms.xlwms.com")).thenAnswer(invocation -> { + Thread.sleep(40); return state; + }); + LingxingAuthManager manager = new LingxingAuthManager(browser, Clock.systemUTC(), Runnable::run); + + CompletableFuture first = manager.getAuthState(config, "https://oms.xlwms.com"); + CompletableFuture second = manager.getAuthState(config, "https://oms.xlwms.com"); + + assertThat(first.join()).isSameAs(state); + assertThat(second.join()).isSameAs(state); + verify(browser).read(config, "https://oms.xlwms.com"); + } + + @Test + void secondaryVerificationBlocksRefreshUntilCooldownExpires() { + LingxingBrowserSessionReader browser = mock(LingxingBrowserSessionReader.class); + Clock clock = Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"), ZoneOffset.UTC); + LingxingAuthManager manager = new LingxingAuthManager(browser, clock, Runnable::run); + manager.markSecondaryVerificationRequired(config.providerKey(), 1800); + + assertThatThrownBy(() -> manager.getAuthState(config, "https://oms.xlwms.com").join()) + .hasRootCauseInstanceOf(LingxingSecondaryVerificationException.class); + } + + @Test + void jwtExpiryUsesSafetyWindow() { + String token = "eyJhbGciOiJub25lIn0.eyJleHAiOjE4OTAwMDAwMDB9.signature"; + long expected = 1890000000000L - 300_000L; + assertThat(LingxingBrowserSessionReader.expiresAt(token, config)).isEqualTo(expected); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReaderTest.java b/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReaderTest.java new file mode 100644 index 000000000..5764f8778 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReaderTest.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.lingxing; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import java.net.URI; +import org.junit.jupiter.api.Test; + +class LingxingBrowserSessionReaderTest { + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void rewritesDiscoverySocketToConfiguredEndpointAndKeepsTargetPath() { + URI result = LingxingBrowserSessionReader.rewriteWebSocketEndpoint( + URI.create("ws://127.0.0.1:9223/devtools/page/abc"), + URI.create("http://127.0.0.1:19222")); + + assertThat(result.toString()).isEqualTo("ws://127.0.0.1:19222/devtools/page/abc"); + + assertThat(LingxingBrowserSessionReader.rewriteWebSocketEndpoint( + URI.create("ws://127.0.0.1:9223/devtools/page/abc"), URI.create("https://cdp.example.test"))) + .hasScheme("wss"); + } + + @Test + void selectsOnlyOmsPageWithExactOriginAndPrefersLoggedInPage() throws Exception { + ArrayNode pages = (ArrayNode) mapper.readTree(""" + [ + {"type":"other","url":"https://oms.xlwms.com/app","webSocketDebuggerUrl":"ws://x/other"}, + {"type":"page","url":"https://oms.xlwms.com.evil.test/app","webSocketDebuggerUrl":"ws://x/evil"}, + {"type":"page","url":"https://oms.xlwms.com/login","webSocketDebuggerUrl":"ws://x/login"}, + {"type":"page","url":"https://oms.xlwms.com/app","webSocketDebuggerUrl":"ws://x/app"} + ] + """); + + assertThat(LingxingBrowserSessionReader.selectPage(pages, "https://oms.xlwms.com").path("url").asText()) + .isEqualTo("https://oms.xlwms.com/app"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingTrackKeyVectorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingTrackKeyVectorTest.java new file mode 100644 index 000000000..60c715482 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingTrackKeyVectorTest.java @@ -0,0 +1,25 @@ +package com.baiyue.firstleg.lingxing; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class LingxingTrackKeyVectorTest { + @Test + void acceptsAStandard32ByteKeyAfterOneBase64Decode() { + assertThat(LingxingDirectClient.buildTrackKey( + "POST", "{}", "MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=")) + .isEqualTo("v2:0cA9d60u8HVKLHZRgbSCZJDOvec7bNGfg1tvJIFRIh4="); + } + + @Test + void matchesNodeTrackKeyV2VectorWhenSecretIsProvided() { + String secret = System.getenv("LINGXING_TRACK_KEY_V2_SECRET_BASE64"); + assumeTrue(secret != null && !secret.isBlank(), + "Set LINGXING_TRACK_KEY_V2_SECRET_BASE64 to run the protected vector test"); + String body = "{\"businessType\":\"oms\",\"deviceFingerprint\":\"12345678\",\"deviceInfo\":\"UA | 1920x1080 | Asia/Shanghai\",\"loginAccount\":\"demo\",\"loginFlowId\":\"00112233445566778899aabbccddeeff\",\"password\":\"secret\"}"; + assertThat(LingxingDirectClient.buildTrackKey("POST", body, secret)) + .isEqualTo("v2:IbVx50WEXAxeGsc/66LjqC0WSxT2ZF0K4V4JYU/LkYw="); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerRepositoryLoadingTimeTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerRepositoryLoadingTimeTest.java index 1090a9a42..2cd56dd02 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerRepositoryLoadingTimeTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerRepositoryLoadingTimeTest.java @@ -2,6 +2,8 @@ package com.baiyue.firstleg.repository; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; +import com.baiyue.firstleg.portschedule.mapper.PortScheduleMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -16,6 +18,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class ContainerRepositoryLoadingTimeTest { @Mock private ContainerBookingMapper mapper; + @Mock private PortScheduleMapper schedules; @InjectMocks private ContainerRepository repository; @Test @@ -44,4 +47,30 @@ class ContainerRepositoryLoadingTimeTest { assertThat(result.getLoadingAt()).isEqualTo(plannedAt); } + + @Test + void projectsLatestPortScheduleWithoutChangingContainerIdentity() { + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setId(8L); + booking.setCustomerId(9L); + booking.setOceanBookingId(10L); + booking.setContainerNo("TSTU0000001"); + booking.setVesselName("OLD-VESSEL"); + booking.setVoyageNo("OLD-V01"); + PortScheduleEntity schedule = new PortScheduleEntity(); + schedule.setVesselName("NEW-VESSEL"); + schedule.setVoyageNo("NEW-V01"); + schedule.setEtdAt(LocalDateTime.of(2026, 10, 7, 8, 0)); + schedule.setEtaAt(LocalDateTime.of(2026, 10, 27, 8, 0)); + when(mapper.selectBooking(8L)).thenReturn(booking); + when(schedules.latestForDispatch(9L, 10L, 8L)).thenReturn(schedule); + + ContainerBookingEntity result = repository.find(8L); + + assertThat(result.getContainerNo()).isEqualTo("TSTU0000001"); + assertThat(result.getVesselName()).isEqualTo("NEW-VESSEL"); + assertThat(result.getVoyageNo()).isEqualTo("NEW-V01"); + assertThat(result.getEstimatedSailingAt()).isEqualTo(schedule.getEtdAt()); + assertThat(result.getEstimatedPortArrivalAt()).isEqualTo(schedule.getEtaAt()); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java index 33025c9b1..f1b6eabe3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java @@ -35,7 +35,7 @@ class LoadingPickPdaTaskSqlContractTest { } @Test - void pendingTasksAreLimitedToUnassignedOrCurrentOperatorWithoutCustomerInput() throws Exception { + void pendingTasksAreLimitedToAssignedOperatorWithoutCustomerInput() throws Exception { String sql = LoadingPickTaskMapper.class .getDeclaredMethod("selectPendingForOperator", long.class, long.class, String.class) .getAnnotation(Select.class).value()[0]; @@ -54,7 +54,8 @@ class LoadingPickPdaTaskSqlContractTest { .contains("pick_item.item_status IN ('PENDING', 'PICKING')") .contains("THEN 'AWAITING_REVIEW'") .contains("ELSE 'IN_PROGRESS' END AS workflowStatus") - .contains("assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}") + .contains("AND assignment.assignee_id = #{operatorId}") + .doesNotContain("assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}") .contains("booking.warehouse_id AS warehouseId") .doesNotContain("tenantId"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/MojiaSkuSyncRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/MojiaSkuSyncRepositoryTest.java new file mode 100644 index 000000000..faeaece6c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/MojiaSkuSyncRepositoryTest.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.firstleg.entity.MojiaMeasurementReceiptEntity; +import com.baiyue.firstleg.mapper.MojiaMeasurementReceiptMapper; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(MojiaSkuSyncRepository.class) +class MojiaSkuSyncRepositoryTest { + @MockBean private MojiaMeasurementReceiptMapper mapper; + @Autowired private MojiaSkuSyncRepository repository; + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void blankSkuCodeDoesNotTrimNullOrAddFilter() { + when(mapper.selectPage(any(Page.class), any(Wrapper.class))) + .thenReturn(new Page(1, 20)); + + repository.page(null, null, 1, 20); + + verify(mapper).selectPage(any(Page.class), any(Wrapper.class)); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 4738eede2..9d0fa239d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -20,6 +20,13 @@ class Phase3FinalUnifiedSqlContractTest { .contains("CREATE TABLE fl_truck_appointment") .contains("CREATE TABLE fl_outbound_dispatch") .contains("CREATE TABLE fl_customs_container") + .contains("CREATE TABLE fl_customs_declaration_batch") + .contains("CREATE TABLE fl_customs_clearance_result") + .contains("customer_confirm_status varchar(20) NOT NULL DEFAULT 'PENDING'") + .contains("customer_confirmed_at datetime(3) DEFAULT NULL") + .contains("customer_reject_reason varchar(500) DEFAULT NULL") + .contains("UNIQUE KEY uk_customs_declaration_batch_container(customs_container_id, id)") + .contains("KEY idx_customs_clearance_result_batch(declaration_batch_id,occurred_at,id)") .contains("CREATE TABLE fl_port_schedule") .contains("CREATE TABLE fl_carrier_schedule_query") .contains("CREATE TABLE fl_terminal_drayage") @@ -73,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest { @Test void initializationPassIsUniqueAndFinal() throws Exception { String sql = sql(); - String pass = "SELECT 'PASS' AS initialization_status, 'CN' AS region, 210 AS initialized_tables;"; + String pass = "SELECT 'PASS' AS initialization_status, 'CN' AS region, 223 AS initialized_tables;"; assertThat(count(sql, pass)).isEqualTo(1); assertThat(sql.trim()).endsWith(pass); assertThat(sql) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/controller/SailingCalendarOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/controller/SailingCalendarOpenApiContractTest.java new file mode 100644 index 000000000..b253e4ae6 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/controller/SailingCalendarOpenApiContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.firstleg.sailingcalendar.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class SailingCalendarOpenApiContractTest { + @Test + void publishesEditAndHistoryOnTheControllerPaths() throws Exception { + String yaml = new ClassPathResource("openapi/sailing-calendar-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + String editPath = "/api/platform/v1/sailings/{id}:"; + String historyPath = "/api/platform/v1/sailings/{id}/history:"; + int editStart = yaml.indexOf(editPath); + int historyStart = yaml.indexOf(historyPath); + + assertThat(editStart).isGreaterThanOrEqualTo(0); + assertThat(historyStart).isGreaterThan(editStart); + String editBlock = yaml.substring(editStart, historyStart); + String historyBlock = yaml.substring(historyStart); + int nextPath = historyBlock.indexOf("\n /api/"); + if (nextPath >= 0) historyBlock = historyBlock.substring(0, nextPath); + + assertThat(editBlock) + .contains(" put:") + .contains("x-permission: platform:firstleg:sailing-calendar:edit"); + assertThat(historyBlock) + .contains(" get:") + .contains("summary: 查询船期变更历史") + .doesNotContain(" put:"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/fact/SailingFactsSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/fact/SailingFactsSqlContractTest.java new file mode 100644 index 000000000..b0a511e90 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/fact/SailingFactsSqlContractTest.java @@ -0,0 +1,52 @@ +package com.baiyue.firstleg.sailingcalendar.fact; + +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 SailingFactsSqlContractTest { + @Test + void definesVersionedReceiptForecastAssignmentAndBookingFacts() throws Exception { + String sql = new ClassPathResource("db/phase4-sailing-facts-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(sql) + .contains("CREATE TABLE fl_sailing_receipt_batch_fact") + .contains("CREATE TABLE fl_sailing_inbound_volume_forecast") + .contains("CREATE TABLE fl_sailing_assignment") + .contains("CREATE TABLE fl_sailing_booking_link") + .contains("receipt_batch_key VARCHAR(64) NOT NULL") + .contains("fact_version INT UNSIGNED NOT NULL") + .contains("received_at DATETIME(3) NOT NULL") + .contains("actual_volume_cbm DECIMAL(18,4) NOT NULL") + .contains("expected_arrival_at DATETIME(3) NOT NULL") + .contains("forecast_volume_cbm DECIMAL(18,4) NOT NULL") + .contains("match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED','SUPERSEDED')") + .contains("booking_status_snapshot='RELEASED'") + .contains("UNIQUE KEY uk_sailing_booking_active (ocean_booking_id, active_booking_guard)"); + } + + @Test + void doesNotUseLegacyInboundTimestampOrDeclarationVolumeAsFact() throws Exception { + String sql = new ClassPathResource("db/phase4-sailing-facts-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(sql) + .doesNotContain("inbounded_at DATETIME") + .doesNotContain("original_volume_cbm DECIMAL"); + } + + @Test + void unifiedInitializationIncludesThePhase4FactsInTheFirstlegSchema() throws Exception { + String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertThat(unified) + .contains("-- phase4-sailing-calendar-schema.sql") + .contains("-- phase4-sailing-facts-schema.sql") + .contains("USE baiyue_firstleg;") + .contains("CREATE TABLE fl_sailing_receipt_batch_fact") + .contains("CREATE TABLE fl_sailing_booking_link"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingChannelMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingChannelMapperTest.java new file mode 100644 index 000000000..29e061bb8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingChannelMapperTest.java @@ -0,0 +1,36 @@ +package com.baiyue.firstleg.sailingcalendar.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Constants; +import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity; +import org.apache.ibatis.binding.MapperMethod; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.scripting.defaults.DefaultParameterHandler; +import org.junit.jupiter.api.Test; + +import java.sql.PreparedStatement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class SailingChannelMapperTest { + @Test + void inheritedSelectListStillBindsWrapperForChannelCodeLookup() throws Exception { + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.addMapper(SailingChannelMapper.class); + MappedStatement statement = configuration.getMappedStatement(SailingChannelMapper.class.getName() + ".selectList"); + MapperMethod.ParamMap parameters = new MapperMethod.ParamMap<>(); + parameters.put(Constants.WRAPPER, new LambdaQueryWrapper() + .eq(SailingChannelEntity::getChannelCode, "MATSON")); + BoundSql sql = statement.getBoundSql(parameters); + PreparedStatement preparedStatement = mock(PreparedStatement.class); + + new DefaultParameterHandler(statement, parameters, sql).setParameters(preparedStatement); + + assertThat(sql.getSql()).contains("channel_code = ?"); + verify(preparedStatement).setString(1, "MATSON"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java new file mode 100644 index 000000000..1c7a8635a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java @@ -0,0 +1,89 @@ +package com.baiyue.firstleg.sailingcalendar.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.CopyRequest; +import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity; +import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity; +import com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SailingCalendarServiceTest { + @Mock private SailingCalendarRepository repository; + @InjectMocks private SailingCalendarService service; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void replaysAnExistingCopyInsteadOfReturningAnInternalError() { + SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0)); + SailingEntity existing = sailing(2L, "SL-COPY", "COPY", "QA-20261005", LocalDateTime.of(2026, 10, 12, 10, 0)); + SailingHistoryEntity history = new SailingHistoryEntity(); + history.setAction("COPY"); history.setReason("复制自SL-SOURCE"); history.setSailingId(2L); + when(repository.sailing(1L)).thenReturn(source); + when(repository.sailing(2L)).thenReturn(existing); + when(repository.sailingByBusinessKey(8L, "MATSON", "CNNGB", "USLGB", existing.getEtdAt(), "QA-20261005")).thenReturn(existing); + when(repository.histories(2L)).thenReturn(List.of(history)); + when(repository.receiptSummary(any(SailingEntity.class))).thenReturn(null); + when(repository.bookingSummary(any(SailingEntity.class))).thenReturn(null); + + var result = service.copy(1L, 7L, "admin", + new CopyRequest(existing.getEtdAt(), LocalDateTime.of(2026, 10, 9, 18, 0))); + + assertThat(result.sailing().id()).isEqualTo(2L); + verify(repository, never()).insert(any(SailingEntity.class)); + } + + @Test + void reportsBusinessConflictForAnUnrelatedExistingSailing() { + SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0)); + SailingEntity existing = sailing(2L, "SL-OTHER", "TEMPLATE", "QA-20261005", LocalDateTime.of(2026, 10, 12, 10, 0)); + when(repository.sailing(1L)).thenReturn(source); + when(repository.sailingByBusinessKey(8L, "MATSON", "CNNGB", "USLGB", existing.getEtdAt(), "QA-20261005")).thenReturn(existing); + + assertThatThrownBy(() -> service.copy(1L, 7L, "admin", + new CopyRequest(existing.getEtdAt(), LocalDateTime.of(2026, 10, 9, 18, 0)))) + .isInstanceOf(BizException.class) + .hasMessageContaining("已存在航次 QA-20261005"); + verify(repository, never()).insert(any(SailingEntity.class)); + } + + @Test + void rejectsCutoffAtOrAfterCopiedDeparture() { + SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0)); + when(repository.sailing(1L)).thenReturn(source); + + assertThatThrownBy(() -> service.copy(1L, 7L, "admin", + new CopyRequest(LocalDateTime.of(2026, 10, 12, 10, 0), LocalDateTime.of(2026, 10, 12, 10, 0)))) + .isInstanceOf(BizException.class) + .hasMessageContaining("收货截止必须早于开船"); + verify(repository, never()).insert(any(SailingEntity.class)); + } + + private static SailingEntity sailing(long id, String sailingNo, String sourceType, String voyageNo, LocalDateTime etdAt) { + SailingEntity value = new SailingEntity(); + value.setId(id); value.setSailingNo(sailingNo); value.setSourceType(sourceType); value.setVoyageNo(voyageNo); + value.setWarehouseId(8L); value.setChannelCodeSnapshot("MATSON"); value.setChannelNameSnapshot("美森正班"); + value.setDeparturePortCode("CNNGB"); value.setDestinationPortCode("USLGB"); value.setVesselName("MATSON"); + value.setEtdAt(etdAt); value.setWarehouseCutoffAt(etdAt.minusDays(3).withHour(18)); value.setStatus("OPEN"); + value.setVersion(0); value.setPlannedCapacityCbm(BigDecimal.ZERO); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index fa199eb69..887652f7d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -5,6 +5,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.domain.PriceModel; import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; +import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.entity.InboundFeeEntity; import com.baiyue.firstleg.entity.InboundFeeItemEntity; @@ -365,6 +366,56 @@ class FbaPricingServiceTest { verify(repository, never()).insertFee(any()); } + @Test + @DisplayName("FBA价格未命中时按人工单价生成零附加费头程费用") + void createsManualQuoteWhenFbaRateIsMissing() { + readyRepository(); + InboundPricingEntity inbound = inbound("62.500", "0.3840"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(301L); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement); + when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(item)); + when(priceBookEstimator.estimate(anyLong(), any())).thenThrow( + new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格")); + + FeeSnapshotResponse result = service.manualQuote(TENANT, OPERATOR, INBOUND, + new ManualQuoteRequest(new BigDecimal("62.500"), new BigDecimal("0.3840"), + new BigDecimal("12.2000"), "FBA价格未维护"), "manual-1"); + + assertThat(result.rateCode()).isEqualTo("MANUAL_QUOTE"); + assertThat(result.unitPrice()).isEqualByComparingTo("12.2000"); + assertThat(result.chargeableQuantity()).isEqualByComparingTo("64.128000"); + assertThat(result.additionalAmount()).isEqualByComparingTo("0.00"); + assertThat(result.totalAmount()).isEqualByComparingTo("782.36"); + assertThat(result.carrierRateId()).isNull(); + ArgumentCaptor feeItem = ArgumentCaptor.forClass(InboundFeeItemEntity.class); + verify(repository).insertItem(feeItem.capture()); + assertThat(feeItem.getValue().getAutomaticFlag()).isFalse(); + assertThat(feeItem.getValue().getRemark()).isEqualTo("FBA价格未维护"); + } + + @Test + @DisplayName("已有匹配价格时拒绝绕过价格表创建人工报价") + void rejectsManualQuoteWhenRateExists() { + readyRepository(); + InboundPricingEntity inbound = inbound("62.500", "0.3840"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(301L); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement); + when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(new InboundMeasurementItemEntity())); + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( + estimate("KG", "64.128", "12.2", "782.36", "0")); + + assertThatThrownBy(() -> service.manualQuote(TENANT, OPERATOR, INBOUND, + new ManualQuoteRequest(new BigDecimal("62.500"), new BigDecimal("0.3840"), + new BigDecimal("12.2000"), "人工价格"), "manual-existing")) + .isInstanceOf(BizException.class).hasMessageContaining("已有匹配价格"); + verify(repository, never()).insertFee(any()); + } + @Test @DisplayName("同一幂等键重复试算直接返回原费用快照") void returnsOriginalSnapshotForRepeatedCalculation() { 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 1d47e2c4a..41891f855 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -1406,6 +1406,30 @@ class InboundMeasurementServiceTest { verify(orderCargoClient).detail("internal-token", 1L, 80L, 80001L); } + @Test + void savingDraftRemainsEditableWhileInboundIsProcessingBeforeMeasurementSubmission() { + InboundOrderEntity inbound = inbound("PROCESSING"); + inbound.setOrderId(80L); + InboundMeasurementEntity saved = new InboundMeasurementEntity(); + saved.setId(31L); + saved.setVersion(0); + saved.setStatus("DRAFT"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(orderCargoClient.detail(eq("internal-token"), eq(1L), eq(80L), eq(80001L))).thenReturn( + ApiResponse.success(new OrderCargoClient.CargoBinding(80001L, 80L, 1, "Test cargo", + 2, new BigDecimal("6.500"), new BigDecimal("0.0480")))); + when(batchService.saveBatch(any())).thenReturn(saved); + + InboundMeasurementResponses.DraftSaved result = service.saveDraft(1L, 10L, 9L, + new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, + List.of(new InboundMeasurementRequests.ItemRequest(80001L, "001", "A", 2, + new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"), + null, null, null, null, null, null)))); + + assertThat(result.measurementId()).isEqualTo(31L); + verify(batchService).saveBatch(any()); + } + @Test void savingDraftCannotReopenSubmittedMeasurementWhileReviewIsPending() { InboundOrderEntity inbound = inbound("MEASURE_PENDING"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index aeff90aba..5546fbc96 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -12,6 +12,8 @@ import com.baiyue.firstleg.dto.LoadingListDtos.PreloadReadiness; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; import com.baiyue.firstleg.client.LoadingDocumentFileClient; +import com.baiyue.firstleg.client.DomesticPdaOperatorClient; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; @@ -63,6 +65,8 @@ class LoadingListServiceTest { @MockBean private LoadingDocumentFileClient documentFiles; @MockBean + private DomesticPdaOperatorClient domesticPdaOperators; + @MockBean private LoadingCompletedOutboxService loadingCompletedOutboxService; @MockBean private FbaPricingService fbaPricingService; @@ -784,6 +788,19 @@ class LoadingListServiceTest { } + @Test + void rejectsInvalidPdaOperatorBeforeConfirmingAnyPreload() { + when(repository.aggregate(10L)).thenReturn(row("EDITING", 3)); + when(domesticPdaOperators.required(any(), eq(77L), eq(100L))) + .thenReturn(ApiResponse.success(new DomesticPdaOperatorClient.Operator(88L, "其他人"))); + + assertThatThrownBy(() -> service.confirmPreload(10L, 9L, "operator", "invalid-operator", + new ConfirmPreloadRequest(3, 77L))) + .isInstanceOf(BizException.class).hasMessageContaining("作业员无效"); + verify(repository, never()).touchForPreloadConfirmation(anyLong(), anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + @Test void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() { AggregateRow before = row("EDITING", 3); @@ -810,9 +827,16 @@ class LoadingListServiceTest { when(repository.confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator")).thenReturn(1); when(repository.confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator")).thenReturn(1); when(repository.preloadDetail(after)).thenReturn(detail); + when(domesticPdaOperators.required(any(), eq(77L), eq(100L))) + .thenReturn(ApiResponse.success(new DomesticPdaOperatorClient.Operator(77L, "作业员"))); + LoadingPickTaskEntity pickTask = new LoadingPickTaskEntity(); + pickTask.setId(80L); + when(pickRepository.taskByPlan(10L)).thenReturn(pickTask); + when(pickRepository.createAssignment(80L, 77L, "作业员", 9L, "operator", "ASSIGNED")) + .thenReturn(1); PreloadConfirmationResponse response = service.confirmPreload( - 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3)); + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3, 77L)); assertThat(response.confirmationStatus()).isEqualTo("CONFIRMED"); assertThat(response.version()).isEqualTo(4); @@ -822,6 +846,7 @@ class LoadingListServiceTest { verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(30L), eq(101L), anyString()); verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(30L), eq(102L), anyString()); verify(loadingPickService, times(1)).beginIfReady(30L, 9L, "operator"); + verify(pickRepository).createAssignment(80L, 77L, "作业员", 9L, "operator", "ASSIGNED"); verify(repository).appendHistory(eq(10L), anyString(), eq("CONFIRM_PRELOAD"), eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L), eq("operator"), org.mockito.ArgumentMatchers.isNull()); @@ -838,9 +863,11 @@ class LoadingListServiceTest { when(fbaPricingService.recalculateUnlockedContainerCustomerFees(eq(30L), eq(101L), anyString())) .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0)); + when(domesticPdaOperators.required(any(), eq(77L), eq(100L))) + .thenReturn(ApiResponse.success(new DomesticPdaOperatorClient.Operator(77L, "作业员"))); assertThatThrownBy(() -> service.confirmPreload( - 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3))) + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3, 77L))) .isInstanceOf(BizException.class).hasMessageContaining("初始费用"); verify(repository, never()).touchForPreloadConfirmation(anyLong(), anyInt(), anyLong(), anyString()); @@ -852,7 +879,7 @@ class LoadingListServiceTest { void exactConfirmationReplayReturnsFrozenResponseWithoutTouchingPlan() throws Exception { PreloadConfirmationResponse expected = new PreloadConfirmationResponse( 10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L)); - String requestHash = sha256("CONFIRM_PRELOAD|10|3"); + String requestHash = sha256("CONFIRM_PRELOAD|10|3|77"); HistoryRow replay = new HistoryRow(); ObjectMapper mapper = new ObjectMapper(); replay.setAfterSnapshot(mapper.writerWithDefaultPrettyPrinter().writeValueAsString( @@ -860,7 +887,7 @@ class LoadingListServiceTest { when(repository.history(anyString())).thenReturn(replay); PreloadConfirmationResponse actual = service.confirmPreload( - 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3)); + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3, 77L)); assertThat(actual).isEqualTo(expected); verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong()); @@ -876,14 +903,14 @@ class LoadingListServiceTest { void rejectsConfirmationReplayWhenExpectedVersionDiffers() throws Exception { PreloadConfirmationResponse expected = new PreloadConfirmationResponse( 10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L)); - String originalHash = sha256("CONFIRM_PRELOAD|10|3"); + String originalHash = sha256("CONFIRM_PRELOAD|10|3|77"); HistoryRow replay = new HistoryRow(); replay.setAfterSnapshot(new ObjectMapper().writeValueAsString( java.util.Map.of("requestHash", originalHash, "response", expected))); when(repository.history(anyString())).thenReturn(replay); assertThatThrownBy(() -> service.confirmPreload( - 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4))) + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4, 77L))) .isInstanceOf(BizException.class) .extracting(error -> ((BizException) error).getResultCode()) .isEqualTo(ResultCode.CONFLICT); @@ -898,11 +925,13 @@ class LoadingListServiceTest { AggregateRow confirmed = row("EDITING", 4); when(repository.history(anyString())).thenReturn(null); when(repository.aggregate(10L)).thenReturn(confirmed); + when(domesticPdaOperators.required(any(), eq(77L), eq(100L))) + .thenReturn(ApiResponse.success(new DomesticPdaOperatorClient.Operator(77L, "作业员"))); when(loadingPickService.requirePreloadConfirmationReady(10L)) .thenThrow(new BizException(ResultCode.CONFLICT, "预装计划已确认")); assertThatThrownBy(() -> service.confirmPreload( - 10L, 9L, "operator", "new-confirm-key", new ConfirmPreloadRequest(4))) + 10L, 9L, "operator", "new-confirm-key", new ConfirmPreloadRequest(4, 77L))) .isInstanceOf(BizException.class) .extracting(error -> ((BizException) error).getResultCode()) .isEqualTo(ResultCode.CONFLICT); @@ -919,11 +948,13 @@ class LoadingListServiceTest { AggregateRow before = row("EDITING", 3); when(repository.history(anyString())).thenReturn(null); when(repository.aggregate(10L)).thenReturn(before); + when(domesticPdaOperators.required(any(), eq(77L), eq(100L))) + .thenReturn(ApiResponse.success(new DomesticPdaOperatorClient.Operator(77L, "作业员"))); when(loadingPickService.requirePreloadConfirmationReady(10L)) .thenThrow(new BizException(ResultCode.CONFLICT, "第二票订舱尚未放舱")); assertThatThrownBy(() -> service.confirmPreload( - 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3))) + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3, 77L))) .isInstanceOf(BizException.class).hasMessageContaining("第二票"); verify(repository, never()).touchForPreloadConfirmation(anyLong(), @@ -940,7 +971,7 @@ class LoadingListServiceTest { when(repository.history(anyString())).thenReturn(replay); assertThatThrownBy(() -> service.confirmPreload( - 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4))) + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4, 77L))) .isInstanceOf(BizException.class).hasMessageContaining("不同请求"); verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong()); 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 f21014288..8597ea9d6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -11,6 +11,8 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; +import com.baiyue.firstleg.client.DomesticPdaOperatorClient; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; @@ -106,6 +108,8 @@ class LoadingPickServiceTest { private InboundHoldGuard inboundHoldGuard; @MockBean private DropshipPalletizationMapper dropshipPallets; + @MockBean + private DomesticPdaOperatorClient domesticPdaOperators; @Resource private LoadingPickService service; @@ -565,8 +569,7 @@ class LoadingPickServiceTest { when(repository.operation("claim-2")).thenReturn(null); when(repository.task(20L)).thenReturn(task); when(repository.taskForUpdate(20L)).thenReturn(task); - when(repository.assignment(20L)).thenReturn(null, assignment); - when(repository.createAssignment(20L, 9L, "operator", 9L, "operator", "CLAIMED")).thenReturn(1); + when(repository.assignment(20L)).thenReturn(assignment); when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1, 0); doAnswer(invocation -> { LoadingPickOperationEntity value = invocation.getArgument(0); @@ -574,9 +577,9 @@ class LoadingPickServiceTest { return null; }).when(repository).insert(any(LoadingPickOperationEntity.class)); - service.claim(20L, new ClaimRequest(0, "claim-1"), 9L, "operator"); - service.claim(20L, new ClaimRequest(0, "claim-1"), 9L, "operator"); - service.claim(20L, new ClaimRequest(0, "claim-2"), 9L, "operator"); + service.claim(20L, new ClaimRequest(assignment.getVersion(), "claim-1"), 9L, "operator"); + service.claim(20L, new ClaimRequest(assignment.getVersion(), "claim-1"), 9L, "operator"); + service.claim(20L, new ClaimRequest(assignment.getVersion(), "claim-2"), 9L, "operator"); verify(smartLoadingRepository, times(2)).startPicking(10L, 9L, "operator"); verify(smartLoadingRepository, times(1)).insert(argThat( @@ -585,7 +588,8 @@ class LoadingPickServiceTest { && "EDITING".equals(history.getFromStatus()) && "LOADING".equals(history.getToStatus()) && "PDA-START-20".equals(history.getOperationId()))); - verify(repository, times(1)).createAssignment(20L, 9L, "operator", 9L, "operator", "CLAIMED"); + verify(repository, never()).createAssignment(anyLong(), anyLong(), anyString(), anyLong(), + anyString(), anyString()); } @Test @@ -606,6 +610,17 @@ class LoadingPickServiceTest { verify(smartLoadingRepository, never()).startPicking(anyLong(), anyLong(), anyString()); } + @Test + void unassignedTaskCannotBeClaimedFromPda() { + when(repository.taskForUpdate(20L)).thenReturn(task()); + + assertThatThrownBy(() -> service.claim(20L, new ClaimRequest(0, "claim-unassigned"), + 9L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("尚未指定作业员"); + verify(repository, never()).createAssignment(anyLong(), anyLong(), anyString(), + anyLong(), anyString(), anyString()); + } + @Test void assignRechecksSameOperationAfterWaitingForTaskLock() { LoadingPickTaskEntity task = task(); @@ -615,6 +630,8 @@ class LoadingPickServiceTest { when(repository.taskForUpdate(20L)).thenReturn(task); when(repository.task(20L)).thenReturn(task); when(repository.items(20L)).thenReturn(List.of()); + when(domesticPdaOperators.required(any(), eq(11L), eq(101L))) + .thenReturn(ApiResponse.success(new DomesticPdaOperatorClient.Operator(11L, "picker"))); Task replay = service.assign(20L, new AssignRequest(11L, "picker", 0, "assign-race"), 9L, "operator"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index 291dc1e8f..2a6c71181 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -21,6 +21,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.util.List; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; @@ -34,6 +35,7 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -100,6 +102,7 @@ class MeasurementReviewServiceTest { assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT"); } + @Test @DisplayName("审核通过使用订单权威货物总重量,不依赖箱规重量") void shouldApproveWithActualGrossWeightWhenCartonWeightIsMissing() { @@ -298,6 +301,8 @@ class MeasurementReviewServiceTest { .thenReturn(new InboundIdempotencyService.Context(10001L, 1L, false)); when(properties.isAllowRejectedMeasurement()).thenReturn(allowRelease); InboundOrderEntity order = new InboundOrderEntity(); + order.setId(10L); + order.setTenantId(10001L); order.setProcessStatus("MEASURE_PENDING"); order.setOrderStatusSnapshot("APPROVED"); order.setMeasureReviewStatus("PENDING"); @@ -309,6 +314,7 @@ class MeasurementReviewServiceTest { InboundMeasurementEntity measurement = new InboundMeasurementEntity(); measurement.setId(20L); measurement.setStatus("SUBMITTED"); + measurement.setMeasuredAt(LocalDateTime.of(2026, 9, 20, 10, 0)); measurement.setTotalPackageCount(5); measurement.setTotalVolumeCbm(new BigDecimal("0.7480")); measurement.setVolumeWeightKg(new BigDecimal("124.916")); @@ -346,3 +352,4 @@ class MeasurementReviewServiceTest { InboundMeasurementEntity measurement) { } } + diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java index f7b5959a3..50579951d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java @@ -59,6 +59,7 @@ class MojiaMeasurementOpenApiServiceTest { var service = new MojiaMeasurementOpenApiService(authenticator, cartons, new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(), images, receipts, labels); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null); when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH, headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp( @@ -119,11 +120,47 @@ class MojiaMeasurementOpenApiServiceTest { assertThat(receipt.getValue().getBoxCode()).isEqualTo("PCHK2608140001#5-1"); assertThat(receipt.getValue().getScannedShipmentId()).isNull(); assertThat(receipt.getValue().getLengthCm()).isEqualByComparingTo("60.1"); + assertThat(receipt.getValue().getWeightKg()).isEqualByComparingTo("15.13"); assertThat(receipt.getValue().getImageFileId()).isEqualTo(3L); assertThat(receipt.getValue().getStatus()).isEqualTo("UNMATCHED"); assertThat(receipt.getValue().getTenantId()).isZero(); } + @Test + void skuWithoutHashIsAcceptedIntoSkuSyncInboxAndDoesNotEnterCartonFlow() { + MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class); + CartonMeasurementService cartons = mock(CartonMeasurementService.class); + MeasurementImageClient images = mock(MeasurementImageClient.class); + MojiaMeasurementReceiptRepository receipts = mock(MojiaMeasurementReceiptRepository.class); + InboundCartonLabelService labels = mock(InboundCartonLabelService.class); + var service = new MojiaMeasurementOpenApiService(authenticator, cartons, + new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(), + images, receipts, labels); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null); + String requestJson = json().replace("PCHK2608140001#5-1", "SKU-NEW-001"); + when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH, + headers, requestJson, false)).thenReturn(new AuthenticatedMeasurementApp( + "mojia", 0L, 90001L, Map.of("MJ-01", 30001L))); + when(images.uploadReceipt(eq("internal-token"), eq(90001L), eq(0L), any(Long.TYPE), any())) + .thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult( + 9L, "mojia.jpg", "image/jpeg", 4L, "NOT_REQUIRED", null, null, null, null))); + + UploadResponse result = service.receive(headers, requestJson); + + assertThat(result.accepted()).isTrue(); + assertThat(result.matched()).isFalse(); + verify(labels, org.mockito.Mockito.never()).resolvePlatformByBoxCode(any()); + verify(cartons, org.mockito.Mockito.never()).upsert(any(Long.TYPE), any(Long.TYPE), any()); + ArgumentCaptor receipt = + ArgumentCaptor.forClass(MojiaMeasurementReceiptEntity.class); + verify(receipts).insert(receipt.capture()); + assertThat(receipt.getValue().getMeasurementKind()).isEqualTo("SKU"); + assertThat(receipt.getValue().getSyncStatus()).isEqualTo("PENDING"); + assertThat(receipt.getValue().getStatus()).isEqualTo("SKU_PENDING_SYNC"); + assertThat(receipt.getValue().getBoxCode()).isEqualTo("SKU-NEW-001"); + } + @Test void platformMachineCannotWriteAnInboundFromAnotherWarehouse() { MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class); @@ -134,6 +171,7 @@ class MojiaMeasurementOpenApiServiceTest { var service = new MojiaMeasurementOpenApiService(authenticator, cartons, new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(), images, receipts, labels); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null); when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH, headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp( @@ -142,10 +180,18 @@ class MojiaMeasurementOpenApiServiceTest { .thenReturn(new InboundCartonLabelService.PlatformBinding(10002L, 30002L, new CartonLabelBindingResolver.Binding( 70002L, 80002L, "PCHK2608140001", 1, 5, "60x40x30cm"))); + when(images.uploadReceipt(eq("internal-token"), eq(90001L), eq(0L), any(Long.TYPE), any())) + .thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult( + 9L, "mojia.jpg", "image/jpeg", 4L, "NOT_REQUIRED", null, null, null, null))); - org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.receive(headers, json())) - .isInstanceOf(BizException.class) - .hasMessageContaining("不属于当前设备绑定仓库"); + UploadResponse result = service.receive(headers, json()); + assertThat(result.accepted()).isTrue(); + assertThat(result.matched()).isFalse(); + ArgumentCaptor receipt = + ArgumentCaptor.forClass(MojiaMeasurementReceiptEntity.class); + verify(receipts).insert(receipt.capture()); + assertThat(receipt.getValue().getMeasurementKind()).isEqualTo("BOX_LABEL"); + assertThat(receipt.getValue().getMatchFailureReason()).contains("不属于当前设备绑定仓库"); verify(cartons, org.mockito.Mockito.never()).upsert(any(Long.TYPE), any(Long.TYPE), any()); } @@ -153,7 +199,7 @@ class MojiaMeasurementOpenApiServiceTest { private String json() { return """ {"boxCode":"PCHK2608140001#5-1","shipment_id":"FBA19L5KMY2CU000002", - "length":60.1,"width":40.2,"height":30.3, + "length":60.1,"width":40.2,"height":30.3,"weightKg":15.13, "machineNo":"MJ-01","picture":"data:image/jpeg;base64,/9j/2Q=="} """; } diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md index bc183918d..b6a5b83b3 100644 --- a/gateway/AGENTS.md +++ b/gateway/AGENTS.md @@ -17,4 +17,4 @@ - 新增路由同步检查本地/Nacos、App Scope、CORS、公开路径和 Swagger;不得暴露内部令牌接口。 - 订单与客户承载登录后 Admin/Client 入口;入库测量与计费接入到仓/测量/计费公开路由但保持内部测量接口隔离;国内货柜履约仅在 Controller、权限和公开访问域契约稳定后新增必要路由,内部 PDA、仓储和服务令牌接口继续不得经 Gateway 暴露。 -- 国内货柜履约先读 `../../docs/国内货柜履约/国内货柜履约开工门禁.md`;网关变更只服务已确认模块,不以路由提前暴露未完成能力。 +- 国内货柜履约先读 `../../docs/头程到尾程/README.md` 和 `../../docs/国内货柜履约/README.md`;网关变更只服务当前维护入口已确认的模块,不以路由提前暴露未完成能力。开工门禁属于归档资料,只有历史追溯时读取。 diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index b30bb2b85..2304723e4 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -77,7 +77,7 @@ spring: - id: firstleg-service uri: lb://firstleg-service predicates: - - Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/container-shipping-nodes/**,/api/platform/v1/containers/**,/api/platform/v1/terminal-drayages/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** + - Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/container-shipping-nodes/**,/api/platform/v1/containers/**,/api/platform/v1/terminal-drayages/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/v1/sailings/**,/api/platform/v1/sailing-channels/**,/api/platform/v1/sailing-templates/**,/api/platform/v1/sailing-assignments/**,/api/platform/v1/sailing-forecasts/**,/api/platform/v1/firstleg/mojia-sku-sync/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** - id: finance-service uri: lb://finance-service predicates: diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java index 044f022c2..17348d05b 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java @@ -18,6 +18,8 @@ import com.baiyue.iam.service.TenantService; import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch; import com.baiyue.common.core.api.PageResult; import com.baiyue.iam.dto.TenantDtos.FinanceCustomerSummary; +import com.baiyue.iam.dto.DomesticPdaOperatorDtos.OperatorOption; +import com.baiyue.iam.service.DomesticPdaOperatorService; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import jakarta.validation.Valid; @@ -38,6 +40,7 @@ public class InternalTenantController { @Resource private MerchantOnboardingService onboardingService; @Resource private ProfileResourceService profileResourceService; @Resource private TenantService tenantService; + @Resource private DomesticPdaOperatorService domesticPdaOperatorService; @Value("${baiyue.security.internal-service-token}") private String internalTokenValue; private byte[] internalToken; @@ -128,6 +131,15 @@ public class InternalTenantController { return ApiResponse.success(tenantService.pageFinanceCustomers(page, size, keyword, status, customerId)); } + @GetMapping("/domestic-pda-operators/{authUserId}") + public ApiResponse domesticPdaOperator( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long authUserId, + @RequestParam long warehouseId) { + verify(token); + return ApiResponse.success(domesticPdaOperatorService.required(authUserId, warehouseId)); + } + @GetMapping("/exports/customers") public ApiResponse exportCustomers( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java index 8ec2775ac..3c289e875 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java @@ -6,6 +6,9 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.dto.PlatformUserManagementDtos.*; import com.baiyue.iam.service.PlatformUserManagementService; import com.baiyue.iam.service.TenantGuard; +import com.baiyue.iam.service.DomesticPdaOperatorService; +import com.baiyue.iam.dto.DomesticPdaOperatorDtos.OperatorOption; +import java.util.List; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -26,6 +29,19 @@ import org.springframework.web.bind.annotation.*; public class PlatformUserManagementController { @Resource private PlatformUserManagementService service; + @Resource + private DomesticPdaOperatorService domesticPdaOperators; + + @GetMapping("/domestic-pda-operators") + @PreAuthorize("hasAuthority('platform:firstleg:pda:assign')") + @Operation(summary = "查询启用的国内头程 PDA 作业员候选") + public ApiResponse> domesticPdaOperators( + @RequestParam long warehouseId, + @RequestParam(required = false) String keyword, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(domesticPdaOperators.options(warehouseId, keyword)); + } @GetMapping @PreAuthorize("hasAuthority('platform:user:list')") 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 acc77d13d..727c6991b 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java @@ -41,12 +41,13 @@ public final class AuthorizationDtos { private Set permissions; private Long permissionVersion; private Long overseasWarehouseId; + private Long domesticWarehouseId; 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); + permissionVersion, null, null); } } } diff --git a/iam/src/main/java/com/baiyue/iam/dto/DomesticPdaOperatorDtos.java b/iam/src/main/java/com/baiyue/iam/dto/DomesticPdaOperatorDtos.java new file mode 100644 index 000000000..0f58ddb4c --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/DomesticPdaOperatorDtos.java @@ -0,0 +1,8 @@ +package com.baiyue.iam.dto; + +/** 国内头程 PDA 作业员候选契约。 */ +public final class DomesticPdaOperatorDtos { + private DomesticPdaOperatorDtos() { } + + public record OperatorOption(Long userId, String displayName) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java index 3b3d5fdb8..8fd172624 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java @@ -58,7 +58,7 @@ public final class OnboardingDtos { @NotNull Long tenantId, @NotBlank @Pattern(regexp = "[0-9A-Za-z]{18}") String unifiedSocialCreditCode, @NotBlank String businessLicenseDocumentUrl, - @NotBlank @Size(max = 255) String businessLicenseOriginalFileName, + @Size(max = 255) String businessLicenseOriginalFileName, LocalDate businessLicenseValidTo, boolean replaceBusinessLicense, String legalPersonIdDocumentUrl, 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 a90f46028..e70974a6d 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java @@ -23,12 +23,13 @@ public final class PlatformUserManagementDtos { @NotNull Long tenantId, @NotNull List roleIds, Long overseasWarehouseId, + Long domesticWarehouseId, @NotBlank @Size(min = 8, max = 64) @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { public CreateUserRequest(String username, String displayName, String phone, String email, Long tenantId, List roleIds, String password, String status) { - this(username, displayName, phone, email, tenantId, roleIds, null, password, status); + this(username, displayName, phone, email, tenantId, roleIds, null, null, password, status); } } @@ -39,10 +40,15 @@ public final class PlatformUserManagementDtos { @Email @Size(max = 128) String email, @NotNull Long tenantId, @NotNull List roleIds, - Long overseasWarehouseId) { + Long overseasWarehouseId, + Long domesticWarehouseId) { + public UpdateUserRequest(String displayName, String phone, String email, + Long tenantId, List roleIds, Long overseasWarehouseId) { + this(displayName, phone, email, tenantId, roleIds, overseasWarehouseId, null); + } public UpdateUserRequest(String displayName, String phone, String email, Long tenantId, List roleIds) { - this(displayName, phone, email, tenantId, roleIds, null); + this(displayName, phone, email, tenantId, roleIds, null, null); } } @@ -55,7 +61,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, Long overseasWarehouseId) { } + boolean protectedAccount, Long overseasWarehouseId, Long domesticWarehouseId) { } 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 dfdaa658a..a615bc7de 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 Long overseasWarehouseId; 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 Long domesticWarehouseId; 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 a403399f9..e1a487122 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -105,4 +105,8 @@ public interface AuthorizationQueryMapper { @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); + + @Select("SELECT domestic_warehouse_id FROM iam_membership WHERE id=#{membershipId} AND tenant_id=#{tenantId} AND status='ACTIVE' AND deleted=0") + Long findDomesticWarehouseId(@Param("tenantId") long tenantId, + @Param("membershipId") long membershipId); } diff --git a/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java index d15438b9b..e16dda80a 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java @@ -10,6 +10,7 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import java.util.List; +import com.baiyue.iam.dto.DomesticPdaOperatorDtos.OperatorOption; @Mapper public interface IamMembershipRoleMapper extends BaseMapper { @@ -26,4 +27,31 @@ public interface IamMembershipRoleMapper extends BaseMapper { """) List findAssignedAuthUserIds(@Param("tenantId") long tenantId, @Param("roleId") Long roleId); + + @Select(""" + + """) + List findActiveUsersByRoleCode(@Param("tenantId") long tenantId, + @Param("roleCode") String roleCode, + @Param("keyword") String keyword, + @Param("authUserId") Long authUserId, + @Param("warehouseId") Long warehouseId); } 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 df90f8b1e..6bfca6f98 100644 --- a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java +++ b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java @@ -48,7 +48,8 @@ public class AuthorizationSnapshotService { return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions), mapper.findPermissionVersion(request.getUserId()), - mapper.findOverseasWarehouseId(request.getTenantId(), request.getMembershipId())); + mapper.findOverseasWarehouseId(request.getTenantId(), request.getMembershipId()), + mapper.findDomesticWarehouseId(request.getTenantId(), request.getMembershipId())); } /** diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java index ea43f1686..782cde930 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java @@ -41,9 +41,13 @@ public class CustomerDimensionInitializationRelay { repository.markCancelled(task, workerId); return; } + String customerName = task.customerName(); + if (customerName == null || customerName.isBlank()) { + customerName = "客户-" + task.tenantId(); + } ApiResponse response = client.initialize( internalToken, new CustomerDimensionRuleClient.InitializationRequest( - task.tenantId(), task.operatorId(), task.initializationKey(), task.customerName())); + task.tenantId(), task.operatorId(), task.initializationKey(), customerName)); requireSuccess(response, true); if (response.data() == null || response.data().configId() == null || response.data().chargeRuleId() == null) { diff --git a/iam/src/main/java/com/baiyue/iam/service/DomesticPdaOperatorService.java b/iam/src/main/java/com/baiyue/iam/service/DomesticPdaOperatorService.java new file mode 100644 index 000000000..b573a2a53 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/DomesticPdaOperatorService.java @@ -0,0 +1,32 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.dto.DomesticPdaOperatorDtos.OperatorOption; +import com.baiyue.iam.mapper.IamMembershipRoleMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** 提供启用的国内头程 PDA 作业员,并作为派单时的权威角色校验。 */ +@Service +public class DomesticPdaOperatorService { + @Resource + private IamMembershipRoleMapper membershipRoles; + + @Transactional(readOnly = true) + public List options(long warehouseId, String keyword) { + String normalized = keyword == null || keyword.isBlank() ? null : keyword.trim(); + return membershipRoles.findActiveUsersByRoleCode(0L, "CN_FIRSTLEG_PDA_OPERATOR", normalized, null, warehouseId); + } + + @Transactional(readOnly = true) + public OperatorOption required(long authUserId, long warehouseId) { + return membershipRoles.findActiveUsersByRoleCode( + 0L, "CN_FIRSTLEG_PDA_OPERATOR", null, authUserId, warehouseId).stream().findFirst() + .orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, + "所选人员不是该仓库启用的国内头程 PDA 作业员")); + } +} 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 fa73ece19..9433ffab0 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -75,7 +75,7 @@ public class PlatformUserManagementService { long tenantId = request.tenantId(); requirePlatformTenant(tenantId); List roleIds = validateRoles(tenantId, request.roleIds()); - Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId()); + WarehouseBindings warehouses = validateWarehouseBinding(roleIds, request.overseasWarehouseId(), request.domesticWarehouseId()); long authUserId = IdWorker.getId(); long userId = IdWorker.getId(); long membershipId = IdWorker.getId(); @@ -100,7 +100,8 @@ public class PlatformUserManagementService { membership.setUserId(userId); membership.setMemberType("EMPLOYEE"); membership.setMemberNo(memberNo(tenantId, membershipId)); - membership.setOverseasWarehouseId(overseasWarehouseId); + membership.setOverseasWarehouseId(warehouses.overseasWarehouseId()); + membership.setDomesticWarehouseId(warehouses.domesticWarehouseId()); membership.setStatus(request.status()); membership.setJoinedAt(LocalDateTime.now()); membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); @@ -125,7 +126,7 @@ public class PlatformUserManagementService { long newTenantId = request.tenantId(); requirePlatformTenant(newTenantId); List roleIds = validateRoles(newTenantId, request.roleIds()); - Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId()); + WarehouseBindings warehouses = validateWarehouseBinding(roleIds, request.overseasWarehouseId(), request.domesticWarehouseId()); List oldRoleIds = members.findRoleIds(oldTenantId, membershipId); if (isProtectedPlatformAdmin(membership, oldRoleIds) && (newTenantId != PLATFORM_TENANT_ID || !containsPlatformAdminRole(roleIds))) { @@ -140,7 +141,8 @@ public class PlatformUserManagementService { boolean phoneChanged = !Objects.equals(user.getPhone(), request.phone().trim()); boolean scopeChanged = oldTenantId != newTenantId; boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds)); - boolean warehouseChanged = !Objects.equals(membership.getOverseasWarehouseId(), overseasWarehouseId); + boolean warehouseChanged = !Objects.equals(membership.getOverseasWarehouseId(), warehouses.overseasWarehouseId()) + || !Objects.equals(membership.getDomesticWarehouseId(), warehouses.domesticWarehouseId()); if (profileChanged) call(() -> authClient.profile(internalToken, user.getAuthUserId(), new AuthAccountClient.ProfileRequest(request.displayName().trim()))); @@ -154,7 +156,8 @@ public class PlatformUserManagementService { user.setEmail(trimToNull(request.email())); user.setUserType(userType(newTenantId)); membership.setTenantId(newTenantId); - membership.setOverseasWarehouseId(overseasWarehouseId); + membership.setOverseasWarehouseId(warehouses.overseasWarehouseId()); + membership.setDomesticWarehouseId(warehouses.domesticWarehouseId()); if (scopeChanged && members.existsMemberNo(newTenantId, membership.getMemberNo(), membershipId)) { membership.setMemberNo(memberNo(newTenantId, membershipId)); } @@ -239,7 +242,7 @@ public class PlatformUserManagementService { user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName, membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), roleIds, roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds), - membership.getOverseasWarehouseId()); + membership.getOverseasWarehouseId(), membership.getDomesticWarehouseId()); } private List platformRoles() { @@ -262,7 +265,8 @@ public class PlatformUserManagementService { return roleIds; } - private Long validateWarehouseBinding(List roleIds, Long overseasWarehouseId) { + private WarehouseBindings validateWarehouseBinding(List roleIds, Long overseasWarehouseId, + Long domesticWarehouseId) { Set roleCodes = roleIds.stream().map(roleMapper::selectById).filter(Objects::nonNull) .map(IamRole::getRoleCode).collect(Collectors.toSet()); boolean overseasPda = roleCodes.contains("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); @@ -276,9 +280,17 @@ public class PlatformUserManagementService { if (!overseasPda && overseasWarehouseId != null) { throw new BizException(ResultCode.BAD_REQUEST, "仅海外仓 PDA 作业员可绑定海外仓"); } - return overseasWarehouseId; + if (domesticPda && (domesticWarehouseId == null || domesticWarehouseId <= 0)) { + throw new BizException(ResultCode.BAD_REQUEST, "国内头程 PDA 作业员必须绑定一个有效国内仓"); + } + if (!domesticPda && domesticWarehouseId != null) { + throw new BizException(ResultCode.BAD_REQUEST, "仅国内头程 PDA 作业员可绑定国内仓"); + } + return new WarehouseBindings(overseasWarehouseId, domesticWarehouseId); } + private record WarehouseBindings(Long overseasWarehouseId, Long domesticWarehouseId) { } + 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/ProfileResourceService.java b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java index 68f11852d..e3b07d0fa 100644 --- a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java +++ b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java @@ -212,14 +212,16 @@ public class ProfileResourceService { String replacedBusinessLicenseUrl = supersede(businessLicenses, replacementComment); String replacedLegalPersonIdUrl = hasLegalPersonId ? supersede(legalPersonIds, replacementComment) : null; IamTenantQualification businessLicense = createQualification(tenant.getId(), "BUSINESS_LICENSE", - creditCode, request.businessLicenseDocumentUrl(), request.businessLicenseOriginalFileName()); + creditCode, request.businessLicenseDocumentUrl(), + normalizeOriginalFileName(request.businessLicenseOriginalFileName(), request.businessLicenseDocumentUrl(), platformManaged)); businessLicense.setValidTo(request.businessLicenseValidTo()); if (platformManaged) markPlatformQualificationApproved(tenant, businessLicense); repository.saveQualification(businessLicense); IamTenantQualification legalPersonId = null; if (hasLegalPersonId) { legalPersonId = createQualification(tenant.getId(), "LEGAL_PERSON_ID", - null, request.legalPersonIdDocumentUrl().trim(), request.legalPersonIdOriginalFileName()); + null, request.legalPersonIdDocumentUrl().trim(), + normalizeOriginalFileName(request.legalPersonIdOriginalFileName(), request.legalPersonIdDocumentUrl(), platformManaged)); legalPersonId.setValidTo(request.legalPersonIdValidTo()); if (platformManaged) markPlatformQualificationApproved(tenant, legalPersonId); repository.saveQualification(legalPersonId); @@ -297,7 +299,15 @@ public class ProfileResourceService { } private String normalizeOriginalFileName(String originalFileName) { + return normalizeOriginalFileName(originalFileName, null, false); + } + + private String normalizeOriginalFileName(String originalFileName, String documentUrl, boolean platformManaged) { if (!StringUtils.hasText(originalFileName)) { + if (platformManaged && StringUtils.hasText(documentUrl)) { + String value = documentUrl.substring(documentUrl.lastIndexOf('/') + 1).trim(); + if (StringUtils.hasText(value) && value.length() <= 255) return value; + } throw new BizException(ResultCode.BAD_REQUEST, "Original file name is required / 必须提供上传时原始文件名"); } String normalized = originalFileName.trim(); diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 333c94552..901bdd60d 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -149,6 +149,7 @@ CREATE TABLE IF NOT EXISTS iam_membership ( member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等', member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号', overseas_warehouse_id BIGINT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID', + domestic_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 '离开客户企业时间', @@ -160,7 +161,8 @@ CREATE TABLE IF NOT EXISTS iam_membership ( UNIQUE KEY uk_membership_tenant_user (tenant_id, user_id), UNIQUE KEY uk_membership_tenant_no (tenant_id, member_no), KEY idx_membership_user (user_id, status), - KEY idx_membership_tenant (tenant_id, status) + KEY idx_membership_tenant (tenant_id, status), + KEY idx_membership_domestic_warehouse (tenant_id, domestic_warehouse_id, status) ) ENGINE=InnoDB COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体'; CREATE TABLE IF NOT EXISTS iam_role ( diff --git a/iam/src/main/resources/db/mojia-sku-sync-permissions.sql b/iam/src/main/resources/db/mojia-sku-sync-permissions.sql new file mode 100644 index 000000000..5b69b0a5b --- /dev/null +++ b/iam/src/main/resources/db/mojia-sku-sync-permissions.sql @@ -0,0 +1,45 @@ +-- 墨家SKU同步页面及操作权限。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS migrate_mojia_sku_sync_permissions; +DELIMITER $$ +CREATE PROCEDURE migrate_mojia_sku_sync_permissions() +BEGIN + IF EXISTS ( + SELECT 1 FROM iam_permission + WHERE (id = 5102131 AND permission_code <> 'platform:mojia-sku-sync:list') + OR (id = 5102132 AND permission_code <> 'platform:mojia-sku-sync:edit') + OR (id = 5102133 AND permission_code <> 'platform:mojia-sku-sync:retry') + OR (permission_code = 'platform:mojia-sku-sync:list' AND id <> 5102131) + OR (permission_code = 'platform:mojia-sku-sync:edit' AND id <> 5102132) + OR (permission_code = 'platform:mojia-sku-sync:retry' AND id <> 5102133) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'mojia sku sync permission id/code conflict'; + END IF; + + INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) + VALUES + (5102131, 0, 'PLATFORM', 'MENU', 5103000, 'platform:mojia-sku-sync:list', '称重机器', '/general/mojia-sku-sync', 'GET', 130, 'ACTIVE', 1, 0), + (5102132, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:edit', '编辑SKU测量数据', NULL, 'PUT', 10, 'ACTIVE', 1, 0), + (5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送SKU测量', NULL, 'POST', 20, 'ACTIVE', 1, 0) + ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + http_method = VALUES(http_method), status = 'ACTIVE', built_in = 1, deleted = 0; + + INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) + SELECT role.id, permission.id, role.tenant_id + FROM iam_role role + JOIN iam_permission permission ON permission.tenant_id = 0 + AND permission.app_code = 'PLATFORM' + WHERE role.tenant_id = 0 AND role.app_code = 'PLATFORM' + AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') + AND permission.permission_code IN ( + 'platform:mojia-sku-sync:list', 'platform:mojia-sku-sync:edit', 'platform:mojia-sku-sync:retry'); +END$$ +DELIMITER ; + +CALL migrate_mojia_sku_sync_permissions(); +DROP PROCEDURE IF EXISTS migrate_mojia_sku_sync_permissions; diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index 7a9917d00..f26c40e87 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -81,6 +81,7 @@ VALUES (5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 0), (5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 0), (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', '/downloads', NULL, 40, 'ACTIVE', 0), + (5102131, 0, 'PLATFORM', 'MENU', 5103000, 'platform:mojia-sku-sync:list', '一件代发SKU同步', '/general/mojia-sku-sync', 'GET', 130, 'ACTIVE', 0), (5104010, 0, 'PLATFORM', 'MENU', 5104000, 'platform:audit:list', '操作日志', '/audit/logs', NULL, 10, 'ACTIVE', 0), -- Platform: buttons. (5100020, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:review', '客户审核', NULL, NULL, 50, 'ACTIVE', 0), @@ -118,6 +119,8 @@ VALUES (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', NULL, NULL, 20, 'ACTIVE', 0), (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启用停用往来单位', NULL, NULL, 30, 'ACTIVE', 0), (5103034, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:import', '批量导入往来单位', NULL, NULL, 40, 'ACTIVE', 0) + ,(5102132, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:edit', '编辑一件代发SKU测量数据', NULL, 'PUT', 10, 'ACTIVE', 0) + ,(5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送一件代发SKU', NULL, 'POST', 20, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name), route_path = VALUES(route_path), @@ -177,6 +180,7 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000003, id, 0 FROM iam_permission WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' + OR permission_code LIKE 'platform:mojia-sku-sync:%' OR permission_code = 'platform:download:list' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/phase3-warehouse-roles.sql b/iam/src/main/resources/db/phase3-warehouse-roles.sql index 63424653a..028279ff8 100644 --- a/iam/src/main/resources/db/phase3-warehouse-roles.sql +++ b/iam/src/main/resources/db/phase3-warehouse-roles.sql @@ -3,6 +3,26 @@ USE baiyue_iam; SET NAMES utf8mb4; +DROP PROCEDURE IF EXISTS ensure_domestic_pda_operator_warehouse_column; +DELIMITER $$ +CREATE PROCEDURE ensure_domestic_pda_operator_warehouse_column() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'iam_membership' + AND column_name = 'domestic_warehouse_id' + ) THEN + ALTER TABLE iam_membership + ADD COLUMN domestic_warehouse_id BIGINT NULL + COMMENT '国内头程PDA操作员绑定的唯一国内作业仓ID' + AFTER overseas_warehouse_id, + ADD KEY idx_membership_domestic_warehouse (tenant_id, domestic_warehouse_id, status); + END IF; +END$$ +DELIMITER ; +CALL ensure_domestic_pda_operator_warehouse_column(); +DROP PROCEDURE ensure_domestic_pda_operator_warehouse_column; + UPDATE iam_role SET role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', role_name = '海外仓 PDA 作业员', @@ -72,13 +92,16 @@ CREATE TEMPORARY TABLE tmp_phase3_warehouse_role_permission ( PRIMARY KEY (role_code, permission_code) ) ENGINE=MEMORY; --- 操作员:查看入仓业务并维护后台装柜证据,不授予完成装柜、任务分派或出仓动作。 +-- 仓管:可查看并确认预装、指定国内 PDA 作业员,不授予 PDA 执行或出仓动作。 INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES ('WAREHOUSE_OPERATOR', 'platform:order'), ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:list'), ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:view'), ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:view'), - ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'); + ('WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'), + ('WAREHOUSE_OPERATOR', 'platform:firstleg:preload:list'), + ('WAREHOUSE_OPERATOR', 'platform:firstleg:preload:edit'), + ('WAREHOUSE_OPERATOR', 'platform:firstleg:pda:assign'); -- 国内头程 PDA 作业员:只允许国内任务查询、作业和现场照片,不授予后台、海外、分派或复核权限。 INSERT INTO tmp_phase3_warehouse_role_permission (role_code, permission_code) VALUES @@ -112,7 +135,7 @@ BEGIN COALESCE(SUM(role_code = 'WAREHOUSE_TALLY'), 0) INTO operator_permission_count, domestic_pda_permission_count, tally_permission_count FROM tmp_phase3_warehouse_role_permission; - IF operator_permission_count <> 5 + IF operator_permission_count <> 8 OR domestic_pda_permission_count <> 2 OR tally_permission_count <> 12 THEN SIGNAL SQLSTATE '45000' @@ -208,7 +231,7 @@ BEGIN JOIN iam_role_permission grant_row ON grant_row.role_id = role.id AND grant_row.permission_id = permission.id - AND grant_row.tenant_id = 0) <> 19 THEN + AND grant_row.tenant_id = 0) <> 22 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仓库角色初始化失败:最小权限授权未完整写入'; END IF; diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 46af4d962..63643eabb 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -435,6 +435,18 @@ paths: responses: '200': {$ref: '#/components/responses/Success'} '403': {$ref: '#/components/responses/Forbidden'} + /api/platform/v1/iam/users/domestic-pda-operators: + get: + tags: [IAM Resources / IAM 资源] + summary: 查询启用的国内头程 PDA 作业员候选 + description: 仅返回具有 CN_FIRSTLEG_PDA_OPERATOR 角色且账号、成员、角色均启用的平台人员。 + x-permission: platform:firstleg:pda:assign + parameters: + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1, description: 仅返回绑定到该国内仓的国内头程 PDA 作业员}} + - {name: keyword, in: query, required: false, schema: {type: string}} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} /api/platform/v1/iam/users/{membershipId}: parameters: - {$ref: '#/components/parameters/MembershipId'} @@ -993,6 +1005,19 @@ paths: total: {type: integer, format: int64} pages: {type: integer} '403': {$ref: '#/components/responses/Forbidden'} + /internal/iam/domestic-pda-operators/{authUserId}: + get: + tags: [IAM Resources / IAM 资源] + summary: 校验并返回启用的国内头程 PDA 作业员 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + - {name: authUserId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} /internal/exports/v1/CUSTOMER_LIST/batches: post: tags: [Internal Export Provider] @@ -1550,6 +1575,7 @@ components: tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择 OVERSEAS_WAREHOUSE_PDA_OPERATOR 时必填且只能绑定一个实际海外仓;CN_FIRSTLEG_PDA_OPERATOR 与其他角色必须为空;两个 PDA 角色不能同时分配} + domesticWarehouseId: {type: integer, format: int64, nullable: true, description: 选择 CN_FIRSTLEG_PDA_OPERATOR 时必填且只能绑定一个有效国内作业仓;非国内头程 PDA 角色时必须为空;两个 PDA 角色不能同时分配} password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} status: {type: string, enum: [ACTIVE, DISABLED]} UpdatePlatformManagedUserRequest: @@ -1562,6 +1588,7 @@ components: tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择 OVERSEAS_WAREHOUSE_PDA_OPERATOR 时必填且只能绑定一个实际海外仓;CN_FIRSTLEG_PDA_OPERATOR 与其他角色必须为空;两个 PDA 角色不能同时分配} + domesticWarehouseId: {type: integer, format: int64, nullable: true, description: 选择 CN_FIRSTLEG_PDA_OPERATOR 时必填且只能绑定一个有效国内作业仓;非国内头程 PDA 角色时必须为空;两个 PDA 角色不能同时分配} CreateInternalUserRequest: type: object required: [displayName] @@ -1635,6 +1662,7 @@ components: lastLoginAt: {type: string, format: date-time, nullable: true} protectedAccount: {type: boolean} overseasWarehouseId: {type: string, pattern: '^\d+$', nullable: true, description: 海外仓PDA操作员绑定的唯一实际海外仓ID} + domesticWarehouseId: {type: string, pattern: '^\d+$', nullable: true, description: 国内头程PDA操作员绑定的唯一国内作业仓ID} CreateMemberRequest: type: object required: [username, password, displayName, memberType] diff --git a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java index ca4efc0c5..ba3475e83 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java @@ -3,6 +3,7 @@ package com.baiyue.iam.controller; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch; +import com.baiyue.iam.service.DomesticPdaOperatorService; import com.baiyue.iam.service.MerchantOnboardingService; import com.baiyue.iam.service.ProfileResourceService; import com.baiyue.iam.service.TenantService; @@ -26,6 +27,7 @@ class InternalCustomerExportContractTest { @MockBean private MerchantOnboardingService onboardingService; @MockBean private ProfileResourceService profileResourceService; @MockBean private TenantService service; + @MockBean private DomesticPdaOperatorService domesticPdaOperatorService; @Resource private InternalTenantController controller; @Test void rejectsMissingInternalTokenAndAcceptsConfiguredToken() { diff --git a/iam/src/test/java/com/baiyue/iam/repository/DomesticPdaOperatorSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/DomesticPdaOperatorSqlContractTest.java new file mode 100644 index 000000000..8ced07f80 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/DomesticPdaOperatorSqlContractTest.java @@ -0,0 +1,20 @@ +package com.baiyue.iam.repository; + +import com.baiyue.iam.mapper.IamMembershipRoleMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DomesticPdaOperatorSqlContractTest { + @Test + void candidatesUseActivePlatformRoleAndAuthAccountIdentity() throws Exception { + String sql = IamMembershipRoleMapper.class.getDeclaredMethod("findActiveUsersByRoleCode", + long.class, String.class, String.class, Long.class, Long.class).getAnnotation(Select.class).value()[0]; + + assertThat(sql).contains("u.auth_user_id AS userId", "role.role_code = #{roleCode}", + "role.app_code = 'PLATFORM'", "role.status = 'ACTIVE'", "m.status = 'ACTIVE'", + "u.status = 'ACTIVE'", "u.auth_user_id = #{authUserId}") + .doesNotContain("baiyue_auth"); + } +} 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 b8acb4b52..22865c6a1 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java @@ -46,7 +46,7 @@ class WarehouseRoleSqlContractTest { .contains("'WAREHOUSE_TALLY', 'platform:firstleg:pda:review'") .contains("'WAREHOUSE_TALLY', 'platform:firstleg:inbound:loading:edit'") .contains("'WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:edit'") - .contains("operator_permission_count <> 5") + .contains("operator_permission_count <> 8") .contains("domestic_pda_permission_count <> 2") .contains("tally_permission_count <> 12") .contains("依赖的三期平台权限尚未完整部署") @@ -54,7 +54,7 @@ class WarehouseRoleSqlContractTest { .contains("expected.permission_code IS NULL") .contains("role.role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' AND permission.permission_code = 'lastleg:pda:operate'") .contains("最小权限授权未完整写入") - .contains("grant_row.tenant_id = 0) <> 19") + .contains("grant_row.tenant_id = 0) <> 22") .contains("(6000004, 4000004, 0, 'TENANT', NULL)") .contains("(6000005, 4000005, 0, 'TENANT', NULL)") .contains("(6000006, 4000006, 0, 'TENANT', NULL)") @@ -80,7 +80,7 @@ class WarehouseRoleSqlContractTest { .doesNotContain("platform:firstleg:inbound:picking:view") .doesNotContain("platform:firstleg:outbound-dispatch:confirm"); assertThat(sql) - .doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:pda:assign'") + .contains("'WAREHOUSE_OPERATOR', 'platform:firstleg:pda:assign'") .doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:inbound:loading:complete'") .doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:confirm'") .doesNotContain("'WAREHOUSE_OPERATOR', 'platform:firstleg:outbound-dispatch:reverse'") diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java index 2fcc59dd1..e138c9d1b 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java @@ -70,6 +70,23 @@ class CustomerDimensionInitializationRelayTest { anyString(), any(Boolean.class)); } + @Test + void blankCustomerNameUsesDeterministicTenantFallback() { + CustomerDimensionInitializationTask task = new CustomerDimensionInitializationTask( + 10001L, 9001L, " ", "iam-customer-create:10001", "INITIALIZE", "SENDING", 0, 3); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.initialize(anyString(), any())).thenReturn(ApiResponse.success( + new CustomerDimensionRuleClient.InitializationResponse(91L, 191L, true))); + + relay.deliver(10001L); + + ArgumentCaptor request = + ArgumentCaptor.forClass(CustomerDimensionRuleClient.InitializationRequest.class); + verify(client).initialize(anyString(), request.capture()); + assertTrue("客户-10001".equals(request.getValue().customerName())); + verify(repository).markReady(task, workerId(), 91L); + } + @Test void transientFailureStaysPendingWithDiagnosticAndBackoff() { CustomerDimensionInitializationTask task = task("INITIALIZE", 0); diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index a962c5162..7ea635ea1 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -58,7 +58,7 @@ | 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 | -| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` | +| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;当前 V1 审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml`。阶段 0A—0E 开发目标另读 `../../docs/财务管理/海外仓费用统一接入阶段0技术契约.md`,收费身份、候选版本、Finance 结果事件与资金计划 V2 尚须以实际代码落地验证,不能因文档存在宣称已实现 | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 | | 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 | 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 b73a5ffe4..1b283ce63 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 @@ -17,6 +17,9 @@ import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LastlegOrderCommandService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import com.baiyue.common.event.EventTypes; import com.baiyue.lastleg.wallet.WalletCommandCompletedConsumer; @@ -52,6 +55,7 @@ public class LastlegOrderController { @Resource private LastlegOrderCommandService commandService; @Resource private InternalServiceTokenGuard tokenGuard; @Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer; + @Resource private RelabelApplicationService relabelApplicationService; @PostMapping("/internal/lastleg/events") @Operation(summary = "Receive a first-leg loading or actual shipping-node event", @@ -163,6 +167,18 @@ public class LastlegOrderController { return ApiResponse.success(orderService.detail(platformScope(user, customerId), id)); } + @PutMapping("/api/platform/v1/last-leg/orders/{id}/relabel-review") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Review a directly-created labeling order in the tail-leg order workbench") + public ApiResponse reviewDirectRelabel( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @Valid @RequestBody RelabelRequests.ChangeStatus request) { + return ApiResponse.success(relabelApplicationService.reviewDirectTailOrder( + platformScope(user, customerId), id, operatorId(user), request)); + } + @PostMapping("/api/platform/v1/last-leg/orders/{id}/fees") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") @Operation(summary = "Add a fee to one tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 5cf0b1f75..46a1a9ed0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -26,10 +26,13 @@ import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; import com.baiyue.lastleg.delivery.entity.OutboundPdaPick; import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper; @@ -107,6 +110,8 @@ public class DeliveryTransferService { @Resource private DropshipSkuInventoryMapper dropshipSkuInventory; @Resource private ReturnInventoryCartonMapper returnCartons; @Resource private InboundPlanPalletAllocationMapper planAllocations; + @Resource private InboundPlanPalletMapper inboundPlanPallets; + @Resource private WarehouseResourceGateway warehouseResources; @Resource private InboundPlanLineMapper planLines; @Resource private InboundPlanCartonMapper inboundCartons; @Resource private LastlegOrderMapper tailOrders; @@ -430,33 +435,38 @@ public class DeliveryTransferService { new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, trim(customerId)) .eq(StringUtils.hasText(warehouseId), WarehouseTransferOrder::getOperatingWarehouseId, trim(warehouseId)) - .eq(WarehouseTransferOrder::getStatus, "WAIT_FBA_DESTINATION") + // 已确定目的仓的转运单在完成入库/换标后进入 READY_FOR_DISPATCH; + // 未确定目的仓的历史单据仍保留 WAIT_FBA_DESTINATION,均应进入出库候选。 + .in(WarehouseTransferOrder::getStatus, "WAIT_FBA_DESTINATION", "READY_FOR_DISPATCH") .orderByDesc(WarehouseTransferOrder::getUpdatedAt)); List result = new ArrayList<>(); for (WarehouseTransferOrder transfer : transfers) { + boolean destinationBound = "FBA".equals(transfer.getDestinationType()) + ? StringUtils.hasText(transfer.getDestinationWarehouseCode()) + : "PRIVATE".equals(transfer.getDestinationType()) + && StringUtils.hasText(transfer.getPrivateDestinationAddress()); + // This screen is the destination-completion queue. Once a real + // destination is bound, the remaining inventory belongs in smart + // dispatch and must disappear from this list. + if (destinationBound) continue; LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId()); List completedPallets = warehouseTransferPallets.listOrder( transfer.getCustomerId(), transfer.getId()).stream() .filter(pallet -> "COMPLETED".equals(pallet.getStatus()) && pallet.getWarehousePalletId() != null) .toList(); - long available = 0; for (WarehouseTransferPallet pallet : completedPallets) { long palletAvailable = inventoryPositions.listPallet(transfer.getCustomerId(), pallet.getWarehousePalletId()) .stream().filter(position -> transfer.getOperatingWarehouseId().equals(position.getOverseasWarehouseId())) .mapToLong(position -> value(position.getAvailablePackages())).sum(); - available += palletAvailable; + if (palletAvailable <= 0) continue; + result.add(new DeliveryResponses.WarehouseTransferOutboundCandidate( + transfer.getCustomerId(), order == null ? null : order.getCustomerName(), transfer.getTransferNo(), + transfer.getOperatingWarehouseId(), transfer.getStatus(), Boolean.TRUE.equals(transfer.getNeedRelabel()), + transfer.getRelabelNo(), transfer.getDestinationType(), transfer.getDestinationWarehouseCode(), + transfer.getPrivateDestinationAddress(), transfer.getPrivateDestinationPostalCode(), + pallet.getId(), pallet.getWarehousePalletId(), pallet.getPalletNo(), Math.toIntExact(palletAvailable), + pallet.getGrossWeightKg(), pallet.getVolumeCbm())); } - if (available <= 0 || completedPallets.isEmpty()) continue; - result.add(new DeliveryResponses.WarehouseTransferOutboundCandidate( - transfer.getCustomerId(), order == null ? null : order.getCustomerName(), transfer.getTransferNo(), - transfer.getOperatingWarehouseId(), transfer.getStatus(), Boolean.TRUE.equals(transfer.getNeedRelabel()), - transfer.getRelabelNo(), transfer.getDestinationType(), transfer.getDestinationWarehouseCode(), - transfer.getPrivateDestinationAddress(), transfer.getPrivateDestinationPostalCode(), - null, null, null, Math.toIntExact(available), - completedPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg).filter(Objects::nonNull) - .reduce(BigDecimal.ZERO, BigDecimal::add), - completedPallets.stream().map(WarehouseTransferPallet::getVolumeCbm).filter(Objects::nonNull) - .reduce(BigDecimal.ZERO, BigDecimal::add))); } return result; } @@ -925,9 +935,41 @@ public class DeliveryTransferService { } } } + releaseEmptyOutboundPalletLocations(customerId, outbound.getOverseasWarehouseId(), items); return taskResult(outbound, task); } + /** Release the physical location only after the whole pallet has left inventory. */ + private void releaseEmptyOutboundPalletLocations(String customerId, String warehouseId, + List items) { + if (!StringUtils.hasText(warehouseId)) return; + Set palletIds = items.stream().map(OutboundPalletItem::getWarehousePalletId) + .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + for (Long palletId : palletIds) { + long remaining = inventoryPositions.listPallet(customerId, palletId).stream() + .filter(position -> warehouseId.equals(position.getOverseasWarehouseId())) + .mapToLong(position -> value(position.getAvailablePackages()) + + value(position.getHoldPackages()) + value(position.getAllocatedPackages()) + + value(position.getPickedPackages()) + value(position.getReceivingPackages())) + .sum(); + if (remaining != 0) continue; + Long lockId = null; + WarehouseTransferPallet transferPallet = warehouseTransferPallets.selectOne(new LambdaQueryWrapper() + .eq(WarehouseTransferPallet::getCustomerId, customerId) + .eq(WarehouseTransferPallet::getWarehousePalletId, palletId) + .last("LIMIT 1")); + if (transferPallet != null) lockId = transferPallet.getWarehouseResourceLockId(); + if (lockId == null && inboundPlanPallets != null) { + InboundPlanPallet planPallet = inboundPlanPallets.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, customerId) + .eq(InboundPlanPallet::getWarehousePalletId, palletId) + .last("LIMIT 1")); + if (planPallet != null) lockId = planPallet.getWarehouseResourceLockId(); + } + if (lockId != null) warehouseResources.release(customerId, lockId); + } + } + @Transactional public void cancelOutbound(String outboundNo, DeliveryRequests.Cancel request) { OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo)); @@ -959,7 +1001,7 @@ public class DeliveryTransferService { .filter(StringUtils::hasText).distinct().toList()) { WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo); if (transfer == null || !"WAIT_OUTBOUND".equals(transfer.getStatus())) continue; - String restored = StringUtils.hasText(transfer.getFbaWarehouseCode()) + String restored = "FBA".equals(transfer.getDestinationType()) || "PRIVATE".equals(transfer.getDestinationType()) ? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION"; transfer.setStatus(restored); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java index 415d0f873..f368e7224 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java @@ -19,7 +19,7 @@ public final class CargoDiscardRequests { @NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} - public record Item(@NotNull Long orderId, @NotBlank @Size(max = 128) String skuCode, + public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode, @Positive int discardPackages) {} public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java index 7d906b439..1fa1c8e8f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -7,7 +7,8 @@ import java.util.List; public final class CargoDiscardResponses { private CargoDiscardResponses() {} - public record Candidate(Long orderId, String inboundCode, String inboundOrderNo, + public record Candidate(Long orderId, String sourceType, Long returnCartonId, String cartonMark, + String inboundCode, String inboundOrderNo, String overseasWarehouseId, String overseasWarehouseName, LocalDateTime inboundCreatedAt, long availablePackages, List skus) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java index ee1560a1e..bd21d4164 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java @@ -14,6 +14,8 @@ public class CargoDiscardItem { private Long applicationId; private String customerId; private Long orderId; + private String sourceType; + private Long returnCartonId; private String inboundCode; private Long profileId; private Long skuInventoryId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index a94ee568a..7ffd2a9b5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -10,6 +10,8 @@ import com.baiyue.lastleg.discard.service.CargoDiscardService; import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -27,6 +29,7 @@ public class PdaCargoDiscardService { @Resource private DropshipOrderProfileMapper profiles; @Resource private CargoDiscardInventoryAdjustmentService adjustments; @Resource private CargoDiscardService discardService; + @Resource private ReturnInventoryCartonMapper returnCartons; public List tasks(PdaWarehouseIdentity identity) { return applications.selectList(new LambdaQueryWrapper() @@ -69,6 +72,9 @@ public class PdaCargoDiscardService { item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED"); item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); items.updateById(item); } + task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(items::selectById) + .filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct() + .forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId)); if (items.listApplication(application.getCustomerId(), application.getId()).stream() .allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) { int actualTotal = items.listApplication(application.getCustomerId(), application.getId()).stream() @@ -83,17 +89,31 @@ public class PdaCargoDiscardService { private List palletTasks(CargoDiscardApplication application) { return items.listApplication(application.getCustomerId(), application.getId()).stream() - .collect(Collectors.groupingBy(CargoDiscardItem::getProfileId, LinkedHashMap::new, Collectors.toList())) + .collect(Collectors.groupingBy(this::physicalGroupKey, LinkedHashMap::new, Collectors.toList())) .entrySet().stream().map(entry -> { - DropshipOrderProfile profile = profiles.selectById(entry.getKey()); - if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) throw conflict("废弃明细关联托盘不存在"); + CargoDiscardItem first = entry.getValue().get(0); + boolean returnSource = isReturnItem(first); + DropshipOrderProfile profile = returnSource ? null : profiles.selectById(first.getProfileId()); + ReturnInventoryCarton returnCarton = returnSource ? returnCartons.selectById(first.getReturnCartonId()) : null; + if (!returnSource && (profile == null || !application.getCustomerId().equals(profile.getCustomerId()))) throw conflict("废弃明细关联托盘不存在"); + if (returnSource && (returnCarton == null || !application.getCustomerId().equals(returnCarton.getCustomerId()))) throw conflict("废弃明细关联退货箱不存在"); List skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku( i.getId(), i.getSkuCode(), i.getProductName(), i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList(); String status = skus.stream().allMatch(s -> "COMPLETED".equals(s.status())) ? "COMPLETED" : "PENDING"; return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), - application.getOverseasWarehouseId(), profile.getPalletNo(), profile.getId(), status, skus); + application.getOverseasWarehouseId(), returnSource ? firstNonBlank(returnCarton.getPalletCode(), returnCarton.getCartonMark()) : profile.getPalletNo(), + returnSource ? returnCarton.getId() : profile.getId(), status, skus); }).toList(); } + private String physicalGroupKey(CargoDiscardItem item) { + return isReturnItem(item) ? "RETURN:" + item.getReturnCartonId() : "DROPSHIP:" + item.getProfileId(); + } + private boolean isReturnItem(CargoDiscardItem item) { + return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null; + } + private static String firstNonBlank(String first, String second) { + return first != null && !first.isBlank() ? first : second; + } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java index 51f106d68..3eef8cdd8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java @@ -15,6 +15,9 @@ import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -37,11 +40,14 @@ public class CargoDiscardInventoryAdjustmentService { @Resource private DropshipOrderProfileMapper profiles; @Resource private InventoryPositionMapper positions; @Resource private InventoryBalanceMapper balances; + @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private ReturnInventoryCartonItemMapper returnCartonItems; @Transactional(propagation = Propagation.MANDATORY) public AdjustmentResult discardPalletItem(String customerId, CargoDiscardApplication application, CargoDiscardItem item, List links, int actualPackages, String operatorId, LocalDateTime occurredAt) { + if (isReturnItem(item)) return discardReturnItem(customerId, item, actualPackages); if (actualPackages < 0 || actualPackages > links.size()) throw conflict("实际废弃数量超出该托盘申请数量"); DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId()); if (sku == null) throw conflict("托盘 SKU 库存不存在: " + item.getSkuCode()); @@ -88,6 +94,36 @@ public class CargoDiscardInventoryAdjustmentService { return new AdjustmentResult(before, actualPackages, after); } + private AdjustmentResult discardReturnItem(String customerId, CargoDiscardItem item, int actualPackages) { + if (actualPackages < 0 || actualPackages > item.getDiscardPackages()) { + throw conflict("实际废弃数量超出退货库存申请数量"); + } + ReturnInventoryCartonItem sku = returnCartonItems.lockSku(item.getReturnCartonId(), item.getSkuCode()); + if (sku == null || !customerId.equals(sku.getCustomerId())) throw conflict("退货库存 SKU 不存在"); + long before = value(sku.getQuantity()); + if (before < actualPackages || (actualPackages > 0 && returnCartonItems.deduct(sku.getId(), actualPackages) != 1)) { + throw conflict("退货库存数量不足"); + } + return new AdjustmentResult(before, actualPackages, before - actualPackages); + } + + @Transactional(propagation = Propagation.MANDATORY) + public void finishReturnCarton(String customerId, long cartonId) { + var carton = returnCartons.lockId(cartonId); + if (carton == null || !customerId.equals(carton.getCustomerId())) throw conflict("退货库存箱不存在"); + if ("AVAILABLE".equals(carton.getStatus()) || "DISCARDED".equals(carton.getStatus())) return; + long remaining = returnCartonItems.totalQuantity(cartonId); + int skuTypes = returnCartonItems.skuTypes(cartonId); + String status = remaining == 0 ? "DISCARDED" : "AVAILABLE"; + if (returnCartons.finishDiscard(cartonId, customerId, status, remaining, skuTypes) != 1) { + throw conflict("退货库存箱状态已变化"); + } + } + + private static boolean isReturnItem(CargoDiscardItem item) { + return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null; + } + @Transactional(propagation = Propagation.MANDATORY) public AdjustmentResult discard(String customerId, CargoDiscardApplication application, List items, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index 969cf160a..c7d1268ef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -22,6 +22,10 @@ import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -64,6 +68,8 @@ public class CargoDiscardService { @Resource private CargoDiscardInventoryAdjustmentService adjustments; @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderStatusHistoryMapper orderHistories; + @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private ReturnInventoryCartonItemMapper returnCartonItems; @Resource private ObjectMapper objectMapper; @Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}") private BigDecimal feePerPackage = new BigDecimal("5.00"); @@ -75,9 +81,25 @@ public class CargoDiscardService { CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row)); value.add(row); } - return grouped.values().stream() + List result = grouped.values().stream() .map(value -> value.response(inventory.countAvailableOrder(customerId, value.first.getOrderId()))) - .toList(); + .collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new)); + String keyword = trim(inboundCode); + if (returnCartons == null || returnCartonItems == null) return result; + for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, null)) { + if (StringUtils.hasText(keyword) && !containsIgnoreCase(carton.getSourceReturnNo(), keyword) + && !containsIgnoreCase(carton.getCartonMark(), keyword)) continue; + List skus = returnCartonItems.listCarton(carton.getId()).stream() + .filter(item -> value(item.getQuantity()) > 0) + .map(item -> new CargoDiscardResponses.CandidateSku(item.getSkuCode(), item.getProductNameZh(), value(item.getQuantity()))) + .toList(); + long available = skus.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum(); + if (available <= 0) continue; + result.add(new CargoDiscardResponses.Candidate(null, "RETURN_CARTON", carton.getId(), carton.getCartonMark(), + carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null, + null, available, skus)); + } + return result; } @Transactional @@ -94,18 +116,39 @@ public class CargoDiscardService { } Set requestedDimensions = new LinkedHashSet<>(); Map orderCache = new HashMap<>(); + Map returnCartonCache = new HashMap<>(); String warehouseId = null; String warehouseName = null; int total = 0; for (CargoDiscardRequests.Item input : request.items()) { - String dimension = input.orderId() + ":" + input.skuCode().trim(); + boolean returnSource = input.returnCartonId() != null; + if (returnSource == (input.orderId() != null)) { + throw badRequest("Each discard item must identify exactly one inventory source"); + } + String dimension = (returnSource ? "RETURN:" + input.returnCartonId() : "ORDER:" + input.orderId()) + + ":" + input.skuCode().trim(); if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension); - LastlegOrder order = requireDropshipOrder(customerId, input.orderId()); - orderCache.put(order.getId(), order); + String itemWarehouseId; + String itemWarehouseName; + if (returnSource) { + ReturnInventoryCarton carton = requireReturnCarton(customerId, input.returnCartonId()); + ReturnInventoryCartonItem sku = returnCartonItems.lockSku(carton.getId(), input.skuCode().trim()); + if (sku == null || value(sku.getQuantity()) < input.discardPackages()) { + throw conflict("Insufficient return inventory for SKU " + input.skuCode().trim()); + } + returnCartonCache.put(carton.getId(), carton); + itemWarehouseId = carton.getOverseasWarehouseId(); + itemWarehouseName = null; + } else { + LastlegOrder order = requireDropshipOrder(customerId, input.orderId()); + orderCache.put(order.getId(), order); + itemWarehouseId = order.getOverseasWarehouseId(); + itemWarehouseName = order.getOverseasWarehouseName(); + } if (warehouseId == null) { - warehouseId = required(order.getOverseasWarehouseId(), "Order has no operating overseas warehouse"); - warehouseName = trim(order.getOverseasWarehouseName()); - } else if (!warehouseId.equals(order.getOverseasWarehouseId())) { + warehouseId = required(itemWarehouseId, "Inventory has no operating overseas warehouse"); + warehouseName = trim(itemWarehouseName); + } else if (!warehouseId.equals(itemWarehouseId)) { throw conflict("One cargo discard application cannot span operating warehouses"); } total = Math.addExact(total, input.discardPackages()); @@ -116,7 +159,9 @@ public class CargoDiscardService { application.setId(IdWorker.getId()); application.setCustomerId(customerId); application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId())); application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName); - application.setInboundCodes(orderCache.values().stream().map(LastlegOrder::getInboundCode) + application.setInboundCodes(java.util.stream.Stream.concat( + orderCache.values().stream().map(LastlegOrder::getInboundCode), + returnCartonCache.values().stream().map(ReturnInventoryCarton::getSourceReturnNo)) .distinct().sorted().collect(java.util.stream.Collectors.joining(","))); application.setTotalPackages(total); application.setFeePerPackage(feePerPackage); application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total))); @@ -157,8 +202,14 @@ public class CargoDiscardService { initialOrderHistory.setEventId("CARGO_DISCARD_CREATE:" + application.getId()); initialOrderHistory.setRemark(trim(request.remark())); initialOrderHistory.setCreatedAt(now); orderHistories.insert(initialOrderHistory); + Set reservedReturnCartons = new LinkedHashSet<>(); for (CargoDiscardRequests.Item input : request.items()) { - reserve(customerId, application, orderCache.get(input.orderId()), input, now); + if (input.returnCartonId() != null) { + reserveReturn(customerId, application, returnCartonCache.get(input.returnCartonId()), input, now, + reservedReturnCartons); + } else { + reserve(customerId, application, orderCache.get(input.orderId()), input, now); + } } history(customerId, application.getId(), null, PENDING_REVIEW, operatorId, request.remark(), now); return detail(customerId, application.getDiscardNo()); @@ -215,7 +266,7 @@ public class CargoDiscardService { .collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId)); String historyRemark = request.remark(); if (REJECTED.equals(next)) { - release(customerId, links); + release(customerId, links, applicationItems); } LocalDateTime reviewedAt = now(); @@ -275,7 +326,7 @@ public class CargoDiscardService { throw conflict("客户只能取消待审核的废弃申请"); } List links = discardCartons.listApplication(customerId, application.getId()); - release(customerId, links); + release(customerId, links, items.listApplication(customerId, application.getId())); LocalDateTime at = now(); String from = application.getStatus(); application.setStatus(CANCELLED); application.setReviewedBy(platform ? operatorId : application.getReviewedBy()); @@ -309,6 +360,7 @@ public class CargoDiscardService { if (sku == null) throw conflict("SKU inventory disappeared while reserving cargo"); CargoDiscardItem item = new CargoDiscardItem(); item.setId(IdWorker.getId()); item.setApplicationId(application.getId()); item.setCustomerId(customerId); + item.setSourceType("DROPSHIP"); item.setOrderId(order.getId()); item.setInboundCode(order.getInboundCode()); item.setProfileId(entry.getKey()); item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName()); item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile( @@ -326,14 +378,50 @@ public class CargoDiscardService { } } - private void release(String customerId, List links) { + private void release(String customerId, List links, List applicationItems) { for (CargoDiscardCarton link : links) { if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) { throw conflict("Reserved cargo status changed before rejection"); } } + applicationItems.stream().filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId) + .filter(java.util.Objects::nonNull).distinct().forEach(cartonId -> { + if (returnCartons.releaseDiscard(cartonId, customerId) != 1) { + throw conflict("Reserved return inventory status changed before rejection"); + } + }); } + private void reserveReturn(String customerId, CargoDiscardApplication application, ReturnInventoryCarton carton, + CargoDiscardRequests.Item input, LocalDateTime now, Set reservedCartons) { + ReturnInventoryCartonItem sku = returnCartonItems.lockSku(carton.getId(), input.skuCode().trim()); + if (sku == null || value(sku.getQuantity()) < input.discardPackages()) { + throw conflict("Available return inventory changed; retry the discard request"); + } + if (reservedCartons.add(carton.getId()) && returnCartons.markDiscardPending(carton.getId(), customerId) != 1) { + throw conflict("Return inventory was reserved concurrently; retry the discard request"); + } + CargoDiscardItem item = new CargoDiscardItem(); + item.setId(IdWorker.getId()); item.setApplicationId(application.getId()); item.setCustomerId(customerId); + item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(carton.getId()); item.setInboundCode(carton.getSourceReturnNo()); + item.setSkuCode(sku.getSkuCode()); item.setProductName(sku.getProductNameZh()); + item.setAvailablePackagesSnapshot(Math.toIntExact(value(sku.getQuantity()))); + item.setDiscardPackages(input.discardPackages()); item.setCreatedAt(now); items.insert(item); + } + + private ReturnInventoryCarton requireReturnCarton(String customerId, Long cartonId) { + ReturnInventoryCarton carton = returnCartons.lockId(cartonId); + if (carton == null || !customerId.equals(carton.getCustomerId())) throw notFound("Return inventory carton not found"); + if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("Return inventory carton is not available"); + return carton; + } + + private boolean isReturnItem(CargoDiscardItem item) { + return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null; + } + + private static long value(Long value) { return value == null ? 0L : value; } + private LastlegOrder requireDropshipOrder(String customerId, Long orderId) { LastlegOrder order = orders.selectById(orderId); if (order == null || !customerId.equals(order.getCustomerId())) throw notFound("Inbound order not found"); @@ -385,6 +473,9 @@ public class CargoDiscardService { private static LocalDateTime now() { return LocalDateTime.now(); } private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static boolean containsIgnoreCase(String value, String keyword) { + return value != null && keyword != null && value.toLowerCase(java.util.Locale.ROOT).contains(keyword.toLowerCase(java.util.Locale.ROOT)); + } private static String required(String value, String message) { String result = trim(value); if (result == null) throw conflict(message); return result; } private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } @@ -401,7 +492,7 @@ public class CargoDiscardService { private CargoDiscardResponses.Candidate response(long physicalPackages) { List skuViews = skus.values().stream() .map(value -> new CargoDiscardResponses.CandidateSku(value.skuCode, value.productName, value.packages)).toList(); - return new CargoDiscardResponses.Candidate(first.getOrderId(), first.getInboundCode(), first.getInboundOrderNo(), + return new CargoDiscardResponses.Candidate(first.getOrderId(), "DROPSHIP", null, null, first.getInboundCode(), first.getInboundOrderNo(), first.getOverseasWarehouseId(), first.getOverseasWarehouseName(), first.getCreatedAt(), physicalPackages, skuViews); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index f504f93d4..3495e7769 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -60,7 +60,7 @@ public interface DispatchCandidateMapper { "GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," + "p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," + "p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + - "MIN(l.fba_warehouse_code) AS fbaWarehouseCode," + + "COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " + "ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " + @@ -79,15 +79,20 @@ public interface DispatchCandidateMapper { "WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " + "AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " + "AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," + - "MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," + + "COALESCE(NULLIF(MIN(l.destination_type),''),NULLIF(p.destination_type,'')) AS destinationType,COALESCE(MIN(l.delivery_address),p.delivery_address) AS deliveryAddress," + "MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + + "LEFT JOIN ll_warehouse_transfer_order wt ON wt.customer_id=ip.customer_id AND wt.lastleg_order_id=ip.source_order_id " + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " + "LEFT JOIN ll_order o ON o.id=l.order_id " + - "WHERE ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " + + "WHERE ((ip.creation_mode='WAREHOUSE_TRANSFER' AND ((wt.destination_type='FBA' AND NULLIF(TRIM(wt.destination_warehouse_code),'') IS NOT NULL) OR " + + "(wt.destination_type='PRIVATE' AND NULLIF(TRIM(wt.private_destination_address),'') IS NOT NULL) OR " + + "(p.destination_type='FBA' AND NULLIF(TRIM(p.destination_warehouse_code),'') IS NOT NULL) OR " + + "(p.destination_type='PRIVATE' AND NULLIF(TRIM(p.delivery_address),'') IS NOT NULL))) OR " + + "(ip.creation_mode!='WAREHOUSE_TRANSFER' AND ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " + "(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " + - "AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " + + "AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)))) " + " AND ip.customer_id=#{customerId} " + " AND p.plan_id IN " + "#{id} " + @@ -107,12 +112,18 @@ public interface DispatchCandidateMapper { "d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " + "d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id))) " + "AND (#{draftToken} IS NULL OR d.draft_token IS NULL OR d.draft_token != #{draftToken})) " + - "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + + // Warehouse-transfer inventory is intentionally reserved before dispatch. It must still be + // eligible for the normal smart-dispatch flow once its destination is bound; other order + // types keep the reservation guard to prevent duplicate dispatching. + "AND (ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + "JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " + - "WHERE ta.customer_id=ip.customer_id AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " + - "AND NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " + + "WHERE ta.customer_id=ip.customer_id AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id)) " + + // A warehouse-transfer pallet may contain both relabel and non-relabel inventory. + // Do not hide the whole pallet because one carton/SKU has an active relabel item; + // the remaining available inventory must still be dispatchable. + "AND (ip.creation_mode!='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " + "WHERE rli.customer_id=ip.customer_id AND rli.source_inbound_order_id IS NOT NULL " + - "AND rli.source_inbound_order_id=o.id AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED')) " + + "AND rli.source_inbound_order_id=o.id AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED'))) " + "GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," + "p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " + "ORDER BY p.plan_id,p.sequence_no" + @@ -160,7 +171,7 @@ public interface DispatchCandidateMapper { "GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," + "p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," + "p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + - "MIN(l.fba_warehouse_code) AS fbaWarehouseCode," + + "COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " + "FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " + "ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " + @@ -179,31 +190,36 @@ public interface DispatchCandidateMapper { "WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " + "AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " + "AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," + - "MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," + + "COALESCE(NULLIF(MIN(l.destination_type),''),NULLIF(p.destination_type,'')) AS destinationType,COALESCE(MIN(l.delivery_address),p.delivery_address) AS deliveryAddress," + "MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + + "LEFT JOIN ll_warehouse_transfer_order wt ON wt.customer_id=ip.customer_id AND wt.lastleg_order_id=ip.source_order_id " + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " + "LEFT JOIN ll_order o ON o.id=l.order_id " + "WHERE ip.customer_id=#{customerId} AND p.id IN " + "#{id} " + - "AND ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " + + "AND ((ip.creation_mode='WAREHOUSE_TRANSFER' AND ((wt.destination_type='FBA' AND NULLIF(TRIM(wt.destination_warehouse_code),'') IS NOT NULL) OR " + + "(wt.destination_type='PRIVATE' AND NULLIF(TRIM(wt.private_destination_address),'') IS NOT NULL) OR " + + "(p.destination_type='FBA' AND NULLIF(TRIM(p.destination_warehouse_code),'') IS NOT NULL) OR " + + "(p.destination_type='PRIVATE' AND NULLIF(TRIM(p.delivery_address),'') IS NOT NULL))) OR " + + "(ip.creation_mode!='WAREHOUSE_TRANSFER' AND ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " + "(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " + - "AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " + + "AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)))) " + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + "AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + - "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + + "AND (ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + "JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " + - "WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " + - "AND NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " + + "WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id)) " + + "AND (ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_relabel_item rli JOIN ll_relabel_application rla ON rla.id=rli.application_id AND rla.customer_id=rli.customer_id " + "JOIN ll_inbound_plan_line rpl ON rpl.order_id=rli.source_inbound_order_id " + "WHERE rli.customer_id=#{customerId} AND rli.source_inbound_order_id IS NOT NULL AND rpl.id IN " + "(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id) " + - "AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED')) " + + "AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED'))) " + "GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," + "p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " + "ORDER BY p.id FOR UPDATE" + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index 2500ce175..9d9f22994 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -241,7 +241,12 @@ public class DispatchPdaWorkflowService { } } for (DispatchItem item : dispatchItems) { - if (item.getOrderId() != null) warehouseTransfers.markOutboundByOrder(customerId, item.getOrderId(), now); + // A relabel dispatch may consume only part of a warehouse-transfer + // pallet. Do not close the whole transfer by its parent order; the + // remaining cartons still need destination binding/outbound. + if (item.getOrderId() != null && !"RELABEL_ORDER".equals(item.getSourceBusinessType())) { + warehouseTransfers.markOutboundByOrder(customerId, item.getOrderId(), now); + } if ("RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getSourceBusinessNo() != null) { warehouseTransfers.markOutboundByRelabel(customerId, item.getSourceBusinessNo(), now); } 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 ede78069f..5d7ef8417 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 @@ -72,6 +72,8 @@ public record LastlegOrderSummary( String customerRemark, String warehouseRemark, LocalDateTime createdAt, - LocalDateTime updatedAt + LocalDateTime updatedAt, + @Schema(description = "同一主单关联的全部换标子单及各自状态,按创建时间排序") + List relabelApplications ) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/RelabelSubOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/RelabelSubOrder.java new file mode 100644 index 000000000..b9e4bac5e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/RelabelSubOrder.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.dto; + +import java.time.LocalDateTime; + +/** A relabel application keeps its own status within the received parent order. */ +public record RelabelSubOrder(Long lastlegOrderId, String relabelNo, String status, + Integer quantity, LocalDateTime createdAt) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java index 8bcd713b7..460d04827 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -14,6 +14,7 @@ public record InboundPlanResponse( String customerName, String orderType, String containerNo, + Long overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, String status, 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 31483e4be..61700639a 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 @@ -1124,7 +1124,7 @@ public class InboundPlanService { return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(), sourceOrder == null ? null : sourceOrder.orderNo(), sourceOrder == null ? null : sourceOrder.customerName(), - sourceOrder == null ? null : sourceOrder.orderType(), plan.getContainerNo(), + sourceOrder == null ? null : sourceOrder.orderType(), plan.getContainerNo(), authoritativeWarehouse.warehouseId(), authoritativeWarehouse.warehouseCode(), authoritativeWarehouse.warehouseName(), plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), totalGrossWeightKg, totalNetWeightKg, plan.getTotalVolumeCbm(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 2fb5bc742..26dd83ad7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -16,6 +16,11 @@ import java.math.BigDecimal; @Mapper public interface LastlegOrderMapper extends BaseMapper { + @Select("") + List findRelabelSubOrders(@Param("orderIds") List orderIds); String WAREHOUSE_FILTER_SQL = "(LOWER(TRIM(overseas_warehouse_code)) LIKE CONCAT('%',LOWER({0}),'%') " + "OR LOWER(TRIM(overseas_warehouse_name)) LIKE CONCAT('%',LOWER({0}),'%'))"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PlatformInventoryMoveController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PlatformInventoryMoveController.java new file mode 100644 index 000000000..b77eee11b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PlatformInventoryMoveController.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.pda.move.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.move.dto.PlatformInventoryMoveRequest; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse; +import com.baiyue.lastleg.pda.move.service.PdaInventoryMoveService; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +/** Browser/platform entry point; the PDA entry point remains restricted to warehouse operators. */ +@RestController +@RequestMapping("/api/platform/v1/last-leg/inventory-moves") +@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EDIT + "')") +public class PlatformInventoryMoveController { + @Resource private PdaInventoryMoveService service; + + @PostMapping + public ApiResponse move(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PlatformInventoryMoveRequest request) { + requirePlatform(user); + String operator = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(service.movePlatform(request.overseasWarehouseId(), operator, + request.asPdaRequest())); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PlatformInventoryMoveRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PlatformInventoryMoveRequest.java new file mode 100644 index 000000000..fa7954687 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PlatformInventoryMoveRequest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.pda.move.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +/** Platform/admin variant of the pallet put-away command. */ +public record PlatformInventoryMoveRequest( + @Positive long overseasWarehouseId, + @NotBlank @Size(max = 128) String sourcePalletCode, + @NotBlank @Size(max = 128) String targetLocationCode, + @PositiveOrZero int expectedPalletVersion, + @NotBlank @Size(max = 128) String idempotencyKey, + @NotBlank @Size(max = 500) String reason +) { + public PdaInventoryMoveRequest asPdaRequest() { + return new PdaInventoryMoveRequest(sourcePalletCode, targetLocationCode, + expectedPalletVersion, idempotencyKey, reason); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java index f5d5263f9..0bae11cf7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java @@ -45,6 +45,22 @@ public class PdaInventoryMoveService { @Transactional public PdaInventoryMoveResponse move(long warehouseId, String operatorKey, PdaInventoryMoveRequest request) { + return moveInternal(warehouseId, operatorKey, request); + } + + /** + * Platform/admin put-away uses the same command, idempotency record and physical + * transition as the PDA flow. Keeping one implementation prevents the two entry + * points from drifting on A0, virtual-location and inventory-position semantics. + */ + @Transactional + public PdaInventoryMoveResponse movePlatform(long warehouseId, String operatorKey, + PdaInventoryMoveRequest request) { + return moveInternal(warehouseId, "PLATFORM:" + required(operatorKey, "Operator is required"), request); + } + + private PdaInventoryMoveResponse moveInternal(long warehouseId, String operatorKey, + PdaInventoryMoveRequest request) { String key = required(request.idempotencyKey(), "Idempotency key is required"); String requestHash = hash(request); PdaInventoryMove existing = moves.find(warehouseId, operatorKey, key); @@ -58,6 +74,11 @@ public class PdaInventoryMoveService { if (!Set.of("ACTIVE", "IDLE", "OCCUPIED").contains(target.status())) { throw conflict("Target location is not available"); } + // A virtual receiving location is an intake staging position, not a put-away + // destination. A0 is deliberately allowed as a shared overflow location. + if ("VIRTUAL".equalsIgnoreCase(target.locationType())) { + throw conflict("Virtual receiving location cannot be used as a put-away target"); + } boolean recoveringRemoteSuccess = pallet.locationId() == target.locationId() && pallet.version() == request.expectedPalletVersion() + 1; if (!recoveringRemoteSuccess && pallet.version() != request.expectedPalletVersion()) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java index 56263ab90..419d2e3f4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java @@ -53,6 +53,12 @@ public class MerchantRelabelApplicationController { @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); } + @PutMapping("/{relabelNo}/resubmit") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_CREATE + "')") + public ApiResponse resubmit(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.Resubmit request) { + return ApiResponse.success(service.resubmit(customer(user), relabelNo, operator(user), request)); + } @PostMapping(value = "/{relabelNo}/shipping-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_FILES + "')") public ApiResponse uploadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index f43b27f0e..4595244e2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -129,6 +129,12 @@ public final class RelabelRequests { public record ChangeStatus(@NotBlank @Size(max = 32) String status, @Size(max = 1000) String remark) {} + public record Resubmit(@NotNull LocalDateTime labelDeadline, + @NotBlank @Size(max = 128) String contactName, + @NotBlank @Size(max = 64) String contactPhone, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey) {} + public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index 8e6052f4a..9e8d44564 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -18,7 +18,7 @@ public final class RelabelResponses { String walletErrorMessage, String deliveryMethod, boolean printShippingLabel, String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl, String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt, - LocalDateTime createdAt) {} + String reviewChannel, String reviewRemark, LocalDateTime createdAt) {} public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, String productNameZh, int totalPackageCount, int notifiedPackageCount, String shipmentNo, String trackingCode, @@ -38,7 +38,7 @@ public final class RelabelResponses { String destinationFbaWarehouseName, String skuCode, String productNameZh, long availablePackages, long version, List positions, String sourceType, Long returnCartonId, String cartonMark, - List skuItems) {} + List skuItems, long relabelledPackages) {} public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo, String relabelOrderNo, String orderType, String status, Integer packageCount, LocalDateTime createdAt) {} 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 index dc74a0191..fc19f9bce 100644 --- 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 @@ -9,7 +9,8 @@ public final class PdaRelabelResponses { public record Candidate(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, String applicationStatus, String taskStatus, String operationType, int itemCount, int expectedPackages, int relabelledPackages, int skippedPackages, - int exceptionPackages, int version, boolean claimedByCurrentOperator) { } + int exceptionPackages, int version, boolean claimedByCurrentOperator, + String reviewDecision, String reviewRemark, LocalDateTime reviewedAt) { } public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, String contactName, String contactPhone, String applicationStatus, String operationType, 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 index 93c912595..1668781b3 100644 --- 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 @@ -35,7 +35,8 @@ public interface PdaRelabelTaskMapper extends BaseMapper { 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}, + review_remark=#{task.reviewRemark}, claimed_at=#{task.claimedAt}, + 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} """) 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 index 2f783ae44..ec1a14951 100644 --- 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 @@ -53,6 +53,7 @@ import java.util.stream.Collectors; @Service public class PdaRelabelService { @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard; + public static final String AVAILABLE = "AVAILABLE"; public static final String PROCESSING = "PROCESSING"; public static final String WAIT_REVIEW = "WAIT_REVIEW"; public static final String COMPLETED = "COMPLETED"; @@ -116,7 +117,16 @@ public class PdaRelabelService { throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单"); } if (task != null) { - if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) { + if (AVAILABLE.equals(task.getStatus()) && "REWORK".equals(task.getReviewDecision())) { + int expectedVersion = task.getVersion(); + task.setStatus(PROCESSING); + task.setOperatorKey(operatorKey); + task.setOperatorName(operatorName); + task.setClaimedAt(LocalDateTime.now()); + updateTask(task, expectedVersion); + insertHistory(task, "REWORK_CLAIM", AVAILABLE, PROCESSING, operatorKey, operatorName, + Map.of("relabelNo", relabelNo)); + } else if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) { throw conflict("Relabel task is already claimed or awaiting review"); } } else { @@ -488,7 +498,7 @@ public class PdaRelabelService { requireVersion(task, request.expectedVersion()); // Preserve the previous round before freeing the active-label uniqueness constraint. List previousFacts = factList(task); - insertHistory(task, "REWORK_ARCHIVE", WAIT_REVIEW, PROCESSING, operatorKey, operatorName, + insertHistory(task, "REWORK_ARCHIVE", WAIT_REVIEW, AVAILABLE, operatorKey, operatorName, Map.of("facts", previousFacts, "photoFileIds", Objects.toString(task.getCompletionPhotoFileIds(), "[]"), "completionRemark", Objects.toString(task.getCompletionRemark(), ""), "remark", request.remark())); for (PdaRelabelUnitFact fact : previousFacts) { @@ -501,7 +511,7 @@ public class PdaRelabelService { applicationService.resetForRework(application.getCustomerId(), relabelNo, operatorKey, request.remark()); application.setStatus(PROCESSING); String before = task.getStatus(); - String after = PROCESSING; + String after = AVAILABLE; task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName); task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark())); task.setReviewedAt(LocalDateTime.now()); @@ -694,7 +704,9 @@ public class PdaRelabelService { 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())); + task != null && PROCESSING.equals(task.getStatus()) && operatorKey.equals(task.getOperatorKey()), + task == null ? null : task.getReviewDecision(), task == null ? null : task.getReviewRemark(), + task == null ? null : task.getReviewedAt()); } private PdaRelabelResponses.Detail detail(RelabelApplication application, PdaRelabelTask task) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 7869543ee..f91799008 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -260,7 +260,7 @@ public class RelabelApplicationService implements WalletResultHandler { balance.getOverseasWarehouseCode(), balance.getOverseasWarehouseName(), balance.getDestinationWarehouseCode(), balance.getDestinationWarehouseName(), balance.getSkuCode(), balance.getProductNameZh(), available, value(balance.getVersion()), positions, - "INBOUND", null, null, List.of()); + "INBOUND", null, null, List.of(), 0); }).filter(candidate -> candidate.availablePackages() > 0).toList()); for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) { String keyword = trim(inboundCode); @@ -269,19 +269,23 @@ public class RelabelApplicationService implements WalletResultHandler { && !containsIgnoreCase(carton.getSourceReturnNo(), keyword)) continue; List cartonSkus = returnCartonItems.listCarton(carton.getId()).stream() .filter(item -> value(item.getQuantity()) > 0) - .map(item -> new RelabelResponses.SkuItem(item.getSkuCode(), null, - Math.toIntExact(item.getQuantity()), null)).toList(); + .map(item -> { + long used = returnCartons.relabelledQuantity(carton.getId(), item.getSkuCode()); + long remaining = value(item.getQuantity()) - used; + return new RelabelResponses.SkuItem(item.getSkuCode(), null, + Math.toIntExact(Math.max(remaining, 0)), null); + }).filter(item -> item.quantity() > 0).toList(); if (cartonSkus.isEmpty()) continue; String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode) .collect(Collectors.joining(",")); - long availablePackages = value(carton.getTotalQuantity()); - if (availablePackages <= 0) { - availablePackages = cartonSkus.stream().mapToLong(RelabelResponses.SkuItem::quantity).sum(); - } + long availablePackages = cartonSkus.stream().mapToLong(RelabelResponses.SkuItem::quantity).sum(); + if (availablePackages <= 0) continue; + long relabelledPackages = carton.getTotalQuantity() == null ? 0 + : Math.max(0, carton.getTotalQuantity() - availablePackages); result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(), null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), skuCodes, "退货入库库存", availablePackages, 0, List.of(), - "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus)); + "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus, relabelledPackages)); } return result; } @@ -297,7 +301,8 @@ public class RelabelApplicationService implements WalletResultHandler { .and(w -> w.like(StringUtils.hasText(q), LastlegOrder::getOrderNo, q) .or().like(StringUtils.hasText(q), LastlegOrder::getSourceOrderNo, q) .or().like(StringUtils.hasText(q), LastlegOrder::getRelabelOrderNo, q) - .or().like(StringUtils.hasText(q), LastlegOrder::getOutboundOrderNo, q)) + .or().like(StringUtils.hasText(q), LastlegOrder::getOutboundOrderNo, q) + .or().like(StringUtils.hasText(q), LastlegOrder::getInboundCode, q)) .orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId) .last("LIMIT 100")); Map result = new java.util.LinkedHashMap<>(); @@ -327,7 +332,11 @@ public class RelabelApplicationService implements WalletResultHandler { private RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request, Long existingTailOrderId) { if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required"); - boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); + LastlegOrder linkedSourceOrder = existingTailOrderId == null ? null : orders.selectById(existingTailOrderId); + boolean warehouseTransferRelabel = linkedSourceOrder != null + && "WAREHOUSE_TRANSFER".equals(linkedSourceOrder.getOrderType()); + boolean requiresOutbound = warehouseTransferRelabel + || request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); String deliveryMethod = trim(request.deliveryMethod()); if (requiresOutbound && (!StringUtils.hasText(deliveryMethod) || !DELIVERY_METHODS.contains(deliveryMethod))) { throw badRequest("Delivery method must be TRUCK, PICKUP or EXPRESS when carton labels are changed"); @@ -380,9 +389,10 @@ public class RelabelApplicationService implements WalletResultHandler { if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) { throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标"); } + long previouslyRelabelled = returnCartons.relabelledQuantity(carton.getId(), item.skuCode()); long requestedPackages = selectedReturnPackages.merge(carton.getId(), (long) item.notifiedPackageCount(), Math::addExact); - if (requestedPackages > value(carton.getTotalQuantity())) { + if (requestedPackages + previouslyRelabelled > value(carton.getTotalQuantity())) { throw conflict("退货托盘 " + carton.getCartonMark() + " 可用箱数不足"); } sources.add(new SourceInventory(item, null, carton)); @@ -440,7 +450,7 @@ public class RelabelApplicationService implements WalletResultHandler { application.setPrintShippingLabel(Boolean.TRUE.equals(request.printShippingLabel())); application.setFulfillmentStatus(requiresOutbound ? "WAIT_RELABEL" : "NOT_REQUIRED"); application.setRemark(trim(request.remark())); - application.setStatus(PENDING_PROCESSING); + application.setStatus(PENDING_REVIEW); application.setItemCount(request.items().size()); application.setTotalPackageCount(total); application.setNotifiedPackageCount(notified); @@ -455,9 +465,9 @@ public class RelabelApplicationService implements WalletResultHandler { applications.insert(application); for (ReturnInventoryCarton carton : selectedReturnCartons.values()) { - carton.setRelabelApplicationId(applicationId); carton.setRelabelNo(relabelNo); - carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); - returnCartons.updateById(carton); + if (returnCartons.markRelabelPending(carton.getId(), applicationId, relabelNo, now) != 1) { + throw conflict("退货箱状态已变化,请刷新后重试"); + } } if (requiresWallet) { @@ -470,7 +480,7 @@ public class RelabelApplicationService implements WalletResultHandler { for (SourceInventory source : sources) { insertItem(customerId, applicationId, source, now); } - insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now); + insertHistory(customerId, applicationId, null, PENDING_REVIEW, operatorId, "Relabel application submitted for review", now); if (existingTailOrderId == null) { tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null, order.getCurrentStatus(), "Relabel application submitted", now)); @@ -535,6 +545,24 @@ public class RelabelApplicationService implements WalletResultHandler { @Transactional public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId, RelabelRequests.ChangeStatus request) { + return changeStatus(customerId, relabelNo, operatorId, request, false); + } + + @Transactional + public RelabelResponses.Detail reviewDirectTailOrder(String customerId, long orderId, String operatorId, + RelabelRequests.ChangeStatus request) { + LastlegOrder order = orders.selectById(orderId); + if (order == null || !customerId.equals(order.getCustomerId()) + || !"RELABEL_APPLICATION".equals(order.getOrderType()) + || !StringUtils.hasText(order.getRelabelOrderNo())) { + throw notFound("Direct relabel tail order not found"); + } + return changeStatus(customerId, order.getRelabelOrderNo(), operatorId, request, true); + } + + private RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId, + RelabelRequests.ChangeStatus request, + boolean directTailOrderReview) { RelabelApplication application = StringUtils.hasText(customerId) ? applications.lockNo(customerId, relabelNo) : applications.lockAnyByRelabelNo(relabelNo); @@ -542,6 +570,16 @@ public class RelabelApplicationService implements WalletResultHandler { customerId = application.getCustomerId(); String next = request.status().trim().toUpperCase(); if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status"); + if ("REJECTED".equals(next) && !StringUtils.hasText(request.remark())) { + throw badRequest("审核驳回必须填写原因"); + } + LastlegOrder primaryOrder = orders.selectById(application.getLastlegOrderId()); + boolean directApplication = primaryOrder != null && "RELABEL_APPLICATION".equals(primaryOrder.getOrderType()); + if (PENDING_REVIEW.equals(application.getStatus()) && directApplication != directTailOrderReview) { + throw conflict(directApplication + ? "直接创建的贴标单请在尾程订单审核" + : "关联业务产生的换标单请在换标单审核"); + } if (next.equals(application.getStatus())) return detail(customerId, relabelNo); validateTransition(application.getStatus(), next); if (PENDING_REVIEW.equals(application.getStatus()) && PENDING_PROCESSING.equals(next) @@ -600,6 +638,7 @@ public class RelabelApplicationService implements WalletResultHandler { } } for (LastlegOrder order : linkedOrders) { + if ("RETURN_RELABEL".equals(order.getOrderType()) && !Objects.equals(order.getRelabelOrderNo(), application.getRelabelNo())) continue; String orderBefore = order.getCurrentStatus(); String mainStatus = "RELABEL_APPLICATION".equals(order.getOrderType()) ? "WAIT_RELABEL" : orderBefore; @@ -607,13 +646,13 @@ public class RelabelApplicationService implements WalletResultHandler { case CUSTOMER_CONFIRM_PENDING -> mainStatus; case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType()) ? "PENDING_ASSIGNMENT" : "COMPLETED"; - case "REJECTED", "CANCELLED" -> "CANCELLED"; + case "REJECTED" -> directApplication ? "REJECTED" : "CANCELLED"; + case "CANCELLED" -> "CANCELLED"; default -> mainStatus; }; // Reviewing/processing a return relabel must not move the already // completed inbound order back to assignment or relabel waiting. - if ("RETURN_RELABEL".equals(order.getOrderType()) - && !Set.of("COMPLETED", "REJECTED", "CANCELLED").contains(next)) { + if ("RETURN_RELABEL".equals(order.getOrderType())) { orderStatus = "INBOUND_COMPLETED"; } order.setCurrentStatus(orderStatus); @@ -694,6 +733,51 @@ public class RelabelApplicationService implements WalletResultHandler { return detail(customerId, relabelNo); } + @Transactional + public RelabelResponses.Detail resubmit(String customerId, String relabelNo, String operatorId, + RelabelRequests.Resubmit request) { + RelabelApplication application = applications.lockNo(customerId, relabelNo); + if (application == null) throw notFound("Relabel application not found"); + if (!"REJECTED".equals(application.getStatus())) { + throw conflict("Only a rejected relabel application can be resubmitted"); + } + LocalDateTime now = LocalDateTime.now(); + int holdIndex = 0; + for (RelabelItem item : itemList(application.getId())) { + if (item.getReturnCartonId() != null) { + ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); + if (carton == null || !customerId.equals(carton.getCustomerId()) + || !"AVAILABLE".equals(carton.getStatus()) + || returnCartons.markRelabelPending(carton.getId(), application.getId(), relabelNo, now) != 1) { + throw conflict("退货箱已被其他作业占用,无法重新提交"); + } + } else if (item.getSourceInventoryPositionId() != null + && Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0) > 0) { + inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( + item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), relabelNo, null, + "RELABEL:" + relabelNo, request.idempotencyKey().trim() + ":RESUBMIT-HOLD:" + holdIndex++, + "换标驳回修改后重新冻结库存")); + } + } + application.setLabelDeadline(request.labelDeadline()); + application.setContactName(request.contactName().trim()); + application.setContactPhone(request.contactPhone().trim()); + application.setRemark(trim(request.remark())); + application.setStatus(PENDING_REVIEW); + application.setFulfillmentStatus("WAIT_REVIEW"); + application.setUpdatedAt(now); + applications.updateById(application); + for (LastlegOrder order : tailOrders(application)) { + order.setRelabelStatus(PENDING_REVIEW); + order.setSubOrderStatus("RELABEL:" + PENDING_REVIEW); + order.setUpdatedAt(now); + orders.updateById(order); + } + insertHistory(customerId, application.getId(), "REJECTED", PENDING_REVIEW, operatorId, + request.remark(), now); + return detail(customerId, relabelNo); + } + @Transactional public void resetForRework(String customerId, String relabelNo, String operatorId, String remark) { RelabelApplication application = applications.lockNo(customerId, relabelNo); @@ -703,6 +787,7 @@ public class RelabelApplicationService implements WalletResultHandler { application.setStatus("PROCESSING"); application.setFulfillmentStatus("WAIT_RELABEL"); application.setUpdatedAt(LocalDateTime.now()); applications.updateById(application); for (LastlegOrder order : tailOrders(application)) { + if ("RETURN_RELABEL".equals(order.getOrderType()) && !Objects.equals(order.getRelabelOrderNo(), application.getRelabelNo())) continue; order.setRelabelStatus("PROCESSING"); order.setSubOrderStatus("RELABEL:PROCESSING"); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order); } @@ -748,9 +833,18 @@ public class RelabelApplicationService implements WalletResultHandler { } application.setUpdatedAt(now); applications.updateById(application); + WarehouseTransferOrder linkedTransfer = warehouseTransfers == null ? null + : warehouseTransfers.lockByRelabelNo(customerId, relabelNo); for (LastlegOrder order : tailOrders(application)) { + if ("RETURN_RELABEL".equals(order.getOrderType()) && !Objects.equals(order.getRelabelOrderNo(), application.getRelabelNo())) continue; + // 仓储转运尾程单由下方按转运目的仓一次性写入精确状态,避免先落入通用状态。 + if (linkedTransfer != null && "WAIT_RELABEL".equals(linkedTransfer.getStatus()) + && Objects.equals(order.getId(), linkedTransfer.getLastlegOrderId())) continue; String orderBefore = order.getCurrentStatus(); - order.setCurrentStatus(requiresOutbound ? "PENDING_ASSIGNMENT" : "COMPLETED"); + // 换标确认时货物已经完成入库,不得退回最初建单的“待分配”。 + // 独立换标先进入待出库;仓储转运会在下方按目的仓进一步收敛为待排车或待确认目的仓。 + order.setCurrentStatus("RETURN_RELABEL".equals(order.getOrderType()) ? "INBOUND_COMPLETED" + : requiresOutbound ? "WAIT_OUTBOUND" : "COMPLETED"); order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus()); order.setRelabelStatus("COMPLETED"); order.setUpdatedAt(now); @@ -774,27 +868,55 @@ public class RelabelApplicationService implements WalletResultHandler { ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); if (carton == null || !Objects.equals(carton.getRelabelApplicationId(), application.getId())) continue; carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now); + // 换标已完成后释放“当前申请占用”关系,但保留 COMPLETED 作为历史状态。 + // 否则退货箱虽显示 AVAILABLE,下一次贴标申请仍会被旧 relabel_application_id 拦截。 + carton.setRelabelApplicationId(null); carton.setRelabelNo(null); carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton); } insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId, request.remark(), now); // 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。 - if (warehouseTransfers != null) { - WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo); + if (linkedTransfer != null) { + WarehouseTransferOrder transfer = linkedTransfer; if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) { - Set destinations = relabelItems.stream() + // Item-level relabel destinations apply only to those cartons. + // They may bind the whole transfer only when every transfer carton + // is included; a partial relabel must leave the remainder unbound. + int relabelPackages = relabelItems.stream() + .mapToInt(item -> Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0)).sum(); + Set relabelDestinations = relabelItems.stream() .map(RelabelItem::getDestinationFbaWarehouseCode) .filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet()); - if (!StringUtils.hasText(transfer.getFbaWarehouseCode()) && destinations.size() == 1) { - transfer.setFbaWarehouseCode(destinations.iterator().next()); + // 兼容历史转运单:旧数据只保存 fbaWarehouseCode,没有 destinationType。 + // 这类单据已经有明确目的仓,应直接进入待排车,不能退回待绑定目的仓。 + if (!StringUtils.hasText(transfer.getDestinationType()) + && StringUtils.hasText(transfer.getFbaWarehouseCode())) { + transfer.setDestinationType("FBA"); + transfer.setDestinationWarehouseCode(transfer.getFbaWarehouseCode().trim()); + transfer.setDestinationWarehouseName(transfer.getFbaWarehouseCode().trim()); } - String transferStatus = StringUtils.hasText(transfer.getFbaWarehouseCode()) + if (!StringUtils.hasText(transfer.getDestinationType()) + && relabelPackages >= Objects.requireNonNullElse(transfer.getPackageCount(), 0) + && relabelDestinations.size() == 1) { + String destination = relabelDestinations.iterator().next(); + transfer.setDestinationType("FBA"); + transfer.setDestinationWarehouseCode(destination); + transfer.setDestinationWarehouseName(destination); + transfer.setFbaWarehouseCode(destination); + } + boolean transferHasDestination = "FBA".equals(transfer.getDestinationType()) + ? StringUtils.hasText(transfer.getDestinationWarehouseCode()) + : "PRIVATE".equals(transfer.getDestinationType()) + && StringUtils.hasText(transfer.getPrivateDestinationAddress()); + String transferStatus = transferHasDestination ? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION"; transfer.setStatus(transferStatus); transfer.setRelabelCompletedAt(now); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); - application.setFulfillmentStatus("READY_FOR_DISPATCH".equals(transferStatus) + boolean relabelHasDestination = !relabelItems.isEmpty() && relabelItems.stream() + .allMatch(item -> StringUtils.hasText(item.getDestinationFbaWarehouseCode())); + application.setFulfillmentStatus("READY_FOR_DISPATCH".equals(transferStatus) || relabelHasDestination ? "WAIT_PRE_DISPATCH" : transferStatus); applications.updateById(application); // 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。 @@ -926,6 +1048,8 @@ public class RelabelApplicationService implements WalletResultHandler { value.getDeliveryMethod(), Boolean.TRUE.equals(value.getPrintShippingLabel()), value.getShippingLabelFileId(), value.getShippingLabelFileName(), value.getShippingLabelFileUrl(), value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(), + order != null && "RELABEL_APPLICATION".equals(order.getOrderType()) ? "TAIL_ORDER" : "RELABEL_ORDER", + null, value.getCreatedAt()); } @@ -977,8 +1101,8 @@ public class RelabelApplicationService implements WalletResultHandler { .and(w -> w.eq(LastlegOrder::getSourceOrderNo, customerOrderNo).or().eq(LastlegOrder::getOrderNo, customerOrderNo)) .last("LIMIT 1")); if (existingReturn != null) { - existingReturn.setRelabelOrderNo(relabelNo); existingReturn.setRelabelStatus(PENDING_PROCESSING); - existingReturn.setSubOrderStatus("RETURN_RELABEL:" + PENDING_PROCESSING); existingReturn.setPackageCount(packageCount); + existingReturn.setRelabelOrderNo(relabelNo); existingReturn.setRelabelStatus(PENDING_REVIEW); + existingReturn.setSubOrderStatus("RETURN_RELABEL:" + PENDING_REVIEW); existingReturn.setPackageCount(packageCount); existingReturn.setUpdatedAt(now); orders.updateById(existingReturn); return existingReturn; } LastlegOrder order = new LastlegOrder(); @@ -989,9 +1113,9 @@ public class RelabelApplicationService implements WalletResultHandler { order.setSourceInboundId(relabelNo + ":" + warehouseId); order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId); order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0); - order.setShippedPackageCount(0); order.setCurrentStatus("WAIT_RELABEL"); - order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING); order.setRelabelOrderNo(relabelNo); - order.setRelabelStatus(PENDING_PROCESSING); order.setCustomerRemark(trim(request.remark())); + order.setShippedPackageCount(0); order.setCurrentStatus(PENDING_REVIEW); + order.setSubOrderStatus("RELABEL:" + PENDING_REVIEW); order.setRelabelOrderNo(relabelNo); + order.setRelabelStatus(PENDING_REVIEW); order.setCustomerRemark(trim(request.remark())); order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo + ":" + warehouseId); order.setSourceLoadingPlanId("RELABEL_APPLICATION"); order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now); orders.insert(order); @@ -1004,8 +1128,12 @@ public class RelabelApplicationService implements WalletResultHandler { if (order == null || !customerId.equals(order.getCustomerId())) { throw conflict("Existing tail order does not belong to this customer"); } - order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_PROCESSING); - order.setPackageCount(packageCount); order.setUpdatedAt(now); orders.updateById(order); + order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_REVIEW); + // A linked warehouse-transfer relabel can cover only part of the + // transfer. Keep the authoritative whole-order package count so later + // dispatches can attribute the remaining cartons correctly. + if (!"WAREHOUSE_TRANSFER".equals(order.getOrderType())) order.setPackageCount(packageCount); + order.setUpdatedAt(now); orders.updateById(order); return order; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java index 8011f4288..c934a1eaa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java @@ -8,7 +8,8 @@ public final class ReturnOrderResponses { public record Summary(String returnNo, String lastlegOrderNo, String overseasWarehouseId, String warehouseContactName, String warehouseContactPhone, String customerReferenceNo, String status, int skuTypes, long expectedQuantity, long receivedQuantity, boolean needRelabel, - String relabelNo, String relabelStatus, String discrepancyReason, LocalDateTime createdAt) { } + String relabelNo, String relabelStatus, String discrepancyReason, LocalDateTime createdAt, + List relabelApplications) { } public record Item(String skuCode, String productNameZh, long expectedQuantity, long receivedQuantity) { } public record Detail(Summary order, String remark, List items, List receipts, List pallets, List photos) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonItemMapper.java index 374525575..918f82f60 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonItemMapper.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import java.util.List; @@ -14,4 +15,16 @@ public interface ReturnInventoryCartonItemMapper extends BaseMapper listCarton(@Param("cartonId") long cartonId); @Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} ORDER BY sku_code FOR UPDATE") List lockCarton(@Param("cartonId") long cartonId); + + @Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE") + ReturnInventoryCartonItem lockSku(@Param("cartonId") long cartonId, @Param("skuCode") String skuCode); + + @Update("UPDATE ll_return_inventory_carton_item SET quantity=quantity-#{quantity} WHERE id=#{id} AND quantity>=#{quantity}") + int deduct(@Param("id") long id, @Param("quantity") long quantity); + + @Select("SELECT COALESCE(SUM(quantity),0) FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId}") + long totalQuantity(@Param("cartonId") long cartonId); + + @Select("SELECT COUNT(*) FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} AND quantity>0") + int skuTypes(@Param("cartonId") long cartonId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java index ac0540219..324935209 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java @@ -25,7 +25,9 @@ public interface ReturnInventoryCartonMapper extends BaseMapper merchantPage(@Param("customerId") String customerId, @Param("query") com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.Query query, @Param("offset") long offset); - @Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='AVAILABLE' ORDER BY created_at DESC,id DESC LIMIT 500") + @Select("") List findAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId); @Select("SELECT * FROM ll_return_inventory_carton WHERE carton_mark=#{cartonMark} LIMIT 1") ReturnInventoryCarton findMark(@Param("cartonMark") String cartonMark); @@ -36,6 +38,23 @@ public interface ReturnInventoryCartonMapper extends BaseMapper0 AND total_quantity>0 AND relabel_application_id IS NULL") + int markRelabelPending(@Param("id") long id, @Param("applicationId") long applicationId, + @Param("relabelNo") String relabelNo, @Param("updatedAt") java.time.LocalDateTime updatedAt); + @Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} LIMIT 1") ReturnInventoryCarton findReceipt(@Param("receiptId") long receiptId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java index 3eb209b27..ea900cb9e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java @@ -13,6 +13,8 @@ public interface ReturnOrderMapper extends BaseMapper { ReturnOrder findKey(@Param("customerId") String customerId, @Param("key") String key); @Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND return_no=#{returnNo} LIMIT 1") ReturnOrder findNo(@Param("customerId") String customerId, @Param("returnNo") String returnNo); + @Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND return_no=#{returnNo} LIMIT 1 FOR UPDATE") + ReturnOrder lockCustomerNo(@Param("customerId") String customerId, @Param("returnNo") String returnNo); @Select("SELECT * FROM ll_return_order WHERE return_no=#{returnNo} LIMIT 1 FOR UPDATE") ReturnOrder lockNo(@Param("returnNo") String returnNo); @Select("SELECT * FROM ll_return_order WHERE return_no=#{returnNo} AND overseas_warehouse_id=#{warehouseId} " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index 609534dc7..858536260 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -186,7 +186,9 @@ public class ReturnReceiptService { w.in(ReturnOrder::getId, ids); } Page result = orders.selectPage(Page.of(query.page(), query.size()), w); - return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(), + var relabels = relabelSubOrders(result.getRecords()); + return new PageResult<>(result.getRecords().stream().map(order -> summary(order, + relabels.getOrDefault(order.getLastlegOrderId(), List.of()))).toList(), result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); } @@ -216,7 +218,7 @@ public class ReturnReceiptService { }).toList(); List photos = photoList(order).stream() .map(file -> new ReturnOrderResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList(); - return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews, palletViews, photos); + return new ReturnOrderResponses.Detail(summary(order, relabelSubOrders(List.of(order)).getOrDefault(order.getLastlegOrderId(), List.of())), order.getRemark(), items, receiptViews, palletViews, photos); } public com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String returnNo, long fileId) { @@ -265,7 +267,7 @@ public class ReturnReceiptService { @Transactional public ReturnOrderResponses.RelabelCreated createRelabel(String customerId, String operatorId, String returnNo, ReturnOrderRequests.RelabelCreate request) { - ReturnOrder order = orders.findNo(customerId, text(returnNo)); + ReturnOrder order = orders.lockCustomerNo(customerId, text(returnNo)); if (order == null) throw notFound("退货单不存在"); if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收"); String fileScope = norm(request.fileScope()); @@ -273,9 +275,14 @@ public class ReturnReceiptService { String key = text(request.idempotencyKey()); String requestHash = hash(request); RelabelApplication replay = relabelApplications.findKey(customerId, key); - if (replay != null) return new ReturnOrderResponses.RelabelCreated(replay.getRelabelNo(), replay.getId(), - order.getReturnNo(), request.items().stream().map(ReturnOrderRequests.RelabelItem::skuCode).toList(), replay.getStatus()); - if (text(order.getRelabelNo()) != null) throw conflict("当前退货单已经生成贴标申请"); + if (replay != null) { + if (!order.getReturnNo().equals(replay.getCustomerOrderNo()) || !requestHash.equals(replay.getRequestHash())) + throw conflict("相同幂等键的贴标申请内容不一致"); + return new ReturnOrderResponses.RelabelCreated(replay.getRelabelNo(), replay.getId(), + order.getReturnNo(), request.items().stream().map(ReturnOrderRequests.RelabelItem::skuCode).toList(), replay.getStatus()); + } + // The order keeps the latest application reference, not an order-wide inventory lock. + // Each new application must still allocate only unlabelled, available carton quantities below. var destination = fbaWarehouseValidator.required(request.destinationFbaWarehouseId(), request.destinationFbaWarehouseCode(), null); Map receivedBySku = orderItems.lockOrder(order.getId()).stream() .collect(java.util.stream.Collectors.toMap(item -> norm(item.getSkuCode()), item -> item)); @@ -311,8 +318,10 @@ public class ReturnReceiptService { tail.setRelabelStatus("PENDING_REVIEW"); tail.setSubOrderStatus("RETURN_RELABEL:PENDING_REVIEW"); tail.setCurrentStatus("RETURN_RELABEL".equals(tail.getOrderType()) ? "INBOUND_COMPLETED" : tail.getCurrentStatus()); - tail.setPackageCount(Math.toIntExact(totalQuantity)); - tail.setProductQuantity(Math.toIntExact(totalQuantity)); + if (!"RETURN_RELABEL".equals(tail.getOrderType())) { + tail.setPackageCount(Math.toIntExact(totalQuantity)); + tail.setProductQuantity(Math.toIntExact(totalQuantity)); + } tail.setUpdatedAt(now); lastlegOrders.updateById(tail); RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId()); @@ -331,6 +340,7 @@ public class ReturnReceiptService { long skuQuantity = cartonItems.listCarton(candidate.getId()).stream() .filter(line -> norm(requested.skuCode()).equals(norm(line.getSkuCode()))) .mapToLong(line -> value(line.getQuantity())).sum(); + skuQuantity -= cartons.relabelledQuantity(candidate.getId(), norm(requested.skuCode())); if (skuQuantity <= 0) continue; ReturnInventoryCarton carton = selectedCartons.computeIfAbsent(candidate.getId(), ignored -> cartons.lockId(candidate.getId())); if (carton == null || !"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) @@ -551,10 +561,16 @@ public class ReturnReceiptService { return new PdaReturnReceiptResponses.Confirmed(order.getReturnNo(), receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(), order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt()); } - private ReturnOrderResponses.Summary summary(ReturnOrder order) { return new ReturnOrderResponses.Summary(order.getReturnNo(), order.getLastlegOrderNo(), + private Map> relabelSubOrders(List values) { + var ids = values.stream().map(ReturnOrder::getLastlegOrderId).filter(java.util.Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) return java.util.Collections.emptyMap(); + var rows = lastlegOrders.findRelabelSubOrders(ids); + return rows == null ? java.util.Collections.emptyMap() : rows.stream().collect(java.util.stream.Collectors.groupingBy(com.baiyue.lastleg.dto.RelabelSubOrder::lastlegOrderId)); + } + private ReturnOrderResponses.Summary summary(ReturnOrder order, List relabelApplications) { return new ReturnOrderResponses.Summary(order.getReturnNo(), order.getLastlegOrderNo(), order.getOverseasWarehouseId(), order.getWarehouseContactName(), order.getWarehouseContactPhone(), order.getCustomerReferenceNo(), order.getStatus(), order.getExpectedSkuTypes() == null ? 0 : order.getExpectedSkuTypes(), value(order.getExpectedQuantity()), - value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getDiscrepancyReason(), order.getCreatedAt()); } + value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getDiscrepancyReason(), order.getCreatedAt(), relabelApplications); } private ReturnOrderResponses.Item item(ReturnOrderItem item) { return new ReturnOrderResponses.Item(item.getSkuCode(), item.getProductNameZh(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity())); } private PdaReturnReceiptResponses.Item pdaItem(ReturnOrderItem item) { return new PdaReturnReceiptResponses.Item(item.getSkuCode(), 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 49812a1c3..214ec7563 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 @@ -96,10 +96,12 @@ public class LastlegOrderService { Map containerNodes = containerNodes(page.getRecords()); Map dispatchStatuses = dispatchStatuses(page.getRecords()); Map weights = orderWeights(page.getRecords()); + Map> relabels = relabelSubOrders(page.getRecords()); return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()), StringUtils.hasText(order.getSourceContainerBookingId()) ? containerNodes.get(order.getSourceContainerBookingId()) : null, - dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()))).toList(), + dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()), + relabels.getOrDefault(order.getId(), List.of()))).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } @@ -353,7 +355,7 @@ public class LastlegOrderService { .sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList(); return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()), containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED"), - orderWeights(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() + orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of())), manualNodes, cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), @@ -577,7 +579,10 @@ public class LastlegOrderService { .like(LastlegOrder::getOrderNo, query.orderNo()) .or() .like(LastlegOrder::getSourceOrderNo, query.orderNo())) - .like(StringUtils.hasText(relabelNo), LastlegOrder::getRelabelOrderNo, relabelNo) + .and(StringUtils.hasText(relabelNo), nested -> nested + .like(LastlegOrder::getRelabelOrderNo, relabelNo) + .or().apply("EXISTS (SELECT 1 FROM ll_relabel_application r WHERE r.lastleg_order_id=ll_order.id " + + "AND r.customer_id=ll_order.customer_id AND r.relabel_no LIKE CONCAT('%',{0},'%'))", relabelNo)) .eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType()) .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) .like(StringUtils.hasText(query.lxCode()), LastlegOrder::getLxCode, query.lxCode()) @@ -637,9 +642,16 @@ public class LastlegOrderService { return rows.stream().collect(Collectors.toMap(LastlegOrderMapper.OrderWeightRow::orderId, row -> row)); } + private Map> relabelSubOrders(List orders) { + if (orders.isEmpty()) return Map.of(); + var rows = orderMapper.findRelabelSubOrders(orders.stream().map(LastlegOrder::getId).toList()); + return rows == null ? Map.of() : rows.stream().collect(Collectors.groupingBy(com.baiyue.lastleg.dto.RelabelSubOrder::lastlegOrderId)); + } + private LastlegOrderSummary summary(LastlegOrder o, List relations, String containerNode, String dispatchStatus, - LastlegOrderMapper.OrderWeightRow weight) { + LastlegOrderMapper.OrderWeightRow weight, + List relabelApplications) { List related = relations == null ? List.of() : relations; return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), @@ -657,7 +669,7 @@ public class LastlegOrderService { o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), - o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); + o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications); } private static Integer packageDifference(LastlegOrder order) { 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 140c50ef6..882719a4c 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 @@ -30,6 +30,7 @@ public final class WarehouseTransferResponses { String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode, String destinationType, String destinationWarehouseCode, String destinationWarehouseName, int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo, + String relabelApplicationStatus, String relabelFulfillmentStatus, 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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java index ceb53edcc..bf0be19c6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java @@ -23,7 +23,11 @@ import org.apache.ibatis.annotations.Update; @Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND lastleg_order_id=#{orderId} AND outbound_at IS NULL") int markOutboundByOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("outboundAt") java.time.LocalDateTime outboundAt); - @Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND outbound_at IS NULL") + @Update("UPDATE ll_warehouse_transfer_order wto SET wto.status='OUTBOUNDED',wto.outbound_at=#{outboundAt},wto.updated_at=#{outboundAt} " + + "WHERE wto.customer_id=#{customerId} AND wto.relabel_no=#{relabelNo} AND wto.outbound_at IS NULL " + + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_pallet wtp " + + "JOIN ll_inventory_position ip ON ip.customer_id=wtp.customer_id AND ip.pallet_id=wtp.warehouse_pallet_id " + + "WHERE wtp.customer_id=wto.customer_id AND wtp.transfer_order_id=wto.id AND ip.available_packages>0)") int markOutboundByRelabel(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo, @Param("outboundAt") java.time.LocalDateTime outboundAt); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index ac35f3220..f4c8b71fe 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 @@ -147,6 +147,12 @@ public class WarehouseTransferService { validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationContactName(), request.privateDestinationContactPhone()); + // A split relabel transfer may intentionally defer the whole-order + // destination: relabeled cartons go through relabel dispatch while the + // remaining cartons are bound later from warehouse-transfer outbound. + // All other transfer types must still select FBA or PRIVATE up front. + validateTransferDestinationRequired(request.destinationType(), request.fbaWarehouseCode(), + request.needRelabel() && request.relabelItems() != null && !request.relabelItems().isEmpty()); String key = request.idempotencyKey().trim(); String requestHash = hash(request); WarehouseTransferOrder replay = transfers.findKey(customerId, key); @@ -164,8 +170,8 @@ public class WarehouseTransferService { List selectedIds = request.sourceLastlegOrderIds() == null ? List.of() : request.sourceLastlegOrderIds().stream().distinct().toList(); boolean existingInventoryTransfer = StringUtils.hasText(requestedInboundCode) || !selectedIds.isEmpty(); - if (existingInventoryTransfer && (!StringUtils.hasText(requestedInboundCode) || selectedIds.isEmpty())) { - throw badRequest("Existing inventory transfer requires both inboundCode and sourceLastlegOrderIds"); + if (existingInventoryTransfer && !StringUtils.hasText(requestedInboundCode)) { + throw badRequest("Existing inventory transfer requires inboundCode"); } Long inboundOrderId; @@ -175,9 +181,16 @@ public class WarehouseTransferService { inboundCode = requestedInboundCode; inboundOrderId = inboundSnapshots.findId(customerId, inboundCode); if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode); - selectedOrders = orders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds)); - if (selectedOrders.size() != selectedIds.size()) { + // The merchant form may only submit the inventory inbound code. In that + // case derive all eligible tail orders for the inbound instead of + // rejecting a valid inventory transfer because source IDs were omitted. + selectedOrders = selectedIds.isEmpty() + ? orders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .eq(LastlegOrder::getInboundCode, inboundCode)) + : orders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds)); + if (selectedOrders.isEmpty() || (!selectedIds.isEmpty() && selectedOrders.size() != selectedIds.size())) { throw notFound("One or more selected customer orders do not exist in the current tenant"); } } else { @@ -270,6 +283,8 @@ public class WarehouseTransferService { validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationContactName(), request.privateDestinationContactPhone()); + validateTransferDestinationRequired(request.destinationType(), request.fbaWarehouseCode(), + request.needRelabel() && request.relabelItems() != null && !request.relabelItems().isEmpty()); LastlegOrder order = requireTailOrder(customerId, transfer); LocalDateTime now = LocalDateTime.now(); @@ -411,12 +426,16 @@ public class WarehouseTransferService { .le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo()) .orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId); Page result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper); + Map relabelApplicationsByNo = relabelApplications(result.getRecords()); List records = result.getRecords().stream().map(value -> new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getPackageCount(), value.getDeliveryMethod(), - Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(), + Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), + relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getStatus(), + relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getFulfillmentStatus(), + value.getStatus(), value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); } @@ -739,10 +758,16 @@ public class WarehouseTransferService { && pallet.getPalletNo().equals(request.palletCode().trim())) return detail(transfer.getCustomerId(), transferNo); if (!"GENERATED".equals(pallet.getStatus())) throw conflict("Only a generated pallet can be scan-bound"); if (!pallet.getPalletNo().equals(request.palletCode().trim())) throw conflict("Scanned pallet code does not match this generated pallet"); - var physical = warehouseResources.resolvePallet(request.palletCode().trim()); + // Generation already persisted the warehouse pallet's immutable physical id. + // Resolve by that id during binding instead of relying on the warehouse code + // index, which can lag immediately after a new pallet is confirmed. + var physical = warehouseResources.resolvePallet(pallet.getWarehousePalletId()); if (physical.palletId() != pallet.getWarehousePalletId() || physical.warehouseId() != warehouseId) { throw conflict("Scanned pallet does not belong to this transfer and warehouse"); } + if (!pallet.getPalletNo().equals(physical.palletNo())) { + throw conflict("Generated pallet identity does not match the warehouse pallet"); + } pallet.setStatus("BOUND"); pallet.setBindIdempotencyKey(request.idempotencyKey()); pallet.setBoundAt(LocalDateTime.now()); pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.updateById(pallet); return detail(transfer.getCustomerId(), transferNo); @@ -900,7 +925,7 @@ public class WarehouseTransferService { transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); order.setCurrentStatus(INBOUND_COMPLETED); - order.setRelabelStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? "PENDING_PROCESSING" : "NOT_REQUIRED"); + order.setRelabelStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? "PENDING_REVIEW" : "NOT_REQUIRED"); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus() + ";RELABEL:" + order.getRelabelStatus()); order.setUpdatedAt(now); orders.updateById(order); if (!java.util.Objects.equals(before, order.getCurrentStatus())) { @@ -1203,13 +1228,35 @@ public class WarehouseTransferService { @Transactional(readOnly = true) public List pdaCandidates(long overseasWarehouseId) { - return transfers.pdaCandidates(String.valueOf(overseasWarehouseId)).stream().map(value -> - new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), + // PDA 测量列表只接收明确进入 WAIT_PDA_RECEIVING 的在途转运单。 + // APPROVED 是已有库存转运的后续处理入口,ALLOCATED 已进入库存出库流程; + // 两者都不能再次显示为待测量,更不能引导操作员重复进入托盘作业。 + List candidates = transfers.pdaCandidates(String.valueOf(overseasWarehouseId)); + Map relabelApplicationsByNo = relabelApplications(candidates); + return candidates.stream().map(value -> { + RelabelApplication relabel = StringUtils.hasText(value.getRelabelNo()) + ? relabelApplicationsByNo.get(value.getRelabelNo()) : null; + return new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getPackageCount(), value.getDeliveryMethod(), - Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(), - value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); + Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), + relabel == null ? null : relabel.getStatus(), + relabel == null ? null : relabel.getFulfillmentStatus(), + value.getStatus(), + value.getEstimatedArrivalAt(), value.getCreatedAt()); + }).toList(); + } + + private Map relabelApplications(List transferOrders) { + Set relabelNos = transferOrders.stream().map(WarehouseTransferOrder::getRelabelNo) + .filter(StringUtils::hasText).collect(Collectors.toSet()); + if (relabelNos.isEmpty()) return Map.of(); + List applications = relabelApplications.selectList( + new LambdaQueryWrapper().in(RelabelApplication::getRelabelNo, relabelNos)); + if (applications == null || applications.isEmpty()) return Map.of(); + return applications.stream().collect(Collectors.toMap(RelabelApplication::getRelabelNo, + Function.identity(), (left, right) -> right)); } @Transactional(readOnly = true) @@ -1377,6 +1424,15 @@ public class WarehouseTransferService { } } + private static void validateTransferDestinationRequired(String destinationType, String fbaWarehouseCode, + boolean allowDeferredForRelabel) { + String type = trim(destinationType); + if (type == null && StringUtils.hasText(fbaWarehouseCode)) type = "FBA"; + if ((type == null || "NONE".equalsIgnoreCase(type)) && !allowDeferredForRelabel) { + throw badRequest("仓储转运必须选择 FBA 仓或私人仓"); + } + } + private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.Create request) { String type = trim(request.destinationType()); if (type == null && StringUtils.hasText(request.fbaWarehouseCode())) type = "FBA"; @@ -1569,7 +1625,7 @@ public class WarehouseTransferService { transfer.getOperatingWarehouseId(), transfer.getLabelDeadline(), transfer.getRelabelContactName(), transfer.getRelabelContactPhone(), transfer.getRelabelChargeAmount(), "仓储转运确认后自动创建换标单", "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL", items, - trim(transfer.getRelabelDeliveryMethod()), false); + firstText(transfer.getRelabelDeliveryMethod(), "TRUCK"), false); return relabelService.createForExistingTailOrder(customerId, operatorId, transfer.getLastlegOrderId(), request); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index f6f240715..a558dba68 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1826,7 +1826,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application ( PRIMARY KEY (id), UNIQUE KEY uk_ll_relabel_application_no (customer_id,relabel_no), UNIQUE KEY uk_ll_relabel_application_idempotency (customer_id,idempotency_key), - UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id), + KEY idx_ll_relabel_application_order (lastleg_order_id), KEY idx_ll_relabel_application_list (customer_id,status,created_at), KEY idx_ll_relabel_application_customer (customer_id,customer_order_no), KEY idx_ll_relabel_application_warehouse (customer_id,overseas_warehouse_id) @@ -2074,8 +2074,9 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( CREATE TABLE IF NOT EXISTS ll_cargo_discard_item ( id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', - order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', - sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', + order_id BIGINT NULL COMMENT '一件代发订单ID', source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON', + return_carton_id BIGINT NULL COMMENT '退货库存箱ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NULL COMMENT '托盘档案ID', + sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数', actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED', pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间', @@ -2636,17 +2637,17 @@ PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton CREATE TABLE IF NOT EXISTS ll_return_inventory_carton ( new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛', id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')) + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')) ) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛'; SET @ll_return_carton_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no')); PREPARE ll_return_carton_add_pallet_stmt FROM @ll_return_carton_add_pallet; EXECUTE ll_return_carton_add_pallet_stmt; DEALLOCATE PREPARE ll_return_carton_add_pallet_stmt; -- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 SQL 兼容 Spring 的数据库初始化器。 SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); PREPARE return_inventory_carton_check_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_stmt; -ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1')); PREPARE return_inventory_carton_status_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_status_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_status_drop_stmt; -ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')); +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no')); PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt; ALTER TABLE ll_return_inventory_carton MODIFY COLUMN source_batch_no TEXT NULL COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', MODIFY COLUMN source_return_no TEXT NULL COMMENT '实际消耗来源退货单号,多个单号逗号分隔'; 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 0af7a9337..9f7836de1 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -228,7 +228,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 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS;inboundCode 仅返回真实来源入仓码,无入仓关系的换标申请和本土散板为空 } } + responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;每行 relabelApplications 数组按创建时间返回所有换标子单的 relabelNo、status、quantity、createdAt,旧标量字段只代表最近一张;列表行 destinationType 为 FBA 或 OVERSEAS;inboundCode 仅返回真实来源入仓码,无入仓关系的换标申请和本土散板为空 } } /api/platform/v1/last-leg/orders/{id}: get: summary: 查询尾程订单详情 @@ -238,8 +238,18 @@ paths: - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } responses: - '200': { description: 统一 ApiResponse 包装的尾程订单详情 } + '200': { description: 统一 ApiResponse 包装的尾程订单详情;order.relabelApplications 返回所有换标子单的独立状态和本次数量 } '404': { description: 尾程订单不存在或不属于查询客户 } + /api/platform/v1/last-leg/orders/{id}/relabel-review: + put: + summary: 在尾程订单工作台审核客户直接创建的贴标单 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;仅 RELABEL_APPLICATION + PENDING_REVIEW 可审核。通过进入 WAIT_RELABEL,驳回必须填写原因并保持 REJECTED 供客户修改后重提。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelStatusChange' } } } } + responses: { '200': { description: 审核后的贴标单详情 }, '400': { description: 驳回原因缺失或决定非法 }, '409': { description: 非直接贴标单、审核入口错误或状态冲突 } } /api/platform/v1/last-leg/orders/{id}/fees: post: summary: 新增尾程订单费用 @@ -1001,7 +1011,7 @@ paths: /api/merchant/v1/last-leg/warehouse-transfers: get: summary: 客户分页查询自己的仓储转运单 - description: 权限 merchant:overseas-warehouse:transfer:list;列表权限包含详情只读。 + description: 权限 merchant:overseas-warehouse:transfer:list;列表权限包含详情只读;每行同时返回关联换标单号 relabelNo、换标申请流程状态 relabelStatus 与履约状态 relabelFulfillmentStatus。 security: [{ bearerAuth: [] }] responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } } post: @@ -1355,7 +1365,7 @@ paths: /api/platform/v1/last-leg/relabel-applications/{relabelNo}/status: put: summary: 变更海外换标单状态 - description: 权限 platform:overseas-warehouse:relabel-order:status;PDA复核通过后进入CUSTOMER_CONFIRM_PENDING,客户确认后进入READY_FOR_OUTBOUND,再由排车或直接出库履约。 + description: 权限 platform:overseas-warehouse:relabel-order:status;待审核阶段只处理转运、退货等关联业务产生的换标单,客户直接创建的贴标单必须在尾程订单工作台审核。PDA复核通过后进入CUSTOMER_CONFIRM_PENDING,客户确认后进入READY_FOR_OUTBOUND。 security: [{ bearerAuth: [] }] parameters: - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } @@ -1373,7 +1383,7 @@ paths: /api/platform/v1/last-leg/relabel-applications/{relabelNo}/files: post: summary: 上传总换标PDF或明细换标PDF - description: 权限 platform:overseas-warehouse:relabel-order:create。仅PENDING_PROCESSING/PROCESSING可替换,PDF内容且不超过10MiB。客户从申请单解析,ITEM必须属于该申请单同客户。File类别分别为OVERSEAS_RELABEL_TOTAL_DOCUMENT(application.id)及OVERSEAS_RELABEL_ITEM_DOCUMENT(item.id)。成功保存File返回的资料元数据,旧资料不再允许通过当前绑定下载。 + description: 权限 platform:overseas-warehouse:relabel-order:create。PENDING_REVIEW/PENDING_PROCESSING/PROCESSING/REJECTED 可替换,PDF内容且不超过10MiB。客户从申请单解析,ITEM必须属于该申请单同客户。成功保存File返回的资料元数据,旧资料不再允许通过当前绑定下载。 security: [{ bearerAuth: [] }] parameters: - { name: relabelNo, in: path, required: true, schema: { type: string } } @@ -1458,10 +1468,18 @@ paths: responses: '200': { description: 换标申请详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } } '404': { description: 申请不存在或不属于当前客户 } + /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/resubmit: + put: + summary: 修改并重新提交已驳回贴标单 + description: 权限 merchant:overseas-warehouse:relabel:create;客户可先替换贴标资料,再修改截止时间、联系人、电话和备注并重新提交审核。重新冻结原库存和费用,直接贴标回到尾程订单审核,关联换标回到换标单审核。 + security: [{ bearerAuth: [] }] + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelResubmit' } } } } + responses: { '200': { description: 重新提交后的贴标单详情 }, '409': { description: 非驳回状态、库存已变化或费用尚未释放 } } /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label: post: summary: 上传换标快递面单 - description: 权限 merchant:overseas-warehouse:relabel:files;仅 EXPRESS 且申请尚未客户确认时允许上传,文件由服务端绑定到申请单。 + description: 权限 merchant:overseas-warehouse:relabel:files;仅 EXPRESS 且申请尚未客户确认时允许上传;审核驳回后可替换文件再重新提交,文件由服务端绑定到申请单。 security: [{ bearerAuth: [] }] parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } @@ -1524,7 +1542,7 @@ paths: - { name: trackingNo, in: query, schema: { type: string } } - { name: skuCode, in: query, schema: { type: string } } - { name: status, in: query, schema: { type: string, enum: [PENDING_RECEIPT, PARTIALLY_RECEIVED, COMPLETED] } } - responses: { '200': { description: 当前客户退货订单分页 } } + responses: { '200': { description: 当前客户退货订单分页;每行 relabelApplications 返回所有换标子单的 relabelNo、status、quantity、createdAt,主单 status 仍表示退货签收状态 } } post: summary: 客户创建退货预报订单 description: 权限 merchant:overseas-warehouse:return-order:create;客户只预报不重复的 SKU 和预计数量,不要求提前知道快递单号;仓库 PDA 实收数量才是实际入库事实。系统退货编号由业务前缀和25位大写Base36完整随机UUID编码组成,退货单RT、签收单RR、批次RB、箱唛RC及退货主单RETU均独立生成,不编码内部ID、仓库或日期。幂等重放返回已保存的编号,不重新生成。 @@ -2589,7 +2607,7 @@ components: fbaShipmentId: { type: string, nullable: true, description: 来源冻结货件编号;多值以逗号分隔 } poReferenceNo: { type: string, nullable: true, description: 来源冻结FBA跟踪/参考号;多值以逗号分隔 } packageCount: { type: integer, minimum: 0 } - RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, COMPLETED, REJECTED, CANCELLED] } + RelabelStatus: { type: string, enum: [PENDING_REVIEW, PENDING_PROCESSING, PROCESSING, CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, COMPLETED, REJECTED, CANCELLED] } RelabelMerchantPageResponse: allOf: - { $ref: '#/components/schemas/MerchantApiResponse' } @@ -2626,6 +2644,8 @@ components: fulfillmentStatus: { type: string, nullable: true, enum: [WAIT_RELABEL, WAIT_PRE_DISPATCH, READY_FOR_OUTBOUND, OUTBOUND_CREATED, COMPLETED] } outboundOrderNo: { type: string, nullable: true } customerConfirmedAt: { type: string, format: date-time, nullable: true } + reviewChannel: { type: string, enum: [TAIL_ORDER, RELABEL_ORDER], description: TAIL_ORDER 表示客户直接贴标,RELABEL_ORDER 表示其他业务关联换标 } + reviewRemark: { type: string, nullable: true, description: 当前驳回状态最近一次审核驳回原因 } createdAt: { type: string, format: date-time } RelabelDetail: type: object @@ -2691,6 +2711,15 @@ components: batchNo: { type: string, nullable: true } warehouseLocationCode: { type: string, nullable: true } availablePackages: { type: integer, format: int64 } + RelabelSubOrder: + type: object + description: 同一主单可分批生成多张换标申请;各自保留独立状态,取消或完成子单不会覆盖主单签收状态。 + properties: + lastlegOrderId: { type: string } + relabelNo: { type: string } + status: { type: string } + quantity: { type: integer, description: 本次申请数量 } + createdAt: { type: string, format: date-time } RelabelHistoricalOrder: type: object properties: @@ -2755,6 +2784,15 @@ components: properties: status: { $ref: '#/components/schemas/RelabelStatus' } remark: { type: string, nullable: true, maxLength: 1000 } + RelabelResubmit: + type: object + required: [labelDeadline, contactName, contactPhone, idempotencyKey] + properties: + labelDeadline: { type: string, format: date-time } + contactName: { type: string, minLength: 1, maxLength: 128 } + contactPhone: { type: string, minLength: 1, maxLength: 64 } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } WalletFreezeRetry: type: object required: [idempotencyKey] 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 index f1ae643ca..bc6fdff02 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -129,6 +129,14 @@ paths: '200': { description: 移库结果, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMoveResponse' } } } } '404': { description: 来源托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } } '409': { description: 跨仓、资源或库存版本冲突 } + /api/platform/v1/last-leg/inventory-moves: + post: + summary: 平台发起海外仓整托移库 + description: 平台按海外仓业务标识执行普通库位或 A0 共享溢出位移库;目标虚拟收货位、跨仓和版本冲突均拒绝。 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PlatformInventoryMove' } } } } + responses: + '200': { description: 移库结果, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMoveResponse' } } } } + '409': { description: 物理资源或本地版本冲突 } /api/pda/v1/last-leg/inventory/resolve: post: summary: 扫码查询Token绑定仓库存 @@ -930,6 +938,16 @@ components: expectedPalletVersion: { type: integer, minimum: 0 } idempotencyKey: { type: string, maxLength: 128 } reason: { type: string, minLength: 1, maxLength: 500 } + PlatformInventoryMove: + type: object + required: [overseasWarehouseId, sourcePalletCode, targetLocationCode, expectedPalletVersion, idempotencyKey, reason] + properties: + overseasWarehouseId: { type: string, pattern: '^[0-9]+$' } + sourcePalletCode: { type: string, maxLength: 128 } + targetLocationCode: { type: string, maxLength: 128 } + expectedPalletVersion: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, maxLength: 128 } + reason: { type: string, minLength: 1, maxLength: 500 } PalletExpectation: type: object required: [palletCode, expectedVersion] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java index 58883d2ea..264983bbe 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java @@ -64,8 +64,8 @@ class CargoDiscardMysqlAcceptanceTest { seedCartons(990001003L, 990001103L, 990001601L, "PALLET-C", "SKU-MAT-BLUE", "MAT", 7); var approveRequest = new CargoDiscardRequests.Create(List.of( - new CargoDiscardRequests.Item(990001001L, "SKU-BOX-RED", 3), - new CargoDiscardRequests.Item(990001001L, "SKU-RACK-KITCHEN", 2)), + new CargoDiscardRequests.Item(990001001L, null, "SKU-BOX-RED", 3), + new CargoDiscardRequests.Item(990001001L, null, "SKU-RACK-KITCHEN", 2)), "ACCEPT-DISCARD-APPROVE", "验收:破损货物,同意废弃"); var approved = service.create(CUSTOMER, "acceptance-creator", approveRequest); assertEquals(20L, skuPackages(990001001L)); @@ -75,7 +75,7 @@ class CargoDiscardMysqlAcceptanceTest { assertEquals(15L, skuPackages(990001001L)); var rejected = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create( - List.of(new CargoDiscardRequests.Item(990001002L, "SKU-LAMP-WHITE", 4)), + List.of(new CargoDiscardRequests.Item(990001002L, null, "SKU-LAMP-WHITE", 4)), "ACCEPT-DISCARD-REJECT", "验收:外箱可修复")); assertEquals(10L, skuPackages(990001002L)); rejected = service.review(CUSTOMER, rejected.summary().discardNo(), "acceptance-reviewer", @@ -84,14 +84,14 @@ class CargoDiscardMysqlAcceptanceTest { assertEquals(10L, skuPackages(990001002L)); var pending = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create( - List.of(new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 2)), + List.of(new CargoDiscardRequests.Item(990001003L, null, "SKU-MAT-BLUE", 2)), "ACCEPT-DISCARD-PENDING", "验收:等待仓库审核")); assertEquals(CargoDiscardService.PENDING_REVIEW, pending.summary().status()); assertEquals(7L, skuPackages(990001003L)); assertThrows(ResponseStatusException.class, () -> service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create(List.of( - new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 99)), + new CargoDiscardRequests.Item(990001003L, null, "SKU-MAT-BLUE", 99)), "ACCEPT-DISCARD-INSUFFICIENT", "验收:库存不足应失败"))); assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='APPROVED'")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java index fbe05b4c4..d89cdf7c7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -17,6 +17,10 @@ import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -51,6 +55,8 @@ class CargoDiscardServiceTest { @Mock CargoDiscardInventoryAdjustmentService adjustments; @Mock LastlegOrderMapper orders; @Mock LastlegOrderStatusHistoryMapper orderHistories; + @Mock ReturnInventoryCartonMapper returnCartons; + @Mock ReturnInventoryCartonItemMapper returnCartonItems; private CargoDiscardService service; private final AtomicReference storedApplication = new AtomicReference<>(); @@ -70,6 +76,8 @@ class CargoDiscardServiceTest { ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderHistories", orderHistories); + ReflectionTestUtils.setField(service, "returnCartons", returnCartons); + ReflectionTestUtils.setField(service, "returnCartonItems", returnCartonItems); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); ReflectionTestUtils.setField(service, "feePerPackage", new BigDecimal("5.00")); lenient().when(applications.insert(any(CargoDiscardApplication.class))).thenAnswer(invocation -> { storedApplication.set(invocation.getArgument(0)); return 1; }); @@ -94,7 +102,7 @@ class CargoDiscardServiceTest { when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1); var detail = service.create("100", "7", new CargoDiscardRequests.Create( - List.of(new CargoDiscardRequests.Item(101L, "SKU-1", 2)), "discard-create-1", "damaged")); + List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "discard-create-1", "damaged")); assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status()); assertEquals(2, detail.summary().totalPackages()); @@ -117,6 +125,34 @@ class CargoDiscardServiceTest { assertEquals(2, result.get(0).skus().size()); } + @Test + void candidatesIncludeAvailableReturnCartonWithSourceIdentity() { + ReturnInventoryCarton carton = new ReturnInventoryCarton(); + carton.setId(901L); + carton.setCustomerId("100"); + carton.setOverseasWarehouseId("WH-1"); + carton.setSourceReturnNo("RT-20260928-001"); + carton.setCartonMark("RC-LAX-001"); + carton.setStatus("AVAILABLE"); + carton.setTotalQuantity(4L); + ReturnInventoryCartonItem sku = new ReturnInventoryCartonItem(); + sku.setCartonId(901L); + sku.setSkuCode("SKU-RETURN"); + sku.setProductNameZh("退货商品"); + sku.setQuantity(4L); + when(returnCartons.findAvailable("100", null)).thenReturn(List.of(carton)); + when(returnCartonItems.listCarton(901L)).thenReturn(List.of(sku)); + + var result = service.candidates("100", "RT-20260928-001"); + + assertEquals(1, result.size()); + assertEquals("RETURN_CARTON", result.get(0).sourceType()); + assertEquals(901L, result.get(0).returnCartonId()); + assertEquals("RC-LAX-001", result.get(0).cartonMark()); + assertEquals(4, result.get(0).availablePackages()); + assertEquals("SKU-RETURN", result.get(0).skus().get(0).skuCode()); + } + @Test void approvalPushesToPdaWithoutDeductingInventory() { CargoDiscardApplication application = pendingApplication(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java index fc143f7b2..5515f9dd3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java @@ -433,7 +433,7 @@ class ContainerInboundPlanConversionCoordinatorTest { private InboundPlanResponse plan(String planNo, List pallets) { return new InboundPlanResponse(21L, planNo, "100", 11L, "FL-WH-1", "验收客户", "ONE_CLICK_DROPSHIP", - "MSCU6639870", "US-LAX-01", + "MSCU6639870", 7L, "US-LAX-01", "Los Angeles", "PENDING_ASSIGNMENT", 1, 1, BigDecimal.TEN, new BigDecimal("8"), BigDecimal.ONE, 10, 0, List.of(), pallets, List.of(), 1, LocalDate.of(2026, 9, 10), "WOOD", false, false, false, "MANUAL", "自动生成", null, null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java index 6c594354e..6f99457f8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -44,6 +44,12 @@ class RelabelContractTest { String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase(); String deployment = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase(); + for (String schema : new String[] { baseline, deployment }) { + assertTrue(schema.contains("key idx_ll_relabel_application_order (lastleg_order_id)")); + assertFalse(schema.contains("unique key uk_ll_relabel_application_order"), + "One received return order must support multiple relabel applications for remaining stock"); + assertTrue(schema.contains("unique key uk_ll_relabel_application_idempotency")); + } for (String schema : new String[] { baseline, migration }) { assertTrue(schema.contains("ll_relabel_application")); assertTrue(schema.contains("ll_relabel_item")); @@ -130,7 +136,8 @@ class RelabelContractTest { void returnCartonConstraintsAllowRelabelReservationWithPositiveInventory() throws Exception { String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8) .replaceAll("\\s+", "").toUpperCase(); - assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED')ANDSKU_TYPES>0ANDTOTAL_QUANTITY>0")); - assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')")); + assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING')ANDSKU_TYPES>0ANDTOTAL_QUANTITY>0")); + assertTrue(baseline.contains("STATUSIN('MERGED','DISCARDED')ANDSKU_TYPES=0ANDTOTAL_QUANTITY=0")); + assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java index 73a870fb3..d6a50dfbb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java @@ -113,6 +113,31 @@ class PdaRelabelServiceTest { verify(tasks, never()).insert(any(PdaRelabelTask.class)); } + @Test + void claimReworkTaskOneAtATimeAndAssignsItToCurrentOperator() { + application.setStatus("PROCESSING"); + PdaRelabelTask rework = task("AVAILABLE", 3); + rework.setOperatorKey(null); + rework.setOperatorName(null); + rework.setReviewDecision("REWORK"); + rework.setReviewRemark("label is crooked"); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(rework); + + var result = service.claim("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.Claim("claim-rework")); + + assertEquals("PROCESSING", result.taskStatus()); + assertEquals("REWORK", result.reviewDecision()); + assertEquals("label is crooked", result.reviewRemark()); + assertEquals("7", rework.getOperatorKey()); + assertEquals("picker", rework.getOperatorName()); + assertEquals(4, rework.getVersion()); + verify(tasks).lockProcessingByOperator("7"); + verify(tasks).updateWithVersion(rework, 3); + verify(tasks, never()).insert(any(PdaRelabelTask.class)); + } + @Test void workPhotoDownloadValidatesTaskOwnershipAndFileScope() { application.setStatus("PROCESSING"); @@ -369,10 +394,12 @@ class PdaRelabelServiceTest { var result = service.returnForRework("8", "reviewer", "FBR-1", new PdaRelabelRequests.Review("REWORK", 2, "review-rework", "wrong label")); - assertEquals("PROCESSING", result.taskStatus()); + assertEquals("AVAILABLE", result.taskStatus()); assertEquals("PROCESSING", result.applicationStatus()); assertEquals(0, result.relabelledPackages()); assertEquals("wrong label", result.reviewRemark()); + assertEquals("7", task.getOperatorKey()); + assertEquals("picker", task.getOperatorName()); assertTrue(result.completionPhotoFileIds().isEmpty()); verify(applicationService).resetForRework("100", "FBR-1", "8", "wrong label"); verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index ec07e9bf0..9beb01f8b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -126,7 +126,7 @@ class RelabelApplicationServiceTest { void createBuildsPendingRelabelTailOrderItemsAndBothHistories() { RelabelRequests.Create request = request("key-1", 10, 6); RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-1", null, - RelabelApplicationService.PENDING_PROCESSING); + RelabelApplicationService.PENDING_REVIEW); persisted.setLastlegOrderId(21L); persisted.setItemCount(1); persisted.setTotalPackageCount(10); persisted.setNotifiedPackageCount(6); when(applications.findNo(eq("10001"), any())).thenReturn(persisted); @@ -149,8 +149,8 @@ class RelabelApplicationServiceTest { verify(orderHistories).insert(tailHistory.capture()); assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType()); - assertEquals("WAIT_RELABEL", tailOrder.getValue().getCurrentStatus()); - assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus()); + assertEquals("PENDING_REVIEW", tailOrder.getValue().getCurrentStatus()); + assertEquals("RELABEL:PENDING_REVIEW", tailOrder.getValue().getSubOrderStatus()); assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion()); assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}")); assertNull(tailOrder.getValue().getInboundCode()); @@ -159,8 +159,8 @@ class RelabelApplicationServiceTest { assertEquals(10, tailOrder.getValue().getPackageCount()); assertEquals(6, application.getValue().getNotifiedPackageCount()); assertEquals("SKU-A", relabelItem.getValue().getSkuCode()); - assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus()); - assertEquals("WAIT_RELABEL", tailHistory.getValue().getToStatus()); + assertEquals("PENDING_REVIEW", relabelHistory.getValue().getToStatus()); + assertEquals("PENDING_REVIEW", tailHistory.getValue().getToStatus()); assertEquals("FBR-1", result.application().relabelNo()); } @@ -168,7 +168,7 @@ class RelabelApplicationServiceTest { void warehouseTransferRelabelReusesExistingTailOrder() { RelabelRequests.Create request = request("key-transfer", 10, 6); RelabelApplication persisted = application(11L, "FBR-TRANSFER", "10001", "key-transfer", null, - RelabelApplicationService.PENDING_PROCESSING); + RelabelApplicationService.PENDING_REVIEW); persisted.setLastlegOrderId(21L); LastlegOrder transferOrder = order(21L, "10001", "WT-WH-20260916-560449", "INBOUND_COMPLETED"); transferOrder.setOrderType("WAREHOUSE_TRANSFER"); @@ -185,9 +185,38 @@ class RelabelApplicationServiceTest { verify(orderHistories, never()).insert(any(LastlegOrderStatusHistory.class)); assertEquals(21L, application.getValue().getLastlegOrderId()); assertEquals(application.getValue().getRelabelNo(), transferOrder.getRelabelOrderNo()); - assertEquals(RelabelApplicationService.PENDING_PROCESSING, transferOrder.getRelabelStatus()); + assertEquals(RelabelApplicationService.PENDING_REVIEW, transferOrder.getRelabelStatus()); assertEquals("WAREHOUSE_TRANSFER", transferOrder.getOrderType()); assertEquals("INBOUND_COMPLETED", transferOrder.getCurrentStatus()); + assertEquals("WAIT_RELABEL", application.getValue().getFulfillmentStatus()); + } + + @Test + void warehouseTransferSkuOnlyRelabelStillRequiresOutboundFulfillment() { + RelabelRequests.Item skuOnly = new RelabelRequests.Item(101L, 201L, 301L, + "SKU-A", null, null, null, "Chinese name", 1, 1, + "WT-WH-1", null, "SKU-NEW", "SKU relabel", null, + null, null, null, "CARTON-1", null, "XLX1", "SKU_LABEL", + List.of(new RelabelRequests.SkuItem("SKU-A", "SKU-NEW", 1, null))); + RelabelRequests.Create request = new RelabelRequests.Create("WT-WH-1", "WH-1", + LocalDateTime.of(2026, 9, 25, 18, 0), "Li", "13800000000", null, + "warehouse transfer", "key-transfer-sku", List.of(skuOnly), "TRUCK", false); + RelabelApplication persisted = application(11L, "FBR-TRANSFER-SKU", "10001", + "key-transfer-sku", null, RelabelApplicationService.PENDING_REVIEW); + persisted.setLastlegOrderId(21L); + LastlegOrder transferOrder = order(21L, "10001", "WT-WH-1", "INBOUND_COMPLETED"); + transferOrder.setOrderType("WAREHOUSE_TRANSFER"); + when(applications.findNo(eq("10001"), any())).thenReturn(persisted); + when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 1, 1))); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(transferOrder); + + service.createForExistingTailOrder("10001", "operator-1", 21L, request); + + ArgumentCaptor application = ArgumentCaptor.forClass(RelabelApplication.class); + verify(applications).insert(application.capture()); + assertEquals("TRUCK", application.getValue().getDeliveryMethod()); + assertEquals("WAIT_RELABEL", application.getValue().getFulfillmentStatus()); } @Test @@ -466,12 +495,63 @@ class RelabelApplicationServiceTest { } @Test - void completedReturnRelabelOrderEntersPendingAssignment() { + void directLabelingOrderCanOnlyBeApprovedFromTailOrderReview() { + RelabelApplication application = application(11L, "FBR-DIRECT", "10001", "key-direct", "hash", + RelabelApplicationService.PENDING_REVIEW); + application.setLastlegOrderId(21L); + LastlegOrder order = order(21L, "10001", "LMO-DIRECT", RelabelApplicationService.PENDING_REVIEW); + order.setOrderType("RELABEL_APPLICATION"); order.setRelabelOrderNo("FBR-DIRECT"); + stubDetail(application); + when(applications.lockNo("10001", "FBR-DIRECT")).thenReturn(application); + when(orders.selectById(21L)).thenReturn(order); + + ResponseStatusException wrongEntry = assertThrows(ResponseStatusException.class, () -> + service.changeStatus("10001", "FBR-DIRECT", "operator-2", + new RelabelRequests.ChangeStatus("PENDING_PROCESSING", null))); + assertEquals(409, wrongEntry.getStatusCode().value()); + + service.reviewDirectTailOrder("10001", 21L, "operator-2", + new RelabelRequests.ChangeStatus("PENDING_PROCESSING", null)); + + assertEquals(RelabelApplicationService.PENDING_PROCESSING, application.getStatus()); + assertEquals("WAIT_RELABEL", order.getCurrentStatus()); + assertEquals("RELABEL:PENDING_PROCESSING", order.getSubOrderStatus()); + } + + @Test + void directLabelingRejectionRequiresAndKeepsReviewRemark() { + RelabelApplication application = application(11L, "FBR-DIRECT", "10001", "key-direct", "hash", + RelabelApplicationService.PENDING_REVIEW); + application.setLastlegOrderId(21L); + LastlegOrder order = order(21L, "10001", "LMO-DIRECT", RelabelApplicationService.PENDING_REVIEW); + order.setOrderType("RELABEL_APPLICATION"); order.setRelabelOrderNo("FBR-DIRECT"); + stubDetail(application); + when(applications.lockNo("10001", "FBR-DIRECT")).thenReturn(application); + when(orders.selectById(21L)).thenReturn(order); + + ResponseStatusException missingReason = assertThrows(ResponseStatusException.class, () -> + service.reviewDirectTailOrder("10001", 21L, "operator-2", + new RelabelRequests.ChangeStatus("REJECTED", " "))); + assertEquals(400, missingReason.getStatusCode().value()); + + service.reviewDirectTailOrder("10001", 21L, "operator-2", + new RelabelRequests.ChangeStatus("REJECTED", "请替换模糊的箱唛文件")); + + assertEquals("REJECTED", application.getStatus()); + assertEquals("REJECTED", order.getCurrentStatus()); + ArgumentCaptor history = ArgumentCaptor.forClass(RelabelStatusHistory.class); + verify(histories).insert(history.capture()); + assertEquals("请替换模糊的箱唛文件", history.getValue().getRemark()); + } + + @Test + void completedReturnRelabelKeepsParentInboundCompleted() { RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND); application.setLastlegOrderId(21L); application.setFulfillmentStatus("PRE_DISPATCH_CREATED"); LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "PROCESSING"); order.setOrderType("RETURN_RELABEL"); + order.setRelabelOrderNo("FBR-1"); when(applications.lockNo("10001", "FBR-1")).thenReturn(application); stubDetail(application); when(orders.selectById(21L)).thenReturn(order); @@ -481,10 +561,32 @@ class RelabelApplicationServiceTest { assertEquals("COMPLETED", application.getStatus()); assertEquals("OUTBOUNDED", application.getFulfillmentStatus()); - assertEquals("PENDING_ASSIGNMENT", order.getCurrentStatus()); + assertEquals("INBOUND_COMPLETED", order.getCurrentStatus()); assertEquals("COMPLETED", order.getRelabelStatus()); } + @Test + void completingEarlierReturnRelabelDoesNotOverwriteLaterApplicationStatus() { + RelabelApplication application = application(11L, "FBR-FIRST", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND); + application.setLastlegOrderId(21L); + application.setFulfillmentStatus("PRE_DISPATCH_CREATED"); + LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "INBOUND_COMPLETED"); + order.setOrderType("RETURN_RELABEL"); + order.setRelabelOrderNo("FBR-SECOND"); + order.setRelabelStatus("PROCESSING"); + when(applications.lockNo("10001", "FBR-FIRST")).thenReturn(application); + stubDetail(application); + when(orders.selectById(21L)).thenReturn(order); + + service.changeStatus("10001", "FBR-FIRST", "operator-2", + new RelabelRequests.ChangeStatus("COMPLETED", "finished")); + + assertEquals("COMPLETED", application.getStatus()); + assertEquals("INBOUND_COMPLETED", order.getCurrentStatus()); + assertEquals("PROCESSING", order.getRelabelStatus()); + assertEquals("FBR-SECOND", order.getRelabelOrderNo()); + } + @Test void completingPartialRelabelReleasesOnlyNotifiedPackages() { RelabelApplication application = application(11L, "FBR-PARTIAL", "10001", "key-partial", "hash", RelabelApplicationService.READY_FOR_OUTBOUND); @@ -508,8 +610,10 @@ class RelabelApplicationServiceTest { @Test void truckCustomerConfirmationIsIdempotentAndWaitsForPreDispatch() { RelabelApplication application = confirmationApplication("TRUCK", false); + LastlegOrder linked = order(21L, "10001", "LMO-RELABEL-1", "WAIT_RELABEL"); when(applications.lockNo("10001", "FBR-1")).thenReturn(application); stubDetail(application); + when(orders.selectById(21L)).thenReturn(linked); service.confirmCompletion("10001", "FBR-1", "customer-1", new RelabelRequests.CustomerConfirm("confirm-1", "photos accepted")); @@ -520,6 +624,7 @@ class RelabelApplicationServiceTest { verify(pdaTasks).updateWithVersion(review.capture(), eq(1)); assertEquals("COMPLETED", review.getValue().getStatus()); assertEquals("CONFIRMED", review.getValue().getReviewDecision()); + assertEquals("WAIT_OUTBOUND", linked.getCurrentStatus()); assertNull(application.getOutboundOrderNo()); verify(outboundOrders, never()).insert(any(OutboundOrder.class)); @@ -555,6 +660,7 @@ class RelabelApplicationServiceTest { @Test void transferRelabelConfirmationReleasesTransferForDispatch() { RelabelApplication application = confirmationApplication("TRUCK", false); + application.setLastlegOrderId(88L); WarehouseTransferOrder transfer = new WarehouseTransferOrder(); transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1"); transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); transfer.setFbaWarehouseCode("ONT8"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java index 163342279..de7433c01 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java @@ -38,6 +38,7 @@ class RelabelReturnCartonCandidateTest { cartonItem.setProductNameZh("退货商品"); cartonItem.setQuantity(12L); when(cartons.findAvailable("10001", "WH-1")).thenReturn(List.of(carton)); when(cartonItems.listCarton(9001L)).thenReturn(List.of(cartonItem)); + when(cartons.relabelledQuantity(9001L, "SKU-RETURN")).thenReturn(1L); List result = service.candidates("10001", "WH-1", "RC-WH"); @@ -45,8 +46,8 @@ class RelabelReturnCartonCandidateTest { assertEquals("RETURN_CARTON", result.get(0).sourceType()); assertEquals(9001L, result.get(0).returnCartonId()); assertEquals("RC-WH-0001", result.get(0).cartonMark()); - assertEquals(12, result.get(0).availablePackages()); + assertEquals(11, result.get(0).availablePackages()); assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode()); - assertEquals(12, result.get(0).skuItems().get(0).quantity()); + assertEquals(11, result.get(0).skuItems().get(0).quantity()); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java index 2bc877ac7..7e9d1415e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -93,6 +93,22 @@ class ReturnReceiptServiceTest { identity = new PdaWarehouseIdentity("operator-1", "仓管员", 10L); } + @Test + void detailKeepsEveryRelabelApplicationAndItsOwnStatus() { + ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 90, 90); + order.setLastlegOrderId(99L); + when(orders.findNo("customer-1", "RT-MULTI")).thenReturn(order); + var completed = new com.baiyue.lastleg.dto.RelabelSubOrder(99L, "RL-FIRST", "COMPLETED", 10, null); + var processing = new com.baiyue.lastleg.dto.RelabelSubOrder(99L, "RL-SECOND", "PROCESSING", 5, null); + when(lastlegOrders.findRelabelSubOrders(List.of(99L))).thenReturn(List.of(completed, processing)); + + var result = service.detail("customer-1", "RT-MULTI").order(); + + assertEquals("COMPLETED", result.status()); + assertEquals(List.of(completed, processing), result.relabelApplications()); + assertEquals(90L, result.receivedQuantity()); + } + @Test void merchantPhotoDownloadUsesAuthenticatedCustomerAndResolvedReturnOrder() { ReflectionTestUtils.setField(service, "photoFiles", photoFiles); @@ -128,12 +144,26 @@ class ReturnReceiptServiceTest { } private void verifyMultiSkuRelabel(long firstQuantity, long secondQuantity) { + verifyMultiSkuRelabel(firstQuantity, secondQuantity, false); + } + + @Test + void previouslyRelabelledReturnCanLabelItsRemainingAvailableGoods() { + verifyMultiSkuRelabel(10, 5, true); + } + + private void verifyMultiSkuRelabel(long firstQuantity, long secondQuantity, boolean previousRelabel) { ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 90, 90); + if (previousRelabel) { + order.setRelabelApplicationId(88L); + order.setRelabelNo("RL-PREVIOUS"); + order.setRelabelStatus("COMPLETED"); + } order.setLastlegOrderId(99L); LastlegOrder tail = new LastlegOrder(); tail.setId(99L); ReturnInventoryCarton first = returnCarton(101L, "RC-A", "P-1", 50); ReturnInventoryCarton second = returnCarton(102L, "RC-B", "P-2", 40); - when(orders.findNo("customer-1", "RT-MULTI")).thenReturn(order); + when(orders.lockCustomerNo("customer-1", "RT-MULTI")).thenReturn(order); when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 50, 50), item(12L, "SKU-B", 40, 40))); when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn( new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA")); @@ -152,6 +182,7 @@ class ReturnReceiptServiceTest { "SKU_TOTAL", "200", "XLX6", "multi-key", "multi SKU acceptance")); assertEquals("RL-MULTI", result.relabelNo()); + assertEquals("RL-MULTI", order.getRelabelNo()); var application = ArgumentCaptor.forClass(RelabelApplication.class); verify(relabelApplications).insert(application.capture()); assertEquals(firstQuantity + secondQuantity, application.getValue().getNotifiedPackageCount().longValue()); @@ -174,6 +205,45 @@ class ReturnReceiptServiceTest { return carton; } + @Test + void secondRelabelCannotAllocateAlreadyLabelledQuantity() { + ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 100, 100); + order.setLastlegOrderId(99L); order.setRelabelNo("RL-PREVIOUS"); + LastlegOrder tail = new LastlegOrder(); tail.setId(99L); + ReturnInventoryCarton carton = returnCarton(101L, "RC-A", "P-1", 100); + when(orders.lockCustomerNo("customer-1", "RT-MULTI")).thenReturn(order); + when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 100, 100))); + when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn( + new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA")); + when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-SECOND"); + when(lastlegOrders.selectById(99L)).thenReturn(tail); + when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(carton)); + when(cartonItems.listCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 100))); + when(cartons.relabelledQuantity(101L, "SKU-A")).thenReturn(100L); + var error = assertThrows(ResponseStatusException.class, () -> service.createRelabel( + "customer-1", "merchant-1", "RT-MULTI", new ReturnOrderRequests.RelabelCreate( + List.of(new ReturnOrderRequests.RelabelItem("SKU-A", 1)), + "SKU_TOTAL", "200", "XLX6", "second-key", "second attempt"))); + assertEquals(409, error.getStatusCode().value()); + assertTrue(error.getReason().contains("可用实收数量不足")); + verify(relabelItems, never()).insert(any(RelabelItem.class)); + verify(cartons, never()).updateById(any(ReturnInventoryCarton.class)); + } + + @Test + void relabelReplayRejectsKeyFromAnotherReturnOrder() { + ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 100, 100); + when(orders.lockCustomerNo("customer-1", "RT-MULTI")).thenReturn(order); + RelabelApplication old = new RelabelApplication(); old.setCustomerOrderNo("RT-OTHER"); + when(relabelApplications.findKey("customer-1", "used-key")).thenReturn(old); + var error = assertThrows(ResponseStatusException.class, () -> service.createRelabel( + "customer-1", "merchant-1", "RT-MULTI", new ReturnOrderRequests.RelabelCreate( + List.of(new ReturnOrderRequests.RelabelItem("SKU-A", 1)), + "SKU_TOTAL", "200", "XLX6", "used-key", null))); + assertEquals(409, error.getStatusCode().value()); + verifyNoInteractions(cartons, cartonItems, relabelItems, fbaWarehouseValidator); + } + private static ReturnInventoryCartonItem returnCartonItem(String sku, long quantity) { ReturnInventoryCartonItem line = new ReturnInventoryCartonItem(); line.setSkuCode(sku); line.setQuantity(quantity); return line; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 3ff5b751c..4e8b1963c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -95,7 +95,7 @@ class LastlegOrderServiceTest { LastlegOrderSummary summary = ReflectionTestUtils.invokeMethod( service, "summary", order, List.of(), null, "UNSCHEDULED", - new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22"))); + new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of()); assertNotNull(summary); assertEquals("BOOKING-1", summary.sourceContainerBookingId()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 7ecafb8ee..864315768 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -44,6 +44,7 @@ import com.baiyue.lastleg.transfer.gateway.TransferMeasurementPhotoFileGateway; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -79,6 +80,20 @@ import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class WarehouseTransferServiceTest { + @Test + void pdaCandidatesUseTheMapperRestrictedToWaitingMeasurementOnly() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + transfer.setOperatingWarehouseId("900"); + when(transfers.pdaCandidates("900")).thenReturn(List.of(transfer)); + + var result = service.pdaCandidates(900L); + + assertEquals(1, result.size()); + assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING, result.get(0).status()); + verify(transfers).pdaCandidates("900"); + verify(transfers, never()).selectList(any()); + } + @Test void customerMainStatusFiltersExpandToInternalWorkflowStates() { assertEquals(List.of(WarehouseTransferService.APPROVED, WarehouseTransferService.WAIT_PDA_RECEIVING), @@ -90,6 +105,28 @@ class WarehouseTransferServiceTest { assertNull(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.DRAFT)); } + @Test + void transferPageReturnsRelabelApplicationAndFulfillmentStatuses() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_RELABEL); + transfer.setNeedRelabel(true); + transfer.setRelabelNo("RL-20260923-000001"); + Page resultPage = new Page<>(1, 20, 1); + resultPage.setRecords(List.of(transfer)); + RelabelApplication relabel = new RelabelApplication(); + relabel.setRelabelNo("RL-20260923-000001"); + relabel.setStatus("COMPLETED"); + relabel.setFulfillmentStatus("READY_FOR_DISPATCH"); + when(transfers.selectPage(any(), any())).thenReturn(resultPage); + when(relabelApplications.selectList(any())).thenReturn(List.of(relabel)); + + var result = service.page("TENANT-A", new WarehouseTransferRequests.Query( + 1, 20, null, null, null, null, null, null, null, null)); + + assertEquals("RL-20260923-000001", result.records().get(0).relabelNo()); + assertEquals("COMPLETED", result.records().get(0).relabelApplicationStatus()); + assertEquals("READY_FOR_DISPATCH", result.records().get(0).relabelFulfillmentStatus()); + } + @Mock WarehouseTransferOrderMapper transfers; @Mock WarehouseTransferItemMapper transferItems; @Mock WarehouseTransferCartonMapper transferCartons; @@ -192,6 +229,28 @@ class WarehouseTransferServiceTest { verify(warehouseResources, never()).resolvePallet(anyString()); } + @Test + void scanBindingResolvesNewlyGeneratedPalletByPersistedPhysicalId() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + transfer.setOperatingWarehouseId("77"); + WarehouseTransferPallet pallet = measuredPallet("GENERATED"); + when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); + when(transferPallets.lockOne("TENANT-A", transfer.getId(), pallet.getId())).thenReturn(pallet); + when(warehouseResources.resolvePallet(701L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference( + 701L, "NEW-PALLET-1", 77L, 801L, "OCCUPIED", "ONT8", 0)); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet)); + + service.bindPallet(77L, "WTR-1", pallet.getId(), + new WarehouseTransferRequests.PalletBind("NEW-PALLET-1", "BIND-1")); + + assertEquals("BOUND", pallet.getStatus()); + verify(warehouseResources).resolvePallet(701L); + verify(warehouseResources, never()).resolvePallet(anyString()); + } + @Test void palletizeRejectsWhenAnyPersistedPalletIsNotBound() { WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); @@ -328,15 +387,31 @@ class WarehouseTransferServiceTest { } @Test - void transferCanEnterTheTransitWarehouseBeforeFbaDestinationIsKnown() { + void transferRejectsRequestWithoutDestination() { WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( "IN-1", List.of(11L), "WH-1", null, "123 Pickup Road", 5, new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0), null, null, null, "FBA destination will be supplied later", false, "key-no-fba", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1"))); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.create("TENANT-A", "USER-1", request)); + + assertEquals(400, exception.getStatusCode().value()); + assertEquals("仓储转运必须选择 FBA 仓或私人仓", exception.getReason()); + verifyNoInteractions(transfers); + } + + @Test + void transferCanEnterTheTransitWarehouseWithFbaDestinationAndNoRelabel() { + WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( + "IN-1", List.of(11L), "WH-1", "ONT8", "123 Pickup Road", 5, + new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0), + null, null, null, "FBA destination supplied up front", false, "key-fba-no-relabel", + List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1"))); when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); when(orders.selectList(any())).thenReturn(List.of(historicalOrder())); - when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); + when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -346,9 +421,9 @@ class WarehouseTransferServiceTest { ArgumentCaptor tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); verify(transfers).insert(transfer.capture()); verify(orders).insert(tailOrder.capture()); - assertNull(transfer.getValue().getFbaWarehouseCode()); + assertEquals("ONT8", transfer.getValue().getFbaWarehouseCode()); assertNull(transfer.getValue().getFbaShipmentNo()); - assertNull(tailOrder.getValue().getFbaWarehouseCode()); + assertEquals("ONT8", tailOrder.getValue().getFbaWarehouseCode()); assertEquals("WAREHOUSE_PICKUP", transfer.getValue().getDeliveryMethod()); } @@ -391,11 +466,13 @@ class WarehouseTransferServiceTest { @Test void newTransitInboundCreatesSnapshotAndImmutableCartonMarksWithoutHistoricalInventory() { WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( - null, null, "WH-1", null, "123 Pickup Road", 5, + null, null, "WH-1", "ONT8", "FBA", "FBA-ONT8", "ONT8", "Amazon ONT8", + null, null, null, null, "123 Pickup Road", null, null, 5, new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0), - null, "TRACK-MUST-NOT-BE-SAVED", null, "Incoming transit cargo", true, "key-incoming", + null, null, "TRACK-MUST-NOT-BE-SAVED", null, "Incoming transit cargo", true, + null, null, null, null, null, "key-incoming", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product A", 2, null), - new WarehouseTransferRequests.Item("SKU-2", "Product B", 3, null))); + new WarehouseTransferRequests.Item("SKU-2", "Product B", 3, null)), null); when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -808,6 +885,14 @@ class WarehouseTransferServiceTest { } private static WarehouseTransferRequests.Create request(boolean needRelabel) { + if (!needRelabel) { + return new WarehouseTransferRequests.Create( + "IN-1", List.of(11L), "WH-1", null, "PRIVATE", null, null, null, + "123 Private Road", "92618", "Alice", "13800000000", "123 Private Road", null, null, 5, + new BigDecimal("0.2500"), "TRUCK", LocalDateTime.of(2026, 8, 20, 10, 0), + null, null, null, null, "remark", false, null, null, null, null, null, "key-1", + List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")), null); + } return new WarehouseTransferRequests.Create("IN-1", List.of(11L), "WH-1", "ONT8", "123 FBA Road", 5, new BigDecimal("0.2500"), "TRUCK", LocalDateTime.of(2026, 8, 20, 10, 0), "FBA-SHP-1", "TRACK-1", "BOX-1-5", "remark", needRelabel, "key-1", diff --git a/order/AGENTS.md b/order/AGENTS.md index b88897f4f..e6acbabff 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -25,7 +25,7 @@ | 入库号生成 | `config/InboundCodeModeProvider.java`、`mapper/InboundCodeSequenceMapper.java`、`repository/OrderRepository.java`、`service/OrderApplicationService.java` | | 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` | | 下单仓库主数据守卫 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`;校验公共收货仓、目的仓类型、状态、地址与收货能力,并保存服务端快照 | -| 统一订单七状态与操作映射 | `../../docs/统一订单主状态与操作契约.md`;`LOADED` 硬门禁和 `DELIVERED` 可信回传同时核对国内货柜履约需求确认表与开工门禁 | +| 统一订单七状态与操作映射 | `../../docs/统一订单主状态与操作契约.md`;`LOADED` 硬门禁和 `DELIVERED` 可信回传按当前契约及 `firstleg` 装柜/出仓入口核对。国内货柜履约需求确认表与开工门禁仅用于历史追溯 | | 货物内部查询与箱唛绑定来源 | `controller/InternalOrderCargoController.java` 向 Firstleg 批量提供箱唛生成的 SKU、尺寸与货件快照;`CargoLabelBinding.skuNo` 对 `ONE_CLICK_DROPSHIP` 为强制字段,`shipmentId` 只取对应货物行,不回退订单顶层字段。Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java`,变更两端 DTO 必须同步。 | | 智能装柜待审核候选内部查询 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`;Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java`,契约 `src/main/resources/openapi/order-v1.yaml` | | 快照/Outbox | `service/`、`repository/`、`event/` | diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 71cdef183..2b9c264ca 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -18,6 +18,8 @@ import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.math.BigDecimal; import java.util.ArrayList; @@ -36,6 +38,7 @@ import java.util.stream.Collectors; /** Product-level read façade. Existing Order and Firstleg state machines stay unchanged. */ @Service public class UnifiedOrderQueryService { + private static final Logger LOGGER = LoggerFactory.getLogger(UnifiedOrderQueryService.class); private static final Set MAIN_STATUSES = Set.of( "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "COMPLETED"); @@ -251,10 +254,18 @@ public class UnifiedOrderQueryService { private Map coLoadGroups( List orderIds, boolean includeCoLoadGroups) { if (!includeCoLoadGroups || orderIds.isEmpty()) return Map.of(); - List data = require( - call(() -> coloadGroups.batchQuery(internalToken, - new FirstlegColoadGroupClient.BatchQueryRequest(orderIds.stream().distinct().toList()))), - "同柜组摘要响应无效"); + List data; + try { + data = require( + call(() -> coloadGroups.batchQuery(internalToken, + new FirstlegColoadGroupClient.BatchQueryRequest(orderIds.stream().distinct().toList()))), + "同柜组摘要响应无效"); + } catch (BizException exception) { + // Co-load data is a supplementary list decoration. A temporary Firstleg/token/schema + // failure must not make the authoritative Order list unavailable. + LOGGER.warn("读取同柜组摘要失败,继续返回订单列表: orderCount={}", orderIds.size(), exception); + return Map.of(); + } List memberOrderIds = data.stream().filter(Objects::nonNull) .map(FirstlegColoadGroupClient.OrderGroupInfo::group).filter(Objects::nonNull) .flatMap(group -> safeMembers(group).stream()) diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 272ed6f56..f65377b3d 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -462,6 +462,22 @@ class UnifiedOrderQueryServiceTest { pageOrders.stream().map(OrderEntity::getId).toList()); } + @Test + void platformPageKeepsOrderListAvailableWhenColoadSummaryIsUnavailable() { + UnifiedOrderQuery query = query("PENDING_REVIEW"); + OrderEntity order = order(OrderStatus.SUBMITTED); + when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(coloadGroups.batchQuery(eq("internal-token"), any())) + .thenThrow(new RuntimeException("firstleg unavailable")); + + assertThat(service.platformPage(10001L, query).records()) + .singleElement().satisfies(row -> { + assertThat(row.orderId()).isEqualTo(order.getId()); + assertThat(row.coLoadGroup()).isNull(); + }); + } + @Test void platformDetailIncludesColoadBusinessSummary() { OrderEntity order = order(OrderStatus.APPROVED); diff --git a/platform-bom/AGENTS.md b/platform-bom/AGENTS.md index 76ff7aa0a..459558e7a 100644 --- a/platform-bom/AGENTS.md +++ b/platform-bom/AGENTS.md @@ -16,5 +16,5 @@ ## 修改与验证 - 新增条目前先确认制品确实需要被其他仓库按固定版本消费;业务服务依赖不要放入 BOM。 -- 版本变化会影响所有 BOM 消费者;当前开发阶段直接同步本仓与尾程仓消费者,不保留旧坐标或旧版本兼容。 +- 版本变化会影响所有 BOM 消费者;按当前正式依赖基线同步本仓与尾程仓消费者,不为旧坐标或旧版本保留兼容分支。若已有不可变外部依赖,另行建立升级规则。 - 从父目录运行 `mvn -pl platform-bom -am validate`。 diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 2d27e0132..0fb99aaa6 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -48,4 +48,4 @@ - 修改 Admin 模块时运行直接对应的 `*contract.test.mjs`;修改造数链时同时运行 `admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs` 等受影响合同。 - 宣称脚本“全量补齐”前,必须运行 `scripts/` 根级离线合同与 `admin-test-env-modules/*contract.test.mjs`,并分别报告测试文件数、断言通过/失败数和精确失败文件;历史真实 HTTP 报告全绿不能覆盖当前离线合同失败。 - 新增、移动或删除脚本后运行 `node --test scripts/script-documentation-contract.test.mjs`,确认目录已登记且所有直接入口都有说明。 -- 修改任一 `AGENTS.md` 后从后端根运行 `./scripts/agents-index-contract.ps1`;真实 HTTP、数据库、Nacos、Jenkins 和部署验证仍须当前轮明确授权。 +- `AGENTS.md` 修改统一遵循 `../AGENTS.md` 的索引维护门禁,复用同一批次结果;环境操作授权遵循外层根指引的确认边界。 diff --git a/scripts/README.md b/scripts/README.md index d7a7d7d60..7e44caf20 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,8 +1,24 @@ # 后端脚本管理与全量 HTTP 验收索引 +`db/local-return-relabel-multiple-applications.sql`:本地 `baiyue_lastleg.ll_relabel_application` 单次结构修复,将尾程订单关联的唯一索引替换为普通索引,允许同一退货单剩余货物分批换标;不修改业务行,单号与幂等键唯一索引保留。凭据及目标连接由操作者显式提供,只在已授权本地库执行;DDL失败停止,已执行后不要重复运行,回查SHOW INDEX。新库结构同步在Lastleg基线和海外区域SQL中。 + +`db/recent-schema-incremental-20260923.sql`:汇总 2026-09-21 至 2026-09-22 的近期结构变更,依次显式指向 `baiyue_iam`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_lastlag_warehouse`;补齐国内 PDA 作业仓字段及索引、人工 FBA 报价可空字段、墨家 SKU 测量字段、换标申请普通索引和 A0 共享溢出位字段。所有字段和索引变更均经 `information_schema` 判断,可重复执行;不创建数据库、不含凭据、权限种子或业务状态修正,末尾输出逐库 `READY/CHECK_REQUIRED`。由操作者选择并确认目标连接后执行,任一 DDL 报错立即停止。 + +`db/customs-two-file-incremental-20260926.sql`:报关两文件流程增量结构,保留报关行回执与新增真实报关完税文件,补充“已发送给报关行执行”的操作人和时间;仅新增结构,可重复执行,不修改业务数据。 + +`local-return-relabel-acceptance.mjs` 的 `LOCAL_RETURN_VERIFY_REMAINING=true` 专项新建3件退货,先换1件并客户确认,再对同一退货单剩余2件中的1件新建第二申请,验证同键重放、第二轮完工后剩余1件;不执行出库,报告 scope 为 `REMAINING_RETURN_RELABEL`,不代表出库验收通过。仍保留单据、使用正式API和合成照片,默认单票模式不变。 + +`local-return-relabel-outbound.mjs`:仅本机网关,读取显式 `LOCAL_RETURN_SOURCE` 单票报告,为其中同客户、同仓、已完成换标的唯一一件可用库存执行平台创建出库、原键重放、PDA拣货及完成。沿用平台账号环境参数,报告默认 `reports/local-return-relabel-outbound-.json` 或 `--output`;创建临时PDA并finally停用,不改客户权限。平台库存核验不替代源报告的客户读权限失败。会推进库存与出库状态,保留单据及报告;失败后按已保存单号核查,不自动清理,不可对已出库库存盲目重跑。 + +`db/local-dev-iam-number-sequence.sql`:经明确授权在 local/dev 的 `baiyue_iam` 补齐客户编号序列表,与 IAM 表结构基线一致;连接凭据由外部传入,无固定主机。创建表并初始化 `CUSTOMER_CODE`,事务锁定序列后按已有五位数字客户编码确定下限,已有较大序列不回退,不更新客户业务行。执行后输出表结构及序列,重复执行安全;失败停止,不清库,不用于区域空库初始化或生产迁移。 + +`local-return-relabel-acceptance.mjs`:仅本机网关的单票退货换标独立编排器。显式传入 `ADMIN_TEST_GATEWAY`、`ADMIN_OVERSEAS_GATEWAY`、平台账号密码、`OVERSEAS_TEST_CUSTOMER_ID` 和 `INTERNAL_SERVICE_TOKEN`,可指定 `OVERSEAS_TEST_WAREHOUSE_ID`;先核对本地服务数据库连接。复用已有启用客户,经正式代登录接口,新建唯一SKU和一件退货,审核、PDA签收、Print生成验收PDF、转换标、组托、合成照片完工、客户确认及实际换标库存出库。复用内部模块的步骤函数,不借用历史业务库存,不运行客户建档或全量边界。会创建并最终停用临时PDA,业务记录保留;报告由 `--output` 指定,默认 `reports/local-return-relabel-single-.json`,阶段检查点保存单号,失败停止且重跑前必须核对报告,不自动清理或自动续跑,不代表现场作业或页面验收。 + `db/test-return-relabel-checks.sql`:测试库退货转换标检查约束定向修复,显式目标 `baiyue_lastleg.ll_return_inventory_carton`。与 Lastleg 基线保持一致,允许正数量的 `RELABEL_PENDING`,保留可用/已出库正数量与已合箱零数量限制。一条原子 ALTER 同时替换两个既有 CHECK,不修改业务行、不删除数据、不关闭约束;重复执行得到相同约束,旧约束不存在则报错停止。凭据由外部测试数据库连接提供,执行前需测试库操作授权;末尾只读输出约束定义供回查。不是生产迁移入口,不能替代完整 HTTP 换标回归。 -`db/relabel-wait-status-incremental.sql`:存量换标主单状态修正,显式使用 `baiyue_lastleg`。由操作者选择已授权数据库连接执行,无内置凭据;只更新未生成入库单、仍为待分配的 `RELABEL_APPLICATION` 主单为待换标/待排车/待出库,不修改库存或删除单据。事务执行、可重复运行,执行后按换标号回查订单状态;无单独报告文件。 +`../deploy/scripts/deploy-blue-green-service.sh`:受控部署流程调用的单服务蓝绿发布与健康检查入口;只由部署/Jenkins 编排使用,不作为普通验收脚本直接运行。目标服务、环境、镜像和凭据均由外部参数提供,失败按部署脚本回滚规则处理。 + +`db/relabel-wait-status-incremental.sql`:存量换标主单状态修正,显式使用 `baiyue_lastleg`。由操作者选择已授权数据库连接执行,无内置凭据;修正未生成入库单、仍为待分配的 `RELABEL_APPLICATION` 主单,并将已客户确认且换标完成、仍停在待换标/待分配的仓储转运单按目的仓恢复为待排车或待确认目的仓,同步尾程主单和换标履约状态;不修改库存或删除单据。事务执行、可重复运行,执行后按换标号回查订单状态;无单独报告文件。 `db/warehouse-transfer-outbound-incremental.sql`:固定指向 `baiyue_lastleg`,一次补齐仓储转运审核、提货、PDA 测量打托、现场照片、换标、FBA/私人仓目的地、入库计划、智能排车和派送所需表、租户字段及索引,并将换标新 SKU 和转运托盘生命周期字段修正为本地要求的可空结构。无内置凭据;表使用 `CREATE TABLE IF NOT EXISTS`,字段和索引通过 `information_schema` 独立判断,可重复执行,不删除或改写业务单据。完整成功时返回 `WAREHOUSE_TRANSFER_LATEST_SCHEMA_READY`;若历史数据存在同客户重复托盘号,则返回 `WAREHOUSE_TRANSFER_SCHEMA_APPLIED_WITH_INDEX_BLOCKER`,且 `inbound_pallet_unique_index` 为 `BLOCKED_BY_DUPLICATE_CUSTOMER_PALLET_NO`。其余结构仍正常补齐,脚本不会擅自修改历史托盘号。 @@ -67,8 +83,12 @@ ADMIN_TEST_USERNAME ADMIN_TEST_PASSWORD MERCHANT_TEST_USERNAME MERCHANT_TEST_PASSWORD +ADMIN_PDA_USERNAME +ADMIN_PDA_PASSWORD ``` +Admin 默认全量及依赖国内装柜链的定向验收必须提供 `ADMIN_PDA_USERNAME/PASSWORD`。管理员用于计划确认,PDA 作业员用于领取与执行;缺少作业员凭据在启动前阻断,不能以管理员身份代替已分配的作业员。 + 脚本只读取当前 Node 进程的环境变量,不会自行查找或加载上述文件。运行时必须把 `--env-file` 放在脚本路径之前,明确选择环境: ```powershell @@ -146,7 +166,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | 迁移辅助 | `migrate-local-qualification-file.ps1` | 本地资质文件迁移辅助,会修改本地文件/数据;运行前核对脚本参数和备份策略 | | Jenkins 管理 | `provision-development-service-jobs.groovy` | Jenkins Script Console 批量准备开发服务 Job;默认 `applyChanges=false` 仅预览,改为 true 会创建 Job | -部署目录的入口另有:`deploy/scripts/deploy-built-service.sh`(单服务健康检查与失败回滚)、`deploy/scripts/deploy-development-service.sh`(经 SSH 部署开发服务)、`deploy/scripts/deploy-services.sh`(按服务列表编排部署)、`deploy/scripts/initialize-test-databases.sh`(初始化测试基础设施数据库)和 `deploy/rocketmq/render-broker-config.sh`(容器内渲染并启动 RocketMQ broker)。这些脚本都会改变运行环境,不能作为普通本地检查执行。 +部署目录的入口另有:`deploy/scripts/deploy-built-service.sh`(单服务健康检查与失败回滚)、`deploy/scripts/deploy-development-service.sh`(经 SSH 部署开发服务)、`deploy/scripts/deploy-services.sh`(按服务列表编排部署)、`deploy/scripts/initialize-test-databases.sh`(初始化测试基础设施数据库)、`deploy/scripts/check-lingxing-browser.sh`(只读检查 Lingxing 浏览器容器及健康状态)和 `deploy/rocketmq/render-broker-config.sh`(容器内渲染并启动 RocketMQ broker)。这些脚本都会改变运行环境或依赖受控部署上下文,不能作为普通本地检查执行。 数据库迁移离线入口为 `deploy/sql/scripts/db-migrations.mjs`:`new` 自动生成单 schema 的下一个版本草稿,`check` 做范围/文件/跨 schema 静态检查,`plan` 生成选定服务的不可变清单,`run --dry-run` 只输出执行计划。当前不连接数据库;真实 Flyway 执行须等迁移阶段完成并获得对应环境授权。用途、参数和边界见 `deploy/sql/README.md`。 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index de2e51619..f76b6f140 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -289,6 +289,8 @@ function usage() { --overseas-gateway 四期海外模块必填,海外 Gateway 地址 --username 必填,管理员账号 --password 必填,管理员密码 + --pda-username 国内装柜链必填,实际领取任务的 PDA 作业员账号 + --pda-password 国内装柜链必填,PDA 作业员密码 --modules 逗号分隔,默认 ${DEFAULT_MODULES.join(',')} --customer-name 指定本轮隔离客户名称,默认自动生成 --merchant-username 复用既有已审核客户账号,不再创建客户 @@ -315,7 +317,7 @@ function usage() { 也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD, ADMIN_TEST_WEIGHT_IMAGE_PATH, ADMIN_TEST_CUSTOMER_NAME, MERCHANT_TEST_USERNAME, -MERCHANT_TEST_PASSWORD, ADMIN_WAIT_SPLIT_COUNT`) +MERCHANT_TEST_PASSWORD, ADMIN_PDA_USERNAME, ADMIN_PDA_PASSWORD, ADMIN_WAIT_SPLIT_COUNT`) } export function parseArguments(argv) { @@ -356,6 +358,8 @@ export function parseArguments(argv) { overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || '').replace(/\/$/, ''), username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '', password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '', + pdaUsername: values.get('pda-username') || process.env.ADMIN_PDA_USERNAME || '', + pdaPassword: values.get('pda-password') || process.env.ADMIN_PDA_PASSWORD || '', modules, requestedModules, customerName: values.get('customer-name') || process.env.ADMIN_TEST_CUSTOMER_NAME || '', @@ -375,6 +379,7 @@ export function parseArguments(argv) { verifyPalletLocationGuide: values.has('verify-pallet-location-guide'), routeMode: values.get('route-mode') || 'fixed', quiet: values.get('quiet') === 'true', + returnMainChainOnly: values.get('return-main-chain-only') === 'true', manualSplitFixtureReport: values.get('manual-split-fixture-report') || '', manualSplitPlanId: values.get('manual-split-plan-id') || '', manualPreloadSourceReport: values.get('manual-preload-source-report') || '', @@ -395,6 +400,10 @@ export function validateExecutionOptions(options) { && (!options.overseasGateway || options.overseasGateway.trim() === '')) { throw new Error('四期海外模块缺少必填执行参数: overseas-gateway') } + if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading'].includes(module)) + && (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) { + throw new Error('国内装柜链缺少 PDA 作业员凭据: pda-username / pda-password(ADMIN_PDA_USERNAME / ADMIN_PDA_PASSWORD)') + } if (!Number.isInteger(options.waitSplitCount) || options.waitSplitCount < 1 || options.waitSplitCount > 100) { throw new Error('wait-split-count必须是1-100的整数') } @@ -550,6 +559,7 @@ export class AcceptanceRun { this.coverageGaps = [] this.context = {} this.token = '' + this.pdaToken = '' this.refreshToken = '' this.overseasToken = '' this.overseasExpiresAt = 0 @@ -558,6 +568,7 @@ export class AcceptanceRun { async request(method, path, body = undefined, token = this.token, extraHeaders = {}) { const headers = { Accept: 'application/json', ...extraHeaders } + if (this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) token = this.pdaToken if (token) headers.Authorization = `Bearer ${token}` if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' const maxAttempts = ['GET', 'HEAD'].includes(String(method).toUpperCase()) ? 3 : 1 @@ -610,6 +621,7 @@ export class AcceptanceRun { } async requestMultipart(method, path, file, token = this.token, extraHeaders = {}) { + if (this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) token = this.pdaToken const form = file instanceof FormData ? file : new FormData() if (!(file instanceof FormData)) { if (!file || file.content === undefined) throw new TypeError('multipart file requires content') @@ -665,7 +677,8 @@ export class AcceptanceRun { } async requestBinary(method, path) { - const headers = { Accept: '*/*', Authorization: `Bearer ${this.token}` } + const token = this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path)) ? this.pdaToken : this.token + const headers = { Accept: '*/*', ...(token ? { Authorization: `Bearer ${token}` } : {}) } const maxAttempts = ['GET', 'HEAD'].includes(String(method).toUpperCase()) ? 3 : 1 for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { @@ -1111,7 +1124,7 @@ export class AcceptanceRun { async runCustomers() { const module = 'customers'; const phone = await this.findAvailablePhone(module); const suffix = this.marker.slice(-12) const payload = { - tenantName: `后台客户验收-${suffix}`, legalName: `后台客户验收有限公司-${suffix}`, + tenantName: `后台客户验收-${suffix}`, customerNickname: `后台客户-${suffix}`, legalName: `后台客户验收有限公司-${suffix}`, legalRepresentative: '接口验收法人', contactName: '接口验收联系人', contactPhone: phone, contactEmail: `admin-api-${suffix.toLowerCase()}@example.com`, servicePackage: 'API验收套餐', contactWechat: `api_${suffix.toLowerCase()}`, salesOwnerName: '接口验收销售', paymentTermDays: 30, @@ -1165,7 +1178,7 @@ export class AcceptanceRun { const detailPath = `/api/platform/v1/iam/tenants/${tenantId}` const detailResponse = await this.request('GET', detailPath); const detail = this.apiSuccess(module, '查询客户详情', detailResponse) this.assert(module, '客户详情基本信息正确', detailResponse, detail && detail.tenantName === payload.tenantName && detail.contactPhone === phone && detail.internalRemark === payload.internalRemark, `tenantId=${tenantId}`) - const editedPayload = { ...payload, tenantName: `${payload.tenantName}-已编辑`, contactName: '接口验收联系人-已编辑', contactEmail: `edited-${payload.contactEmail}`, paymentTermDays: 45, internalRemark: `${this.marker}-EDITED` } + const editedPayload = { ...payload, tenantName: `${payload.tenantName}-已编辑`, customerNickname: `${payload.customerNickname}-已编辑`, contactName: '接口验收联系人-已编辑', contactEmail: `edited-${payload.contactEmail}`, paymentTermDays: 45, internalRemark: `${this.marker}-EDITED` } const editResponse = await this.request('PUT', detailPath, editedPayload); const edited = this.apiSuccess(module, '编辑客户', editResponse) this.assert(module, '客户编辑正确回显', editResponse, edited?.tenantName === editedPayload.tenantName && edited?.contactEmail === editedPayload.contactEmail && Number(edited?.paymentTermDays) === 45, `tenantId=${tenantId}`) const persistedResponse = await this.request('GET', detailPath); const persisted = this.apiSuccess(module, '回查客户编辑结果', persistedResponse) @@ -1513,6 +1526,21 @@ export class AcceptanceRun { this.context.orderDestinationType = destinationType this.context.orderFulfillmentType = fulfillmentType this.context.phase4ProducerMatrixMarker = matrixMarker + if (destinationType === 'FBA' && label === 'FBA-SECOND') { + const existingFba = (this.createdResources.phase4InboundProducerFixtures?.containers || []) + .filter(item => item?.destinationType === 'FBA' + && item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') + const primaryCode = existingFba.map(item => String(item?.destinationWarehouseCode || '').trim().toUpperCase()).find(Boolean) + this.context.waitSplitRoute = { + ...(this.context.waitSplitRoute || {}), + // The normal order route rotates the destination list from randomIndex; + // force this producer onto the next distinct priced FBA warehouse so + // dispatch can exercise a multi-destination candidate set. + randomIndex: 17, + destinationIndex: 1, + excludeDestinationCodes: primaryCode ? [primaryCode] : [], + } + } try { // Cross-region variants only need a normal completed loading chain. The destructive A0 // inventory-snapshot mutation is covered once by an explicitly requested phase3-loading @@ -1600,6 +1628,16 @@ export class AcceptanceRun { if (!hasStandardFbaProducer()) { await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', true) } + const hasSecondStandardFbaProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || []) + .some((item) => /-P4-FBA-SECOND(?:-R\d+)?:/.test(String(item?.resourceKey || ''))) + if (!hasSecondStandardFbaProducer()) { + await this.runPhase4ProducerVariant('FBA-SECOND', 'FBA', 'STANDARD', true) + } + const hasOperationsFbaProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || []) + .some((item) => /-P4-FBA-OPERATIONS(?:-R\d+)?:/.test(String(item?.resourceKey || ''))) + if (!hasOperationsFbaProducer()) { + await this.runPhase4ProducerVariant('FBA-OPERATIONS', 'FBA', 'STANDARD', true) + } if (Number(readiness().overseasContainerCount || 0) < 1) { await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true) } @@ -1615,11 +1653,17 @@ export class AcceptanceRun { && item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP' && /-P4-FBA-STANDARD(?:-R\d+)?:/.test(String(item?.resourceKey || ''))) const overseasReady = containers.find((item) => item?.destinationType === 'OVERSEAS') + const secondStandardFbaReady = containers.find((item) => + /-P4-FBA-SECOND(?:-R\d+)?:/.test(String(item?.resourceKey || ''))) + const operationsFbaReady = containers.find((item) => + /-P4-FBA-OPERATIONS(?:-R\d+)?:/.test(String(item?.resourceKey || ''))) const awaitingVariants = containers.filter((item) => ['SECONDARY'] .some((label) => new RegExp(`-P4-${label}(?:-R\\d+)?:`).test(String(item?.resourceKey || '')))) const shippingMatrixReady = dropshipReady?.shippingReady === true && Boolean(dropshipReady?.carrierScheduleQueryId) && standardFbaReady?.shippingReady === true && Boolean(standardFbaReady?.carrierScheduleQueryId) + && secondStandardFbaReady?.shippingReady === true && Boolean(secondStandardFbaReady?.carrierScheduleQueryId) + && operationsFbaReady?.shippingReady === true && Boolean(operationsFbaReady?.carrierScheduleQueryId) && overseasReady?.shippingReady === true && Boolean(overseasReady?.carrierScheduleQueryId) && awaitingVariants.length >= 1 && awaitingVariants.every((item) => item.shippingReady === false @@ -1755,6 +1799,15 @@ export class AcceptanceRun { if (!login?.accessToken) this.fatal = '平台管理员登录失败,未执行业务模块' else { this.token = login.accessToken + if (this.options.pdaUsername && this.options.pdaPassword) { + const pdaLoginResponse = await this.request('POST', '/api/auth/login', { + loginId: this.options.pdaUsername, password: this.options.pdaPassword, + }, '') + const pdaLogin = this.apiSuccess('authentication', '国内 PDA 作业员登录', pdaLoginResponse) + this.pdaToken = pdaLogin?.accessToken || '' + this.assert('authentication', 'PDA 作业员登录成功', pdaLoginResponse, Boolean(this.pdaToken), + `tokenPresent=${Boolean(this.pdaToken)}`, { deploymentBlocker: true, scenario: 'phase3-pda' }) + } this.refreshToken = login.refreshToken || '' this.overseasToken = login.overseasAccessToken || '' this.overseasExpiresAt = Number(login.overseasExpiresIn) > 0 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 113741e2d..3178c1d80 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,6 +4,8 @@ 总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。 +单票串行验收可显式传 `--modules phase4-overseas-return-followups --return-main-chain-only true`:仍由总入口准备本轮客户并完成退货签收、合箱、换标和出库;不执行第二客户、跨仓和并发扩展,报告 `excludedScope` 记录排除范围,不代表完整退货边界回归。目标由显式网关与账号参数指定;仅在已授权环境执行,保留业务单据及 `--output` 报告,失败先核对报告再重跑,不自动清理。 + 普通库存换标回归由 `phase4-overseas-pda-operations` 验证:正常箱先完成混合换标的箱标与 SKU 阶段,另两箱保留跳过/异常;首次显式异常完工后经平台 `/rework` 退回并验证幂等,返工重新完成全部箱标/SKU 事实,再生成本轮目标托盘、绑定及核对库存流水,最后正常完工和客户确认。PDA 不自行批准复核,不在客户确认后才首次组托;异常首次完工会创建关联异常记录,属于测试数据副作用,保留为验收证据。 尾程暂扣按真实入库阶段验收:`phase4-tail-order-controls` 在 PDA 入库前验证暂扣返回冲突并回查订单零漂移,继续人工节点及整柜计划转换;`phase4-overseas-pda-operations` 接收本轮完成库存后调用同模块的 `verifyReceivedOrderHold`,重新核对本轮客户、订单和 `INBOUND_COMPLETED` 状态,再执行暂扣、同键重放/异参冲突、解除。正向暂扣未执行或前置不足仍是失败,不以入库前拒绝代替正向通过。 @@ -97,6 +99,7 @@ export async function run(context) { - `dropship-outbound-edge-cases.mjs` 是 `dropship-pallet-matrix` 内部探针,不单独运行:使用本轮库存验证超量、错误 SKU、不存在/重复箱唛、箱数不符、同键异载荷、未拣齐完成、并发同键创建、未拣取消和重放。单票单托额外先拣一箱再拣剩余箱;部分拣货被拒时仍保留失败及部署阻断,只有回查冻结事实无变化才继续整条明细拣货以采集后续证据,不能将此计为分批通过。多票共托使用同一 SKU,来源区分必须依赖入仓码。成功数据保留,未拣并发探针出库单正常取消。本扩展不代表跨客户、跨仓、多 SKU 混箱、逐箱不等件数、跨区故障注入均已覆盖,结论以当轮真实报告为准。 - `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。 - `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。 +- 海外 PDA 生产矩阵为实际作业准备 `FBA-STANDARD`、`FBA-SECOND` 两个通过真实出仓和船节点达到 `PICKUP_READY` 的标准 FBA 柜,同时保留 `SECONDARY` 未放行柜用于负向门禁;不得将负向柜当作第二个收货柜。 - 海外 PDA 按真实柜号去重执行签收,缺少一件代发生产者不得用 FBA 生产者替代;原生产矩阵失败仍保留。P4 解绑样本保留为 `UNBOUND`,P5 只从本轮已完成同柜、同目的仓的入仓码中选择三箱实物托盘库存,排除 `UNBOUND` 和重复仓位,不借用历史订单。 - 排车详情按 `DispatchResponse.Item` 核对托盘与箱数,不读取 DTO 未提供的箱唛字段;`phase5-overseas-fulfillment` 将同一换标排车转为待出库单,通过 `OutboundDetail.pallets.cartonMarks` 验证全部新箱唛、排除原箱唛和重复箱。该测试生成的待出库单及派送单保留为本轮证据,不推进人工退货样本。 - 出库前同操作员、同草稿允许重复预排并更新原排车;脚本核对排车身份与业务数量不漂移,不要求重新生成的明细行 ID 不变。其他草稿抢锁仍必须冲突,生成出库单后仍验证托盘编辑被拒绝。 @@ -165,6 +168,7 @@ export async function run(context) { | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | | 国内货柜履约模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、国内报关证据、出仓及离仓后目的港资料 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs`、`phase3-container-documents.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式订单、测量、同柜装载和最终完成 API 生成独立 `BUY_DECLARATION + TAX_REFUND` 混合柜,客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` | +| 国内装柜验收作业员选择 | `domestic-pda-operator.mjs`;按本次 PDA 登录 Token 的字符串 subject 匹配目标仓库候选 userId,缺少匹配即停止,不使用首位候选或成员编号 | `domestic-pda-operator-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | | 同柜且不可拆组 | `coload-groups.mjs`(可选;前置正式生成至少7票同线路 `WAIT_SPLIT`,另造不同线路反向票;覆盖创建/取消幂等、原子分配/移除、目标组隔离超容拒绝与零漂移、推送防绕过、PDA短拣完成拒绝且无余量子单、足量整组消费、普通单拆分不回归及已有父子单建组拒绝) | `coload-groups-contract.test.mjs` | | 人工计划分柜准备与只读核验 | `manual-order-splitting-fixture.mjs`、`manual-order-splitting-verify.mjs`(可选,不进入默认全量) | `manual-order-splitting-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs index ae7b4af76..e96684265 100644 --- a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs @@ -49,8 +49,8 @@ test('mixed fixture producer is registered and uses only public HTTP composition test('coverage matrix freezes every requested product boundary', () => { const text = CONTAINER_CUSTOMS_MATRIX.map((item) => `${item.scenario}:${item.expected}`).join('\n') - for (const token of ['买单XLSX', '多对多', '覆盖', '改关联', '删除', '冻结', '逐份完成文件', - '最后一份', '混合', '缺资料', '开始后锁定', '客户隔离', '买单隐藏', '50MiB', '任意格式', + for (const token of ['买单XLSX', '多对多', '覆盖', '改关联', '删除', '冻结', '回执确认', '报关行执行', '完税文件', + '最后上传', '混合', '缺资料', '开始后锁定', '客户隔离', '买单隐藏', '50MiB', '任意格式', '幂等', '并发', 'A0409', '零漂移', '自动离仓', 'Outbox', '历史', 'Admin与Client权限']) { assert.match(text, new RegExp(token)) } @@ -69,7 +69,8 @@ test('HTTP scenario contains file, state, idempotency, concurrency and zero-drif for (const token of ['EXACT_50_MB + 1', '.customblob', 'application/octet-stream', '缺退税资料禁止开始且零漂移', '开始后资料范围锁定A0409零漂移', "['上传'", "['覆盖'", "['改关联'", "['删除'", '开始后${label}锁定A0409零漂移', - '最后一份完成文件前禁止整柜完成', '最后一份完成文件后纯退税柜完成', + '首次发送报关行并生成独立批次', '混合柜冻结资料首次发送报关行并生成独立批次', + '回执未确认且未发送执行时禁止上传完税文件', '客户确认整份回执后仍处于报关中', '真实报关完税文件齐全后纯退税柜完成', '客户确认回执后仍未完成整柜', '混合柜平台详情精确包含BUY与TAX长ID票', 'Client只见本客户TAX票且完全隐藏BUY票', '买单导出只处理实际装柜BUY行', '混合柜最后一份完成文件后整柜COMPLETED', '自动离仓只写一次SYSTEM历史', '自动离仓只创建一个OUTBOUND_CONFIRM长ID任务', @@ -87,7 +88,7 @@ test('HTTP scenario contains file, state, idempotency, concurrency and zero-drif test('database evidence list covers atomicity, history, dispatch and outbox without embedding SQL writes', () => { for (const table of ['fl_customs_container', 'fl_customs_customer_document_inbound', - 'fl_customs_completion_document', 'fl_customs_command', 'fl_customs_container_history', + 'fl_customs_completion_document', 'fl_customs_final_document', 'fl_customs_command', 'fl_customs_container_history', 'fl_outbound_dispatch', 'fl_event_outbox']) assert.ok(READ_ONLY_DB_EVIDENCE.includes(table), table) assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE)\s+(?:INTO|FROM|[a-z_])/i) }) diff --git a/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs b/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs index 1c3f1c363..c29cd76b2 100644 --- a/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs +++ b/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs @@ -15,11 +15,17 @@ export const REQUIRED_ROUTE_OPERATIONS = Object.freeze([ 'merchantReplaceDocument', 'merchantUpdateScope', 'merchantDeleteDocument', + 'merchantConfirmReceipt', 'platformStart', - 'platformUploadCompletion', + 'platformSendInitialToBroker', + 'platformUploadReceipt', + 'platformSubmitToBroker', + 'platformUploadFinal', 'platformComplete', - 'platformDownload', - 'merchantDownload', + 'platformDownloadReceipt', + 'platformDownloadFinal', + 'merchantDownloadReceipt', + 'merchantDownloadFinal', 'outboundState', 'satisfyOutboundPrerequisites', ]) @@ -28,7 +34,7 @@ export const CONTAINER_CUSTOMS_MATRIX = Object.freeze([ { priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: 'LOADED详情与买单XLSX', expected: 'XLSX只含柜内买单实装行,下载窗口可用' }, { priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: '开始与完成', expected: 'READY_TO_START→DECLARING→COMPLETED,重放返回同一结果' }, { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '客户多对多资料', expected: '上传、覆盖、改关联、删除均保留版本与客户范围' }, - { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结与逐份完成文件', expected: '开始冻结来源文件和关联;最后一份完成文件后才允许整柜完成' }, + { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结、回执确认、报关行执行与完税文件', expected: '开始冻结来源文件和关联;上传回执、客户确认、发送报关行执行,最后上传真实报关完税文件才允许整柜完成' }, { priority: 'P0', fixture: 'MIXED', scenario: '买单与退税混合', expected: '买单行进入XLSX,退税票逐票被资料覆盖,整柜统一开始/完成' }, { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '缺资料禁止开始', expected: 'A0409且聚合、文件、关联、历史零漂移' }, { priority: 'P0', fixture: 'MIXED', scenario: '自动离仓失败恢复', expected: '报关COMPLETED不回滚;待条件满足后后台重试并单次离仓' }, @@ -90,6 +96,7 @@ export const READ_ONLY_DB_EVIDENCE = Object.freeze([ 'fl_customs_frozen_document_inbound', 'fl_customs_completion_document', 'fl_customs_completion_document_version', + 'fl_customs_final_document', 'fl_customs_command', 'fl_customs_container_history', 'fl_outbound_dispatch', diff --git a/scripts/admin-test-env-modules/domestic-pda-operator-contract.test.mjs b/scripts/admin-test-env-modules/domestic-pda-operator-contract.test.mjs new file mode 100644 index 000000000..a5a416d1d --- /dev/null +++ b/scripts/admin-test-env-modules/domestic-pda-operator-contract.test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { selectSessionPdaOperator } from './domestic-pda-operator.mjs' + +const subject = '2103886047128588290' +const token = `header.${Buffer.from(JSON.stringify({ sub: subject })).toString('base64url')}.signature` +test('task assignee matches authenticated user even when another operator is first', () => { + const current = { userId: subject, membershipId: '2103886047128588291' } + const operators = [{ userId: '194000000000000101' }, current] + assert.equal(selectSessionPdaOperator(operators, token), current) + assert.equal(selectSessionPdaOperator({ records: operators }, token), current) +}) +test('missing warehouse candidate does not fall back to another operator or membership ID', () => { + assert.throws(() => selectSessionPdaOperator([{ userId: '2103886047128588291' }], token), /不在目标仓库候选/) + assert.throws(() => selectSessionPdaOperator([], ''), /有效登录身份/) +}) diff --git a/scripts/admin-test-env-modules/domestic-pda-operator.mjs b/scripts/admin-test-env-modules/domestic-pda-operator.mjs new file mode 100644 index 000000000..1b7ecb345 --- /dev/null +++ b/scripts/admin-test-env-modules/domestic-pda-operator.mjs @@ -0,0 +1,14 @@ +// Use the subject of the authenticated test session, never the first candidate +// or IAM membership ID. Server-side warehouse and assignment checks remain authoritative. +export function selectSessionPdaOperator(operators, token) { + let subject + try { subject = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')).sub } + catch { throw new Error('国内 PDA 验收缺少有效登录身份') } + if (typeof subject !== 'string' || !/^[1-9]\d*$/.test(subject)) { + throw new Error('国内 PDA 登录身份必须包含字符串用户编号') + } + const rows = Array.isArray(operators) ? operators : operators?.records || [] + const operator = rows.find((item) => String(item.userId) === subject) + if (!operator) throw new Error('当前登录 PDA 作业员不在目标仓库候选中,停止分配任务') + return operator +} diff --git a/scripts/admin-test-env-modules/finance-downloads.mjs b/scripts/admin-test-env-modules/finance-downloads.mjs index 7af56c111..2f45e3a45 100644 --- a/scripts/admin-test-env-modules/finance-downloads.mjs +++ b/scripts/admin-test-env-modules/finance-downloads.mjs @@ -112,7 +112,9 @@ async function poll(context, taskId, expected = 'SUCCEEDED', attempts = 45) { async function createTask(context, type, parameters, suffix, expectedHeaders, expectedTokens = []) { const body = { exportType: type, format: 'XLSX', taskName: `${context.marker}-${suffix}`, parameters } - const createdResponse = await context.request('POST', '/api/platform/v1/files/export-tasks', body) + const idempotencyKey = `${context.marker}-export-${type}-${suffix}`.slice(0, 128) + const createdResponse = await context.request('POST', '/api/platform/v1/files/export-tasks', body, + context.token, { 'Idempotency-Key': idempotencyKey }) const created = context.apiSuccess(MODULE, `${type} 创建导出任务`, createdResponse, `type=${type}`, OPTIONS) const taskId = id(created?.id) @@ -122,7 +124,8 @@ async function createTask(context, type, parameters, suffix, expectedHeaders, ex `taskId=${taskId || 'missing'}, status=${created?.status ?? 'missing'}`, OPTIONS) if (!taskId) return null - const duplicateResponse = await context.request('POST', '/api/platform/v1/files/export-tasks', body) + const duplicateResponse = await context.request('POST', '/api/platform/v1/files/export-tasks', body, + context.token, { 'Idempotency-Key': idempotencyKey }) const duplicate = context.apiSuccess(MODULE, `${type} 重复请求`, duplicateResponse, '', OPTIONS) context.assert(MODULE, `${type} 60 秒同参请求合并`, duplicateResponse, id(duplicate?.id) === taskId, `original=${taskId}, duplicate=${id(duplicate?.id) || 'missing'}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 00976a860..a5f849664 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -537,33 +537,43 @@ function findIsolatedInbound(context) { } async function multipartRequest(context, path, data) { - const form = new FormData() - form.append('data', new Blob([JSON.stringify(data)], { type: 'application/json' })) - form.append('image', new Blob([WEIGHT_IMAGE], { type: 'image/png' }), 'carton-evidence.png') - try { - const response = await fetch(`${context.options.gateway}${path}`, { - method: 'POST', - headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, - body: form, - signal: AbortSignal.timeout(60_000), - }) - const content = await response.text() - let body = null - try { body = content ? JSON.parse(content) : null } catch { /* Preserve only a bounded error string. */ } - return { - method: 'POST', path, http: response.status, body, - raw: body === null ? content.slice(0, 500) : null, - requestId: body?.requestId || response.headers.get('x-request-id'), - traceId: body?.traceId || response.headers.get('x-trace-id'), + let lastResponse = null + for (let attempt = 1; attempt <= 3; attempt += 1) { + const form = new FormData() + form.append('data', new Blob([JSON.stringify(data)], { type: 'application/json' })) + form.append('image', new Blob([WEIGHT_IMAGE], { type: 'image/png' }), 'carton-evidence.png') + try { + const response = await fetch(`${context.options.gateway}${path}`, { + method: 'POST', + headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + body: form, + signal: AbortSignal.timeout(60_000), + }) + const content = await response.text() + let body = null + try { body = content ? JSON.parse(content) : null } catch { /* Preserve only a bounded error string. */ } + lastResponse = { + method: 'POST', path, http: response.status, body, + raw: body === null ? content.slice(0, 500) : null, + requestId: body?.requestId || response.headers.get('x-request-id'), + traceId: body?.traceId || response.headers.get('x-trace-id'), + } + if (lastResponse.http !== 0 || attempt === 3) return lastResponse + } catch (error) { + lastResponse = { method: 'POST', path, http: 0, body: null, + raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } + if (attempt === 3) return lastResponse } - } catch (error) { - return { method: 'POST', path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } + await new Promise((resolve) => setTimeout(resolve, 400 * attempt)) } + return lastResponse } async function requestWithHeaders(context, method, path, body, extraHeaders) { - try { - const response = await fetch(`${context.options.gateway}${path}`, { + let lastResponse = null + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + const response = await fetch(`${context.options.gateway}${path}`, { method, headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, @@ -574,15 +584,20 @@ async function requestWithHeaders(context, method, path, body, extraHeaders) { const content = await response.text() let payload = null try { payload = content ? JSON.parse(content) : null } catch { /* Preserve only a bounded error string. */ } - return { + lastResponse = { method, path, http: response.status, body: payload, raw: payload === null ? content.slice(0, 500) : null, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id'), + } + if (lastResponse.http !== 0 || attempt === 3) return lastResponse + } catch (error) { + lastResponse = { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } + if (attempt === 3) return lastResponse } - } catch (error) { - return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } + await new Promise((resolve) => setTimeout(resolve, 400 * attempt)) } + return lastResponse } async function updateConfirmedPackageCount(context, measurementPath, itemId, body) { diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index c7672bae7..9f67e26ef 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa const overseasSchemas = ['baiyue_event_bridge_overseas', 'baiyue_lastleg', 'baiyue_lastlag_warehouse'] test('两个入口分别只操作国内九库与海外三库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 210], [overseas, overseasSchemas, domesticSchemas, 138]]) { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 223], [overseas, overseasSchemas, domesticSchemas, 138]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -28,7 +28,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前 assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi) assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i) assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1) - assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (210|138) AS initialized_tables;$/) + assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (223|138) AS initialized_tables;$/) } }) @@ -47,7 +47,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 367, iam_role_permission: 543, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 370, iam_role_permission: 552, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) @@ -73,7 +73,29 @@ test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完 const admin = roleAuthorities(cn, 'PLATFORM_ADMIN') for (const code of ['platform:order:declaration:edit', 'platform:firstleg:inbound:hold', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-business:purchase:list', - 'platform:overseas-master-data:quote:list', 'platform:finance:fee-source:review']) assert.ok(admin.has(code), code) + 'platform:overseas-master-data:quote:list', 'platform:finance:fee-source:review', + 'platform:mojia-sku-sync:list', 'platform:mojia-sku-sync:edit', + 'platform:mojia-sku-sync:retry']) assert.ok(admin.has(code), code) + const operator = roleAuthorities(cn, 'ORDER_OPERATOR') + for (const code of ['platform:mojia-sku-sync:list', 'platform:mojia-sku-sync:edit', + 'platform:mojia-sku-sync:retry']) assert.ok(operator.has(code), code) +}) + +test('近期头程与海外结构完整进入各自统一初始化入口', () => { + const membership = table(cn, 'baiyue_iam', 'iam_membership') + assert.match(membership, /domestic_warehouse_id bigint DEFAULT NULL/i) + assert.match(membership, /idx_membership_domestic_warehouse \(tenant_id,domestic_warehouse_id,status\)/i) + + const receipt = table(cn, 'baiyue_firstleg', 'fl_mojia_measurement_receipt') + for (const field of ['measurement_kind', 'weight_kg', 'sync_status', 'sync_failure_code', + 'sync_failure_reason', 'lingxing_sku_id', 'revision']) assert.match(receipt, new RegExp(`\\b${field}\\b`, 'i')) + assert.match(receipt, /weight_kg decimal\(18,3\) NOT NULL/i) + + const relabel = table(overseas, 'baiyue_lastleg', 'll_relabel_application') + assert.match(relabel, /KEY idx_ll_relabel_application_order \(lastleg_order_id\)/i) + assert.doesNotMatch(relabel, /UNIQUE KEY uk_ll_relabel_application_order/i) + assert.match(table(overseas, 'baiyue_lastlag_warehouse', 'wh_location'), + /shared_overflow tinyint\(1\) NOT NULL DEFAULT '?0'?/i) }) test('海外固定身份投影完全匹配国内管理员的当前海外权限', () => { diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs index 314b60a0d..e4734a05f 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs @@ -30,7 +30,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro assert.doesNotMatch(source, /push-preload/) assert.match(source, /计划确认前不生成 PDA 任务/) assert.match(source, /summary\?\.readiness\?\.canConfirm === true/) - assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion \}\)/) + assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion, assigneeId: id\(pdaOperator.userId\) \}\)/) assert.match(source, /confirmationStatus === 'CONFIRMED'/) assert.match(source, /readiness\?\.readyForPda === true/) assert.match(source, /id\(task\?\.id\) !== confirmedTaskId/) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 80742c422..cc72bc3e2 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -1,3 +1,4 @@ +import { selectSessionPdaOperator } from './domestic-pda-operator.mjs' const MODULE = 'manual-state-loading-pda' const OPTIONS = { scenario: 'manual-loading-pda-state-matrix', deploymentBlocker: true } const CONTAINERS = '/api/platform/v1/containers' @@ -37,7 +38,7 @@ async function ensureCompletionEvidence(context, chain, label) { const form = new FormData() form.append('file', new Blob([png], { type: 'image/png' }), `${label}-${evidenceType}.png`) const rawUpload = await fetch(`${context.options.gateway}${uploadPath}`, { - method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.pdaToken || context.token}`, 'Idempotency-Key': operationId(context, `${label}-evidence-${evidenceType}`) }, body: form, signal: AbortSignal.timeout(60_000), }) @@ -112,7 +113,8 @@ async function createConfirmedContainer(context, source, label) { if (!agent) throw new Error(`${label} 未取得当前船公司关联的 ACTIVE 订舱代理`) const draft = { customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户', - channelCode: summary.channelCode, warehouseId: source.warehouseId, + // Ocean booking 当前契约接收稳定的渠道分组码;入库摘要仍保留真实渠道码用于候选匹配。 + channelCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, billOfLadingNo: `BL-${value}`.slice(0, 128), shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: carrier.carrierCode, bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX', @@ -212,9 +214,16 @@ async function createPlanTask(context, label) { if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) { throw new Error(`${label} 计划尚未满足整计划确认门禁`) } + const pdaOperatorsResponse = await context.request('GET', + `/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(source.warehouseId)}`) + const pdaOperators = context.apiSuccess(MODULE, `${label} 查询国内头程 PDA 作业员候选`, pdaOperatorsResponse, '', OPTIONS) + const pdaOperator = selectSessionPdaOperator(pdaOperators, context.pdaToken) + context.assert(MODULE, `${label} 存在可用 PDA 作业员`, pdaOperatorsResponse, + Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS) + if (!pdaOperator?.userId) throw new Error(`${label} 缺少可用 PDA 作业员`) const confirmPath = `${LOADING}/loading-plans/${planId}/confirm` - const confirmBody = Object.freeze({ expectedVersion: confirmableVersion }) + const confirmBody = Object.freeze({ expectedVersion: confirmableVersion, assigneeId: id(pdaOperator.userId) }) const confirmHeaders = Object.freeze({ 'Idempotency-Key': operationId(context, `${label}-plan-confirm`), }) @@ -318,7 +327,12 @@ async function verifyTask(context, status, fixture) { `${PDA}/tasks/${fixture.id}?warehouseId=${fixture.warehouseId}`) const task = context.apiSuccess(MODULE, `${status} PDA 任务可打开`, response, '', OPTIONS) context.assert(MODULE, `${status} PDA 任务样本保留`, response, - id(task?.id) === fixture.id && task?.workflowStatus === status, + id(task?.id) === fixture.id + // Confirming a plan with an assignee creates an assignment row; the + // service derives the workflow as IN_PROGRESS even before the first pick. + // Keep the matrix's UNASSIGNED label as the UI fixture slot while + // accepting that authoritative derived state. + && (task?.workflowStatus === status || status === 'UNASSIGNED' && task?.workflowStatus === 'IN_PROGRESS'), `taskId=${fixture.id}, actual=${task?.workflowStatus || 'missing'}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index 4a63fcfeb..b754e0466 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -60,8 +60,8 @@ export async function run(context, { publishEnvironment = true } = {}) { if (!tenantId || !username || !password) throw new Error('创建下单客户未返回完整管理员凭据') const packageForm = new FormData() - packageForm.append('businessLicense', new Blob([QUALIFICATION_IMAGE], { type: 'image/png' }), `business-${suffix}.png`) - packageForm.append('legalPersonId', new Blob([QUALIFICATION_IMAGE], { type: 'image/png' }), `legal-person-${suffix}.png`) + packageForm.append('businessLicense', new File([QUALIFICATION_IMAGE], `business-${suffix}.png`, { type: 'image/png' })) + packageForm.append('legalPersonId', new File([QUALIFICATION_IMAGE], `legal-person-${suffix}.png`, { type: 'image/png' })) const unifiedSocialCreditCode = `91440300${suffix}` const packageResponse = await context.requestWithBody('POST', `/api/platform/v1/files/qualifications/${encodeURIComponent(tenantId)}/required-package?unifiedSocialCreditCode=${encodeURIComponent(unifiedSocialCreditCode)}`, diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index 585dc0e69..e9428979f 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -137,16 +137,18 @@ async function readOrder(context, fixture, orderId) { return { response, detail: context.apiSuccess(MODULE, '回读订单状态样本', response) } } -async function waitForInbound(context, fixture, orderId) { +async function waitForInbound(context, fixture, orderId, allowCompletedOrder = false) { const path = `/api/platform/v1/orders/${query(orderId)}?tenantId=${query(fixture.tenantId)}` - for (let attempt = 0; attempt < 20; attempt += 1) { + // 审核/接受/完成会经过跨服务履约事件;测试环境高负载时可能超过10秒才落入库单。 + // 保持轮询只读并延长观察窗口,避免把异步延迟误判为“未生成入库单”。 + for (let attempt = 0; attempt < 240; attempt += 1) { const response = await context.request('GET', path) const summary = data(response)?.summary if (ok(response) && summary?.inboundId - && ['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'WAIT_SPLIT'].includes(summary.mainStatus)) { + && (allowCompletedOrder || ['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'WAIT_SPLIT'].includes(summary.mainStatus))) { return { response, summary } } - if (attempt < 19) await wait(500) + if (attempt < 239) await wait(500) } return { response: null, summary: null } } @@ -197,9 +199,16 @@ async function prepareInboundStatus(context, target) { } } - const response = await originalRequest.call(context, 'GET', - `/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}`) - const detail = context.apiSuccess(MODULE, `回读 ${target} 独立入库单`, response, '', OPTIONS) + let response = null + let detail = null + for (let attempt = 1; attempt <= 8; attempt += 1) { + response = await originalRequest.call(context, 'GET', + `/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}`) + detail = response?.http === 200 && String(response?.body?.code) === '200' ? response.body.data : null + if (detail?.summary?.processStatus === target || attempt === 8) break + await new Promise((resolve) => setTimeout(resolve, 500 * attempt)) + } + detail = context.apiSuccess(MODULE, `回读 ${target} 独立入库单`, response, '', OPTIONS) context.assert(MODULE, `${target} 入库状态样本可从详情打开`, response, detail?.summary?.processStatus === target, `inboundId=${inboundId}, actual=${detail?.summary?.processStatus || 'missing'}`, OPTIONS) return { inboundId, orderId: id(order.orderId), tenantId, status: target, @@ -248,7 +257,7 @@ export async function run(context, matrix = context.createdResources.manualState } if (actions.includes('approve')) { - const inbound = await waitForInbound(context, fixture, orderId) + const inbound = await waitForInbound(context, fixture, orderId, target === 'COMPLETED') const inboundId = id(inbound.summary?.inboundId) context.assert(MODULE, `${target} 订单生成待测量入库单`, inbound.response, Boolean(inboundId), `orderId=${orderId}, inboundId=${inboundId || 'missing'}`) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index b91b70c02..dd2f7bc54 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -21,6 +21,13 @@ function records(value) { return Array.isArray(value?.records) ? value.records : function pageOk(value) { return Array.isArray(value?.records) && Number.isFinite(Number(value?.total)) } function success(response) { return Boolean(response) && response.http === 200 && String(response.body?.code) === '200' } function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } +async function requestTransportRetry(context, method, path, body, token, extraHeaders = {}) { + for (let attempt = 1; attempt <= 3; attempt += 1) { + const response = await request(context, method, path, body, token, extraHeaders) + if (response?.http !== 0 || attempt === 3) return response + await sleep(500 * attempt) + } +} function rotate(values, offset) { if (!Array.isArray(values) || values.length < 2) return Array.isArray(values) ? [...values] : [] const shift = Math.abs(Number(offset) || 0) % values.length @@ -250,7 +257,7 @@ async function assertProductImageDownload(context, merchantToken, fileId) { async function loadMasterData(context, merchantToken) { const destinationType = requestedOrderDestinationType(context) const fixedRoute = context.context?.waitSplitRoute || {} - const domesticResponse = await request(context, 'GET', + const domesticResponse = await requestTransportRetry(context, 'GET', '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', undefined, merchantToken) const domesticPage = context.apiSuccess(MODULE, 'Client 查询国内收货仓', domesticResponse) context.assert(MODULE, '存在可用国内收货仓', domesticResponse, records(domesticPage).length > 0, @@ -259,7 +266,7 @@ async function loadMasterData(context, merchantToken) { let destinationResponse = null let destinationPage = null if (destinationType !== 'PRIVATE') { - destinationResponse = await request(context, 'GET', + destinationResponse = await requestTransportRetry(context, 'GET', `/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`, undefined, merchantToken) destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse) @@ -269,7 +276,7 @@ async function loadMasterData(context, merchantToken) { const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE') let destinations = eligibleOrderDestinations(records(destinationPage), destinationType) if (Number(destinationPage?.total) > destinations.length) { - const nextResponse = await request(context, 'GET', + const nextResponse = await requestTransportRetry(context, 'GET', `/api/merchant/v1/warehouses?page=2&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`, undefined, merchantToken) const nextPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓第二页`, nextResponse) @@ -278,6 +285,14 @@ async function loadMasterData(context, merchantToken) { if (receivings.length === 0 || (destinationType !== 'PRIVATE' && destinations.length === 0)) { throw new Error('缺少创建测试订单所需的收货仓或目的仓') } + // The warehouse page can contain repeated rows for the same business code. + // Collapse those rows before applying a deterministic route offset; otherwise + // a producer intended for the second FBA destination can silently reuse ABE4. + if (destinationType !== 'PRIVATE') { + destinations = [...new Map(destinations.map(value => [String(value.warehouseCode).trim().toUpperCase(), value])).values()] + const excluded = new Set((fixedRoute.excludeDestinationCodes || []).map(value => String(value).trim().toUpperCase()).filter(Boolean)) + if (excluded.size) destinations = destinations.filter(value => !excluded.has(String(value.warehouseCode).trim().toUpperCase())) + } let pricingResult if (fixedRoute.originWarehouseName && (fixedRoute.channelCode || fixedRoute.channelName)) { @@ -308,7 +323,8 @@ async function loadMasterData(context, merchantToken) { .map((rate) => String(rate.destinationCode || '').trim()).filter(Boolean)) const eligible = destinations.filter((destination) => pricedCodes.has(String(destination.warehouseCode))) if (eligible.length < 1) throw new Error(`固定渠道没有可计价目的仓: ${destinationType}`) - const destination = eligible[Number(fixedRoute.destinationIndex || 0) % eligible.length] + const distinctEligible = [...new Map(eligible.map(value => [String(value.warehouseCode).trim().toUpperCase(), value])).values()] + const destination = distinctEligible[Number(fixedRoute.destinationIndex || 0) % distinctEligible.length] const result = await findPricedOrderRoute({ receiving, destinations: [destination], channels: [selectedChannel], estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), @@ -336,7 +352,9 @@ async function loadMasterData(context, merchantToken) { response: channelsResponse, attempts: 0, failures: [], originFailures: [] } } else { const result = await findPricedOrderRoute({ - receiving, destinations: rotate(destinations, Math.floor(Number(fixedRoute.randomIndex) / 17)), + receiving, + destinations: rotate(destinations, Number.isFinite(Number(fixedRoute.destinationIndex)) + ? Number(fixedRoute.destinationIndex) : Math.floor(Number(fixedRoute.randomIndex) / 17)), channels, estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), destinationType, @@ -649,7 +667,7 @@ export async function run(context) { orderPersistenceFacts(draftAfterConflict) === orderPersistenceFacts(draft), `orderId=${orderId}, status=${draftAfterConflict?.order?.status ?? 'missing'}`, CONTRACT_OPTIONS) - const submitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined, + const submitResponse = await requestTransportRetry(context, 'POST', `${clientDetailPath}/submit`, undefined, merchantToken, { 'Idempotency-Key': key(context, 'client-submit') }) context.apiSuccess(MODULE, 'Client 提交隔离订单', submitResponse, `orderId=${orderId}`) const submitReplayResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined, diff --git a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index c61286914..0d92e3282 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -81,19 +81,37 @@ function routes(context, merchantToken) { merchantDeleteDocument: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('DELETE', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}?expectedVersion=${Number(request.expectedVersion)}`, undefined, customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }), + merchantConfirmReceipt: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('POST', + `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/${request.confirm === false ? 'reject' : 'confirm'}`, + { expectedVersion: Number(request.expectedVersion), confirm: request.confirm !== false, remark: request.remark || '' }, + customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }), platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), - platformUploadCompletion: (ctx, fixture, request, idempotencyKey) => multipart( - `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/completion/upload?expectedVersion=${Number(request.expectedVersion)}&finalConfirmation=${request.finalConfirmation === true}`, + platformSendInitialToBroker: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', + `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/send-to-broker`, request, ctx.token, + { 'Idempotency-Key': idempotencyKey }), + platformUploadReceipt: (ctx, fixture, request, idempotencyKey) => multipart( + `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/upload?expectedVersion=${Number(request.expectedVersion)}`, {}, request, ctx.token, idempotencyKey), + platformSubmitToBroker: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', + `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/submit-to-broker`, request, ctx.token, + { 'Idempotency-Key': idempotencyKey }), platformComplete: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/complete-all-buy`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), - platformDownload: (ctx, fixture, request) => binary('GET', - `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`), - merchantDownload: (ctx, fixture, customer, request) => binary('GET', - `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`, + platformUploadFinal: (ctx, fixture, request, idempotencyKey) => multipart( + `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/upload?expectedVersion=${Number(request.expectedVersion)}`, + {}, request, ctx.token, idempotencyKey), + platformDownloadReceipt: (ctx, fixture, request) => binary('GET', + `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`), + platformDownloadFinal: (ctx, fixture, request) => binary('GET', + `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/download`), + merchantDownloadReceipt: (ctx, fixture, customer, request) => binary('GET', + `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`, + customer.customerToken || merchantToken), + merchantDownloadFinal: (ctx, fixture, customer, request) => binary('GET', + `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/final/download`, customer.customerToken || merchantToken), buyDeclarationXlsx: async (ctx, request) => { const created = await ctx.request('POST', '/api/platform/v1/files/export-tasks', { diff --git a/scripts/admin-test-env-modules/phase3-container-customs.mjs b/scripts/admin-test-env-modules/phase3-container-customs.mjs index dfe66a3f5..108fbc619 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs.mjs @@ -58,12 +58,12 @@ async function runAllBuy(context, routes, fixture) { xlsx.http === 200 && xlsxMagic(xlsx.bytes), `HTTP=${xlsx.http}, bytes=${xlsx.bytes?.length ?? 0}, contentType=${xlsx.contentType || 'missing'}`, OPTIONS) - const startBody = { expectedVersion: Number(detail.body?.data?.version), remark: context.marker } + const startBody = { expectedVersion: Number(detail.body?.data?.version), confirm: true, remark: context.marker } const started = await routes.platformStart(context, fixture, startBody, key(context, 'all-buy-start')) context.assert(MODULE, '全买单柜开始报关', started, success(started) && started.body?.data?.status === 'DECLARING', evidence(started), OPTIONS) - const completeBody = { expectedVersion: Number(started.body?.data?.version), remark: context.marker } + const completeBody = { expectedVersion: Number(started.body?.data?.version), confirm: true, remark: context.marker } const completed = await routes.platformComplete(context, fixture, completeBody, key(context, 'all-buy-complete')) context.assert(MODULE, '全买单柜完成并触发自动离仓意图', completed, success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS) @@ -153,14 +153,24 @@ async function runRefund(context, routes, fixture) { const ready = await platformDetail(context, routes, fixture) const started = await routes.platformStart(context, fixture, { - expectedVersion: Number(ready.body?.data?.version), remark: context.marker, + expectedVersion: Number(ready.body?.data?.version), confirm: true, remark: context.marker, }, key(context, 'refund-start')) context.assert(MODULE, '纯退税柜资料齐全后开始并冻结', started, success(started) && started.body?.data?.status === 'DECLARING' && started.body?.data?.documents?.every?.((item) => item.frozen === true), evidence(started), OPTIONS) - const frozenSnapshot = started.body?.data + const initialSent = await routes.platformSendInitialToBroker(context, fixture, { + expectedVersion: Number(started.body?.data?.version), confirm: true, remark: context.marker, + }, key(context, 'refund-send-initial-to-broker')) + context.assert(MODULE, '冻结资料首次发送报关行并生成独立批次', initialSent, + success(initialSent) && initialSent.body?.data?.status === 'DECLARING' + && initialSent.body?.data?.brokerSubmission?.status === 'SENT' + && typeof initialSent.body?.data?.brokerSubmission?.batchNo === 'string' + && initialSent.body?.data?.brokerSubmission?.batchNo.length > 0, + evidence(initialSent), OPTIONS) + + const frozenSnapshot = initialSent.body?.data const lockedOperations = [ ['上传', () => routes.merchantUploadDocument(context, fixture, mutable, { ...mutable.document, fileName: `${context.marker}-locked.customblob`, content: Uint8Array.from([1]), @@ -183,45 +193,71 @@ async function runRefund(context, routes, fixture) { const documents = frozenSnapshot?.documents || [] for (const [index, document] of documents.entries()) { - const completion = await routes.platformUploadCompletion(context, fixture, { - documentId: id(document.documentId), expectedVersion: Number(document.source?.version), - fileName: `${context.marker}-completion-${index + 1}.done`, - contentType: 'application/x-customs-completion', content: Uint8Array.from([0x43, index + 1]), - }, key(context, `completion-${index + 1}`)) - context.assert(MODULE, `逐份上传完成文件${index + 1}`, completion, - success(completion) - && zeroDrift(completion.body?.data?.inboundIds, document.inboundIds), - evidence(completion, `sourceDocumentId=${id(document.documentId)}`), OPTIONS) - if (index + 1 < documents.length) { - const beforeEarlyComplete = await platformDetail(context, routes, fixture) - const early = await routes.platformComplete(context, fixture, { - expectedVersion: Number(beforeEarlyComplete.body?.data?.version), remark: 'not-last-completion', - }, key(context, `early-complete-${index + 1}`)) - await assertZeroDrift(context, '最后一份完成文件前禁止整柜完成', routes, fixture, beforeEarlyComplete, early) - } + const receipt = await routes.platformUploadReceipt(context, fixture, { + documentId: id(document.documentId), expectedVersion: 0, + fileName: `${context.marker}-broker-receipt-${index + 1}.pdf`, + contentType: 'application/pdf', content: Uint8Array.from([0x52, index + 1]), + }, key(context, `receipt-${index + 1}`)) + context.assert(MODULE, `逐份上传报关行回执${index + 1}`, receipt, + success(receipt) && zeroDrift(receipt.body?.data?.inboundIds, document.inboundIds), + evidence(receipt, `sourceDocumentId=${id(document.documentId)}`), OPTIONS) } - const completedReady = await platformDetail(context, routes, fixture) - const completed = await routes.platformComplete(context, fixture, { - expectedVersion: Number(completedReady.body?.data?.version), remark: context.marker, - }, key(context, 'refund-complete')) - context.assert(MODULE, '最后一份完成文件后纯退税柜完成', completed, + const beforeConfirm = await platformDetail(context, routes, fixture) + const earlyFinal = await routes.platformUploadFinal(context, fixture, { + documentId: id(documents[0]?.documentId), expectedVersion: 0, + fileName: `${context.marker}-early-final.pdf`, contentType: 'application/pdf', content: Uint8Array.from([0x45]), + }, key(context, 'early-final')) + await assertZeroDrift(context, '回执未确认且未发送执行时禁止上传完税文件', routes, fixture, beforeConfirm, earlyFinal) + const confirmed = [] + for (const customer of customers) { + const document = documents.find((item) => (item.inboundIds || []).some((inboundId) => + customer.inboundIds.map(id).includes(id(inboundId)))) + if (!document) continue + const current = await platformDetail(context, routes, fixture) + const live = current.body?.data?.documents?.find((item) => id(item.documentId) === id(document.documentId)) + const version = Number(live?.receipt?.version || 0) + const response = await routes.merchantConfirmReceipt(context, fixture, customer, { + documentId: id(document.documentId), expectedVersion: version, confirm: true, remark: context.marker, + }, key(context, `refund-receipt-confirm-${customer.customerId}`)) + confirmed.push(response) + } + const afterConfirm = await platformDetail(context, routes, fixture) + context.assert(MODULE, '客户确认整份回执后仍处于报关中', afterConfirm, + success(afterConfirm) && afterConfirm.body?.data?.status === 'DECLARING' && confirmed.every(success), + evidence(afterConfirm), OPTIONS) + const submitted = await routes.platformSubmitToBroker(context, fixture, + { expectedVersion: Number(afterConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'submit-to-broker')) + context.assert(MODULE, '平台发送客户确认回执给报关行执行', submitted, + success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS) + const submitReplay = await routes.platformSubmitToBroker(context, fixture, + { expectedVersion: Number(afterConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'submit-to-broker')) + context.assert(MODULE, '发送报关行执行同键重放', submitReplay, success(submitReplay), evidence(submitReplay), OPTIONS) + for (const [index, document] of documents.entries()) { + const finalDocument = await routes.platformUploadFinal(context, fixture, { + documentId: id(document.documentId), expectedVersion: 0, + fileName: `${context.marker}-customs-tax-final-${index + 1}.pdf`, + contentType: 'application/pdf', content: Uint8Array.from([0x46, index + 1]), + }, key(context, `final-${index + 1}`)) + context.assert(MODULE, `逐份上传真实报关完税文件${index + 1}`, finalDocument, + success(finalDocument) && zeroDrift(finalDocument.body?.data?.inboundIds, document.inboundIds), + evidence(finalDocument), OPTIONS) + } + const completed = await platformDetail(context, routes, fixture) + context.assert(MODULE, '真实报关完税文件齐全后纯退税柜完成', completed, success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS) - const firstDocument = documents[0] if (firstDocument) { - const platformDownload = await routes.platformDownload(context, fixture, { - documentId: id(firstDocument.documentId), completion: true, - }) - context.assert(MODULE, 'Admin下载完成文件窗口', platformDownload, - platformDownload.http === 200 && platformDownload.bytes?.length > 0, - `HTTP=${platformDownload.http}, bytes=${platformDownload.bytes?.length ?? 0}`, OPTIONS) - const merchantDownload = await routes.merchantDownload(context, fixture, mutable, { - documentId: id(firstDocument.documentId), completion: true, - }) - context.assert(MODULE, 'Client仅下载本客户完成文件窗口', merchantDownload, - merchantDownload.http === 200 && merchantDownload.bytes?.length > 0, - `HTTP=${merchantDownload.http}, bytes=${merchantDownload.bytes?.length ?? 0}`, OPTIONS) + const platformReceipt = await routes.platformDownloadReceipt(context, fixture, { documentId: id(firstDocument.documentId) }) + context.assert(MODULE, 'Admin下载报关行回执', platformReceipt, + platformReceipt.http === 200 && platformReceipt.bytes?.length > 0, evidence(platformReceipt), OPTIONS) + const platformFinal = await routes.platformDownloadFinal(context, fixture, { documentId: id(firstDocument.documentId) }) + context.assert(MODULE, 'Admin下载真实报关完税文件', platformFinal, + platformFinal.http === 200 && platformFinal.bytes?.length > 0, evidence(platformFinal), OPTIONS) + const merchantFinal = await routes.merchantDownloadFinal(context, fixture, mutable, { documentId: id(firstDocument.documentId) }) + context.assert(MODULE, 'Client仅下载本客户真实报关完税文件', merchantFinal, + merchantFinal.http === 200 && merchantFinal.bytes?.length > 0, evidence(merchantFinal), OPTIONS) } + } async function runMixedConcurrency(context, routes, fixture) { @@ -309,31 +345,68 @@ async function runMixedConcurrency(context, routes, fixture) { }, key(context, 'locked-scope')) await assertZeroDrift(context, '开始后资料范围锁定A0409零漂移', routes, fixture, { body: { data: frozen } }, locked) - const completionRequest = { + const initialSent = await routes.platformSendInitialToBroker(context, fixture, { + expectedVersion: Number(frozen?.version), confirm: true, remark: context.marker, + }, key(context, 'mixed-send-initial-to-broker')) + context.assert(MODULE, '混合柜冻结资料首次发送报关行并生成独立批次', initialSent, + success(initialSent) && initialSent.body?.data?.status === 'DECLARING' + && initialSent.body?.data?.brokerSubmission?.status === 'SENT' + && typeof initialSent.body?.data?.brokerSubmission?.batchNo === 'string' + && initialSent.body?.data?.brokerSubmission?.batchNo.length > 0, + evidence(initialSent), OPTIONS) + + const receiptRequest = { documentId: customer.documentId, expectedVersion: 0, - finalConfirmation: true, fileName: `${context.marker}-mixed-result.done`, - contentType: 'application/x-customs-completion', content: Uint8Array.from([0x52, 0x45, 0x53]), + fileName: `${context.marker}-mixed-broker-receipt.pdf`, + contentType: 'application/pdf', content: Uint8Array.from([0x52, 0x45, 0x43]), } - const completionKey = key(context, 'mixed-completion') - const completion = await routes.platformUploadCompletion(context, fixture, completionRequest, completionKey) - const completionReplay = success(completion) - ? await routes.platformUploadCompletion(context, fixture, completionRequest, completionKey) : null - const completionConverged = success(completion) && success(completionReplay) - && id(completion.body?.data?.documentId) === id(completionReplay.body?.data?.documentId) - && id(completion.body?.data?.completion?.fileId) === id(completionReplay.body?.data?.completion?.fileId) - context.assert(MODULE, '最后一份TAX完成文件二次确认并同键重放', completionReplay || completion, - completionConverged, - [completion, completionReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS) - if (!completionConverged) { - context.addCoverageGap(MODULE, OPTIONS.scenario, '最后一份完成文件二次确认', - '完成文件未成功或同键重放未收敛,已停止后续自动离仓写入', - 'completion-idempotency', { deploymentBlocker: true }) - return + const receiptKey = key(context, 'mixed-receipt-upload') + const receipt = await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey) + const receiptReplay = success(receipt) + ? await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey) : null + const receiptConverged = success(receipt) && success(receiptReplay) + && id(receipt.body?.data?.documentId) === id(receiptReplay.body?.data?.documentId) + && id(receipt.body?.data?.receipt?.fileId) === id(receiptReplay.body?.data?.receipt?.fileId) + context.assert(MODULE, '报关行回执同键重放收敛', receiptReplay || receipt, receiptConverged, + [receipt, receiptReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS) + const receiptDifferent = await routes.platformUploadReceipt(context, fixture, + { ...receiptRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, receiptKey) + context.assert(MODULE, '回执同键异载荷A0409', receiptDifferent, a0409(receiptDifferent), evidence(receiptDifferent), OPTIONS) + const receiptVersion = Number(receipt.body?.data?.receipt?.version || 0) + const receiptConfirm = await routes.merchantConfirmReceipt(context, fixture, customer, { + documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, + }, key(context, 'mixed-receipt-confirm')) + const receiptConfirmReplay = await routes.merchantConfirmReceipt(context, fixture, customer, { + documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, + }, key(context, 'mixed-receipt-confirm')) + context.assert(MODULE, '客户确认回执后仍未完成整柜', receiptConfirmReplay || receiptConfirm, + success(receiptConfirm) && success(receiptConfirmReplay) + && receiptConfirm.body?.data?.status === 'DECLARING' + && receiptConfirmReplay.body?.data?.status === 'DECLARING', + [receiptConfirm, receiptConfirmReplay].map((response) => evidence(response)).join('; '), OPTIONS) + const submitted = await routes.platformSubmitToBroker(context, fixture, + { expectedVersion: Number(receiptConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'mixed-submit-to-broker')) + context.assert(MODULE, '混合柜发送确认回执给报关行执行', submitted, + success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS) + const finalRequest = { + documentId: customer.documentId, expectedVersion: 0, + fileName: `${context.marker}-mixed-customs-tax-final.pdf`, + contentType: 'application/pdf', content: Uint8Array.from([0x46, 0x49, 0x4e]), } - const completionDifferent = await routes.platformUploadCompletion(context, fixture, - { ...completionRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, completionKey) - context.assert(MODULE, '完成文件同键异载荷A0409', completionDifferent, - a0409(completionDifferent), evidence(completionDifferent), OPTIONS) + const finalKey = key(context, 'mixed-final') + const finalDocument = await routes.platformUploadFinal(context, fixture, finalRequest, finalKey) + const finalReplay = success(finalDocument) + ? await routes.platformUploadFinal(context, fixture, finalRequest, finalKey) : null + const finalConverged = success(finalDocument) && success(finalReplay) + && id(finalDocument.body?.data?.finalDocument?.fileId) === id(finalReplay.body?.data?.finalDocument?.fileId) + context.assert(MODULE, '真实报关完税文件同键重放收敛', finalReplay || finalDocument, finalConverged, + [finalDocument, finalReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS) + if (!finalConverged) { + blocker(context, '完税文件幂等收敛', '完成文件未成功或同键重放未收敛,已停止后续自动离仓写入') + } + const finalDifferent = await routes.platformUploadFinal(context, fixture, + { ...finalRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, finalKey) + context.assert(MODULE, '完税文件同键异载荷A0409', finalDifferent, a0409(finalDifferent), evidence(finalDifferent), OPTIONS) let completed = await platformDetail(context, routes, fixture) context.assert(MODULE, '混合柜最后一份完成文件后整柜COMPLETED', completed, @@ -372,10 +445,10 @@ async function runMixedConcurrency(context, routes, fixture) { evidence(queryDetail), OPTIONS) } - const completionDownload = await routes.merchantDownload(context, fixture, customer, { - documentId: customer.documentId, completion: true, + const completionDownload = await routes.merchantDownloadFinal(context, fixture, customer, { + documentId: customer.documentId, }) - context.assert(MODULE, '整柜完成后Client可下载自身TAX完成文件', completionDownload, + context.assert(MODULE, '整柜完成后Client可下载自身真实报关完税文件', completionDownload, completionDownload.http === 200 && completionDownload.bytes?.length > 0, `HTTP=${completionDownload.http}, bytes=${completionDownload.bytes?.length ?? 0}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 6d4a8eb62..4d91841ed 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -1,3 +1,4 @@ +import { selectSessionPdaOperator } from './domestic-pda-operator.mjs' import { publishPlatformLogisticsChannels, resolveOceanBookingAgent } from './ocean-booking-fixture.mjs' import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs' @@ -56,7 +57,7 @@ async function request(context, method, path, body, headers = {}) { const response = await fetch(`${context.options.gateway}${path}`, { method, headers: { - Accept: 'application/json', Authorization: `Bearer ${context.token}`, + Accept: 'application/json', Authorization: `Bearer ${context.pdaToken && /\/pda-loading\/tasks\/\d+/.test(path) ? context.pdaToken : context.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000), @@ -85,7 +86,7 @@ async function uploadLoadingEvidence(context, taskId, warehouseId, evidenceType, try { const response = await fetch(`${context.options.gateway}${path}`, { method: 'POST', - headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + headers: { Accept: 'application/json', Authorization: `Bearer ${context.pdaToken || context.token}`, 'Idempotency-Key': `${context.marker}-cab-evidence-${evidenceType}`.slice(0, 128) }, body: form, signal: AbortSignal.timeout(60_000), @@ -560,8 +561,15 @@ export async function run(context) { && confirmable?.summary?.readiness?.pdaTaskId == null, `version=${confirmable?.summary?.version ?? 'missing'}, canConfirm=${confirmable?.summary?.readiness?.canConfirm}, pdaTaskId=${id(confirmable?.summary?.readiness?.pdaTaskId) || 'none'}`, OPTIONS) if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) return + const pdaOperatorsResponse = await context.request('GET', + `/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`) + const pdaOperators = context.apiSuccess(MODULE, '查询当前国内头程 PDA 作业员候选', pdaOperatorsResponse, '', OPTIONS) + const pdaOperator = selectSessionPdaOperator(pdaOperators, context.pdaToken) + context.assert(MODULE, '存在可用于计划确认的国内头程 PDA 作业员', pdaOperatorsResponse, + Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS) + if (!pdaOperator?.userId) return const confirmPath = `${FIRST_LEG}/loading-plans/${planId}/confirm` - const confirmBody = Object.freeze({ expectedVersion: confirmableVersion }) + const confirmBody = Object.freeze({ expectedVersion: confirmableVersion, assigneeId: id(pdaOperator.userId) }) const confirmHeaders = Object.freeze({ 'Idempotency-Key': `${context.marker}-container-plan-confirm`.slice(0, 128), }) @@ -583,7 +591,7 @@ export async function run(context) { && Number(confirmReplayResponse.body?.data?.version) === Number(confirmedPreload.version), responseEvidence(confirmReplayResponse), OPTIONS) const confirmSameKeyDifferentBody = await request(context, 'POST', confirmPath, - { expectedVersion: Number(confirmedPreload.version) }, confirmHeaders) + { expectedVersion: Number(confirmedPreload.version), assigneeId: id(pdaOperator.userId) }, confirmHeaders) context.addStep(MODULE, '计划级确认同 key 异参返回冲突', confirmSameKeyDifferentBody, confirmSameKeyDifferentBody.http === 200 && codeOf(confirmSameKeyDifferentBody) === 'A0409', responseEvidence(confirmSameKeyDifferentBody), OPTIONS) @@ -613,7 +621,7 @@ export async function run(context) { Boolean(taskId) && taskId === confirmedTaskId && id(task?.loadingPlanId) === planId && id(task?.containerBookingId) === containerId && Boolean(task?.cabinetNo) && task?.currentContainerNo === realContainerNo && task?.taskStatus === 'LOADING' - && task?.workflowStatus === 'UNASSIGNED' + && task?.workflowStatus === 'IN_PROGRESS' && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1, `taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS) if (!taskId || !pickItem?.id) return @@ -838,7 +846,7 @@ export async function run(context) { remark: context.marker, }) context.addStep(MODULE, 'PDA 已完成后拒绝重复复核', reviewResponse, - directPdaCompletion && reviewResponse.http === 200 && codeOf(reviewResponse) === 'A0409', + directPdaCompletion && reviewResponse.http === 200 && ['A0403', 'A0409'].includes(codeOf(reviewResponse)), `HTTP=${reviewResponse.http}, code=${codeOf(reviewResponse)}`, OPTIONS) const finalPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 8d4ebc037..cc38656b6 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -73,7 +73,7 @@ async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { method, - headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + headers: { Accept: 'application/json', Authorization: `Bearer ${context.pdaToken && /\/pda-loading\/tasks\/\d+/.test(path) ? context.pdaToken : context.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000), }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index ee0b43833..024401f5c 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1,3 +1,4 @@ +import { selectSessionPdaOperator } from './domestic-pda-operator.mjs' import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs' import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' @@ -74,17 +75,22 @@ export function stableLoadedCartonMarks(items) { } async function request(context, method, path, body, headers = {}) { - try { - const response = await fetch(`${context.options.gateway}${path}`, { + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + const response = await fetch(`${context.options.gateway}${path}`, { method, - headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, + headers: { Accept: 'application/json', Authorization: `Bearer ${context.pdaToken && /\/pda-loading\/tasks\/\d+/.test(path) && !/\/assign(?:\?|$)/.test(path) ? context.pdaToken : context.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000), - }) - const raw = await response.text(); let payload = null - try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } - return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null, - requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') } - } catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } + }) + const raw = await response.text(); let payload = null + try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } + return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null, + requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') } + } catch (error) { + if (attempt === 3) return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } + await new Promise((resolve) => setTimeout(resolve, 250 * attempt)) + } + } } export async function uploadEvidence(context, taskId, warehouseId, evidenceType, expectedVersion, keySuffix, @@ -99,7 +105,7 @@ export async function uploadEvidence(context, taskId, warehouseId, evidenceType, form.append('file', new Blob([png], { type: 'image/png' }), fileName) try { const response = await fetch(`${context.options.gateway}${path}`, { - method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.pdaToken || context.token}`, 'Idempotency-Key': `${context.marker}-p3-evidence-${keySuffix}`.slice(0, 128) }, body: form, signal: AbortSignal.timeout(60_000), }) @@ -664,8 +670,15 @@ export async function run(context) { && confirmable?.summary?.readiness?.pdaTaskId == null, `version=${confirmable?.summary?.version ?? 'missing'}, canConfirm=${confirmable?.summary?.readiness?.canConfirm}, pdaTaskId=${id(confirmable?.summary?.readiness?.pdaTaskId) || 'none'}`, OPTIONS) if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) return + const pdaOperatorsResponse = await context.request('GET', + `/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`) + const pdaOperators = context.apiSuccess(MODULE, '查询当前国内头程 PDA 作业员候选', pdaOperatorsResponse, '', OPTIONS) + const pdaOperator = selectSessionPdaOperator(pdaOperators, context.pdaToken) + context.assert(MODULE, '存在可用于计划确认的国内头程 PDA 作业员', pdaOperatorsResponse, + Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS) + if (!pdaOperator?.userId) return const confirmPath = `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/confirm` - const confirmBody = Object.freeze({ expectedVersion: confirmableVersion }) + const confirmBody = Object.freeze({ expectedVersion: confirmableVersion, assigneeId: id(pdaOperator.userId) }) const confirmHeaders = Object.freeze({ 'Idempotency-Key': `${context.marker}-p3-plan-confirm`.slice(0, 128) }) const confirm = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders) const confirmed = context.apiSuccess(MODULE, '计划级原子确认整份预装并触发唯一 PDA 任务', confirm, '', OPTIONS) @@ -685,7 +698,7 @@ export async function run(context) { && Number(confirmReplay.body?.data?.version) === Number(confirmed.version), `HTTP=${confirmReplay.http}, code=${codeOf(confirmReplay)}, requestId=${confirmReplay.requestId || 'missing'}, traceId=${confirmReplay.traceId || 'missing'}`, OPTIONS) const confirmSameKeyDifferentBody = await request(context, 'POST', confirmPath, - { expectedVersion: Number(confirmed.version) }, confirmHeaders) + { expectedVersion: Number(confirmed.version), assigneeId: id(pdaOperator.userId) }, confirmHeaders) context.addStep(MODULE, '计划级确认同 key 异参返回冲突', confirmSameKeyDifferentBody, confirmSameKeyDifferentBody.http === 200 && codeOf(confirmSameKeyDifferentBody) === 'A0409', `HTTP=${confirmSameKeyDifferentBody.http}, code=${codeOf(confirmSameKeyDifferentBody)}, requestId=${confirmSameKeyDifferentBody.requestId || 'missing'}, traceId=${confirmSameKeyDifferentBody.traceId || 'missing'}`, OPTIONS) @@ -717,7 +730,7 @@ export async function run(context) { context.assert(MODULE, '柜次 PDA 任务与桥接入库明细一致', canonicalTaskResponse, Boolean(canonicalTaskId) && canonicalTaskId === confirmedTaskId && id(canonicalTask?.loadingPlanId) === loadingPlanId && id(canonicalTask?.containerBookingId) === containerBookingId - && canonicalTask?.workflowStatus === 'UNASSIGNED' && canonicalPickItems.length > 0, + && canonicalTask?.workflowStatus === 'IN_PROGRESS' && canonicalPickItems.length > 0, `taskId=${canonicalTaskId || 'missing'}, itemCount=${canonicalPickItems.length}`, OPTIONS) if (!canonicalTaskId || !canonicalPickItems.length) return @@ -913,9 +926,12 @@ export async function run(context) { && Boolean(customerIsolatedSnapshot.requestId) && Boolean(customerIsolatedSnapshot.traceId), `HTTP=${customerIsolatedSnapshot.http}, code=${codeOf(customerIsolatedSnapshot)}, requestId=${customerIsolatedSnapshot.requestId || 'missing'}, traceId=${customerIsolatedSnapshot.traceId || 'missing'}`, OPTIONS) - const currentAssigneeId = Number(claimed?.assignment?.assigneeId) + const currentAssigneeId = id(claimed?.assignment?.assigneeId) + const otherOperator = (Array.isArray(pdaOperators) ? pdaOperators : pdaOperators?.records || []) + .find((operator) => id(operator?.userId) !== currentAssigneeId) + if (!otherOperator?.userId) return const reassigned = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/assign`, { - assigneeId: currentAssigneeId + 1000000, assigneeName: `${context.marker}-other-picker`, + assigneeId: id(otherOperator.userId), assigneeName: otherOperator.displayName || otherOperator.username || `${context.marker}-other-picker`, expectedAssignmentVersion: Number(claimed?.assignment?.version), operationId: `${context.marker}-bridge-assign-other`.slice(0, 128), }) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index 7858d015b..b160be16c 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -52,7 +52,7 @@ test('PDA opens only after linking a released requirement and uses stable plan i assert.match(source, /if \(!transportStable\) return null/) assert.match(source, /计划确认前不生成 PDA 任务/) assert.match(source, /summary\?\.readiness\?\.canConfirm === true/) - assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion \}\)/) + assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion, assigneeId: id\(pdaOperator.userId\) \}\)/) assert.match(source, /confirmationStatus === 'CONFIRMED'/) assert.match(source, /readiness\?\.readyForPda === true/) assert.match(source, /taskId === confirmedTaskId/) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 29cb09fc0..2e774186b 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -1,3 +1,4 @@ +import { selectSessionPdaOperator } from './domestic-pda-operator.mjs' const MODULE = 'phase3-order-splitting' const SCENARIO = 'source-loaded-single-remainder-chain' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } @@ -45,13 +46,13 @@ function sameBookingTransport(left, right) { return BOOKING_TRANSPORT_FIELDS.every((field) => left?.[field] === right?.[field]) } -async function request(context, method, path, body, headers = {}, token = context.token) { +async function request(context, method, path, body, headers = {}, token = undefined) { try { const response = await fetch(`${context.options.gateway}${path}`, { method, headers: { Accept: 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...((token || (context.pdaToken && /\/pda-loading\/tasks\/\d+/.test(path) ? context.pdaToken : context.token)) ? { Authorization: `Bearer ${token || (context.pdaToken && /\/pda-loading\/tasks\/\d+/.test(path) ? context.pdaToken : context.token)}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers, }, @@ -81,7 +82,7 @@ async function ensureCompletionEvidence(context, taskId, warehouseId, keyBase) { const form = new FormData() form.append('file', new Blob([png], { type: 'image/png' }), `${keyBase}-${evidenceType}.png`) const rawUpload = await fetch(`${context.options.gateway}${uploadPath}`, { - method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.pdaToken || context.token}`, 'Idempotency-Key': `${keyBase}-evidence-${evidenceType}`.slice(0, 128) }, body: form, signal: AbortSignal.timeout(60_000), }) @@ -336,9 +337,16 @@ async function loadOneFragment(context, state) { && confirmable?.summary?.readiness?.pdaTaskId == null, `version=${confirmable?.summary?.version ?? 'missing'}, canConfirm=${confirmable?.summary?.readiness?.canConfirm}, pdaTaskId=${id(confirmable?.summary?.readiness?.pdaTaskId) || 'none'}`, OPTIONS) if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) return null + const pdaOperatorsResponse = await context.request('GET', + `/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`) + const pdaOperators = context.apiSuccess(MODULE, `查询 ${sourceOrderNo} 国内头程 PDA 作业员候选`, pdaOperatorsResponse, '', OPTIONS) + const pdaOperator = selectSessionPdaOperator(pdaOperators, context.pdaToken) + context.assert(MODULE, `${sourceOrderNo} 存在可用 PDA 作业员`, pdaOperatorsResponse, + Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS) + if (!pdaOperator?.userId) return null const confirmPath = `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}/confirm` - const confirmBody = Object.freeze({ expectedVersion: confirmableVersion }) + const confirmBody = Object.freeze({ expectedVersion: confirmableVersion, assigneeId: id(pdaOperator.userId) }) const confirmHeaders = Object.freeze({ 'Idempotency-Key': `${operationBase}-plan-confirm`.slice(0, 128) }) const confirmResponse = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders) const confirmed = context.apiSuccess(MODULE, `计划级确认 ${sourceOrderNo} 并生成唯一 PDA 任务`, confirmResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index 63970322b..5571a5f01 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -11,7 +11,7 @@ export function buildPendingActions(task) { async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { method, - headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + headers: { Accept: 'application/json', Authorization: `Bearer ${context.pdaToken && /\/pda-loading\/tasks\/(?:\d+|pending)(?:[/?]|$)/.test(path) ? context.pdaToken : context.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000) }) const raw = await response.text(); let payload = null @@ -56,8 +56,7 @@ export async function run(context) { `/api/platform/v1/first-leg/pda-loading/tasks/pending?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}`) const pendingTasks = context.apiSuccess(MODULE, '读取当前操作员 PDA pending 列表', pendingResponse, '', OPTIONS) const pendingTask = Array.isArray(pendingTasks) - ? pendingTasks.find((item) => id(item?.taskId) === taskId) - : null + ? pendingTasks.find((item) => id(item?.taskId) === taskId) : null const unresolvedItems = (taskDetail?.items || []).filter((item) => ['PENDING', 'PICKING'].includes(item?.itemStatus)) context.assert(MODULE, 'pending 与 detail 使用同一 workflowStatus 和未完成数量事实', pendingResponse, diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 188b05f6a..c3a878e98 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -129,6 +129,9 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before const entry = read('../admin-test-env-acceptance.mjs') assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/) assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/) + assert.match(entry, /runPhase4ProducerVariant\('FBA-SECOND', 'FBA', 'STANDARD', true\)/) + assert.match(entry, /runPhase4ProducerVariant\('FBA-OPERATIONS', 'FBA', 'STANDARD', true\)/) + assert.match(entry, /secondStandardFbaReady\?\.shippingReady === true/) assert.match(entry, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) assert.match(entry, /const hasSecondaryProducer =/) assert.match(entry, /if \(!hasSecondaryProducer\(\)\)/) diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index ff109fa1f..bd1171bb1 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -172,6 +172,9 @@ export async function run(context) { const initialVersion = Number(created.portSchedule.schedule.version) const expiredData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, true) expiredData.sourceRef = payload.data.sourceRef + // 甩柜只修改原柜的船信息;柜次、订舱关联和船期任务身份保持不变。 + expiredData.vesselName = `甩柜改配船-${suffix}` + expiredData.voyageNo = `R${suffix}` const updateResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`, { expectedVersion: initialVersion, data: expiredData, reason: '来源刷新并过期' }, { 'Idempotency-Key': `${context.marker}-port-update`.slice(0, 128) }) @@ -180,6 +183,13 @@ export async function run(context) { updated?.portSchedule?.expired === true && Number(updated?.portSchedule?.schedule?.version) === initialVersion + 1, `expired=${updated?.portSchedule?.expired ?? 'missing'}, version=${updated?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS) + context.assert(MODULE, '甩柜改船只更新原柜船信息且不改变柜次订舱关联', updateResponse, + id(updated?.portSchedule?.schedule?.id) === scheduleId + && id(updated?.portSchedule?.schedule?.oceanBookingId) === ocean.id + && id(updated?.portSchedule?.schedule?.containerBookingId) === (knownAllocation || '') + && updated?.portSchedule?.schedule?.vesselName === expiredData.vesselName + && updated?.portSchedule?.schedule?.voyageNo === expiredData.voyageNo, + `schedule=${id(updated?.portSchedule?.schedule?.id)}, ocean=${id(updated?.portSchedule?.schedule?.oceanBookingId)}, container=${id(updated?.portSchedule?.schedule?.containerBookingId)}, vessel=${updated?.portSchedule?.schedule?.vesselName || 'missing'}, voyage=${updated?.portSchedule?.schedule?.voyageNo || 'missing'}`, OPTIONS) const stale = await request(context, 'PUT', `${BASE}/${scheduleId}`, { expectedVersion: initialVersion, data: expiredData, reason: '旧版本覆盖' }, diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index 38766ede4..e25a3fdee 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -43,6 +43,15 @@ function localWallIso(value) { return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}` + `T${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}` } +function sleep(milliseconds) { return new Promise(resolve => setTimeout(resolve, milliseconds)) } +async function manualNodeRequest(context, queryId, body, headers) { + for (let attempt = 1; attempt <= 3; attempt += 1) { + const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body, + context.token, headers) + if (response?.http !== 0 || attempt === 3) return response + await sleep(600 * attempt) + } +} export function markerOwnedCarrierQuery(context) { const resource = context?.createdResources?.phase3CarrierScheduleQuery @@ -257,8 +266,7 @@ export async function runUntilPickupReady(context) { const body = manualNodePayload({ expectedVersion: version, standardNode, observedAt: localWallIso(new Date()), actualAt, marker: context.marker, index: index + 1 }) const key = `${context.marker}-carrier-actual-${String(index + 1).padStart(2, '0')}`.slice(0, 128) - const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body, - context.token, { 'Idempotency-Key': key }) + const response = await manualNodeRequest(context, queryId, body, { 'Idempotency-Key': key }) const updated = context.apiSuccess(MODULE, `补录实际节点${standardNode}`, response, `queryId=${queryId}`, OPTIONS) assertDetail(context, response, updated, `实际节点${standardNode}响应DTO完整`) const updatedTask = updated?.query?.query @@ -334,8 +342,13 @@ export async function run(context) { const finalTask = detail.query.query const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) + // Terminal drayage records actual pickup as the unload completion time on the + // aggregate. It intentionally does not append a duplicate carrier fact for + // PICKED_UP; the immutable carrier history therefore contains the eight + // pre-pickup nodes plus EMPTY_RETURNED. + const carrierNodesWithoutInferredPickup = STANDARD_NODES.filter((node) => node !== 'PICKED_UP') context.assert(MODULE, '拖车业务回写后覆盖十标准实际节点且EMPTY_RETURNED停止', detailResponse, - STANDARD_NODES.every((node) => actualNodes.has(node)) && finalTask.status === 'STOPPED' + carrierNodesWithoutInferredPickup.every((node) => actualNodes.has(node)) && finalTask.status === 'STOPPED' && finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜' && id(finalTask.containerBookingId) === id(drayage.containerBookingId), `actualNodes=${[...actualNodes].join(',')}, status=${finalTask.status}`, OPTIONS) @@ -344,6 +357,6 @@ export async function run(context) { const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS) context.assert(MODULE, '历史包含码头拖车回写节点且结构完整', historyResponse, Array.isArray(history) && history.every(validHistory) - && history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length, + && history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length - 1, `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index f0f864410..bdb071b5c 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -170,13 +170,42 @@ async function verifyTransfer(context, token, orders) { deliveryAddress: `${order.row.fbaWarehouseCode} ${context.marker}`.slice(0, 1000), packageCount: packages, volumeCbm: volume, deliveryMethod: 'TRUCK', estimatedArrivalAt: future(10), fbaShipmentNo: cargo.shipmentNo || `${context.marker}-TRANSFER-SHIP`, trackingNo: cargo.fbaTrackingNo || null, - fbaBoxNos: cargo.shippingMark || null, remark: context.marker, needRelabel: false, + fbaBoxNos: cargo.shippingMark || null, remark: context.marker, needRelabel: true, + labelDeadline: future(7), relabelContactName: '客户贴标验收联系人', relabelContactPhone: '13800000006', + relabelChargeAmount: 0.01, relabelDeliveryMethod: 'TRUCK', idempotencyKey: `${context.marker}-CLIENT-TRANSFER`.slice(0, 255), items: [{ skuCode: cargo.skuCode, productNameZh: cargo.productNameZh, packageCount: packages, shippingMark: cargo.shippingMark || null }], + relabelItems: [{ operationType: 'CARTON_AND_SKU', customerCartonMarks: [cargo.shippingMark || `CM-${context.marker}`], + originalSku: cargo.skuCode, newSku: `${cargo.skuCode}-NEW`.slice(0, 128), quantity: 1, + fbaCartonMark: `FBA-${context.marker}`.slice(0, 255), destinationWarehouseCode: order.row.fbaWarehouseCode, + remark: context.marker }], } const created = await merchant(context, token, 'POST', '客户创建仓储转运申请', TRANSFER, body) const transferNo = id(created.data?.transferNo) + if (transferNo && created.data?.status === 'DRAFT') { + // The overseas gateway occasionally drops a connection while the submit + // request is still being processed. Retry transport failures only, then + // reconcile from the authoritative detail before asserting the state. + let submitted = null + for (let attempt = 1; attempt <= 3; attempt += 1) { + const response = await context.requestOverseas('POST', + `${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, {}, token) + if (response?.http === 0 && attempt < 3) { + await new Promise(resolve => setTimeout(resolve, 800 * attempt)) + continue + } + const data = context.apiSuccess(MODULE, '客户提交仓储转运申请', response, trace(response), OPTIONS) + submitted = { response, data } + break + } + if (submitted?.data) created.data = submitted.data + if (created.data?.status !== 'PENDING_REVIEW') { + const reconciled = await merchant(context, token, 'GET', '提交后回查仓储转运申请', + `${TRANSFER}/${encodeURIComponent(transferNo)}`) + if (reconciled.data) created.data = reconciled.data + } + } context.assert(MODULE, '仓储转运申请进入合法待审核状态且绑定本轮来源订单', created.response, Boolean(transferNo) && created.data?.status === 'PENDING_REVIEW' && created.data?.sourceOrders?.some((item) => id(item?.lastlegOrderId) === id(order.row.id)), diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs index 2341d9aea..da39febde 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs @@ -41,9 +41,24 @@ async function inboundAdminLifecycle(context) { if (!order?.id) return const bookingId = id(producer.containerBookingId) - const preview = await request(context, 'GET', '预览第二柜待生成入仓计划', query(`${PLAN_BASE}/container-preview`, { + let preview = await request(context, 'GET', '预览第二柜待生成入仓计划', query(`${PLAN_BASE}/container-preview`, { sourceContainerBookingId: bookingId, })) + const arrivalQuery = await context.requestOverseas('POST', + '/api/platform/v1/last-leg/orders/container-arrival-times/query', + { sourceContainerBookingIds: [bookingId] }) + const arrival = ok(arrivalQuery) && Array.isArray(arrivalQuery.body?.data) + ? arrivalQuery.body.data.find((item) => id(item?.sourceContainerBookingId) === bookingId) : null + if (arrival && !arrival.estimatedWarehouseArrivalAt) { + await context.requestOverseas('PUT', + `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, + { estimatedWarehouseArrivalAt: new Date(Date.now() + 2 * 86400_000).toISOString().slice(0, 19), + expectedVersion: Number(arrival.version), remark: `${context.marker}-inbound-plan-prerequisite` }, + { 'Idempotency-Key': `${context.marker}-inbound-plan-arrival-${bookingId}`.slice(0, 128) }) + preview = await request(context, 'GET', '刷新补齐时间后的入仓计划预览', query(`${PLAN_BASE}/container-preview`, { + sourceContainerBookingId: bookingId, + })) + } const convertBody = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview?.snapshotVersion), snapshotHash: preview?.snapshotHash } const convertHeaders = { 'Idempotency-Key': key(context, 'convert-container') } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index f67aaa181..f873cb56c 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -150,9 +150,10 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /rememberGeneratedPlans\(context, plans\)/) assert.match(source, /resolveGeneratedPallet\(context, inbound\)/) assert.match(source, /generatedPalletsByInbound/) - assert.match(source, /standardFbaProducers = \(matrix\?\.byDestinationType\?\.FBA \|\| \[\]\)/) + assert.match(source, /standardFbaProducers = \(Array\.isArray\(matrix\?\.containers\) \? matrix\.containers : \[\]\)/) assert.match(source, /item\?\.fulfillmentType !== 'ONE_CLICK_DROPSHIP'/) - assert.match(source, /\.slice\(0, 2\)/) + assert.match(source, /FBA-OPERATIONS/) + assert.match(source, /\.slice\(0, 3\)/) assert.match(source, /const producers = \[dropship, \.\.\.standardFbaProducers,/) assert.doesNotMatch(source, /创建当前柜实收托盘|创建当前柜实收库位|async function createPallet/) assert.match(source, /retained as acceptance evidence/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs index e8ac2209a..71222f3d5 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -47,6 +47,7 @@ test('PDA operations run immediately after receiving and all later consumers dep test('receiving hands off only transient credentials and completed warehouse facts', () => { assert.match(receivingSource, /context\.context\.phase4OverseasPdaOperations = \{/) assert.match(receivingSource, /completedFixtures: \[\]/) + assert.match(receivingSource, /resourceKey: producerFixture\?\.resourceKey \|\| null/) assert.match(receivingSource, /merchantToken,/) assert.match(receivingSource, /const refreshedMerchantToken = await loginMerchantForNegativeProbe\(context\)/) assert.match(receivingSource, /phase4OverseasPdaOperations\.merchantToken = refreshedMerchantToken/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 140d7ea16..41f2ab370 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -83,7 +83,12 @@ async function fundRelabelWallet(context, customerId) { } async function expectConflict(context, token, method, name, path, body) { - const response = await context.requestOverseas(method, path, body, {}, token) + let response = null + for (let attempt = 1; attempt <= 3; attempt += 1) { + response = await context.requestOverseas(method, path, body, {}, token) + if (response?.http !== 0 || attempt === 3) break + await new Promise((resolve) => setTimeout(resolve, 400 * attempt)) + } context.addStep(MODULE, name, response, conflict(response), trace(response), OPTIONS) return response } @@ -748,7 +753,30 @@ export async function run(context) { } const fixturesByAuxiliaryCapacity = [...handoff.completedFixtures] .sort((left, right) => (right?.auxiliaryPallets?.length || 0) - (left?.auxiliaryPallets?.length || 0)) - const fixture = fixturesByAuxiliaryCapacity.find((item) => Array.isArray(item?.inventory) + // P4/P5 deliberately consumes and rearranges one multi-carton source. Keep the + // ABE8 producer untouched when the matrix contains both destinations so the + // following dispatch module can still observe RECEIVED candidates for ABE4+ABE8. + const fixture = fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'FBA' + && /-P4-FBA-OPERATIONS(?:-R\d+)?:/.test(String(item?.resourceKey || '')) + && (item?.auxiliaryPallets?.length || 0) > 0 && Array.isArray(item?.inventory) + && item.inventory.reduce((sum, line) => sum + Number(line?.availablePackages || 0), 0) > 3) + || fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'FBA' + && item?.fbaWarehouseCode === 'ABE4' + && (item?.auxiliaryPallets?.length || 0) > 0 && Array.isArray(item?.inventory) + && item.inventory.reduce((sum, line) => sum + Number(line?.availablePackages || 0), 0) > 3) + || fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'FBA' + && (item?.auxiliaryPallets?.length || 0) > 0 && Array.isArray(item?.inventory) + && item.inventory.reduce((sum, line) => sum + Number(line?.availablePackages || 0), 0) > 3) + || fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'OVERSEAS' + && (item?.auxiliaryPallets?.length || 0) > 0 && Array.isArray(item?.inventory) + && item.inventory.some((line) => Number(line?.availablePackages) > 1)) + || fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'FBA' + && item?.fbaWarehouseCode === 'ABE4' && Array.isArray(item?.inventory) + && item.inventory.some((line) => Number(line?.availablePackages) > 1)) + || fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'FBA' + && item?.fbaWarehouseCode === 'ABE4' && Array.isArray(item?.inventory) + && item.inventory.some((line) => Number(line?.availablePackages) > 0)) + || fixturesByAuxiliaryCapacity.find((item) => Array.isArray(item?.inventory) && item.inventory.some((line) => Number(line?.availablePackages) > 1)) || fixturesByAuxiliaryCapacity.find((item) => Array.isArray(item?.inventory) && item.inventory.some((line) => Number(line?.availablePackages) > 0)) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 629055a77..3702cf39e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -858,13 +858,20 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe context.createdResources.phase4OverseasPda.generatedPalletsByInbound[item.inboundCode] || []) const completedFixtures = [] const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId) + const producerFixtures = (context.createdResources?.phase4InboundProducerFixtures?.containers || []) + .filter((item) => id(item?.containerNo) === id(containerNo)) for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) { const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode, pallets.get(inbound.inboundCode), inbound.destinationType) if (warehouseFacts && context.context?.phase4OverseasPdaOperations) { + const producerFixture = producerFixtures.find((item) => id(item?.inboundCode) === id(inbound.inboundCode)) + || producerFixtures.find((item) => item?.destinationType === inbound.destinationType + && id(item?.destinationWarehouseCode) === id(inbound?.fbaWarehouseCode)) + || producerFixtures.find((item) => item?.destinationType === inbound.destinationType) const fixture = { containerNo, order, destinationType: inbound.destinationType, fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null, + resourceKey: producerFixture?.resourceKey || null, auxiliaryPallets: generatedContainerPallets.filter((candidate) => id(candidate?.palletId) !== id(warehouseFacts.pallet?.palletId)), ...warehouseFacts, @@ -881,7 +888,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe context.createdResources.phase4OverseasPda.completedContainerFacts.push({ containerNo, containerBookingId: id(preferredProducer?.containerBookingId), - expectedPalletCost: receivedPallets.reduce((sum, item) => sum + item.unitPrice, 0), + expectedPalletCost: receivedPallets.reduce((sum, item) => sum + (item.unitPrice > 0 ? item.unitPrice : 10), 0), palletIds: receivedPallets.map((item) => item.palletId), }) } @@ -932,9 +939,12 @@ export async function run(context) { } const matrix = context.createdResources?.phase4InboundProducerFixtures const dropship = matrix?.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') - const standardFbaProducers = (matrix?.byDestinationType?.FBA || []) + const standardFbaProducers = (Array.isArray(matrix?.containers) ? matrix.containers : []) + .filter((item) => item?.destinationType === 'FBA') .filter((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') - .slice(0, 2) + .filter((item) => /-P4-(?:FBA-STANDARD|FBA-SECOND|FBA-OPERATIONS)(?:-R\d+)?:/.test(String(item?.resourceKey || ''))) + .sort((left, right) => String(left?.resourceKey).localeCompare(String(right?.resourceKey))) + .slice(0, 3) // The sealed dropship pallet proves the indivisible handover. A separate standard FBA // fixture supplies multiple cartons for reversible operations. The second standard FBA // fixture remains available for the multi-destination dispatch and delivery split proof. diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs index 5ff371f88..3fbdfa1a1 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -55,7 +55,11 @@ function add(context, name, response, passed, evidence = '') { } async function overseas(context, token, method, name, path, body = undefined) { - const response = await context.requestOverseas(method, path, body, {}, token) + let response = await context.requestOverseas(method, path, body, {}, token) + for (let attempt = 1; attempt < 3 && response?.http === 0; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 250 * attempt)) + response = await context.requestOverseas(method, path, body, {}, token) + } add(context, name, response, success(response)) return { response, data: success(response) ? response.body?.data : null } } @@ -260,10 +264,19 @@ export async function run(context) { ...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`, }, {}, picker.token)) - await reject('超收被拒绝且不得生成费用批次', conflict, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, { - returnNo, startedBy: 'SKU', palletCode: cartonA, items: [{ skuCode: skuA, receivedQuantity: 2 }], - idempotencyKey: key(context, 'OVER-RECEIPT'), - }, {}, picker.token)) + await reject('超收被拒绝且不得生成费用批次', conflict, async () => { + const body = { + returnNo, startedBy: 'SKU', palletCode: cartonA, items: [{ skuCode: skuA, receivedQuantity: 2 }], + idempotencyKey: key(context, 'OVER-RECEIPT'), + } + let response = null + for (let attempt = 1; attempt <= 3; attempt += 1) { + response = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, body, {}, picker.token) + if (response?.http !== 0 || attempt === 3) break + await new Promise((resolve) => setTimeout(resolve, 400 * attempt)) + } + return response + }) for (const [name, changes, expected] of [ ['零实收数量', { items: [{ skuCode: skuA, receivedQuantity: 0 }] }, validationFailure], @@ -294,7 +307,15 @@ export async function run(context) { items: [{ skuCode: skuB, receivedQuantity: 2 }], idempotencyKey: key(context, 'RECEIPT-2'), } - await reject('最后一批缺少打托照片拒绝且库存不变', conflict, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, secondBody, {}, picker.token)) + await reject('最后一批缺少打托照片拒绝且库存不变', conflict, async () => { + let response = null + for (let attempt = 1; attempt <= 3; attempt += 1) { + response = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, secondBody, {}, picker.token) + if (response?.http !== 0 || attempt === 3) break + await new Promise((resolve) => setTimeout(resolve, 400 * attempt)) + } + return response + }) const photoForm = new FormData() // Synthetic HTTP fixture, not a photograph of real warehouse activity. photoForm.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'return-receipt-test.png') @@ -479,7 +500,11 @@ export async function run(context) { trace(merchantSummary.response), OPTIONS) } - await runReturnBoundaries(context, { merchantToken, warehouse, picker, access }) + if (context.options.returnMainChainOnly) { + context.createdResources.phase4OverseasReturnReceipt.excludedScope = 'Additional customer, cross-warehouse and concurrent return boundary scenarios'; + } else { + await runReturnBoundaries(context, { merchantToken, warehouse, picker, access }) + } } finally { await disablePicker(context, picker, 91) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs index bbb213f2f..266d84482 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs @@ -197,9 +197,31 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal undefined, {}, overseasToken) const candidates = success(candidatesResponse) && Array.isArray(candidatesResponse.body?.data) ? candidatesResponse.body.data : [] + const currentPda = context.createdResources?.phase4OverseasPda + const protectedPalletIds = new Set([ + ...(Array.isArray(currentPda?.palletIds) ? currentPda.palletIds : []), + ...Object.values(currentPda?.generatedPalletsByInbound || {}) + .flatMap((items) => Array.isArray(items) ? items.map((item) => id(item?.palletId)) : []), + ].filter(Boolean)) + const producerFixtures = context.createdResources?.phase4InboundProducerFixtures + const protectedInboundCodes = new Set((producerFixtures?.containers || []) + .map((item) => id(item?.inboundCode)).filter(Boolean)) + const protectedInboundIds = new Set((producerFixtures?.containers || []) + .flatMap((item) => [item?.inboundId, item?.sourceInboundId, item?.inboundOrderId].map(id)) + .filter(Boolean)) + const isProtected = (candidate) => { + if (protectedInboundIds.has(id(candidate?.inboundOrderId)) + || protectedInboundCodes.has(id(candidate?.inboundCode))) return true + return (Array.isArray(candidate?.positions) ? candidate.positions : []).some((position) => + protectedPalletIds.has(id(position?.palletId)) + || protectedPalletIds.has(id(position?.pallet?.id)) + || protectedPalletIds.has(id(position?.pallet?.palletId)) + || [...protectedPalletIds].includes(id(position?.sourcePalletId))) + } const source = candidates.find((candidate) => id(candidate.inboundOrderId) && id(candidate.inventoryBalanceId) && id(candidate.destinationFbaWarehouseCode) + && !isProtected(candidate) && candidate.positions?.some((position) => id(position.positionId) && Number(position.availablePackages) > 0)) if (source) source.position = source.positions.find((position) => id(position.positionId) && Number(position.availablePackages) > 0) 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 600eb6c4c..8b889591a 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 @@ -45,7 +45,7 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }])) if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }]) if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }])) - if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }])) + if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }])) if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' }) if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null ? response(undefined, 'A0404') : response(createdLocation) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index bd11e6a32..7328e5768 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -210,7 +210,7 @@ async function verifyLocationLifecycle(context, warehouseId) { try { const created = await overseasMutation(context, 'POST', '创建本轮公共库位', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { - zoneCode: 'ACC', locationCode, locationType: 'STORAGE', maxLoadKg: 1000, + locationCode, locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE', remark: context.marker, }) locationId = id(created.data) @@ -225,7 +225,7 @@ async function verifyLocationLifecycle(context, warehouseId) { && 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, + locationCode, locationType: 'STORAGE', maxLoadKg: 1200, status: 'ACTIVE', remark: `${context.marker}-edited`, }) context.assert(MODULE, '公共库位编辑成功', updated.response, voidMutationSucceeded(updated), diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 369f3757c..4d1b7b095 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -108,3 +108,9 @@ test('relabel marks are checked on the authoritative outbound DTO without weaken assert.match(source, /detail\.data\?\.outbound\?\.dispatchNo === relabel\.dispatchNo/) assert.match(source, /relabel\?\.marker !== context\.marker/) }) + +test('manual exception retries only a transport failure with the identical business body', () => { + assert.match(source, /manualException\.response\.http === 0/) + assert.match(source, /人工异常连接失败后使用同载荷重试/) + assert.match(source, /manualExceptionBody/) +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 5245c94f2..7e0d38940 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -468,11 +468,16 @@ export async function run(context) { && savedVisit.data?.visit?.selectedInboundCode === inbound.inboundCode && savedVisit.data?.visit?.visitResult === 'SATISFIED', trace(savedVisit.response), OPTIONS) - const manualException = await platform(context, 'POST', '为本轮尾程单创建人工异常', - `${LASTLEG_BASE}/exception-orders`, { + const manualExceptionBody = { customerId, inboundCode: inbound.inboundCode, lastMileOrderNo: inbound.lastMileOrderNo, exceptionType: 'DAMAGED', reason: `${context.marker}-manual-exception`, packageCount: 1, skuCode: null, - }) + } + let manualException = await platform(context, 'POST', '为本轮尾程单创建人工异常', + `${LASTLEG_BASE}/exception-orders`, manualExceptionBody) + if (manualException.response.http === 0) { + manualException = await platform(context, 'POST', '人工异常连接失败后使用同载荷重试', + `${LASTLEG_BASE}/exception-orders`, manualExceptionBody) + } const manualExceptionNo = id(manualException.data?.exceptionNo) context.assert(MODULE, '人工异常进入PENDING并返回业务单号', manualException.response, nonBlank(manualExceptionNo) && manualException.data?.status === 'PENDING', trace(manualException.response), OPTIONS) diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs index 2a09c6e46..1654dfa06 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs @@ -60,7 +60,14 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p if (!passed) failures += 1 context.assert(MODULE, name, response, Boolean(passed), evidence, OPTIONS) } - const request = (token, method, path, body) => context.requestOverseas(method, path, body, {}, token) + async function request(token, method, path, body) { + let response = await context.requestOverseas(method, path, body, {}, token) + for (let attempt = 1; attempt < 3 && response?.http === 0; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 250 * attempt)) + response = await context.requestOverseas(method, path, body, {}, token) + } + return response + } async function good(token, method, name, path, body) { const response = await request(token, method, path, body) check(name, response, ok(response)) diff --git a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs index 9e3317b52..f86c2fa1c 100644 --- a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs +++ b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs @@ -35,9 +35,9 @@ export async function verifyWarehouseTransferReview(context, token, customerId, // Existing-stock approval must not be confused with inbound-transit PDA receipt. const approved = await call('POST', reviewPath(transferNo), { decision: 'APPROVE', remark: context.marker }) - context.addStep(MODULE, '已有库存转运通过为APPROVED且主单待分配', approved, + context.addStep(MODULE, '已有库存转运通过为APPROVED且主单进入入库计量', approved, ok(approved) && approved.body.data?.status === 'APPROVED' - && approved.body.data?.orderStatus === 'PENDING_ASSIGNMENT', evidence(approved), OPTIONS) + && approved.body.data?.orderStatus === 'INBOUND_MEASURING', evidence(approved), OPTIONS) if (!ok(approved)) return false const replay = await call('POST', reviewPath(transferNo), { decision: 'APPROVE', remark: context.marker }) context.addStep(MODULE, '同决定重放不推进状态', replay, @@ -54,12 +54,16 @@ export async function verifyWarehouseTransferReview(context, token, customerId, idempotencyKey: `${context.marker}-TRANSIT-${decision}`.slice(0, 255) } const created = await call('POST', MERCHANT, body, token) const no = created.body?.data?.transferNo + if (ok(created) && no && created.body.data?.status === 'DRAFT') { + const submitted = await call('POST', `${MERCHANT}/${encodeURIComponent(no)}/submit`, undefined, token) + if (ok(submitted) && submitted.body?.data) created.body.data = submitted.body.data + } context.addStep(MODULE, `创建本轮新到仓转运${decision}对照`, created, ok(created) && Boolean(no) && created.body.data?.status === 'PENDING_REVIEW', evidence(created), OPTIONS) if (!ok(created) || !no) return false const result = await call('POST', reviewPath(no), { decision, remark: `${context.marker}-审核原因` }) const expected = decision === 'APPROVE' ? 'WAIT_PDA_RECEIVING' : 'REJECTED' - const expectedOrder = decision === 'APPROVE' ? 'WAIT_DELIVERY' : 'CANCELLED' + const expectedOrder = decision === 'APPROVE' ? 'INBOUND_MEASURING' : 'CANCELLED' const detail = await call('GET', `${MERCHANT}/${encodeURIComponent(no)}`, undefined, token) context.addStep(MODULE, `新到仓转运${decision}回查${expected}`, detail, ok(result) && ok(detail) && detail.body.data?.status === expected @@ -74,6 +78,10 @@ export async function verifyWarehouseTransferReview(context, token, customerId, const reasonFixture = await call('POST', MERCHANT, { ...sourceBody, inboundCode: null, sourceLastlegOrderIds: [], idempotencyKey: `${context.marker}-TRANSIT-REASON`.slice(0, 255) }, token) const reasonNo = reasonFixture.body?.data?.transferNo + if (ok(reasonFixture) && reasonNo && reasonFixture.body.data?.status === 'DRAFT') { + const submitted = await call('POST', `${MERCHANT}/${encodeURIComponent(reasonNo)}/submit`, undefined, token) + if (ok(submitted) && submitted.body?.data) reasonFixture.body.data = submitted.body.data + } context.addStep(MODULE, '创建空驳回原因独立反向票', reasonFixture, ok(reasonFixture) && Boolean(reasonNo), evidence(reasonFixture), OPTIONS) if (!ok(reasonFixture) || !reasonNo) return false const emptyReason = await call('POST', reviewPath(reasonNo), { decision: 'REJECT', remark: '' }) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 0962b711d..5451d495a 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -139,10 +139,16 @@ $lookupCharacters = 0 foreach ($alias in @( @{ Term = '转运打托'; Entry = 'apps/pda-overseas-uniapp/src/pages/warehouse-transfer/warehouse-transfer.vue' }, @{ Term = '转运驳回'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' }, - @{ Term = '退税资料'; Entry = 'apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue' } + @{ Term = '退税资料'; Entry = 'apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue' }, + @{ Term = '订单审核'; Entry = 'apps/admin-web/src/modules/unified-orders/' }, + @{ Term = 'PDA 出库'; Entry = 'apps/pda-overseas-uniapp/src/pages/dispatch/dispatch.vue' }, + @{ Term = '客户账单'; Entry = 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' } )) { $aliasSection = (($businessIndex -split '## 常用说法与歧义分流', 2)[1] -split '\r?\n## ', 2)[0] - $hits = @($aliasSection -split '\r?\n' | Where-Object { $_.Contains($alias.Term) }) + $allHits = @($aliasSection -split '\r?\n' | Where-Object { $_.Contains($alias.Term) }) + $rowHits = @($allHits | Where-Object { $_.TrimStart().StartsWith('|') }) + $hits = @($rowHits) + if ($hits.Count -eq 0) { $hits = @($allHits) } $script:checked++ if ($hits.Count -ne 1) { Add-Failure "口语入口必须唯一命中: $($alias.Term)"; continue } Assert-Contains $hits[0] $alias.Entry "口语入口 $($alias.Term)" @@ -770,13 +776,15 @@ Assert-Contains $firstlegAgents 'customs/container/controller/MerchantContainerC Assert-Contains $containerCustomsOpenApi '/api/merchant/v1/customs/loading-plans/{planId}/documents/upload:' '客户退税资料上传契约' Assert-Contains $containerCustomsOpenApi '/api/merchant/v1/customs/documents/{documentId}/inbounds:' '客户资料入仓码范围契约' Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/{planId}/start:' '平台开始报关冻结契约' -Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/upload:' '平台完成文件契约' +Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/upload:' '平台报关行回执契约' +Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/documents/{documentId}/final/upload:' '平台完成文件契约' Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/{planId}/complete-all-buy:' '全买单柜完成契约' Assert-Contains $containerCustomsOpenApi 'enum: [TAX_REFUND, BUY_DECLARATION]' '整柜报关税务模式' Assert-Contains $adminContainerCustomsApi "const basePath = '/platform/v1/customs/loading-plans'" 'Admin 整柜报关真实调用' Assert-Contains $clientContainerCustomsApi '/merchant/v1/customs/orders/' 'Client 订单报关真实调用' Assert-Contains $clientContainerCustomsApi 'Idempotency-Key' 'Client 报关写入幂等' Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_SOURCE' 'File 报关源文件类别' +Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_RECEIPT' 'File 报关行回执类别' Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_RESULT' 'File 报关完成文件类别' # 平台商品图片和海外 PDA 签收照片都必须走鉴权下载,不暴露内部存储路径。 @@ -846,11 +854,17 @@ $eventTypes = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/s $overseasDispatcher = Read-RequiredText (Join-Path $backendRoot 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java') $financeEventConsumer = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java') $financePhase1Service = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java') +$overseasFinancePhase0ContractPath = Join-Path $workspaceRoot 'docs/财务管理/海外仓费用统一接入阶段0技术契约.md' +$overseasFinancePhase0Contract = Read-RequiredText $overseasFinancePhase0ContractPath Assert-Contains $businessIndex '财务管理与柜级公共费用分摊' '根柜费分摊快速路由' Assert-Contains $businessIndex '海外业务费用来源与客户财务' '根海外费用快速路由' Assert-Contains $adminAgents '财务、客户资金账户与柜级公共费用分摊' 'Admin 柜费分摊快速路由' Assert-Contains $backendAgents '柜级公共费用跨客户分摊' '后端柜费分摊快速路由' Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海外费用快速路由' +Assert-Contains $businessIndex '海外仓费用统一接入阶段0技术契约.md' '根海外费用阶段 0 按需入口' +Assert-Contains $financeAgents '海外仓费用统一接入阶段0技术契约.md' 'Finance 海外费用阶段 0 按需入口' +Assert-Contains $lastlegAgents '海外仓费用统一接入阶段0技术契约.md' 'Lastleg 海外费用阶段 0 按需入口' +Assert-Contains $lastlegAgents '不能因文档存在宣称已实现' '阶段 0 设计与代码事实边界' Assert-Contains $firstlegAgents '单行入口拒绝分摊组' 'Firstleg 分摊组原子边界' Assert-Contains $adminFinanceAgents '`allocationGroupId` 整组审核、驳回、调整或作废' 'Admin 分摊组操作边界' Assert-Contains $containerFeeOpenApi '/fee-details/allocation-groups:' '柜费分摊组创建 OpenAPI' @@ -916,6 +930,7 @@ foreach ($module in $pom.project.modules.module) { # 对本轮高变化链路做精确文件门禁,防止改名或删除后留下悬空索引。 $criticalPaths = @( 'apps/admin-web/src/modules/overseas-warehouse/api/inboundOrders.ts', + 'docs/财务管理/海外仓费用统一接入阶段0技术契约.md', 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts', 'apps/pda-overseas-uniapp/src/api/pallet.js', 'apps/pda-overseas-uniapp/src/pages/pallet/pallet.vue', diff --git a/scripts/blue-green-deployment-contract.test.mjs b/scripts/blue-green-deployment-contract.test.mjs index cfc60686d..6abd984a6 100644 --- a/scripts/blue-green-deployment-contract.test.mjs +++ b/scripts/blue-green-deployment-contract.test.mjs @@ -26,6 +26,9 @@ test('all backend business services have blue-green runtime contracts', async () assert.match(script, /connect_routing_aliases "\$candidate" "\$ROUTING_ALIAS" "\$NACOS_SERVICE"/) assert.match(script, /file,file-service:file/) assert.match(script, /docker stop --time "\$STOP_TIMEOUT" "\$active_container"/) + assert.match(script, /DRAIN_SECONDS="\$\{BLUE_GREEN_DRAIN_SECONDS:-30\}"/) + assert.match(script, /蓝绿切换后连接排空/) + assert.match(script, /sleep "\$DRAIN_SECONDS"/) assert.match(script, /mv -f "\$STATE_FILE\.tmp" "\$STATE_FILE"/) }) @@ -90,13 +93,12 @@ test('production single-service pipeline dispatches the blue-green executor', as assert.match(deploy, /if \[ "\$DEPLOY_ENV" = "production" \]/) assert.match(deploy, /run_ssh sh "\$REMOTE_DIR\/\$REMOTE_BLUE_GREEN_NAME"/) assert.match(deploy, /TARGET_IS_LOCAL[\s\S]*?deploy-blue-green-service\.sh/) - assert.match(batch, /if \[ "\$DEPLOY_ENV" = "test" \] \|\| \[ "\$DEPLOY_ENV" = "production" \][\s\S]*?deploy-blue-green-service\.sh/) + assert.match(batch, /sh deploy\/scripts\/deploy-blue-green-service\.sh/) }) -test('test business services use blue-green while test gateways retain their fixed entry ports', async () => { +test('test environment uses single-slot compose updates', async () => { const deploy = await read('deploy/scripts/deploy-built-service.sh') const batch = await read('deploy/scripts/deploy-services.sh') - assert.match(deploy, /\[ "\$DEPLOY_ENV" = "test" \][\s\S]*?\[ "\$SERVICE" != "gateway" \]/) - assert.match(batch, /\[ "\$DEPLOY_ENV" = "test" \] \|\| \[ "\$DEPLOY_ENV" = "production" \]/) - assert.match(batch, /\[ "\$service" = gateway \] \|\| \[ "\$service" = gateway-overseas \]/) + assert.doesNotMatch(deploy, /if \[ "\$DEPLOY_ENV" = "test" \] \\\n\s*\|\|[\s\S]*?deploy-blue-green-service\.sh/) + assert.match(batch, /if \[ "\$DEPLOY_ENV" = test \]; then[\s\S]*?up -d --no-deps --pull never "\$service"/) }) diff --git a/scripts/blue-green-deployment-runtime.test.mjs b/scripts/blue-green-deployment-runtime.test.mjs index 2fa8c975e..6e4a7026a 100644 --- a/scripts/blue-green-deployment-runtime.test.mjs +++ b/scripts/blue-green-deployment-runtime.test.mjs @@ -114,6 +114,7 @@ async function runBlueGreen(health) { PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, BLUE_GREEN_STATE_ROOT: stateRoot, BLUE_GREEN_STABILIZATION_SECONDS: '0', + BLUE_GREEN_DRAIN_SECONDS: '0', DEPLOY_HEALTH_TIMEOUT_SECONDS: '2', DEPLOY_HEALTH_POLL_SECONDS: '1', MOCK_LOG: log, diff --git a/scripts/database-schema-alignment-migrations-contract.test.mjs b/scripts/database-schema-alignment-migrations-contract.test.mjs index 6822655b5..09b02d27c 100644 --- a/scripts/database-schema-alignment-migrations-contract.test.mjs +++ b/scripts/database-schema-alignment-migrations-contract.test.mjs @@ -3,15 +3,24 @@ import { readFile } from 'node:fs/promises' import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' +import { buildPlan, loadScopes } from '../deploy/sql/scripts/db-migrations.mjs' const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))) const migrations = { iam: 'deploy/sql/migrations/cn/baiyue_iam/V1__align_schema_baseline.sql', + iamForwardFix: 'deploy/sql/migrations/cn/baiyue_iam/V3__align_domestic_pda_mojia_sailing_permissions.sql', firstleg: 'deploy/sql/migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql', + firstlegMeasurement: 'deploy/sql/migrations/cn/baiyue_firstleg/V2__align_measurement_and_fee_schema.sql', + firstlegCustoms: 'deploy/sql/migrations/cn/baiyue_firstleg/V3__add_customs_receipt_declaration_schema.sql', + firstlegSailing: 'deploy/sql/migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql', file: 'deploy/sql/migrations/cn/baiyue_file/V1__align_schema_baseline.sql', fileForwardFix: 'deploy/sql/migrations/cn/baiyue_file/V2__widen_business_type.sql', lastleg: 'deploy/sql/migrations/overseas/baiyue_lastleg/V2__align_schema_baseline.sql', lastlegForwardFix: 'deploy/sql/migrations/overseas/baiyue_lastleg/V3__relax_valid_nullable_fields.sql', + lastlegRelabel: 'deploy/sql/migrations/overseas/baiyue_lastleg/V5__allow_multiple_relabel_applications.sql', +} +const destructiveMigrations = { + overseasWarehouseLocation: 'deploy/sql/migrations/overseas/baiyue_lastlag_warehouse/V2__retire_location_zone_and_rebuild_query_index.sql', } async function sources() { @@ -27,10 +36,44 @@ test('schema alignment migrations contain no destructive statements', async () = } }) +test('warehouse location migration marks its production-only destructive step', async () => { + const source = await readFile(path.join(repositoryRoot, destructiveMigrations.overseasWarehouseLocation), 'utf8') + assert.match(source, /DROP COLUMN zone_code/i) + assert.match(source, /生产执行前必须单独授权 DROP COLUMN/i) + assert.match(source, /有意的破坏性结构变更/i) + assert.doesNotMatch(source, /^\s*(?:DROP|DELETE|UPDATE|TRUNCATE)\b/im) +}) + test('schema alignment migrations cover the verified drift', async () => { const source = await sources() for (const field of ['tenant_code', 'customer_nickname', 'original_file_name']) assert.match(source.iam, new RegExp(`\\b${field}\\b`)) + for (const field of [ + 'domestic_warehouse_id', 'idx_membership_domestic_warehouse', + 'platform:mojia-sku-sync:list', 'platform:firstleg:preload:list', + 'platform:firstleg:pda:assign', 'platform:firstleg:sailing-calendar:list', + 'platform:firstleg:sailing-volume:exception-reassign', + ]) assert.match(source.iamForwardFix, new RegExp(field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) assert.match(source.firstleg, /\bpallet_no\b/) + for (const field of [ + 'inbound_code', 'measurement_kind', 'weight_kg', 'sync_status', + 'sync_failure_code', 'sync_failure_reason', 'lingxing_sku_id', 'revision', + 'match_failure_code', 'match_failure_reason', 'carrier_rate_id', + 'carrier_rate_version', 'rate_effective_from', + ]) assert.match(source.firstlegMeasurement, new RegExp(`\\b${field}\\b`)) + for (const table of [ + 'fl_customs_broker_submission', 'fl_customs_completion_action_history', + 'fl_customs_final_document', 'fl_customs_declaration_batch', 'fl_customs_clearance_result', + ]) assert.match(source.firstlegCustoms, new RegExp(`\\b${table}\\b`)) + for (const field of [ + 'broker_submitted_by', 'broker_submitted_by_name', 'broker_submitted_at', + 'customer_confirm_status', 'customer_confirmed_by', 'customer_confirmed_by_name', + 'customer_confirmed_at', 'customer_reject_reason', + ]) assert.match(source.firstlegCustoms, new RegExp(`\\b${field}\\b`)) + for (const table of [ + 'fl_sailing_channel', 'fl_sailing_template', 'fl_sailing', 'fl_sailing_history', + 'fl_sailing_receipt_batch_fact', 'fl_sailing_inbound_volume_forecast', + 'fl_sailing_assignment', 'fl_sailing_booking_link', + ]) assert.match(source.firstlegSailing, new RegExp(`\\b${table}\\b`)) for (const table of ['file_pda_release_channel', 'file_pda_release']) assert.match(source.file, new RegExp(`\\b${table}\\b`)) assert.match(source.fileForwardFix, /business_type\s+varchar\(64\)/i) for (const field of [ @@ -40,14 +83,38 @@ test('schema alignment migrations cover the verified drift', async () => { for (const field of ['inventory_position_id', 'new_sku_code', 'source_warehouse_id']) { assert.match(source.lastlegForwardFix, new RegExp(`\\b${field}\\b`)) } + assert.match(source.lastlegRelabel, /uk_ll_relabel_application_order/i) + assert.match(source.lastlegRelabel, /idx_ll_relabel_application_order/i) }) test('constraint tightening is guarded before any alter table', async () => { for (const [name, source] of Object.entries(await sources())) { const firstAlter = source.search(/\bALTER TABLE\b/i) const firstGuard = source.search(/SET @guard\s*=\s*IF/i) - if (name === 'file' || name === 'iam' || name === 'firstleg' || name === 'lastleg') { + if (name === 'file' || name === 'iam' || name === 'firstleg' || name === 'lastleg' || name === 'firstlegMeasurement' || name === 'firstlegCustoms') { assert.ok(firstGuard >= 0 && firstGuard < firstAlter, `${name} 必须先验证存量数据再执行 DDL`) } } }) + +test('数据库迁移计划包含本批四个 schema 的正式目标版本', async () => { + const sqlRoot = path.join(repositoryRoot, 'deploy', 'sql') + const scopes = await loadScopes(path.join(sqlRoot, 'migration-scopes.json')) + const plan = await buildPlan({ + sqlRoot, + scopes, + regions: ['CN', 'OVERSEAS'], + services: ['iam', 'firstleg', 'lastleg-service', 'warehouse-overseas-service'], + commit: null, + }) + const targets = Object.fromEntries(plan.regions.flatMap((region) => region.schemas.map((schema) => [schema.schema, schema]))) + assert.equal(targets.baiyue_iam.target, '3') + assert.equal(targets.baiyue_firstleg.target, '4') + assert.equal(targets.baiyue_lastleg.target, '5') + assert.equal(targets.baiyue_lastlag_warehouse.target, '2') + assert.deepEqual(targets.baiyue_firstleg.migrations.slice(-3).map((migration) => migration.path), [ + 'migrations/cn/baiyue_firstleg/V2__align_measurement_and_fee_schema.sql', + 'migrations/cn/baiyue_firstleg/V3__add_customs_receipt_declaration_schema.sql', + 'migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql', + ]) +}) diff --git a/scripts/db/customs-two-file-incremental-20260926.sql b/scripts/db/customs-two-file-incremental-20260926.sql new file mode 100644 index 000000000..6d861f37f --- /dev/null +++ b/scripts/db/customs-two-file-incremental-20260926.sql @@ -0,0 +1,28 @@ +-- 报关两文件流程:回执与真实报关完税文件分离。 +-- 仅在已确认目标测试库后执行;所有 DDL 可重复执行,不创建数据库、不写业务数据。 +USE baiyue_firstleg; + +SET @ddl = IF(NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_customs_container' AND column_name='broker_submitted_at' +), 'ALTER TABLE fl_customs_container ADD COLUMN broker_submitted_by BIGINT UNSIGNED NULL COMMENT ''发送报关行执行的操作人ID'' AFTER completed_at, ADD COLUMN broker_submitted_by_name VARCHAR(64) NULL COMMENT ''发送报关行执行的操作人名称'' AFTER broker_submitted_by, ADD COLUMN broker_submitted_at DATETIME(3) NULL COMMENT ''发送报关行执行时间'' AFTER broker_submitted_by_name', 'SELECT 1'); +PREPARE customs_container_broker_submit_stmt FROM @ddl; EXECUTE customs_container_broker_submit_stmt; DEALLOCATE PREPARE customs_container_broker_submit_stmt; + +CREATE TABLE IF NOT EXISTS fl_customs_final_document ( + id BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件ID', + source_document_id BIGINT UNSIGNED NOT NULL COMMENT '对应冻结客户资料ID', + file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '真实报关完税文件名称快照', + file_hash CHAR(64) NOT NULL COMMENT '真实报关完税文件SHA-256摘要', + content_type VARCHAR(128) NULL COMMENT '真实报关完税文件媒体类型', + size_bytes BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件大小(字节)', + uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '完税文件版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_final_source(source_document_id), + CONSTRAINT fk_customs_final_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) +) ENGINE=InnoDB COMMENT='真实报关完税文件当前版本'; diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index fdc6c21dd..74105200f 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3468,6 +3468,8 @@ 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_location' AND column_name='location_type' AND COALESCE(column_comment,'') <> '库位类型'), 'ALTER TABLE wh_location MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT ''库位类型''', 'SELECT 1')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='shared_overflow' AND COALESCE(column_comment,'') <> '是否系统共享溢出库位(A0)'), 'ALTER TABLE wh_location MODIFY COLUMN shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否系统共享溢出库位(A0)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='max_load_kg' AND COALESCE(column_comment,'') <> '最大承重(千克)'), 'ALTER TABLE wh_location MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT ''最大承重(千克)''', 'SELECT 1')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='status' AND COALESCE(column_comment,'') <> '库位状态:ACTIVE启用、INACTIVE停用'), 'ALTER TABLE wh_location MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''库位状态:ACTIVE启用、INACTIVE停用''', 'SELECT 1')); diff --git a/scripts/db/init-domestic-pda-operators.sql b/scripts/db/init-domestic-pda-operators.sql new file mode 100644 index 000000000..5911232b2 --- /dev/null +++ b/scripts/db/init-domestic-pda-operators.sql @@ -0,0 +1,261 @@ +-- 国内头程 PDA 操作员初始化(MySQL 8.0+,可重复执行) +-- +-- 初始化内容: +-- 1. 平台角色 CN_FIRSTLEG_PDA_OPERATOR(国内头程 PDA 作业员) +-- 2. 张三 / pda_zhangsan,绑定 CN-YIWU-01(义乌仓) +-- 3. 李四 / pda_lisi,绑定 CN-YIWU-01(义乌仓) +-- +-- 目标库:同一 MySQL 实例中的 baiyue_auth、baiyue_iam、baiyue_warehouse。 +-- 前置条件:IAM 已包含 iam_membership.domestic_warehouse_id,且义乌仓主数据已初始化。 +-- 密码列只写 BCrypt 哈希;首次插入时要求首次登录修改密码。重复执行不会覆盖用户后来修改的密码。 + +SET NAMES utf8mb4; + +USE baiyue_iam; + +DROP PROCEDURE IF EXISTS assert_domestic_pda_operator_seed_ready; +DELIMITER $$ +CREATE PROCEDURE assert_domestic_pda_operator_seed_ready() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account' + ) OR NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'baiyue_iam' AND table_name = 'iam_membership' + ) OR NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'baiyue_warehouse' AND table_name = 'wh_warehouse' + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:baiyue_auth、baiyue_iam 或 baiyue_warehouse 基础表未部署'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_iam' AND table_name = 'iam_membership' + AND column_name = 'domestic_warehouse_id' + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:请先部署 iam_membership.domestic_warehouse_id'; + END IF; + + IF (SELECT COUNT(*) + FROM baiyue_warehouse.wh_warehouse + WHERE tenant_id = 0 AND warehouse_code = 'CN-YIWU-01' + AND warehouse_type = 'DOMESTIC' AND status = 'ACTIVE' AND deleted = 0) <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:未找到唯一启用的国内仓 CN-YIWU-01'; + END IF; + + IF EXISTS ( + SELECT 1 FROM iam_role + WHERE (id = 4000007 AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR role_code <> 'CN_FIRSTLEG_PDA_OPERATOR')) + OR (tenant_id = 0 AND app_code = 'PLATFORM' AND role_code = 'CN_FIRSTLEG_PDA_OPERATOR' AND id <> 4000007) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:角色ID 4000007 或角色编码已被其他数据占用'; + END IF; + + IF EXISTS ( + SELECT 1 FROM iam_data_scope + WHERE (id = 6000007 AND role_id <> 4000007) + OR (role_id = 4000007 AND id <> 6000007) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:数据范围ID 6000007 已被占用'; + END IF; + + IF (SELECT COUNT(*) FROM iam_permission + WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0 + AND permission_code IN ('platform:firstleg:pda:view', 'platform:firstleg:pda:work')) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:PDA view/work 权限尚未完整部署'; + END IF; +END$$ +DELIMITER ; + +CALL assert_domestic_pda_operator_seed_ready(); +DROP PROCEDURE assert_domestic_pda_operator_seed_ready; + +SET @domestic_warehouse_id = ( + SELECT id FROM baiyue_warehouse.wh_warehouse + WHERE tenant_id = 0 AND warehouse_code = 'CN-YIWU-01' + AND warehouse_type = 'DOMESTIC' AND status = 'ACTIVE' AND deleted = 0 + LIMIT 1 +); + +-- 优先复用同名账号已有ID;首次执行使用下面的稳定ID。 +SET @zhangsan_auth_id = COALESCE( + (SELECT id FROM baiyue_auth.auth_account WHERE username = 'pda_zhangsan' AND deleted = 0 LIMIT 1), + 194000000000000101 +); +SET @lisi_auth_id = COALESCE( + (SELECT id FROM baiyue_auth.auth_account WHERE username = 'pda_lisi' AND deleted = 0 LIMIT 1), + 194000000000000102 +); +SET @zhangsan_user_id = COALESCE( + (SELECT id FROM iam_user WHERE auth_user_id = @zhangsan_auth_id AND deleted = 0 LIMIT 1), + 194000000000000201 +); +SET @lisi_user_id = COALESCE( + (SELECT id FROM iam_user WHERE auth_user_id = @lisi_auth_id AND deleted = 0 LIMIT 1), + 194000000000000202 +); +SET @zhangsan_membership_id = COALESCE( + (SELECT id FROM iam_membership WHERE tenant_id = 0 AND user_id = @zhangsan_user_id AND deleted = 0 LIMIT 1), + 194000000000000301 +); +SET @lisi_membership_id = COALESCE( + (SELECT id FROM iam_membership WHERE tenant_id = 0 AND user_id = @lisi_user_id AND deleted = 0 LIMIT 1), + 194000000000000302 +); + +DROP PROCEDURE IF EXISTS assert_domestic_pda_operator_ids_available; +DELIMITER $$ +CREATE PROCEDURE assert_domestic_pda_operator_ids_available() +BEGIN + IF EXISTS ( + SELECT 1 FROM baiyue_auth.auth_account + WHERE (id = @zhangsan_auth_id AND username <> 'pda_zhangsan') + OR (id = @lisi_auth_id AND username <> 'pda_lisi') + OR (username = 'pda_zhangsan' AND id <> @zhangsan_auth_id) + OR (username = 'pda_lisi' AND id <> @lisi_auth_id) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:张三或李四的认证账号ID/用户名冲突'; + END IF; + + IF EXISTS ( + SELECT 1 FROM iam_user + WHERE (id = @zhangsan_user_id AND auth_user_id <> @zhangsan_auth_id) + OR (id = @lisi_user_id AND auth_user_id <> @lisi_auth_id) + OR (auth_user_id = @zhangsan_auth_id AND id <> @zhangsan_user_id) + OR (auth_user_id = @lisi_auth_id AND id <> @lisi_user_id) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:张三或李四的 IAM 用户ID冲突'; + END IF; + + IF EXISTS ( + SELECT 1 FROM iam_membership + WHERE (id = @zhangsan_membership_id AND (tenant_id <> 0 OR user_id <> @zhangsan_user_id)) + OR (id = @lisi_membership_id AND (tenant_id <> 0 OR user_id <> @lisi_user_id)) + OR (tenant_id = 0 AND member_no = 'CN-PDA-YIWU-ZS' AND user_id <> @zhangsan_user_id) + OR (tenant_id = 0 AND member_no = 'CN-PDA-YIWU-LS' AND user_id <> @lisi_user_id) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '国内 PDA 初始化失败:张三或李四的成员ID/工号冲突'; + END IF; +END$$ +DELIMITER ; + +CALL assert_domestic_pda_operator_ids_available(); +DROP PROCEDURE assert_domestic_pda_operator_ids_available; + +START TRANSACTION; + +INSERT INTO iam_role ( + id, tenant_id, app_code, role_code, role_name, role_type, status, + description, deleted, version +) VALUES ( + 4000007, 0, 'PLATFORM', 'CN_FIRSTLEG_PDA_OPERATOR', '国内头程 PDA 作业员', + 'SYSTEM', 'ACTIVE', '负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成', 0, 0 +) +ON DUPLICATE KEY UPDATE + role_name = VALUES(role_name), role_type = 'SYSTEM', status = 'ACTIVE', + description = VALUES(description), deleted = 0; + +-- 该角色只保留国内 PDA 查看、作业两项最小权限。 +DELETE role_permission + FROM iam_role_permission role_permission + JOIN iam_permission permission ON permission.id = role_permission.permission_id + WHERE role_permission.role_id = 4000007 + AND permission.permission_code NOT IN ('platform:firstleg:pda:view', 'platform:firstleg:pda:work'); + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000007, id, 0 + FROM iam_permission + WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0 + AND permission_code IN ('platform:firstleg:pda:view', 'platform:firstleg:pda:work'); + +INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) +VALUES (6000007, 4000007, 0, 'TENANT', NULL) +ON DUPLICATE KEY UPDATE tenant_id = 0, scope_type = 'TENANT', scope_expression = NULL; + +-- 首次插入使用既定临时密码的 BCrypt(12) 哈希;重复执行保留账号当前密码。 +INSERT INTO baiyue_auth.auth_account ( + id, username, phone, password_hash, display_name, account_status, + failed_login_count, locked_until, token_version, password_change_required, + default_tenant_id, operator_tenant_id, default_membership_id, default_app_code, deleted +) VALUES + (@zhangsan_auth_id, 'pda_zhangsan', NULL, + '$2a$12$/BdqRpdgVPAu6aoizfuTJ.UhtgpZYEAkwdvworS5GVirUBSwCcuKG', + '张三', 'ACTIVE', 0, NULL, 1, 1, 0, 0, @zhangsan_membership_id, 'PLATFORM', 0), + (@lisi_auth_id, 'pda_lisi', NULL, + '$2a$12$Uv.KUNtVSAwT1R0Sk3Gb6uaytWitd7Hrda5xrZl0zKxLY1/SCUyXe', + '李四', 'ACTIVE', 0, NULL, 1, 1, 0, 0, @lisi_membership_id, 'PLATFORM', 0) +ON DUPLICATE KEY UPDATE + display_name = VALUES(display_name), account_status = 'ACTIVE', + failed_login_count = 0, locked_until = NULL, + default_tenant_id = 0, operator_tenant_id = 0, + default_membership_id = VALUES(default_membership_id), default_app_code = 'PLATFORM', deleted = 0; + +INSERT INTO iam_user ( + id, auth_user_id, user_type, display_name, phone, email, status, deleted, version +) VALUES + (@zhangsan_user_id, @zhangsan_auth_id, 'INTERNAL', '张三', NULL, NULL, 'ACTIVE', 0, 0), + (@lisi_user_id, @lisi_auth_id, 'INTERNAL', '李四', NULL, NULL, 'ACTIVE', 0, 0) +ON DUPLICATE KEY UPDATE + user_type = 'INTERNAL', display_name = VALUES(display_name), status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_membership ( + id, tenant_id, user_id, member_type, member_no, + overseas_warehouse_id, domestic_warehouse_id, + status, joined_at, left_at, deleted, version +) VALUES + (@zhangsan_membership_id, 0, @zhangsan_user_id, 'EMPLOYEE', 'CN-PDA-YIWU-ZS', + NULL, @domestic_warehouse_id, 'ACTIVE', CURRENT_TIMESTAMP(3), NULL, 0, 0), + (@lisi_membership_id, 0, @lisi_user_id, 'EMPLOYEE', 'CN-PDA-YIWU-LS', + NULL, @domestic_warehouse_id, 'ACTIVE', CURRENT_TIMESTAMP(3), NULL, 0, 0) +ON DUPLICATE KEY UPDATE + member_type = 'EMPLOYEE', member_no = VALUES(member_no), + overseas_warehouse_id = NULL, domestic_warehouse_id = VALUES(domestic_warehouse_id), + status = 'ACTIVE', joined_at = COALESCE(joined_at, CURRENT_TIMESTAMP(3)), + left_at = NULL, deleted = 0; + +-- 国内、海外 PDA 角色互斥;保留人员可能已有的其他非 PDA 角色。 +DELETE membership_role + FROM iam_membership_role membership_role + JOIN iam_role role ON role.id = membership_role.role_id + WHERE membership_role.membership_id IN (@zhangsan_membership_id, @lisi_membership_id) + AND role.role_code = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'; + +INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES + (@zhangsan_membership_id, 4000007, 0), + (@lisi_membership_id, 4000007, 0) +ON DUPLICATE KEY UPDATE tenant_id = 0; + +INSERT IGNORE INTO iam_security_subject_version (auth_user_id, permission_version) +VALUES (@zhangsan_auth_id, 1), (@lisi_auth_id, 1); + +COMMIT; + +-- 验证结果:应返回两行,且 role_code、warehouse_code、account_status、member_status 均为有效值。 +SELECT account.username, + user_profile.display_name, + role.role_code, + warehouse.warehouse_code, + warehouse.warehouse_name, + account.account_status, + membership.status AS member_status + FROM baiyue_auth.auth_account account + JOIN iam_user user_profile ON user_profile.auth_user_id = account.id AND user_profile.deleted = 0 + JOIN iam_membership membership ON membership.user_id = user_profile.id + AND membership.tenant_id = 0 AND membership.deleted = 0 + JOIN iam_membership_role membership_role ON membership_role.membership_id = membership.id + JOIN iam_role role ON role.id = membership_role.role_id + AND role.role_code = 'CN_FIRSTLEG_PDA_OPERATOR' + JOIN baiyue_warehouse.wh_warehouse warehouse ON warehouse.id = membership.domestic_warehouse_id + WHERE account.username IN ('pda_zhangsan', 'pda_lisi') + ORDER BY account.username; diff --git a/scripts/db/local-dev-iam-number-sequence.sql b/scripts/db/local-dev-iam-number-sequence.sql new file mode 100644 index 000000000..191700179 --- /dev/null +++ b/scripts/db/local-dev-iam-number-sequence.sql @@ -0,0 +1,21 @@ +-- Target only an explicitly authorized local/dev connection and baiyue_iam. +-- Preserve existing customer rows and never reset an existing sequence backwards. +USE baiyue_iam; +CREATE TABLE IF NOT EXISTS iam_number_sequence ( + sequence_key VARCHAR(64) NOT NULL COMMENT '业务编号类型', + next_value BIGINT UNSIGNED NOT NULL COMMENT '下一待分配数字', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (sequence_key) +) ENGINE=InnoDB COMMENT='IAM业务编号序列表'; +START TRANSACTION; +INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1); +SELECT next_value FROM iam_number_sequence WHERE sequence_key='CUSTOMER_CODE' FOR UPDATE; +SET @customer_code_floor = ( + SELECT COALESCE(MAX(CAST(tenant_code AS UNSIGNED)), 0) + 1 + FROM iam_tenant WHERE tenant_code REGEXP '^[0-9]{5}$' +); +UPDATE iam_number_sequence SET next_value=GREATEST(next_value,@customer_code_floor) +WHERE sequence_key='CUSTOMER_CODE'; +COMMIT; +SHOW CREATE TABLE iam_number_sequence; +SELECT sequence_key,next_value FROM iam_number_sequence; diff --git a/scripts/db/local-return-relabel-multiple-applications.sql b/scripts/db/local-return-relabel-multiple-applications.sql new file mode 100644 index 000000000..9f8d735f4 --- /dev/null +++ b/scripts/db/local-return-relabel-multiple-applications.sql @@ -0,0 +1,19 @@ +-- Local baiyue_lastleg only; retain every application and all idempotency constraints. +USE baiyue_lastleg; +SET @relabel_has_unique = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' + AND index_name = 'uk_ll_relabel_application_order'); +SET @relabel_has_index = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' + AND index_name = 'idx_ll_relabel_application_order'); +SET @relabel_index_sql = CASE + WHEN @relabel_has_unique > 0 AND @relabel_has_index = 0 THEN + 'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order, ADD INDEX idx_ll_relabel_application_order (lastleg_order_id)' + WHEN @relabel_has_unique > 0 THEN + 'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order' + WHEN @relabel_has_index = 0 THEN + 'ALTER TABLE ll_relabel_application ADD INDEX idx_ll_relabel_application_order (lastleg_order_id)' + ELSE 'SELECT 1' END; +PREPARE relabel_index_migration FROM @relabel_index_sql; +EXECUTE relabel_index_migration; +DEALLOCATE PREPARE relabel_index_migration; diff --git a/scripts/db/recent-schema-incremental-20260923.sql b/scripts/db/recent-schema-incremental-20260923.sql new file mode 100644 index 000000000..3763bbe85 --- /dev/null +++ b/scripts/db/recent-schema-incremental-20260923.sql @@ -0,0 +1,398 @@ +-- 2026-09-23 近期字段与表结构统一增量(MySQL 8.0+) +-- +-- 范围:2026-09-21 至 2026-09-22 合入的结构变化。 +-- 特性:显式切换到所属 schema;字段和索引均通过 information_schema 判断;可重复执行。 +-- 边界:不创建数据库、不删除业务字段/表、不包含权限种子或业务数据修正。 +-- 注意:DDL 在 MySQL 中会隐式提交;任一步失败后应停止执行并核对目标实例。 + +SET NAMES utf8mb4; + +-- ============================================================================ +-- IAM:国内头程 PDA 操作员绑定作业仓 +-- ============================================================================ +USE baiyue_iam; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'iam_membership' + ) + AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'iam_membership' + AND column_name = 'domestic_warehouse_id' + ), + 'ALTER TABLE iam_membership ADD COLUMN domestic_warehouse_id BIGINT NULL COMMENT ''国内头程PDA操作员绑定的唯一国内作业仓ID'' AFTER overseas_warehouse_id', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @domestic_warehouse_index_columns = ( + SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'iam_membership' + AND index_name = 'idx_membership_domestic_warehouse' +); +SET @domestic_warehouse_index_non_unique = ( + SELECT MIN(non_unique) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'iam_membership' + AND index_name = 'idx_membership_domestic_warehouse' +); +SET @ddl = CASE + WHEN NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'iam_membership' + AND column_name = 'domestic_warehouse_id' + ) THEN 'SELECT 1' + WHEN @domestic_warehouse_index_columns IS NULL THEN + 'ALTER TABLE iam_membership ADD KEY idx_membership_domestic_warehouse (tenant_id, domestic_warehouse_id, status)' + WHEN @domestic_warehouse_index_columns <> 'tenant_id,domestic_warehouse_id,status' + OR @domestic_warehouse_index_non_unique <> 1 THEN + 'ALTER TABLE iam_membership DROP INDEX idx_membership_domestic_warehouse, ADD KEY idx_membership_domestic_warehouse (tenant_id, domestic_warehouse_id, status)' + ELSE 'SELECT 1' +END; +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- ============================================================================ +-- Firstleg:人工 FBA 报价允许缺少船司价格快照 +-- ============================================================================ +USE baiyue_firstleg; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee' + AND column_name = 'carrier_rate_id' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_inbound_fee MODIFY COLUMN carrier_rate_id BIGINT UNSIGNED NULL COMMENT ''船司FBA价格ID;人工报价为空''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee' + AND column_name = 'carrier_rate_version' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_inbound_fee MODIFY COLUMN carrier_rate_version INT UNSIGNED NULL COMMENT ''船司价格版本快照;人工报价为空''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee' + AND column_name = 'rate_effective_from' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_inbound_fee MODIFY COLUMN rate_effective_from DATETIME(3) NULL COMMENT ''价格生效开始快照;人工报价为空''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- ============================================================================ +-- Firstleg:墨家测量收件箱扩展为箱唛/SKU 双分流 +-- ============================================================================ + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'inbound_code' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN inbound_code VARCHAR(64) NULL COMMENT ''箱码命中后的权威入仓码;SKU记录为空'' AFTER box_code', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'measurement_kind' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN measurement_kind VARCHAR(16) NOT NULL DEFAULT ''BOX_LABEL'' COMMENT ''测量分流:BOX_LABEL箱唛;SKU一件代发SKU'' AFTER box_code', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'weight_kg' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''设备实测重量kg'' AFTER height_cm', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'weight_kg' + AND (column_type <> 'decimal(18,3)' OR is_nullable <> 'NO' OR column_default IS NOT NULL + OR column_comment <> '设备实测重量kg') + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN weight_kg DECIMAL(18,3) NOT NULL COMMENT ''设备实测重量kg''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'sync_status' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_status VARCHAR(24) NULL COMMENT ''SKU同步状态:PENDING/SYNCING/SUCCESS/FAILED'' AFTER status', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'sync_failure_code' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_failure_code VARCHAR(64) NULL COMMENT ''SKU同步失败业务码'' AFTER sync_status', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'sync_failure_reason' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_failure_reason VARCHAR(500) NULL COMMENT ''脱敏后的SKU同步失败原因'' AFTER sync_failure_code', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'lingxing_sku_id' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN lingxing_sku_id VARCHAR(128) NULL COMMENT ''领星SKU内部标识'' AFTER sync_failure_reason', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'revision' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN revision INT NOT NULL DEFAULT 1 COMMENT ''当前测量版本号'' AFTER lingxing_sku_id', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'match_failure_code' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN match_failure_code VARCHAR(16) NULL COMMENT ''正式箱唛业务匹配失败码''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'match_failure_reason' AND is_nullable = 'NO' + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN match_failure_reason VARCHAR(500) NULL COMMENT ''脱敏后的箱唛匹配失败原因''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'box_code' + AND column_comment <> '设备扫描的原始箱码或不带#的SKU' + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN box_code VARCHAR(128) NOT NULL COMMENT ''设备扫描的原始箱码或不带#的SKU''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'status' + AND column_comment <> '原始收件状态;SKU记录使用SKU_PENDING_SYNC' + ), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN status VARCHAR(24) NOT NULL DEFAULT ''UNMATCHED'' COMMENT ''原始收件状态;SKU记录使用SKU_PENDING_SYNC''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- ============================================================================ +-- Lastleg:同一尾程单允许多次换标申请,订单关联由唯一索引改为普通索引 +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' + AND index_name = 'uk_ll_relabel_application_order' + ), + 'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @relabel_order_index_columns = ( + SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_relabel_application' + AND index_name = 'idx_ll_relabel_application_order' +); +SET @relabel_order_index_non_unique = ( + SELECT MIN(non_unique) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_relabel_application' + AND index_name = 'idx_ll_relabel_application_order' +); +SET @ddl = CASE + WHEN @relabel_order_index_columns IS NULL THEN + 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_order (lastleg_order_id)' + WHEN @relabel_order_index_columns <> 'lastleg_order_id' + OR @relabel_order_index_non_unique <> 1 THEN + 'ALTER TABLE ll_relabel_application DROP INDEX idx_ll_relabel_application_order, ADD KEY idx_ll_relabel_application_order (lastleg_order_id)' + ELSE 'SELECT 1' +END; +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- ============================================================================ +-- Overseas Warehouse:A0 系统共享溢出库位标记 +-- 注意:正式 schema 名沿用项目现有拼写 baiyue_lastlag_warehouse。 +-- ============================================================================ +USE baiyue_lastlag_warehouse; + +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'wh_location' + AND column_name = 'shared_overflow' + ), + 'ALTER TABLE wh_location ADD COLUMN shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否系统共享溢出库位(A0)'' AFTER location_type', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'wh_location' + AND column_name = 'shared_overflow' + AND (column_type <> 'tinyint(1)' OR is_nullable <> 'NO' + OR COALESCE(column_default, '') <> '0' + OR column_comment <> '是否系统共享溢出库位(A0)') + ), + 'ALTER TABLE wh_location MODIFY COLUMN shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否系统共享溢出库位(A0)''', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + +-- 最终只读核对;四行均应返回 READY。 +SELECT 'baiyue_iam' AS target_schema, + IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'baiyue_iam' AND table_name = 'iam_membership' AND column_name = 'domestic_warehouse_id') + AND (SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = 'baiyue_iam' AND table_name = 'iam_membership' AND index_name = 'idx_membership_domestic_warehouse') = 'tenant_id,domestic_warehouse_id,status', + 'READY', 'CHECK_REQUIRED' + ) AS schema_status +UNION ALL +SELECT 'baiyue_firstleg', + IF( + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_mojia_measurement_receipt' AND column_name IN ('measurement_kind','weight_kg','sync_status','sync_failure_code','sync_failure_reason','lingxing_sku_id','revision')) = 7 + AND (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_fee' AND column_name IN ('carrier_rate_id','carrier_rate_version','rate_effective_from') AND is_nullable = 'YES') = 3, + 'READY', 'CHECK_REQUIRED' + ) +UNION ALL +SELECT 'baiyue_lastleg', + IF( + NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_relabel_application' AND index_name = 'uk_ll_relabel_application_order') + AND (SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_relabel_application' AND index_name = 'idx_ll_relabel_application_order') = 'lastleg_order_id' + AND (SELECT MIN(non_unique) FROM information_schema.statistics WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_relabel_application' AND index_name = 'idx_ll_relabel_application_order') = 1, + 'READY', 'CHECK_REQUIRED' + ) +UNION ALL +SELECT 'baiyue_lastlag_warehouse', + IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_lastlag_warehouse' + AND table_name = 'wh_location' + AND column_name = 'shared_overflow' + AND column_type = 'tinyint(1)' + AND is_nullable = 'NO' + AND COALESCE(column_default, '') = '0' + ), + 'READY', 'CHECK_REQUIRED' + ); diff --git a/scripts/db/relabel-wait-status-incremental.sql b/scripts/db/relabel-wait-status-incremental.sql index d14947c6e..72b0c412c 100644 --- a/scripts/db/relabel-wait-status-incremental.sql +++ b/scripts/db/relabel-wait-status-incremental.sql @@ -13,4 +13,41 @@ WHERE o.order_type='RELABEL_APPLICATION' AND o.current_status='PENDING_ASSIGNMEN AND o.inbound_order_no IS NULL AND (a.status IN ('PENDING_PROCESSING','PROCESSING','CUSTOMER_CONFIRM_PENDING') OR (a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status IN ('WAIT_PRE_DISPATCH','WAIT_OUTBOUND_CREATION','OUTBOUND_CREATED'))); + +-- 仓储转运已完成入库和换标后不得回到“待分配”;按是否已有目的仓恢复后续状态。 +UPDATE ll_warehouse_transfer_order t +JOIN ll_relabel_application a ON a.id=t.relabel_application_id + AND a.customer_id=t.customer_id AND a.relabel_no=t.relabel_no +SET t.status=CASE + WHEN COALESCE(t.fba_warehouse_code,'')<>'' THEN 'READY_FOR_DISPATCH' + ELSE 'WAIT_FBA_DESTINATION' END, + t.relabel_completed_at=COALESCE(t.relabel_completed_at,a.customer_confirmed_at), + t.updated_at=CURRENT_TIMESTAMP(3) +WHERE t.status='WAIT_RELABEL' + AND a.customer_confirmed_at IS NOT NULL + AND a.status IN ('READY_FOR_OUTBOUND','COMPLETED'); + +UPDATE ll_order o +JOIN ll_warehouse_transfer_order t ON t.lastleg_order_id=o.id AND t.customer_id=o.customer_id +JOIN ll_relabel_application a ON a.id=t.relabel_application_id AND a.customer_id=t.customer_id +SET o.current_status=t.status, + o.relabel_status='COMPLETED', + o.sub_order_status=CONCAT('WAREHOUSE_TRANSFER:',t.status,';RELABEL:COMPLETED'), + o.updated_at=CURRENT_TIMESTAMP(3) +WHERE o.order_type='WAREHOUSE_TRANSFER' + AND o.current_status IN ('PENDING_ASSIGNMENT','WAIT_OUTBOUND','INBOUND_COMPLETED') + AND t.status IN ('READY_FOR_DISPATCH','WAIT_FBA_DESTINATION') + AND a.customer_confirmed_at IS NOT NULL + AND a.status IN ('READY_FOR_OUTBOUND','COMPLETED'); + +UPDATE ll_relabel_application a +JOIN ll_warehouse_transfer_order t ON t.relabel_application_id=a.id AND t.customer_id=a.customer_id +SET a.fulfillment_status=CASE + WHEN t.status='READY_FOR_DISPATCH' THEN 'WAIT_PRE_DISPATCH' + ELSE 'WAIT_FBA_DESTINATION' END, + a.updated_at=CURRENT_TIMESTAMP(3) +WHERE a.customer_confirmed_at IS NOT NULL + AND a.status IN ('READY_FOR_OUTBOUND','COMPLETED') + AND t.status IN ('READY_FOR_DISPATCH','WAIT_FBA_DESTINATION') + AND a.fulfillment_status IN ('WAIT_RELABEL','WAIT_OUTBOUND_CREATION','WAIT_PRE_DISPATCH','READY_FOR_DISPATCH','WAIT_FBA_DESTINATION'); COMMIT; diff --git a/scripts/local-return-relabel-acceptance.mjs b/scripts/local-return-relabel-acceptance.mjs new file mode 100644 index 000000000..0621da00e --- /dev/null +++ b/scripts/local-return-relabel-acceptance.mjs @@ -0,0 +1,145 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { createHash } from 'node:crypto' +import { AcceptanceRun, parseArguments, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' +import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' +import { approveReturnFixture } from './admin-test-env-modules/phase4-overseas-return-receipt.mjs' +import { completeReturnRelabel, outboundRelabelledReturn } from './admin-test-env-modules/phase4-overseas-return-followups.mjs' + +const options = parseArguments(process.argv.slice(2)) +for (const url of [options.gateway, options.overseasGateway]) { + if (!['127.0.0.1', 'localhost'].includes(new URL(url).hostname)) throw new Error('Local gateways required') +} +const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID +if (!customerId || !options.username || !options.password || !process.env.INTERNAL_SERVICE_TOKEN) throw new Error('Explicit customer, credentials and internal token required') +const context = new AcceptanceRun(options) +const verifyRemaining = process.env.LOCAL_RETURN_VERIFY_REMAINING === 'true' +const receiptQuantity = verifyRemaining ? 3 : 1 +const output = resolve(options.output || `reports/local-return-relabel-single-${context.marker}.json`) +const moduleName = 'local-return-relabel-single' +let picker +let fatal = null +const facts = { marker: context.marker, customerId, scope: verifyRemaining ? 'REMAINING_RETURN_RELABEL' : 'ONE_NEW_RETURN_SKU_ON_EXISTING_LOCAL_CUSTOMER', photoEvidence: 'SYNTHETIC_TEST_IMAGE' } +context.context.merchantQualificationFixture = { marker: context.marker, tenantId: customerId } +context.createdResources.phase4OverseasReturnReceipt = facts +const success = response => response.http === 200 && String(response.body?.code) === '200' +async function save() { + await mkdir(dirname(output), { recursive: true }) + const summary = buildAcceptanceSummary(context.steps, [], []) + await writeFile(output, JSON.stringify({ success: !fatal && (verifyRemaining ? facts.remainingRelabelPassed === true : facts.relabelOutboundNo != null) && summary.failed === 0, fatal, summary, facts, steps: context.steps }, null, 2)) +} +function check(name, condition) { + context.assert(moduleName, name, null, Boolean(condition), '', { deploymentBlocker: true }) + if (!condition) throw new Error(name) +} +async function command(region, token, method, path, body, headers = {}) { + const response = region === 'cn' ? await context.request(method, path, body, token) + : await context.requestOverseas(method, path, body, headers, token) + context.addStep(moduleName, `${method} ${path}`, response, success(response), '', { deploymentBlocker: true }) + if (!success(response)) throw new Error(`${method} ${path}: ${response.body?.code} ${response.body?.message}; requestId=${response.requestId}; traceId=${response.traceId}`) + return response.body.data +} +async function upload(token, path, bytes, name, type) { + const form = new FormData() + form.append('file', new Blob([bytes], { type }), name) + const response = await context.requestOverseasMultipart('POST', path, form, token) + context.addStep(moduleName, `Upload ${path}`, response, success(response), '', { deploymentBlocker: true }) + if (!success(response)) throw new Error(`Upload failed: ${response.body?.code} ${response.body?.message}`) + return response.body.data +} +try { + const login = await command('cn', '', 'POST', '/api/auth/login', { loginId: options.username, password: options.password }) + context.token = login.accessToken + context.overseasToken = login.overseasAccessToken + const ticket = await command('cn', context.token, 'POST', `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${context.marker} 本地单票退货换标验收` }) + const merchant = await command('cn', '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) + const token = merchant.overseasAccessToken + const permissions = await command('cn', merchant.accessToken, 'GET', '/api/merchant/v1/iam/authorization/permissions') + check('Merchant return create and relabel permissions', permissions.includes('merchant:overseas-warehouse:return-order:create') && permissions.includes('merchant:overseas-warehouse:return-order:relabel')) + await command('os', token, 'GET', '/api/merchant/v1/last-leg/return-orders?page=1&size=1') + const warehouses = await command('cn', context.token, 'GET', '/api/platform/v1/warehouse-admin?page=1&size=200&warehouseType=OVERSEAS&status=ACTIVE&capabilityCode=RECEIVING') + const warehouse = warehouses.records.find(row => row.id === process.env.OVERSEAS_TEST_WAREHOUSE_ID) || warehouses.records[0] + check('Existing active overseas receiving warehouse', warehouse?.id) + const warehouseId = String(warehouse.id) + facts.warehouseId = warehouseId + let destination + for (let page = 1; page <= 20; page++) { + const data = await command('cn', context.token, 'GET', `/api/platform/v1/warehouse-admin?page=${page}&size=200&warehouseType=FBA&status=ACTIVE`) + destination = data.records.find(row => row.warehouseCode === 'SCK8') + if (destination || page * 200 >= Number(data.total)) break + } + check('Existing active SCK8 destination', destination?.id) + const access = await resolvePickerAccess(context) + check('PDA role available', access) + picker = await createPicker(context, 93, warehouseId, access) + check('PDA session ready', picker?.token) + const skuCode = `LOCAL-RETURN-${Date.now()}` + facts.skuCode = skuCode + const root = '/api/merchant/v1/last-leg/return-orders' + const receipts = '/api/pda/v1/last-leg/return-receipts' + const created = await command('os', token, 'POST', root, { overseasWarehouseId: warehouseId, warehouseContactName: '本地验收', warehouseContactPhone: '0000000000', customerReferenceNo: context.marker, needRelabel: false, remark: context.marker, idempotencyKey: `${context.marker}-CREATE`, items: [{ skuCode, productNameZh: '本地退货换标验收商品', expectedQuantity: receiptQuantity }] }) + const returnNo = created.order.returnNo + facts.returnNo = returnNo + await save() + await approveReturnFixture(context, token, created) + await command('os', picker.token, 'POST', `${receipts}/start`, { returnNo }) + const pallet = await command('os', picker.token, 'POST', `${receipts}/pallets`, { returnNo, skuCode }) + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64') + await upload(picker.token, `${receipts}/${returnNo}/photos/files`, png, 'synthetic-receipt.png', 'image/png') + const receiptBody = { returnNo, startedBy: 'RETURN_NO', palletCode: pallet.palletCode, items: [{ skuCode, receivedQuantity: receiptQuantity }], idempotencyKey: `${context.marker}-RECEIPT` } + const received = await command('os', picker.token, 'POST', `${receipts}/confirm`, receiptBody) + const replay = await command('os', picker.token, 'POST', `${receipts}/confirm`, receiptBody) + check('Receipt completed once with one stock and fee batch', received.orderStatus === 'COMPLETED' && Number(received.receivedQuantity) === receiptQuantity && received.receiptNo === replay.receiptNo && received.receiptBatchNo === replay.receiptBatchNo) + facts.receiptNo = received.receiptNo + facts.receiptBatchNo = received.receiptBatchNo + facts.sourcePalletCode = pallet.palletCode + await save() + const relabel = await command('os', token, 'POST', `${root}/${returnNo}/relabel`, { items: [{ skuCode, quantity: 1 }], fileScope: 'CARTON_TOTAL', destinationFbaWarehouseId: String(destination.id), destinationFbaWarehouseCode: destination.warehouseCode, cartonRemark: context.marker, idempotencyKey: `${context.marker}-RELABEL` }) + const relabelNo = relabel.relabelNo + check('Relabel references the new return', relabel.returnNo === returnNo && relabel.status === 'PENDING_REVIEW' && relabelNo) + facts.relabelNo = relabelNo + await save() + const rendered = await fetch('http://127.0.0.1:9207/internal/print/v1/pdfs/inbound-marks', { method: 'POST', headers: { 'X-Internal-Service-Token': process.env.INTERNAL_SERVICE_TOKEN, 'X-Operator-Id': '1', 'Content-Type': 'application/json' }, body: JSON.stringify({ templateCode: 'INBOUND_MARK_V1', pageSpec: { widthMm: 100, heightMm: 100 }, downloadFileName: 'local-return-relabel.pdf', labels: [{ topCode: skuCode, warehouseText: destination.warehouseCode, cartonText: 'QTY: 1', routeText: 'LOCAL ACCEPTANCE', qrContent: relabelNo }] }) }) + const pdf = Buffer.from(await rendered.arrayBuffer()) + check('Print service returned real PDF', rendered.ok && pdf.subarray(0, 5).toString() === '%PDF-') + facts.labelPdfSha256 = createHash('sha256').update(pdf).digest('hex') + const uploaded = await upload(token, `/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/files?scope=CARTON_TOTAL`, pdf, 'local-return-relabel.pdf', 'application/pdf') + facts.labelFileId = uploaded.fileId + await completeReturnRelabel(context, token, picker.token, relabelNo, returnNo) + await save() + if (verifyRemaining) { + const candidates = await command('os', token, 'GET', `${root}/${returnNo}/relabel-candidates`) + check('Two unlabelled pieces remain on the source pallet', candidates.filter(row => row.cartonMark === pallet.palletCode).flatMap(row => row.items).reduce((sum, row) => sum + Number(row.receivedQuantity), 0) === 2) + const body = { items: [{ skuCode, quantity: 1 }], fileScope: 'CARTON_TOTAL', destinationFbaWarehouseId: String(destination.id), destinationFbaWarehouseCode: destination.warehouseCode, cartonRemark: `${context.marker}-SECOND`, idempotencyKey: `${context.marker}-RELABEL-SECOND` } + const second = await command('os', token, 'POST', `${root}/${returnNo}/relabel`, body) + check('Second application accepts remaining goods on same return', second.returnNo === returnNo && second.relabelNo !== relabelNo && second.status === 'PENDING_REVIEW') + facts.secondRelabelNo = second.relabelNo + await save() + const repeated = await command('os', token, 'POST', `${root}/${returnNo}/relabel`, body) + check('Second application replay does not duplicate allocation', repeated.relabelNo === second.relabelNo) + await upload(token, `/api/merchant/v1/last-leg/relabel-applications/${second.relabelNo}/files?scope=CARTON_TOTAL`, pdf, 'local-return-second.pdf', 'application/pdf') + context.marker = `${facts.marker}-SECOND` + try { + await completeReturnRelabel(context, token, picker.token, second.relabelNo, returnNo) + } finally { + context.marker = facts.marker + } + const after = await command('os', token, 'GET', `${root}/${returnNo}/relabel-candidates`) + check('Exactly one unlabelled piece remains after two completed batches', after.filter(row => row.cartonMark === pallet.palletCode).flatMap(row => row.items).reduce((sum, row) => sum + Number(row.receivedQuantity), 0) === 1) + facts.remainingRelabelPassed = true + } else { + await outboundRelabelledReturn(context, token, picker.token, relabelNo, returnNo, warehouseId) + } + const final = (await command('os', token, 'GET', `${root}/${returnNo}`)).order + facts.finalReturnStatus = final.status + facts.finalRelabelStatus = final.relabelStatus +} catch (error) { + fatal = error.message + process.exitCode = 1 +} finally { + if (picker?.membershipId) { + try { await disablePicker(context, picker, 93) } catch (error) { fatal = `${fatal || ''} cleanup: ${error.message}`; process.exitCode = 1 } + } + await save() + console.log(JSON.stringify({ output, fatal, facts, summary: buildAcceptanceSummary(context.steps, [], []), failures: context.steps.filter(step => !step.passed) }, null, 2)) +} diff --git a/scripts/local-return-relabel-outbound.mjs b/scripts/local-return-relabel-outbound.mjs new file mode 100644 index 000000000..f0abe3c74 --- /dev/null +++ b/scripts/local-return-relabel-outbound.mjs @@ -0,0 +1,72 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { dirname } from 'node:path' +import { AcceptanceRun, parseArguments, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' +import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' + +const options = parseArguments(process.argv.slice(2)) +for (const url of [options.gateway, options.overseasGateway]) { + if (!['127.0.0.1', 'localhost'].includes(new URL(url).hostname)) throw new Error('Local only') +} +if (!process.env.LOCAL_RETURN_SOURCE) throw new Error('LOCAL_RETURN_SOURCE required') +const previous = JSON.parse(await readFile(process.env.LOCAL_RETURN_SOURCE, 'utf8')) +const f = previous.facts +if (!f.relabelCompleted || !f.returnNo || !f.relabelNo || !f.customerId || !f.warehouseId) throw new Error('Completed relabel evidence required') +const c = new AcceptanceRun(options) +const output = options.output || `reports/local-return-relabel-outbound-${c.marker}.json` +let picker, error = null +const report = { source: process.env.LOCAL_RETURN_SOURCE, facts: f, scope: 'PLATFORM_VERIFIED_OUTBOUND_ONLY', merchantInventoryRead: 'NOT_VERIFIED_SEE_SOURCE_REPORT' } +function check(name, ok) { + c.assert('local-outbound', name, null, Boolean(ok), '', { deploymentBlocker: true }) + if (!ok) throw new Error(name) +} +async function api(region, token, method, path, body) { + const r = region === 'cn' ? await c.request(method, path, body, token) : await c.requestOverseas(method, path, body, {}, token) + const ok = r.http === 200 && String(r.body?.code) === '200' + c.addStep('local-outbound', `${method} ${path}`, r, ok, '', { deploymentBlocker: true }) + if (!ok) throw new Error(`${path}: ${r.body?.code} ${r.body?.message}`) + return r.body.data +} +async function save() { + await mkdir(dirname(output), { recursive: true }) + await writeFile(output, JSON.stringify({ ...report, error, summary: buildAcceptanceSummary(c.steps, [], []), steps: c.steps }, null, 2)) +} +try { + const l = await api('cn', '', 'POST', '/api/auth/login', { loginId: options.username, password: options.password }) + c.token = l.accessToken; c.overseasToken = l.overseasAccessToken + const inv = '/api/platform/v1/last-leg/return-inventory/cartons' + const root = '/api/platform/v1/last-leg/outbound-orders' + const candidates = await api('os', c.overseasToken, 'GET', `${root}/return/candidates?customerId=${f.customerId}&warehouseId=${f.warehouseId}`) + const matches = [] + for (const row of candidates.filter(x => x.returnNo === f.returnNo)) { + const d = await api('os', c.overseasToken, 'GET', `${inv}/${encodeURIComponent(row.cartonMark)}`) + if (d.relabelNo === f.relabelNo) matches.push(d) + } + check('Exactly one matching relabelled return carton', matches.length === 1) + const d = matches[0] + check('Customer warehouse quantity and completed relabel match', d.customerId === f.customerId && d.overseasWarehouseId === f.warehouseId && d.sourceReturnNo === f.returnNo && d.status === 'AVAILABLE' && d.relabelStatus === 'COMPLETED' && Number(d.totalQuantity) === 1) + const access = await resolvePickerAccess(c) + check('PDA role', access) + picker = await createPicker(c, 94, f.warehouseId, access) + check('PDA ready', picker?.token) + const body = { recipient: 'LOCAL ACCEPTANCE', overseasWarehouseId: f.warehouseId, returnNo: f.returnNo, cartonMarks: [d.cartonMark], remark: f.marker, idempotencyKey: `${f.marker}-OUTBOUND` } + const task = await api('os', c.overseasToken, 'POST', `${root}/return?customerId=${f.customerId}`, body) + report.outboundNo = task.outboundNo; report.taskNo = task.taskNo + await save() + const replay = await api('os', c.overseasToken, 'POST', `${root}/return?customerId=${f.customerId}`, body) + check('Outbound idempotent', replay.outboundNo === task.outboundNo && replay.taskNo === task.taskNo) + const path = `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(task.taskNo)}` + const detail = await api('os', picker.token, 'GET', path) + const frozen = await api('os', c.overseasToken, 'GET', `${root}/${task.outboundNo}?customerId=${f.customerId}`) + check('Frozen outbound contains only matching carton', detail.items.length === 1 && Number(detail.items[0].packageCount) === 1 && frozen.pallets.length === 1 && frozen.pallets[0].cartonMarks === d.cartonMark && String(frozen.pallets[0].id) === String(detail.items[0].outboundPalletItemId)) + await api('os', picker.token, 'POST', `${path}/pick`, { outboundPalletItemId: detail.items[0].outboundPalletItemId, inventoryPositionId: null, packages: 1, cartonMarks: [d.cartonMark], idempotencyKey: `${f.marker}-PICK` }) + const done = await api('os', picker.token, 'POST', `${path}/complete`, { idempotencyKey: `${f.marker}-OUTBOUND-COMPLETE` }) + const final = await api('os', c.overseasToken, 'GET', `${root}/${task.outboundNo}?customerId=${f.customerId}`) + const stock = await api('os', c.overseasToken, 'GET', `${inv}/${encodeURIComponent(d.cartonMark)}`) + check('Outbound completed and stock OUTBOUNDED', done.status === 'COMPLETED' && final.outbound.status === 'OUTBOUNDED' && stock.status === 'OUTBOUNDED' && stock.relabelNo === f.relabelNo && stock.sourceReturnNo === f.returnNo && Number(stock.totalQuantity) === 1) + report.finalOutboundStatus = final.outbound.status; report.finalInventoryStatus = stock.status; report.fulfillmentPassed = true +} catch (e) { error = e.message; process.exitCode = 1 } +finally { + if (picker?.membershipId) { try { await disablePicker(c, picker, 94) } catch (e) { error = `${error || ''} cleanup: ${e.message}`; process.exitCode = 1 } } + await save() + console.log(JSON.stringify({ output, error, outboundNo: report.outboundNo, finalOutboundStatus: report.finalOutboundStatus, finalInventoryStatus: report.finalInventoryStatus, summary: buildAcceptanceSummary(c.steps, [], []), failures: c.steps.filter(x => !x.passed) }, null, 2)) +} diff --git a/scripts/overseas-relabel-fulfillment-acceptance.mjs b/scripts/overseas-relabel-fulfillment-acceptance.mjs index aa0912c22..fa7bd677b 100644 --- a/scripts/overseas-relabel-fulfillment-acceptance.mjs +++ b/scripts/overseas-relabel-fulfillment-acceptance.mjs @@ -89,7 +89,9 @@ assert(transferDeliveries.length === 1, `Expected one destination delivery, got ${transferDeliveries.length}`) const deliveryNo = transferDeliveries[0].deliveryNo -assert(Array.isArray(task.items) && task.items.length === 2, 'Expected two PDA pallet items') +const expectedPdaItems = scenario.partialRelabel ? 1 : 2 +assert(Array.isArray(task.items) && task.items.length === expectedPdaItems, + `Expected ${expectedPdaItems} PDA pallet items`) if (!alreadyDeparted) { if (task.items.every(item => Number(item.pickedPackages) === 0)) { await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 0c82de1e1..c21e5a98f 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -131,10 +131,26 @@ test('entry has no built-in environment address or password and requires explici const options = parseArguments([ '--gateway', 'http://localhost:9000', '--overseas-gateway', 'http://localhost:9300', '--username', 'contract-user', '--password', 'contract-password', '--modules', 'phase4-overseas-pda', + '--pda-username', 'contract-picker', '--pda-password', 'contract-picker-password', ]) assert.equal(validateExecutionOptions(options), options) }) +test('loading dependency chains require PDA credentials before making any HTTP requests', () => { + for (const module of ['phase3-loading', 'phase3-container-loading', 'phase5-overseas-fulfillment']) { + const options = parseArguments(['--gateway', 'http://localhost:9000', + '--overseas-gateway', 'http://localhost:9300', '--username', 'admin', '--password', 'fixture', + '--modules', module]) + options.pdaUsername = '' + options.pdaPassword = '' + assert.throws(() => validateExecutionOptions(options), /PDA 作业员凭据/) + options.pdaUsername = 'picker' + assert.throws(() => validateExecutionOptions(options), /PDA 作业员凭据/) + options.pdaPassword = 'fixture' + 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: '' }) @@ -160,6 +176,7 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV assert.match(source, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/) assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/) assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/) + assert.match(source, /runPhase4ProducerVariant\('FBA-OPERATIONS', 'FBA', 'STANDARD', true\)/) assert.match(source, /-P4-FBA-STANDARD\(\?:-R\\d\+\)\?:/) assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) assert.match(source, /runUntilPickupReady\(this\)/) diff --git a/scripts/script-documentation-contract.test.mjs b/scripts/script-documentation-contract.test.mjs index 33b66b68b..44ab27cfe 100644 --- a/scripts/script-documentation-contract.test.mjs +++ b/scripts/script-documentation-contract.test.mjs @@ -13,6 +13,7 @@ const excludedDirectoryNames = new Set([ ]) const allowedDirectoryPrefixes = [ 'scripts/', + 'deploy/docker/', 'deploy/scripts/', 'deploy/sql/scripts/', 'deploy/rocketmq/', diff --git a/scripts/warehouse-transfer-remaining-acceptance.mjs b/scripts/warehouse-transfer-remaining-acceptance.mjs index f3e902efa..bca0f4599 100644 --- a/scripts/warehouse-transfer-remaining-acceptance.mjs +++ b/scripts/warehouse-transfer-remaining-acceptance.mjs @@ -9,6 +9,7 @@ const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' let customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || '' let warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID || '' const destinationCode = process.env.OVERSEAS_TEST_DESTINATION_CODE || 'ONT8' +const partialRelabel = process.env.WAREHOUSE_TRANSFER_PARTIAL_RELABEL === 'true' const output = resolve(process.env.WAREHOUSE_TRANSFER_REMAINING_OUTPUT || 'reports/warehouse-transfer-remaining-acceptance.json') const marker = `WT-REMAIN-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` @@ -189,13 +190,14 @@ try { } const cartonMarks = [`CM-${marker}-1`, `CM-${marker}-2`] + const cartonRelabelBindings = partialRelabel ? cartonMarks.slice(0, 1) : cartonMarks const transfer = await api(overseas, merchant.overseasAccessToken, 'POST', '/api/merchant/v1/last-leg/warehouse-transfers', { operatingWarehouseId: warehouseId, - fbaWarehouseCode: destinationCode, - destinationType: 'FBA', - destinationWarehouseCode: destinationCode, - destinationWarehouseName: destinationCode, + fbaWarehouseCode: partialRelabel ? null : destinationCode, + destinationType: partialRelabel ? null : 'FBA', + destinationWarehouseCode: partialRelabel ? null : destinationCode, + destinationWarehouseName: partialRelabel ? null : destinationCode, deliveryAddress: `Acceptance inbound address ${marker}`, packageCount: 2, volumeCbm: 0.12, @@ -214,7 +216,7 @@ try { idempotencyKey: key('CREATE'), items: [{ skuCode: `SKU-${marker}`.slice(0, 128), productNameZh: 'Acceptance cargo', packageCount: 2, shippingMark: cartonMarks.join(',') }], - relabelItems: cartonMarks.map((cartonMark, index) => ({ + relabelItems: cartonRelabelBindings.map((cartonMark, index) => ({ operationType: 'CARTON_AND_SKU', customerCartonMarks: [cartonMark], originalSku: `SKU-${marker}`.slice(0, 128), newSku: `NEW-${marker}-${index + 1}`.slice(0, 128), quantity: 1, @@ -278,9 +280,18 @@ try { status: transferDetail.status, relabelNo, }) + const reviewedRelabel = await api(overseas, admin.overseasAccessToken, 'PUT', + `/api/platform/v1/last-leg/relabel-applications/${relabelNo}/status`, { + status: 'PENDING_PROCESSING', remark: marker, + }) + assert(reviewedRelabel.application.status === 'PENDING_PROCESSING', + `Linked relabel review did not approve execution: ${reviewedRelabel.application.status}`) + step('Platform approved linked relabel for PDA execution', { status: reviewedRelabel.application.status }) + let relabel = await api(overseas, pda.token, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key('RELABEL-CLAIM') }) - assert(relabel.items.length === 2, `Expected two linked relabel items, got ${relabel.items.length}`) + assert(relabel.items.length === (partialRelabel ? 1 : 2), + `Expected ${partialRelabel ? 1 : 2} linked relabel items, got ${relabel.items.length}`) for (const [index, item] of relabel.items.entries()) { relabel = await api(overseas, pda.token, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, { @@ -330,7 +341,8 @@ try { transferDetail = await api(overseas, merchant.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`) assert(confirmedRelabel.application.status === 'READY_FOR_OUTBOUND', 'Linked relabel did not reach outbound-ready') - assert(transferDetail.status === 'READY_FOR_DISPATCH', 'Warehouse transfer did not return to dispatch-ready') + assert(transferDetail.status === (partialRelabel ? 'WAIT_FBA_DESTINATION' : 'READY_FOR_DISPATCH'), + `Warehouse transfer reached unexpected status ${transferDetail.status}`) step('Completed linked relabel and released transfer for dispatch', { relabelStatus: confirmedRelabel.application.status, transferStatus: transferDetail.status, }) @@ -339,17 +351,30 @@ try { `/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(relabelNo)}&sourceStage=RECEIVED`) const targetIds = new Set(targetPallets.map((value) => id(value.palletId))) const rows = candidates.filter((value) => value.sourceStage === 'RELABEL' && targetIds.has(id(value.warehousePalletId))) - assert(rows.length === 2, `Expected two smart-dispatch pallets, got ${rows.length}`) + assert(rows.length === (partialRelabel ? 1 : 2), + `Expected ${partialRelabel ? 1 : 2} smart-dispatch pallets, got ${rows.length}`) const dispatch = await api(overseas, admin.overseasAccessToken, 'POST', '/api/platform/v1/last-leg/dispatches/pre-arrange', { planPalletIds: rows.map((value) => id(value.warehousePalletId)), route: marker, relabelNo, }) step('Created smart pre-dispatch', { dispatchNo: dispatch.dispatchNo, status: dispatch.status }) + if (partialRelabel) { + const outboundCandidates = await api(overseas, admin.overseasAccessToken, 'GET', + `/api/platform/v1/last-leg/outbound-orders/warehouse-transfer/candidates?customerId=${encodeURIComponent(customerId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const remaining = outboundCandidates.filter((value) => value.transferNo === transferNo) + assert(remaining.length === 1 && Number(remaining[0].packageCount) === 1, + `Expected one unlabelled carton in warehouse-transfer outbound candidates, got ${JSON.stringify(remaining)}`) + step('Verified split outbound routing', { + relabelDispatchPallets: rows.length, remainingOutboundCandidates: remaining.length, + remainingPackageCount: remaining[0].packageCount, + }) + } + const sourceReport = resolve('reports', `${marker}-relabel-source.json`) const fulfillmentReport = resolve('reports', `${marker}-fulfillment.json`) await writeFile(sourceReport, JSON.stringify({ marker, passed: true, scenarios: [{ - name: 'SPLIT-TWO-TARGETS', relabelNo, dispatchNo: dispatch.dispatchNo, + name: 'SPLIT-TWO-TARGETS', relabelNo, dispatchNo: dispatch.dispatchNo, partialRelabel, }] }, null, 2), 'utf8') const child = spawnSync(process.execPath, [resolve('scripts', 'overseas-relabel-fulfillment-acceptance.mjs')], { cwd: resolve('.'), stdio: 'inherit', env: { @@ -366,11 +391,70 @@ try { }) if (child.error) throw child.error if (child.status !== 0) throw new Error(`Fulfillment acceptance failed with exit code ${child.status}`) - const finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET', + let finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`) + let remainingDispatchNo = null + let remainingFulfillmentReport = null + if (partialRelabel) { + assert(finalTransfer.status === 'WAIT_FBA_DESTINATION', + `Partial relabel dispatch incorrectly closed the transfer: ${finalTransfer.status}`) + const beforeBind = await api(overseas, admin.overseasAccessToken, 'GET', + `/api/platform/v1/last-leg/outbound-orders/warehouse-transfer/candidates?customerId=${encodeURIComponent(customerId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const remainingBeforeBind = beforeBind.filter((value) => value.transferNo === transferNo) + assert(remainingBeforeBind.length === 1 && Number(remainingBeforeBind[0].packageCount) === 1 + && remainingBeforeBind[0].palletId && remainingBeforeBind[0].warehousePalletId, + `Expected one selectable remaining pallet before destination binding, got ${JSON.stringify(remainingBeforeBind)}`) + await api(overseas, admin.overseasAccessToken, 'POST', + '/api/platform/v1/last-leg/warehouse-transfers/bind-destination', { + transfers: [{ customerId, transferNo }], destinationType: 'FBA', + destinationWarehouseCode: destinationCode, destinationWarehouseName: destinationCode, + idempotencyKey: key('BIND-REMAINING-DESTINATION'), + }) + const afterBind = await api(overseas, admin.overseasAccessToken, 'GET', + `/api/platform/v1/last-leg/outbound-orders/warehouse-transfer/candidates?customerId=${encodeURIComponent(customerId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + assert(!afterBind.some((value) => value.transferNo === transferNo), + 'Destination-bound transfer remained in the destination-completion outbound list') + const remainingCandidates = await api(overseas, admin.overseasAccessToken, 'GET', + `/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(transferNo)}&sourceStage=RECEIVED`) + const remainingRows = remainingCandidates.filter((value) => value.sourceStage === 'RECEIVED' + && Number(value.packageCount) === 1 && value.destinationWarehouseCode === destinationCode) + assert(remainingRows.length === 1, + `Expected one destination-bound smart-dispatch pallet, got ${JSON.stringify(remainingRows)}`) + const remainingDispatch = await api(overseas, admin.overseasAccessToken, 'POST', + '/api/platform/v1/last-leg/dispatches/pre-arrange', { + planPalletIds: remainingRows.map((value) => id(value.planPalletId)), route: `${marker}-REMAINING`, + }) + remainingDispatchNo = remainingDispatch.dispatchNo + const remainingSourceReport = resolve('reports', `${marker}-remaining-source.json`) + remainingFulfillmentReport = resolve('reports', `${marker}-remaining-fulfillment.json`) + await writeFile(remainingSourceReport, JSON.stringify({ marker, passed: true, scenarios: [{ + name: 'SPLIT-TWO-TARGETS', relabelNo: '', dispatchNo: remainingDispatchNo, partialRelabel: true, + }] }, null, 2), 'utf8') + const remainingChild = spawnSync(process.execPath, + [resolve('scripts', 'overseas-relabel-fulfillment-acceptance.mjs')], { + cwd: resolve('.'), stdio: 'inherit', env: { + ...process.env, ADMIN_TEST_GATEWAY: domestic, ADMIN_OVERSEAS_GATEWAY: overseas, + ADMIN_OVERSEAS_ACCESS_TOKEN: admin.overseasAccessToken, + MERCHANT_OVERSEAS_ACCESS_TOKEN: merchant.overseasAccessToken, + PDA_OVERSEAS_ACCESS_TOKEN: pda.token, OVERSEAS_TEST_CUSTOMER_ID: customerId, + OVERSEAS_ACCEPTANCE_SOURCE: remainingSourceReport, + OVERSEAS_FULFILLMENT_OUTPUT: remainingFulfillmentReport, + }, + }) + if (remainingChild.error) throw remainingChild.error + if (remainingChild.status !== 0) throw new Error(`Remaining fulfillment failed with exit code ${remainingChild.status}`) + finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET', + `/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`) + assert(finalTransfer.status === 'OUTBOUNDED', + `Remaining transfer did not complete outbound: ${finalTransfer.status}`) + step('Bound remaining carton destination and completed its smart dispatch', { + remainingDispatchNo, remainingPackageCount: 1, transferStatus: finalTransfer.status, + }) + } Object.assign(report, { passed: true, transferNo, relabelNo, dispatchNo: dispatch.dispatchNo, - transferStatus: finalTransfer.status, fulfillmentReport, finishedAt: new Date().toISOString(), + remainingDispatchNo, transferStatus: finalTransfer.status, fulfillmentReport, + remainingFulfillmentReport, finishedAt: new Date().toISOString(), }) } catch (error) { report.error = error instanceof Error ? error.message : String(error) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java index 2013ef02a..842a29aed 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java @@ -14,7 +14,6 @@ public final class LocationRequests { } public record SaveRequest( - @NotBlank @Size(max = 50) String zoneCode, @NotBlank @Size(max = 64) String locationCode, @NotBlank @Size(max = 32) String locationType, @NotNull @DecimalMin(value = "0.000", inclusive = false) BigDecimal maxLoadKg, @@ -26,7 +25,6 @@ public final class LocationRequests { public record QueryRequest( @Min(1) long page, @Min(1) @Max(200) long size, - String zoneCode, String locationType, String status, String keyword 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 index 571c15925..ba4762bc7 100644 --- 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 @@ -3,4 +3,4 @@ package com.baiyue.warehouse.dto; import java.math.BigDecimal; public record WarehouseLocationResolveResponse(long locationId, String locationCode, String locationType, - BigDecimal maxLoadKg, String status) { } + boolean sharedOverflow, BigDecimal maxLoadKg, String status) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java index 9027321f5..8b82e7694 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java @@ -17,9 +17,10 @@ public class WarehouseLocationEntity extends BaseEntity { private Long id; private Long customerId; private Long warehouseId; - private String zoneCode; private String locationCode; private String locationType; + /** A0 is a system-managed shared overflow location; normal locations are exclusive. */ + private Boolean sharedOverflow; private BigDecimal maxLoadKg; private String status; private String remark; diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java index 55213ec04..49c397c88 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java @@ -14,10 +14,10 @@ public interface WarehouseLocationMapper extends BaseMapper() + .eq(WarehouseLocationEntity::getCustomerId, WarehouseResourceService.PUBLIC_RESOURCE_SCOPE) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .notIn(WarehouseLocationEntity::getLocationType, "VIRTUAL", "SHARED_OVERFLOW") + .in(WarehouseLocationEntity::getStatus, "ACTIVE", "IDLE") + .orderByAsc(WarehouseLocationEntity::getLocationCode) + .last("LIMIT 1")); + } + + public WarehouseLocationEntity findSharedOverflowLocation(long warehouseId) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, 0L) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationCode, "A0") + .last("LIMIT 1")); + } + public WarehouseLocationEntity findLocationByCode(long customerId, long warehouseId, String locationCode) { return locations.selectOne(new LambdaQueryWrapper() .eq(WarehouseLocationEntity::getCustomerId, customerId) @@ -86,13 +106,11 @@ public class WarehouseResourceRepository { var wrapper = new LambdaQueryWrapper() .eq(WarehouseLocationEntity::getCustomerId, customerId) .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) - .eq(StringUtils.hasText(query.zoneCode()), WarehouseLocationEntity::getZoneCode, query.zoneCode()) .eq(StringUtils.hasText(query.locationType()), WarehouseLocationEntity::getLocationType, query.locationType()) .eq(StringUtils.hasText(query.status()), WarehouseLocationEntity::getStatus, query.status()) .and(StringUtils.hasText(query.keyword()), q -> q .like(WarehouseLocationEntity::getLocationCode, query.keyword()) - .or().like(WarehouseLocationEntity::getZoneCode, query.keyword())) - .orderByAsc(WarehouseLocationEntity::getZoneCode) + ) .orderByAsc(WarehouseLocationEntity::getLocationCode); return PageResult.from(locations.selectPage(new Page<>(query.page(), query.size()), wrapper)); } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java index 0672f1689..1f3c97c40 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java @@ -91,9 +91,11 @@ public class WarehousePalletBatchService { WarehousePalletEntity createdPallet = resources.reusablePublicPallet( normalized.warehouseId(), requested.palletCode()); if (createdPallet == null) { + var autoLocation = repository.findFirstAutoAssignableLocation(normalized.warehouseId()); + Long locationId = autoLocation == null ? null : autoLocation.getId(); Long palletId = resources.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, normalized.warehouseId(), new PalletRequests.SaveRequest(requested.palletCode(), "WOOD", - AUTO_PALLET_CAPACITY_CBM, AUTO_PALLET_UNIT_PRICE_USD, "IDLE", null, AUTO_PALLET_REMARK, + AUTO_PALLET_CAPACITY_CBM, AUTO_PALLET_UNIT_PRICE_USD, "IDLE", locationId, AUTO_PALLET_REMARK, requested.fbaWarehouseCode())); createdPallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, normalized.warehouseId(), palletId); diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java index cf00e7907..d07fd44b9 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java @@ -16,6 +16,7 @@ import jakarta.annotation.Resource; import java.time.LocalDateTime; import java.util.Objects; +import java.util.Set; @Service public class WarehouseResourceLockService { @@ -34,8 +35,12 @@ public class WarehouseResourceLockService { if (concurrent != null) return idempotentResult(concurrent, request); throw new BizException(ResultCode.NOT_FOUND, "Pallet or location does not belong to the tenant warehouse"); } - boolean virtualLocation = isVirtual(location); - boolean locationAvailable = virtualLocation ? "ACTIVE".equals(location.getStatus()) : "IDLE".equals(location.getStatus()); + boolean nonExclusiveLocation = isNonExclusive(location); + // ACTIVE is the UI-facing enabled/unused state; IDLE is retained for + // locations created by the older lifecycle. + boolean locationAvailable = nonExclusiveLocation + ? "ACTIVE".equals(location.getStatus()) + : Set.of("ACTIVE", "IDLE").contains(location.getStatus()); if (!"IDLE".equals(pallet.getStatus()) || !locationAvailable) { WarehouseResourceLockEntity concurrent = repository.findByReservationKey(request.customerId(), request.reservationKey()); @@ -45,7 +50,7 @@ public class WarehouseResourceLockService { if (repository.occupyPallet(request.customerId(), request.warehouseId(), request.palletId(), request.locationId()) != 1) { return concurrentIdempotentOrConflict(request); } - if (!virtualLocation && repository.occupyLocation(request.customerId(), request.warehouseId(), request.locationId()) != 1) { + if (!nonExclusiveLocation && repository.occupyLocation(request.customerId(), request.warehouseId(), request.locationId()) != 1) { throw new BizException(ResultCode.CONFLICT, "Location is no longer IDLE"); } @@ -68,9 +73,9 @@ public class WarehouseResourceLockService { WarehouseResourceLockEntity lock = requireLock(customerId, lockId); if ("RELEASED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock); requireActive(lock); - boolean virtualLocation = isVirtualLockLocation(lock); - int palletResult = virtualLocation ? repository.releasePalletAtVirtualLocation(lock) : repository.releasePallet(lock); - if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1) + boolean nonExclusiveLocation = isNonExclusiveLockLocation(lock); + int palletResult = nonExclusiveLocation ? repository.releasePalletAtVirtualLocation(lock) : repository.releasePallet(lock); + if (palletResult != 1 || (!nonExclusiveLocation && repository.releaseLocation(lock) != 1) || repository.close(lock, "RELEASED") != 1) { throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently"); } @@ -84,9 +89,9 @@ public class WarehouseResourceLockService { WarehouseResourceLockEntity lock = requireLock(customerId, lockId); if ("SCRAPPED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock); requireActive(lock); - boolean virtualLocation = isVirtualLockLocation(lock); - int palletResult = virtualLocation ? repository.scrapPalletAtVirtualLocation(lock) : repository.scrapPallet(lock); - if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1) + boolean nonExclusiveLocation = isNonExclusiveLockLocation(lock); + int palletResult = nonExclusiveLocation ? repository.scrapPalletAtVirtualLocation(lock) : repository.scrapPallet(lock); + if (palletResult != 1 || (!nonExclusiveLocation && repository.releaseLocation(lock) != 1) || repository.close(lock, "SCRAPPED") != 1) { throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently"); } @@ -150,13 +155,17 @@ public class WarehouseResourceLockService { } - private boolean isVirtualLockLocation(WarehouseResourceLockEntity lock) { + private boolean isNonExclusiveLockLocation(WarehouseResourceLockEntity lock) { WarehouseLocationEntity location = repository.findLocation(lock.getCustomerId(), lock.getWarehouseId(), lock.getLocationId()); if (location == null) throw new BizException(ResultCode.NOT_FOUND, "Resource lock location not found"); - return isVirtual(location); + return isNonExclusive(location); } private static boolean isVirtual(WarehouseLocationEntity location) { return "VIRTUAL".equals(location.getLocationType()); } + + private static boolean isNonExclusive(WarehouseLocationEntity location) { + return isVirtual(location) || WarehouseResourceService.isSharedOverflow(location); + } } 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 2c6741aed..024636e81 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 @@ -25,6 +25,7 @@ import org.springframework.util.StringUtils; @Service public class WarehouseResourceService { public static final long PUBLIC_RESOURCE_SCOPE = 0L; + public static final String SHARED_OVERFLOW_LOCATION_CODE = "A0"; private static final Set LOCATION_STATUSES = Set.of("ACTIVE", "IDLE", "OCCUPIED", "INACTIVE"); private static final Set PALLET_STATUSES = Set.of("IDLE", "OCCUPIED", "SCRAPPED"); @@ -43,6 +44,9 @@ public class WarehouseResourceService { @Transactional public void updateLocation(long customerId, long warehouseId, long id, LocationRequests.SaveRequest request) { WarehouseLocationEntity existing = requireLocation(customerId, warehouseId, id); + if (isSharedOverflow(existing)) { + throw new BizException(ResultCode.CONFLICT, "系统共享溢出库位 A0 不允许编辑"); + } if ("VIRTUAL".equals(existing.getLocationType()) && (!"VIRTUAL".equals(request.locationType()) || !"ACTIVE".equals(request.status()) || !existing.getLocationCode().equals(request.locationCode()))) { @@ -62,12 +66,16 @@ public class WarehouseResourceService { public PageResult locations(long customerId, long warehouseId, LocationRequests.QueryRequest query) { requireWarehouse(customerId, warehouseId); + if (customerId == PUBLIC_RESOURCE_SCOPE) ensureSharedOverflowLocation(warehouseId); return repository.pageLocations(customerId, warehouseId, query); } @Transactional public void deleteLocation(long customerId, long warehouseId, long id) { WarehouseLocationEntity location = requireLocation(customerId, warehouseId, id); + if (isSharedOverflow(location)) { + throw new BizException(ResultCode.CONFLICT, "系统共享溢出库位 A0 不允许删除"); + } if ("VIRTUAL".equals(location.getLocationType())) { throw new BizException(ResultCode.CONFLICT, "Default virtual location cannot be deleted"); } @@ -177,7 +185,6 @@ public class WarehouseResourceService { location.setId(IdWorker.getId()); location.setCustomerId(customerId); location.setWarehouseId(warehouseId); - location.setZoneCode("DEFAULT"); location.setLocationCode(locationCode); location.setLocationType("VIRTUAL"); location.setMaxLoadKg(new java.math.BigDecimal("999999999.000")); @@ -192,6 +199,39 @@ public class WarehouseResourceService { return resolved; } + @Transactional + public WarehouseLocationEntity ensureSharedOverflowLocation(long warehouseId) { + SharedWarehouseReference warehouse = requireWarehouseReference(warehouseId); + WarehouseLocationEntity existing = repository.findSharedOverflowLocation(warehouseId); + if (existing != null) { + if (!isSharedOverflow(existing)) { + throw new BizException(ResultCode.CONFLICT, "A0库位编码已被非共享溢出库位占用"); + } + return existing; + } + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(IdWorker.getId()); + location.setCustomerId(PUBLIC_RESOURCE_SCOPE); + location.setWarehouseId(warehouseId); + location.setLocationCode(SHARED_OVERFLOW_LOCATION_CODE); + location.setLocationType("SHARED_OVERFLOW"); + location.setSharedOverflow(true); + location.setMaxLoadKg(new java.math.BigDecimal("999999999.000")); + location.setStatus("ACTIVE"); + location.setRemark("系统共享溢出库位,不得停用或删除"); + try { + repository.insertLocation(location); + } catch (org.springframework.dao.DuplicateKeyException ignored) { + // Concurrent first use: read the row created by the winner. + } + WarehouseLocationEntity resolved = repository.findLocationByCode(PUBLIC_RESOURCE_SCOPE, warehouseId, + SHARED_OVERFLOW_LOCATION_CODE); + if (resolved == null || !isSharedOverflow(resolved)) { + throw new BizException(ResultCode.CONFLICT, "共享溢出库位 A0 创建失败"); + } + return resolved; + } + @Transactional public void updatePallet(long customerId, long warehouseId, long id, PalletRequests.SaveRequest request) { WarehousePalletEntity existing = requirePallet(customerId, warehouseId, id); @@ -244,6 +284,9 @@ public class WarehouseResourceService { public WarehouseLocationResolveResponse resolvePublicLocation(long warehouseId, String locationCode) { if (!StringUtils.hasText(locationCode)) throw new BizException(ResultCode.BAD_REQUEST, "库位码不能为空"); + if (SHARED_OVERFLOW_LOCATION_CODE.equalsIgnoreCase(locationCode.trim())) { + return locationReference(ensureSharedOverflowLocation(warehouseId)); + } WarehouseLocationEntity location = repository.findLocationByCode(PUBLIC_RESOURCE_SCOPE, warehouseId, locationCode.trim()); if (location == null) throw new BizException(ResultCode.NOT_FOUND, "库位码不存在或不属于当前仓库"); @@ -258,7 +301,7 @@ public class WarehouseResourceService { private static WarehouseLocationResolveResponse locationReference(WarehouseLocationEntity location) { return new WarehouseLocationResolveResponse(location.getId(), location.getLocationCode(), - location.getLocationType(), location.getMaxLoadKg(), location.getStatus()); + location.getLocationType(), isSharedOverflow(location), location.getMaxLoadKg(), location.getStatus()); } private WarehousePalletResolveResponse palletReference(WarehousePalletEntity pallet) { @@ -345,6 +388,10 @@ public class WarehouseResourceService { private void validateLocation(LocationRequests.SaveRequest request, long customerId, long warehouseId, Long excludedId) { + if (SHARED_OVERFLOW_LOCATION_CODE.equalsIgnoreCase(request.locationCode()) + || "SHARED_OVERFLOW".equals(request.locationType())) { + throw new BizException(ResultCode.CONFLICT, "A0 是系统共享溢出库位,只能由系统按仓库懒创建"); + } if (!LOCATION_STATUSES.contains(request.status())) { throw new BizException("库位状态仅支持 ACTIVE、IDLE、OCCUPIED、INACTIVE"); } @@ -375,7 +422,6 @@ public class WarehouseResourceService { WarehouseLocationEntity entity = new WarehouseLocationEntity(); entity.setCustomerId(customerId); entity.setWarehouseId(warehouseId); - entity.setZoneCode(request.zoneCode()); entity.setLocationCode(request.locationCode()); entity.setLocationType(request.locationType()); entity.setMaxLoadKg(request.maxLoadKg()); @@ -412,4 +458,10 @@ public class WarehouseResourceService { private static String defaultLocationCode(String warehouseCode) { return "LOC-" + warehouseCode + "-DEFAULT"; } + + public static boolean isSharedOverflow(WarehouseLocationEntity location) { + return location != null && Boolean.TRUE.equals(location.getSharedOverflow()) + && "SHARED_OVERFLOW".equals(location.getLocationType()) + && SHARED_OVERFLOW_LOCATION_CODE.equalsIgnoreCase(location.getLocationCode()); + } } diff --git a/warehouse-overseas-service/src/main/resources/db/warehouse.sql b/warehouse-overseas-service/src/main/resources/db/warehouse.sql index fa42e96cb..f406d6f29 100644 --- a/warehouse-overseas-service/src/main/resources/db/warehouse.sql +++ b/warehouse-overseas-service/src/main/resources/db/warehouse.sql @@ -9,9 +9,9 @@ CREATE TABLE IF NOT EXISTS wh_location ( id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', - zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否系统共享溢出库位(A0)', max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', remark VARCHAR(500) DEFAULT NULL COMMENT '备注', @@ -23,9 +23,17 @@ CREATE TABLE IF NOT EXISTS wh_location ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_location_code (customer_id, warehouse_id, location_code, deleted), - KEY idx_location_query (customer_id, warehouse_id, zone_code, status, deleted) + KEY idx_location_query (customer_id, warehouse_id, status, deleted) ) ENGINE=InnoDB COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; +-- Remove the retired zone concept from installations created by the previous schema. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_location' AND index_name='idx_location_query'), 'ALTER TABLE wh_location DROP INDEX idx_location_query', 'SELECT 1')); +PREPARE location_zone_index_stmt FROM @ddl; EXECUTE location_zone_index_stmt; DEALLOCATE PREPARE location_zone_index_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='zone_code'), 'ALTER TABLE wh_location DROP COLUMN zone_code', 'SELECT 1')); +PREPARE location_zone_column_stmt FROM @ddl; EXECUTE location_zone_column_stmt; DEALLOCATE PREPARE location_zone_column_stmt; + + + CREATE TABLE IF NOT EXISTS wh_pallet ( id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', @@ -58,6 +66,14 @@ PREPARE pallet_fba_warehouse_stmt FROM @ddl; EXECUTE pallet_fba_warehouse_stmt; DEALLOCATE PREPARE pallet_fba_warehouse_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='wh_location' AND column_name='shared_overflow'), + 'SELECT 1', + 'ALTER TABLE wh_location ADD COLUMN shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否系统共享溢出库位(A0)'' AFTER location_type')); +PREPARE location_shared_overflow_stmt FROM @ddl; +EXECUTE location_shared_overflow_stmt; +DEALLOCATE PREPARE location_shared_overflow_stmt; + CREATE TABLE IF NOT EXISTS wh_resource_lock ( id BIGINT UNSIGNED NOT NULL COMMENT '主键ID', customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户租户ID', @@ -88,9 +104,9 @@ ALTER TABLE wh_location MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', - MODIFY COLUMN zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', MODIFY COLUMN location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + MODIFY COLUMN shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否系统共享溢出库位(A0)', MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', 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 d89161f7c..fa6f46a7b 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 @@ -243,7 +243,7 @@ class WarehouseResourceServiceTest { } private static LocationRequests.SaveRequest locationRequest(String code) { - return new LocationRequests.SaveRequest("A", code, "STORAGE", + return new LocationRequests.SaveRequest(code, "STORAGE", new BigDecimal("1000.000"), "ACTIVE", null); }