Merge pull request 'Test' (#17) from test into main

Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-09-28 16:00:14 +08:00
283 changed files with 10967 additions and 650 deletions
+16 -9
View File
@@ -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 或权限契约的改动。
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -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 <name>`,模块名称和静态合同先查 `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` 中已确认的范围、对象、状态和接口约束
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
全量验收需要证据时再读取对应阶段报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
@@ -22,11 +22,19 @@ public class AuthorizationSnapshot {
private Set<String> permissions;
private Long permissionVersion;
private Long overseasWarehouseId;
private Long domesticWarehouseId;
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> 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<String> roles, Set<String> permissions,
Long permissionVersion, Long overseasWarehouseId) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, overseasWarehouseId, null);
}
}
@@ -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();
+1 -1
View File
@@ -18,7 +18,7 @@
## 修改与验证
- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。
- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约。当前正式模型不保留旧序列化或数据库兼容分支;若已有不可变外部契约,先单独定义升级规则。
- `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。
- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 新能力只有被多个模块稳定复用时才放入本模块。
+1 -1
View File
@@ -29,7 +29,7 @@
## 修改与验证
- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。
- 所有字段与事件类型都视为跨仓公共契约;当前正式模型不保留旧事件字段、旧版本或兼容解析,契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。若存在不可变外部事件契约,先单独定义版本升级规则。
- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。
- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。
- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。
+1 -1
View File
@@ -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 序列化保持一致。
+1 -1
View File
@@ -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` |
+17
View File
@@ -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}
+2
View File
@@ -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:
+16
View File
@@ -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}
@@ -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
+20
View File
@@ -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"]
@@ -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"
+84
View File
@@ -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`。
+12
View File
@@ -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."
+6 -1
View File
@@ -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
+2 -4
View File
@@ -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" \
+1 -1
View File
@@ -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
+10 -7
View File
@@ -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>/`,一个文件只允许修改一个 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
@@ -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 '价格生效开始快照;人工报价为空';
@@ -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='正式申报后的海关结果不可变事实';
@@ -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='船期计划与已放舱订舱的最小关联及历史';
@@ -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');
@@ -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;
@@ -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;
@@ -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;
@@ -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;
+422 -15
View File
@@ -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;
+17
View File
@@ -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` 自动编号及发布标签归档;
- 真实测试环境部署与生产晋级。
因此现在可以开始编写和评审迁移文件,但不能把离线检查通过当作测试或生产发布通过。
+11 -8
View File
@@ -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 (
+50
View File
@@ -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 <dev-user>@<dev-server>
```
## 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 时,应判定为需要人工登录。
+210
View File
@@ -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、账号、密码或验证码。
+1 -1
View File
@@ -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` |
@@ -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);
@@ -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,
@@ -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:
@@ -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")
@@ -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);
@@ -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();
+2 -1
View File
@@ -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 通过内部接口读取或消费快照,不跨库查询,也不复制上游业务状态机。
## 快速查询
+2 -2
View File
@@ -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 小时计算,实际报空时间可为空。
@@ -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<Operator> required(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long authUserId,
@RequestParam long warehouseId);
record Operator(Long userId, String displayName) { }
}
@@ -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<String> warehouses = new ArrayList<>(List.of("US01", "US02"));
}
@@ -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<UploadResponse> receiveMojia(
@RequestHeader("X-App-Key") String appKey,
@@ -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<FeeSnapshotResponse> 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')")
@@ -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<PreloadConfirmationResponse> confirmPreload(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId,
@@ -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<List<Operation>> 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");
}
}
@@ -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<PageResult<MojiaSkuSyncDtos.Record>> 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<MojiaSkuSyncDtos.Config> 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<LingxingTestDtos.Response> 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<String> 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<MojiaSkuSyncDtos.Record> 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<MojiaSkuSyncDtos.Record> 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, "领星需要在浏览器中完成安全验证");
}
}
}
@@ -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<List<ClearanceResultView>> 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<DocumentView> 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<List<ReceiptActionView>> 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<byte[]> 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<byte[]> downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
public ResponseEntity<byte[]> 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<byte[]> 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<ContainerView> 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<ContainerView> 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<byte[]> response(Download value) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
@@ -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<List<DeclarationBatchView>> 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<DeclarationBatchView> 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<DeclarationBatchView> 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<List<ClearanceResultView>> 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<ClearanceResultView> 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<ClearanceResultView> 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<ContainerView> 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<ContainerView> 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<DocumentView> 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<DocumentView> 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<List<ReceiptActionView>> 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<byte[]> 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<byte[]> downloadFinal(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
FirstlegScopeGuard.platform(user);
return response(service.downloadFinal(0,documentId,true));
}
private static ResponseEntity<byte[]> response(Download value) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
@@ -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<Long> inboundIds, List<String> inboundCodes,
boolean frozen, FileView completion) {}
boolean frozen, FileView receipt, String receiptStatus,
String receiptRejectReason, FileView finalDocument,
List<ReceiptVersionView> receiptHistory,
List<ReceiptActionView> 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<InboundView> inbounds, List<DocumentView> 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<ClearanceResultView> 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) {}
}
@@ -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<InboundRow> 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<DeclarationBatchRow> 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<ClearanceResultRow> 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<Long> 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<CompletionVersionRow> 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("""
<script>
SELECT h.id id,h.receipt_version receiptVersion,h.action action,h.reason reason,
h.operator_name operatorName,h.created_at createdAt
FROM fl_customs_completion_action_history h
JOIN fl_customs_completion_document r ON r.id=h.completion_document_id
JOIN fl_customs_customer_document d ON d.id=r.source_document_id
WHERE d.id=#{documentId} AND d.status='ACTIVE'
<if test="tenantId != null">AND d.tenant_id=#{tenantId}</if>
ORDER BY h.created_at,h.id
</script>
""")
List<ReceiptActionRow> 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,
@@ -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<DocumentRow> 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<DeclarationBatchView> 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<ClearanceResultView> 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<ClearanceResultView> 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<Map<String,Object>> snapshot = mapper.documents(containerId, null).stream().map(doc -> {
CompletionRow receipt = mapper.completion(doc.id());
FinalRow finalDocument = mapper.finalDocument(doc.id());
return Map.<String,Object>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<Map<String,Object>> snapshot = mapper.documents(containerId, null).stream().map(document -> {
List<ScopeRow> scopes = mapper.frozenScopes(document.id());
return Map.<String,Object>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<ReceiptActionView> 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<ReceiptActionView> 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<DocumentView> 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<ScopeRow> 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<ReceiptVersionView> 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<ScopeRow> 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<InboundRow> 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("无法读取上传文件"); } }
@@ -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,
@@ -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<WarehouseResult> warehouses) {}
}
@@ -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(
@@ -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
@@ -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) {}
}
@@ -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;
@@ -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<String, LingxingAuthState> cache = new ConcurrentHashMap<>();
private final Map<String, CompletableFuture<LingxingAuthState>> refreshing = new ConcurrentHashMap<>();
private final Map<String, Long> cooldownUntil = new ConcurrentHashMap<>();
private final Map<String, LingxingProviderConfig> pendingVerification = new ConcurrentHashMap<>();
private volatile Function<LingxingProviderConfig, LingxingAuthState> 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<LingxingAuthState> getAuthState(LingxingProviderConfig config, String loginOrigin) {
LingxingAuthState cached = cache.get(config.providerKey());
if (cached != null && cached.usable(clock.millis())) return CompletableFuture.completedFuture(cached);
CompletableFuture<LingxingAuthState> current = refreshing.get(config.providerKey());
if (current != null) return current;
CompletableFuture<LingxingAuthState> created = CompletableFuture.supplyAsync(
() -> refresh(config, loginOrigin), executor);
CompletableFuture<LingxingAuthState> 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<LingxingProviderConfig, LingxingAuthState> 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<LingxingProviderConfig, LingxingAuthState> 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;
}
}
@@ -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;
}
}
@@ -0,0 +1,6 @@
package com.baiyue.firstleg.lingxing;
/** 需要人工完成浏览器安全验证(例如腾讯滑块)时的可识别错误。 */
public class LingxingBrowserChallengeException extends RuntimeException {
public LingxingBrowserChallengeException(String message) { super(message); }
}
@@ -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<String> 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<Long, CompletableFuture<ObjectNode>> pending = new ConcurrentHashMap<>();
private final StringBuilder textBuffer = new StringBuilder();
private final CompletableFuture<WebSocket> 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<ObjectNode> command(String method, ObjectNode params) {
return connected.thenCompose(ws -> {
long id = ids.incrementAndGet(); CompletableFuture<ObjectNode> 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<ObjectNode> 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)); }
}
}
@@ -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<LingxingTestDtos.WarehouseResult> 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<PushWarehouseResult> 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<String> 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<LingxingAuthState> 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<String> 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<String> 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<PushWarehouseResult> 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<String, JsonNode> 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; }
}
@@ -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()) + "]";
}
}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.lingxing;
public class LingxingSecondaryVerificationException extends RuntimeException {
public LingxingSecondaryVerificationException(String message) { super(message); }
}
@@ -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<PreloadLoadingPlanL
@Select(LINK_SELECT + " WHERE link.loading_plan_id=#{loadingPlanId}"
+ " ORDER BY link.tenant_id,link.inbound_id,link.id")
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
List<Link> byLoadingPlan(@Param("loadingPlanId") long loadingPlanId);
String LINK_SELECT = """
@@ -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
@@ -108,7 +108,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
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}
<if test="keyword != null and keyword != ''">
AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%')
OR booking.warehouse_name LIKE CONCAT('%', #{keyword}, '%'))
@@ -13,12 +13,47 @@ public interface UnifiedInboundTransportMapper {
@Select("""
<script>
SELECT DISTINCT i.inbound_id, i.account_id, p.container_booking_id,
b.container_no, b.cabinet_no, b.vessel_name, b.voyage_no,
b.estimated_sailing_at, b.actual_sailing_at,
b.estimated_port_arrival_at, b.actual_port_arrival_at
b.container_no, b.cabinet_no,
COALESCE(schedule.vessel_name, b.vessel_name) AS vessel_name,
COALESCE(schedule.voyage_no, b.voyage_no) AS voyage_no,
COALESCE(schedule.etd_at, b.estimated_sailing_at) AS estimated_sailing_at,
b.actual_sailing_at,
COALESCE(schedule.eta_at, b.estimated_port_arrival_at) AS estimated_port_arrival_at,
b.actual_port_arrival_at
FROM fl_loading_plan_item i
JOIN fl_loading_plan p ON p.id = i.loading_plan_id
JOIN fl_container_booking b ON b.id = p.container_booking_id
LEFT JOIN (
SELECT ranked.customer_id,
ranked.ocean_booking_id,
ranked.container_booking_id,
ranked.vessel_name,
ranked.voyage_no,
ranked.etd_at,
ranked.eta_at
FROM (
SELECT candidate.customer_id,
candidate.ocean_booking_id,
candidate.container_booking_id,
candidate.vessel_name,
candidate.voyage_no,
candidate.etd_at,
candidate.eta_at,
ROW_NUMBER() OVER (
PARTITION BY candidate.customer_id,
candidate.ocean_booking_id,
candidate.container_booking_id
ORDER BY candidate.observed_at DESC,
candidate.updated_at DESC,
candidate.id DESC
) AS schedule_rank
FROM fl_port_schedule candidate
) ranked
WHERE ranked.schedule_rank = 1
) schedule
ON schedule.customer_id = i.account_id
AND schedule.ocean_booking_id = b.ocean_booking_id
AND schedule.container_booking_id = b.id
WHERE i.deleted = 0
AND p.plan_status IN ('LOADING', 'COMPLETED')
AND i.inbound_id IN
@@ -6,19 +6,22 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
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 jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@Repository
public class ContainerRepository {
@Resource private ContainerBookingMapper mapper;
@Resource private PortScheduleMapper schedules;
public PageResult<ContainerBookingEntity> page(String workbench, Query query) {
Page<ContainerBookingEntity> result = mapper.selectBookingPage(
new Page<>(query.page(), query.size()), query, workbench);
result.getRecords().forEach(this::resolveLoadingTime);
result.getRecords().forEach(this::resolveReadModel);
return PageResult.from(result);
}
public ContainerBookingEntity find(long id) { return resolveLoadingTime(mapper.selectBooking(id)); }
public ContainerBookingEntity find(long id) { return resolveReadModel(mapper.selectBooking(id)); }
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; }
@@ -62,6 +65,21 @@ public class ContainerRepository {
estimatedSailingAt, operatorId, operatorName);
}
private ContainerBookingEntity resolveReadModel(ContainerBookingEntity value) {
resolveCurrentPortSchedule(value);
return resolveLoadingTime(value);
}
private void resolveCurrentPortSchedule(ContainerBookingEntity value) {
if (value == null || value.getCustomerId() == null || value.getOceanBookingId() == null) return;
PortScheduleEntity schedule = schedules.latestForDispatch(value.getCustomerId(), value.getOceanBookingId(), value.getId());
if (schedule == null) return;
value.setVesselName(schedule.getVesselName());
value.setVoyageNo(schedule.getVoyageNo());
value.setEstimatedSailingAt(schedule.getEtdAt());
value.setEstimatedPortArrivalAt(schedule.getEtaAt());
}
private ContainerBookingEntity resolveLoadingTime(ContainerBookingEntity value) {
if (value != null && value.getLoadingAt() == null
&& "COMPLETED".equals(value.getPlanStatus())) {
@@ -0,0 +1,115 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.entity.MojiaMeasurementReceiptEntity;
import com.baiyue.firstleg.mapper.MojiaMeasurementReceiptMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
@Repository
public class MojiaSkuSyncRepository {
@Resource private MojiaMeasurementReceiptMapper mapper;
/** 保留仓储层旧测试/内部调用的四参数入口,统一查询改为全部上传记录。 */
public PageResult<MojiaMeasurementReceiptEntity> page(String code, String syncStatus,
long page, long size) {
return page(code, null, syncStatus, page, size);
}
public PageResult<MojiaMeasurementReceiptEntity> page(String code, String measurementKind, String syncStatus,
long page, long size) {
String normalizedCode = StringUtils.hasText(code) ? code.trim() : null;
return PageResult.from(mapper.selectPage(new Page<>(page, size),
new LambdaQueryWrapper<MojiaMeasurementReceiptEntity>()
.eq(StringUtils.hasText(measurementKind), MojiaMeasurementReceiptEntity::getMeasurementKind, measurementKind)
.eq(StringUtils.hasText(syncStatus), MojiaMeasurementReceiptEntity::getSyncStatus, syncStatus)
.and(normalizedCode != null, wrapper -> wrapper
.like(MojiaMeasurementReceiptEntity::getBoxCode, normalizedCode)
.or().like(MojiaMeasurementReceiptEntity::getInboundCode, normalizedCode))
.orderByDesc(MojiaMeasurementReceiptEntity::getMeasuredAt)
.orderByDesc(MojiaMeasurementReceiptEntity::getId)));
}
public MojiaMeasurementReceiptEntity find(long id) {
return mapper.selectOne(new LambdaQueryWrapper<MojiaMeasurementReceiptEntity>()
.eq(MojiaMeasurementReceiptEntity::getId, id)
.eq(MojiaMeasurementReceiptEntity::getMeasurementKind, "SKU"));
}
public int updateMeasurement(MojiaMeasurementReceiptEntity value, int revision) {
return mapper.update(null, new LambdaUpdateWrapper<MojiaMeasurementReceiptEntity>()
.eq(MojiaMeasurementReceiptEntity::getId, value.getId())
.eq(MojiaMeasurementReceiptEntity::getMeasurementKind, "SKU")
.eq(MojiaMeasurementReceiptEntity::getRevision, revision)
.set(MojiaMeasurementReceiptEntity::getBoxCode, value.getBoxCode())
.set(MojiaMeasurementReceiptEntity::getLengthCm, value.getLengthCm())
.set(MojiaMeasurementReceiptEntity::getWidthCm, value.getWidthCm())
.set(MojiaMeasurementReceiptEntity::getHeightCm, value.getHeightCm())
.set(MojiaMeasurementReceiptEntity::getWeightKg, value.getWeightKg())
.set(MojiaMeasurementReceiptEntity::getSyncStatus, "PENDING")
.set(MojiaMeasurementReceiptEntity::getSyncFailureCode, null)
.set(MojiaMeasurementReceiptEntity::getSyncFailureReason, null)
.set(MojiaMeasurementReceiptEntity::getStatus, "SKU_PENDING_SYNC")
.set(MojiaMeasurementReceiptEntity::getRevision, revision + 1));
}
public int retry(long id, int revision) {
return mapper.update(null, new LambdaUpdateWrapper<MojiaMeasurementReceiptEntity>()
.eq(MojiaMeasurementReceiptEntity::getId, id)
.eq(MojiaMeasurementReceiptEntity::getMeasurementKind, "SKU")
.eq(MojiaMeasurementReceiptEntity::getRevision, revision)
.in(MojiaMeasurementReceiptEntity::getSyncStatus, "PENDING", "FAILED")
.set(MojiaMeasurementReceiptEntity::getSyncStatus, "PENDING")
.set(MojiaMeasurementReceiptEntity::getSyncFailureCode, null)
.set(MojiaMeasurementReceiptEntity::getSyncFailureReason, null)
.set(MojiaMeasurementReceiptEntity::getStatus, "SKU_PENDING_SYNC")
.set(MojiaMeasurementReceiptEntity::getRevision, revision + 1));
}
/**
* A secondary-verification challenge happens before the Lingxing request is sent.
* Release the optimistic claim so the original UI action can resume with its
* original revision after the code is submitted.
*/
public int restorePendingClaim(long id, int claimedRevision) {
return mapper.update(null, new LambdaUpdateWrapper<MojiaMeasurementReceiptEntity>()
.eq(MojiaMeasurementReceiptEntity::getId, id)
.eq(MojiaMeasurementReceiptEntity::getMeasurementKind, "SKU")
.eq(MojiaMeasurementReceiptEntity::getRevision, claimedRevision)
.eq(MojiaMeasurementReceiptEntity::getSyncStatus, "PENDING")
.set(MojiaMeasurementReceiptEntity::getSyncStatus, "PENDING")
.set(MojiaMeasurementReceiptEntity::getSyncFailureCode, null)
.set(MojiaMeasurementReceiptEntity::getSyncFailureReason, null)
.set(MojiaMeasurementReceiptEntity::getStatus, "SKU_PENDING_SYNC")
.set(MojiaMeasurementReceiptEntity::getRevision, claimedRevision - 1));
}
public int markSuccess(long id, int revision, String lingxingSkuId) {
return mapper.update(null, new LambdaUpdateWrapper<MojiaMeasurementReceiptEntity>()
.eq(MojiaMeasurementReceiptEntity::getId, id)
.eq(MojiaMeasurementReceiptEntity::getMeasurementKind, "SKU")
.eq(MojiaMeasurementReceiptEntity::getRevision, revision)
.set(MojiaMeasurementReceiptEntity::getSyncStatus, "SUCCESS")
.set(MojiaMeasurementReceiptEntity::getSyncFailureCode, null)
.set(MojiaMeasurementReceiptEntity::getSyncFailureReason, null)
.set(MojiaMeasurementReceiptEntity::getLingxingSkuId, lingxingSkuId)
.set(MojiaMeasurementReceiptEntity::getStatus, "SKU_SYNCED")
.set(MojiaMeasurementReceiptEntity::getRevision, revision + 1));
}
public int markFailed(long id, int revision, String code, String reason) {
return mapper.update(null, new LambdaUpdateWrapper<MojiaMeasurementReceiptEntity>()
.eq(MojiaMeasurementReceiptEntity::getId, id)
.eq(MojiaMeasurementReceiptEntity::getMeasurementKind, "SKU")
.eq(MojiaMeasurementReceiptEntity::getRevision, revision)
.set(MojiaMeasurementReceiptEntity::getSyncStatus, "FAILED")
.set(MojiaMeasurementReceiptEntity::getSyncFailureCode, code)
.set(MojiaMeasurementReceiptEntity::getSyncFailureReason, reason)
.set(MojiaMeasurementReceiptEntity::getStatus, "SKU_SYNC_FAILED")
.set(MojiaMeasurementReceiptEntity::getRevision, revision + 1));
}
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.sailingcalendar.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ExceptionPage;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ExceptionQuery;
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@Tag(name = "船期异常货量")
@RequestMapping("/api/platform/v1/sailing-assignments")
public class PlatformSailingAssignmentController {
@Resource private SailingCalendarService service;
@GetMapping("/exceptions")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
@Operation(summary = "查询未安排和晚到货量")
public ApiResponse<ExceptionPage> exceptions(@AuthenticationPrincipal AuthenticatedUser user, @Valid ExceptionQuery query) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.exceptions(query));
}
}
@@ -0,0 +1,104 @@
package com.baiyue.firstleg.sailingcalendar.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
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.Positive;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
@Validated
@RestController
@Tag(name = "船期日历")
@RequestMapping("/api/platform/v1/sailings")
public class PlatformSailingCalendarController {
@Resource private SailingCalendarService service;
@GetMapping("/calendar")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
@Operation(summary = "查询船期月历")
public ApiResponse<CalendarResult> calendar(@AuthenticationPrincipal AuthenticatedUser user, @Valid SailingQuery query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.calendar(query));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
public ApiResponse<SailingDetail> detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id));
}
@GetMapping("/{id}/history")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
@Operation(summary = "查询船期变更历史")
public ApiResponse<List<SailingHistoryView>> history(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.history(id));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:create')")
public ApiResponse<SailingDetail> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody SailingCreateRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(service.createSailing(user.userId(), user.username(), request).getId()));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:edit')")
public ApiResponse<SailingDetail> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@Valid @RequestBody SailingUpdateRequest request) {
FirstlegScopeGuard.platform(user); service.updateSailing(id, user.userId(), user.username(), request); return ApiResponse.success(service.detail(id));
}
@PostMapping("/{id}/cancel")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:edit')")
public ApiResponse<SailingDetail> cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@Valid @RequestBody CancelRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.cancel(id, user.userId(), user.username(), request));
}
@PostMapping("/{id}/exception-reassign")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:exception-reassign')")
public ApiResponse<ExceptionReassignResult> exceptionReassign(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@Valid @RequestBody ExceptionReassignRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.exceptionReassign(id, user.userId(), user.username(), request));
}
@PostMapping("/{id}/copy")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:create')")
public ApiResponse<SailingDetail> copy(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@Valid @RequestBody CopyRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.copy(id, user.userId(), user.username(), request));
}
@GetMapping("/{id}/volume-summary")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
public ApiResponse<VolumeDetail> volumeSummary(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.volumeSummary(id));
}
@GetMapping("/{id}/booking-advice")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
public ApiResponse<BookingAdvice> bookingAdvice(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.bookingAdvice(id));
}
@PostMapping("/{id}/booking-links")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-booking:link')")
public ApiResponse<java.util.Map<String, Object>> bookingLink(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@Valid @RequestBody BookingLinkRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkBooking(id, user.userId(), user.username(), request));
}
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.sailingcalendar.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*;
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Validated
@RestController
@RequestMapping("/api/platform/v1/sailing-channels")
public class PlatformSailingChannelController {
@Resource private SailingCalendarService service;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
public ApiResponse<List<SailingChannelEntity>> list(@AuthenticationPrincipal AuthenticatedUser user, @Valid ChannelQuery query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.channels(query).stream().map(ChannelView::channel).toList()); }
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
public ApiResponse<SailingChannelEntity> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ChannelCreateRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.createChannel(user.userId(), user.username(), request)); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
public ApiResponse<SailingChannelEntity> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody ChannelUpdateRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.updateChannel(id, user.userId(), user.username(), request)); }
}
@@ -0,0 +1,74 @@
package com.baiyue.firstleg.sailingcalendar.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastCreateRequest;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastRevisionRequest;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastReceiptBindRequest;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastVoidRequest;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 平台运营维护船期日历使用的正式未收货量预报事实入口。 */
@Validated
@RestController
@Tag(name = "船期货量预报")
@RequestMapping("/api/platform/v1/sailing-forecasts")
public class PlatformSailingForecastController {
@Resource private SailingCalendarService service;
@PostMapping
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')")
@Operation(summary = "新增未收货量预报", description = "保存版本化预报事实并按仓库、渠道、港口和截止时间自动归班;无法解释时保留UNMATCHED。")
public ApiResponse<SailingInboundVolumeForecastEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ForecastCreateRequest request) {
FirstlegScopeGuard.platform(user, request.tenantId());
return ApiResponse.success(service.createForecast(user.userId(), request));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')")
@Operation(summary = "修订未收货量预报", description = "旧版本标记为SUPERSEDED,新版本重新归班;不覆盖历史版本。")
public ApiResponse<SailingInboundVolumeForecastEntity> revise(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id,
@Valid @RequestBody ForecastRevisionRequest request) {
FirstlegScopeGuard.platform(user, request.tenantId());
return ApiResponse.success(service.reviseForecast(user.userId(), id, request));
}
@PostMapping("/{id}/void")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')")
@Operation(summary = "作废未收货量预报", description = "作废有效预报并将其当前归班版本标记为SUPERSEDED。")
public ApiResponse<Void> voidForecast(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id,
@Valid @RequestBody ForecastVoidRequest request) {
FirstlegScopeGuard.platform(user, request.tenantId());
service.voidForecast(user.userId(), id, request);
return ApiResponse.success((Void) null);
}
@PostMapping("/{id}/bind-receipt")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')")
@Operation(summary = "将实收批次绑定到预报批次", description = "多批预报无法由实测体积唯一解释时,由运营显式选择预报批次;服务端保留原历史版本。")
public ApiResponse<Void> bindReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id,
@Valid @RequestBody ForecastReceiptBindRequest request) {
FirstlegScopeGuard.platform(user, request.tenantId());
service.bindReceiptToForecast(user.userId(), id, request);
return ApiResponse.success((Void) null);
}
}
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.sailingcalendar.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity;
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
@Validated
@RestController
@RequestMapping("/api/platform/v1/sailing-templates")
public class PlatformSailingTemplateController {
@Resource private SailingCalendarService service;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
public ApiResponse<List<TemplateView>> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid TemplateQuery query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.templateViews(query)); }
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
public ApiResponse<TemplateView> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody TemplateCreateRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.templateView(service.createTemplate(user.userId(), user.username(), request).getId())); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
public ApiResponse<TemplateView> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody TemplateUpdateRequest request) { FirstlegScopeGuard.platform(user); service.updateTemplate(id, user.userId(), user.username(), request); return ApiResponse.success(service.templateView(id)); }
@PostMapping("/{id}/generation-preview") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
public ApiResponse<GenerationPreviewResult> preview(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@Valid @RequestBody GenerationRange range) { FirstlegScopeGuard.platform(user); return ApiResponse.success(new GenerationPreviewResult(id, service.generationPreview(id, range.from(), range.to()))); }
@PostMapping("/{id}/generate") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
public ApiResponse<GenerationResult> generate(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@Valid @RequestBody GenerationRange range) { FirstlegScopeGuard.platform(user); List<SailingEntity> generated = service.generate(user.userId(), user.username(), id, range.from(), range.to()); List<GenerationPreview> records = service.generationPreview(id, range.from(), range.to()); return ApiResponse.success(new GenerationResult(id, records, generated.size(), Math.max(0, records.size() - generated.size()))); }
}
@@ -0,0 +1,284 @@
package com.baiyue.firstleg.sailingcalendar.dto;
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
public final class SailingCalendarDtos {
private SailingCalendarDtos() {}
public record ChannelQuery(@Size(max = 64) String keyword, String status) {}
public record ChannelData(@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotBlank String sourceType,
@Size(max = 64) String mappedChannelCode,
@Size(max = 32) String defaultDeparturePortCode,
@Size(max = 32) String defaultDestinationPortCode,
@Min(0) @Max(30) Integer defaultCutoffDays,
@Size(max = 1000) String remark) {}
/** Flat JSON contract; data() keeps the service boundary grouped by domain object. */
public record ChannelCreateRequest(@Min(0) Integer expectedVersion,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotBlank String sourceType,
@Size(max = 64) String mappedChannelCode,
@Size(max = 32) String defaultDeparturePortCode,
@Size(max = 32) String defaultDestinationPortCode,
@Min(0) @Max(30) Integer defaultCutoffDays,
@Size(max = 1000) String remark) {
public ChannelData data() { return new ChannelData(channelCode, channelName, sourceType,
mappedChannelCode, defaultDeparturePortCode, defaultDestinationPortCode, defaultCutoffDays, remark); }
}
public record ChannelUpdateRequest(@Min(0) Integer expectedVersion,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotBlank String sourceType,
@Size(max = 64) String mappedChannelCode,
@Size(max = 32) String defaultDeparturePortCode,
@Size(max = 32) String defaultDestinationPortCode,
@Min(0) @Max(30) Integer defaultCutoffDays,
@Size(max = 1000) String remark,
@NotBlank String status) {
public ChannelData data() { return new ChannelData(channelCode, channelName, sourceType,
mappedChannelCode, defaultDeparturePortCode, defaultDestinationPortCode, defaultCutoffDays, remark); }
}
public record ChannelView(SailingChannelEntity channel, boolean editable) {}
public record TemplateQuery(@Positive Long warehouseId, @Positive Long channelId,
String status, @Min(1) long page, @Min(1) @Max(200) long size) {
public TemplateQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record TemplateData(@Size(max = 128) String templateName,
@NotNull @Positive Long channelId,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
List<@NotNull @Min(1) @Max(7) Integer> weekdays,
@NotNull LocalDate effectiveFrom,
LocalDate effectiveTo,
@NotNull LocalTime departureTime,
@NotNull @Min(0) @Max(30) Integer cutoffDays,
@NotNull LocalTime cutoffLocalTime,
@Size(max = 64) String voyageRule,
@Size(max = 128) String vesselName,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@Size(max = 1000) String remark) {}
public record TemplateCreateRequest(@Min(0) Integer expectedVersion,
@Size(max = 128) String templateName,
@NotNull @Positive Long channelId,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
List<@NotNull @Min(1) @Max(7) Integer> weekdays,
@NotNull LocalDate effectiveFrom,
LocalDate effectiveTo,
@NotNull LocalTime departureTime,
@NotNull @Min(0) @Max(30) Integer cutoffDays,
@NotNull LocalTime cutoffLocalTime,
@Size(max = 64) String voyageRule,
@Size(max = 128) String vesselName,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@Size(max = 1000) String remark) {
public TemplateData data() { return new TemplateData(templateName, channelId, warehouseId, warehouseName,
weekdays, effectiveFrom, effectiveTo, departureTime, cutoffDays, cutoffLocalTime, voyageRule,
vesselName, departurePortCode, destinationPortCode, remark); }
}
public record TemplateUpdateRequest(@Min(0) Integer expectedVersion,
@Size(max = 128) String templateName,
@NotNull @Positive Long channelId,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
List<@NotNull @Min(1) @Max(7) Integer> weekdays,
@NotNull LocalDate effectiveFrom,
LocalDate effectiveTo,
@NotNull LocalTime departureTime,
@NotNull @Min(0) @Max(30) Integer cutoffDays,
@NotNull LocalTime cutoffLocalTime,
@Size(max = 64) String voyageRule,
@Size(max = 128) String vesselName,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@Size(max = 1000) String remark,
String status) {
public TemplateData data() { return new TemplateData(templateName, channelId, warehouseId, warehouseName,
weekdays, effectiveFrom, effectiveTo, departureTime, cutoffDays, cutoffLocalTime, voyageRule,
vesselName, departurePortCode, destinationPortCode, remark); }
}
public record TemplateView(Long id, String templateNo, String templateName, Long channelId, String channelCode,
String channelName, Long warehouseId, String warehouseName, List<Integer> weekdays,
LocalDate effectiveFrom, LocalDate effectiveTo, LocalTime departureTime,
Integer cutoffDays, LocalTime cutoffLocalTime, String voyageRule, String vesselName,
String departurePortCode, String destinationPortCode, String remark,
String status, Integer version) {}
public record SailingQuery(@NotBlank String month,
@Positive Long warehouseId, @Positive Long channelId,
@Size(max = 64) String channelCode,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@Size(max = 128) String keyword, String status) {}
public record SailingData(@Positive Long templateId, @Positive Long channelId,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
@Size(max = 64) String channelCode,
@Size(max = 128) String channelName,
@NotBlank @Size(max = 32) String departurePortCode,
@Size(max = 128) String departurePortName,
@NotBlank @Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName,
@Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo,
@NotNull LocalDateTime etdAt,
LocalDateTime etaAt,
@NotNull LocalDateTime cutoffAt,
@Size(max = 64) String sourceType,
@Size(max = 128) String remark,
BigDecimal plannedCapacityCbm) {}
public record SailingCreateRequest(@Min(0) Integer expectedVersion,
@Positive Long templateId,
@Positive Long channelId,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
@Size(max = 64) String channelCode,
@Size(max = 128) String channelName,
@NotBlank @Size(max = 32) String departurePortCode,
@Size(max = 128) String departurePortName,
@NotBlank @Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName,
@Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo,
@NotNull LocalDateTime etdAt,
LocalDateTime etaAt,
@NotNull LocalDateTime cutoffAt,
@Size(max = 64) String sourceType,
@Size(max = 128) String remark,
BigDecimal plannedCapacityCbm) {
public SailingData data() { return new SailingData(templateId, channelId, warehouseId, warehouseName,
channelCode, channelName, departurePortCode, departurePortName, destinationPortCode, destinationPortName,
vesselName, voyageNo, etdAt, etaAt, cutoffAt, sourceType, remark, plannedCapacityCbm); }
}
public record SailingUpdateRequest(@Min(0) Integer expectedVersion,
@Positive Long templateId,
@Positive Long channelId,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
@Size(max = 64) String channelCode,
@Size(max = 128) String channelName,
@NotBlank @Size(max = 32) String departurePortCode,
@Size(max = 128) String departurePortName,
@NotBlank @Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName,
@Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo,
@NotNull LocalDateTime etdAt,
LocalDateTime etaAt,
@NotNull LocalDateTime cutoffAt,
@Size(max = 64) String sourceType,
@Size(max = 128) String remark,
BigDecimal plannedCapacityCbm,
String status) {
public SailingData data() { return new SailingData(templateId, channelId, warehouseId, warehouseName,
channelCode, channelName, departurePortCode, departurePortName, destinationPortCode, destinationPortName,
vesselName, voyageNo, etdAt, etaAt, cutoffAt, sourceType, remark, plannedCapacityCbm); }
}
public record CalendarItem(Long id, String sailingNo, Long warehouseId, String warehouseName,
String channelCode, String channelName, String vesselName, String voyageNo,
String departurePortCode, String destinationPortCode, LocalDateTime etdAt,
LocalDateTime cutoffAt, String status, Integer version,
VolumeSummary volumeSummary, Long templateId, Long channelId,
LocalDateTime etaAt, String sourceType, BigDecimal plannedCapacityCbm) {}
public record VolumeSummary(BigDecimal actualReceivedCbm, BigDecimal catchableForecastCbm,
BigDecimal riskUnmatchedCbm, int bookedContainerCount,
int recommendedContainerCount, int pendingContainerCount,
int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm,
BigDecimal bookedPlanningCapacityCbm, LocalDateTime snapshotAt,
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {}
public record CalendarResult(String month, String timezone, List<CalendarItem> records,
Summary summary, FilterOptions filterOptions) {
public record Summary(BigDecimal actualReceivedCbm, BigDecimal catchableForecastCbm, BigDecimal riskUnmatchedCbm,
int bookedContainerCount, int recommendedContainerCount, int pendingContainerCount,
int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm, int sailingCount,
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {}
public record FilterOptions(List<Option> warehouses, List<Option> channels, List<Option> departurePorts, List<Option> destinationPorts) {}
public record Option(String value, String label) {}
}
public record SailingDetail(CalendarItem sailing, List<SailingHistoryView> histories) {}
public record SailingHistoryView(Long id, String action, String fromStatus, String toStatus,
String reason, String operatorName, LocalDateTime createdAt) {}
public record GenerationPreview(LocalDate date, LocalDateTime etdAt, LocalDateTime cutoffAt,
String voyageNo, boolean conflict, String conflictReason) {}
public record GenerationRange(@NotNull LocalDate from, @NotNull LocalDate to) {}
public record GenerationPreviewResult(Long templateId, List<GenerationPreview> records) {}
public record GenerationResult(Long templateId, List<GenerationPreview> records, int generatedCount, int skippedCount) {}
public record CancelRequest(Integer expectedVersion, @Size(max = 1000) String reason) {}
public record CopyRequest(@NotNull LocalDateTime etdAt, @NotNull LocalDateTime cutoffAt) {}
public record AssignmentView(String id, String receiptBatchId, String forecastId, String inboundCode,
LocalDateTime receivedAt, BigDecimal volumeCbm, String status,
String assignmentSource, String reason, Integer assignmentVersion) {}
public record VolumeDetail(VolumeSummary summary, List<AssignmentView> assignments) {}
public record BookingAdvice(BigDecimal actualReceivedCbm, BigDecimal catchableTotalCbm,
BigDecimal planningCapacityCbm, int bookedContainerCount,
int recommendedContainerCount, int pendingContainerCount,
List<BookingScenario> scenarios, LocalDateTime calculatedAt,
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {}
public record BookingScenario(String containerTypeCode, String containerTypeName,
BigDecimal capacityCbm, int recommendedContainerCount, int pendingContainerCount) {}
public record BookingLinkRequest(@NotNull @Positive Long oceanBookingId, Integer expectedVersion) {}
public record ExceptionReassignRequest(@NotNull @Positive Long assignmentId,
@NotNull @Positive Long targetSailingId,
@NotNull @Min(0) Integer expectedAssignmentVersion,
@NotNull @Min(0) Integer expectedTargetVersion,
@NotBlank @Size(max = 500) String reason) {}
public record ExceptionReassignResult(Long oldAssignmentId, Long newAssignmentId,
Long sourceSailingId, Long targetSailingId,
Integer assignmentVersion, String matchStatus,
BigDecimal volumeCbm, BigDecimal forecastVolumeCbm,
String reason) {}
public record ExceptionQuery(Long warehouseId, String channelCode, String status,
@Min(1) long page, @Min(1) @Max(200) long size) {
public ExceptionQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record ExceptionRecord(Long assignmentId, String inboundCode, Long warehouseId, String warehouseName,
String channelCode, String channelName, String sourceSailingNo,
String sourceVoyageNo, String status, BigDecimal volumeCbm,
LocalDateTime receivedAt, LocalDateTime expectedArrivalAt,
Integer waitingDays, String reason, Integer assignmentVersion) {}
public record ExceptionPage(List<ExceptionRecord> records, long page, long size, long total, long pages) {}
public record ForecastCreateRequest(@NotNull @Positive Long tenantId,
@NotNull @Positive Long inboundId,
@NotBlank @Size(max = 64) String forecastKey,
@NotNull @Positive Long receivingWarehouseId,
@Size(max = 64) String channelCode,
@Size(max = 128) String channelName,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@NotNull LocalDateTime expectedArrivalAt,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0.0001") BigDecimal forecastVolumeCbm,
@NotBlank @Size(max = 32) String sourceType) {}
public record ForecastRevisionRequest(@NotNull @Positive Long tenantId,
@NotNull @Min(1) Integer expectedFactVersion,
@Size(max = 64) String channelCode,
@Size(max = 128) String channelName,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@NotNull LocalDateTime expectedArrivalAt,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0.0001") BigDecimal forecastVolumeCbm,
@NotBlank @Size(max = 32) String sourceType) {}
public record ForecastVoidRequest(@NotNull @Positive Long tenantId, @NotBlank @Size(max = 500) String reason) {}
public record ForecastReceiptBindRequest(@NotNull @Positive Long tenantId,
@NotNull @Positive Long receiptBatchId,
@NotNull @Min(1) Integer expectedAssignmentVersion,
@NotBlank @Size(max = 500) String reason) {}
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.sailingcalendar.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_sailing_channel")
public class SailingChannelEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String channelCode;
private String channelName;
private String sourceType;
private String mappedChannelCode;
private String defaultDeparturePortCode;
private String defaultDestinationPortCode;
private Integer defaultCutoffDays;
private String status;
private String remark;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
private LocalDateTime updatedAt;
private Integer version;
}
@@ -0,0 +1,43 @@
package com.baiyue.firstleg.sailingcalendar.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_sailing")
public class SailingEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String sailingNo;
private Long templateId;
private Long channelId;
private String channelCodeSnapshot;
private String channelNameSnapshot;
private Long warehouseId;
private String warehouseNameSnapshot;
private String departurePortCode;
private String destinationPortCode;
private String vesselName;
private String voyageNo;
private LocalDateTime etdAt;
private LocalDateTime warehouseCutoffAt;
private LocalDateTime etaAt;
private String status;
private String sourceType;
private String generatedKey;
private BigDecimal plannedCapacityCbm;
private String remark;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
private LocalDateTime updatedAt;
private Integer version;
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.sailingcalendar.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 船期实例的追加式变更历史。 */
@Getter
@Setter
@TableName("fl_sailing_history")
public class SailingHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long sailingId;
private String action;
private String fromStatus;
private String toStatus;
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,45 @@
package com.baiyue.firstleg.sailingcalendar.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_sailing_template")
public class SailingTemplateEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String templateNo;
private String templateName;
private Long channelId;
private String channelCodeSnapshot;
private String channelNameSnapshot;
private Long warehouseId;
private String warehouseNameSnapshot;
private String departurePortCode;
private String destinationPortCode;
private Integer weekday;
private Integer weekdayMask;
private LocalTime etdLocalTime;
private Integer cutoffDays;
private LocalTime cutoffLocalTime;
private LocalDate validFrom;
private LocalDate validTo;
private String voyageRule;
private String vesselName;
private String status;
private String remark;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
private LocalDateTime updatedAt;
private Integer version;
}
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.sailingcalendar.fact.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 自动归班事实;重算只追加新版本并将旧版本标记为SUPERSEDED。 */
@Getter
@Setter
@TableName("fl_sailing_assignment")
public class SailingAssignmentEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private Long sailingId;
private Long originSailingId;
private Long inboundId;
private String inboundCode;
private Long receiptBatchId;
private Long forecastId;
private String assignmentSource;
private Integer assignmentVersion;
private String matchStatus;
private BigDecimal volumeCbm;
private BigDecimal forecastVolumeCbm;
private String routeSource;
private String volumeSource;
private String ruleVersion;
private LocalDateTime matchedAt;
private LocalDateTime supersededAt;
private LocalDateTime lockedAt;
private String lockReason;
private Long operatorId;
private String reason;
private LocalDateTime createdAt;
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.sailingcalendar.fact.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 计划船期与RELEASED订舱的最小关联;不创建真实柜次。 */
@Getter
@Setter
@TableName("fl_sailing_booking_link")
public class SailingBookingLinkEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private Long sailingId;
private Long oceanBookingId;
private String linkStatus;
private Integer activeBookingGuard;
private String bookingStatusSnapshot;
private Long warehouseId;
private String channelCode;
private String departurePortCode;
private String destinationPortCode;
private LocalDateTime linkedAt;
private LocalDateTime revokedAt;
private Long operatorId;
private String operatorName;
private String reason;
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.sailingcalendar.fact.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 未收货量预报事实;申报体积不是本实体的回退来源。 */
@Getter
@Setter
@TableName("fl_sailing_inbound_volume_forecast")
public class SailingInboundVolumeForecastEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private String forecastKey;
private Integer factVersion;
private Long inboundId;
private Long receivingWarehouseId;
private String channelCode;
private String channelName;
private String departurePortCode;
private String destinationPortCode;
private LocalDateTime expectedArrivalAt;
private BigDecimal forecastVolumeCbm;
private String sourceType;
private String factStatus;
private LocalDateTime effectiveAt;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.sailingcalendar.fact.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 船期日历消费的稳定收货批次实测事实;不直接替代入库单聚合。 */
@Getter
@Setter
@TableName("fl_sailing_receipt_batch_fact")
public class SailingReceiptBatchFactEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private String receiptBatchKey;
private Integer factVersion;
private Long inboundId;
private Long measurementId;
private Long receivingWarehouseId;
private String channelCode;
private String channelName;
private String departurePortCode;
private String destinationPortCode;
private LocalDateTime receivedAt;
private BigDecimal actualVolumeCbm;
private String factStatus;
private Long reversalOfFactId;
private String sourceType;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.sailingcalendar.fact.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity;
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 SailingAssignmentMapper extends BaseMapper<SailingAssignmentEntity> {
@Select("SELECT * FROM fl_sailing_assignment WHERE id=#{id} FOR UPDATE")
SailingAssignmentEntity selectForUpdate(@Param("id") long id);
@Update("UPDATE fl_sailing_assignment SET match_status='SUPERSEDED',superseded_at=#{at},operator_id=#{operatorId},reason=#{reason} "
+ "WHERE id=#{id} AND assignment_version=#{expectedVersion} AND match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED')")
int supersedeCurrent(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("reason") String reason,
@Param("at") java.time.LocalDateTime at);
@Select("SELECT * FROM fl_sailing_assignment WHERE match_status IN ('UNMATCHED','LATE') ORDER BY matched_at ASC,id ASC")
List<SailingAssignmentEntity> selectExceptions();
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.sailingcalendar.fact.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SailingBookingLinkMapper extends BaseMapper<SailingBookingLinkEntity> {}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.sailingcalendar.fact.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SailingInboundVolumeForecastMapper extends BaseMapper<SailingInboundVolumeForecastEntity> {}
@@ -0,0 +1,18 @@
package com.baiyue.firstleg.sailingcalendar.fact.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface SailingReceiptBatchFactMapper extends BaseMapper<SailingReceiptBatchFactEntity> {
@Select("SELECT * FROM fl_sailing_receipt_batch_fact WHERE tenant_id=#{tenantId} AND receipt_batch_key=#{batchKey} AND fact_status='APPROVED' ORDER BY fact_version DESC, id DESC LIMIT 1")
SailingReceiptBatchFactEntity selectCurrent(@Param("tenantId") long tenantId, @Param("batchKey") String batchKey);
@Select("SELECT * FROM fl_sailing_receipt_batch_fact WHERE tenant_id=#{tenantId} AND receiving_warehouse_id=#{warehouseId} AND fact_status='APPROVED' AND received_at >= #{from} AND received_at < #{to} ORDER BY received_at, id")
IPage<SailingReceiptBatchFactEntity> selectReceived(Page<SailingReceiptBatchFactEntity> page, @Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId, @Param("from") java.time.LocalDateTime from, @Param("to") java.time.LocalDateTime to);
}
@@ -0,0 +1,195 @@
package com.baiyue.firstleg.sailingcalendar.fact.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
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.mapper.SailingAssignmentMapper;
import com.baiyue.firstleg.sailingcalendar.fact.mapper.SailingBookingLinkMapper;
import com.baiyue.firstleg.sailingcalendar.fact.mapper.SailingInboundVolumeForecastMapper;
import com.baiyue.firstleg.sailingcalendar.fact.mapper.SailingReceiptBatchFactMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.time.LocalDateTime;
/**
* 船期日历事实读写端口。
*
* 这里故意不从 fl_inbound_order 分页拼装数据,也不把订舱柜次关联当作已订柜数;
* 上层服务应在事务内写入新的事实版本,再由日历域按有效状态读取。
*/
@Repository
public class SailingFactsRepository {
@Resource private SailingReceiptBatchFactMapper receiptFacts;
@Resource private SailingInboundVolumeForecastMapper forecasts;
@Resource private SailingAssignmentMapper assignments;
@Resource private SailingBookingLinkMapper bookingLinks;
public void insertReceiptFact(SailingReceiptBatchFactEntity value) {
receiptFacts.insert(value);
}
public SailingReceiptBatchFactEntity currentReceiptFact(long tenantId, String batchKey) {
return receiptFacts.selectCurrent(tenantId, batchKey);
}
/** 查询船期候选范围内的有效实收事实;平台汇总故意不带客户分页条件。 */
public List<SailingReceiptBatchFactEntity> approvedReceiptFacts(long warehouseId, LocalDateTime from, LocalDateTime to) {
return receiptFacts.selectList(new LambdaQueryWrapper<SailingReceiptBatchFactEntity>()
.eq(SailingReceiptBatchFactEntity::getReceivingWarehouseId, warehouseId)
.eq(SailingReceiptBatchFactEntity::getFactStatus, "APPROVED")
.ge(SailingReceiptBatchFactEntity::getReceivedAt, from)
.lt(SailingReceiptBatchFactEntity::getReceivedAt, to)
.orderByAsc(SailingReceiptBatchFactEntity::getReceivedAt)
.orderByAsc(SailingReceiptBatchFactEntity::getId));
}
public void insertForecast(SailingInboundVolumeForecastEntity value) {
forecasts.insert(value);
}
public SailingInboundVolumeForecastEntity forecast(long id) { return forecasts.selectById(id); }
public int consumeForecast(long tenantId, long forecastId, LocalDateTime at) {
return forecasts.update(null, new LambdaUpdateWrapper<SailingInboundVolumeForecastEntity>()
.eq(SailingInboundVolumeForecastEntity::getId, forecastId)
.eq(SailingInboundVolumeForecastEntity::getTenantId, tenantId)
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
.set(SailingInboundVolumeForecastEntity::getFactStatus, "CONSUMED")
.set(SailingInboundVolumeForecastEntity::getUpdatedAt, at));
}
public int supersedeForecast(long tenantId, long forecastId, LocalDateTime at) {
return forecasts.update(null, new LambdaUpdateWrapper<SailingInboundVolumeForecastEntity>()
.eq(SailingInboundVolumeForecastEntity::getId, forecastId)
.eq(SailingInboundVolumeForecastEntity::getTenantId, tenantId)
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
.set(SailingInboundVolumeForecastEntity::getFactStatus, "SUPERSEDED")
.set(SailingInboundVolumeForecastEntity::getUpdatedAt, at));
}
public int voidForecast(long tenantId, long forecastId, LocalDateTime at) {
return forecasts.update(null, new LambdaUpdateWrapper<SailingInboundVolumeForecastEntity>()
.eq(SailingInboundVolumeForecastEntity::getId, forecastId)
.eq(SailingInboundVolumeForecastEntity::getTenantId, tenantId)
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
.set(SailingInboundVolumeForecastEntity::getFactStatus, "VOIDED")
.set(SailingInboundVolumeForecastEntity::getUpdatedAt, at));
}
public SailingReceiptBatchFactEntity receiptFact(long id) { return receiptFacts.selectById(id); }
public List<SailingInboundVolumeForecastEntity> currentForecasts(long tenantId, long inboundId) {
return forecasts.selectList(new LambdaQueryWrapper<SailingInboundVolumeForecastEntity>()
.eq(SailingInboundVolumeForecastEntity::getTenantId, tenantId)
.eq(SailingInboundVolumeForecastEntity::getInboundId, inboundId)
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
.orderByDesc(SailingInboundVolumeForecastEntity::getFactVersion)
.orderByDesc(SailingInboundVolumeForecastEntity::getId));
}
/** 返回某个预计批次当前仍有效的归班版本,供实收替换时保留原船期。 */
public SailingAssignmentEntity currentAssignmentForForecast(long tenantId, long forecastId) {
return assignments.selectOne(new LambdaQueryWrapper<SailingAssignmentEntity>()
.eq(SailingAssignmentEntity::getTenantId, tenantId)
.eq(SailingAssignmentEntity::getForecastId, forecastId)
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
.orderByDesc(SailingAssignmentEntity::getAssignmentVersion)
.orderByDesc(SailingAssignmentEntity::getId)
.last("LIMIT 1"));
}
public SailingAssignmentEntity currentAssignmentForReceipt(long tenantId, long receiptBatchId) {
return assignments.selectOne(new LambdaQueryWrapper<SailingAssignmentEntity>()
.eq(SailingAssignmentEntity::getTenantId, tenantId)
.eq(SailingAssignmentEntity::getReceiptBatchId, receiptBatchId)
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
.orderByDesc(SailingAssignmentEntity::getAssignmentVersion)
.orderByDesc(SailingAssignmentEntity::getId)
.last("LIMIT 1"));
}
/** 查询仍有效且有ETA的预计事实;实收事实应由上层扣除,避免重复计量。 */
public List<SailingInboundVolumeForecastEntity> validForecasts(long warehouseId, LocalDateTime from, LocalDateTime to) {
return forecasts.selectList(new LambdaQueryWrapper<SailingInboundVolumeForecastEntity>()
.eq(SailingInboundVolumeForecastEntity::getReceivingWarehouseId, warehouseId)
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
.ge(SailingInboundVolumeForecastEntity::getExpectedArrivalAt, from)
.lt(SailingInboundVolumeForecastEntity::getExpectedArrivalAt, to)
.orderByAsc(SailingInboundVolumeForecastEntity::getExpectedArrivalAt)
.orderByAsc(SailingInboundVolumeForecastEntity::getId));
}
public void insertAssignment(SailingAssignmentEntity value) {
assignments.insert(value);
}
public SailingAssignmentEntity assignmentForUpdate(long id) { return assignments.selectForUpdate(id); }
public int supersedeAssignment(long id, int expectedVersion, long operatorId, String reason, LocalDateTime at) {
return assignments.supersedeCurrent(id, expectedVersion, operatorId, reason, at);
}
public List<SailingAssignmentEntity> assignments(long sailingId) {
return assignments.selectList(new LambdaQueryWrapper<SailingAssignmentEntity>()
.eq(SailingAssignmentEntity::getSailingId, sailingId)
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
.orderByAsc(SailingAssignmentEntity::getMatchedAt)
.orderByAsc(SailingAssignmentEntity::getId));
}
public List<SailingAssignmentEntity> exceptionAssignments() { return assignments.selectExceptions(); }
/** 旧版本只允许转为SUPERSEDED,不物理删除或覆盖。 */
public int supersedeReceiptAssignments(long tenantId, long receiptBatchId, java.time.LocalDateTime at) {
return assignments.update(null, new LambdaUpdateWrapper<SailingAssignmentEntity>()
.eq(SailingAssignmentEntity::getTenantId, tenantId)
.eq(SailingAssignmentEntity::getReceiptBatchId, receiptBatchId)
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
.set(SailingAssignmentEntity::getMatchStatus, "SUPERSEDED")
.set(SailingAssignmentEntity::getSupersededAt, at));
}
public int supersedeForecastAssignments(long tenantId, long forecastId, java.time.LocalDateTime at) {
return assignments.update(null, new LambdaUpdateWrapper<SailingAssignmentEntity>()
.eq(SailingAssignmentEntity::getTenantId, tenantId)
.eq(SailingAssignmentEntity::getForecastId, forecastId)
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
.set(SailingAssignmentEntity::getMatchStatus, "SUPERSEDED")
.set(SailingAssignmentEntity::getSupersededAt, at));
}
public void insertBookingLink(SailingBookingLinkEntity value) {
bookingLinks.insert(value);
}
public SailingBookingLinkEntity activeBookingLink(long oceanBookingId) {
return bookingLinks.selectOne(new LambdaQueryWrapper<SailingBookingLinkEntity>()
.eq(SailingBookingLinkEntity::getOceanBookingId, oceanBookingId)
.eq(SailingBookingLinkEntity::getLinkStatus, "ACTIVE")
.last("LIMIT 1"));
}
public List<SailingBookingLinkEntity> activeBookingLinks(long sailingId) {
return bookingLinks.selectList(new LambdaQueryWrapper<SailingBookingLinkEntity>()
.eq(SailingBookingLinkEntity::getSailingId, sailingId)
.eq(SailingBookingLinkEntity::getLinkStatus, "ACTIVE")
.orderByAsc(SailingBookingLinkEntity::getLinkedAt)
.orderByAsc(SailingBookingLinkEntity::getId));
}
public int revokeBookingLink(long id, long operatorId, String reason, java.time.LocalDateTime at) {
return bookingLinks.update(null, new LambdaUpdateWrapper<SailingBookingLinkEntity>()
.eq(SailingBookingLinkEntity::getId, id)
.eq(SailingBookingLinkEntity::getLinkStatus, "ACTIVE")
.set(SailingBookingLinkEntity::getLinkStatus, "REVOKED")
.set(SailingBookingLinkEntity::getActiveBookingGuard, null)
.set(SailingBookingLinkEntity::getRevokedAt, at)
.set(SailingBookingLinkEntity::getOperatorId, operatorId)
.set(SailingBookingLinkEntity::getReason, reason));
}
}
@@ -0,0 +1,83 @@
package com.baiyue.firstleg.sailingcalendar.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
/**
* 船期日历的只读展示投影。
*
* 这里直接读取现有入库审核结果,不写入船期事实表,也不触发归班、改绑或智能排柜。
* 主子入库单只统计未拆分主单或仍可用的余量子单,避免把父单和子单重复计入。
*/
@Mapper
public interface SailingCalendarDisplayMapper {
@Select("""
SELECT
COALESCE(SUM(r.quantity), 0) AS containerCount,
COALESCE(SUM(COALESCE(r.capacity_cbm, 0) * r.quantity), 0) AS capacityCbm
FROM fl_ocean_booking b
JOIN fl_ocean_booking_container_requirement r ON r.ocean_booking_id = b.id
WHERE b.status = 'RELEASED'
AND (
EXISTS (
SELECT 1 FROM fl_sailing_booking_link link
WHERE link.sailing_id = #{sailingId}
AND link.ocean_booking_id = b.id
AND link.link_status = 'ACTIVE'
)
OR (
b.warehouse_id = #{warehouseId}
AND b.channel_code = #{channelCode}
AND b.departure_port_code = #{departurePortCode}
AND b.destination_port_code = #{destinationPortCode}
AND (#{vesselName} IS NOT NULL AND #{voyageNo} IS NOT NULL
AND b.vessel_name = #{vesselName}
AND b.voyage_no = #{voyageNo}
AND (b.estimated_sailing_at IS NULL OR DATE(b.estimated_sailing_at) = DATE(#{etdAt})))
)
)
""")
BookingSummary bookingSummary(@Param("sailingId") long sailingId,
@Param("warehouseId") long warehouseId,
@Param("channelCode") String channelCode,
@Param("departurePortCode") String departurePortCode,
@Param("destinationPortCode") String destinationPortCode,
@Param("vesselName") String vesselName,
@Param("voyageNo") String voyageNo,
@Param("etdAt") java.time.LocalDateTime etdAt);
@Select("""
SELECT
COALESCE(SUM(CASE WHEN receipt_at <= #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS catchableCbm,
COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS lateCbm,
COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN 1 ELSE 0 END), 0) AS lateCount
FROM (
SELECT
COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) AS receipt_at,
COALESCE(o.actual_volume_cbm, 0) AS volume_cbm
FROM fl_inbound_order o
WHERE o.deleted = 0
AND o.measure_review_status = 'APPROVED'
AND o.warehouse_id = #{warehouseId}
AND o.channel_code = #{channelCode}
AND (
(o.inbound_role = 'MASTER' AND COALESCE(o.split_status, 'NOT_SPLIT') = 'NOT_SPLIT')
OR (o.inbound_role = 'CHILD' AND o.split_purpose = 'REMAINDER' AND o.split_status = 'NOT_SPLIT')
)
AND o.actual_volume_cbm IS NOT NULL
AND (#{fromAt} IS NULL OR COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) > #{fromAt})
AND (#{toAt} IS NULL OR COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) <= #{toAt})
) received
""")
ReceiptSummary receiptSummary(@Param("warehouseId") long warehouseId,
@Param("channelCode") String channelCode,
@Param("cutoffAt") java.time.LocalDateTime cutoffAt,
@Param("fromAt") java.time.LocalDateTime fromAt,
@Param("toAt") java.time.LocalDateTime toAt);
record ReceiptSummary(BigDecimal catchableCbm, BigDecimal lateCbm, Integer lateCount) {}
record BookingSummary(Integer containerCount, BigDecimal capacityCbm) {}
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.sailingcalendar.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ChannelQuery;
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
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 SailingChannelMapper extends BaseMapper<SailingChannelEntity> {
@Select("""
<script>SELECT * FROM fl_sailing_channel
WHERE (#{query.keyword} IS NULL OR channel_code LIKE CONCAT('%',#{query.keyword},'%') OR channel_name LIKE CONCAT('%',#{query.keyword},'%'))
AND (#{query.status} IS NULL OR status=#{query.status})
ORDER BY status ASC,channel_name ASC,id ASC</script>
""")
List<SailingChannelEntity> 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);
}
@@ -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<SailingHistoryEntity> {}
@@ -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<SailingEntity> {
@Select("""
<script>SELECT * FROM fl_sailing
WHERE etd_at &gt;= #{startAt} AND etd_at &lt; #{endAt}
AND (#{query.warehouseId} IS NULL OR warehouse_id=#{query.warehouseId})
AND (#{query.channelId} IS NULL OR channel_id=#{query.channelId})
AND (#{query.channelCode} IS NULL OR channel_code_snapshot=#{query.channelCode})
AND (#{query.departurePortCode} IS NULL OR departure_port_code=#{query.departurePortCode})
AND (#{query.destinationPortCode} IS NULL OR destination_port_code=#{query.destinationPortCode})
AND (#{query.status} IS NULL OR status=#{query.status})
AND (#{query.keyword} IS NULL OR sailing_no LIKE CONCAT('%',#{query.keyword},'%')
OR vessel_name LIKE CONCAT('%',#{query.keyword},'%') OR voyage_no LIKE CONCAT('%',#{query.keyword},'%'))
ORDER BY etd_at ASC,sailing_no ASC,id ASC</script>
""")
List<SailingEntity> 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<SailingEntity> 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<SailingEntity> 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);
}
@@ -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<SailingTemplateEntity> {
@Select("""
<script>SELECT * FROM fl_sailing_template
WHERE (#{query.warehouseId} IS NULL OR warehouse_id=#{query.warehouseId})
AND (#{query.channelId} IS NULL OR channel_id=#{query.channelId})
AND (#{query.status} IS NULL OR status=#{query.status})
ORDER BY updated_at DESC,id DESC</script>
""")
Page<SailingTemplateEntity> selectPage(Page<SailingTemplateEntity> 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);
}
@@ -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<SailingChannelEntity> 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<SailingChannelEntity>()
.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<SailingTemplateEntity> templates(TemplateQuery query) {
Page<SailingTemplateEntity> 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<SailingEntity> calendar(SailingQuery query, LocalDateTime start, LocalDateTime end) {
return sailings.selectCalendar(query, start, end);
}
public List<SailingEntity> futureCandidates(long warehouseId, String channelCode, String departurePortCode, String destinationPortCode, LocalDateTime from) {
return sailings.selectFutureCandidates(warehouseId, channelCode, departurePortCode, destinationPortCode, from);
}
public List<SailingEntity> 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<SailingEntity>()
.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<SailingEntity>().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<SailingHistoryEntity> histories(long sailingId) {
return histories.selectList(new LambdaQueryWrapper<SailingHistoryEntity>()
.eq(SailingHistoryEntity::getSailingId, sailingId)
.orderByDesc(SailingHistoryEntity::getCreatedAt)
.orderByDesc(SailingHistoryEntity::getId));
}
public List<SailingAssignmentEntity> assignments(long sailingId) { return facts.assignments(sailingId); }
public void insertAssignment(SailingAssignmentEntity value) { facts.insertAssignment(value); }
public List<SailingAssignmentEntity> 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<SailingBookingLinkEntity> 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<OceanBookingContainerRequirementEntity> 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());
}
}
@@ -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<ChannelView> 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<SailingTemplateEntity> templates(TemplateQuery query) { return repository.templates(query); }
public List<TemplateView> 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<SailingEntity> values = repository.calendar(query, first.atStartOfDay(), next.atStartOfDay());
List<CalendarItem> 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<SailingHistoryView> 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<ExceptionRecord> 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<SailingEntity> 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<SailingEntity> 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<AssignmentView> 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<BookingScenario> 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<String, Object> 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> generationPreview(long templateId, LocalDate from, LocalDate to) {
SailingTemplateEntity template = requiredTemplate(templateId); if (!from.isBefore(to.plusDays(1))) throw bad("日期范围无效");
java.util.ArrayList<GenerationPreview> 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<SailingEntity> 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<SailingEntity> 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<Integer> 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); }
}
@@ -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<ContainerFeeMember> regionalMembers = members.stream()
.filter(member -> member.current().getCarrierRateId() != null)
.filter(member -> priceModel(member.current()) == PriceModel.REGION_WEIGHT_TIER)
.toList();
List<ContainerFeeMember> unlockedRegionalMembers = regionalMembers.stream()
@@ -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<String> MEASUREMENT_DRAFT_EDITABLE_STATUSES = Set.of(
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
"PREPARING", "WAIT_MEASURE", "MEASURING", "PROCESSING", "MEASURE_REJECTED");
private static final Set<String> 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<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inbound.getId());
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationSlots =
palletAllocations.list(tenantId, inbound.getId());
loadAllocationSlotsForDetail(tenantId, inbound.getId());
Map<Long, com.baiyue.firstleg.entity.InboundPalletAllocationEntity> 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<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> 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(

Some files were not shown because too many files have changed in this diff Show More